finalised edit item page

This commit is contained in:
lotte
2019-02-19 10:13:49 +01:00
parent 86115c44ce
commit bfa1e77177
19 changed files with 346 additions and 213 deletions

View File

@@ -188,7 +188,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 +196,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 +204,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 +212,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 +220,7 @@
"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",
@@ -233,6 +233,14 @@
"language": "Lang",
"edit": "Edit"
},
"edit": {
"buttons": {
"edit": "Edit",
"unedit": "Stop editing",
"remove": "Remove",
"undo": "Undo changes"
}
},
"metadatafield": {
"invalid": "Please choose a valid metadata field"
},
@@ -247,7 +255,7 @@
},
"invalid": {
"title": "Metadata invalid",
"content": "Please make sure all fields are valid"
"content": "Your changes were not saved. Please make sure all fields are valid before you save."
}
}
}

View File

@@ -1,37 +1,21 @@
<div class="container">
<div class="row">
<div class="col-12">
<h2 class="border-bottom">{{'item.edit.head' | translate}}</h2>
<div class="pt-2">
<ngb-tabset [activeId]="(params$ | async)?.page || 'status'">
<ngb-tab [id]="'status'" 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 [id]="'bitstreams'" title="{{'item.edit.tabs.bitstreams.head' | translate}}">
<ng-template ngbTabContent>
</ng-template>
</ngb-tab>
<ngb-tab [id]="'metadata'" title="{{'item.edit.tabs.metadata.head' | translate}}">
<ng-template ngbTabContent>
<ds-item-metadata [item]="(itemRD$ | async)?.payload">
</ds-item-metadata>
</ng-template>
</ngb-tab>
<ngb-tab [id]="'view'" title="{{'item.edit.tabs.view.head' | translate}}">
<ng-template ngbTabContent>
</ng-template>
</ngb-tab>
<ngb-tab [id]="'curate'" title="{{'item.edit.tabs.curate.head' | translate}}">
<ng-template ngbTabContent>
</ng-template>
</ngb-tab>
</ngb-tabset>
</div>
<div class="row">
<div class="col-12">
<h2 class="border-bottom">{{'item.edit.head' | translate}}</h2>
<div class="pt-2">
<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]="'/items/' + (itemRD$ | async)?.payload.uuid + '/edit/' + page">
{{'item.edit.tabs.' + page + '.head' | translate}}
</a>
</li>
</ul>
<div class="tab-pane active">
<router-outlet></router-outlet>
</div>
</div>
</div>
</div>
</div>
</div>

View File

@@ -1,10 +1,11 @@
import {fadeIn, fadeInOut} from '../../shared/animations/fade';
import {ChangeDetectionStrategy, Component, OnInit} from '@angular/core';
import { ActivatedRoute, Params } 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 { fadeIn, fadeInOut } from '../../shared/animations/fade';
import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
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';
@Component({
selector: 'ds-edit-item-page',
@@ -24,13 +25,27 @@ export class EditItemPageComponent implements OnInit {
* The item to edit
*/
itemRD$: Observable<RemoteData<Item>>;
params$: Observable<Params>;
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));
this.params$ = this.route.params;
}
}

View File

