From 8b1fdbae18e112a81af0150cb9ac102a5e408628 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Kilchenmann?= Date: Mon, 26 Jul 2021 10:19:20 +0200 Subject: [PATCH] feat(resource): delete and erase resource (DSP-1228) (#489) * chore(deps): bump dsp-js to latest version * feat(resource): delete and erase resource (DSP-1228) * chore(resource): handle permissions * test(resource): fix the tests * fix(resource): get last modification date after editing a prop value * test(resource): big fixes in tests * refactor(resource): better lastModificationDate handling --- package-lock.json | 31 ++-- package.json | 2 +- src/app/main/dialog/dialog.component.html | 39 +++++ src/app/main/dialog/dialog.component.scss | 15 +- src/app/main/dialog/dialog.component.ts | 13 +- .../properties/properties.component.html | 67 +++++---- .../properties/properties.component.scss | 10 +- .../properties/properties.component.spec.ts | 24 +++- .../properties/properties.component.ts | 133 ++++++++++++++++-- .../resource-instance-form.component.spec.ts | 6 +- .../resource/resource.component.html | 30 +++- .../workspace/resource/resource.component.ts | 30 +++- .../workspace/results/results.component.scss | 5 - src/assets/style/_elements.scss | 4 + 14 files changed, 328 insertions(+), 81 deletions(-) diff --git a/package-lock.json b/package-lock.json index c83f733069..9dcac47ce0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,7 @@ "@angular/platform-browser-dynamic": "^11.2.9", "@angular/router": "^11.2.9", "@ckeditor/ckeditor5-angular": "^1.2.3", - "@dasch-swiss/dsp-js": "file:.yalc/@dasch-swiss/dsp-js", + "@dasch-swiss/dsp-js": "^2.7.0", "@dasch-swiss/dsp-ui": "^1.6.0", "@ngx-translate/core": "^12.1.2", "@ngx-translate/http-loader": "5.0.0", @@ -76,18 +76,6 @@ "typescript": "4.0.7" } }, - ".yalc/@dasch-swiss/dsp-js": { - "version": "2.6.1", - "license": "AGPL-3.0", - "dependencies": { - "@types/jsonld": "^1.5.0", - "json2typescript": "1.4.1", - "jsonld": "^5.2.0" - }, - "peerDependencies": { - "rxjs": "6.x" - } - }, "node_modules/@angular-devkit/architect": { "version": "0.1102.14", "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.1102.14.tgz", @@ -2257,8 +2245,17 @@ } }, "node_modules/@dasch-swiss/dsp-js": { - "resolved": ".yalc/@dasch-swiss/dsp-js", - "link": true + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@dasch-swiss/dsp-js/-/dsp-js-2.7.0.tgz", + "integrity": "sha512-E7ZPoxMLHaX0wxYo7HNJHuuXT5JLoXrxzeh04yQwWvvYJLSrBBSZjYZ1ZDpsCCsZgwievjx1OkPOvlAbHU7LFg==", + "dependencies": { + "@types/jsonld": "^1.5.0", + "json2typescript": "1.4.1", + "jsonld": "^5.2.0" + }, + "peerDependencies": { + "rxjs": "6.x" + } }, "node_modules/@dasch-swiss/dsp-ui": { "version": "1.6.0", @@ -21723,7 +21720,9 @@ } }, "@dasch-swiss/dsp-js": { - "version": "file:.yalc/@dasch-swiss/dsp-js", + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@dasch-swiss/dsp-js/-/dsp-js-2.7.0.tgz", + "integrity": "sha512-E7ZPoxMLHaX0wxYo7HNJHuuXT5JLoXrxzeh04yQwWvvYJLSrBBSZjYZ1ZDpsCCsZgwievjx1OkPOvlAbHU7LFg==", "requires": { "@types/jsonld": "^1.5.0", "json2typescript": "1.4.1", diff --git a/package.json b/package.json index f7fce2e049..566f4cb4ae 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "@angular/platform-browser-dynamic": "^11.2.9", "@angular/router": "^11.2.9", "@ckeditor/ckeditor5-angular": "^1.2.3", - "@dasch-swiss/dsp-js": "^2.6.2", + "@dasch-swiss/dsp-js": "^2.7.0", "@dasch-swiss/dsp-ui": "^1.6.0", "@ngx-translate/core": "^12.1.2", "@ngx-translate/http-loader": "5.0.0", diff --git a/src/app/main/dialog/dialog.component.html b/src/app/main/dialog/dialog.component.html index 741fa760ab..3b4b5c18bd 100644 --- a/src/app/main/dialog/dialog.component.html +++ b/src/app/main/dialog/dialog.component.html @@ -324,6 +324,45 @@ +
+ + + Do you want to delete this resource? + + + + + + + +
+ +
+ + Do you want to erase this resource forever?
WARNING: This action cannot be undone, so use it with care. + + + + + +
+
diff --git a/src/app/main/dialog/dialog.component.scss b/src/app/main/dialog/dialog.component.scss index 66aa11bc54..aef24dac6b 100644 --- a/src/app/main/dialog/dialog.component.scss +++ b/src/app/main/dialog/dialog.component.scss @@ -1,6 +1,13 @@ .todo { - background-color: bisque; - padding: 6px; - border-radius: 4px; - text-align: center; + background-color: bisque; + padding: 6px; + border-radius: 4px; + text-align: center; +} + +.deletion-comment { + width: calc(100% - 48px); + margin: 8px; + padding: 8px; + height: 64px; } diff --git a/src/app/main/dialog/dialog.component.ts b/src/app/main/dialog/dialog.component.ts index 5bfa2ad601..1dd784b289 100644 --- a/src/app/main/dialog/dialog.component.ts +++ b/src/app/main/dialog/dialog.component.ts @@ -1,4 +1,4 @@ -import { Component, Inject, OnInit } from '@angular/core'; +import { Component, Inject, OnInit, ViewChild } from '@angular/core'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { PropertyInfoObject } from 'src/app/project/ontology/default-data/default-properties'; @@ -18,6 +18,11 @@ export interface DialogData { projectCode?: string; } +export interface ConfirmationWithComment { + confirmed: boolean; + comment?: string; +} + @Component({ selector: 'app-material-dialog', templateUrl: './dialog.component.html', @@ -27,6 +32,8 @@ export class DialogComponent implements OnInit { notYetImplemented = `The component ${this.data.mode} is not implemented yet.`; + comment?: string; + constructor( public dialogRef: MatDialogRef, @Inject(MAT_DIALOG_DATA) public data: DialogData @@ -45,4 +52,8 @@ export class DialogComponent implements OnInit { this.data.subtitle = heading.subtitle; } } + + onKey(event: KeyboardEvent) { + this.comment = (event.target as HTMLInputElement).value; + } } diff --git a/src/app/workspace/resource/properties/properties.component.html b/src/app/workspace/resource/properties/properties.component.html index 85f6bf79af..6928e34c6e 100644 --- a/src/app/workspace/resource/properties/properties.component.html +++ b/src/app/workspace/resource/properties/properties.component.html @@ -1,26 +1,20 @@ -
+

- {{resource.res.label}} + {{resource.res.label}} (deleted)

- - - + - @@ -30,25 +24,46 @@

--> - - - -
- -
-
- - + + + + + + + + + +
diff --git a/src/app/workspace/resource/properties/properties.component.scss b/src/app/workspace/resource/properties/properties.component.scss index 19ab7298c5..e9cac7aab1 100644 --- a/src/app/workspace/resource/properties/properties.component.scss +++ b/src/app/workspace/resource/properties/properties.component.scss @@ -13,7 +13,11 @@ color: rgba(0, 0, 0, 0.87); } .toolbar { - background: whitesmoke; + background: $primary_200; + + &.deleted { + background: $warn; + } .label { margin: 0 !important; @@ -22,6 +26,10 @@ overflow: hidden; text-overflow: ellipsis; } + + .action button { + border-radius: 0; + } } .infobar { diff --git a/src/app/workspace/resource/properties/properties.component.spec.ts b/src/app/workspace/resource/properties/properties.component.spec.ts index 502db0e208..16ef6d17da 100644 --- a/src/app/workspace/resource/properties/properties.component.spec.ts +++ b/src/app/workspace/resource/properties/properties.component.spec.ts @@ -189,6 +189,26 @@ describe('PropertiesComponent', () => { voeService = TestBed.inject(ValueOperationEventService); })); + // mock localStorage + beforeEach(() => { + let store = {}; + + spyOn(localStorage, 'getItem').and.callFake( + (key: string): string => store[key] || null + ); + spyOn(localStorage, 'removeItem').and.callFake( + (key: string): void => { + delete store[key]; + } + ); + spyOn(localStorage, 'setItem').and.callFake( + (key: string, value: string): string => (store[key] = value) + ); + spyOn(localStorage, 'clear').and.callFake(() => { + store = {}; + }); + }); + beforeEach(() => { const adminSpy = TestBed.inject(DspApiConnectionToken); @@ -254,14 +274,14 @@ describe('PropertiesComponent', () => { const resLabelDebugElement = propertyToolbarComponentDe.query(By.css('button.toggle-props')); const resLabelNativeElement = resLabelDebugElement.nativeElement; // the button contains an icon "unfold_more" and the text "Increase properties" - expect(resLabelNativeElement.textContent.trim()).toBe('unfold_moreShow all properties'); + expect(resLabelNativeElement.textContent.trim()).toBe('unfold_more'); resLabelNativeElement.click(); testHostFixture.detectChanges(); // the button contains an icon "unfold_less" and the text "Decrease properties" - expect(resLabelNativeElement.textContent.trim()).toBe('unfold_lessHide empty properties'); + expect(resLabelNativeElement.textContent.trim()).toBe('unfold_less'); }); }); diff --git a/src/app/workspace/resource/properties/properties.component.ts b/src/app/workspace/resource/properties/properties.component.ts index 90dfc7956d..2b8afb20ec 100644 --- a/src/app/workspace/resource/properties/properties.component.ts +++ b/src/app/workspace/resource/properties/properties.component.ts @@ -1,16 +1,20 @@ import { Component, EventEmitter, Inject, Input, OnChanges, OnDestroy, OnInit, Output } from '@angular/core'; +import { MatDialog, MatDialogConfig } from '@angular/material/dialog'; import { MatSnackBar } from '@angular/material/snack-bar'; import { ApiResponseData, ApiResponseError, CardinalityUtil, Constants, + DeleteResource, + DeleteResourceResponse, DeleteValue, KnoraApiConnection, PermissionUtil, ProjectResponse, ReadLinkValue, ReadProject, + ReadResource, ReadResourceSequence, ReadTextValueAsXml, ReadUser, @@ -31,6 +35,8 @@ import { ValueService } from '@dasch-swiss/dsp-ui'; import { Subscription } from 'rxjs'; +import { ConfirmationWithComment, DialogComponent } from 'src/app/main/dialog/dialog.component'; +import { ErrorHandlerService } from 'src/app/main/error/error-handler.service'; import { DspResource } from '../dsp-resource'; import { RepresentationConstants } from '../representation/file-representation'; @@ -53,6 +59,16 @@ export class PropertiesComponent implements OnInit, OnChanges, OnDestroy { */ @Input() displayProjectInfo: false; + /** + * does the logged-in user has system or project admin permissions? + */ + @Input() adminPermissions: false; + + /** + * is the logged-in user project member? + */ + @Input() editPermissions: false; + /** * output `referredProjectClicked` of resource view component: * can be used to go to project page @@ -77,6 +93,10 @@ export class PropertiesComponent implements OnInit, OnChanges, OnDestroy { */ @Output() referredResourceHovered: EventEmitter = new EventEmitter(); + lastModificationDate: string; + + deletedResource = false; + addButtonIsVisible: boolean; // used to toggle add value button addValueFormIsVisible: boolean; // used to toggle add value form field propID: string; // used in template to show only the add value form of the corresponding value @@ -92,8 +112,9 @@ export class PropertiesComponent implements OnInit, OnChanges, OnDestroy { constructor( @Inject(DspApiConnectionToken) private _dspApiConnection: KnoraApiConnection, + private _dialog: MatDialog, + private _errorHandler: ErrorHandlerService, private _notification: NotificationService, - private _snackBar: MatSnackBar, private _userService: UserService, private _valueOperationEventService: ValueOperationEventService, private _valueService: ValueService @@ -106,6 +127,9 @@ export class PropertiesComponent implements OnInit, OnChanges, OnDestroy { this.resource.res.userHasPermission as 'RV' | 'V' | 'M' | 'D' | 'CR' ); + // get last modification date + this.lastModificationDate = this.resource.res.lastModificationDate; + // if user has modify permissions, set addButtonIsVisible to true so the user see's the add button this.addButtonIsVisible = allPermissions.indexOf(PermissionUtil.Permissions.M) !== -1; } @@ -118,6 +142,7 @@ export class PropertiesComponent implements OnInit, OnChanges, OnDestroy { this.valueOperationEventSubscriptions.push(this._valueOperationEventService.on( Events.ValueAdded, (newValue: AddedEventValue) => { if (newValue) { + this.lastModificationDate = newValue.addedValue.valueCreationDate; this.addValueToResource(newValue.addedValue); this.hideAddValueForm(); } @@ -125,13 +150,25 @@ export class PropertiesComponent implements OnInit, OnChanges, OnDestroy { this.valueOperationEventSubscriptions.push(this._valueOperationEventService.on( Events.ValueUpdated, (updatedValue: UpdatedEventValues) => { + this.lastModificationDate = updatedValue.updatedValue.valueCreationDate; this.updateValueInResource(updatedValue.currentValue, updatedValue.updatedValue); this.hideAddValueForm(); })); this.valueOperationEventSubscriptions.push(this._valueOperationEventService.on( - Events.ValueDeleted, (deletedValue: DeletedEventValue) => this.deleteValueFromResource(deletedValue.deletedValue) - )); + Events.ValueDeleted, (deletedValue: DeletedEventValue) => { + // the DeletedEventValue does not contain a creation or last modification date + // so, we have to grab it from res info + this._getLastModificationDate(this.resource.res.id); + this.deleteValueFromResource(deletedValue.deletedValue); + })); + + // keep the information if the user wants to display all properties or not + if (localStorage.getItem('showAllProps')) { + this.showAllProps = JSON.parse(localStorage.getItem('showAllProps')); + } else { + localStorage.setItem('showAllProps', JSON.stringify(this.showAllProps)); + } } ngOnChanges(): void { @@ -180,25 +217,79 @@ export class PropertiesComponent implements OnInit, OnChanges, OnDestroy { * opens resource * @param linkValue */ - openResource(linkValue: ReadLinkValue) { - window.open('/resource/' + encodeURIComponent(linkValue.linkedResource.id), '_blank'); + openResource(linkValue: ReadLinkValue | string) { + const id = ((typeof linkValue == 'string') ? linkValue : linkValue.linkedResourceIri); + window.open('/resource/' + encodeURIComponent(id), '_blank'); } previewResource(linkValue: ReadLinkValue) { // --> TODO: pop up resource preview on hover } + openDialog(type: 'delete' | 'erase') { + const dialogConfig: MatDialogConfig = { + width: '560px', + maxHeight: '80vh', + position: { + top: '112px' + }, + data: { mode: type + 'Resource', title: this.resource.res.label } + }; + + const dialogRef = this._dialog.open( + DialogComponent, + dialogConfig + ); + + dialogRef.afterClosed().subscribe((answer: ConfirmationWithComment) => { + + if (answer.confirmed === true) { + + const payload = new DeleteResource(); + payload.id = this.resource.res.id; + payload.type = this.resource.res.type; + payload.deleteComment = answer.comment ? answer.comment : undefined; + payload.lastModificationDate = this.lastModificationDate; + switch (type) { + case 'delete': + // delete the resource and refresh the view + this._dspApiConnection.v2.res.deleteResource(payload).subscribe( + (response: DeleteResourceResponse) => { + // display notification and mark resource as 'deleted' + this._notification.openSnackBar(`${response.result}: ${this.resource.res.label}`); + this.deletedResource = true; + }, + (error: ApiResponseError) => { + this._errorHandler.showMessage(error); + } + ); + break; + + case 'erase': + // erase the resource and refresh the view + this._dspApiConnection.v2.res.eraseResource(payload).subscribe( + (response: DeleteResourceResponse) => { + // display notification and mark resource as 'erased' + this._notification.openSnackBar(`${response.result}: ${this.resource.res.label}`); + this.deletedResource = true; + }, + (error: ApiResponseError) => { + this._errorHandler.showMessage(error); + } + ); + break; + } + + } + }); + } + /** * display message to confirm the copy of the citation link (ARK URL) */ openSnackBar() { - const message = 'Copied to clipboard!'; - const action = 'Citation Link'; - this._snackBar.open(message, action, { - duration: 3000, - horizontalPosition: 'center', - verticalPosition: 'top' - }); + const message = 'ARK URL copied to clipboard!'; + this._notification.openSnackBar(message); } /** @@ -251,8 +342,10 @@ export class PropertiesComponent implements OnInit, OnChanges, OnDestroy { this.resource.resProps .filter(propInfoValueArray => propInfoValueArray.propDef.id === valueToAdd.property) // filter to the correct property - .forEach(propInfoValue => - propInfoValue.values.push(valueToAdd)); // push new value to array + .forEach(propInfoValue => { + propInfoValue.values.push(valueToAdd); // push new value to array + }); + if (valueToAdd instanceof ReadTextValueAsXml) { this._updateStandoffLinkValue(); } @@ -302,6 +395,7 @@ export class PropertiesComponent implements OnInit, OnChanges, OnDestroy { filteredpropInfoValueArray.values.forEach((val, index) => { // loop through each value of the current property if (val.id === valueToDelete.id) { // find the value that was deleted using the id filteredpropInfoValueArray.values.splice(index, 1); // remove the value from the values array + if (val instanceof ReadTextValueAsXml) { this._updateStandoffLinkValue(); } @@ -314,6 +408,11 @@ export class PropertiesComponent implements OnInit, OnChanges, OnDestroy { } } + toggleAllProps(status: boolean) { + this.showAllProps = !status; + localStorage.setItem('showAllProps', JSON.stringify(this.showAllProps)); + } + /** * updates the standoff link value for the resource being displayed. * @@ -372,4 +471,10 @@ export class PropertiesComponent implements OnInit, OnChanges, OnDestroy { ); } + + private _getLastModificationDate(resId: string) { + this._dspApiConnection.v2.res.getResource(resId).subscribe( + (res: ReadResource) => this.lastModificationDate = res.lastModificationDate + ); + } } diff --git a/src/app/workspace/resource/resource-instance-form/resource-instance-form.component.spec.ts b/src/app/workspace/resource/resource-instance-form/resource-instance-form.component.spec.ts index 03b14354fb..09dd2c7578 100644 --- a/src/app/workspace/resource/resource-instance-form/resource-instance-form.component.spec.ts +++ b/src/app/workspace/resource/resource-instance-form/resource-instance-form.component.spec.ts @@ -12,7 +12,6 @@ import { MatSelectModule } from '@angular/material/select'; import { MatSnackBarModule } from '@angular/material/snack-bar'; import { By } from '@angular/platform-browser'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { Router } from '@angular/router'; import { RouterTestingModule } from '@angular/router/testing'; import { ApiResponseData, @@ -42,6 +41,7 @@ import { of } from 'rxjs'; import { AjaxResponse } from 'rxjs/ajax'; import { CacheService } from 'src/app/main/cache/cache.service'; import { BaseValueDirective } from 'src/app/main/directive/base-value.directive'; +import { ResourceComponent } from '../resource.component'; import { ResourceInstanceFormComponent } from './resource-instance-form.component'; import { SwitchPropertiesComponent } from './select-properties/switch-properties/switch-properties.component'; @@ -275,7 +275,9 @@ describe('ResourceInstanceFormComponent', () => { MatSelectModule, MatSnackBarModule, ReactiveFormsModule, - RouterTestingModule, + RouterTestingModule.withRoutes([ + { path: 'resource', component: ResourceComponent } + ]), TranslateModule.forRoot() ], providers: [ diff --git a/src/app/workspace/resource/resource.component.html b/src/app/workspace/resource/resource.component.html index 9216dd9524..2be66434eb 100644 --- a/src/app/workspace/resource/resource.component.html +++ b/src/app/workspace/resource/resource.component.html @@ -1,11 +1,12 @@
-
+
- @@ -28,19 +29,22 @@ - + - + - +
- +
@@ -58,5 +63,16 @@
- + + + +
+

The resource - {{resourceIri}} - could not + be found.

+

Reasons:

+
    +
  • It could be a deleted resource and does not exist anymore.
  • +
  • The identifier or the ARK URL is wrong.
  • +
+
diff --git a/src/app/workspace/resource/resource.component.ts b/src/app/workspace/resource/resource.component.ts index 535bdaaa7d..c280ae43a8 100644 --- a/src/app/workspace/resource/resource.component.ts +++ b/src/app/workspace/resource/resource.component.ts @@ -12,6 +12,7 @@ import { ApiResponseError, Constants, CountQueryResponse, + DeleteResourceResponse, IHasPropertyWithPropertyDefinition, KnoraApiConnection, ReadAudioFileValue, @@ -23,6 +24,8 @@ import { DspApiConnectionToken, NotificationService, PropertyInfoValues, + Session, + SessionService, ValueOperationEventService } from '@dasch-swiss/dsp-ui'; import { Subscription } from 'rxjs'; @@ -71,6 +74,11 @@ export class ResourceComponent implements OnInit, OnChanges, OnDestroy { refresh: boolean; + // permissions of logged-in user + session: Session; + adminPermissions = false; + editPermissions = false; + navigationSubscription: Subscription; constructor( @@ -79,6 +87,7 @@ export class ResourceComponent implements OnInit, OnChanges, OnDestroy { private _notification: NotificationService, private _route: ActivatedRoute, private _router: Router, + private _session: SessionService, private _titleService: Title ) { @@ -116,7 +125,6 @@ export class ResourceComponent implements OnInit, OnChanges, OnDestroy { ngOnInit() { - } ngOnChanges() { @@ -192,6 +200,16 @@ export class ResourceComponent implements OnInit, OnChanges, OnDestroy { this.resource = res; + // get information about the logged-in user, if one is logged-in + if (this._session.getSession()) { + this.session = this._session.getSession(); + // is the logged-in user project member? + // --> TODO: as soon as we know how to handle the permissions, set this value the correct way + this.editPermissions = true; + // is the logged-in user system admin or project admin? + this.adminPermissions = this.session.user.sysAdmin ? this.session.user.sysAdmin : this.session.user.projectAdmin.some(e => e === res.res.attachedToProject); + } + this.collectRepresentationsAndAnnotations(this.resource); if (!this.representationsToDisplay.length && !this.compoundPosition) { @@ -221,7 +239,15 @@ export class ResourceComponent implements OnInit, OnChanges, OnDestroy { this.loading = false; }, (error: ApiResponseError) => { - this._notification.openSnackBar(error); + this.loading = false; + if (error.status === 404) { + // resource not found: maybe it's deleted or the iri is wrong + // display message that it couldn't be found + + } else { + this._notification.openSnackBar(error); + } + } ); } diff --git a/src/app/workspace/results/results.component.scss b/src/app/workspace/results/results.component.scss index 9cedbcb74d..5636074623 100644 --- a/src/app/workspace/results/results.component.scss +++ b/src/app/workspace/results/results.component.scss @@ -3,8 +3,3 @@ .content { height: calc(100vh - #{$header-height}); } - -.no-results { - margin: 64px auto; - width: 400px; -} diff --git a/src/assets/style/_elements.scss b/src/assets/style/_elements.scss index 59361cbbd0..7c0748e68a 100644 --- a/src/assets/style/_elements.scss +++ b/src/assets/style/_elements.scss @@ -391,6 +391,10 @@ a, } } +.no-results { + margin: 64px auto; + width: 400px; +} // --------------------------------------