mirror of
https://github.com/DSpace/dspace-angular.git
synced 2025-10-07 18:14:17 +00:00
Merge pull request #362 from atmire/Edit-item-metadata
Administrative Item Edit
This commit is contained in:
@@ -79,5 +79,10 @@ module.exports = {
|
||||
code: 'nl',
|
||||
label: 'Nederlands',
|
||||
active: false,
|
||||
}]
|
||||
}],
|
||||
item: {
|
||||
edit: {
|
||||
undoTimeout: 10000 // 10 seconds
|
||||
}
|
||||
}
|
||||
};
|
||||
|
@@ -123,6 +123,7 @@
|
||||
"status": {
|
||||
"head": "Item Status",
|
||||
"description": "Welcome to the item management page. From here you can withdraw, reinstate, move or delete the item. You may also update or add new metadata / bitstreams on the other tabs.",
|
||||
"title": "Item Edit - Status",
|
||||
"labels": {
|
||||
"id": "Item Internal ID",
|
||||
"handle": "Handle",
|
||||
@@ -165,16 +166,20 @@
|
||||
}
|
||||
},
|
||||
"bitstreams": {
|
||||
"head": "Item Bitstreams"
|
||||
"head": "Item Bitstreams",
|
||||
"title": "Item Edit - Bitstreams"
|
||||
},
|
||||
"metadata": {
|
||||
"head": "Item Metadata"
|
||||
"head": "Item Metadata",
|
||||
"title": "Item Edit - Metadata"
|
||||
},
|
||||
"view": {
|
||||
"head": "View Item"
|
||||
"head": "View Item",
|
||||
"title": "Item Edit - View"
|
||||
},
|
||||
"curate": {
|
||||
"head": "Curate"
|
||||
"head": "Curate",
|
||||
"title": "Item Edit - Curate"
|
||||
}
|
||||
},
|
||||
"modify.overview": {
|
||||
@@ -188,7 +193,7 @@
|
||||
"confirm": "Withdraw",
|
||||
"cancel": "Cancel",
|
||||
"success": "The item was withdrawn successfully",
|
||||
"error": "An error occured while withdrawing the item"
|
||||
"error": "An error occurred while withdrawing the item"
|
||||
},
|
||||
"reinstate": {
|
||||
"header": "Reinstate item: {{ id }}",
|
||||
@@ -196,7 +201,7 @@
|
||||
"confirm": "Reinstate",
|
||||
"cancel": "Cancel",
|
||||
"success": "The item was reinstated successfully",
|
||||
"error": "An error occured while reinstating the item"
|
||||
"error": "An error occurred while reinstating the item"
|
||||
},
|
||||
"private": {
|
||||
"header": "Make item private: {{ id }}",
|
||||
@@ -204,7 +209,7 @@
|
||||
"confirm": "Make it Private",
|
||||
"cancel": "Cancel",
|
||||
"success": "The item is now private",
|
||||
"error": "An error occured while making the item private"
|
||||
"error": "An error occurred while making the item private"
|
||||
},
|
||||
"public": {
|
||||
"header": "Make item public: {{ id }}",
|
||||
@@ -212,7 +217,7 @@
|
||||
"confirm": "Make it Public",
|
||||
"cancel": "Cancel",
|
||||
"success": "The item is now public",
|
||||
"error": "An error occured while making the item public"
|
||||
"error": "An error occurred while making the item public"
|
||||
},
|
||||
"delete": {
|
||||
"header": "Delete item: {{ id }}",
|
||||
@@ -220,7 +225,48 @@
|
||||
"confirm": "Delete",
|
||||
"cancel": "Cancel",
|
||||
"success": "The item has been deleted",
|
||||
"error": "An error occured while deleting the item"
|
||||
"error": "An error occurred while deleting the item"
|
||||
},
|
||||
"metadata": {
|
||||
"add-button": "Add",
|
||||
"discard-button": "Discard",
|
||||
"reinstate-button": "Undo",
|
||||
"save-button": "Save",
|
||||
"headers": {
|
||||
"field": "Field",
|
||||
"value": "Value",
|
||||
"language": "Lang",
|
||||
"edit": "Edit"
|
||||
},
|
||||
"edit": {
|
||||
"buttons": {
|
||||
"edit": "Edit",
|
||||
"unedit": "Stop editing",
|
||||
"remove": "Remove",
|
||||
"undo": "Undo changes"
|
||||
}
|
||||
},
|
||||
"metadatafield": {
|
||||
"invalid": "Please choose a valid metadata field"
|
||||
},
|
||||
"notifications": {
|
||||
"outdated": {
|
||||
"title": "Changed outdated",
|
||||
"content": "The item you're currently working on has been changed by another user. Your current changes are discarded to prevent conflicts"
|
||||
},
|
||||
"discarded": {
|
||||
"title": "Changed discarded",
|
||||
"content": "Your changes were discarded. To reinstate your changes click the 'Undo' button"
|
||||
},
|
||||
"invalid": {
|
||||
"title": "Metadata invalid",
|
||||
"content": "Your changes were not saved. Please make sure all fields are valid before you save."
|
||||
},
|
||||
"saved": {
|
||||
"title": "Metadata saved",
|
||||
"content": "Your changes to this item's metadata were saved."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@@ -20,7 +20,8 @@ describe('MetadataSchemaFormComponent', () => {
|
||||
/* tslint:disable:no-empty */
|
||||
const registryServiceStub = {
|
||||
getActiveMetadataSchema: () => observableOf(undefined),
|
||||
createOrUpdateMetadataSchema: (schema: MetadataSchema) => observableOf(schema)
|
||||
createOrUpdateMetadataSchema: (schema: MetadataSchema) => observableOf(schema),
|
||||
cancelEditMetadataSchema: () => {}
|
||||
};
|
||||
const formBuilderServiceStub = {
|
||||
createFormGroup: () => {
|
||||
|
@@ -27,7 +27,8 @@ describe('MetadataFieldFormComponent', () => {
|
||||
/* tslint:disable:no-empty */
|
||||
const registryServiceStub = {
|
||||
getActiveMetadataField: () => observableOf(undefined),
|
||||
createOrUpdateMetadataField: (field: MetadataField) => observableOf(field)
|
||||
createOrUpdateMetadataField: (field: MetadataField) => observableOf(field),
|
||||
cancelEditMetadataSchema: () => {},
|
||||
};
|
||||
const formBuilderServiceStub = {
|
||||
createFormGroup: () => {
|
||||
|
@@ -3,7 +3,6 @@ import { CommunityDataService } from '../../core/data/community-data.service';
|
||||
import { RouteService } from '../../shared/services/route.service';
|
||||
import { Router } from '@angular/router';
|
||||
import { CreateComColPageComponent } from '../../shared/comcol-forms/create-comcol-page/create-comcol-page.component';
|
||||
import { NormalizedCollection } from '../../core/cache/models/normalized-collection.model';
|
||||
import { Collection } from '../../core/shared/collection.model';
|
||||
import { CollectionDataService } from '../../core/data/collection-data.service';
|
||||
|
||||
@@ -15,7 +14,7 @@ import { CollectionDataService } from '../../core/data/collection-data.service';
|
||||
styleUrls: ['./create-collection-page.component.scss'],
|
||||
templateUrl: './create-collection-page.component.html'
|
||||
})
|
||||
export class CreateCollectionPageComponent extends CreateComColPageComponent<Collection, NormalizedCollection> {
|
||||
export class CreateCollectionPageComponent extends CreateComColPageComponent<Collection> {
|
||||
protected frontendURL = '/collections/';
|
||||
|
||||
public constructor(
|
||||
|
@@ -1,12 +1,8 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { Community } from '../../core/shared/community.model';
|
||||
import { CommunityDataService } from '../../core/data/community-data.service';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { NormalizedCommunity } from '../../core/cache/models/normalized-community.model';
|
||||
import { DeleteComColPageComponent } from '../../shared/comcol-forms/delete-comcol-page/delete-comcol-page.component';
|
||||
import { NotificationsService } from '../../shared/notifications/notifications.service';
|
||||
import { CollectionDataService } from '../../core/data/collection-data.service';
|
||||
import { NormalizedCollection } from '../../core/cache/models/normalized-collection.model';
|
||||
import { Collection } from '../../core/shared/collection.model';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
|
||||
@@ -18,7 +14,7 @@ import { TranslateService } from '@ngx-translate/core';
|
||||
styleUrls: ['./delete-collection-page.component.scss'],
|
||||
templateUrl: './delete-collection-page.component.html'
|
||||
})
|
||||
export class DeleteCollectionPageComponent extends DeleteComColPageComponent<Collection, NormalizedCollection> {
|
||||
export class DeleteCollectionPageComponent extends DeleteComColPageComponent<Collection> {
|
||||
protected frontendURL = '/collections/';
|
||||
|
||||
public constructor(
|
||||
|
@@ -13,7 +13,7 @@ import { CollectionDataService } from '../../core/data/collection-data.service';
|
||||
styleUrls: ['./edit-collection-page.component.scss'],
|
||||
templateUrl: './edit-collection-page.component.html'
|
||||
})
|
||||
export class EditCollectionPageComponent extends EditComColPageComponent<Collection, NormalizedCollection> {
|
||||
export class EditCollectionPageComponent extends EditComColPageComponent<Collection> {
|
||||
protected frontendURL = '/collections/';
|
||||
|
||||
public constructor(
|
||||
|
@@ -4,7 +4,6 @@ import { CommunityDataService } from '../../core/data/community-data.service';
|
||||
import { RouteService } from '../../shared/services/route.service';
|
||||
import { Router } from '@angular/router';
|
||||
import { CreateComColPageComponent } from '../../shared/comcol-forms/create-comcol-page/create-comcol-page.component';
|
||||
import { NormalizedCommunity } from '../../core/cache/models/normalized-community.model';
|
||||
|
||||
/**
|
||||
* Component that represents the page where a user can create a new Community
|
||||
@@ -14,7 +13,7 @@ import { NormalizedCommunity } from '../../core/cache/models/normalized-communit
|
||||
styleUrls: ['./create-community-page.component.scss'],
|
||||
templateUrl: './create-community-page.component.html'
|
||||
})
|
||||
export class CreateCommunityPageComponent extends CreateComColPageComponent<Community, NormalizedCommunity> {
|
||||
export class CreateCommunityPageComponent extends CreateComColPageComponent<Community> {
|
||||
protected frontendURL = '/communities/';
|
||||
|
||||
public constructor(
|
||||
|
@@ -2,7 +2,6 @@ import { Component } from '@angular/core';
|
||||
import { Community } from '../../core/shared/community.model';
|
||||
import { CommunityDataService } from '../../core/data/community-data.service';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { NormalizedCommunity } from '../../core/cache/models/normalized-community.model';
|
||||
import { DeleteComColPageComponent } from '../../shared/comcol-forms/delete-comcol-page/delete-comcol-page.component';
|
||||
import { NotificationsService } from '../../shared/notifications/notifications.service';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
@@ -15,7 +14,7 @@ import { TranslateService } from '@ngx-translate/core';
|
||||
styleUrls: ['./delete-community-page.component.scss'],
|
||||
templateUrl: './delete-community-page.component.html'
|
||||
})
|
||||
export class DeleteCommunityPageComponent extends DeleteComColPageComponent<Community, NormalizedCommunity> {
|
||||
export class DeleteCommunityPageComponent extends DeleteComColPageComponent<Community> {
|
||||
protected frontendURL = '/communities/';
|
||||
|
||||
public constructor(
|
||||
|
@@ -2,7 +2,6 @@ import { Component } from '@angular/core';
|
||||
import { Community } from '../../core/shared/community.model';
|
||||
import { CommunityDataService } from '../../core/data/community-data.service';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { NormalizedCommunity } from '../../core/cache/models/normalized-community.model';
|
||||
import { EditComColPageComponent } from '../../shared/comcol-forms/edit-comcol-page/edit-comcol-page.component';
|
||||
|
||||
/**
|
||||
@@ -13,7 +12,7 @@ import { EditComColPageComponent } from '../../shared/comcol-forms/edit-comcol-p
|
||||
styleUrls: ['./edit-community-page.component.scss'],
|
||||
templateUrl: './edit-community-page.component.html'
|
||||
})
|
||||
export class EditCommunityPageComponent extends EditComColPageComponent<Community, NormalizedCommunity> {
|
||||
export class EditCommunityPageComponent extends EditComColPageComponent<Community> {
|
||||
protected frontendURL = '/communities/';
|
||||
|
||||
public constructor(
|
||||
|
@@ -3,33 +3,21 @@
|
||||
<div class="col-12">
|
||||
<h2 class="border-bottom">{{'item.edit.head' | translate}}</h2>
|
||||
<div class="pt-2">
|
||||
<ngb-tabset>
|
||||
<ngb-tab title="{{'item.edit.tabs.status.head' | translate}}">
|
||||
<ng-template ngbTabContent>
|
||||
<ds-item-status [item]="(itemRD$ | async)?.payload"></ds-item-status>
|
||||
</ng-template>
|
||||
</ngb-tab>
|
||||
<ngb-tab title="{{'item.edit.tabs.bitstreams.head' | translate}}">
|
||||
<ng-template ngbTabContent>
|
||||
|
||||
</ng-template>
|
||||
</ngb-tab>
|
||||
<ngb-tab title="{{'item.edit.tabs.metadata.head' | translate}}">
|
||||
<ng-template ngbTabContent>
|
||||
|
||||
</ng-template>
|
||||
</ngb-tab>
|
||||
<ngb-tab title="{{'item.edit.tabs.view.head' | translate}}">
|
||||
<ng-template ngbTabContent>
|
||||
|
||||
</ng-template>
|
||||
</ngb-tab>
|
||||
<ngb-tab title="{{'item.edit.tabs.curate.head' | translate}}">
|
||||
<ng-template ngbTabContent>
|
||||
|
||||
</ng-template>
|
||||
</ngb-tab>
|
||||
</ngb-tabset>
|
||||
<ul class="nav nav-tabs justify-content-start">
|
||||
<li *ngFor="let page of pages" class="nav-item">
|
||||
<a class="nav-link"
|
||||
[ngClass]="{'active' : page === currentPage}"
|
||||
[routerLink]="['./' + page]">
|
||||
{{'item.edit.tabs.' + page + '.head' | translate}}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="tab-pane active">
|
||||
<div class="mb-4">
|
||||
<router-outlet></router-outlet>
|
||||
</div>
|
||||
<a [routerLink]="getItemPage((itemRD$ | async)?.payload)" class="btn btn-outline-secondary">Cancel</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -0,0 +1,5 @@
|
||||
@import '../../../styles/variables.scss';
|
||||
|
||||
.btn {
|
||||
min-width: $edit-item-button-min-width;
|
||||
}
|
@@ -1,10 +1,12 @@
|
||||
import { fadeIn, fadeInOut } from '../../shared/animations/fade';
|
||||
import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
|
||||
import {ActivatedRoute} from '@angular/router';
|
||||
import { ActivatedRoute, Params, Router } from '@angular/router';
|
||||
import { RemoteData } from '../../core/data/remote-data';
|
||||
import { Item } from '../../core/shared/item.model';
|
||||
import { Observable } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
import { isNotEmpty } from '../../shared/empty.util';
|
||||
import { getItemPageRoute } from '../item-page-routing.module';
|
||||
|
||||
@Component({
|
||||
selector: 'ds-edit-item-page',
|
||||
@@ -25,11 +27,34 @@ export class EditItemPageComponent implements OnInit {
|
||||
*/
|
||||
itemRD$: Observable<RemoteData<Item>>;
|
||||
|
||||
constructor(private route: ActivatedRoute) {
|
||||
/**
|
||||
* The current page outlet string
|
||||
*/
|
||||
currentPage: string;
|
||||
|
||||
/**
|
||||
* All possible page outlet strings
|
||||
*/
|
||||
pages: string[];
|
||||
|
||||
constructor(private route: ActivatedRoute, private router: Router) {
|
||||
this.router.events.subscribe(() => {
|
||||
this.currentPage = this.route.snapshot.firstChild.routeConfig.path;
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.pages = this.route.routeConfig.children
|
||||
.map((child: any) => child.path)
|
||||
.filter((path: string) => isNotEmpty(path)); // ignore reroutes
|
||||
this.itemRD$ = this.route.data.pipe(map((data) => data.item));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the item page url
|
||||
* @param item The item for which the url is requested
|
||||
*/
|
||||
getItemPage(item: Item): string {
|
||||
return getItemPageRoute(item.id)
|
||||
}
|
||||
}
|
||||
|
@@ -12,6 +12,9 @@ import {AbstractSimpleItemActionComponent} from './simple-item-action/abstract-s
|
||||
import { ItemPrivateComponent } from './item-private/item-private.component';
|
||||
import { ItemPublicComponent } from './item-public/item-public.component';
|
||||
import { ItemDeleteComponent } from './item-delete/item-delete.component';
|
||||
import { ItemMetadataComponent } from './item-metadata/item-metadata.component';
|
||||
import { EditInPlaceFieldComponent } from './item-metadata/edit-in-place-field/edit-in-place-field.component';
|
||||
import { ItemBitstreamsComponent } from './item-bitstreams/item-bitstreams.component';
|
||||
|
||||
/**
|
||||
* Module that contains all components related to the Edit Item page administrator functionality
|
||||
@@ -32,7 +35,10 @@ import {ItemDeleteComponent} from './item-delete/item-delete.component';
|
||||
ItemPrivateComponent,
|
||||
ItemPublicComponent,
|
||||
ItemDeleteComponent,
|
||||
ItemStatusComponent
|
||||
ItemStatusComponent,
|
||||
ItemMetadataComponent,
|
||||
ItemBitstreamsComponent,
|
||||
EditInPlaceFieldComponent
|
||||
]
|
||||
})
|
||||
export class EditItemPageModule {
|
||||
|
@@ -7,6 +7,9 @@ import {ItemReinstateComponent} from './item-reinstate/item-reinstate.component'
|
||||
import { ItemPrivateComponent } from './item-private/item-private.component';
|
||||
import { ItemPublicComponent } from './item-public/item-public.component';
|
||||
import { ItemDeleteComponent } from './item-delete/item-delete.component';
|
||||
import { ItemStatusComponent } from './item-status/item-status.component';
|
||||
import { ItemMetadataComponent } from './item-metadata/item-metadata.component';
|
||||
import { ItemBitstreamsComponent } from './item-bitstreams/item-bitstreams.component';
|
||||
|
||||
const ITEM_EDIT_WITHDRAW_PATH = 'withdraw';
|
||||
const ITEM_EDIT_REINSTATE_PATH = 'reinstate';
|
||||
@@ -25,7 +28,40 @@ const ITEM_EDIT_DELETE_PATH = 'delete';
|
||||
component: EditItemPageComponent,
|
||||
resolve: {
|
||||
item: ItemPageResolver
|
||||
}
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
redirectTo: 'status',
|
||||
},
|
||||
{
|
||||
path: 'status',
|
||||
component: ItemStatusComponent,
|
||||
data: { title: 'item.edit.tabs.status.title' }
|
||||
},
|
||||
{
|
||||
path: 'bitstreams',
|
||||
component: ItemBitstreamsComponent,
|
||||
data: { title: 'item.edit.tabs.bitstreams.title' }
|
||||
},
|
||||
{
|
||||
path: 'metadata',
|
||||
component: ItemMetadataComponent,
|
||||
data: { title: 'item.edit.tabs.metadata.title' }
|
||||
},
|
||||
{
|
||||
path: 'view',
|
||||
/* TODO - change when view page exists */
|
||||
component: ItemBitstreamsComponent,
|
||||
data: { title: 'item.edit.tabs.view.title' }
|
||||
},
|
||||
{
|
||||
path: 'curate',
|
||||
/* TODO - change when curate page exists */
|
||||
component: ItemBitstreamsComponent,
|
||||
data: { title: 'item.edit.tabs.curate.title' }
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
path: ITEM_EDIT_WITHDRAW_PATH,
|
||||
|
@@ -0,0 +1,3 @@
|
||||
<div>
|
||||
|
||||
</div>
|
@@ -0,0 +1 @@
|
||||
@import '../../../../styles/variables.scss';
|
@@ -0,0 +1,13 @@
|
||||
import { Component } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'ds-item-bitstreams',
|
||||
styleUrls: ['./item-bitstreams.component.scss'],
|
||||
templateUrl: './item-bitstreams.component.html',
|
||||
})
|
||||
/**
|
||||
* Component for displaying an item's bitstreams edit page
|
||||
*/
|
||||
export class ItemBitstreamsComponent {
|
||||
/* TODO implement */
|
||||
}
|
@@ -0,0 +1,70 @@
|
||||
<td>
|
||||
<div class="metadata-field">
|
||||
<div *ngIf="!(editable | async)">
|
||||
<span>{{metadata?.key?.split('.').join('.​')}}</span>
|
||||
</div>
|
||||
<div *ngIf="(editable | async)" class="field-container">
|
||||
<ds-input-suggestions [suggestions]="(metadataFieldSuggestions | async)"
|
||||
[(ngModel)]="metadata.key"
|
||||
(submitSuggestion)="update(suggestionControl)"
|
||||
(clickSuggestion)="update(suggestionControl)"
|
||||
(typeSuggestion)="update(suggestionControl)"
|
||||
(dsClickOutside)="checkValidity(suggestionControl)"
|
||||
(findSuggestions)="findMetadataFieldSuggestions($event)"
|
||||
#suggestionControl="ngModel"
|
||||
[dsInListValidator]="metadataFields"
|
||||
[valid]="(valid | async) !== false"
|
||||
dsAutoFocus autoFocusSelector=".suggestion_input"
|
||||
[ngModelOptions]="{standalone: true}"
|
||||
></ds-input-suggestions>
|
||||
</div>
|
||||
<small class="text-danger"
|
||||
*ngIf="(valid | async) === false">{{"item.edit.metadata.metadatafield.invalid" | translate}}</small>
|
||||
</div>
|
||||
</td>
|
||||
<td class="w-100">
|
||||
<div class="value-field">
|
||||
<div *ngIf="!(editable | async)">
|
||||
<span>{{metadata?.value}}</span>
|
||||
</div>
|
||||
<div *ngIf="(editable | async)" class="field-container">
|
||||
<textarea class="form-control" type="textarea" [(ngModel)]="metadata.value" [dsDebounce]
|
||||
(onDebounce)="update()"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<div class="language-field">
|
||||
<div *ngIf="!(editable | async)">
|
||||
<span>{{metadata?.language}}</span>
|
||||
</div>
|
||||
<div *ngIf="(editable | async)" class="field-container">
|
||||
<input class="form-control" type="text" [(ngModel)]="metadata.language" [dsDebounce]
|
||||
(onDebounce)="update()"/>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<div class="btn-group edit-field">
|
||||
<button [disabled]="!(canSetEditable() | async)" *ngIf="!(editable | async)"
|
||||
(click)="setEditable(true)" class="btn btn-outline-primary btn-sm"
|
||||
title="{{'item.edit.metadata.edit.buttons.edit' | translate}}">
|
||||
<i class="fas fa-edit fa-fw"></i>
|
||||
</button>
|
||||
<button [disabled]="!(canSetUneditable() | async)" *ngIf="(editable | async)"
|
||||
(click)="setEditable(false)" class="btn btn-outline-success btn-sm"
|
||||
title="{{'item.edit.metadata.edit.buttons.unedit' | translate}}">
|
||||
<i class="fas fa-check fa-fw"></i>
|
||||
</button>
|
||||
<button [disabled]="!(canRemove() | async)" (click)="remove()"
|
||||
class="btn btn-outline-danger btn-sm"
|
||||
title="{{'item.edit.metadata.edit.buttons.remove' | translate}}">
|
||||
<i class="fas fa-trash-alt fa-fw"></i>
|
||||
</button>
|
||||
<button [disabled]="!(canUndo() | async)" (click)="removeChangesFromField()"
|
||||
class="btn btn-outline-warning btn-sm"
|
||||
title="{{'item.edit.metadata.edit.buttons.undo' | translate}}">
|
||||
<i class="fas fa-undo-alt fa-fw"></i>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
@@ -0,0 +1,14 @@
|
||||
@import '../../../../../styles/variables.scss';
|
||||
.btn[disabled] {
|
||||
color: $gray-600;
|
||||
border-color: $gray-600;
|
||||
z-index: 0; // prevent border colors jumping on hover
|
||||
}
|
||||
|
||||
.metadata-field {
|
||||
width: $edit-item-metadata-field-width;
|
||||
}
|
||||
|
||||
.language-field {
|
||||
width: $edit-item-language-field-width;
|
||||
}
|
@@ -0,0 +1,432 @@
|
||||
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { CUSTOM_ELEMENTS_SCHEMA, DebugElement } from '@angular/core';
|
||||
import { EditInPlaceFieldComponent } from './edit-in-place-field.component';
|
||||
import { RegistryService } from '../../../../core/registry/registry.service';
|
||||
import { ObjectUpdatesService } from '../../../../core/data/object-updates/object-updates.service';
|
||||
import { of as observableOf } from 'rxjs';
|
||||
import { RemoteData } from '../../../../core/data/remote-data';
|
||||
import { PaginatedList } from '../../../../core/data/paginated-list';
|
||||
import { MetadataField } from '../../../../core/metadata/metadatafield.model';
|
||||
import { By } from '@angular/platform-browser';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { SharedModule } from '../../../../shared/shared.module';
|
||||
import { getTestScheduler } from 'jasmine-marbles';
|
||||
import { InputSuggestion } from '../../../../shared/input-suggestions/input-suggestions.model';
|
||||
import { TestScheduler } from 'rxjs/testing';
|
||||
import { MetadataSchema } from '../../../../core/metadata/metadataschema.model';
|
||||
import { FieldChangeType } from '../../../../core/data/object-updates/object-updates.actions';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { MetadatumViewModel } from '../../../../core/shared/metadata.models';
|
||||
|
||||
let comp: EditInPlaceFieldComponent;
|
||||
let fixture: ComponentFixture<EditInPlaceFieldComponent>;
|
||||
let de: DebugElement;
|
||||
let el: HTMLElement;
|
||||
let metadataFieldService;
|
||||
let objectUpdatesService;
|
||||
let paginatedMetadataFields;
|
||||
const mdSchema = Object.assign(new MetadataSchema(), { prefix: 'dc' })
|
||||
const mdField1 = Object.assign(new MetadataField(), {
|
||||
schema: mdSchema,
|
||||
element: 'contributor',
|
||||
qualifier: 'author'
|
||||
});
|
||||
const mdField2 = Object.assign(new MetadataField(), { schema: mdSchema, element: 'title' });
|
||||
const mdField3 = Object.assign(new MetadataField(), {
|
||||
schema: mdSchema,
|
||||
element: 'description',
|
||||
qualifier: 'abstract'
|
||||
});
|
||||
|
||||
const metadatum = Object.assign(new MetadatumViewModel(), {
|
||||
key: 'dc.description.abstract',
|
||||
value: 'Example abstract',
|
||||
language: 'en'
|
||||
});
|
||||
|
||||
const url = 'http://test-url.com/test-url';
|
||||
const fieldUpdate = {
|
||||
field: metadatum,
|
||||
changeType: undefined
|
||||
};
|
||||
let scheduler: TestScheduler;
|
||||
|
||||
describe('EditInPlaceFieldComponent', () => {
|
||||
|
||||
beforeEach(async(() => {
|
||||
scheduler = getTestScheduler();
|
||||
|
||||
paginatedMetadataFields = new PaginatedList(undefined, [mdField1, mdField2, mdField3]);
|
||||
|
||||
metadataFieldService = jasmine.createSpyObj({
|
||||
queryMetadataFields: observableOf(new RemoteData(false, false, true, undefined, paginatedMetadataFields)),
|
||||
});
|
||||
objectUpdatesService = jasmine.createSpyObj('objectUpdatesService',
|
||||
{
|
||||
saveChangeFieldUpdate: {},
|
||||
saveRemoveFieldUpdate: {},
|
||||
setEditableFieldUpdate: {},
|
||||
setValidFieldUpdate: {},
|
||||
removeSingleFieldUpdate: {},
|
||||
isEditable: observableOf(false), // should always return something --> its in ngOnInit
|
||||
isValid: observableOf(true) // should always return something --> its in ngOnInit
|
||||
}
|
||||
);
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
imports: [FormsModule, SharedModule, TranslateModule.forRoot()],
|
||||
declarations: [EditInPlaceFieldComponent],
|
||||
providers: [
|
||||
{ provide: RegistryService, useValue: metadataFieldService },
|
||||
{ provide: ObjectUpdatesService, useValue: objectUpdatesService },
|
||||
], schemas: [
|
||||
CUSTOM_ELEMENTS_SCHEMA
|
||||
]
|
||||
}).compileComponents();
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(EditInPlaceFieldComponent);
|
||||
comp = fixture.componentInstance; // EditInPlaceFieldComponent test instance
|
||||
de = fixture.debugElement;
|
||||
el = de.nativeElement;
|
||||
|
||||
comp.url = url;
|
||||
comp.fieldUpdate = fieldUpdate;
|
||||
comp.metadata = metadatum;
|
||||
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
beforeEach(() => {
|
||||
comp.update();
|
||||
});
|
||||
|
||||
it('it should call saveChangeFieldUpdate on the objectUpdatesService with the correct url and metadata', () => {
|
||||
expect(objectUpdatesService.saveChangeFieldUpdate).toHaveBeenCalledWith(url, metadatum);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setEditable', () => {
|
||||
const editable = false;
|
||||
beforeEach(() => {
|
||||
comp.setEditable(editable);
|
||||
});
|
||||
|
||||
it('it should call setEditableFieldUpdate on the objectUpdatesService with the correct url and uuid and false', () => {
|
||||
expect(objectUpdatesService.setEditableFieldUpdate).toHaveBeenCalledWith(url, metadatum.uuid, editable);
|
||||
});
|
||||
});
|
||||
|
||||
describe('editable is true', () => {
|
||||
beforeEach(() => {
|
||||
comp.editable = observableOf(true);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
it('the div should contain input fields or textareas', () => {
|
||||
const inputField = de.queryAll(By.css('input'));
|
||||
const textAreas = de.queryAll(By.css('textarea'));
|
||||
expect(inputField.length + textAreas.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('editable is false', () => {
|
||||
beforeEach(() => {
|
||||
comp.editable = observableOf(false);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
it('the div should contain no input fields or textareas', () => {
|
||||
const inputField = de.queryAll(By.css('input'));
|
||||
const textAreas = de.queryAll(By.css('textarea'));
|
||||
expect(inputField.length + textAreas.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isValid is true', () => {
|
||||
beforeEach(() => {
|
||||
comp.valid = observableOf(true);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
it('the div should not contain an error message', () => {
|
||||
const errorMessages = de.queryAll(By.css('small.text-danger'));
|
||||
expect(errorMessages.length).toBe(0);
|
||||
|
||||
});
|
||||
});
|
||||
|
||||
describe('isValid is false', () => {
|
||||
beforeEach(() => {
|
||||
comp.valid = observableOf(false);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
it('the div should contain no input fields or textareas', () => {
|
||||
const errorMessages = de.queryAll(By.css('small.text-danger'));
|
||||
expect(errorMessages.length).toBeGreaterThan(0);
|
||||
|
||||
});
|
||||
});
|
||||
|
||||
describe('remove', () => {
|
||||
beforeEach(() => {
|
||||
comp.remove();
|
||||
});
|
||||
|
||||
it('it should call saveRemoveFieldUpdate on the objectUpdatesService with the correct url and metadata', () => {
|
||||
expect(objectUpdatesService.saveRemoveFieldUpdate).toHaveBeenCalledWith(url, metadatum);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeChangesFromField', () => {
|
||||
beforeEach(() => {
|
||||
comp.removeChangesFromField();
|
||||
});
|
||||
|
||||
it('it should call removeChangesFromField on the objectUpdatesService with the correct url and uuid', () => {
|
||||
expect(objectUpdatesService.removeSingleFieldUpdate).toHaveBeenCalledWith(url, metadatum.uuid);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findMetadataFieldSuggestions', () => {
|
||||
const query = 'query string';
|
||||
|
||||
const metadataFieldSuggestions: InputSuggestion[] =
|
||||
[
|
||||
{ displayValue: mdField1.toString().split('.').join('.​'), value: mdField1.toString() },
|
||||
{ displayValue: mdField2.toString().split('.').join('.​'), value: mdField2.toString() },
|
||||
{ displayValue: mdField3.toString().split('.').join('.​'), value: mdField3.toString() }
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
comp.findMetadataFieldSuggestions(query);
|
||||
|
||||
});
|
||||
|
||||
it('it should call queryMetadataFields on the metadataFieldService with the correct query', () => {
|
||||
|
||||
expect(metadataFieldService.queryMetadataFields).toHaveBeenCalledWith(query);
|
||||
});
|
||||
|
||||
it('it should set metadataFieldSuggestions to the right value', () => {
|
||||
const expected = 'a';
|
||||
scheduler.expectObservable(comp.metadataFieldSuggestions).toBe(expected, { a: metadataFieldSuggestions });
|
||||
});
|
||||
});
|
||||
|
||||
describe('canSetEditable', () => {
|
||||
describe('when editable is currently true', () => {
|
||||
beforeEach(() => {
|
||||
comp.editable = observableOf(true);
|
||||
});
|
||||
|
||||
it('canSetEditable should return an observable emitting false', () => {
|
||||
const expected = '(a|)';
|
||||
scheduler.expectObservable(comp.canSetEditable()).toBe(expected, { a: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('when editable is currently false', () => {
|
||||
beforeEach(() => {
|
||||
comp.editable = observableOf(false);
|
||||
});
|
||||
|
||||
describe('when the fieldUpdate\'s changeType is currently not REMOVE', () => {
|
||||
beforeEach(() => {
|
||||
comp.fieldUpdate.changeType = FieldChangeType.ADD;
|
||||
});
|
||||
it('canSetEditable should return an observable emitting true', () => {
|
||||
const expected = '(a|)';
|
||||
scheduler.expectObservable(comp.canSetEditable()).toBe(expected, { a: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the fieldUpdate\'s changeType is currently REMOVE', () => {
|
||||
beforeEach(() => {
|
||||
comp.fieldUpdate.changeType = FieldChangeType.REMOVE;
|
||||
});
|
||||
it('canSetEditable should return an observable emitting false', () => {
|
||||
const expected = '(a|)';
|
||||
scheduler.expectObservable(comp.canSetEditable()).toBe(expected, { a: false });
|
||||
});
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
describe('canSetUneditable', () => {
|
||||
describe('when editable is currently true', () => {
|
||||
beforeEach(() => {
|
||||
comp.editable = observableOf(true);
|
||||
});
|
||||
|
||||
it('canSetUneditable should return an observable emitting true', () => {
|
||||
const expected = '(a|)';
|
||||
scheduler.expectObservable(comp.canSetUneditable()).toBe(expected, { a: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('when editable is currently false', () => {
|
||||
beforeEach(() => {
|
||||
comp.editable = observableOf(false);
|
||||
});
|
||||
|
||||
it('canSetUneditable should return an observable emitting false', () => {
|
||||
const expected = '(a|)';
|
||||
scheduler.expectObservable(comp.canSetUneditable()).toBe(expected, { a: false });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when canSetEditable emits true', () => {
|
||||
beforeEach(() => {
|
||||
comp.editable = observableOf(false);
|
||||
spyOn(comp, 'canSetEditable').and.returnValue(observableOf(true));
|
||||
fixture.detectChanges();
|
||||
});
|
||||
it('the div should have an enabled button with an edit icon', () => {
|
||||
const editIcon = de.query(By.css('i.fa-edit')).parent.nativeElement.disabled;
|
||||
expect(editIcon).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when canSetEditable emits false', () => {
|
||||
beforeEach(() => {
|
||||
comp.editable = observableOf(false);
|
||||
spyOn(comp, 'canSetEditable').and.returnValue(observableOf(false));
|
||||
fixture.detectChanges();
|
||||
});
|
||||
it('the div should have a disabled button with an edit icon', () => {
|
||||
const editIcon = de.query(By.css('i.fa-edit')).parent.nativeElement.disabled;
|
||||
expect(editIcon).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when canSetUneditable emits true', () => {
|
||||
beforeEach(() => {
|
||||
comp.editable = observableOf(true);
|
||||
spyOn(comp, 'canSetUneditable').and.returnValue(observableOf(true));
|
||||
fixture.detectChanges();
|
||||
});
|
||||
it('the div should have an enabled button with a check icon', () => {
|
||||
const checkButtonAttrs = de.query(By.css('i.fa-check')).parent.nativeElement.disabled;
|
||||
expect(checkButtonAttrs).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when canSetUneditable emits false', () => {
|
||||
beforeEach(() => {
|
||||
comp.editable = observableOf(true);
|
||||
spyOn(comp, 'canSetUneditable').and.returnValue(observableOf(false));
|
||||
fixture.detectChanges();
|
||||
});
|
||||
it('the div should have a disabled button with a check icon', () => {
|
||||
const checkButtonAttrs = de.query(By.css('i.fa-check')).parent.nativeElement.disabled;
|
||||
expect(checkButtonAttrs).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when canRemove emits true', () => {
|
||||
beforeEach(() => {
|
||||
spyOn(comp, 'canRemove').and.returnValue(observableOf(true));
|
||||
fixture.detectChanges();
|
||||
});
|
||||
it('the div should have an enabled button with a trash icon', () => {
|
||||
const trashButtonAttrs = de.query(By.css('i.fa-trash-alt')).parent.nativeElement.disabled;
|
||||
expect(trashButtonAttrs).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when canRemove emits false', () => {
|
||||
beforeEach(() => {
|
||||
spyOn(comp, 'canRemove').and.returnValue(observableOf(false));
|
||||
fixture.detectChanges();
|
||||
});
|
||||
it('the div should have a disabled button with a trash icon', () => {
|
||||
const trashButtonAttrs = de.query(By.css('i.fa-trash-alt')).parent.nativeElement.disabled;
|
||||
expect(trashButtonAttrs).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when canUndo emits true', () => {
|
||||
beforeEach(() => {
|
||||
spyOn(comp, 'canUndo').and.returnValue(observableOf(true));
|
||||
fixture.detectChanges();
|
||||
});
|
||||
it('the div should have an enabled button with an undo icon', () => {
|
||||
const undoIcon = de.query(By.css('i.fa-undo-alt')).parent.nativeElement.disabled;
|
||||
expect(undoIcon).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when canUndo emits false', () => {
|
||||
beforeEach(() => {
|
||||
spyOn(comp, 'canUndo').and.returnValue(observableOf(false));
|
||||
fixture.detectChanges();
|
||||
});
|
||||
it('the div should have a disabled button with an undo icon', () => {
|
||||
const undoIcon = de.query(By.css('i.fa-undo-alt')).parent.nativeElement.disabled;
|
||||
expect(undoIcon).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('canRemove', () => {
|
||||
describe('when the fieldUpdate\'s changeType is currently not REMOVE or ADD', () => {
|
||||
beforeEach(() => {
|
||||
comp.fieldUpdate.changeType = FieldChangeType.UPDATE;
|
||||
});
|
||||
it('canRemove should return an observable emitting true', () => {
|
||||
const expected = '(a|)';
|
||||
scheduler.expectObservable(comp.canRemove()).toBe(expected, { a: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the fieldUpdate\'s changeType is currently ADD', () => {
|
||||
beforeEach(() => {
|
||||
comp.fieldUpdate.changeType = FieldChangeType.ADD;
|
||||
});
|
||||
it('canRemove should return an observable emitting false', () => {
|
||||
const expected = '(a|)';
|
||||
scheduler.expectObservable(comp.canRemove()).toBe(expected, { a: false });
|
||||
});
|
||||
})
|
||||
});
|
||||
|
||||
describe('canUndo', () => {
|
||||
|
||||
describe('when editable is currently true', () => {
|
||||
beforeEach(() => {
|
||||
comp.editable = observableOf(true);
|
||||
comp.fieldUpdate.changeType = undefined;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
it('canUndo should return an observable emitting true', () => {
|
||||
const expected = '(a|)';
|
||||
scheduler.expectObservable(comp.canUndo()).toBe(expected, { a: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('when editable is currently false', () => {
|
||||
describe('when the fieldUpdate\'s changeType is currently ADD, UPDATE or REMOVE', () => {
|
||||
beforeEach(() => {
|
||||
comp.fieldUpdate.changeType = FieldChangeType.ADD;
|
||||
});
|
||||
|
||||
it('canUndo should return an observable emitting true', () => {
|
||||
const expected = '(a|)';
|
||||
scheduler.expectObservable(comp.canUndo()).toBe(expected, { a: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the fieldUpdate\'s changeType is currently undefined', () => {
|
||||
beforeEach(() => {
|
||||
comp.fieldUpdate.changeType = undefined;
|
||||
});
|
||||
|
||||
it('canUndo should return an observable emitting false', () => {
|
||||
const expected = '(a|)';
|
||||
scheduler.expectObservable(comp.canUndo()).toBe(expected, { a: false });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
});
|
@@ -0,0 +1,194 @@
|
||||
import { Component, Input, OnChanges, OnInit } from '@angular/core';
|
||||
import { hasValue, isNotEmpty } from '../../../../shared/empty.util';
|
||||
import { RegistryService } from '../../../../core/registry/registry.service';
|
||||
import { cloneDeep } from 'lodash';
|
||||
import { BehaviorSubject, Observable, of as observableOf } from 'rxjs';
|
||||
import { map, take } from 'rxjs/operators';
|
||||
import { MetadataField } from '../../../../core/metadata/metadatafield.model';
|
||||
import { InputSuggestion } from '../../../../shared/input-suggestions/input-suggestions.model';
|
||||
import { FieldChangeType } from '../../../../core/data/object-updates/object-updates.actions';
|
||||
import { FieldUpdate } from '../../../../core/data/object-updates/object-updates.reducer';
|
||||
import { ObjectUpdatesService } from '../../../../core/data/object-updates/object-updates.service';
|
||||
import { NgModel } from '@angular/forms';
|
||||
import { MetadatumViewModel } from '../../../../core/shared/metadata.models';
|
||||
|
||||
@Component({
|
||||
// tslint:disable-next-line:component-selector
|
||||
selector: '[ds-edit-in-place-field]',
|
||||
styleUrls: ['./edit-in-place-field.component.scss'],
|
||||
templateUrl: './edit-in-place-field.component.html',
|
||||
})
|
||||
/**
|
||||
* Component that displays a single metadatum of an item on the edit page
|
||||
*/
|
||||
export class EditInPlaceFieldComponent implements OnInit, OnChanges {
|
||||
/**
|
||||
* The current field, value and state of the metadatum
|
||||
*/
|
||||
@Input() fieldUpdate: FieldUpdate;
|
||||
|
||||
/**
|
||||
* The current url of this page
|
||||
*/
|
||||
@Input() url: string;
|
||||
|
||||
/**
|
||||
* List of strings with all metadata field keys available
|
||||
*/
|
||||
@Input() metadataFields: string[];
|
||||
|
||||
/**
|
||||
* The metadatum of this field
|
||||
*/
|
||||
metadata: MetadatumViewModel;
|
||||
|
||||
/**
|
||||
* Emits whether or not this field is currently editable
|
||||
*/
|
||||
editable: Observable<boolean>;
|
||||
|
||||
/**
|
||||
* Emits whether or not this field is currently valid
|
||||
*/
|
||||
valid: Observable<boolean>;
|
||||
|
||||
/**
|
||||
* The current suggestions for the metadatafield when editing
|
||||
*/
|
||||
metadataFieldSuggestions: BehaviorSubject<InputSuggestion[]> = new BehaviorSubject([]);
|
||||
|
||||
constructor(
|
||||
private metadataFieldService: RegistryService,
|
||||
private objectUpdatesService: ObjectUpdatesService,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up an observable that keeps track of the current editable and valid state of this field
|
||||
*/
|
||||
ngOnInit(): void {
|
||||
this.editable = this.objectUpdatesService.isEditable(this.url, this.metadata.uuid);
|
||||
this.valid = this.objectUpdatesService.isValid(this.url, this.metadata.uuid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a new change update for this field to the object updates service
|
||||
*/
|
||||
update(ngModel?: NgModel) {
|
||||
this.objectUpdatesService.saveChangeFieldUpdate(this.url, this.metadata);
|
||||
if (hasValue(ngModel)) {
|
||||
this.checkValidity(ngModel);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Method to check the validity of a form control
|
||||
* @param ngModel
|
||||
*/
|
||||
private checkValidity(ngModel: NgModel) {
|
||||
ngModel.control.setValue(ngModel.viewModel);
|
||||
ngModel.control.updateValueAndValidity();
|
||||
this.objectUpdatesService.setValidFieldUpdate(this.url, this.metadata.uuid, ngModel.control.valid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a new editable state for this field to the service to change it
|
||||
* @param editable The new editable state for this field
|
||||
*/
|
||||
setEditable(editable: boolean) {
|
||||
this.objectUpdatesService.setEditableFieldUpdate(this.url, this.metadata.uuid, editable);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a new remove update for this field to the object updates service
|
||||
*/
|
||||
remove() {
|
||||
this.objectUpdatesService.saveRemoveFieldUpdate(this.url, this.metadata);
|
||||
}
|
||||
|
||||
/**
|
||||
* Notifies the object updates service that the updates for the current field can be removed
|
||||
*/
|
||||
removeChangesFromField() {
|
||||
this.objectUpdatesService.removeSingleFieldUpdate(this.url, this.metadata.uuid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the current metadatafield based on the fieldUpdate input field
|
||||
*/
|
||||
ngOnChanges(): void {
|
||||
this.metadata = cloneDeep(this.fieldUpdate.field) as MetadatumViewModel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests all metadata fields that contain the query string in their key
|
||||
* Then sets all found metadata fields as metadataFieldSuggestions
|
||||
* @param query The query to look for
|
||||
*/
|
||||
findMetadataFieldSuggestions(query: string): void {
|
||||
if (isNotEmpty(query)) {
|
||||
this.metadataFieldService.queryMetadataFields(query).pipe(
|
||||
// getSucceededRemoteData(),
|
||||
take(1),
|
||||
map((data) => data.payload.page)
|
||||
).subscribe(
|
||||
(fields: MetadataField[]) => this.metadataFieldSuggestions.next(
|
||||
fields.map((field: MetadataField) => {
|
||||
return {
|
||||
displayValue: field.toString().split('.').join('.​'),
|
||||
value: field.toString()
|
||||
};
|
||||
})
|
||||
)
|
||||
);
|
||||
} else {
|
||||
this.metadataFieldSuggestions.next([]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user should be allowed to edit this field
|
||||
* @return an observable that emits true when the user should be able to edit this field and false when they should not
|
||||
*/
|
||||
canSetEditable(): Observable<boolean> {
|
||||
return this.editable.pipe(
|
||||
map((editable: boolean) => {
|
||||
if (editable) {
|
||||
return false;
|
||||
} else {
|
||||
return this.fieldUpdate.changeType !== FieldChangeType.REMOVE;
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user should be allowed to disabled editing this field
|
||||
* @return an observable that emits true when the user should be able to disable editing this field and false when they should not
|
||||
*/
|
||||
canSetUneditable(): Observable<boolean> {
|
||||
return this.editable;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user should be allowed to remove this field
|
||||
* @return an observable that emits true when the user should be able to remove this field and false when they should not
|
||||
*/
|
||||
canRemove(): Observable<boolean> {
|
||||
return observableOf(this.fieldUpdate.changeType !== FieldChangeType.REMOVE && this.fieldUpdate.changeType !== FieldChangeType.ADD);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user should be allowed to undo changes to this field
|
||||
* @return an observable that emits true when the user should be able to undo changes to this field and false when they should not
|
||||
*/
|
||||
canUndo(): Observable<boolean> {
|
||||
return this.editable.pipe(
|
||||
map((editable: boolean) => this.fieldUpdate.changeType >= 0 || editable)
|
||||
);
|
||||
}
|
||||
|
||||
protected isNotEmpty(value): boolean {
|
||||
return isNotEmpty(value);
|
||||
}
|
||||
}
|
@@ -0,0 +1,64 @@
|
||||
<div class="item-metadata">
|
||||
<div class="button-row top d-flex">
|
||||
<button class="mr-auto btn btn-success"
|
||||
(click)="add()"><i
|
||||
class="fas fa-plus"></i>
|
||||
<span class="d-none d-sm-inline"> {{"item.edit.metadata.add-button" | translate}}</span>
|
||||
</button>
|
||||
<button class="btn btn-danger" *ngIf="!(isReinstatable() | async)"
|
||||
[disabled]="!(hasChanges() | async)"
|
||||
(click)="discard()"><i
|
||||
class="fas fa-times"></i>
|
||||
<span class="d-none d-sm-inline"> {{"item.edit.metadata.discard-button" | translate}}</span>
|
||||
</button>
|
||||
<button class="btn btn-warning" *ngIf="isReinstatable() | async"
|
||||
(click)="reinstate()"><i
|
||||
class="fas fa-undo-alt"></i>
|
||||
<span class="d-none d-sm-inline"> {{"item.edit.metadata.reinstate-button" | translate}}</span>
|
||||
</button>
|
||||
<button class="btn btn-primary" [disabled]="!(hasChanges() | async)"
|
||||
(click)="submit()"><i
|
||||
class="fas fa-save"></i>
|
||||
<span class="d-none d-sm-inline"> {{"item.edit.metadata.save-button" | translate}}</span>
|
||||
</button>
|
||||
</div>
|
||||
<table class="table table-responsive table-striped table-bordered">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>{{'item.edit.metadata.headers.field' | translate}}</th>
|
||||
<th>{{'item.edit.metadata.headers.value' | translate}}</th>
|
||||
<th class="text-center">{{'item.edit.metadata.headers.language' | translate}}</th>
|
||||
<th class="text-center">{{'item.edit.metadata.headers.edit' | translate}}</th>
|
||||
</tr>
|
||||
<tr *ngFor="let updateValue of ((updates$ | async)| dsObjectValues); trackBy: trackUpdate"
|
||||
ds-edit-in-place-field
|
||||
[fieldUpdate]="updateValue || {}"
|
||||
[metadataFields]="metadataFields$ | async"
|
||||
[url]="url"
|
||||
[ngClass]="{
|
||||
'table-warning': updateValue.changeType === 0,
|
||||
'table-danger': updateValue.changeType === 2,
|
||||
'table-success': updateValue.changeType === 1
|
||||
}">
|
||||
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="button-row bottom">
|
||||
<div class="my-2 float-right">
|
||||
<button class="btn btn-danger" *ngIf="!(isReinstatable() | async)"
|
||||
[disabled]="!(hasChanges() | async)"
|
||||
(click)="discard()"><i
|
||||
class="fas fa-times"></i> {{"item.edit.metadata.discard-button" | translate}}
|
||||
</button>
|
||||
<button class="btn btn-warning" *ngIf="isReinstatable() | async"
|
||||
(click)="reinstate()"><i
|
||||
class="fas fa-undo-alt"></i> {{"item.edit.metadata.reinstate-button" | translate}}
|
||||
</button>
|
||||
<button class="btn btn-primary" [disabled]="!(hasChanges() | async)"
|
||||
(click)="submit()"><i
|
||||
class="fas fa-save"></i> {{"item.edit.metadata.save-button" | translate}}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
@@ -0,0 +1,22 @@
|
||||
@import '../../../../styles/variables.scss';
|
||||
|
||||
.button-row {
|
||||
.btn {
|
||||
margin-right: 0.5 * $spacer;
|
||||
|
||||
&:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
@media screen and (min-width: map-get($grid-breakpoints, sm)) {
|
||||
min-width: $edit-item-button-min-width;
|
||||
}
|
||||
}
|
||||
|
||||
&.top .btn {
|
||||
margin-top: $spacer/2;
|
||||
margin-bottom: $spacer/2;
|
||||
}
|
||||
|
||||
|
||||
}
|
@@ -0,0 +1,278 @@
|
||||
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { DebugElement, NO_ERRORS_SCHEMA } from '@angular/core';
|
||||
import { of as observableOf } from 'rxjs';
|
||||
import { getTestScheduler } from 'jasmine-marbles';
|
||||
import { ItemMetadataComponent } from './item-metadata.component';
|
||||
import { TestScheduler } from 'rxjs/testing';
|
||||
import { SharedModule } from '../../../shared/shared.module';
|
||||
import { ObjectUpdatesService } from '../../../core/data/object-updates/object-updates.service';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { NotificationsService } from '../../../shared/notifications/notifications.service';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { ItemDataService } from '../../../core/data/item-data.service';
|
||||
import { By } from '@angular/platform-browser';
|
||||
import {
|
||||
INotification,
|
||||
Notification
|
||||
} from '../../../shared/notifications/models/notification.model';
|
||||
import { NotificationType } from '../../../shared/notifications/models/notification-type';
|
||||
import { RouterStub } from '../../../shared/testing/router-stub';
|
||||
import { GLOBAL_CONFIG } from '../../../../config';
|
||||
import { Item } from '../../../core/shared/item.model';
|
||||
import { FieldChangeType } from '../../../core/data/object-updates/object-updates.actions';
|
||||
import { RemoteData } from '../../../core/data/remote-data';
|
||||
import { MetadatumViewModel } from '../../../core/shared/metadata.models';
|
||||
import { RegistryService } from '../../../core/registry/registry.service';
|
||||
import { PaginatedList } from '../../../core/data/paginated-list';
|
||||
import { MetadataSchema } from '../../../core/metadata/metadataschema.model';
|
||||
import { MetadataField } from '../../../core/metadata/metadatafield.model';
|
||||
import { Metadata } from '../../../core/shared/metadata.utils';
|
||||
|
||||
let comp: ItemMetadataComponent;
|
||||
let fixture: ComponentFixture<ItemMetadataComponent>;
|
||||
let de: DebugElement;
|
||||
let el: HTMLElement;
|
||||
let objectUpdatesService;
|
||||
const infoNotification: INotification = new Notification('id', NotificationType.Info, 'info');
|
||||
const warningNotification: INotification = new Notification('id', NotificationType.Warning, 'warning');
|
||||
const successNotification: INotification = new Notification('id', NotificationType.Success, 'success');
|
||||
const date = new Date();
|
||||
const router = new RouterStub();
|
||||
let metadataFieldService;
|
||||
let paginatedMetadataFields;
|
||||
let routeStub;
|
||||
|
||||
const mdSchema = Object.assign(new MetadataSchema(), { prefix: 'dc' });
|
||||
const mdField1 = Object.assign(new MetadataField(), {
|
||||
schema: mdSchema,
|
||||
element: 'contributor',
|
||||
qualifier: 'author'
|
||||
});
|
||||
const mdField2 = Object.assign(new MetadataField(), { schema: mdSchema, element: 'title' });
|
||||
const mdField3 = Object.assign(new MetadataField(), {
|
||||
schema: mdSchema,
|
||||
element: 'description',
|
||||
qualifier: 'abstract'
|
||||
});
|
||||
|
||||
let itemService;
|
||||
const notificationsService = jasmine.createSpyObj('notificationsService',
|
||||
{
|
||||
info: infoNotification,
|
||||
warning: warningNotification,
|
||||
success: successNotification
|
||||
}
|
||||
);
|
||||
const metadatum1 = Object.assign(new MetadatumViewModel(), {
|
||||
key: 'dc.description.abstract',
|
||||
value: 'Example abstract',
|
||||
language: 'en'
|
||||
});
|
||||
|
||||
const metadatum2 = Object.assign(new MetadatumViewModel(), {
|
||||
key: 'dc.title',
|
||||
value: 'Title test',
|
||||
language: 'de'
|
||||
});
|
||||
|
||||
const metadatum3 = Object.assign(new MetadatumViewModel(), {
|
||||
key: 'dc.contributor.author',
|
||||
value: 'Shakespeare, William',
|
||||
});
|
||||
|
||||
const url = 'http://test-url.com/test-url';
|
||||
|
||||
router.url = url;
|
||||
|
||||
const fieldUpdate1 = {
|
||||
field: metadatum1,
|
||||
changeType: undefined
|
||||
};
|
||||
|
||||
const fieldUpdate2 = {
|
||||
field: metadatum2,
|
||||
changeType: FieldChangeType.REMOVE
|
||||
};
|
||||
|
||||
const fieldUpdate3 = {
|
||||
field: metadatum3,
|
||||
changeType: undefined
|
||||
};
|
||||
|
||||
let scheduler: TestScheduler;
|
||||
let item;
|
||||
describe('ItemMetadataComponent', () => {
|
||||
beforeEach(async(() => {
|
||||
item = Object.assign(new Item(), {
|
||||
metadata: {
|
||||
[metadatum1.key]: [metadatum1],
|
||||
[metadatum2.key]: [metadatum2],
|
||||
[metadatum3.key]: [metadatum3]
|
||||
}
|
||||
},
|
||||
{
|
||||
lastModified: date
|
||||
}
|
||||
)
|
||||
;
|
||||
itemService = jasmine.createSpyObj('itemService', {
|
||||
update: observableOf(new RemoteData(false, false, true, undefined, item)),
|
||||
commitUpdates: {}
|
||||
});
|
||||
routeStub = {
|
||||
parent: {
|
||||
data: observableOf({ item: new RemoteData(false, false, true, null, item) })
|
||||
}
|
||||
};
|
||||
paginatedMetadataFields = new PaginatedList(undefined, [mdField1, mdField2, mdField3]);
|
||||
|
||||
metadataFieldService = jasmine.createSpyObj({
|
||||
getAllMetadataFields: observableOf(new RemoteData(false, false, true, undefined, paginatedMetadataFields))
|
||||
});
|
||||
scheduler = getTestScheduler();
|
||||
objectUpdatesService = jasmine.createSpyObj('objectUpdatesService',
|
||||
{
|
||||
getFieldUpdates: observableOf({
|
||||
[metadatum1.uuid]: fieldUpdate1,
|
||||
[metadatum2.uuid]: fieldUpdate2,
|
||||
[metadatum3.uuid]: fieldUpdate3
|
||||
}),
|
||||
saveAddFieldUpdate: {},
|
||||
discardFieldUpdates: {},
|
||||
reinstateFieldUpdates: observableOf(true),
|
||||
initialize: {},
|
||||
getUpdatedFields: observableOf([metadatum1, metadatum2, metadatum3]),
|
||||
getLastModified: observableOf(date),
|
||||
hasUpdates: observableOf(true),
|
||||
isReinstatable: observableOf(false), // should always return something --> its in ngOnInit
|
||||
isValidPage: observableOf(true)
|
||||
}
|
||||
);
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
imports: [SharedModule, TranslateModule.forRoot()],
|
||||
declarations: [ItemMetadataComponent],
|
||||
providers: [
|
||||
{ provide: ItemDataService, useValue: itemService },
|
||||
{ provide: ObjectUpdatesService, useValue: objectUpdatesService },
|
||||
{ provide: Router, useValue: router },
|
||||
{ provide: ActivatedRoute, useValue: routeStub },
|
||||
{ provide: NotificationsService, useValue: notificationsService },
|
||||
{ provide: GLOBAL_CONFIG, useValue: { item: { edit: { undoTimeout: 10 } } } as any },
|
||||
{ provide: RegistryService, useValue: metadataFieldService },
|
||||
], schemas: [
|
||||
NO_ERRORS_SCHEMA
|
||||
]
|
||||
}).compileComponents();
|
||||
})
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(ItemMetadataComponent);
|
||||
comp = fixture.componentInstance; // EditInPlaceFieldComponent test instance
|
||||
de = fixture.debugElement;
|
||||
el = de.nativeElement;
|
||||
comp.url = url;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
describe('add', () => {
|
||||
const md = new MetadatumViewModel();
|
||||
beforeEach(() => {
|
||||
comp.add(md);
|
||||
});
|
||||
|
||||
it('it should call saveAddFieldUpdate on the objectUpdatesService with the correct url and metadata', () => {
|
||||
expect(objectUpdatesService.saveAddFieldUpdate).toHaveBeenCalledWith(url, md);
|
||||
});
|
||||
});
|
||||
|
||||
describe('discard', () => {
|
||||
beforeEach(() => {
|
||||
comp.discard();
|
||||
});
|
||||
|
||||
it('it should call discardFieldUpdates on the objectUpdatesService with the correct url and notification', () => {
|
||||
expect(objectUpdatesService.discardFieldUpdates).toHaveBeenCalledWith(url, infoNotification);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reinstate', () => {
|
||||
beforeEach(() => {
|
||||
comp.reinstate();
|
||||
});
|
||||
|
||||
it('it should call reinstateFieldUpdates on the objectUpdatesService with the correct url', () => {
|
||||
expect(objectUpdatesService.reinstateFieldUpdates).toHaveBeenCalledWith(url);
|
||||
});
|
||||
});
|
||||
|
||||
describe('submit', () => {
|
||||
beforeEach(() => {
|
||||
comp.submit();
|
||||
});
|
||||
|
||||
it('it should call reinstateFieldUpdates on the objectUpdatesService with the correct url and metadata', () => {
|
||||
expect(objectUpdatesService.getUpdatedFields).toHaveBeenCalledWith(url, comp.item.metadataAsList);
|
||||
expect(itemService.update).toHaveBeenCalledWith(Object.assign(comp.item, { metadata: Metadata.toMetadataMap(comp.item.metadataAsList) }));
|
||||
expect(objectUpdatesService.getFieldUpdates).toHaveBeenCalledWith(url, comp.item.metadataAsList);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasChanges', () => {
|
||||
describe('when the objectUpdatesService\'s hasUpdated method returns true', () => {
|
||||
beforeEach(() => {
|
||||
objectUpdatesService.hasUpdates.and.returnValue(observableOf(true));
|
||||
});
|
||||
|
||||
it('should return an observable that emits true', () => {
|
||||
const expected = '(a|)';
|
||||
scheduler.expectObservable(comp.hasChanges()).toBe(expected, { a: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the objectUpdatesService\'s hasUpdated method returns false', () => {
|
||||
beforeEach(() => {
|
||||
objectUpdatesService.hasUpdates.and.returnValue(observableOf(false));
|
||||
});
|
||||
|
||||
it('should return an observable that emits false', () => {
|
||||
const expected = '(a|)';
|
||||
scheduler.expectObservable(comp.hasChanges()).toBe(expected, { a: false });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('changeType is UPDATE', () => {
|
||||
beforeEach(() => {
|
||||
fieldUpdate1.changeType = FieldChangeType.UPDATE;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
it('the div should have class table-warning', () => {
|
||||
const element = de.queryAll(By.css('tr'))[1].nativeElement;
|
||||
expect(element.classList).toContain('table-warning');
|
||||
});
|
||||
});
|
||||
|
||||
describe('changeType is ADD', () => {
|
||||
beforeEach(() => {
|
||||
fieldUpdate1.changeType = FieldChangeType.ADD;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
it('the div should have class table-success', () => {
|
||||
const element = de.queryAll(By.css('tr'))[1].nativeElement;
|
||||
expect(element.classList).toContain('table-success');
|
||||
});
|
||||
});
|
||||
|
||||
describe('changeType is REMOVE', () => {
|
||||
beforeEach(() => {
|
||||
fieldUpdate1.changeType = FieldChangeType.REMOVE;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
it('the div should have class table-danger', () => {
|
||||
const element = de.queryAll(By.css('tr'))[1].nativeElement;
|
||||
expect(element.classList).toContain('table-danger');
|
||||
});
|
||||
});
|
||||
});
|
@@ -0,0 +1,233 @@
|
||||
import { Component, Inject, Input, OnInit } from '@angular/core';
|
||||
import { Item } from '../../../core/shared/item.model';
|
||||
import { ItemDataService } from '../../../core/data/item-data.service';
|
||||
import { ObjectUpdatesService } from '../../../core/data/object-updates/object-updates.service';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { cloneDeep } from 'lodash';
|
||||
import { Observable } from 'rxjs';
|
||||
import {
|
||||
FieldUpdate,
|
||||
FieldUpdates,
|
||||
Identifiable
|
||||
} from '../../../core/data/object-updates/object-updates.reducer';
|
||||
import { first, map, switchMap, take, tap } from 'rxjs/operators';
|
||||
import { getSucceededRemoteData } from '../../../core/shared/operators';
|
||||
import { RemoteData } from '../../../core/data/remote-data';
|
||||
import { NotificationsService } from '../../../shared/notifications/notifications.service';
|
||||
import { GLOBAL_CONFIG, GlobalConfig } from '../../../../config';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { RegistryService } from '../../../core/registry/registry.service';
|
||||
import { MetadataField } from '../../../core/metadata/metadatafield.model';
|
||||
import { MetadatumViewModel } from '../../../core/shared/metadata.models';
|
||||
import { Metadata } from '../../../core/shared/metadata.utils';
|
||||
|
||||
@Component({
|
||||
selector: 'ds-item-metadata',
|
||||
styleUrls: ['./item-metadata.component.scss'],
|
||||
templateUrl: './item-metadata.component.html',
|
||||
})
|
||||
/**
|
||||
* Component for displaying an item's metadata edit page
|
||||
*/
|
||||
export class ItemMetadataComponent implements OnInit {
|
||||
|
||||
/**
|
||||
* The item to display the edit page for
|
||||
*/
|
||||
item: Item;
|
||||
/**
|
||||
* The current values and updates for all this item's metadata fields
|
||||
*/
|
||||
updates$: Observable<FieldUpdates>;
|
||||
/**
|
||||
* The current url of this page
|
||||
*/
|
||||
url: string;
|
||||
/**
|
||||
* The time span for being able to undo discarding changes
|
||||
*/
|
||||
private discardTimeOut: number;
|
||||
/**
|
||||
* Prefix for this component's notification translate keys
|
||||
*/
|
||||
private notificationsPrefix = 'item.edit.metadata.notifications.';
|
||||
|
||||
/**
|
||||
* Observable with a list of strings with all existing metadata field keys
|
||||
*/
|
||||
metadataFields$: Observable<string[]>;
|
||||
|
||||
constructor(
|
||||
private itemService: ItemDataService,
|
||||
private objectUpdatesService: ObjectUpdatesService,
|
||||
private router: Router,
|
||||
private notificationsService: NotificationsService,
|
||||
private translateService: TranslateService,
|
||||
@Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig,
|
||||
private route: ActivatedRoute,
|
||||
private metadataFieldService: RegistryService,
|
||||
) {
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up and initialize all fields
|
||||
*/
|
||||
ngOnInit(): void {
|
||||
this.metadataFields$ = this.findMetadataFields();
|
||||
this.route.parent.data.pipe(map((data) => data.item))
|
||||
.pipe(
|
||||
first(),
|
||||
map((data: RemoteData<Item>) => data.payload)
|
||||
).subscribe((item: Item) => {
|
||||
this.item = item;
|
||||
});
|
||||
|
||||
this.discardTimeOut = this.EnvConfig.item.edit.undoTimeout;
|
||||
this.url = this.router.url;
|
||||
if (this.url.indexOf('?') > 0) {
|
||||
this.url = this.url.substr(0, this.url.indexOf('?'));
|
||||
}
|
||||
this.hasChanges().pipe(first()).subscribe((hasChanges) => {
|
||||
if (!hasChanges) {
|
||||
this.initializeOriginalFields();
|
||||
} else {
|
||||
this.checkLastModified();
|
||||
}
|
||||
});
|
||||
this.updates$ = this.objectUpdatesService.getFieldUpdates(this.url, this.item.metadataAsList);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a new add update for a field to the object updates service
|
||||
* @param metadata The metadata to add, if no parameter is supplied, create a new Metadatum
|
||||
*/
|
||||
add(metadata: MetadatumViewModel = new MetadatumViewModel()) {
|
||||
this.objectUpdatesService.saveAddFieldUpdate(this.url, metadata);
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Request the object updates service to discard all current changes to this item
|
||||
* Shows a notification to remind the user that they can undo this
|
||||
*/
|
||||
discard() {
|
||||
const undoNotification = this.notificationsService.info(this.getNotificationTitle('discarded'), this.getNotificationContent('discarded'), { timeOut: this.discardTimeOut });
|
||||
this.objectUpdatesService.discardFieldUpdates(this.url, undoNotification);
|
||||
}
|
||||
|
||||
/**
|
||||
* Request the object updates service to undo discarding all changes to this item
|
||||
*/
|
||||
reinstate() {
|
||||
this.objectUpdatesService.reinstateFieldUpdates(this.url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends all initial values of this item to the object updates service
|
||||
*/
|
||||
private initializeOriginalFields() {
|
||||
this.objectUpdatesService.initialize(this.url, this.item.metadataAsList, this.item.lastModified);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prevent unnecessary rerendering so fields don't lose focus
|
||||
*/
|
||||
trackUpdate(index, update: FieldUpdate) {
|
||||
return update && update.field ? update.field.uuid : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests all current metadata for this item and requests the item service to update the item
|
||||
* Makes sure the new version of the item is rendered on the page
|
||||
*/
|
||||
submit() {
|
||||
this.isValid().pipe(first()).subscribe((isValid) => {
|
||||
if (isValid) {
|
||||
const metadata$: Observable<Identifiable[]> = this.objectUpdatesService.getUpdatedFields(this.url, this.item.metadataAsList) as Observable<MetadatumViewModel[]>;
|
||||
metadata$.pipe(
|
||||
first(),
|
||||
switchMap((metadata: MetadatumViewModel[]) => {
|
||||
const updatedItem: Item = Object.assign(cloneDeep(this.item), { metadata: Metadata.toMetadataMap(metadata) });
|
||||
return this.itemService.update(updatedItem);
|
||||
}),
|
||||
tap(() => this.itemService.commitUpdates()),
|
||||
getSucceededRemoteData()
|
||||
).subscribe(
|
||||
(rd: RemoteData<Item>) => {
|
||||
this.item = rd.payload;
|
||||
this.initializeOriginalFields();
|
||||
this.updates$ = this.objectUpdatesService.getFieldUpdates(this.url, this.item.metadataAsList);
|
||||
this.notificationsService.success(this.getNotificationTitle('saved'), this.getNotificationContent('saved'));
|
||||
}
|
||||
)
|
||||
} else {
|
||||
this.notificationsService.error(this.getNotificationTitle('invalid'), this.getNotificationContent('invalid'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether or not there are currently updates for this item
|
||||
*/
|
||||
hasChanges(): Observable<boolean> {
|
||||
return this.objectUpdatesService.hasUpdates(this.url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether or not the item is currently reinstatable
|
||||
*/
|
||||
isReinstatable(): Observable<boolean> {
|
||||
return this.objectUpdatesService.isReinstatable(this.url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the current item is still in sync with the version in the store
|
||||
* If it's not, a notification is shown and the changes are removed
|
||||
*/
|
||||
private checkLastModified() {
|
||||
const currentVersion = this.item.lastModified;
|
||||
this.objectUpdatesService.getLastModified(this.url).pipe(first()).subscribe(
|
||||
(updateVersion: Date) => {
|
||||
if (updateVersion.getDate() !== currentVersion.getDate()) {
|
||||
this.notificationsService.warning(this.getNotificationTitle('outdated'), this.getNotificationContent('outdated'));
|
||||
this.initializeOriginalFields();
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current page is entirely valid
|
||||
*/
|
||||
private isValid() {
|
||||
return this.objectUpdatesService.isValidPage(this.url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get translated notification title
|
||||
* @param key
|
||||
*/
|
||||
private getNotificationTitle(key: string) {
|
||||
return this.translateService.instant(this.notificationsPrefix + key + '.title');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get translated notification content
|
||||
* @param key
|
||||
*/
|
||||
private getNotificationContent(key: string) {
|
||||
return this.translateService.instant(this.notificationsPrefix + key + '.content');
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Method to request all metadata fields and convert them to a list of strings
|
||||
*/
|
||||
findMetadataFields(): Observable<string[]> {
|
||||
return this.metadataFieldService.getAllMetadataFields().pipe(
|
||||
getSucceededRemoteData(),
|
||||
take(1),
|
||||
map((remoteData$) => remoteData$.payload.page.map((field: MetadataField) => field.toString())));
|
||||
}
|
||||
}
|
@@ -12,7 +12,7 @@
|
||||
{{'item.edit.tabs.status.labels.itemPage' | translate}}:
|
||||
</div>
|
||||
<div class="col-9 float-left status-data" id="status-itemPage">
|
||||
<a href="{{getItemPage()}}">{{getItemPage()}}</a>
|
||||
<a href="{{getItemPage((itemRD$ | async)?.payload)}}">{{getItemPage((itemRD$ | async)?.payload)}}</a>
|
||||
</div>
|
||||
|
||||
<div *ngFor="let operation of operations" class="w-100 pt-3">
|
||||
|
@@ -6,11 +6,12 @@ import { CommonModule } from '@angular/common';
|
||||
import { HostWindowServiceStub } from '../../../shared/testing/host-window-service-stub';
|
||||
import { HostWindowService } from '../../../shared/host-window.service';
|
||||
import { RouterTestingModule } from '@angular/router/testing';
|
||||
import { Router } from '@angular/router';
|
||||
import { RouterStub } from '../../../shared/testing/router-stub';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { Item } from '../../../core/shared/item.model';
|
||||
import { By } from '@angular/platform-browser';
|
||||
import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
|
||||
import { of as observableOf } from 'rxjs';
|
||||
import { RemoteData } from '../../../core/data/remote-data';
|
||||
|
||||
describe('ItemStatusComponent', () => {
|
||||
let comp: ItemStatusComponent;
|
||||
@@ -22,17 +23,20 @@ describe('ItemStatusComponent', () => {
|
||||
lastModified: '2018'
|
||||
});
|
||||
|
||||
const itemPageUrl = `fake-url/${mockItem.id}`;
|
||||
const routerStub = Object.assign(new RouterStub(), {
|
||||
url: `${itemPageUrl}/edit`
|
||||
});
|
||||
const itemPageUrl = `items/${mockItem.id}`;
|
||||
|
||||
const routeStub = {
|
||||
parent: {
|
||||
data: observableOf({ item: new RemoteData(false, false, true, null, mockItem) })
|
||||
}
|
||||
};
|
||||
|
||||
beforeEach(async(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [CommonModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule.forRoot()],
|
||||
declarations: [ItemStatusComponent],
|
||||
providers: [
|
||||
{ provide: Router, useValue: routerStub },
|
||||
{ provide: ActivatedRoute, useValue: routeStub },
|
||||
{ provide: HostWindowService, useValue: new HostWindowServiceStub(0) }
|
||||
], schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||
}).compileComponents();
|
||||
@@ -41,7 +45,6 @@ describe('ItemStatusComponent', () => {
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(ItemStatusComponent);
|
||||
comp = fixture.componentInstance;
|
||||
comp.item = mockItem;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
@@ -65,4 +68,5 @@ describe('ItemStatusComponent', () => {
|
||||
expect(statusItemPage.textContent).toContain(itemPageUrl);
|
||||
});
|
||||
|
||||
});
|
||||
})
|
||||
;
|
||||
|
@@ -1,8 +1,12 @@
|
||||
import {ChangeDetectionStrategy, Component, Input, OnInit} from '@angular/core';
|
||||
import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
|
||||
import { fadeIn, fadeInOut } from '../../../shared/animations/fade';
|
||||
import { Item } from '../../../core/shared/item.model';
|
||||
import {Router} from '@angular/router';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { ItemOperation } from '../item-operation/itemOperation.model';
|
||||
import { first, map } from 'rxjs/operators';
|
||||
import { Observable } from 'rxjs';
|
||||
import { RemoteData } from '../../../core/data/remote-data';
|
||||
import { getItemEditPath, getItemPageRoute } from '../../item-page-routing.module';
|
||||
|
||||
@Component({
|
||||
selector: 'ds-item-status',
|
||||
@@ -21,7 +25,7 @@ export class ItemStatusComponent implements OnInit {
|
||||
/**
|
||||
* The item to display the status for
|
||||
*/
|
||||
@Input() item: Item;
|
||||
itemRD$: Observable<RemoteData<Item>>;
|
||||
|
||||
/**
|
||||
* The data to show in the status
|
||||
@@ -37,59 +41,62 @@ export class ItemStatusComponent implements OnInit {
|
||||
* key: id value: url to action's component
|
||||
*/
|
||||
operations: ItemOperation[];
|
||||
|
||||
/**
|
||||
* The keys of the actions (to loop over)
|
||||
*/
|
||||
actionsKeys;
|
||||
|
||||
constructor(private router: Router) {
|
||||
constructor(private route: ActivatedRoute) {
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.itemRD$ = this.route.parent.data.pipe(map((data) => data.item));
|
||||
this.itemRD$.pipe(
|
||||
first(),
|
||||
map((data: RemoteData<Item>) => data.payload)
|
||||
).subscribe((item: Item) => {
|
||||
this.statusData = Object.assign({
|
||||
id: this.item.id,
|
||||
handle: this.item.handle,
|
||||
lastModified: this.item.lastModified
|
||||
id: item.id,
|
||||
handle: item.handle,
|
||||
lastModified: item.lastModified
|
||||
});
|
||||
this.statusDataKeys = Object.keys(this.statusData);
|
||||
|
||||
/*
|
||||
The key is used to build messages
|
||||
i18n example: 'item.edit.tabs.status.buttons.<key>.label'
|
||||
The value is supposed to be a href for the button
|
||||
*/
|
||||
this.operations = [];
|
||||
if (this.item.isWithdrawn) {
|
||||
this.operations.push(new ItemOperation('reinstate', this.getCurrentUrl() + '/reinstate'));
|
||||
if (item.isWithdrawn) {
|
||||
this.operations.push(new ItemOperation('reinstate', this.getCurrentUrl(item) + '/reinstate'));
|
||||
} else {
|
||||
this.operations.push(new ItemOperation('withdraw', this.getCurrentUrl() + '/withdraw'));
|
||||
this.operations.push(new ItemOperation('withdraw', this.getCurrentUrl(item) + '/withdraw'));
|
||||
}
|
||||
if (this.item.isDiscoverable) {
|
||||
this.operations.push(new ItemOperation('private', this.getCurrentUrl() + '/private'));
|
||||
if (item.isDiscoverable) {
|
||||
this.operations.push(new ItemOperation('private', this.getCurrentUrl(item) + '/private'));
|
||||
} else {
|
||||
this.operations.push(new ItemOperation('public', this.getCurrentUrl() + '/public'));
|
||||
this.operations.push(new ItemOperation('public', this.getCurrentUrl(item) + '/public'));
|
||||
}
|
||||
this.operations.push(new ItemOperation('delete', this.getCurrentUrl() + '/delete'));
|
||||
this.operations.push(new ItemOperation('delete', this.getCurrentUrl(item) + '/delete'));
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the url to the simple item page
|
||||
* @returns {string} url
|
||||
*/
|
||||
getItemPage(): string {
|
||||
return this.router.url.substr(0, this.router.url.lastIndexOf('/'));
|
||||
getItemPage(item: Item): string {
|
||||
return getItemPageRoute(item.id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current url without query params
|
||||
* @returns {string} url
|
||||
*/
|
||||
getCurrentUrl(): string {
|
||||
if (this.router.url.indexOf('?') > -1) {
|
||||
return this.router.url.substr(0, this.router.url.indexOf('?'));
|
||||
} else {
|
||||
return this.router.url;
|
||||
}
|
||||
getCurrentUrl(item: Item): string {
|
||||
return getItemEditPath(item.id);
|
||||
}
|
||||
|
||||
}
|
||||
|
@@ -1,6 +1,6 @@
|
||||
import {Component, Input, OnInit} from '@angular/core';
|
||||
import {Item} from '../../../core/shared/item.model';
|
||||
import {MetadataMap} from '../../../core/shared/metadata.interfaces';
|
||||
import {MetadataMap} from '../../../core/shared/metadata.models';
|
||||
|
||||
@Component({
|
||||
selector: 'ds-modify-item-overview',
|
||||
|
@@ -1,7 +1,7 @@
|
||||
import { Component, Input } from '@angular/core';
|
||||
|
||||
import { MetadataValuesComponent } from '../metadata-values/metadata-values.component';
|
||||
import { MetadataValue } from '../../../core/shared/metadata.interfaces';
|
||||
import { MetadataValue } from '../../../core/shared/metadata.models';
|
||||
|
||||
/**
|
||||
* This component renders the configured 'values' into the ds-metadata-field-wrapper component as a link.
|
||||
|
@@ -1,5 +1,5 @@
|
||||
import { Component, Input } from '@angular/core';
|
||||
import { MetadataValue } from '../../../core/shared/metadata.interfaces';
|
||||
import { MetadataValue } from '../../../core/shared/metadata.models';
|
||||
|
||||
/**
|
||||
* This component renders the configured 'values' into the ds-metadata-field-wrapper component.
|
||||
|
@@ -6,7 +6,7 @@ import { ActivatedRoute } from '@angular/router';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
import { ItemPageComponent } from '../simple/item-page.component';
|
||||
import { MetadataMap } from '../../core/shared/metadata.interfaces';
|
||||
import { MetadataMap } from '../../core/shared/metadata.models';
|
||||
import { ItemDataService } from '../../core/data/item-data.service';
|
||||
|
||||
import { RemoteData } from '../../core/data/remote-data';
|
||||
|
@@ -4,9 +4,9 @@ import { RouterModule } from '@angular/router';
|
||||
import { ItemPageComponent } from './simple/item-page.component';
|
||||
import { FullItemPageComponent } from './full/full-item-page.component';
|
||||
import { ItemPageResolver } from './item-page.resolver';
|
||||
import { AuthenticatedGuard } from '../core/auth/authenticated.guard';
|
||||
import { URLCombiner } from '../core/url-combiner/url-combiner';
|
||||
import { getItemModulePath } from '../app-routing.module';
|
||||
import { AuthenticatedGuard } from '../core/auth/authenticated.guard';
|
||||
|
||||
export function getItemPageRoute(itemId: string) {
|
||||
return new URLCombiner(getItemModulePath(), itemId).toString();
|
||||
@@ -39,7 +39,7 @@ const ITEM_EDIT_PATH = ':id/edit';
|
||||
path: ITEM_EDIT_PATH,
|
||||
loadChildren: './edit-item-page/edit-item-page.module#EditItemPageModule',
|
||||
canActivate: [AuthenticatedGuard]
|
||||
}
|
||||
},
|
||||
])
|
||||
],
|
||||
providers: [
|
||||
|
@@ -1,5 +1,5 @@
|
||||
import { autoserialize } from 'cerialize';
|
||||
import { MetadataMap } from '../core/shared/metadata.interfaces';
|
||||
import { MetadataMap } from '../core/shared/metadata.models';
|
||||
import { ListableObject } from '../shared/object-collection/shared/listable-object.model';
|
||||
|
||||
/**
|
||||
|
@@ -21,6 +21,7 @@ import { SearchService } from '../../../search-service/search.service';
|
||||
import { FILTER_CONFIG, SearchFilterService } from '../search-filter.service';
|
||||
import { SearchConfigurationService } from '../../../search-service/search-configuration.service';
|
||||
import { getSucceededRemoteData } from '../../../../core/shared/operators';
|
||||
import { InputSuggestion } from '../../../../shared/input-suggestions/input-suggestions.model';
|
||||
|
||||
@Component({
|
||||
selector: 'ds-search-facet-filter',
|
||||
@@ -59,7 +60,7 @@ export class SearchFacetFilterComponent implements OnInit, OnDestroy {
|
||||
/**
|
||||
* Emits the result values for this filter found by the current filter query
|
||||
*/
|
||||
filterSearchResults: Observable<any[]> = observableOf([]);
|
||||
filterSearchResults: Observable<InputSuggestion[]> = observableOf([]);
|
||||
|
||||
/**
|
||||
* Emits the active values for this filter
|
||||
@@ -266,7 +267,10 @@ export class SearchFacetFilterComponent implements OnInit, OnDestroy {
|
||||
map(
|
||||
(rd: RemoteData<PaginatedList<FacetValue>>) => {
|
||||
return rd.payload.page.map((facet) => {
|
||||
return { displayValue: this.getDisplayValue(facet, data), value: facet.value }
|
||||
return {
|
||||
displayValue: this.getDisplayValue(facet, data),
|
||||
value: facet.value
|
||||
}
|
||||
})
|
||||
}
|
||||
))
|
||||
|
@@ -1,5 +1,5 @@
|
||||
import { DSpaceObject } from '../core/shared/dspace-object.model';
|
||||
import { MetadataMap } from '../core/shared/metadata.interfaces';
|
||||
import { MetadataMap } from '../core/shared/metadata.models';
|
||||
import { ListableObject } from '../shared/object-collection/shared/listable-object.model';
|
||||
|
||||
/**
|
||||
|
@@ -1,4 +1,3 @@
|
||||
|
||||
import { autoserialize, autoserializeAs } from 'cerialize';
|
||||
|
||||
/**
|
||||
|
@@ -3,10 +3,10 @@ import { GenericConstructor } from '../shared/generic-constructor';
|
||||
import { NormalizedAuthStatus } from './models/normalized-auth-status.model';
|
||||
import { NormalizedEPerson } from '../eperson/models/normalized-eperson.model';
|
||||
import { NormalizedObject } from '../cache/models/normalized-object.model';
|
||||
import { EPerson } from '../eperson/models/eperson.model';
|
||||
import { CacheableObject } from '../cache/object-cache.reducer';
|
||||
|
||||
export class AuthObjectFactory {
|
||||
public static getConstructor(type): GenericConstructor<NormalizedObject> {
|
||||
public static getConstructor(type): GenericConstructor<NormalizedObject<CacheableObject>> {
|
||||
switch (type) {
|
||||
case AuthType.EPerson: {
|
||||
return NormalizedEPerson
|
||||
|
@@ -152,7 +152,7 @@ export class AuthService {
|
||||
// TODO this should be cleaned up, AuthStatus could be parsed by the RemoteDataService as a whole...
|
||||
// Review when https://jira.duraspace.org/browse/DS-4006 is fixed
|
||||
// See https://github.com/DSpace/dspace-angular/issues/292
|
||||
const person$ = this.rdbService.buildSingle<NormalizedEPerson, EPerson>(status.eperson.toString());
|
||||
const person$ = this.rdbService.buildSingle<EPerson>(status.eperson.toString());
|
||||
return person$.pipe(map((eperson) => eperson.payload));
|
||||
} else {
|
||||
throw(new Error('Not authenticated'));
|
||||
|
@@ -7,7 +7,7 @@ import { IDToUUIDSerializer } from '../../cache/id-to-uuid-serializer';
|
||||
|
||||
@mapsTo(AuthStatus)
|
||||
@inheritSerialization(NormalizedObject)
|
||||
export class NormalizedAuthStatus extends NormalizedObject {
|
||||
export class NormalizedAuthStatus extends NormalizedObject<AuthStatus> {
|
||||
@autoserialize
|
||||
id: string;
|
||||
|
||||
|
@@ -10,7 +10,6 @@ import { AuthService } from './auth.service';
|
||||
import { AuthTokenInfo } from './models/auth-token-info.model';
|
||||
import { CheckAuthenticationTokenAction } from './auth.actions';
|
||||
import { EPerson } from '../eperson/models/eperson.model';
|
||||
import { NormalizedEPerson } from '../eperson/models/normalized-eperson.model';
|
||||
|
||||
/**
|
||||
* The auth service.
|
||||
@@ -40,8 +39,10 @@ export class ServerAuthService extends AuthService {
|
||||
if (status.authenticated) {
|
||||
|
||||
// TODO this should be cleaned up, AuthStatus could be parsed by the RemoteDataService as a whole...
|
||||
const person$ = this.rdbService.buildSingle<NormalizedEPerson, EPerson>(status.eperson.toString());
|
||||
return person$.pipe(map((eperson) => eperson.payload));
|
||||
const person$ = this.rdbService.buildSingle<EPerson>(status.eperson.toString());
|
||||
return person$.pipe(
|
||||
map((eperson) => eperson.payload)
|
||||
);
|
||||
} else {
|
||||
throw(new Error('Not authenticated'));
|
||||
}
|
||||
|
@@ -35,7 +35,7 @@ export class NormalizedObjectBuildService {
|
||||
*
|
||||
* @param {TDomain} domainModel a domain model
|
||||
*/
|
||||
normalize<TDomain extends CacheableObject, TNormalized extends NormalizedObject>(domainModel: TDomain): TNormalized {
|
||||
normalize<T extends CacheableObject>(domainModel: T): NormalizedObject<T> {
|
||||
const normalizedConstructor = NormalizedObjectFactory.getConstructor(domainModel.type);
|
||||
const relationships = getRelationships(normalizedConstructor) || [];
|
||||
|
||||
|
@@ -5,15 +5,7 @@ import {
|
||||
race as observableRace
|
||||
} from 'rxjs';
|
||||
import { Injectable } from '@angular/core';
|
||||
import {
|
||||
distinctUntilChanged,
|
||||
first,
|
||||
flatMap,
|
||||
map,
|
||||
startWith,
|
||||
switchMap,
|
||||
take
|
||||
} from 'rxjs/operators';
|
||||
import { distinctUntilChanged, flatMap, map, startWith, switchMap } from 'rxjs/operators';
|
||||
import { hasValue, hasValueOperator, isEmpty, isNotEmpty } from '../../../shared/empty.util';
|
||||
import { PaginatedList } from '../../data/paginated-list';
|
||||
import { RemoteData } from '../../data/remote-data';
|
||||
@@ -29,9 +21,11 @@ import { getMapsTo, getRelationMetadata, getRelationships } from './build-decora
|
||||
import { PageInfo } from '../../shared/page-info.model';
|
||||
import {
|
||||
filterSuccessfulResponses,
|
||||
getRequestFromRequestHref, getRequestFromRequestUUID,
|
||||
getRequestFromRequestHref,
|
||||
getRequestFromRequestUUID,
|
||||
getResourceLinksFromResponse
|
||||
} from '../../shared/operators';
|
||||
import { CacheableObject } from '../object-cache.reducer';
|
||||
|
||||
@Injectable()
|
||||
export class RemoteDataBuildService {
|
||||
@@ -39,7 +33,7 @@ export class RemoteDataBuildService {
|
||||
protected requestService: RequestService) {
|
||||
}
|
||||
|
||||
buildSingle<TNormalized extends NormalizedObject, TDomain>(href$: string | Observable<string>): Observable<RemoteData<TDomain>> {
|
||||
buildSingle<T extends CacheableObject>(href$: string | Observable<string>): Observable<RemoteData<T>> {
|
||||
if (typeof href$ === 'string') {
|
||||
href$ = observableOf(href$);
|
||||
}
|
||||
@@ -56,13 +50,13 @@ export class RemoteDataBuildService {
|
||||
const payload$ =
|
||||
observableCombineLatest(
|
||||
href$.pipe(
|
||||
switchMap((href: string) => this.objectCache.getBySelfLink<TNormalized>(href)),
|
||||
switchMap((href: string) => this.objectCache.getBySelfLink<T>(href)),
|
||||
startWith(undefined)),
|
||||
requestEntry$.pipe(
|
||||
getResourceLinksFromResponse(),
|
||||
switchMap((resourceSelfLinks: string[]) => {
|
||||
if (isNotEmpty(resourceSelfLinks)) {
|
||||
return this.objectCache.getBySelfLink(resourceSelfLinks[0]);
|
||||
return this.objectCache.getBySelfLink<T>(resourceSelfLinks[0]);
|
||||
} else {
|
||||
return observableOf(undefined);
|
||||
}
|
||||
@@ -79,8 +73,8 @@ export class RemoteDataBuildService {
|
||||
}
|
||||
}),
|
||||
hasValueOperator(),
|
||||
map((normalized: TNormalized) => {
|
||||
return this.build<TNormalized, TDomain>(normalized);
|
||||
map((normalized: NormalizedObject<T>) => {
|
||||
return this.build<T>(normalized);
|
||||
}),
|
||||
startWith(undefined),
|
||||
distinctUntilChanged()
|
||||
@@ -91,8 +85,8 @@ export class RemoteDataBuildService {
|
||||
toRemoteDataObservable<T>(requestEntry$: Observable<RequestEntry>, payload$: Observable<T>) {
|
||||
return observableCombineLatest(requestEntry$, payload$).pipe(
|
||||
map(([reqEntry, payload]) => {
|
||||
const requestPending = hasValue(reqEntry) && hasValue(reqEntry.requestPending) ? reqEntry.requestPending : true;
|
||||
const responsePending = hasValue(reqEntry) && hasValue(reqEntry.responsePending) ? reqEntry.responsePending : false;
|
||||
const requestPending = hasValue(reqEntry.requestPending) ? reqEntry.requestPending : true;
|
||||
const responsePending = hasValue(reqEntry.responsePending) ? reqEntry.responsePending : false;
|
||||
let isSuccessful: boolean;
|
||||
let error: RemoteDataError;
|
||||
if (hasValue(reqEntry) && hasValue(reqEntry.response)) {
|
||||
@@ -113,7 +107,7 @@ export class RemoteDataBuildService {
|
||||
);
|
||||
}
|
||||
|
||||
buildList<TNormalized extends NormalizedObject, TDomain>(href$: string | Observable<string>): Observable<RemoteData<PaginatedList<TDomain>>> {
|
||||
buildList<T extends CacheableObject>(href$: string | Observable<string>): Observable<RemoteData<PaginatedList<T>>> {
|
||||
if (typeof href$ === 'string') {
|
||||
href$ = observableOf(href$);
|
||||
}
|
||||
@@ -123,9 +117,9 @@ export class RemoteDataBuildService {
|
||||
getResourceLinksFromResponse(),
|
||||
flatMap((resourceUUIDs: string[]) => {
|
||||
return this.objectCache.getList(resourceUUIDs).pipe(
|
||||
map((normList: TNormalized[]) => {
|
||||
return normList.map((normalized: TNormalized) => {
|
||||
return this.build<TNormalized, TDomain>(normalized);
|
||||
map((normList: Array<NormalizedObject<T>>) => {
|
||||
return normList.map((normalized: NormalizedObject<T>) => {
|
||||
return this.build<T>(normalized);
|
||||
});
|
||||
}));
|
||||
}),
|
||||
@@ -155,7 +149,7 @@ export class RemoteDataBuildService {
|
||||
return this.toRemoteDataObservable(requestEntry$, payload$);
|
||||
}
|
||||
|
||||
build<TNormalized, TDomain>(normalized: TNormalized): TDomain {
|
||||
build<T extends CacheableObject>(normalized: NormalizedObject<T>): T {
|
||||
const links: any = {};
|
||||
const relationships = getRelationships(normalized.constructor) || [];
|
||||
|
||||
|
@@ -11,7 +11,7 @@ import { SupportLevel } from './support-level.model';
|
||||
*/
|
||||
@mapsTo(BitstreamFormat)
|
||||
@inheritSerialization(NormalizedObject)
|
||||
export class NormalizedBitstreamFormat extends NormalizedObject {
|
||||
export class NormalizedBitstreamFormat extends NormalizedObject<BitstreamFormat> {
|
||||
|
||||
/**
|
||||
* Short description of this Bitstream Format
|
||||
|
@@ -10,7 +10,7 @@ import { ResourceType } from '../../shared/resource-type';
|
||||
*/
|
||||
@mapsTo(Bitstream)
|
||||
@inheritSerialization(NormalizedDSpaceObject)
|
||||
export class NormalizedBitstream extends NormalizedDSpaceObject {
|
||||
export class NormalizedBitstream extends NormalizedDSpaceObject<Bitstream> {
|
||||
|
||||
/**
|
||||
* The size of this bitstream in bytes
|
||||
|
@@ -10,7 +10,7 @@ import { ResourceType } from '../../shared/resource-type';
|
||||
*/
|
||||
@mapsTo(Bundle)
|
||||
@inheritSerialization(NormalizedDSpaceObject)
|
||||
export class NormalizedBundle extends NormalizedDSpaceObject {
|
||||
export class NormalizedBundle extends NormalizedDSpaceObject<Bundle> {
|
||||
/**
|
||||
* The primary bitstream of this Bundle
|
||||
*/
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import { autoserialize, inheritSerialization } from 'cerialize';
|
||||
import { autoserialize, deserialize, inheritSerialization } from 'cerialize';
|
||||
|
||||
import { NormalizedDSpaceObject } from './normalized-dspace-object.model';
|
||||
import { Collection } from '../../shared/collection.model';
|
||||
@@ -10,7 +10,7 @@ import { ResourceType } from '../../shared/resource-type';
|
||||
*/
|
||||
@mapsTo(Collection)
|
||||
@inheritSerialization(NormalizedDSpaceObject)
|
||||
export class NormalizedCollection extends NormalizedDSpaceObject {
|
||||
export class NormalizedCollection extends NormalizedDSpaceObject<Collection> {
|
||||
|
||||
/**
|
||||
* A string representing the unique handle of this Collection
|
||||
@@ -21,28 +21,28 @@ export class NormalizedCollection extends NormalizedDSpaceObject {
|
||||
/**
|
||||
* The Bitstream that represents the logo of this Collection
|
||||
*/
|
||||
@autoserialize
|
||||
@deserialize
|
||||
@relationship(ResourceType.Bitstream, false)
|
||||
logo: string;
|
||||
|
||||
/**
|
||||
* An array of Communities that are direct parents of this Collection
|
||||
*/
|
||||
@autoserialize
|
||||
@deserialize
|
||||
@relationship(ResourceType.Community, true)
|
||||
parents: string[];
|
||||
|
||||
/**
|
||||
* The Community that owns this Collection
|
||||
*/
|
||||
@autoserialize
|
||||
@deserialize
|
||||
@relationship(ResourceType.Community, false)
|
||||
owner: string;
|
||||
|
||||
/**
|
||||
* List of Items that are part of (not necessarily owned by) this Collection
|
||||
*/
|
||||
@autoserialize
|
||||
@deserialize
|
||||
@relationship(ResourceType.Item, true)
|
||||
items: string[];
|
||||
|
||||
|
@@ -10,7 +10,7 @@ import { ResourceType } from '../../shared/resource-type';
|
||||
*/
|
||||
@mapsTo(Community)
|
||||
@inheritSerialization(NormalizedDSpaceObject)
|
||||
export class NormalizedCommunity extends NormalizedDSpaceObject {
|
||||
export class NormalizedCommunity extends NormalizedDSpaceObject<Community> {
|
||||
|
||||
/**
|
||||
* A string representing the unique handle of this Community
|
||||
|
@@ -1,6 +1,6 @@
|
||||
import { autoserialize, autoserializeAs, deserialize, serialize } from 'cerialize';
|
||||
import { autoserializeAs, deserializeAs } from 'cerialize';
|
||||
import { DSpaceObject } from '../../shared/dspace-object.model';
|
||||
import { MetadataMap } from '../../shared/metadata.interfaces';
|
||||
import { MetadataMap, MetadataMapSerializer } from '../../shared/metadata.models';
|
||||
import { ResourceType } from '../../shared/resource-type';
|
||||
import { mapsTo } from '../builders/build-decorators';
|
||||
import { NormalizedObject } from './normalized-object.model';
|
||||
@@ -9,7 +9,7 @@ import { NormalizedObject } from './normalized-object.model';
|
||||
* An model class for a DSpaceObject.
|
||||
*/
|
||||
@mapsTo(DSpaceObject)
|
||||
export class NormalizedDSpaceObject extends NormalizedObject {
|
||||
export class NormalizedDSpaceObject<T extends DSpaceObject> extends NormalizedObject<T> {
|
||||
|
||||
/**
|
||||
* The link to the rest endpoint where this object can be found
|
||||
@@ -17,7 +17,7 @@ export class NormalizedDSpaceObject extends NormalizedObject {
|
||||
* Repeated here to make the serialization work,
|
||||
* inheritSerialization doesn't seem to work for more than one level
|
||||
*/
|
||||
@deserialize
|
||||
@deserializeAs(String)
|
||||
self: string;
|
||||
|
||||
/**
|
||||
@@ -35,31 +35,31 @@ export class NormalizedDSpaceObject extends NormalizedObject {
|
||||
* Repeated here to make the serialization work,
|
||||
* inheritSerialization doesn't seem to work for more than one level
|
||||
*/
|
||||
@autoserialize
|
||||
@autoserializeAs(String)
|
||||
uuid: string;
|
||||
|
||||
/**
|
||||
* A string representing the kind of DSpaceObject, e.g. community, item, …
|
||||
*/
|
||||
@autoserialize
|
||||
@autoserializeAs(String)
|
||||
type: ResourceType;
|
||||
|
||||
/**
|
||||
* All metadata of this DSpaceObject
|
||||
*/
|
||||
@autoserialize
|
||||
@autoserializeAs(MetadataMapSerializer)
|
||||
metadata: MetadataMap;
|
||||
|
||||
/**
|
||||
* An array of DSpaceObjects that are direct parents of this DSpaceObject
|
||||
*/
|
||||
@deserialize
|
||||
@deserializeAs(String)
|
||||
parents: string[];
|
||||
|
||||
/**
|
||||
* The DSpaceObject that owns this DSpaceObject
|
||||
*/
|
||||
@deserialize
|
||||
@deserializeAs(String)
|
||||
owner: string;
|
||||
|
||||
/**
|
||||
@@ -68,7 +68,7 @@ export class NormalizedDSpaceObject extends NormalizedObject {
|
||||
* Repeated here to make the serialization work,
|
||||
* inheritSerialization doesn't seem to work for more than one level
|
||||
*/
|
||||
@deserialize
|
||||
@deserializeAs(Object)
|
||||
_links: {
|
||||
[name: string]: string
|
||||
}
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import { inheritSerialization, autoserialize, autoserializeAs } from 'cerialize';
|
||||
import { inheritSerialization, deserialize, autoserialize, autoserializeAs } from 'cerialize';
|
||||
|
||||
import { NormalizedDSpaceObject } from './normalized-dspace-object.model';
|
||||
import { Item } from '../../shared/item.model';
|
||||
@@ -10,7 +10,7 @@ import { ResourceType } from '../../shared/resource-type';
|
||||
*/
|
||||
@mapsTo(Item)
|
||||
@inheritSerialization(NormalizedDSpaceObject)
|
||||
export class NormalizedItem extends NormalizedDSpaceObject {
|
||||
export class NormalizedItem extends NormalizedDSpaceObject<Item> {
|
||||
|
||||
/**
|
||||
* A string representing the unique handle of this Item
|
||||
@@ -21,7 +21,7 @@ export class NormalizedItem extends NormalizedDSpaceObject {
|
||||
/**
|
||||
* The Date of the last modification of this Item
|
||||
*/
|
||||
@autoserialize
|
||||
@deserialize
|
||||
lastModified: Date;
|
||||
|
||||
/**
|
||||
@@ -45,21 +45,21 @@ export class NormalizedItem extends NormalizedDSpaceObject {
|
||||
/**
|
||||
* An array of Collections that are direct parents of this Item
|
||||
*/
|
||||
@autoserialize
|
||||
@deserialize
|
||||
@relationship(ResourceType.Collection, true)
|
||||
parents: string[];
|
||||
|
||||
/**
|
||||
* The Collection that owns this Item
|
||||
*/
|
||||
@autoserialize
|
||||
@deserialize
|
||||
@relationship(ResourceType.Collection, false)
|
||||
owningCollection: string;
|
||||
|
||||
/**
|
||||
* List of Bitstreams that are owned by this Item
|
||||
*/
|
||||
@autoserialize
|
||||
@deserialize
|
||||
@relationship(ResourceType.Bitstream, true)
|
||||
bitstreams: string[];
|
||||
|
||||
|
@@ -11,9 +11,10 @@ import { NormalizedResourcePolicy } from './normalized-resource-policy.model';
|
||||
import { NormalizedEPerson } from '../../eperson/models/normalized-eperson.model';
|
||||
import { NormalizedGroup } from '../../eperson/models/normalized-group.model';
|
||||
import { NormalizedMetadataSchema } from '../../metadata/normalized-metadata-schema.model';
|
||||
import { CacheableObject } from '../object-cache.reducer';
|
||||
|
||||
export class NormalizedObjectFactory {
|
||||
public static getConstructor(type: ResourceType): GenericConstructor<NormalizedObject> {
|
||||
public static getConstructor(type: ResourceType): GenericConstructor<NormalizedObject<CacheableObject>> {
|
||||
switch (type) {
|
||||
case ResourceType.Bitstream: {
|
||||
return NormalizedBitstream
|
||||
|
@@ -4,7 +4,7 @@ import { ResourceType } from '../../shared/resource-type';
|
||||
/**
|
||||
* An abstract model class for a NormalizedObject.
|
||||
*/
|
||||
export abstract class NormalizedObject implements CacheableObject {
|
||||
export abstract class NormalizedObject<T extends CacheableObject> implements CacheableObject {
|
||||
|
||||
/**
|
||||
* The link to the rest endpoint where this object can be found
|
||||
|
@@ -12,7 +12,7 @@ import { ActionType } from './action-type.model';
|
||||
*/
|
||||
@mapsTo(ResourcePolicy)
|
||||
@inheritSerialization(NormalizedObject)
|
||||
export class NormalizedResourcePolicy extends NormalizedObject {
|
||||
export class NormalizedResourcePolicy extends NormalizedObject<ResourcePolicy> {
|
||||
|
||||
/**
|
||||
* The action that is allowed by this Resource Policy
|
||||
|
10
src/app/core/cache/object-cache.service.ts
vendored
10
src/app/core/cache/object-cache.service.ts
vendored
@@ -78,7 +78,7 @@ export class ObjectCacheService {
|
||||
* @return Observable<T>
|
||||
* An observable of the requested object
|
||||
*/
|
||||
getByUUID<T extends NormalizedObject>(uuid: string): Observable<T> {
|
||||
getByUUID<T extends CacheableObject>(uuid: string): Observable<NormalizedObject<T>> {
|
||||
return this.store.pipe(
|
||||
select(selfLinkFromUuidSelector(uuid)),
|
||||
mergeMap((selfLink: string) => this.getBySelfLink(selfLink)
|
||||
@@ -86,7 +86,7 @@ export class ObjectCacheService {
|
||||
)
|
||||
}
|
||||
|
||||
getBySelfLink<T extends NormalizedObject>(selfLink: string): Observable<T> {
|
||||
getBySelfLink<T extends CacheableObject>(selfLink: string): Observable<NormalizedObject<T>> {
|
||||
return this.getEntry(selfLink).pipe(
|
||||
map((entry: ObjectCacheEntry) => {
|
||||
if (isNotEmpty(entry.patches)) {
|
||||
@@ -99,8 +99,8 @@ export class ObjectCacheService {
|
||||
}
|
||||
),
|
||||
map((entry: ObjectCacheEntry) => {
|
||||
const type: GenericConstructor<NormalizedObject> = NormalizedObjectFactory.getConstructor(entry.data.type);
|
||||
return Object.assign(new type(), entry.data) as T
|
||||
const type: GenericConstructor<NormalizedObject<T>> = NormalizedObjectFactory.getConstructor(entry.data.type);
|
||||
return Object.assign(new type(), entry.data) as NormalizedObject<T>
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -145,7 +145,7 @@ export class ObjectCacheService {
|
||||
* The type of the objects to get
|
||||
* @return Observable<Array<T>>
|
||||
*/
|
||||
getList<T extends NormalizedObject>(selfLinks: string[]): Observable<T[]> {
|
||||
getList<T extends CacheableObject>(selfLinks: string[]): Observable<Array<NormalizedObject<T>>> {
|
||||
return observableCombineLatest(
|
||||
selfLinks.map((selfLink: string) => this.getBySelfLink<T>(selfLink))
|
||||
);
|
||||
|
@@ -26,7 +26,6 @@ export interface ServerSyncBufferState {
|
||||
buffer: ServerSyncBufferEntry[];
|
||||
}
|
||||
|
||||
// Object.create(null) ensures the object has no default js properties (e.g. `__proto__`)
|
||||
const initialState: ServerSyncBufferState = { buffer: [] };
|
||||
|
||||
/**
|
||||
|
@@ -4,11 +4,13 @@ import { UUIDIndexEffects } from './index/index.effects';
|
||||
import { RequestEffects } from './data/request.effects';
|
||||
import { AuthEffects } from './auth/auth.effects';
|
||||
import { ServerSyncBufferEffects } from './cache/server-sync-buffer.effects';
|
||||
import { ObjectUpdatesEffects } from './data/object-updates/object-updates.effects';
|
||||
|
||||
export const coreEffects = [
|
||||
RequestEffects,
|
||||
ObjectCacheEffects,
|
||||
UUIDIndexEffects,
|
||||
AuthEffects,
|
||||
ServerSyncBufferEffects
|
||||
ServerSyncBufferEffects,
|
||||
ObjectUpdatesEffects
|
||||
];
|
||||
|
@@ -67,6 +67,8 @@ import { CSSVariableService } from '../shared/sass-helper/sass-helper.service';
|
||||
import { MenuService } from '../shared/menu/menu.service';
|
||||
import { NormalizedObjectBuildService } from './cache/builders/normalized-object-build.service';
|
||||
import { DSOChangeAnalyzer } from './data/dso-change-analyzer.service';
|
||||
import { ObjectUpdatesService } from './data/object-updates/object-updates.service';
|
||||
import { DefaultChangeAnalyzer } from './data/default-change-analyzer.service';
|
||||
|
||||
const IMPORTS = [
|
||||
CommonModule,
|
||||
@@ -132,8 +134,10 @@ const PROVIDERS = [
|
||||
UUIDService,
|
||||
DSpaceObjectDataService,
|
||||
DSOChangeAnalyzer,
|
||||
DefaultChangeAnalyzer,
|
||||
CSSVariableService,
|
||||
MenuService,
|
||||
ObjectUpdatesService,
|
||||
// register AuthInterceptor as HttpInterceptor
|
||||
{
|
||||
provide: HTTP_INTERCEPTORS,
|
||||
|
@@ -1,14 +1,22 @@
|
||||
import { ActionReducerMap, createFeatureSelector } from '@ngrx/store';
|
||||
import {
|
||||
ActionReducerMap,
|
||||
createFeatureSelector,
|
||||
} from '@ngrx/store';
|
||||
|
||||
import { objectCacheReducer, ObjectCacheState } from './cache/object-cache.reducer';
|
||||
import { indexReducer, IndexState } from './index/index.reducer';
|
||||
import { requestReducer, RequestState } from './data/request.reducer';
|
||||
import { authReducer, AuthState } from './auth/auth.reducer';
|
||||
import { serverSyncBufferReducer, ServerSyncBufferState } from './cache/server-sync-buffer.reducer';
|
||||
import {
|
||||
objectUpdatesReducer,
|
||||
ObjectUpdatesState
|
||||
} from './data/object-updates/object-updates.reducer';
|
||||
|
||||
export interface CoreState {
|
||||
'cache/object': ObjectCacheState,
|
||||
'cache/syncbuffer': ServerSyncBufferState,
|
||||
'cache/object-updates': ObjectUpdatesState
|
||||
'data/request': RequestState,
|
||||
'index': IndexState,
|
||||
'auth': AuthState,
|
||||
@@ -17,9 +25,10 @@ export interface CoreState {
|
||||
export const coreReducers: ActionReducerMap<CoreState> = {
|
||||
'cache/object': objectCacheReducer,
|
||||
'cache/syncbuffer': serverSyncBufferReducer,
|
||||
'cache/object-updates': objectUpdatesReducer,
|
||||
'data/request': requestReducer,
|
||||
'index': indexReducer,
|
||||
'auth': authReducer
|
||||
'auth': authReducer,
|
||||
};
|
||||
|
||||
export const coreSelector = createFeatureSelector<CoreState>('core');
|
||||
|
@@ -1,11 +1,12 @@
|
||||
import { NormalizedObject } from '../cache/models/normalized-object.model';
|
||||
import { Operation } from 'fast-json-patch/lib/core';
|
||||
import { CacheableObject } from '../cache/object-cache.reducer';
|
||||
|
||||
/**
|
||||
* An interface to determine what differs between two
|
||||
* NormalizedObjects
|
||||
*/
|
||||
export interface ChangeAnalyzer<TNormalized extends NormalizedObject> {
|
||||
export interface ChangeAnalyzer<T extends CacheableObject> {
|
||||
|
||||
/**
|
||||
* Compare two objects and return their differences as a
|
||||
@@ -16,5 +17,5 @@ export interface ChangeAnalyzer<TNormalized extends NormalizedObject> {
|
||||
* @param {NormalizedObject} object2
|
||||
* The second object to compare
|
||||
*/
|
||||
diff(object1: TNormalized, object2: TNormalized): Operation[];
|
||||
diff(object1: T | NormalizedObject<T>, object2: T | NormalizedObject<T>): Operation[];
|
||||
}
|
||||
|
@@ -15,7 +15,7 @@ import { NormalizedObjectBuildService } from '../cache/builders/normalized-objec
|
||||
import { DSOChangeAnalyzer } from './dso-change-analyzer.service';
|
||||
|
||||
@Injectable()
|
||||
export class CollectionDataService extends ComColDataService<NormalizedCollection, Collection> {
|
||||
export class CollectionDataService extends ComColDataService<Collection> {
|
||||
protected linkPath = 'collections';
|
||||
|
||||
constructor(
|
||||
@@ -28,7 +28,7 @@ export class CollectionDataService extends ComColDataService<NormalizedCollectio
|
||||
protected halService: HALEndpointService,
|
||||
protected notificationsService: NotificationsService,
|
||||
protected http: HttpClient,
|
||||
protected comparator: DSOChangeAnalyzer
|
||||
protected comparator: DSOChangeAnalyzer<Collection>
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
@@ -18,14 +18,16 @@ import { NotificationsService } from '../../shared/notifications/notifications.s
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service';
|
||||
import { DSOChangeAnalyzer } from './dso-change-analyzer.service';
|
||||
import { Item } from '../shared/item.model';
|
||||
import { Community } from '../shared/community.model';
|
||||
|
||||
const LINK_NAME = 'test';
|
||||
|
||||
/* tslint:disable:max-classes-per-file */
|
||||
class NormalizedTestObject extends NormalizedObject {
|
||||
class NormalizedTestObject extends NormalizedObject<Item> {
|
||||
}
|
||||
|
||||
class TestService extends ComColDataService<NormalizedTestObject, any> {
|
||||
class TestService extends ComColDataService<any> {
|
||||
|
||||
constructor(
|
||||
protected requestService: RequestService,
|
||||
@@ -38,7 +40,7 @@ class TestService extends ComColDataService<NormalizedTestObject, any> {
|
||||
protected halService: HALEndpointService,
|
||||
protected notificationsService: NotificationsService,
|
||||
protected http: HttpClient,
|
||||
protected comparator: DSOChangeAnalyzer,
|
||||
protected comparator: DSOChangeAnalyzer<Community>,
|
||||
protected linkPath: string
|
||||
) {
|
||||
super();
|
||||
|
@@ -1,28 +1,17 @@
|
||||
import {
|
||||
distinctUntilChanged,
|
||||
filter,
|
||||
first,
|
||||
map,
|
||||
mergeMap,
|
||||
share,
|
||||
take,
|
||||
tap
|
||||
} from 'rxjs/operators';
|
||||
import { distinctUntilChanged, filter, map, mergeMap, share, take, tap } from 'rxjs/operators';
|
||||
import { merge as observableMerge, Observable, throwError as observableThrowError } from 'rxjs';
|
||||
import { hasValue, isEmpty, isNotEmpty } from '../../shared/empty.util';
|
||||
import { isEmpty, isNotEmpty } from '../../shared/empty.util';
|
||||
import { NormalizedCommunity } from '../cache/models/normalized-community.model';
|
||||
import { ObjectCacheService } from '../cache/object-cache.service';
|
||||
import { CommunityDataService } from './community-data.service';
|
||||
|
||||
import { DataService } from './data.service';
|
||||
import { FindAllOptions, FindByIDRequest } from './request.models';
|
||||
import { NormalizedObject } from '../cache/models/normalized-object.model';
|
||||
import { HALEndpointService } from '../shared/hal-endpoint.service';
|
||||
import { RequestEntry } from './request.reducer';
|
||||
import { getResponseFromEntry } from '../shared/operators';
|
||||
import { CacheableObject } from '../cache/object-cache.reducer';
|
||||
|
||||
export abstract class ComColDataService<TNormalized extends NormalizedObject, TDomain extends CacheableObject> extends DataService<TNormalized, TDomain> {
|
||||
export abstract class ComColDataService<T extends CacheableObject> extends DataService<T> {
|
||||
protected abstract cds: CommunityDataService;
|
||||
protected abstract objectCache: ObjectCacheService;
|
||||
protected abstract halService: HALEndpointService;
|
||||
|
@@ -21,7 +21,7 @@ import { NormalizedObjectBuildService } from '../cache/builders/normalized-objec
|
||||
import { DSOChangeAnalyzer } from './dso-change-analyzer.service';
|
||||
|
||||
@Injectable()
|
||||
export class CommunityDataService extends ComColDataService<NormalizedCommunity, Community> {
|
||||
export class CommunityDataService extends ComColDataService<Community> {
|
||||
protected linkPath = 'communities';
|
||||
protected topLinkPath = 'communities/search/top';
|
||||
protected cds = this;
|
||||
@@ -35,7 +35,7 @@ export class CommunityDataService extends ComColDataService<NormalizedCommunity,
|
||||
protected halService: HALEndpointService,
|
||||
protected notificationsService: NotificationsService,
|
||||
protected http: HttpClient,
|
||||
protected comparator: DSOChangeAnalyzer
|
||||
protected comparator: DSOChangeAnalyzer<Community>
|
||||
) {
|
||||
super();
|
||||
}
|
||||
@@ -54,6 +54,6 @@ export class CommunityDataService extends ComColDataService<NormalizedCommunity,
|
||||
this.requestService.configure(request);
|
||||
});
|
||||
|
||||
return this.rdbService.buildList<NormalizedCommunity, Community>(hrefObs) as Observable<RemoteData<PaginatedList<Community>>>;
|
||||
return this.rdbService.buildList<Community>(hrefObs) as Observable<RemoteData<PaginatedList<Community>>>;
|
||||
}
|
||||
}
|
||||
|
@@ -16,14 +16,15 @@ import { HttpClient } from '@angular/common/http';
|
||||
import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service';
|
||||
import { NotificationsService } from '../../shared/notifications/notifications.service';
|
||||
import { compare } from 'fast-json-patch';
|
||||
import { Item } from '../shared/item.model';
|
||||
|
||||
const endpoint = 'https://rest.api/core';
|
||||
|
||||
// tslint:disable:max-classes-per-file
|
||||
class NormalizedTestObject extends NormalizedObject {
|
||||
class NormalizedTestObject extends NormalizedObject<Item> {
|
||||
}
|
||||
|
||||
class TestService extends DataService<NormalizedTestObject, any> {
|
||||
class TestService extends DataService<any> {
|
||||
constructor(
|
||||
protected requestService: RequestService,
|
||||
protected rdbService: RemoteDataBuildService,
|
||||
|
@@ -41,8 +41,9 @@ import { CacheableObject } from '../cache/object-cache.reducer';
|
||||
import { RequestEntry } from './request.reducer';
|
||||
import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service';
|
||||
import { ChangeAnalyzer } from './change-analyzer';
|
||||
import { RestRequestMethod } from './rest-request-method';
|
||||
|
||||
export abstract class DataService<TNormalized extends NormalizedObject, TDomain extends CacheableObject> {
|
||||
export abstract class DataService<T extends CacheableObject> {
|
||||
protected abstract requestService: RequestService;
|
||||
protected abstract rdbService: RemoteDataBuildService;
|
||||
protected abstract dataBuildService: NormalizedObjectBuildService;
|
||||
@@ -52,7 +53,7 @@ export abstract class DataService<TNormalized extends NormalizedObject, TDomain
|
||||
protected abstract objectCache: ObjectCacheService;
|
||||
protected abstract notificationsService: NotificationsService;
|
||||
protected abstract http: HttpClient;
|
||||
protected abstract comparator: ChangeAnalyzer<TNormalized>;
|
||||
protected abstract comparator: ChangeAnalyzer<T>;
|
||||
|
||||
public abstract getBrowseEndpoint(options: FindAllOptions, linkPath?: string): Observable<string>
|
||||
|
||||
@@ -81,7 +82,7 @@ export abstract class DataService<TNormalized extends NormalizedObject, TDomain
|
||||
}
|
||||
}
|
||||
|
||||
findAll(options: FindAllOptions = {}): Observable<RemoteData<PaginatedList<TDomain>>> {
|
||||
findAll(options: FindAllOptions = {}): Observable<RemoteData<PaginatedList<T>>> {
|
||||
const hrefObs = this.getFindAllHref(options);
|
||||
|
||||
hrefObs.pipe(
|
||||
@@ -92,7 +93,7 @@ export abstract class DataService<TNormalized extends NormalizedObject, TDomain
|
||||
this.requestService.configure(request);
|
||||
});
|
||||
|
||||
return this.rdbService.buildList<TNormalized, TDomain>(hrefObs) as Observable<RemoteData<PaginatedList<TDomain>>>;
|
||||
return this.rdbService.buildList<T>(hrefObs) as Observable<RemoteData<PaginatedList<T>>>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -104,7 +105,7 @@ export abstract class DataService<TNormalized extends NormalizedObject, TDomain
|
||||
return `${endpoint}/${resourceID}`;
|
||||
}
|
||||
|
||||
findById(id: string): Observable<RemoteData<TDomain>> {
|
||||
findById(id: string): Observable<RemoteData<T>> {
|
||||
const hrefObs = this.halService.getEndpoint(this.linkPath).pipe(
|
||||
map((endpoint: string) => this.getIDHref(endpoint, id)));
|
||||
|
||||
@@ -115,12 +116,12 @@ export abstract class DataService<TNormalized extends NormalizedObject, TDomain
|
||||
this.requestService.configure(request);
|
||||
});
|
||||
|
||||
return this.rdbService.buildSingle<TNormalized, TDomain>(hrefObs);
|
||||
return this.rdbService.buildSingle<T>(hrefObs);
|
||||
}
|
||||
|
||||
findByHref(href: string): Observable<RemoteData<TDomain>> {
|
||||
findByHref(href: string): Observable<RemoteData<T>> {
|
||||
this.requestService.configure(new GetRequest(this.requestService.generateRequestId(), href));
|
||||
return this.rdbService.buildSingle<TNormalized, TDomain>(href);
|
||||
return this.rdbService.buildSingle<T>(href);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -137,11 +138,10 @@ export abstract class DataService<TNormalized extends NormalizedObject, TDomain
|
||||
* The patch is derived from the differences between the given object and its version in the object cache
|
||||
* @param {DSpaceObject} object The given object
|
||||
*/
|
||||
update(object: TDomain): Observable<RemoteData<TDomain>> {
|
||||
update(object: T): Observable<RemoteData<T>> {
|
||||
const oldVersion$ = this.objectCache.getBySelfLink(object.self);
|
||||
return oldVersion$.pipe(first(), mergeMap((oldVersion: TNormalized) => {
|
||||
const newVersion = this.dataBuildService.normalize<TDomain, TNormalized>(object);
|
||||
const operations = this.comparator.diff(oldVersion, newVersion);
|
||||
return oldVersion$.pipe(take(1), mergeMap((oldVersion: T) => {
|
||||
const operations = this.comparator.diff(oldVersion, object);
|
||||
if (isNotEmpty(operations)) {
|
||||
this.objectCache.addPatch(object.self, operations);
|
||||
}
|
||||
@@ -160,7 +160,7 @@ export abstract class DataService<TNormalized extends NormalizedObject, TDomain
|
||||
* @param {string} parentUUID
|
||||
* The UUID of the parent to create the new object under
|
||||
*/
|
||||
create(dso: TDomain, parentUUID: string): Observable<RemoteData<TDomain>> {
|
||||
create(dso: T, parentUUID: string): Observable<RemoteData<T>> {
|
||||
const requestId = this.requestService.generateRequestId();
|
||||
const endpoint$ = this.halService.getEndpoint(this.linkPath).pipe(
|
||||
isNotEmptyOperator(),
|
||||
@@ -168,7 +168,7 @@ export abstract class DataService<TNormalized extends NormalizedObject, TDomain
|
||||
map((endpoint: string) => parentUUID ? `${endpoint}?parent=${parentUUID}` : endpoint)
|
||||
);
|
||||
|
||||
const normalizedObject: TNormalized = this.dataBuildService.normalize<TDomain, TNormalized>(dso);
|
||||
const normalizedObject: NormalizedObject<T> = this.dataBuildService.normalize<T>(dso);
|
||||
const serializedDso = new DSpaceRESTv2Serializer(NormalizedObjectFactory.getConstructor(dso.type)).serialize(normalizedObject);
|
||||
|
||||
const request$ = endpoint$.pipe(
|
||||
@@ -209,7 +209,7 @@ export abstract class DataService<TNormalized extends NormalizedObject, TDomain
|
||||
* @param dso The DSpace Object to be removed
|
||||
* Return an observable that emits true when the deletion was successful, false when it failed
|
||||
*/
|
||||
delete(dso: TDomain): Observable<boolean> {
|
||||
delete(dso: T): Observable<boolean> {
|
||||
const requestId = this.requestService.generateRequestId();
|
||||
|
||||
const hrefObs = this.halService.getEndpoint(this.linkPath).pipe(
|
||||
@@ -229,4 +229,12 @@ export abstract class DataService<TNormalized extends NormalizedObject, TDomain
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Commit current object changes to the server
|
||||
* @param method The RestRequestMethod for which de server sync buffer should be committed
|
||||
*/
|
||||
commitUpdates(method?: RestRequestMethod) {
|
||||
this.requestService.commit(method);
|
||||
}
|
||||
|
||||
}
|
||||
|
29
src/app/core/data/default-change-analyzer.service.ts
Normal file
29
src/app/core/data/default-change-analyzer.service.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Operation } from 'fast-json-patch/lib/core';
|
||||
import { compare } from 'fast-json-patch';
|
||||
import { ChangeAnalyzer } from './change-analyzer';
|
||||
import { NormalizedDSpaceObject } from '../cache/models/normalized-dspace-object.model';
|
||||
import { Injectable } from '@angular/core';
|
||||
import { DSpaceObject } from '../shared/dspace-object.model';
|
||||
import { CacheableObject } from '../cache/object-cache.reducer';
|
||||
import { NormalizedObject } from '../cache/models/normalized-object.model';
|
||||
|
||||
/**
|
||||
* A class to determine what differs between two
|
||||
* CacheableObjects
|
||||
*/
|
||||
@Injectable()
|
||||
export class DefaultChangeAnalyzer<T extends CacheableObject> implements ChangeAnalyzer<T> {
|
||||
|
||||
/**
|
||||
* Compare the metadata of two CacheableObject and return the differences as
|
||||
* a JsonPatch Operation Array
|
||||
*
|
||||
* @param {NormalizedObject} object1
|
||||
* The first object to compare
|
||||
* @param {NormalizedObject} object2
|
||||
* The second object to compare
|
||||
*/
|
||||
diff(object1: T | NormalizedObject<T>, object2: T | NormalizedObject<T>): Operation[] {
|
||||
return compare(object1, object2);
|
||||
}
|
||||
}
|
@@ -3,13 +3,14 @@ import { compare } from 'fast-json-patch';
|
||||
import { ChangeAnalyzer } from './change-analyzer';
|
||||
import { NormalizedDSpaceObject } from '../cache/models/normalized-dspace-object.model';
|
||||
import { Injectable } from '@angular/core';
|
||||
import { DSpaceObject } from '../shared/dspace-object.model';
|
||||
|
||||
/**
|
||||
* A class to determine what differs between two
|
||||
* DSpaceObjects
|
||||
*/
|
||||
@Injectable()
|
||||
export class DSOChangeAnalyzer implements ChangeAnalyzer<NormalizedDSpaceObject> {
|
||||
export class DSOChangeAnalyzer<T extends DSpaceObject> implements ChangeAnalyzer<T> {
|
||||
|
||||
/**
|
||||
* Compare the metadata of two DSpaceObjects and return the differences as
|
||||
@@ -20,7 +21,7 @@ export class DSOChangeAnalyzer implements ChangeAnalyzer<NormalizedDSpaceObject>
|
||||
* @param {NormalizedDSpaceObject} object2
|
||||
* The second object to compare
|
||||
*/
|
||||
diff(object1: NormalizedDSpaceObject, object2: NormalizedDSpaceObject): Operation[] {
|
||||
diff(object1: T | NormalizedDSpaceObject<T>, object2: T | NormalizedDSpaceObject<T>): Operation[] {
|
||||
return compare(object1.metadata, object2.metadata).map((operation: Operation) => Object.assign({}, operation, { path: '/metadata' + operation.path }));
|
||||
}
|
||||
}
|
||||
|
@@ -13,6 +13,7 @@ import { RestRequest } from './request.models';
|
||||
import { ResponseParsingService } from './parsing.service';
|
||||
import { BaseResponseParsingService } from './base-response-parsing.service';
|
||||
import { hasNoValue, hasValue } from '../../shared/empty.util';
|
||||
import { DSpaceObject } from '../shared/dspace-object.model';
|
||||
|
||||
@Injectable()
|
||||
export class DSOResponseParsingService extends BaseResponseParsingService implements ResponseParsingService {
|
||||
@@ -33,7 +34,7 @@ export class DSOResponseParsingService extends BaseResponseParsingService implem
|
||||
if (hasValue(data.payload) && hasValue(data.payload.page) && data.payload.page.totalElements === 0) {
|
||||
processRequestDTO = { page: [] };
|
||||
} else {
|
||||
processRequestDTO = this.process<NormalizedObject, ResourceType>(data.payload, request.uuid);
|
||||
processRequestDTO = this.process<NormalizedObject<DSpaceObject>, ResourceType>(data.payload, request.uuid);
|
||||
}
|
||||
let objectList = processRequestDTO;
|
||||
|
||||
|
@@ -2,7 +2,6 @@ import { Injectable } from '@angular/core';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { Observable } from 'rxjs';
|
||||
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
|
||||
import { NormalizedDSpaceObject } from '../cache/models/normalized-dspace-object.model';
|
||||
import { CoreState } from '../core.reducers';
|
||||
import { DSpaceObject } from '../shared/dspace-object.model';
|
||||
import { HALEndpointService } from '../shared/hal-endpoint.service';
|
||||
@@ -17,7 +16,7 @@ import { NormalizedObjectBuildService } from '../cache/builders/normalized-objec
|
||||
import { DSOChangeAnalyzer } from './dso-change-analyzer.service';
|
||||
|
||||
/* tslint:disable:max-classes-per-file */
|
||||
class DataServiceImpl extends DataService<NormalizedDSpaceObject, DSpaceObject> {
|
||||
class DataServiceImpl extends DataService<DSpaceObject> {
|
||||
protected linkPath = 'dso';
|
||||
|
||||
constructor(
|
||||
@@ -29,7 +28,7 @@ class DataServiceImpl extends DataService<NormalizedDSpaceObject, DSpaceObject>
|
||||
protected halService: HALEndpointService,
|
||||
protected notificationsService: NotificationsService,
|
||||
protected http: HttpClient,
|
||||
protected comparator: DSOChangeAnalyzer) {
|
||||
protected comparator: DSOChangeAnalyzer<DSpaceObject>) {
|
||||
super();
|
||||
}
|
||||
|
||||
@@ -55,7 +54,7 @@ export class DSpaceObjectDataService {
|
||||
protected halService: HALEndpointService,
|
||||
protected notificationsService: NotificationsService,
|
||||
protected http: HttpClient,
|
||||
protected comparator: DSOChangeAnalyzer) {
|
||||
protected comparator: DSOChangeAnalyzer<DSpaceObject>) {
|
||||
this.dataService = new DataServiceImpl(requestService, rdbService, dataBuildService, null, objectCache, halService, notificationsService, http, comparator);
|
||||
}
|
||||
|
||||
|
@@ -1,11 +1,10 @@
|
||||
import {distinctUntilChanged, map, filter} from 'rxjs/operators';
|
||||
import { distinctUntilChanged, filter, map } from 'rxjs/operators';
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { Observable } from 'rxjs';
|
||||
import { isNotEmpty } from '../../shared/empty.util';
|
||||
import { BrowseService } from '../browse/browse.service';
|
||||
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
|
||||
import { NormalizedItem } from '../cache/models/normalized-item.model';
|
||||
import { CoreState } from '../core.reducers';
|
||||
import { Item } from '../shared/item.model';
|
||||
import { URLCombiner } from '../url-combiner/url-combiner';
|
||||
@@ -13,16 +12,17 @@ import { URLCombiner } from '../url-combiner/url-combiner';
|
||||
import { DataService } from './data.service';
|
||||
import { RequestService } from './request.service';
|
||||
import { HALEndpointService } from '../shared/hal-endpoint.service';
|
||||
import { DeleteRequest, FindAllOptions, PatchRequest, RestRequest } from './request.models';
|
||||
import { FindAllOptions, PatchRequest, RestRequest } from './request.models';
|
||||
import { ObjectCacheService } from '../cache/object-cache.service';
|
||||
import { configureRequest, getResponseFromEntry } from '../shared/operators';
|
||||
import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service';
|
||||
import { NotificationsService } from '../../shared/notifications/notifications.service';
|
||||
import { DSOChangeAnalyzer } from './dso-change-analyzer.service';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service';
|
||||
import { configureRequest, getRequestFromRequestHref } from '../shared/operators';
|
||||
import { RequestEntry } from './request.reducer';
|
||||
|
||||
@Injectable()
|
||||
export class ItemDataService extends DataService<NormalizedItem, Item> {
|
||||
export class ItemDataService extends DataService<Item> {
|
||||
protected linkPath = 'items';
|
||||
|
||||
constructor(
|
||||
@@ -35,7 +35,7 @@ export class ItemDataService extends DataService<NormalizedItem, Item> {
|
||||
protected halService: HALEndpointService,
|
||||
protected notificationsService: NotificationsService,
|
||||
protected http: HttpClient,
|
||||
protected comparator: DSOChangeAnalyzer) {
|
||||
protected comparator: DSOChangeAnalyzer<Item>) {
|
||||
super();
|
||||
}
|
||||
|
||||
@@ -91,7 +91,9 @@ export class ItemDataService extends DataService<NormalizedItem, Item> {
|
||||
new PatchRequest(this.requestService.generateRequestId(), endpointURL, patchOperation)
|
||||
),
|
||||
configureRequest(this.requestService),
|
||||
getResponseFromEntry()
|
||||
map((request: RestRequest) => request.href),
|
||||
getRequestFromRequestHref(this.requestService),
|
||||
map((requestEntry: RequestEntry) => requestEntry.response)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -110,7 +112,9 @@ export class ItemDataService extends DataService<NormalizedItem, Item> {
|
||||
new PatchRequest(this.requestService.generateRequestId(), endpointURL, patchOperation)
|
||||
),
|
||||
configureRequest(this.requestService),
|
||||
getResponseFromEntry()
|
||||
map((request: RestRequest) => request.href),
|
||||
getRequestFromRequestHref(this.requestService),
|
||||
map((requestEntry: RequestEntry) => requestEntry.response)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@@ -8,20 +8,19 @@ import { CoreState } from '../core.reducers';
|
||||
import { DataService } from './data.service';
|
||||
import { RequestService } from './request.service';
|
||||
import { HALEndpointService } from '../shared/hal-endpoint.service';
|
||||
import { FindAllOptions, GetRequest, RestRequest } from './request.models';
|
||||
import { FindAllOptions } from './request.models';
|
||||
import { ObjectCacheService } from '../cache/object-cache.service';
|
||||
import { MetadataSchema } from '../metadata/metadataschema.model';
|
||||
import { NormalizedMetadataSchema } from '../metadata/normalized-metadata-schema.model';
|
||||
import { NotificationsService } from '../../shared/notifications/notifications.service';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { ChangeAnalyzer } from './change-analyzer';
|
||||
import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { NotificationsService } from '../../shared/notifications/notifications.service';
|
||||
import { DefaultChangeAnalyzer } from './default-change-analyzer.service';
|
||||
|
||||
/**
|
||||
* A service responsible for fetching/sending data from/to the REST API on the metadataschemas endpoint
|
||||
*/
|
||||
@Injectable()
|
||||
export class MetadataSchemaDataService extends DataService<NormalizedMetadataSchema, MetadataSchema> {
|
||||
export class MetadataSchemaDataService extends DataService<MetadataSchema> {
|
||||
protected linkPath = 'metadataschemas';
|
||||
|
||||
constructor(
|
||||
@@ -31,10 +30,10 @@ export class MetadataSchemaDataService extends DataService<NormalizedMetadataSch
|
||||
private bs: BrowseService,
|
||||
protected halService: HALEndpointService,
|
||||
protected objectCache: ObjectCacheService,
|
||||
protected comparator: DefaultChangeAnalyzer<MetadataSchema>,
|
||||
protected dataBuildService: NormalizedObjectBuildService,
|
||||
protected notificationsService: NotificationsService,
|
||||
protected http: HttpClient,
|
||||
protected comparator: ChangeAnalyzer<NormalizedMetadataSchema>) {
|
||||
protected notificationsService: NotificationsService) {
|
||||
super();
|
||||
}
|
||||
|
||||
|
245
src/app/core/data/object-updates/object-updates.actions.ts
Normal file
245
src/app/core/data/object-updates/object-updates.actions.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
import { type } from '../../../shared/ngrx/type';
|
||||
import { Action } from '@ngrx/store';
|
||||
import { Identifiable } from './object-updates.reducer';
|
||||
import { INotification } from '../../../shared/notifications/models/notification.model';
|
||||
|
||||
/**
|
||||
* The list of ObjectUpdatesAction type definitions
|
||||
*/
|
||||
export const ObjectUpdatesActionTypes = {
|
||||
INITIALIZE_FIELDS: type('dspace/core/cache/object-updates/INITIALIZE_FIELDS'),
|
||||
SET_EDITABLE_FIELD: type('dspace/core/cache/object-updates/SET_EDITABLE_FIELD'),
|
||||
SET_VALID_FIELD: type('dspace/core/cache/object-updates/SET_VALID_FIELD'),
|
||||
ADD_FIELD: type('dspace/core/cache/object-updates/ADD_FIELD'),
|
||||
DISCARD: type('dspace/core/cache/object-updates/DISCARD'),
|
||||
REINSTATE: type('dspace/core/cache/object-updates/REINSTATE'),
|
||||
REMOVE: type('dspace/core/cache/object-updates/REMOVE'),
|
||||
REMOVE_FIELD: type('dspace/core/cache/object-updates/REMOVE_FIELD'),
|
||||
};
|
||||
|
||||
/* tslint:disable:max-classes-per-file */
|
||||
|
||||
/**
|
||||
* Enum that represents the different types of updates that can be performed on a field in the ObjectUpdates store
|
||||
*/
|
||||
export enum FieldChangeType {
|
||||
UPDATE = 0,
|
||||
ADD = 1,
|
||||
REMOVE = 2
|
||||
}
|
||||
|
||||
/**
|
||||
* An ngrx action to initialize a new page's fields in the ObjectUpdates state
|
||||
*/
|
||||
export class InitializeFieldsAction implements Action {
|
||||
type = ObjectUpdatesActionTypes.INITIALIZE_FIELDS;
|
||||
payload: {
|
||||
url: string,
|
||||
fields: Identifiable[],
|
||||
lastModified: Date
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a new InitializeFieldsAction
|
||||
*
|
||||
* @param url
|
||||
* the unique url of the page for which the fields are being initialized
|
||||
* @param fields The identifiable fields of which the updates are kept track of
|
||||
* @param lastModified The last modified date of the object that belongs to the page
|
||||
*/
|
||||
constructor(
|
||||
url: string,
|
||||
fields: Identifiable[],
|
||||
lastModified: Date
|
||||
) {
|
||||
this.payload = { url, fields, lastModified };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An ngrx action to add a new field update in the ObjectUpdates state for a certain page url
|
||||
*/
|
||||
export class AddFieldUpdateAction implements Action {
|
||||
type = ObjectUpdatesActionTypes.ADD_FIELD;
|
||||
payload: {
|
||||
url: string,
|
||||
field: Identifiable,
|
||||
changeType: FieldChangeType,
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a new AddFieldUpdateAction
|
||||
*
|
||||
* @param url
|
||||
* the unique url of the page for which a field update is added
|
||||
* @param field The identifiable field of which a new update is added
|
||||
* @param changeType The update's change type
|
||||
*/
|
||||
constructor(
|
||||
url: string,
|
||||
field: Identifiable,
|
||||
changeType: FieldChangeType) {
|
||||
this.payload = { url, field, changeType };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An ngrx action to set the editable state of an existing field in the ObjectUpdates state for a certain page url
|
||||
*/
|
||||
export class SetEditableFieldUpdateAction implements Action {
|
||||
type = ObjectUpdatesActionTypes.SET_EDITABLE_FIELD;
|
||||
payload: {
|
||||
url: string,
|
||||
uuid: string,
|
||||
editable: boolean,
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a new SetEditableFieldUpdateAction
|
||||
*
|
||||
* @param url
|
||||
* the unique url of the page
|
||||
* @param fieldUUID The UUID of the field of which
|
||||
* @param editable The new editable value for the field
|
||||
*/
|
||||
constructor(
|
||||
url: string,
|
||||
fieldUUID: string,
|
||||
editable: boolean) {
|
||||
this.payload = { url, uuid: fieldUUID, editable };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An ngrx action to set the isValid state of an existing field in the ObjectUpdates state for a certain page url
|
||||
*/
|
||||
export class SetValidFieldUpdateAction implements Action {
|
||||
type = ObjectUpdatesActionTypes.SET_VALID_FIELD;
|
||||
payload: {
|
||||
url: string,
|
||||
uuid: string,
|
||||
isValid: boolean,
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a new SetValidFieldUpdateAction
|
||||
*
|
||||
* @param url
|
||||
* the unique url of the page
|
||||
* @param fieldUUID The UUID of the field of which
|
||||
* @param isValid The new isValid value for the field
|
||||
*/
|
||||
constructor(
|
||||
url: string,
|
||||
fieldUUID: string,
|
||||
isValid: boolean) {
|
||||
this.payload = { url, uuid: fieldUUID, isValid };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An ngrx action to discard all existing updates in the ObjectUpdates state for a certain page url
|
||||
*/
|
||||
export class DiscardObjectUpdatesAction implements Action {
|
||||
type = ObjectUpdatesActionTypes.DISCARD;
|
||||
payload: {
|
||||
url: string,
|
||||
notification: INotification
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a new DiscardObjectUpdatesAction
|
||||
*
|
||||
* @param url
|
||||
* the unique url of the page for which the changes should be discarded
|
||||
* @param notification The notification that is raised when changes are discarded
|
||||
*/
|
||||
constructor(
|
||||
url: string,
|
||||
notification: INotification
|
||||
) {
|
||||
this.payload = { url, notification };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An ngrx action to reinstate all previously discarded updates in the ObjectUpdates state for a certain page url
|
||||
*/
|
||||
export class ReinstateObjectUpdatesAction implements Action {
|
||||
type = ObjectUpdatesActionTypes.REINSTATE;
|
||||
payload: {
|
||||
url: string
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a new ReinstateObjectUpdatesAction
|
||||
*
|
||||
* @param url
|
||||
* the unique url of the page for which the changes should be reinstated
|
||||
*/
|
||||
constructor(
|
||||
url: string
|
||||
) {
|
||||
this.payload = { url };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An ngrx action to remove all previously discarded updates in the ObjectUpdates state for a certain page url
|
||||
*/
|
||||
export class RemoveObjectUpdatesAction implements Action {
|
||||
type = ObjectUpdatesActionTypes.REMOVE;
|
||||
payload: {
|
||||
url: string
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a new RemoveObjectUpdatesAction
|
||||
*
|
||||
* @param url
|
||||
* the unique url of the page for which the changes should be removed
|
||||
*/
|
||||
constructor(
|
||||
url: string
|
||||
) {
|
||||
this.payload = { url };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An ngrx action to remove a single field update in the ObjectUpdates state for a certain page url and field uuid
|
||||
*/
|
||||
export class RemoveFieldUpdateAction implements Action {
|
||||
type = ObjectUpdatesActionTypes.REMOVE_FIELD;
|
||||
payload: {
|
||||
url: string,
|
||||
uuid: string
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a new RemoveObjectUpdatesAction
|
||||
*
|
||||
* @param url
|
||||
* the unique url of the page for which a field's change should be removed
|
||||
* @param uuid The UUID of the field for which the change should be removed
|
||||
*/
|
||||
constructor(
|
||||
url: string,
|
||||
uuid: string
|
||||
) {
|
||||
this.payload = { url, uuid };
|
||||
}
|
||||
}
|
||||
|
||||
/* tslint:enable:max-classes-per-file */
|
||||
|
||||
/**
|
||||
* A type to encompass all ObjectUpdatesActions
|
||||
*/
|
||||
export type ObjectUpdatesAction
|
||||
= AddFieldUpdateAction
|
||||
| InitializeFieldsAction
|
||||
| DiscardObjectUpdatesAction
|
||||
| ReinstateObjectUpdatesAction
|
||||
| RemoveObjectUpdatesAction
|
||||
| RemoveFieldUpdateAction;
|
122
src/app/core/data/object-updates/object-updates.effects.spec.ts
Normal file
122
src/app/core/data/object-updates/object-updates.effects.spec.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { async, TestBed } from '@angular/core/testing';
|
||||
import { Observable, Subject } from 'rxjs';
|
||||
import { provideMockActions } from '@ngrx/effects/testing';
|
||||
import { cold, hot } from 'jasmine-marbles';
|
||||
import { NotificationsService } from '../../../shared/notifications/notifications.service';
|
||||
import { ObjectUpdatesEffects } from './object-updates.effects';
|
||||
import {
|
||||
DiscardObjectUpdatesAction,
|
||||
ObjectUpdatesAction,
|
||||
ReinstateObjectUpdatesAction,
|
||||
RemoveFieldUpdateAction,
|
||||
RemoveObjectUpdatesAction
|
||||
} from './object-updates.actions';
|
||||
import {
|
||||
INotification,
|
||||
Notification
|
||||
} from '../../../shared/notifications/models/notification.model';
|
||||
import { NotificationType } from '../../../shared/notifications/models/notification-type';
|
||||
import { filter } from 'rxjs/operators';
|
||||
import { hasValue } from '../../../shared/empty.util';
|
||||
|
||||
describe('ObjectUpdatesEffects', () => {
|
||||
let updatesEffects: ObjectUpdatesEffects;
|
||||
let actions: Observable<any>;
|
||||
let testURL = 'www.dspace.org/dspace7';
|
||||
let testUUID = '20e24c2f-a00a-467c-bdee-c929e79bf08d';
|
||||
beforeEach(async(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
ObjectUpdatesEffects,
|
||||
provideMockActions(() => actions),
|
||||
{
|
||||
provide: NotificationsService,
|
||||
useValue: {
|
||||
remove: (notification) => { /* empty */
|
||||
}
|
||||
}
|
||||
},
|
||||
],
|
||||
});
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
testURL = 'www.dspace.org/dspace7';
|
||||
testUUID = '20e24c2f-a00a-467c-bdee-c929e79bf08d';
|
||||
updatesEffects = TestBed.get(ObjectUpdatesEffects);
|
||||
(updatesEffects as any).actionMap[testURL] = new Subject<ObjectUpdatesAction>();
|
||||
});
|
||||
|
||||
describe('mapLastActions$', () => {
|
||||
describe('When any ObjectUpdatesAction is triggered', () => {
|
||||
let action;
|
||||
let emittedAction;
|
||||
beforeEach(() => {
|
||||
action = new RemoveObjectUpdatesAction(testURL);
|
||||
});
|
||||
it('should emit the action from the actionMap\'s value which key matches the action\'s URL', () => {
|
||||
action = new RemoveObjectUpdatesAction(testURL);
|
||||
actions = hot('--a-', { a: action });
|
||||
(updatesEffects as any).actionMap[testURL].subscribe((act) => emittedAction = act);
|
||||
const expected = cold('--b-', { b: undefined });
|
||||
|
||||
expect(updatesEffects.mapLastActions$).toBeObservable(expected);
|
||||
expect(emittedAction).toBe(action);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeAfterDiscardOrReinstateOnUndo$', () => {
|
||||
describe('When an ObjectUpdatesActionTypes.DISCARD action is triggered', () => {
|
||||
let infoNotification: INotification;
|
||||
let removeAction;
|
||||
describe('When there is no user interactions before the timeout is finished', () => {
|
||||
beforeEach(() => {
|
||||
infoNotification = new Notification('id', NotificationType.Info, 'info');
|
||||
infoNotification.options.timeOut = 0;
|
||||
removeAction = new RemoveObjectUpdatesAction(testURL)
|
||||
});
|
||||
it('should return a RemoveObjectUpdatesAction', () => {
|
||||
actions = hot('a|', { a: new DiscardObjectUpdatesAction(testURL, infoNotification) });
|
||||
updatesEffects.removeAfterDiscardOrReinstateOnUndo$.pipe(
|
||||
filter(((action) => hasValue(action))))
|
||||
.subscribe((t) => {
|
||||
expect(t).toEqual(removeAction);
|
||||
}
|
||||
)
|
||||
;
|
||||
});
|
||||
});
|
||||
|
||||
describe('When there a REINSTATE action is fired before the timeout is finished', () => {
|
||||
beforeEach(() => {
|
||||
infoNotification = new Notification('id', NotificationType.Info, 'info');
|
||||
infoNotification.options.timeOut = 10;
|
||||
});
|
||||
it('should return an action with type NO_ACTION', () => {
|
||||
actions = hot('a', { a: new DiscardObjectUpdatesAction(testURL, infoNotification) });
|
||||
actions = hot('b', { b: new ReinstateObjectUpdatesAction(testURL) });
|
||||
updatesEffects.removeAfterDiscardOrReinstateOnUndo$.subscribe((t) => {
|
||||
expect(t).toEqual({ type: 'NO_ACTION' });
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('When there any ObjectUpdates action - other than REINSTATE - is fired before the timeout is finished', () => {
|
||||
beforeEach(() => {
|
||||
infoNotification = new Notification('id', NotificationType.Info, 'info');
|
||||
infoNotification.options.timeOut = 10;
|
||||
});
|
||||
it('should return a RemoveObjectUpdatesAction', () => {
|
||||
actions = hot('a', { a: new DiscardObjectUpdatesAction(testURL, infoNotification) });
|
||||
actions = hot('b', { b: new RemoveFieldUpdateAction(testURL, testUUID) });
|
||||
|
||||
updatesEffects.removeAfterDiscardOrReinstateOnUndo$.subscribe((t) =>
|
||||
expect(t).toEqual(new RemoveObjectUpdatesAction(testURL))
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
87
src/app/core/data/object-updates/object-updates.effects.ts
Normal file
87
src/app/core/data/object-updates/object-updates.effects.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Actions, Effect, ofType } from '@ngrx/effects';
|
||||
import {
|
||||
DiscardObjectUpdatesAction,
|
||||
ObjectUpdatesAction,
|
||||
ObjectUpdatesActionTypes,
|
||||
RemoveObjectUpdatesAction
|
||||
} from './object-updates.actions';
|
||||
import { delay, map, switchMap, take, tap } from 'rxjs/operators';
|
||||
import { of as observableOf, race as observableRace, Subject } from 'rxjs';
|
||||
import { hasNoValue } from '../../../shared/empty.util';
|
||||
import { NotificationsService } from '../../../shared/notifications/notifications.service';
|
||||
import { INotification } from '../../../shared/notifications/models/notification.model';
|
||||
|
||||
/**
|
||||
* NGRX effects for ObjectUpdatesActions
|
||||
*/
|
||||
@Injectable()
|
||||
export class ObjectUpdatesEffects {
|
||||
/**
|
||||
* Map that keeps track of the latest ObjectUpdatesAction for each page's url
|
||||
*/
|
||||
private actionMap: {
|
||||
/* Use Subject instead of BehaviorSubject:
|
||||
we only want Actions that are fired while we're listening
|
||||
actions that were previously fired do not matter anymore
|
||||
*/
|
||||
[url: string]: Subject<ObjectUpdatesAction>
|
||||
} = {};
|
||||
|
||||
/**
|
||||
* Effect that makes sure all last fired ObjectUpdatesActions are stored in the map of this service, with the url as their key
|
||||
*/
|
||||
@Effect({ dispatch: false }) mapLastActions$ = this.actions$
|
||||
.pipe(
|
||||
ofType(...Object.values(ObjectUpdatesActionTypes)),
|
||||
map((action: DiscardObjectUpdatesAction) => {
|
||||
const url: string = action.payload.url;
|
||||
if (hasNoValue(this.actionMap[url])) {
|
||||
this.actionMap[url] = new Subject<ObjectUpdatesAction>();
|
||||
}
|
||||
this.actionMap[url].next(action);
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
/**
|
||||
* Effect that checks whether the removeAction's notification timeout ends before a user triggers another ObjectUpdatesAction
|
||||
* When no ObjectUpdatesAction is fired during the timeout, a RemoteObjectUpdatesAction will be returned
|
||||
* When a REINSTATE action is fired during the timeout, a NO_ACTION action will be returned
|
||||
* When any other ObjectUpdatesAction is fired during the timeout, a RemoteObjectUpdatesAction will be returned
|
||||
*/
|
||||
@Effect() removeAfterDiscardOrReinstateOnUndo$ = this.actions$
|
||||
.pipe(
|
||||
ofType(ObjectUpdatesActionTypes.DISCARD),
|
||||
switchMap((action: DiscardObjectUpdatesAction) => {
|
||||
const url: string = action.payload.url;
|
||||
const notification: INotification = action.payload.notification;
|
||||
const timeOut = notification.options.timeOut;
|
||||
return observableRace(
|
||||
// Either wait for the delay and perform a remove action
|
||||
observableOf(new RemoveObjectUpdatesAction(action.payload.url)).pipe(delay(timeOut)),
|
||||
// Or wait for a a user action
|
||||
this.actionMap[url].pipe(
|
||||
take(1),
|
||||
tap(() => this.notificationsService.remove(notification)),
|
||||
map((updateAction: ObjectUpdatesAction) => {
|
||||
if (updateAction.type === ObjectUpdatesActionTypes.REINSTATE) {
|
||||
// If someone reinstated, do nothing, just let the reinstating happen
|
||||
return { type: 'NO_ACTION' }
|
||||
} else {
|
||||
// If someone performed another action, assume the user does not want to reinstate and remove all changes
|
||||
return new RemoveObjectUpdatesAction(action.payload.url);
|
||||
}
|
||||
})
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
constructor(private actions$: Actions,
|
||||
private notificationsService: NotificationsService) {
|
||||
|
||||
}
|
||||
|
||||
}
|
274
src/app/core/data/object-updates/object-updates.reducer.spec.ts
Normal file
274
src/app/core/data/object-updates/object-updates.reducer.spec.ts
Normal file
@@ -0,0 +1,274 @@
|
||||
import * as deepFreeze from 'deep-freeze';
|
||||
import {
|
||||
AddFieldUpdateAction,
|
||||
DiscardObjectUpdatesAction,
|
||||
FieldChangeType,
|
||||
InitializeFieldsAction,
|
||||
ReinstateObjectUpdatesAction,
|
||||
RemoveFieldUpdateAction, RemoveObjectUpdatesAction,
|
||||
SetEditableFieldUpdateAction, SetValidFieldUpdateAction
|
||||
} from './object-updates.actions';
|
||||
import { OBJECT_UPDATES_TRASH_PATH, objectUpdatesReducer } from './object-updates.reducer';
|
||||
|
||||
class NullAction extends RemoveFieldUpdateAction {
|
||||
type = null;
|
||||
payload = null;
|
||||
|
||||
constructor() {
|
||||
super(null, null);
|
||||
}
|
||||
}
|
||||
|
||||
const identifiable1 = {
|
||||
uuid: '8222b07e-330d-417b-8d7f-3b82aeaf2320',
|
||||
key: 'dc.contributor.author',
|
||||
language: null,
|
||||
value: 'Smith, John'
|
||||
};
|
||||
|
||||
const identifiable1update = {
|
||||
uuid: '8222b07e-330d-417b-8d7f-3b82aeaf2320',
|
||||
key: 'dc.contributor.author',
|
||||
language: null,
|
||||
value: 'Smith, James'
|
||||
};
|
||||
const identifiable2 = {
|
||||
uuid: '26cbb5ce-5786-4e57-a394-b9fcf8eaf241',
|
||||
key: 'dc.title',
|
||||
language: null,
|
||||
value: 'New title'
|
||||
};
|
||||
const identifiable3 = {
|
||||
uuid: 'c5d2c2f7-d757-48bf-84cc-8c9229c8407e',
|
||||
key: 'dc.description.abstract',
|
||||
language: null,
|
||||
value: 'Unchanged value'
|
||||
};
|
||||
|
||||
const modDate = new Date(2010, 2, 11);
|
||||
const uuid = identifiable1.uuid;
|
||||
const url = 'test-object.url/edit';
|
||||
describe('objectUpdatesReducer', () => {
|
||||
const testState = {
|
||||
[url]: {
|
||||
fieldStates: {
|
||||
[identifiable1.uuid]: {
|
||||
editable: true,
|
||||
isNew: false,
|
||||
isValid: true
|
||||
},
|
||||
[identifiable2.uuid]: {
|
||||
editable: false,
|
||||
isNew: true,
|
||||
isValid: true
|
||||
},
|
||||
[identifiable3.uuid]: {
|
||||
editable: false,
|
||||
isNew: false,
|
||||
isValid: false
|
||||
},
|
||||
},
|
||||
fieldUpdates: {
|
||||
[identifiable2.uuid]: {
|
||||
field: {
|
||||
uuid: identifiable2.uuid,
|
||||
key: 'dc.titl',
|
||||
language: null,
|
||||
value: 'New title'
|
||||
},
|
||||
changeType: FieldChangeType.ADD
|
||||
}
|
||||
},
|
||||
lastModified: modDate
|
||||
}
|
||||
};
|
||||
|
||||
const discardedTestState = {
|
||||
[url]: {
|
||||
fieldStates: {
|
||||
[identifiable1.uuid]: {
|
||||
editable: true,
|
||||
isNew: false,
|
||||
isValid: true
|
||||
},
|
||||
[identifiable2.uuid]: {
|
||||
editable: false,
|
||||
isNew: true,
|
||||
isValid: true
|
||||
},
|
||||
[identifiable3.uuid]: {
|
||||
editable: false,
|
||||
isNew: false,
|
||||
isValid: true
|
||||
},
|
||||
},
|
||||
lastModified: modDate
|
||||
},
|
||||
[url + OBJECT_UPDATES_TRASH_PATH]: {
|
||||
fieldStates: {
|
||||
[identifiable1.uuid]: {
|
||||
editable: true,
|
||||
isNew: false,
|
||||
isValid: true
|
||||
},
|
||||
[identifiable2.uuid]: {
|
||||
editable: false,
|
||||
isNew: true,
|
||||
isValid: true
|
||||
},
|
||||
[identifiable3.uuid]: {
|
||||
editable: false,
|
||||
isNew: false,
|
||||
isValid: false
|
||||
},
|
||||
},
|
||||
fieldUpdates: {
|
||||
[identifiable2.uuid]: {
|
||||
field: {
|
||||
uuid: identifiable2.uuid,
|
||||
key: 'dc.titl',
|
||||
language: null,
|
||||
value: 'New title'
|
||||
},
|
||||
changeType: FieldChangeType.ADD
|
||||
}
|
||||
},
|
||||
lastModified: modDate
|
||||
}
|
||||
};
|
||||
|
||||
deepFreeze(testState);
|
||||
|
||||
it('should return the current state when no valid actions have been made', () => {
|
||||
const action = new NullAction();
|
||||
const newState = objectUpdatesReducer(testState, action);
|
||||
|
||||
expect(newState).toEqual(testState);
|
||||
});
|
||||
|
||||
it('should start with an empty object', () => {
|
||||
const action = new NullAction();
|
||||
const initialState = objectUpdatesReducer(undefined, action);
|
||||
|
||||
expect(initialState).toEqual({});
|
||||
});
|
||||
|
||||
it('should perform the INITIALIZE_FIELDS action without affecting the previous state', () => {
|
||||
const action = new InitializeFieldsAction(url, [identifiable1, identifiable2], modDate);
|
||||
// testState has already been frozen above
|
||||
objectUpdatesReducer(testState, action);
|
||||
});
|
||||
|
||||
it('should perform the SET_EDITABLE_FIELD action without affecting the previous state', () => {
|
||||
const action = new SetEditableFieldUpdateAction(url, uuid, false);
|
||||
// testState has already been frozen above
|
||||
objectUpdatesReducer(testState, action);
|
||||
});
|
||||
|
||||
it('should perform the ADD_FIELD action without affecting the previous state', () => {
|
||||
const action = new AddFieldUpdateAction(url, identifiable1update, FieldChangeType.UPDATE);
|
||||
// testState has already been frozen above
|
||||
objectUpdatesReducer(testState, action);
|
||||
});
|
||||
|
||||
it('should perform the DISCARD action without affecting the previous state', () => {
|
||||
const action = new DiscardObjectUpdatesAction(url, null);
|
||||
// testState has already been frozen above
|
||||
objectUpdatesReducer(testState, action);
|
||||
});
|
||||
|
||||
it('should perform the REINSTATE action without affecting the previous state', () => {
|
||||
const action = new ReinstateObjectUpdatesAction(url);
|
||||
// testState has already been frozen above
|
||||
objectUpdatesReducer(testState, action);
|
||||
});
|
||||
|
||||
it('should perform the REMOVE action without affecting the previous state', () => {
|
||||
const action = new RemoveFieldUpdateAction(url, uuid);
|
||||
// testState has already been frozen above
|
||||
objectUpdatesReducer(testState, action);
|
||||
});
|
||||
|
||||
it('should perform the REMOVE_FIELD action without affecting the previous state', () => {
|
||||
const action = new RemoveFieldUpdateAction(url, uuid);
|
||||
// testState has already been frozen above
|
||||
objectUpdatesReducer(testState, action);
|
||||
});
|
||||
|
||||
it('should initialize all fields when the INITIALIZE action is dispatched, based on the payload', () => {
|
||||
const action = new InitializeFieldsAction(url, [identifiable1, identifiable3], modDate);
|
||||
|
||||
const expectedState = {
|
||||
[url]: {
|
||||
fieldStates: {
|
||||
[identifiable1.uuid]: {
|
||||
editable: false,
|
||||
isNew: false,
|
||||
isValid: true
|
||||
},
|
||||
[identifiable3.uuid]: {
|
||||
editable: false,
|
||||
isNew: false,
|
||||
isValid: true
|
||||
},
|
||||
},
|
||||
fieldUpdates: {},
|
||||
lastModified: modDate
|
||||
}
|
||||
};
|
||||
const newState = objectUpdatesReducer(testState, action);
|
||||
expect(newState).toEqual(expectedState);
|
||||
});
|
||||
|
||||
it('should set the given field\'s fieldStates when the SET_EDITABLE_FIELD action is dispatched, based on the payload', () => {
|
||||
const action = new SetEditableFieldUpdateAction(url, identifiable3.uuid, true);
|
||||
|
||||
const newState = objectUpdatesReducer(testState, action);
|
||||
expect(newState[url].fieldStates[identifiable3.uuid].editable).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should set the given field\'s fieldStates when the SET_VALID_FIELD action is dispatched, based on the payload', () => {
|
||||
const action = new SetValidFieldUpdateAction(url, identifiable3.uuid, false);
|
||||
|
||||
const newState = objectUpdatesReducer(testState, action);
|
||||
expect(newState[url].fieldStates[identifiable3.uuid].isValid).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should add a given field\'s update to the state when the ADD_FIELD action is dispatched, based on the payload', () => {
|
||||
const action = new AddFieldUpdateAction(url, identifiable1update, FieldChangeType.UPDATE);
|
||||
|
||||
const newState = objectUpdatesReducer(testState, action);
|
||||
expect(newState[url].fieldUpdates[identifiable1.uuid].field).toEqual(identifiable1update);
|
||||
expect(newState[url].fieldUpdates[identifiable1.uuid].changeType).toEqual(FieldChangeType.UPDATE);
|
||||
});
|
||||
|
||||
it('should discard a given url\'s updates from the state when the DISCARD action is dispatched, based on the payload', () => {
|
||||
const action = new DiscardObjectUpdatesAction(url, null);
|
||||
|
||||
const newState = objectUpdatesReducer(testState, action);
|
||||
expect(newState[url].fieldUpdates).toEqual({});
|
||||
expect(newState[url + OBJECT_UPDATES_TRASH_PATH]).toEqual(testState[url]);
|
||||
});
|
||||
|
||||
it('should reinstate a given url\'s updates from the state when the REINSTATE action is dispatched, based on the payload', () => {
|
||||
const action = new ReinstateObjectUpdatesAction(url);
|
||||
|
||||
const newState = objectUpdatesReducer(discardedTestState, action);
|
||||
expect(newState).toEqual(testState);
|
||||
});
|
||||
|
||||
it('should remove a given url\'s updates from the state when the REMOVE action is dispatched, based on the payload', () => {
|
||||
const action = new RemoveObjectUpdatesAction(url);
|
||||
|
||||
const newState = objectUpdatesReducer(discardedTestState, action);
|
||||
expect(newState[url].fieldUpdates).toBeUndefined();
|
||||
expect(newState[url + OBJECT_UPDATES_TRASH_PATH]).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should remove a given field\'s update from the state when the REMOVE_FIELD action is dispatched, based on the payload', () => {
|
||||
const action = new RemoveFieldUpdateAction(url, uuid);
|
||||
|
||||
const newState = objectUpdatesReducer(testState, action);
|
||||
expect(newState[url].fieldUpdates[uuid]).toBeUndefined();
|
||||
});
|
||||
});
|
332
src/app/core/data/object-updates/object-updates.reducer.ts
Normal file
332
src/app/core/data/object-updates/object-updates.reducer.ts
Normal file
@@ -0,0 +1,332 @@
|
||||
import {
|
||||
AddFieldUpdateAction,
|
||||
DiscardObjectUpdatesAction,
|
||||
FieldChangeType,
|
||||
InitializeFieldsAction,
|
||||
ObjectUpdatesAction,
|
||||
ObjectUpdatesActionTypes,
|
||||
ReinstateObjectUpdatesAction,
|
||||
RemoveFieldUpdateAction,
|
||||
RemoveObjectUpdatesAction, SetEditableFieldUpdateAction, SetValidFieldUpdateAction
|
||||
} from './object-updates.actions';
|
||||
import { hasNoValue, hasValue } from '../../../shared/empty.util';
|
||||
|
||||
/**
|
||||
* Path where discarded objects are saved
|
||||
*/
|
||||
export const OBJECT_UPDATES_TRASH_PATH = '/trash';
|
||||
|
||||
/**
|
||||
* The state for a single field
|
||||
*/
|
||||
export interface FieldState {
|
||||
editable: boolean,
|
||||
isNew: boolean,
|
||||
isValid: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* A list of states for all the fields for a single page, mapped by uuid
|
||||
*/
|
||||
export interface FieldStates {
|
||||
[uuid: string]: FieldState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents every object that has a UUID
|
||||
*/
|
||||
export interface Identifiable {
|
||||
uuid: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The state of a single field update
|
||||
*/
|
||||
export interface FieldUpdate {
|
||||
field: Identifiable,
|
||||
changeType: FieldChangeType
|
||||
}
|
||||
|
||||
/**
|
||||
* The states of all field updates available for a single page, mapped by uuid
|
||||
*/
|
||||
export interface FieldUpdates {
|
||||
[uuid: string]: FieldUpdate;
|
||||
}
|
||||
|
||||
/**
|
||||
* The updated state of a single page
|
||||
*/
|
||||
export interface ObjectUpdatesEntry {
|
||||
fieldStates: FieldStates;
|
||||
fieldUpdates: FieldUpdates
|
||||
lastModified: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* The updated state of all pages, mapped by the page URL
|
||||
*/
|
||||
export interface ObjectUpdatesState {
|
||||
[url: string]: ObjectUpdatesEntry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initial state for an existing initialized field
|
||||
*/
|
||||
const initialFieldState = { editable: false, isNew: false, isValid: true };
|
||||
|
||||
/**
|
||||
* Initial state for a newly added field
|
||||
*/
|
||||
const initialNewFieldState = { editable: true, isNew: true, isValid: undefined };
|
||||
|
||||
// Object.create(null) ensures the object has no default js properties (e.g. `__proto__`)
|
||||
const initialState = Object.create(null);
|
||||
|
||||
/**
|
||||
* Reducer method to calculate the next ObjectUpdates state, based on the current state and the ObjectUpdatesAction
|
||||
* @param state The current state
|
||||
* @param action The action to perform on the current state
|
||||
*/
|
||||
export function objectUpdatesReducer(state = initialState, action: ObjectUpdatesAction): ObjectUpdatesState {
|
||||
switch (action.type) {
|
||||
case ObjectUpdatesActionTypes.INITIALIZE_FIELDS: {
|
||||
return initializeFieldsUpdate(state, action as InitializeFieldsAction);
|
||||
}
|
||||
case ObjectUpdatesActionTypes.ADD_FIELD: {
|
||||
return addFieldUpdate(state, action as AddFieldUpdateAction);
|
||||
}
|
||||
case ObjectUpdatesActionTypes.DISCARD: {
|
||||
return discardObjectUpdates(state, action as DiscardObjectUpdatesAction);
|
||||
}
|
||||
case ObjectUpdatesActionTypes.REINSTATE: {
|
||||
return reinstateObjectUpdates(state, action as ReinstateObjectUpdatesAction);
|
||||
}
|
||||
case ObjectUpdatesActionTypes.REMOVE: {
|
||||
return removeObjectUpdates(state, action as RemoveObjectUpdatesAction);
|
||||
}
|
||||
case ObjectUpdatesActionTypes.REMOVE_FIELD: {
|
||||
return removeFieldUpdate(state, action as RemoveFieldUpdateAction);
|
||||
}
|
||||
case ObjectUpdatesActionTypes.SET_EDITABLE_FIELD: {
|
||||
return setEditableFieldUpdate(state, action as SetEditableFieldUpdateAction);
|
||||
}
|
||||
case ObjectUpdatesActionTypes.SET_VALID_FIELD: {
|
||||
return setValidFieldUpdate(state, action as SetValidFieldUpdateAction);
|
||||
}
|
||||
default: {
|
||||
return state;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the state for a specific url and store all its fields in the store
|
||||
* @param state The current state
|
||||
* @param action The action to perform on the current state
|
||||
*/
|
||||
function initializeFieldsUpdate(state: any, action: InitializeFieldsAction) {
|
||||
const url: string = action.payload.url;
|
||||
const fields: Identifiable[] = action.payload.fields;
|
||||
const lastModifiedServer: Date = action.payload.lastModified;
|
||||
const fieldStates = createInitialFieldStates(fields);
|
||||
const newPageState = Object.assign(
|
||||
{},
|
||||
state[url],
|
||||
{ fieldStates: fieldStates },
|
||||
{ fieldUpdates: {} },
|
||||
{ lastModified: lastModifiedServer }
|
||||
);
|
||||
return Object.assign({}, state, { [url]: newPageState });
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new update for a specific field to the store
|
||||
* @param state The current state
|
||||
* @param action The action to perform on the current state
|
||||
*/
|
||||
function addFieldUpdate(state: any, action: AddFieldUpdateAction) {
|
||||
const url: string = action.payload.url;
|
||||
const field: Identifiable = action.payload.field;
|
||||
const changeType: FieldChangeType = action.payload.changeType;
|
||||
const pageState: ObjectUpdatesEntry = state[url] || {};
|
||||
|
||||
let states = pageState.fieldStates;
|
||||
if (changeType === FieldChangeType.ADD) {
|
||||
states = Object.assign({}, { [field.uuid]: initialNewFieldState }, pageState.fieldStates)
|
||||
}
|
||||
|
||||
let fieldUpdate: any = pageState.fieldUpdates[field.uuid] || {};
|
||||
const newChangeType = determineChangeType(fieldUpdate.changeType, changeType);
|
||||
|
||||
fieldUpdate = Object.assign({}, { field, changeType: newChangeType });
|
||||
|
||||
const fieldUpdates = Object.assign({}, pageState.fieldUpdates, { [field.uuid]: fieldUpdate });
|
||||
|
||||
const newPageState = Object.assign({}, pageState,
|
||||
{ fieldStates: states },
|
||||
{ fieldUpdates: fieldUpdates });
|
||||
return Object.assign({}, state, { [url]: newPageState });
|
||||
}
|
||||
|
||||
/**
|
||||
* Discard all updates for a specific action's url in the store
|
||||
* @param state The current state
|
||||
* @param action The action to perform on the current state
|
||||
*/
|
||||
function discardObjectUpdates(state: any, action: DiscardObjectUpdatesAction) {
|
||||
const url: string = action.payload.url;
|
||||
const pageState: ObjectUpdatesEntry = state[url];
|
||||
const newFieldStates = {};
|
||||
Object.keys(pageState.fieldStates).forEach((uuid: string) => {
|
||||
const fieldState: FieldState = pageState.fieldStates[uuid];
|
||||
if (!fieldState.isNew) {
|
||||
/* After discarding we don't want the reset fields to stay editable or invalid */
|
||||
newFieldStates[uuid] = Object.assign({}, fieldState, { editable: false, isValid: true });
|
||||
}
|
||||
});
|
||||
|
||||
const discardedPageState = Object.assign({}, pageState, {
|
||||
fieldUpdates: {},
|
||||
fieldStates: newFieldStates
|
||||
});
|
||||
return Object.assign({}, state, { [url]: discardedPageState }, { [url + OBJECT_UPDATES_TRASH_PATH]: pageState });
|
||||
}
|
||||
|
||||
/**
|
||||
* Reinstate all updates for a specific action's url in the store
|
||||
* @param state The current state
|
||||
* @param action The action to perform on the current state
|
||||
*/
|
||||
function reinstateObjectUpdates(state: any, action: ReinstateObjectUpdatesAction) {
|
||||
const url: string = action.payload.url;
|
||||
const trashState = state[url + OBJECT_UPDATES_TRASH_PATH];
|
||||
|
||||
const newState = Object.assign({}, state, { [url]: trashState });
|
||||
delete newState[url + OBJECT_UPDATES_TRASH_PATH];
|
||||
return newState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all updates for a specific action's url in the store
|
||||
* @param state The current state
|
||||
* @param action The action to perform on the current state
|
||||
*/
|
||||
function removeObjectUpdates(state: any, action: RemoveObjectUpdatesAction) {
|
||||
const url: string = action.payload.url;
|
||||
return removeObjectUpdatesByURL(state, url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all updates for a specific url in the store
|
||||
* @param state The current state
|
||||
* @param action The action to perform on the current state
|
||||
*/
|
||||
function removeObjectUpdatesByURL(state: any, url: string) {
|
||||
const newState = Object.assign({}, state);
|
||||
delete newState[url + OBJECT_UPDATES_TRASH_PATH];
|
||||
return newState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discard the update for a specific action's url and field UUID in the store
|
||||
* @param state The current state
|
||||
* @param action The action to perform on the current state
|
||||
*/
|
||||
function removeFieldUpdate(state: any, action: RemoveFieldUpdateAction) {
|
||||
const url: string = action.payload.url;
|
||||
const uuid: string = action.payload.uuid;
|
||||
let newPageState: ObjectUpdatesEntry = state[url];
|
||||
if (hasValue(newPageState)) {
|
||||
const newUpdates: FieldUpdates = Object.assign({}, newPageState.fieldUpdates);
|
||||
if (hasValue(newUpdates[uuid])) {
|
||||
delete newUpdates[uuid];
|
||||
}
|
||||
const newFieldStates: FieldStates = Object.assign({}, newPageState.fieldStates);
|
||||
if (hasValue(newFieldStates[uuid])) {
|
||||
/* When resetting, make field not editable */
|
||||
if (newFieldStates[uuid].isNew) {
|
||||
/* If this field was added, just throw it away */
|
||||
delete newFieldStates[uuid];
|
||||
} else {
|
||||
newFieldStates[uuid] = Object.assign({}, newFieldStates[uuid], { editable: false, isValid: true });
|
||||
}
|
||||
}
|
||||
newPageState = Object.assign({}, state[url], {
|
||||
fieldUpdates: newUpdates,
|
||||
fieldStates: newFieldStates
|
||||
});
|
||||
}
|
||||
return Object.assign({}, state, { [url]: newPageState });
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the most prominent FieldChangeType, ordered as follows:
|
||||
* undefined < UPDATE < ADD < REMOVE
|
||||
* @param oldType The current type
|
||||
* @param newType The new type that should possibly override the new type
|
||||
*/
|
||||
function determineChangeType(oldType: FieldChangeType, newType: FieldChangeType): FieldChangeType {
|
||||
if (hasNoValue(newType)) {
|
||||
return oldType;
|
||||
}
|
||||
if (hasNoValue(oldType)) {
|
||||
return newType;
|
||||
}
|
||||
return oldType.valueOf() > newType.valueOf() ? oldType : newType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the editable state of a specific action's url and uuid to false or true
|
||||
* @param state The current state
|
||||
* @param action The action to perform on the current state
|
||||
*/
|
||||
function setEditableFieldUpdate(state: any, action: SetEditableFieldUpdateAction) {
|
||||
const url: string = action.payload.url;
|
||||
const uuid: string = action.payload.uuid;
|
||||
const editable: boolean = action.payload.editable;
|
||||
|
||||
const pageState: ObjectUpdatesEntry = state[url];
|
||||
|
||||
const fieldState = pageState.fieldStates[uuid];
|
||||
const newFieldState = Object.assign({}, fieldState, { editable });
|
||||
|
||||
const newFieldStates = Object.assign({}, pageState.fieldStates, { [uuid]: newFieldState });
|
||||
|
||||
const newPageState = Object.assign({}, pageState, { fieldStates: newFieldStates });
|
||||
|
||||
return Object.assign({}, state, { [url]: newPageState });
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the isValid state of a specific action's url and uuid to false or true
|
||||
* @param state The current state
|
||||
* @param action The action to perform on the current state
|
||||
*/
|
||||
function setValidFieldUpdate(state: any, action: SetValidFieldUpdateAction) {
|
||||
const url: string = action.payload.url;
|
||||
const uuid: string = action.payload.uuid;
|
||||
const isValid: boolean = action.payload.isValid;
|
||||
|
||||
const pageState: ObjectUpdatesEntry = state[url];
|
||||
|
||||
const fieldState = pageState.fieldStates[uuid];
|
||||
const newFieldState = Object.assign({}, fieldState, { isValid });
|
||||
|
||||
const newFieldStates = Object.assign({}, pageState.fieldStates, { [uuid]: newFieldState });
|
||||
|
||||
const newPageState = Object.assign({}, pageState, { fieldStates: newFieldStates });
|
||||
|
||||
return Object.assign({}, state, { [url]: newPageState });
|
||||
}
|
||||
|
||||
/**
|
||||
* Method to create an initial FieldStates object based on a list of Identifiable objects
|
||||
* @param fields Identifiable objects
|
||||
*/
|
||||
function createInitialFieldStates(fields: Identifiable[]) {
|
||||
const uuids = fields.map((field: Identifiable) => field.uuid);
|
||||
const fieldStates = {};
|
||||
uuids.forEach((uuid: string) => fieldStates[uuid] = initialFieldState);
|
||||
return fieldStates;
|
||||
}
|
254
src/app/core/data/object-updates/object-updates.service.spec.ts
Normal file
254
src/app/core/data/object-updates/object-updates.service.spec.ts
Normal file
@@ -0,0 +1,254 @@
|
||||
import { Store } from '@ngrx/store';
|
||||
import { CoreState } from '../../core.reducers';
|
||||
import { ObjectUpdatesService } from './object-updates.service';
|
||||
import {
|
||||
DiscardObjectUpdatesAction,
|
||||
FieldChangeType,
|
||||
InitializeFieldsAction, ReinstateObjectUpdatesAction, RemoveFieldUpdateAction,
|
||||
SetEditableFieldUpdateAction
|
||||
} from './object-updates.actions';
|
||||
import { of as observableOf } from 'rxjs';
|
||||
import { Notification } from '../../../shared/notifications/models/notification.model';
|
||||
import { NotificationType } from '../../../shared/notifications/models/notification-type';
|
||||
import { OBJECT_UPDATES_TRASH_PATH } from './object-updates.reducer';
|
||||
|
||||
describe('ObjectUpdatesService', () => {
|
||||
let service: ObjectUpdatesService;
|
||||
let store: Store<CoreState>;
|
||||
const value = 'test value';
|
||||
const url = 'test-url.com/dspace';
|
||||
const identifiable1 = { uuid: '8222b07e-330d-417b-8d7f-3b82aeaf2320' };
|
||||
const identifiable1Updated = { uuid: '8222b07e-330d-417b-8d7f-3b82aeaf2320', value: value };
|
||||
const identifiable2 = { uuid: '26cbb5ce-5786-4e57-a394-b9fcf8eaf241' };
|
||||
const identifiable3 = { uuid: 'c5d2c2f7-d757-48bf-84cc-8c9229c8407e' };
|
||||
const identifiables = [identifiable1, identifiable2];
|
||||
|
||||
const fieldUpdates = {
|
||||
[identifiable1.uuid]: { field: identifiable1Updated, changeType: FieldChangeType.UPDATE },
|
||||
[identifiable3.uuid]: { field: identifiable3, changeType: FieldChangeType.ADD }
|
||||
};
|
||||
|
||||
const modDate = new Date(2010, 2, 11);
|
||||
|
||||
beforeEach(() => {
|
||||
const fieldStates = {
|
||||
[identifiable1.uuid]: { editable: false, isNew: false, isValid: true },
|
||||
[identifiable2.uuid]: { editable: true, isNew: false, isValid: false },
|
||||
[identifiable3.uuid]: { editable: true, isNew: true, isValid: true },
|
||||
};
|
||||
|
||||
const objectEntry = {
|
||||
fieldStates, fieldUpdates, lastModified: modDate
|
||||
};
|
||||
store = new Store<CoreState>(undefined, undefined, undefined);
|
||||
spyOn(store, 'dispatch');
|
||||
service = new ObjectUpdatesService(store);
|
||||
|
||||
spyOn(service as any, 'getObjectEntry').and.returnValue(observableOf(objectEntry));
|
||||
spyOn(service as any, 'getFieldState').and.callFake((uuid) => {
|
||||
return observableOf(fieldStates[uuid]);
|
||||
});
|
||||
spyOn(service as any, 'saveFieldUpdate');
|
||||
});
|
||||
|
||||
describe('initialize', () => {
|
||||
it('should dispatch an INITIALIZE action with the correct URL, initial identifiables and the last modified date', () => {
|
||||
service.initialize(url, identifiables, modDate);
|
||||
expect(store.dispatch).toHaveBeenCalledWith(new InitializeFieldsAction(url, identifiables, modDate));
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFieldUpdates', () => {
|
||||
it('should return the list of all fields, including their update if there is one', () => {
|
||||
const result$ = service.getFieldUpdates(url, identifiables);
|
||||
expect((service as any).getObjectEntry).toHaveBeenCalledWith(url);
|
||||
|
||||
const expectedResult = {
|
||||
[identifiable1.uuid]: { field: identifiable1Updated, changeType: FieldChangeType.UPDATE },
|
||||
[identifiable2.uuid]: { field: identifiable2, changeType: undefined },
|
||||
[identifiable3.uuid]: { field: identifiable3, changeType: FieldChangeType.ADD }
|
||||
};
|
||||
|
||||
result$.subscribe((result) => {
|
||||
expect(result).toEqual(expectedResult);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('isEditable', () => {
|
||||
it('should return false if this identifiable is currently not editable in the store', () => {
|
||||
const result$ = service.isEditable(url, identifiable1.uuid);
|
||||
expect((service as any).getFieldState).toHaveBeenCalledWith(url, identifiable1.uuid);
|
||||
result$.subscribe((result) => {
|
||||
expect(result).toEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('should return true if this identifiable is currently editable in the store', () => {
|
||||
const result$ = service.isEditable(url, identifiable2.uuid);
|
||||
expect((service as any).getFieldState).toHaveBeenCalledWith(url, identifiable2.uuid);
|
||||
result$.subscribe((result) => {
|
||||
expect(result).toEqual(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('isValid', () => {
|
||||
it('should return false if this identifiable is currently not valid in the store', () => {
|
||||
const result$ = service.isValid(url, identifiable2.uuid);
|
||||
expect((service as any).getFieldState).toHaveBeenCalledWith(url, identifiable2.uuid);
|
||||
result$.subscribe((result) => {
|
||||
expect(result).toEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('should return true if this identifiable is currently valid in the store', () => {
|
||||
const result$ = service.isValid(url, identifiable1.uuid);
|
||||
expect((service as any).getFieldState).toHaveBeenCalledWith(url, identifiable1.uuid);
|
||||
result$.subscribe((result) => {
|
||||
expect(result).toEqual(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('saveAddFieldUpdate', () => {
|
||||
it('should call saveFieldUpdate on the service with FieldChangeType.ADD', () => {
|
||||
service.saveAddFieldUpdate(url, identifiable1);
|
||||
expect((service as any).saveFieldUpdate).toHaveBeenCalledWith(url, identifiable1, FieldChangeType.ADD);
|
||||
});
|
||||
});
|
||||
|
||||
describe('saveRemoveFieldUpdate', () => {
|
||||
it('should call saveFieldUpdate on the service with FieldChangeType.REMOVE', () => {
|
||||
service.saveRemoveFieldUpdate(url, identifiable1);
|
||||
expect((service as any).saveFieldUpdate).toHaveBeenCalledWith(url, identifiable1, FieldChangeType.REMOVE);
|
||||
});
|
||||
});
|
||||
|
||||
describe('saveChangeFieldUpdate', () => {
|
||||
it('should call saveFieldUpdate on the service with FieldChangeType.UPDATE', () => {
|
||||
service.saveChangeFieldUpdate(url, identifiable1);
|
||||
expect((service as any).saveFieldUpdate).toHaveBeenCalledWith(url, identifiable1, FieldChangeType.UPDATE);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setEditableFieldUpdate', () => {
|
||||
it('should dispatch a SetEditableFieldUpdateAction action with the correct URL, uuid and true when true was set', () => {
|
||||
service.setEditableFieldUpdate(url, identifiable1.uuid, true);
|
||||
expect(store.dispatch).toHaveBeenCalledWith(new SetEditableFieldUpdateAction(url, identifiable1.uuid, true));
|
||||
});
|
||||
|
||||
it('should dispatch an SetEditableFieldUpdateAction action with the correct URL, uuid and false when false was set', () => {
|
||||
service.setEditableFieldUpdate(url, identifiable1.uuid, false);
|
||||
expect(store.dispatch).toHaveBeenCalledWith(new SetEditableFieldUpdateAction(url, identifiable1.uuid, false));
|
||||
});
|
||||
});
|
||||
|
||||
describe('discardFieldUpdates', () => {
|
||||
it('should dispatch a DiscardObjectUpdatesAction action with the correct URL and passed notification ', () => {
|
||||
const undoNotification = new Notification('id', NotificationType.Info, 'undo');
|
||||
service.discardFieldUpdates(url, undoNotification);
|
||||
expect(store.dispatch).toHaveBeenCalledWith(new DiscardObjectUpdatesAction(url, undoNotification));
|
||||
});
|
||||
});
|
||||
|
||||
describe('reinstateFieldUpdates', () => {
|
||||
it('should dispatch a ReinstateObjectUpdatesAction action with the correct URL ', () => {
|
||||
service.reinstateFieldUpdates(url);
|
||||
expect(store.dispatch).toHaveBeenCalledWith(new ReinstateObjectUpdatesAction(url));
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeSingleFieldUpdate', () => {
|
||||
it('should dispatch a RemoveFieldUpdateAction action with the correct URL and uuid', () => {
|
||||
service.removeSingleFieldUpdate(url, identifiable1.uuid);
|
||||
expect(store.dispatch).toHaveBeenCalledWith(new RemoveFieldUpdateAction(url, identifiable1.uuid));
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUpdatedFields', () => {
|
||||
it('should return the list of all metadata fields with their new values', () => {
|
||||
const result$ = service.getUpdatedFields(url, identifiables);
|
||||
expect((service as any).getObjectEntry).toHaveBeenCalledWith(url);
|
||||
|
||||
const expectedResult = [identifiable1Updated, identifiable2, identifiable3];
|
||||
result$.subscribe((result) => {
|
||||
expect(result).toEqual(expectedResult);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasUpdates', () => {
|
||||
it('should return true when there are updates', () => {
|
||||
const result$ = service.hasUpdates(url);
|
||||
expect((service as any).getObjectEntry).toHaveBeenCalledWith(url);
|
||||
|
||||
const expectedResult = true;
|
||||
result$.subscribe((result) => {
|
||||
expect(result).toEqual(expectedResult);
|
||||
});
|
||||
});
|
||||
describe('when updates are emtpy', () => {
|
||||
beforeEach(() => {
|
||||
(service as any).getObjectEntry.and.returnValue(observableOf({}))
|
||||
});
|
||||
|
||||
it('should return false when there are no updates', () => {
|
||||
const result$ = service.hasUpdates(url);
|
||||
expect((service as any).getObjectEntry).toHaveBeenCalledWith(url);
|
||||
|
||||
const expectedResult = false;
|
||||
result$.subscribe((result) => {
|
||||
expect(result).toEqual(expectedResult);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('isReinstatable', () => {
|
||||
|
||||
describe('when updates are not emtpy', () => {
|
||||
beforeEach(() => {
|
||||
spyOn(service, 'hasUpdates').and.returnValue(observableOf(true));
|
||||
});
|
||||
|
||||
it('should return true', () => {
|
||||
const result$ = service.isReinstatable(url);
|
||||
expect(service.hasUpdates).toHaveBeenCalledWith(url + OBJECT_UPDATES_TRASH_PATH);
|
||||
|
||||
const expectedResult = true;
|
||||
result$.subscribe((result) => {
|
||||
expect(result).toEqual(expectedResult);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when updates are emtpy', () => {
|
||||
beforeEach(() => {
|
||||
spyOn(service, 'hasUpdates').and.returnValue(observableOf(false));
|
||||
});
|
||||
|
||||
it('should return false', () => {
|
||||
const result$ = service.isReinstatable(url);
|
||||
expect(service.hasUpdates).toHaveBeenCalledWith(url + OBJECT_UPDATES_TRASH_PATH);
|
||||
const expectedResult = false;
|
||||
result$.subscribe((result) => {
|
||||
expect(result).toEqual(expectedResult);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLastModified', () => {
|
||||
it('should return true when hasUpdates returns true', () => {
|
||||
const result$ = service.getLastModified(url);
|
||||
expect((service as any).getObjectEntry).toHaveBeenCalledWith(url);
|
||||
|
||||
const expectedResult = modDate;
|
||||
result$.subscribe((result) => {
|
||||
expect(result).toEqual(expectedResult);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
});
|
268
src/app/core/data/object-updates/object-updates.service.ts
Normal file
268
src/app/core/data/object-updates/object-updates.service.ts
Normal file
@@ -0,0 +1,268 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { createSelector, MemoizedSelector, select, Store } from '@ngrx/store';
|
||||
import { coreSelector, CoreState } from '../../core.reducers';
|
||||
import {
|
||||
FieldState,
|
||||
FieldUpdates,
|
||||
Identifiable, OBJECT_UPDATES_TRASH_PATH,
|
||||
ObjectUpdatesEntry,
|
||||
ObjectUpdatesState
|
||||
} from './object-updates.reducer';
|
||||
import { Observable } from 'rxjs';
|
||||
import {
|
||||
AddFieldUpdateAction,
|
||||
DiscardObjectUpdatesAction,
|
||||
FieldChangeType,
|
||||
InitializeFieldsAction,
|
||||
ReinstateObjectUpdatesAction,
|
||||
RemoveFieldUpdateAction,
|
||||
SetEditableFieldUpdateAction, SetValidFieldUpdateAction
|
||||
} from './object-updates.actions';
|
||||
import { distinctUntilChanged, filter, map, tap } from 'rxjs/operators';
|
||||
import { hasNoValue, hasValue, isEmpty, isNotEmpty } from '../../../shared/empty.util';
|
||||
import { INotification } from '../../../shared/notifications/models/notification.model';
|
||||
|
||||
function objectUpdatesStateSelector(): MemoizedSelector<CoreState, ObjectUpdatesState> {
|
||||
return createSelector(coreSelector, (state: CoreState) => state['cache/object-updates']);
|
||||
}
|
||||
|
||||
function filterByUrlObjectUpdatesStateSelector(url: string): MemoizedSelector<CoreState, ObjectUpdatesEntry> {
|
||||
return createSelector(objectUpdatesStateSelector(), (state: ObjectUpdatesState) => state[url]);
|
||||
}
|
||||
|
||||
function filterByUrlAndUUIDFieldStateSelector(url: string, uuid: string): MemoizedSelector<CoreState, FieldState> {
|
||||
return createSelector(filterByUrlObjectUpdatesStateSelector(url), (state: ObjectUpdatesEntry) => state.fieldStates[uuid]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Service that dispatches and reads from the ObjectUpdates' state in the store
|
||||
*/
|
||||
@Injectable()
|
||||
export class ObjectUpdatesService {
|
||||
constructor(private store: Store<CoreState>) {
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Method to dispatch an InitializeFieldsAction to the store
|
||||
* @param url The page's URL for which the changes are being mapped
|
||||
* @param fields The initial fields for the page's object
|
||||
* @param lastModified The date the object was last modified
|
||||
*/
|
||||
initialize(url, fields: Identifiable[], lastModified: Date): void {
|
||||
this.store.dispatch(new InitializeFieldsAction(url, fields, lastModified));
|
||||
}
|
||||
|
||||
/**
|
||||
* Method to dispatch an AddFieldUpdateAction to the store
|
||||
* @param url The page's URL for which the changes are saved
|
||||
* @param field An updated field for the page's object
|
||||
* @param changeType The last type of change applied to this field
|
||||
*/
|
||||
private saveFieldUpdate(url: string, field: Identifiable, changeType: FieldChangeType) {
|
||||
this.store.dispatch(new AddFieldUpdateAction(url, field, changeType))
|
||||
}
|
||||
|
||||
/**
|
||||
* Request the ObjectUpdatesEntry state for a specific URL
|
||||
* @param url The URL to filter by
|
||||
*/
|
||||
private getObjectEntry(url: string): Observable<ObjectUpdatesEntry> {
|
||||
return this.store.pipe(select(filterByUrlObjectUpdatesStateSelector(url)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Request the getFieldState state for a specific URL and UUID
|
||||
* @param url The URL to filter by
|
||||
* @param uuid The field's UUID to filter by
|
||||
*/
|
||||
private getFieldState(url: string, uuid: string): Observable<FieldState> {
|
||||
return this.store.pipe(select(filterByUrlAndUUIDFieldStateSelector(url, uuid)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Method that combines the state's updates with the initial values (when there's no update) to create
|
||||
* a FieldUpdates object
|
||||
* @param url The URL of the page for which the FieldUpdates should be requested
|
||||
* @param initialFields The initial values of the fields
|
||||
*/
|
||||
getFieldUpdates(url: string, initialFields: Identifiable[]): Observable<FieldUpdates> {
|
||||
const objectUpdates = this.getObjectEntry(url);
|
||||
return objectUpdates.pipe(map((objectEntry) => {
|
||||
const fieldUpdates: FieldUpdates = {};
|
||||
Object.keys(objectEntry.fieldStates).forEach((uuid) => {
|
||||
let fieldUpdate = objectEntry.fieldUpdates[uuid];
|
||||
if (isEmpty(fieldUpdate)) {
|
||||
const identifiable = initialFields.find((object: Identifiable) => object.uuid === uuid);
|
||||
fieldUpdate = { field: identifiable, changeType: undefined };
|
||||
}
|
||||
fieldUpdates[uuid] = fieldUpdate;
|
||||
});
|
||||
return fieldUpdates;
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Method to check if a specific field is currently editable in the store
|
||||
* @param url The URL of the page on which the field resides
|
||||
* @param uuid The UUID of the field
|
||||
*/
|
||||
isEditable(url: string, uuid: string): Observable<boolean> {
|
||||
const fieldState$ = this.getFieldState(url, uuid);
|
||||
return fieldState$.pipe(
|
||||
filter((fieldState) => hasValue(fieldState)),
|
||||
map((fieldState) => fieldState.editable),
|
||||
distinctUntilChanged()
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Method to check if a specific field is currently valid in the store
|
||||
* @param url The URL of the page on which the field resides
|
||||
* @param uuid The UUID of the field
|
||||
*/
|
||||
isValid(url: string, uuid: string): Observable<boolean> {
|
||||
const fieldState$ = this.getFieldState(url, uuid);
|
||||
return fieldState$.pipe(
|
||||
filter((fieldState) => hasValue(fieldState)),
|
||||
map((fieldState) => fieldState.isValid),
|
||||
distinctUntilChanged()
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Method to check if a specific page is currently valid in the store
|
||||
* @param url The URL of the page
|
||||
*/
|
||||
isValidPage(url: string): Observable<boolean> {
|
||||
const objectUpdates = this.getObjectEntry(url);
|
||||
return objectUpdates.pipe(
|
||||
map((entry: ObjectUpdatesEntry) => {
|
||||
return Object.values(entry.fieldStates).findIndex((state: FieldState) => !state.isValid) < 0
|
||||
}),
|
||||
distinctUntilChanged()
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls the saveFieldUpdate method with FieldChangeType.ADD
|
||||
* @param url The page's URL for which the changes are saved
|
||||
* @param field An updated field for the page's object
|
||||
*/
|
||||
saveAddFieldUpdate(url: string, field: Identifiable) {
|
||||
this.saveFieldUpdate(url, field, FieldChangeType.ADD);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls the saveFieldUpdate method with FieldChangeType.REMOVE
|
||||
* @param url The page's URL for which the changes are saved
|
||||
* @param field An updated field for the page's object
|
||||
*/
|
||||
saveRemoveFieldUpdate(url: string, field: Identifiable) {
|
||||
this.saveFieldUpdate(url, field, FieldChangeType.REMOVE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls the saveFieldUpdate method with FieldChangeType.UPDATE
|
||||
* @param url The page's URL for which the changes are saved
|
||||
* @param field An updated field for the page's object
|
||||
*/
|
||||
saveChangeFieldUpdate(url: string, field: Identifiable) {
|
||||
this.saveFieldUpdate(url, field, FieldChangeType.UPDATE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatches a SetEditableFieldUpdateAction to the store to set a field's editable state
|
||||
* @param url The URL of the page on which the field resides
|
||||
* @param uuid The UUID of the field that should be set
|
||||
* @param editable The new value of editable in the store for this field
|
||||
*/
|
||||
setEditableFieldUpdate(url: string, uuid: string, editable: boolean) {
|
||||
this.store.dispatch(new SetEditableFieldUpdateAction(url, uuid, editable));
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatches a SetValidFieldUpdateAction to the store to set a field's isValid state
|
||||
* @param url The URL of the page on which the field resides
|
||||
* @param uuid The UUID of the field that should be set
|
||||
* @param valid The new value of isValid in the store for this field
|
||||
*/
|
||||
setValidFieldUpdate(url: string, uuid: string, valid: boolean) {
|
||||
this.store.dispatch(new SetValidFieldUpdateAction(url, uuid, valid));
|
||||
}
|
||||
|
||||
/**
|
||||
* Method to dispatch an DiscardObjectUpdatesAction to the store
|
||||
* @param url The page's URL for which the changes should be discarded
|
||||
* @param undoNotification The notification which is should possibly be canceled
|
||||
*/
|
||||
discardFieldUpdates(url: string, undoNotification: INotification) {
|
||||
this.store.dispatch(new DiscardObjectUpdatesAction(url, undoNotification));
|
||||
}
|
||||
|
||||
/**
|
||||
* Method to dispatch an ReinstateObjectUpdatesAction to the store
|
||||
* @param url The page's URL for which the changes should be reinstated
|
||||
*/
|
||||
reinstateFieldUpdates(url: string) {
|
||||
this.store.dispatch(new ReinstateObjectUpdatesAction(url));
|
||||
}
|
||||
|
||||
/**
|
||||
* Method to dispatch an RemoveFieldUpdateAction to the store
|
||||
* @param url The page's URL for which the changes should be removed
|
||||
*/
|
||||
removeSingleFieldUpdate(url: string, uuid) {
|
||||
this.store.dispatch(new RemoveFieldUpdateAction(url, uuid));
|
||||
}
|
||||
|
||||
/**
|
||||
* Method that combines the state's updates with the initial values (when there's no update) to create
|
||||
* a list of updates fields
|
||||
* @param url The URL of the page for which the updated fields should be requested
|
||||
* @param initialFields The initial values of the fields
|
||||
*/
|
||||
getUpdatedFields(url: string, initialFields: Identifiable[]): Observable<Identifiable[]> {
|
||||
const objectUpdates = this.getObjectEntry(url);
|
||||
return objectUpdates.pipe(map((objectEntry) => {
|
||||
const fields: Identifiable[] = [];
|
||||
Object.keys(objectEntry.fieldStates).forEach((uuid) => {
|
||||
const fieldUpdate = objectEntry.fieldUpdates[uuid];
|
||||
if (hasNoValue(fieldUpdate) || fieldUpdate.changeType !== FieldChangeType.REMOVE) {
|
||||
let field;
|
||||
if (isNotEmpty(fieldUpdate)) {
|
||||
field = fieldUpdate.field;
|
||||
} else {
|
||||
field = initialFields.find((object: Identifiable) => object.uuid === uuid);
|
||||
}
|
||||
fields.push(field);
|
||||
}
|
||||
});
|
||||
return fields;
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the page currently has updates in the store or not
|
||||
* @param url The page's url to check for in the store
|
||||
*/
|
||||
hasUpdates(url: string): Observable<boolean> {
|
||||
return this.getObjectEntry(url).pipe(map((objectEntry) => hasValue(objectEntry) && isNotEmpty(objectEntry.fieldUpdates)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the page currently is reinstatable in the store or not
|
||||
* @param url The page's url to check for in the store
|
||||
*/
|
||||
isReinstatable(url: string): Observable<boolean> {
|
||||
return this.hasUpdates(url + OBJECT_UPDATES_TRASH_PATH)
|
||||
}
|
||||
|
||||
/**
|
||||
* Request the current lastModified date stored for the updates in the store
|
||||
* @param url The page's url to check for in the store
|
||||
*/
|
||||
getLastModified(url: string): Observable<Date> {
|
||||
return this.getObjectEntry(url).pipe(map((entry: ObjectUpdatesEntry) => entry.lastModified));
|
||||
}
|
||||
}
|
@@ -7,7 +7,7 @@ import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.
|
||||
import { DSpaceRESTv2Serializer } from '../dspace-rest-v2/dspace-rest-v2.serializer';
|
||||
import { hasValue } from '../../shared/empty.util';
|
||||
import { SearchQueryResponse } from '../../+search-page/search-service/search-query-response.model';
|
||||
import { MetadataMap, MetadataValue } from '../shared/metadata.interfaces';
|
||||
import { MetadataMap, MetadataValue } from '../shared/metadata.models';
|
||||
|
||||
@Injectable()
|
||||
export class SearchResponseParsingService implements ResponseParsingService {
|
||||
@@ -22,7 +22,7 @@ export class SearchResponseParsingService implements ResponseParsingService {
|
||||
const mdMap: MetadataMap = {};
|
||||
if (hhObject) {
|
||||
for (const key of Object.keys(hhObject)) {
|
||||
const value: MetadataValue = { value: hhObject[key].join('...'), language: null };
|
||||
const value: MetadataValue = Object.assign(new MetadataValue(), { value: hhObject[key].join('...'), language: null });
|
||||
mdMap[key] = [ value ];
|
||||
}
|
||||
}
|
||||
|
@@ -19,4 +19,8 @@ export class EPerson extends DSpaceObject {
|
||||
|
||||
public selfRegistered: boolean;
|
||||
|
||||
/** Getter to retrieve the EPerson's full name as a string */
|
||||
get name(): string {
|
||||
return this.firstMetadataValue('eperson.firstname') + ' ' + this.firstMetadataValue('eperson.lastname');
|
||||
}
|
||||
}
|
||||
|
@@ -8,7 +8,7 @@ import { ResourceType } from '../../shared/resource-type';
|
||||
|
||||
@mapsTo(EPerson)
|
||||
@inheritSerialization(NormalizedDSpaceObject)
|
||||
export class NormalizedEPerson extends NormalizedDSpaceObject implements CacheableObject, ListableObject {
|
||||
export class NormalizedEPerson extends NormalizedDSpaceObject<EPerson> implements CacheableObject, ListableObject {
|
||||
|
||||
@autoserialize
|
||||
public handle: string;
|
||||
|
@@ -7,7 +7,7 @@ import { Group } from './group.model';
|
||||
|
||||
@mapsTo(Group)
|
||||
@inheritSerialization(NormalizedDSpaceObject)
|
||||
export class NormalizedGroup extends NormalizedDSpaceObject implements CacheableObject, ListableObject {
|
||||
export class NormalizedGroup extends NormalizedDSpaceObject<Group> implements CacheableObject, ListableObject {
|
||||
|
||||
@autoserialize
|
||||
public handle: string;
|
||||
|
@@ -37,7 +37,7 @@ import { HttpClient } from '@angular/common/http';
|
||||
import { EmptyError } from 'rxjs/internal-compatibility';
|
||||
import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service';
|
||||
import { DSOChangeAnalyzer } from '../data/dso-change-analyzer.service';
|
||||
import { MetadataValue } from '../shared/metadata.interfaces';
|
||||
import { MetadataValue } from '../shared/metadata.models';
|
||||
|
||||
/* tslint:disable:max-classes-per-file */
|
||||
@Component({
|
||||
|
@@ -1,6 +1,7 @@
|
||||
import { MetadataSchema } from './metadataschema.model';
|
||||
import { autoserialize } from 'cerialize';
|
||||
import { ListableObject } from '../../shared/object-collection/shared/listable-object.model';
|
||||
import { isNotEmpty } from '../../shared/empty.util';
|
||||
|
||||
export class MetadataField implements ListableObject {
|
||||
@autoserialize
|
||||
@@ -20,4 +21,12 @@ export class MetadataField implements ListableObject {
|
||||
|
||||
@autoserialize
|
||||
schema: MetadataSchema;
|
||||
|
||||
toString(separator: string = '.'): string {
|
||||
let key = this.schema.prefix + separator + this.element;
|
||||
if (isNotEmpty(this.qualifier)) {
|
||||
key += separator + this.qualifier;
|
||||
}
|
||||
return key;
|
||||
}
|
||||
}
|
||||
|
@@ -1,7 +1,6 @@
|
||||
import { autoserialize } from 'cerialize';
|
||||
import { NormalizedObject } from '../cache/models/normalized-object.model';
|
||||
import { mapsTo } from '../cache/builders/build-decorators';
|
||||
import { CacheableObject } from '../cache/object-cache.reducer';
|
||||
import { ListableObject } from '../../shared/object-collection/shared/listable-object.model';
|
||||
import { MetadataSchema } from './metadataschema.model';
|
||||
|
||||
@@ -9,7 +8,7 @@ import { MetadataSchema } from './metadataschema.model';
|
||||
* Normalized class for a DSpace MetadataSchema
|
||||
*/
|
||||
@mapsTo(MetadataSchema)
|
||||
export class NormalizedMetadataSchema extends NormalizedObject implements CacheableObject, ListableObject {
|
||||
export class NormalizedMetadataSchema extends NormalizedObject<MetadataSchema> implements ListableObject {
|
||||
/**
|
||||
* The unique identifier for this schema
|
||||
*/
|
||||
|
@@ -29,18 +29,24 @@ import {
|
||||
import { HALEndpointService } from '../shared/hal-endpoint.service';
|
||||
import { RegistryMetadatafieldsResponseParsingService } from '../data/registry-metadatafields-response-parsing.service';
|
||||
import { RegistryMetadatafieldsResponse } from './registry-metadatafields-response.model';
|
||||
import { hasValue, isNotEmpty, isNotEmptyOperator } from '../../shared/empty.util';
|
||||
import { hasValue, hasNoValue, isNotEmpty, isNotEmptyOperator } from '../../shared/empty.util';
|
||||
import { URLCombiner } from '../url-combiner/url-combiner';
|
||||
import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model';
|
||||
import { RegistryBitstreamformatsResponseParsingService } from '../data/registry-bitstreamformats-response-parsing.service';
|
||||
import { RegistryBitstreamformatsResponse } from './registry-bitstreamformats-response.model';
|
||||
import { configureRequest, getResponseFromEntry, getSucceededRemoteData } from '../shared/operators';
|
||||
import {
|
||||
configureRequest,
|
||||
getResponseFromEntry,
|
||||
getSucceededRemoteData
|
||||
} from '../shared/operators';
|
||||
import { createSelector, select, Store } from '@ngrx/store';
|
||||
import { AppState } from '../../app.reducer';
|
||||
import { MetadataRegistryState } from '../../+admin/admin-registries/metadata-registry/metadata-registry.reducers';
|
||||
import {
|
||||
MetadataRegistryCancelFieldAction,
|
||||
MetadataRegistryCancelSchemaAction, MetadataRegistryDeselectAllFieldAction, MetadataRegistryDeselectAllSchemaAction,
|
||||
MetadataRegistryCancelSchemaAction,
|
||||
MetadataRegistryDeselectAllFieldAction,
|
||||
MetadataRegistryDeselectAllSchemaAction,
|
||||
MetadataRegistryDeselectFieldAction,
|
||||
MetadataRegistryDeselectSchemaAction,
|
||||
MetadataRegistryEditFieldAction,
|
||||
@@ -167,6 +173,47 @@ export class RegistryService {
|
||||
return this.rdb.toRemoteDataObservable(requestEntryObs, payloadObs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve all existing metadata fields as a paginated list
|
||||
* @param pagination Pagination options to determine which page of metadata fields should be requested
|
||||
* When no pagination is provided, all metadata fields are requested in one large page
|
||||
* @returns an observable that emits a remote data object with a page of metadata fields
|
||||
*/
|
||||
public getAllMetadataFields(pagination?: PaginationComponentOptions): Observable<RemoteData<PaginatedList<MetadataField>>> {
|
||||
if (hasNoValue(pagination)) {
|
||||
pagination = { currentPage: 1, pageSize: 10000 } as any;
|
||||
}
|
||||
const requestObs = this.getMetadataFieldsRequestObs(pagination);
|
||||
|
||||
const requestEntryObs = requestObs.pipe(
|
||||
flatMap((request: RestRequest) => this.requestService.getByHref(request.href))
|
||||
);
|
||||
|
||||
const rmrObs: Observable<RegistryMetadatafieldsResponse> = requestEntryObs.pipe(
|
||||
getResponseFromEntry(),
|
||||
map((response: RegistryMetadatafieldsSuccessResponse) => response.metadatafieldsResponse)
|
||||
);
|
||||
|
||||
const metadatafieldsObs: Observable<MetadataField[]> = rmrObs.pipe(
|
||||
map((rmr: RegistryMetadatafieldsResponse) => rmr.metadatafields),
|
||||
map((metadataFields: MetadataField[]) => metadataFields)
|
||||
);
|
||||
|
||||
const pageInfoObs: Observable<PageInfo> = requestEntryObs.pipe(
|
||||
getResponseFromEntry(),
|
||||
|
||||
map((response: RegistryMetadatafieldsSuccessResponse) => response.pageInfo)
|
||||
);
|
||||
|
||||
const payloadObs = observableCombineLatest(metadatafieldsObs, pageInfoObs).pipe(
|
||||
map(([metadatafields, pageInfo]) => {
|
||||
return new PaginatedList(pageInfo, metadatafields);
|
||||
})
|
||||
);
|
||||
|
||||
return this.rdb.toRemoteDataObservable(requestEntryObs, payloadObs);
|
||||
}
|
||||
|
||||
public getBitstreamFormats(pagination: PaginationComponentOptions): Observable<RemoteData<PaginatedList<BitstreamFormat>>> {
|
||||
const requestObs = this.getBitstreamFormatsRequestObs(pagination);
|
||||
|
||||
@@ -239,6 +286,26 @@ export class RegistryService {
|
||||
);
|
||||
}
|
||||
|
||||
private getMetadataFieldsRequestObs(pagination: PaginationComponentOptions): Observable<RestRequest> {
|
||||
return this.halService.getEndpoint(this.metadataFieldsPath).pipe(
|
||||
map((url: string) => {
|
||||
const args: string[] = [];
|
||||
args.push(`size=${pagination.pageSize}`);
|
||||
args.push(`page=${pagination.currentPage - 1}`);
|
||||
if (isNotEmpty(args)) {
|
||||
url = new URLCombiner(url, `?${args.join('&')}`).toString();
|
||||
}
|
||||
const request = new GetRequest(this.requestService.generateRequestId(), url);
|
||||
return Object.assign(request, {
|
||||
getResponseParser(): GenericConstructor<ResponseParsingService> {
|
||||
return RegistryMetadatafieldsResponseParsingService;
|
||||
}
|
||||
});
|
||||
}),
|
||||
tap((request: RestRequest) => this.requestService.configure(request)),
|
||||
);
|
||||
}
|
||||
|
||||
private getBitstreamFormatsRequestObs(pagination: PaginationComponentOptions): Observable<RestRequest> {
|
||||
return this.halService.getEndpoint(this.bitstreamFormatsPath).pipe(
|
||||
map((url: string) => {
|
||||
@@ -495,4 +562,21 @@ export class RegistryService {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve a filtered paginated list of metadata fields
|
||||
* @param query {string} The query to filter the field names by
|
||||
* @returns an observable that emits a remote data object with a page of metadata fields that match the query
|
||||
*/
|
||||
queryMetadataFields(query: string): Observable<RemoteData<PaginatedList<MetadataField>>> {
|
||||
return this.getAllMetadataFields().pipe(
|
||||
map((rd: RemoteData<PaginatedList<MetadataField>>) => {
|
||||
const filteredFields: MetadataField[] = rd.payload.page.filter(
|
||||
(field: MetadataField) => field.toString().indexOf(query) >= 0
|
||||
);
|
||||
const page: PaginatedList<MetadataField> = new PaginatedList<MetadataField>(new PageInfo(), filteredFields)
|
||||
return Object.assign({}, rd, { payload: page });
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@@ -1,11 +1,15 @@
|
||||
import { MetadataMap, MetadataValue, MetadataValueFilter } from './metadata.interfaces';
|
||||
import { Metadata } from './metadata.model';
|
||||
import {
|
||||
MetadataMap,
|
||||
MetadataValue,
|
||||
MetadataValueFilter,
|
||||
MetadatumViewModel
|
||||
} from './metadata.models';
|
||||
import { Metadata } from './metadata.utils';
|
||||
import { CacheableObject } from '../cache/object-cache.reducer';
|
||||
import { RemoteData } from '../data/remote-data';
|
||||
import { ResourceType } from './resource-type';
|
||||
import { ListableObject } from '../../shared/object-collection/shared/listable-object.model';
|
||||
import { Observable } from 'rxjs';
|
||||
import { autoserialize } from 'cerialize';
|
||||
|
||||
/**
|
||||
* An abstract model class for a DSpaceObject.
|
||||
@@ -17,13 +21,11 @@ export class DSpaceObject implements CacheableObject, ListableObject {
|
||||
/**
|
||||
* The human-readable identifier of this DSpaceObject
|
||||
*/
|
||||
@autoserialize
|
||||
id: string;
|
||||
|
||||
/**
|
||||
* The universally unique identifier of this DSpaceObject
|
||||
*/
|
||||
@autoserialize
|
||||
uuid: string;
|
||||
|
||||
/**
|
||||
@@ -41,9 +43,15 @@ export class DSpaceObject implements CacheableObject, ListableObject {
|
||||
/**
|
||||
* All metadata of this DSpaceObject
|
||||
*/
|
||||
@autoserialize
|
||||
metadata: MetadataMap;
|
||||
|
||||
/**
|
||||
* Retrieve the current metadata as a list of MetadatumViewModels
|
||||
*/
|
||||
get metadataAsList(): MetadatumViewModel[] {
|
||||
return Metadata.toViewModelList(this.metadata);
|
||||
}
|
||||
|
||||
/**
|
||||
* An array of DSpaceObjects that are direct parents of this DSpaceObject
|
||||
*/
|
||||
|
@@ -1,30 +0,0 @@
|
||||
/** A map of metadata keys to an ordered list of MetadataValue objects. */
|
||||
export interface MetadataMap {
|
||||
[ key: string ]: MetadataValue[];
|
||||
}
|
||||
|
||||
/** A single metadata value and its properties. */
|
||||
export interface MetadataValue {
|
||||
|
||||
/** The language. */
|
||||
language: string;
|
||||
|
||||
/** The string value. */
|
||||
value: string;
|
||||
}
|
||||
|
||||
/** Constraints for matching metadata values. */
|
||||
export interface MetadataValueFilter {
|
||||
|
||||
/** The language constraint. */
|
||||
language?: string;
|
||||
|
||||
/** The value constraint. */
|
||||
value?: string;
|
||||
|
||||
/** Whether the value constraint should match without regard to case. */
|
||||
ignoreCase?: boolean;
|
||||
|
||||
/** Whether the value constraint should match as a substring. */
|
||||
substring?: boolean;
|
||||
}
|
78
src/app/core/shared/metadata.models.ts
Normal file
78
src/app/core/shared/metadata.models.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import * as uuidv4 from 'uuid/v4';
|
||||
import { autoserialize, Serialize, Deserialize } from 'cerialize';
|
||||
/* tslint:disable:max-classes-per-file */
|
||||
|
||||
/** A map of metadata keys to an ordered list of MetadataValue objects. */
|
||||
export class MetadataMap {
|
||||
[key: string]: MetadataValue[];
|
||||
}
|
||||
|
||||
/** A single metadata value and its properties. */
|
||||
|
||||
export class MetadataValue {
|
||||
/** The uuid. */
|
||||
uuid: string = uuidv4();
|
||||
|
||||
/** The language. */
|
||||
@autoserialize
|
||||
language: string;
|
||||
|
||||
/** The string value. */
|
||||
@autoserialize
|
||||
value: string;
|
||||
}
|
||||
|
||||
/** Constraints for matching metadata values. */
|
||||
export interface MetadataValueFilter {
|
||||
/** The language constraint. */
|
||||
language?: string;
|
||||
|
||||
/** The value constraint. */
|
||||
value?: string;
|
||||
|
||||
/** Whether the value constraint should match without regard to case. */
|
||||
ignoreCase?: boolean;
|
||||
|
||||
/** Whether the value constraint should match as a substring. */
|
||||
substring?: boolean;
|
||||
}
|
||||
|
||||
export class MetadatumViewModel {
|
||||
/** The uuid. */
|
||||
uuid: string = uuidv4();
|
||||
|
||||
/** The metadatafield key. */
|
||||
key: string;
|
||||
|
||||
/** The language. */
|
||||
language: string;
|
||||
|
||||
/** The string value. */
|
||||
value: string;
|
||||
|
||||
/** The order. */
|
||||
order: number;
|
||||
}
|
||||
|
||||
/** Serializer used for MetadataMaps.
|
||||
* This is necessary because Cerialize has trouble instantiating the MetadataValues using their constructor
|
||||
* when they are inside arrays which also represent the values in a map.
|
||||
*/
|
||||
export const MetadataMapSerializer = {
|
||||
Serialize(map: MetadataMap): any {
|
||||
const json = {};
|
||||
Object.keys(map).forEach((key: string) => {
|
||||
json[key] = Serialize(map[key], MetadataValue);
|
||||
});
|
||||
return json;
|
||||
},
|
||||
|
||||
Deserialize(json: any): MetadataMap {
|
||||
const metadataMap: MetadataMap = {};
|
||||
Object.keys(json).forEach((key: string) => {
|
||||
metadataMap[key] = Deserialize(json[key], MetadataValue);
|
||||
});
|
||||
return metadataMap;
|
||||
}
|
||||
};
|
||||
/* tslint:enable:max-classes-per-file */
|
@@ -1,10 +1,16 @@
|
||||
import { isUndefined } from '../../shared/empty.util';
|
||||
import { MetadataValue, MetadataValueFilter } from './metadata.interfaces';
|
||||
import { Metadata } from './metadata.model';
|
||||
import * as uuidv4 from 'uuid/v4';
|
||||
import {
|
||||
MetadataMap,
|
||||
MetadataValue,
|
||||
MetadataValueFilter,
|
||||
MetadatumViewModel
|
||||
} from './metadata.models';
|
||||
import { Metadata } from './metadata.utils';
|
||||
|
||||
const mdValue = (value: string, language?: string): MetadataValue => {
|
||||
return { value: value, language: isUndefined(language) ? null : language };
|
||||
}
|
||||
return { uuid: uuidv4(), value: value, language: isUndefined(language) ? null : language };
|
||||
};
|
||||
|
||||
const dcDescription = mdValue('Some description');
|
||||
const dcAbstract = mdValue('Some abstract');
|
||||
@@ -22,6 +28,14 @@ const multiMap = {
|
||||
'foo': [bar]
|
||||
};
|
||||
|
||||
const multiViewModelList = [
|
||||
{ key: 'dc.description', ...dcDescription, order: 0 },
|
||||
{ key: 'dc.description.abstract', ...dcAbstract, order: 0 },
|
||||
{ key: 'dc.title', ...dcTitle1, order: 0 },
|
||||
{ key: 'dc.title', ...dcTitle2, order: 1 },
|
||||
{ key: 'foo', ...bar, order: 0 }
|
||||
];
|
||||
|
||||
const testMethod = (fn, resultKind, mapOrMaps, keyOrKeys, expected, filter?) => {
|
||||
const keys = keyOrKeys instanceof Array ? keyOrKeys : [keyOrKeys];
|
||||
describe('and key' + (keys.length === 1 ? (' ' + keys[0]) : ('s ' + JSON.stringify(keys)))
|
||||
@@ -172,4 +186,32 @@ describe('Metadata', () => {
|
||||
testValueMatches(mdValue('a', 'en_US'), true, { language: 'en_US' });
|
||||
});
|
||||
|
||||
describe('toViewModelList method', () => {
|
||||
|
||||
const testToViewModelList = (map: MetadataMap, expected: MetadatumViewModel[]) => {
|
||||
describe('with map ' + JSON.stringify(map), () => {
|
||||
const result = Metadata.toViewModelList(map);
|
||||
it('should return ' + JSON.stringify(expected), () => {
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
testToViewModelList(multiMap, multiViewModelList);
|
||||
});
|
||||
|
||||
describe('toMetadataMap method', () => {
|
||||
|
||||
const testToMetadataMap = (metadatumList: MetadatumViewModel[], expected: MetadataMap) => {
|
||||
describe('with metadatum list ' + JSON.stringify(metadatumList), () => {
|
||||
const result = Metadata.toMetadataMap(metadatumList);
|
||||
it('should return ' + JSON.stringify(expected), () => {
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
testToMetadataMap(multiViewModelList, multiMap);
|
||||
});
|
||||
|
||||
});
|
@@ -1,5 +1,11 @@
|
||||
import { isEmpty, isNotUndefined, isUndefined } from '../../shared/empty.util';
|
||||
import { MetadataMap, MetadataValue, MetadataValueFilter } from './metadata.interfaces';
|
||||
import {
|
||||
MetadataMap,
|
||||
MetadataValue,
|
||||
MetadataValueFilter,
|
||||
MetadatumViewModel
|
||||
} from './metadata.models';
|
||||
import { groupBy, sortBy } from 'lodash';
|
||||
|
||||
/**
|
||||
* Utility class for working with DSpace object metadata.
|
||||
@@ -143,7 +149,7 @@ export class Metadata {
|
||||
* @param {MetadataMap} mdMap The source map.
|
||||
* @param {string|string[]} keyOrKeys The metadata key(s) in scope. Wildcards are supported; see above.
|
||||
*/
|
||||
private static resolveKeys(mdMap: MetadataMap, keyOrKeys: string | string[]): string[] {
|
||||
private static resolveKeys(mdMap: MetadataMap = {}, keyOrKeys: string | string[]): string[] {
|
||||
const inputKeys: string[] = keyOrKeys instanceof Array ? keyOrKeys : [keyOrKeys];
|
||||
const outputKeys: string[] = [];
|
||||
for (const inputKey of inputKeys) {
|
||||
@@ -160,4 +166,53 @@ export class Metadata {
|
||||
}
|
||||
return outputKeys;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an array of MetadatumViewModels from an existing MetadataMap.
|
||||
*
|
||||
* @param {MetadataMap} mdMap The source map.
|
||||
* @returns {MetadatumViewModel[]} List of metadata view models based on the source map.
|
||||
*/
|
||||
public static toViewModelList(mdMap: MetadataMap): MetadatumViewModel[] {
|
||||
let metadatumList: MetadatumViewModel[] = [];
|
||||
Object.keys(mdMap)
|
||||
.sort()
|
||||
.forEach((key: string) => {
|
||||
const fields = mdMap[key].map(
|
||||
(metadataValue: MetadataValue, index: number) =>
|
||||
Object.assign(
|
||||
{},
|
||||
metadataValue,
|
||||
{
|
||||
order: index,
|
||||
key
|
||||
}));
|
||||
metadatumList = [...metadatumList, ...fields];
|
||||
});
|
||||
return metadatumList;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an MetadataMap from an existing array of MetadatumViewModels.
|
||||
*
|
||||
* @param {MetadatumViewModel[]} viewModelList The source list.
|
||||
* @returns {MetadataMap} Map with metadata values based on the source list.
|
||||
*/
|
||||
public static toMetadataMap(viewModelList: MetadatumViewModel[]): MetadataMap {
|
||||
const metadataMap: MetadataMap = {};
|
||||
const groupedList = groupBy(viewModelList, (viewModel) => viewModel.key);
|
||||
Object.keys(groupedList)
|
||||
.sort()
|
||||
.forEach((key: string) => {
|
||||
const orderedValues = sortBy(groupedList[key], ['order']);
|
||||
metadataMap[key] = orderedValues.map((value: MetadataValue) => {
|
||||
const val = Object.assign({}, value);
|
||||
delete (val as any).order;
|
||||
delete (val as any).key;
|
||||
return val;
|
||||
}
|
||||
)
|
||||
});
|
||||
return metadataMap;
|
||||
}
|
||||
}
|
@@ -64,7 +64,7 @@ describe('Core Module - RxJS Operators', () => {
|
||||
scheduler.schedule(() => source.pipe(getRequestFromRequestHref(requestService)).subscribe());
|
||||
scheduler.flush();
|
||||
|
||||
expect(requestService.getByHref).toHaveBeenCalledWith(testRequestHref)
|
||||
expect(requestService.getByHref).toHaveBeenCalledWith(testRequestHref);
|
||||
});
|
||||
|
||||
it('shouldn\'t return anything if there is no request matching the self link', () => {
|
||||
@@ -159,6 +159,22 @@ describe('Core Module - RxJS Operators', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getResponseFromEntry', () => {
|
||||
it('should return the response for all not empty request entries, when they have a value', () => {
|
||||
const source = hot('abcdefg', testRCEs);
|
||||
const result = source.pipe(getResponseFromEntry());
|
||||
const expected = cold('abcde--', {
|
||||
a: testRCEs.a.response,
|
||||
b: testRCEs.b.response,
|
||||
c: testRCEs.c.response,
|
||||
d: testRCEs.d.response,
|
||||
e: testRCEs.e.response
|
||||
});
|
||||
|
||||
expect(result).toBeObservable(expected)
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSucceededRemoteData', () => {
|
||||
it('should return the first() hasSucceeded RemoteData Observable', () => {
|
||||
const testRD = {
|
||||
|
@@ -60,7 +60,7 @@ export const getRemoteDataPayload = () =>
|
||||
|
||||
export const getSucceededRemoteData = () =>
|
||||
<T>(source: Observable<RemoteData<T>>): Observable<RemoteData<T>> =>
|
||||
source.pipe(find((rd: RemoteData<T>) => rd.hasSucceeded), hasValueOperator());
|
||||
source.pipe(find((rd: RemoteData<T>) => rd.hasSucceeded));
|
||||
|
||||
export const getFinishedRemoteData = () =>
|
||||
<T>(source: Observable<RemoteData<T>>): Observable<RemoteData<T>> =>
|
||||
|
@@ -14,7 +14,7 @@
|
||||
<div ngbDropdown placement="bottom-right" class="d-inline-block" @fadeInOut>
|
||||
<a href="#" id="dropdownUser" (click)="$event.preventDefault()" class="px-1" ngbDropdownToggle><i class="fas fa-user-circle fa-lg fa-fw" [title]="'nav.logout' | translate"></i></a>
|
||||
<ul id="logoutDropdownMenu" ngbDropdownMenu aria-labelledby="dropdownUser">
|
||||
<li class="dropdown-item">{{(user | async).name}}</li>
|
||||
<li class="dropdown-item">{{(user | async).name}} ({{(user | async).email}})</li>
|
||||
<li class="dropdown-item"><ds-log-out></ds-log-out></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
@@ -1,3 +1,3 @@
|
||||
<ds-form *ngIf="formModel"
|
||||
[formId]="'comcol-form-id'"
|
||||
[formModel]="formModel" (submitForm)="onSubmit()"></ds-form>
|
||||
[formModel]="formModel" (submitForm)="onSubmit()" (cancel)="onCancel()"></ds-form>
|
||||
|
@@ -50,10 +50,7 @@ describe('ComColFormComponent', () => {
|
||||
];
|
||||
|
||||
/* tslint:disable:no-empty */
|
||||
const locationStub = {
|
||||
back: () => {
|
||||
}
|
||||
};
|
||||
const locationStub = jasmine.createSpyObj('location', ['back']);
|
||||
/* tslint:enable:no-empty */
|
||||
|
||||
beforeEach(async(() => {
|
||||
@@ -112,4 +109,11 @@ describe('ComColFormComponent', () => {
|
||||
);
|
||||
})
|
||||
});
|
||||
|
||||
describe('onCancel', () => {
|
||||
it('should call the back method on the Location service', () => {
|
||||
comp.onCancel();
|
||||
expect(locationStub.back).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@@ -8,7 +8,7 @@ import { FormGroup } from '@angular/forms';
|
||||
import { DynamicFormControlModel } from '@ng-dynamic-forms/core/src/model/dynamic-form-control.model';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { DSpaceObject } from '../../../core/shared/dspace-object.model';
|
||||
import { MetadataMap, MetadataValue } from '../../../core/shared/metadata.interfaces';
|
||||
import { MetadataMap, MetadataValue } from '../../../core/shared/metadata.models';
|
||||
import { isNotEmpty } from '../../empty.util';
|
||||
import { ResourceType } from '../../../core/shared/resource-type';
|
||||
|
||||
@@ -83,7 +83,10 @@ export class ComColFormComponent<T extends DSpaceObject> implements OnInit {
|
||||
onSubmit() {
|
||||
const formMetadata = new Object() as MetadataMap;
|
||||
this.formModel.forEach((fieldModel: DynamicInputModel) => {
|
||||
const value: MetadataValue = { value: fieldModel.value as string, language: null };
|
||||
const value: MetadataValue = {
|
||||
value: fieldModel.value as string,
|
||||
language: null
|
||||
} as any;
|
||||
if (formMetadata.hasOwnProperty(fieldModel.name)) {
|
||||
formMetadata[fieldModel.name].push(value);
|
||||
} else {
|
||||
@@ -117,4 +120,8 @@ export class ComColFormComponent<T extends DSpaceObject> implements OnInit {
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
onCancel() {
|
||||
this.location.back();
|
||||
}
|
||||
}
|
||||
|
@@ -11,13 +11,12 @@ import { CommonModule } from '@angular/common';
|
||||
import { RouterTestingModule } from '@angular/router/testing';
|
||||
import { NO_ERRORS_SCHEMA } from '@angular/core';
|
||||
import { DSpaceObject } from '../../../core/shared/dspace-object.model';
|
||||
import { NormalizedDSpaceObject } from '../../../core/cache/models/normalized-dspace-object.model';
|
||||
import { CreateComColPageComponent } from './create-comcol-page.component';
|
||||
import { DataService } from '../../../core/data/data.service';
|
||||
|
||||
describe('CreateComColPageComponent', () => {
|
||||
let comp: CreateComColPageComponent<DSpaceObject, NormalizedDSpaceObject>;
|
||||
let fixture: ComponentFixture<CreateComColPageComponent<DSpaceObject, NormalizedDSpaceObject>>;
|
||||
let comp: CreateComColPageComponent<DSpaceObject>;
|
||||
let fixture: ComponentFixture<CreateComColPageComponent<DSpaceObject>>;
|
||||
let communityDataService: CommunityDataService;
|
||||
let dsoDataService: CommunityDataService;
|
||||
let routeService: RouteService;
|
||||
|
@@ -10,7 +10,6 @@ import { take } from 'rxjs/operators';
|
||||
import { getSucceededRemoteData } from '../../../core/shared/operators';
|
||||
import { DSpaceObject } from '../../../core/shared/dspace-object.model';
|
||||
import { DataService } from '../../../core/data/data.service';
|
||||
import { NormalizedDSpaceObject } from '../../../core/cache/models/normalized-dspace-object.model';
|
||||
|
||||
/**
|
||||
* Component representing the create page for communities and collections
|
||||
@@ -19,7 +18,7 @@ import { NormalizedDSpaceObject } from '../../../core/cache/models/normalized-ds
|
||||
selector: 'ds-create-comcol',
|
||||
template: ''
|
||||
})
|
||||
export class CreateComColPageComponent<TDomain extends DSpaceObject, TNormalized extends NormalizedDSpaceObject> implements OnInit {
|
||||
export class CreateComColPageComponent<TDomain extends DSpaceObject> implements OnInit {
|
||||
/**
|
||||
* Frontend endpoint for this type of DSO
|
||||
*/
|
||||
@@ -36,7 +35,7 @@ export class CreateComColPageComponent<TDomain extends DSpaceObject, TNormalized
|
||||
public parentRD$: Observable<RemoteData<Community>>;
|
||||
|
||||
public constructor(
|
||||
protected dsoDataService: DataService<TNormalized, TDomain>,
|
||||
protected dsoDataService: DataService<TDomain>,
|
||||
protected parentDataService: CommunityDataService,
|
||||
protected routeService: RouteService,
|
||||
protected router: Router
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user