@@ -1,12 +1,14 @@
import {ItemPageResolver} from '../item-page.resolver';
import {NgModule} from '@angular/core';
import {RouterModule} from '@angular/router';
import {EditItemPageComponent} from './edit-item-page.component';
import {ItemWithdrawComponent} from './item-withdraw/item-withdraw.component';
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 { ItemPageResolver } from '../item-page.resolver';
import { NgModule } from '@angular/core';
import { RouterModule } from '@angular/router';
import { EditItemPageComponent } from './edit-item-page.component';
import { ItemWithdrawComponent } from './item-withdraw/item-withdraw.component';
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';
const ITEM_EDIT_WITHDRAW_PATH = 'withdraw';
const ITEM_EDIT_REINSTATE_PATH = 'reinstate';
@@ -25,7 +27,36 @@ const ITEM_EDIT_DELETE_PATH = 'delete';
component: EditItemPageComponent,
resolve: {
item: ItemPageResolver
}
},
children: [
{
path: '',
redirectTo: 'status'
},
{
path: 'status',
component: ItemStatusComponent
},
{
path: 'bitstreams',
/* TODO - change when bitstreams page exists */
component: ItemStatusComponent
},
{
path: 'metadata',
component: ItemMetadataComponent
},
{
path: 'view',
/* TODO - change when view page exists */
component: ItemStatusComponent
},
{
path: 'curate',
/* TODO - change when curate page exists */
component: ItemStatusComponent
},
]
},
{
path: ITEM_EDIT_WITHDRAW_PATH,

View File

@@ -7,7 +7,7 @@
<!--{{metadata?.uuid}}-->
<td class="col-3">
<div *ngIf="!(editable | async)">
<span>{{metadata?.key}}</span>
<span>{{metadata?.key?.split('.').join('.&#8203;')}}</span>
</div>
<div *ngIf="(editable | async)" class="field-container">
<ds-input-suggestions [suggestions]="(metadataFieldSuggestions | async)"
@@ -15,9 +15,12 @@
(submitSuggestion)="update(suggestionControl.control)"
(clickSuggestion)="update(suggestionControl.control)"
(typeSuggestion)="update(suggestionControl.control)"
(blur)="checkValidity(suggestionControl.control)"
(findSuggestions)="findMetadataFieldSuggestions($event)"
#suggestionControl="ngModel"
[dsInListValidator]="metadataFields | async"
[valid]="(valid | async)"
dsAutoFocus autoFocusSelector=".suggestion_input"
></ds-input-suggestions>
</div>
<small class="text-danger"
@@ -43,17 +46,17 @@
</td>
<td class="col-2 text-center">
<div class="btn-group">
<button [disabled]="!(canSetEditable() | async)" *ngIf="!(editable | async)" (click)="setEditable(true)" class="btn btn-light btn-sm">
<i class="fas fa-edit fa-fw text-primary"></i>
<button [disabled]="!(canSetEditable() | async)" *ngIf="!(editable | async)" (click)="setEditable(true)" class="btn btn-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-light btn-sm">
<i class="fas fa-check fa-fw text-success"></i>
<button [disabled]="!(canSetUneditable() | async)" *ngIf="(editable | async)" (click)="setEditable(false)" class="btn btn-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-light btn-sm">
<i class="fas fa-trash-alt fa-fw text-danger"></i>
<button [disabled]="!(canRemove() | async)" (click)="remove()" class="btn btn-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-light btn-sm">
<i class="fas fa-undo-alt fa-fw text-warning"></i>
<button [disabled]="!(canUndo() | async)" (click)="removeChangesFromField()" class="btn btn-warning btn-sm" title="{{'item.edit.metadata.edit.buttons.undo' | translate}}">
<i class="fas fa-undo-alt fa-fw"></i>
</button>
</div>
</td>

View File

@@ -44,7 +44,7 @@ const metadatum = Object.assign(new Metadatum(), {
language: 'en'
});
const route = 'http://test-url.com/test-url';
const url = 'http://test-url.com/test-url';
const fieldUpdate = {
field: metadatum,
changeType: undefined
@@ -92,7 +92,7 @@ describe('EditInPlaceFieldComponent', () => {
de = fixture.debugElement.query(By.css('div.d-flex'));
el = de.nativeElement;
comp.route = route;
comp.url = url;
comp.fieldUpdate = fieldUpdate;
comp.metadata = metadatum;
@@ -104,8 +104,8 @@ describe('EditInPlaceFieldComponent', () => {
comp.update();
});
it('it should call saveChangeFieldUpdate on the objectUpdatesService with the correct route and metadata', () => {
expect(objectUpdatesService.saveChangeFieldUpdate).toHaveBeenCalledWith(route, metadatum);
it('it should call saveChangeFieldUpdate on the objectUpdatesService with the correct url and metadata', () => {
expect(objectUpdatesService.saveChangeFieldUpdate).toHaveBeenCalledWith(url, metadatum);
});
});
@@ -145,8 +145,8 @@ describe('EditInPlaceFieldComponent', () => {
comp.setEditable(editable);
});
it('it should call setEditableFieldUpdate on the objectUpdatesService with the correct route and uuid and false', () => {
expect(objectUpdatesService.setEditableFieldUpdate).toHaveBeenCalledWith(route, metadatum.uuid, 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);
});
});
@@ -203,8 +203,8 @@ describe('EditInPlaceFieldComponent', () => {
comp.remove();
});
it('it should call saveRemoveFieldUpdate on the objectUpdatesService with the correct route and metadata', () => {
expect(objectUpdatesService.saveRemoveFieldUpdate).toHaveBeenCalledWith(route, metadatum);
it('it should call saveRemoveFieldUpdate on the objectUpdatesService with the correct url and metadata', () => {
expect(objectUpdatesService.saveRemoveFieldUpdate).toHaveBeenCalledWith(url, metadatum);
});
});
@@ -213,8 +213,8 @@ describe('EditInPlaceFieldComponent', () => {
comp.removeChangesFromField();
});
it('it should call removeChangesFromField on the objectUpdatesService with the correct route and uuid', () => {
expect(objectUpdatesService.removeSingleFieldUpdate).toHaveBeenCalledWith(route, metadatum.uuid);
it('it should call removeChangesFromField on the objectUpdatesService with the correct url and uuid', () => {
expect(objectUpdatesService.removeSingleFieldUpdate).toHaveBeenCalledWith(url, metadatum.uuid);
});
});

View File

@@ -10,7 +10,6 @@ import { InputSuggestion } from '../../../../shared/input-suggestions/input-sugg
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 { inListValidator } from '../../../../shared/utils/validator.functions';
import { getSucceededRemoteData } from '../../../../core/shared/operators';
import { FormControl } from '@angular/forms';
@@ -28,9 +27,9 @@ export class EditInPlaceFieldComponent implements OnInit, OnChanges {
*/
@Input() fieldUpdate: FieldUpdate;
/**
* The current route of this page
* The current url of this page
*/
@Input() route: string;
@Input() url: string;
/**
* The metadatum of this field
*/
@@ -65,8 +64,8 @@ export class EditInPlaceFieldComponent implements OnInit, OnChanges {
* 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.route, this.metadata.uuid);
this.valid = this.objectUpdatesService.isValid(this.route, this.metadata.uuid);
this.editable = this.objectUpdatesService.isEditable(this.url, this.metadata.uuid);
this.valid = this.objectUpdatesService.isValid(this.url, this.metadata.uuid);
this.metadataFields = this.findMetadataFields()
}
@@ -74,32 +73,41 @@ export class EditInPlaceFieldComponent implements OnInit, OnChanges {
* Sends a new change update for this field to the object updates service
*/
update(control?: FormControl) {
this.objectUpdatesService.saveChangeFieldUpdate(this.route, this.metadata);
this.objectUpdatesService.saveChangeFieldUpdate(this.url, this.metadata);
if (hasValue(control)) {
this.objectUpdatesService.setValidFieldUpdate(this.route, this.metadata.uuid, control.valid);
this.checkValidity(control);
}
}
/**
* Method to check the validity of a form control
* @param control The form control to check
*/
private checkValidity(control: FormControl) {
control.updateValueAndValidity();
this.objectUpdatesService.setValidFieldUpdate(this.url, this.metadata.uuid, 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.route, this.metadata.uuid, editable);
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.route, this.metadata);
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.route, this.metadata.uuid);
this.objectUpdatesService.removeSingleFieldUpdate(this.url, this.metadata.uuid);
}
/**
@@ -170,15 +178,7 @@ export class EditInPlaceFieldComponent implements OnInit, OnChanges {
* @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 this.editable.pipe(
map((editable: boolean) => {
if (editable) {
return false;
} else {
return this.fieldUpdate.changeType !== FieldChangeType.REMOVE && this.fieldUpdate.changeType !== FieldChangeType.ADD;
}
})
);
return observableOf(this.fieldUpdate.changeType !== FieldChangeType.REMOVE && this.fieldUpdate.changeType !== FieldChangeType.ADD);
}
/**
@@ -186,7 +186,9 @@ export class EditInPlaceFieldComponent implements OnInit, OnChanges {
* @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 observableOf(this.fieldUpdate.changeType >= 0);
return this.editable.pipe(
map((editable: boolean) => this.fieldUpdate.changeType >= 0 || editable)
);
}
protected isNotEmpty(value): boolean {

View File

@@ -5,8 +5,7 @@
(click)="add()"><i
class="fas fa-plus"></i> {{"item.edit.metadata.add-button" | translate}}
</button>
<div class="btn-group btn-group-toggle my-2" data-toggle="buttons">
<div class="my-2 spaced-btn-group">
<button class="btn btn-danger" *ngIf="!(isReinstatable() | async)"
[disabled]="!(hasChanges() | async)"
(click)="discard()"><i
@@ -32,24 +31,26 @@
</tr>
<tr *ngFor="let updateValue of ((updates$ | async)| dsObjectValues); trackBy: trackUpdate">
<ds-edit-in-place-field [fieldUpdate]="updateValue || {}"
[route]="route"></ds-edit-in-place-field>
[url]="url"></ds-edit-in-place-field>
</tr>
</tbody>
</table>
<div class="btn-group btn-group-toggle my-2 float-right" data-toggle="buttons">
<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 class="button-row">
<div class="my-2 float-right spaced-btn-group">
<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>
</div>

View File

@@ -1,5 +1,13 @@
@import '../../../../styles/variables.scss';
.button-row .btn {
min-width: $button-min-width;
.button-row {
.spaced-btn-group > .btn {
margin-right: 0.5 * $spacer;
&:last-child {
margin-right: 0;
}
}
.btn {
min-width: $button-min-width;
}
}

View File

@@ -7,7 +7,7 @@ import { Metadatum } from '../../../core/shared/metadatum.model';
import { TestScheduler } from 'rxjs/testing';
import { SharedModule } from '../../../shared/shared.module';
import { ObjectUpdatesService } from '../../../core/data/object-updates/object-updates.service';
import { Router } from '@angular/router';
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';
@@ -32,6 +32,8 @@ const infoNotification: INotification = new Notification('id', NotificationType.
const warningNotification: INotification = new Notification('id', NotificationType.Warning, 'warning');
const date = new Date();
const router = new RouterStub();
let routeStub;
let itemService;
const notificationsService = jasmine.createSpyObj('notificationsService',
{
@@ -56,9 +58,9 @@ const metadatum3 = Object.assign(new Metadatum(), {
value: 'Shakespeare, William',
});
const route = 'http://test-url.com/test-url';
const url = 'http://test-url.com/test-url';
router.url = route;
router.url = url;
const fieldUpdate1 = {
field: metadatum1,
@@ -84,6 +86,11 @@ describe('ItemMetadataComponent', () => {
update: observableOf(new RemoteData(false, false, true, undefined, item)),
commitUpdates: {}
});
routeStub = {
parent: {
data: observableOf({ item: new RemoteData(false, false, true, null, item) })
}
};
scheduler = getTestScheduler();
objectUpdatesService = jasmine.createSpyObj('objectUpdatesService',
{
@@ -111,6 +118,9 @@ describe('ItemMetadataComponent', () => {
{ provide: ItemDataService, useValue: itemService },
{ provide: ObjectUpdatesService, useValue: objectUpdatesService },
{ provide: Router, useValue: router },
{
provide: ActivatedRoute, useValue: routeStub
},
{ provide: NotificationsService, useValue: notificationsService },
{ provide: GLOBAL_CONFIG, useValue: { notifications: { timeOut: 10 } } as any }
], schemas: [
@@ -118,16 +128,14 @@ describe('ItemMetadataComponent', () => {
]
}).compileComponents();
})
)
;
);
beforeEach(() => {
fixture = TestBed.createComponent(ItemMetadataComponent);
comp = fixture.componentInstance; // EditInPlaceFieldComponent test instance
de = fixture.debugElement.query(By.css('div.d-flex'));
el = de.nativeElement;
comp.item = item;
comp.route = route;
comp.url = url;
fixture.detectChanges();
});
@@ -137,8 +145,8 @@ describe('ItemMetadataComponent', () => {
comp.add(md);
});
it('it should call saveAddFieldUpdate on the objectUpdatesService with the correct route and metadata', () => {
expect(objectUpdatesService.saveAddFieldUpdate).toHaveBeenCalledWith(route, md);
it('it should call saveAddFieldUpdate on the objectUpdatesService with the correct url and metadata', () => {
expect(objectUpdatesService.saveAddFieldUpdate).toHaveBeenCalledWith(url, md);
});
});
@@ -147,8 +155,8 @@ describe('ItemMetadataComponent', () => {
comp.discard();
});
it('it should call discardFieldUpdates on the objectUpdatesService with the correct route and notification', () => {
expect(objectUpdatesService.discardFieldUpdates).toHaveBeenCalledWith(route, infoNotification);
it('it should call discardFieldUpdates on the objectUpdatesService with the correct url and notification', () => {
expect(objectUpdatesService.discardFieldUpdates).toHaveBeenCalledWith(url, infoNotification);
});
});
@@ -157,8 +165,8 @@ describe('ItemMetadataComponent', () => {
comp.reinstate();
});
it('it should call reinstateFieldUpdates on the objectUpdatesService with the correct route', () => {
expect(objectUpdatesService.reinstateFieldUpdates).toHaveBeenCalledWith(route);
it('it should call reinstateFieldUpdates on the objectUpdatesService with the correct url', () => {
expect(objectUpdatesService.reinstateFieldUpdates).toHaveBeenCalledWith(url);
});
});
@@ -167,10 +175,10 @@ describe('ItemMetadataComponent', () => {
comp.submit();
});
it('it should call reinstateFieldUpdates on the objectUpdatesService with the correct route and metadata', () => {
expect(objectUpdatesService.getUpdatedFields).toHaveBeenCalledWith(route, comp.item.metadata);
it('it should call reinstateFieldUpdates on the objectUpdatesService with the correct url and metadata', () => {
expect(objectUpdatesService.getUpdatedFields).toHaveBeenCalledWith(url, comp.item.metadata);
expect(itemService.update).toHaveBeenCalledWith(comp.item);
expect(objectUpdatesService.getFieldUpdates).toHaveBeenCalledWith(route, comp.item.metadata);
expect(objectUpdatesService.getFieldUpdates).toHaveBeenCalledWith(url, comp.item.metadata);
});
});

View File

@@ -2,7 +2,7 @@ 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 { Router } from '@angular/router';
import { ActivatedRoute, Router } from '@angular/router';
import { cloneDeep } from 'lodash';
import { Observable } from 'rxjs';
import {
@@ -31,15 +31,15 @@ export class ItemMetadataComponent implements OnInit {
/**
* The item to display the edit page for
*/
@Input() item: Item;
item: Item;
/**
* The current values and updates for all this item's metadata fields
*/
updates$: Observable<FieldUpdates>;
/**
* The current route of this page
* The current url of this page
*/
route: string;
url: string;
/**
* The time span for being able to undo discarding changes
*/
@@ -51,16 +51,25 @@ export class ItemMetadataComponent implements OnInit {
private router: Router,
private notificationsService: NotificationsService,
private translateService: TranslateService,
@Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig
@Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig,
private route: ActivatedRoute
) {
}
ngOnInit(): void {
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.notifications.timeOut;
this.route = this.router.url;
if (this.route.indexOf('?') > 0) {
this.route = this.route.substr(0, this.route.indexOf('?'));
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) {
@@ -69,7 +78,8 @@ export class ItemMetadataComponent implements OnInit {
this.checkLastModified();
}
});
this.updates$ = this.objectUpdatesService.getFieldUpdates(this.route, this.item.metadata);
this.updates$ = this.objectUpdatesService.getFieldUpdates(this.url, this.item.metadata);
}
/**
@@ -77,7 +87,8 @@ export class ItemMetadataComponent implements OnInit {
* @param metadata The metadata to add, if no parameter is supplied, create a new Metadatum
*/
add(metadata: Metadatum = new Metadatum()) {
this.objectUpdatesService.saveAddFieldUpdate(this.route, metadata);
this.objectUpdatesService.saveAddFieldUpdate(this.url, metadata);
}
/**
@@ -88,21 +99,21 @@ export class ItemMetadataComponent implements OnInit {
const title = this.translateService.instant('item.edit.metadata.notifications.discarded.title');
const content = this.translateService.instant('item.edit.metadata.notifications.discarded.content');
const undoNotification = this.notificationsService.info(title, content, { timeOut: this.discardTimeOut });
this.objectUpdatesService.discardFieldUpdates(this.route, undoNotification);
this.objectUpdatesService.discardFieldUpdates(this.url, undoNotification);
}
/**
* Request the object updates service to undo discarding all changes to this item
*/
reinstate() {
this.objectUpdatesService.reinstateFieldUpdates(this.route);
this.objectUpdatesService.reinstateFieldUpdates(this.url);
}
/**
* Sends all initial values of this item to the object updates service
*/
private initializeOriginalFields() {
this.objectUpdatesService.initialize(this.route, this.item.metadata, this.item.lastModified);
this.objectUpdatesService.initialize(this.url, this.item.metadata, this.item.lastModified);
}
/* Prevent unnecessary rerendering so fields don't lose focus **/
@@ -117,42 +128,42 @@ export class ItemMetadataComponent implements OnInit {
submit() {
this.isValid().pipe(first()).subscribe((isValid) => {
if (isValid) {
const metadata$: Observable<Identifiable[]> = this.objectUpdatesService.getUpdatedFields(this.route, this.item.metadata) as Observable<Metadatum[]>;
metadata$.pipe(
first(),
switchMap((metadata: Metadatum[]) => {
const updatedItem: Item = Object.assign(cloneDeep(this.item), { 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.route, this.item.metadata);
}
)
} else {
const title = this.translateService.instant('item.edit.metadata.notifications.invalid.title');
const content = this.translateService.instant('item.edit.metadata.notifications.invalid.content');
this.notificationsService.error(title, content);
}
});
const metadata$: Observable<Identifiable[]> = this.objectUpdatesService.getUpdatedFields(this.url, this.item.metadata) as Observable<Metadatum[]>;
metadata$.pipe(
first(),
switchMap((metadata: Metadatum[]) => {
const updatedItem: Item = Object.assign(cloneDeep(this.item), { 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.metadata);
}
)
} else {
const title = this.translateService.instant('item.edit.metadata.notifications.invalid.title');
const content = this.translateService.instant('item.edit.metadata.notifications.invalid.content');
this.notificationsService.error(title, content);
}
});
}
/**
* Checks whether or not there are currently updates for this item
*/
hasChanges(): Observable<boolean> {
return this.objectUpdatesService.hasUpdates(this.route);
return this.objectUpdatesService.hasUpdates(this.url);
}
/**
* Checks whether or not the item is currently reinstatable
*/
isReinstatable(): Observable<boolean> {
return this.objectUpdatesService.isReinstatable(this.route);
return this.objectUpdatesService.isReinstatable(this.url);
}
/**
@@ -161,7 +172,7 @@ export class ItemMetadataComponent implements OnInit {
*/
private checkLastModified() {
const currentVersion = this.item.lastModified;
this.objectUpdatesService.getLastModified(this.route).pipe(first()).subscribe(
this.objectUpdatesService.getLastModified(this.url).pipe(first()).subscribe(
(updateVersion: Date) => {
if (updateVersion.getDate() !== currentVersion.getDate()) {
const title = this.translateService.instant('item.edit.metadata.notifications.outdated.title');
@@ -173,7 +184,10 @@ export class ItemMetadataComponent implements OnInit {
);
}
/**
* Check if the current page is entirely valid
*/
private isValid() {
return this.objectUpdatesService.isValidPage(this.route);
return this.objectUpdatesService.isValidPage(this.url);
}
}

View File

@@ -6,11 +6,13 @@ 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 { ActivatedRoute, Router } from '@angular/router';
import { RouterStub } from '../../../shared/testing/router-stub';
import { Item } from '../../../core/shared/item.model';
import { By } from '@angular/platform-browser';
import {CUSTOM_ELEMENTS_SCHEMA} from '@angular/core';
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;
@@ -27,12 +29,19 @@ describe('ItemStatusComponent', () => {
url: `${itemPageUrl}/edit`
});
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 +50,6 @@ describe('ItemStatusComponent', () => {
beforeEach(() => {
fixture = TestBed.createComponent(ItemStatusComponent);
comp = fixture.componentInstance;
comp.item = mockItem;
fixture.detectChanges();
});
@@ -65,4 +73,5 @@ describe('ItemStatusComponent', () => {
expect(statusItemPage.textContent).toContain(itemPageUrl);
});
});
})
;

View File

@@ -1,8 +1,11 @@
import {ChangeDetectionStrategy, Component, Input, OnInit} from '@angular/core';
import {fadeIn, fadeInOut} from '../../../shared/animations/fade';
import {Item} from '../../../core/shared/item.model';
import {Router} from '@angular/router';
import {ItemOperation} from '../item-operation/itemOperation.model';
import { ChangeDetectionStrategy, Component, Input, OnInit } from '@angular/core';
import { fadeIn, fadeInOut } from '../../../shared/animations/fade';
import { Item } from '../../../core/shared/item.model';
import { ActivatedRoute, Router } 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';
@Component({
selector: 'ds-item-status',
@@ -21,7 +24,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,39 +40,46 @@ 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 router: Router, private route: ActivatedRoute) {
}
ngOnInit(): void {
this.statusData = Object.assign({
id: this.item.id,
handle: this.item.handle,
lastModified: this.item.lastModified
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: 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 (item.isWithdrawn) {
this.operations.push(new ItemOperation('reinstate', this.getCurrentUrl() + '/reinstate'));
} else {
this.operations.push(new ItemOperation('withdraw', this.getCurrentUrl() + '/withdraw'));
}
if (item.isDiscoverable) {
this.operations.push(new ItemOperation('private', this.getCurrentUrl() + '/private'));
} else {
this.operations.push(new ItemOperation('public', this.getCurrentUrl() + '/public'));
}
this.operations.push(new ItemOperation('delete', this.getCurrentUrl() + '/delete'));
});
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'));
} else {
this.operations.push(new ItemOperation('withdraw', this.getCurrentUrl() + '/withdraw'));
}
if (this.item.isDiscoverable) {
this.operations.push(new ItemOperation('private', this.getCurrentUrl() + '/private'));
} else {
this.operations.push(new ItemOperation('public', this.getCurrentUrl() + '/public'));
}
this.operations.push(new ItemOperation('delete', this.getCurrentUrl() + '/delete'));
}
/**

View File

@@ -15,7 +15,7 @@ export function getItemEditPath(id: string) {
return new URLCombiner(getItemModulePath(),ITEM_EDIT_PATH.replace(/:id/, id)).toString()
}
const ITEM_EDIT_PATH = ':id/edit/:page';
const ITEM_EDIT_PATH = ':id/edit';
@NgModule({
imports: [
@@ -39,7 +39,7 @@ const ITEM_EDIT_PATH = ':id/edit/:page';
path: ITEM_EDIT_PATH,
loadChildren: './edit-item-page/edit-item-page.module#EditItemPageModule',
canActivate: [AuthenticatedGuard]
}
},
])
],
providers: [

View File

@@ -22,10 +22,10 @@ export class MetadataField implements ListableObject {
@autoserialize
schema: MetadataSchema;
toString(): string {
let key = this.schema.prefix + '.' + this.element;
toString(separator: string = '.'): string {
let key = this.schema.prefix + separator + this.element;
if (isNotEmpty(this.qualifier)) {
key += '.' + this.qualifier;
key += separator + this.qualifier;
}
return key;
}

View File

@@ -5,8 +5,10 @@
(dsClickOutside)="close()">
<input #inputField type="text" [(ngModel)]="value" [name]="name"
class="form-control suggestion_input"
[ngClass]="{'is-invalid': !valid}"
[dsDebounce]="debounceTime" (onDebounce)="find($event)"
[placeholder]="placeholder"
(blur)="blur.emit($event);"
[ngModelOptions]="{standalone: true}" autocomplete="off"/>
<input type="submit" class="d-none"/>
<div class="autocomplete dropdown-menu" [ngClass]="{'show': (show | async) && isNotEmpty(suggestions)}">

View File

@@ -60,6 +60,11 @@ export class InputSuggestionsComponent implements ControlValueAccessor, OnChange
*/
@Input() name;
/**
* Whether or not the current input is valid
*/
@Input() valid;
/**
* Output for when the form is submitted
*/
@@ -80,6 +85,11 @@ export class InputSuggestionsComponent implements ControlValueAccessor, OnChange
*/
@Output() findSuggestions = new EventEmitter();
/**
* Emits event when the input field loses focus
*/
@Output() blur = new EventEmitter();
/**
* Emits true when the list of suggestions should be shown
*/
@@ -238,4 +248,8 @@ export class InputSuggestionsComponent implements ControlValueAccessor, OnChange
this._value = val;
this.propagateChange(this._value);
}
focus() {
this.queryInput.nativeElement.focus();
}
}

View File

@@ -93,6 +93,7 @@ import { DeleteComColPageComponent } from './comcol-forms/delete-comcol-page/del
import { LangSwitchComponent } from './lang-switch/lang-switch.component';
import { ObjectValuesPipe } from './utils/object-values-pipe';
import { InListValidator } from './utils/in-list-validator.directive';
import { AutoFocusDirective } from './utils/auto-focus.directive';
const MODULES = [
// Do NOT include UniversalModule, HttpModule, or JsonpModule here
@@ -199,7 +200,8 @@ const DIRECTIVES = [
DragClickDirective,
DebounceDirective,
ClickOutsideDirective,
InListValidator
InListValidator,
AutoFocusDirective
];
@NgModule({

View File

@@ -0,0 +1,22 @@
import { Directive, AfterViewInit, ElementRef, Input } from '@angular/core';
import { isNotEmpty } from '../empty.util';
@Directive({
selector: '[dsAutoFocus]'
})
export class AutoFocusDirective implements AfterViewInit {
@Input() autoFocusSelector: string;
constructor(private el: ElementRef) {
}
ngAfterViewInit() {
if (isNotEmpty(this.autoFocusSelector)) {
return this.el.nativeElement.querySelector(this.autoFocusSelector).focus();
} else {
return this.el.nativeElement.focus();
}
}
}