From 5d5eabf2521bd71a2b3055fc1f8b5b0c6ff043a5 Mon Sep 17 00:00:00 2001
From: mdelez <60604010+mdelez@users.noreply.github.com>
Date: Wed, 17 Feb 2021 15:36:01 +0100
Subject: [PATCH] feat(list-editor): delete list root node (DSP-1356) (#386)
* feat: adds support for deleting a root node if not in use
* feat: switches the buttons from using text to using mat-icons. Also adds some super fancy CSS.
* test: starting point for unit tests
* test: adds unit tests for list component
---
src/app/project/list/list.component.html | 12 +-
src/app/project/list/list.component.scss | 14 +
src/app/project/list/list.component.spec.ts | 345 +++++++++++++++++---
src/app/project/list/list.component.ts | 31 +-
4 files changed, 345 insertions(+), 57 deletions(-)
diff --git a/src/app/project/list/list.component.html b/src/app/project/list/list.component.html
index 3ba53d39d2..356c4f59da 100644
--- a/src/app/project/list/list.component.html
+++ b/src/app/project/list/list.component.html
@@ -66,9 +66,15 @@
{{list.comments | dspStringifyStringLiteral | dspTruncate:64}}
-
- Edit info
+
+ edit
+
+
+ delete
diff --git a/src/app/project/list/list.component.scss b/src/app/project/list/list.component.scss
index abed5d5d7d..384c8a8322 100644
--- a/src/app/project/list/list.component.scss
+++ b/src/app/project/list/list.component.scss
@@ -16,3 +16,17 @@
min-width: 140px;
}
}
+
+.list-editor {
+ mat-toolbar-row {
+ button {
+ margin: 0% 0.5%;
+ padding: 1%;
+ min-width: 6%;
+ border-radius: 40px;
+ }
+ button:hover {
+ background-color: #ebebeb;
+ }
+ }
+}
diff --git a/src/app/project/list/list.component.spec.ts b/src/app/project/list/list.component.spec.ts
index 8d435fd0d6..0c13d748a4 100644
--- a/src/app/project/list/list.component.spec.ts
+++ b/src/app/project/list/list.component.spec.ts
@@ -1,63 +1,105 @@
-import { HttpClientModule } from '@angular/common/http';
+import { OverlayContainer } from '@angular/cdk/overlay';
+import { HarnessLoader } from '@angular/cdk/testing';
+import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
+import { Component, CUSTOM_ELEMENTS_SCHEMA, ViewChild } from '@angular/core';
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
-import { MatChipsModule } from '@angular/material/chips';
-import { MatDialogModule } from '@angular/material/dialog';
-import { MatFormFieldModule } from '@angular/material/form-field';
+import { MatButtonHarness } from '@angular/material/button/testing';
+import { MatDialogModule, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
+import { MatDialogHarness } from '@angular/material/dialog/testing';
import { MatIconModule } from '@angular/material/icon';
-import { MatMenuModule } from '@angular/material/menu';
import { MatSelectModule } from '@angular/material/select';
+import { MatSelectHarness } from '@angular/material/select/testing';
import { MatToolbarModule } from '@angular/material/toolbar';
import { MatTooltipModule } from '@angular/material/tooltip';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
-import { ActivatedRoute } from '@angular/router';
-import { RouterTestingModule } from '@angular/router/testing';
-import { KnoraApiConnection } from '@dasch-swiss/dsp-js';
+import { ActivatedRoute, Router } from '@angular/router';
+import { ApiResponseData, DeleteListResponse, ListNodeInfo, ListsEndpointAdmin, ListsResponse, MockProjects, ProjectResponse, ProjectsEndpointAdmin } from '@dasch-swiss/dsp-js';
import {
- AppInitService,
DspActionModule,
- DspApiConfigToken,
DspApiConnectionToken,
- DspCoreModule
+ DspCoreModule,
+ Session,
+ SessionService
} from '@dasch-swiss/dsp-ui';
+import { TranslateModule } from '@ngx-translate/core';
import { of } from 'rxjs';
+import { AjaxResponse } from 'rxjs/ajax';
+import { CacheService } from 'src/app/main/cache/cache.service';
+import { DialogHeaderComponent } from 'src/app/main/dialog/dialog-header/dialog-header.component';
import { DialogComponent } from 'src/app/main/dialog/dialog.component';
-import { ErrorComponent } from 'src/app/main/error/error.component';
import { TestConfig } from 'test.config';
-import { ListItemFormComponent } from './list-item-form/list-item-form.component';
-import { ListItemComponent } from './list-item/list-item.component';
import { ListComponent } from './list.component';
+/**
+ * Test Host Component
+ */
+@Component({
+ template: ` `
+})
+class TestHostComponent {
+ @ViewChild('listComponent') listComponent: ListComponent;
+}
+
+/**
+ * Mock ListItem.
+ */
+@Component({
+ template: ` `
+})
+class MockListItemComponent { }
+
+/**
+ * Mock ListItemForm.
+ */
+@Component({
+ template: ` `
+})
+class MockListItemFormComponent { }
+
describe('ListComponent', () => {
- let component: ListComponent;
- let fixture: ComponentFixture;
+ let testHostComponent: TestHostComponent;
+ let testHostFixture: ComponentFixture;
+ let rootLoader: HarnessLoader;
+ let overlayContainer: OverlayContainer;
beforeEach(async(() => {
+
+ const dspConnSpyObj = {
+ admin: {
+ listsEndpoint: jasmine.createSpyObj('listsEndpoint', ['getListsInProject', 'deleteListNode']),
+ projectsEndpoint: jasmine.createSpyObj('projectsEndpoint', ['getProjectByShortcode'])
+ }
+ };
+
+ const sessionServiceSpy = jasmine.createSpyObj('SessionService', ['getSession']);
+
+ const cacheServiceSpy = jasmine.createSpyObj('CacheService', ['get']);
+
+ const routerSpy = jasmine.createSpyObj('Router', ['navigate', 'navigateByUrl']);
+
TestBed.configureTestingModule({
declarations: [
ListComponent,
- ListItemComponent,
- ListItemFormComponent,
+ TestHostComponent,
+ MockListItemComponent,
+ MockListItemFormComponent,
DialogComponent,
- ErrorComponent
+ DialogHeaderComponent
],
imports: [
BrowserAnimationsModule,
DspActionModule,
DspCoreModule,
- HttpClientModule,
MatButtonModule,
- MatChipsModule,
MatDialogModule,
- MatFormFieldModule,
MatIconModule,
- MatMenuModule,
MatSelectModule,
MatToolbarModule,
MatTooltipModule,
ReactiveFormsModule,
- RouterTestingModule
+ TranslateModule.forRoot()
],
providers: [
{
@@ -74,55 +116,252 @@ describe('ListComponent', () => {
}
}
},
- AppInitService,
{
- provide: DspApiConfigToken,
- useValue: TestConfig.ApiConfig
+ provide: DspApiConnectionToken,
+ useValue: dspConnSpyObj
},
{
- provide: DspApiConnectionToken,
- useValue: new KnoraApiConnection(TestConfig.ApiConfig)
+ provide: SessionService,
+ useValue: sessionServiceSpy
+ },
+ {
+ provide: CacheService,
+ useValue: cacheServiceSpy
+ },
+ {
+ provide: Router,
+ useValue: routerSpy
+ },
+ {
+ provide: MAT_DIALOG_DATA,
+ useValue: {}
+ },
+ {
+ provide: MatDialogRef,
+ useValue: {}
}
- ]
+ ],
+ schemas: [CUSTOM_ELEMENTS_SCHEMA]
}).compileComponents();
}));
- // mock localStorage
beforeEach(() => {
- let store = {};
- spyOn(localStorage, 'getItem').and.callFake(
- (key: string): string => {
- return store[key] || null;
+ // mock session service
+ const sessionSpy = TestBed.inject(SessionService);
+
+ (sessionSpy as jasmine.SpyObj).getSession.and.callFake(
+ () => {
+ const session: Session = {
+ id: 12345,
+ user: {
+ name: 'username',
+ jwt: 'myToken',
+ lang: 'en',
+ sysAdmin: true,
+ projectAdmin: []
+ }
+ };
+
+ return session;
}
);
- spyOn(localStorage, 'removeItem').and.callFake(
- (key: string): void => {
- delete store[key];
+
+ // mock cache service
+ const cacheSpy = TestBed.inject(CacheService);
+
+ (cacheSpy as jasmine.SpyObj).get.and.callFake(
+ () => {
+ const response: ProjectResponse = new ProjectResponse();
+
+ const mockProjects = MockProjects.mockProjects();
+
+ response.project = mockProjects.body.projects[0];
+
+ return of(ApiResponseData.fromAjaxResponse({response} as AjaxResponse));
}
);
- spyOn(localStorage, 'setItem').and.callFake(
- (key: string, value: string): string => {
- return (store[key] = value);
+
+ // mock router
+ const routerSpy = TestBed.inject(Router);
+
+ (routerSpy as jasmine.SpyObj).navigate.and.stub();
+ (routerSpy as jasmine.SpyObj).navigateByUrl.and.stub();
+
+ // mock lists endpoint
+ const dspConnSpy = TestBed.inject(DspApiConnectionToken);
+
+ (dspConnSpy.admin.listsEndpoint as jasmine.SpyObj).getListsInProject.and.callFake(
+ () => {
+ const response = new ListsResponse();
+
+ response.lists = new Array();
+
+ const mockList1 = new ListNodeInfo();
+ mockList1.comments = [];
+ mockList1.id = 'http://rdfh.ch/lists/0001/mockList01';
+ mockList1.isRootNode = true;
+ mockList1.labels = [{language: 'en', value: 'Mock List 01'}];
+ mockList1.projectIri = 'http://rdfh.ch/projects/myProjectIri';
+
+ const mockList2 = new ListNodeInfo();
+ mockList2.comments = [];
+ mockList2.id = 'http://rdfh.ch/lists/0001/mockList02';
+ mockList2.isRootNode = true;
+ mockList2.labels = [{language: 'en', value: 'Mock List 02'}];
+ mockList2.projectIri = 'http://rdfh.ch/projects/myProjectIri';
+
+ response.lists.push(mockList1, mockList2);
+
+ return of(ApiResponseData.fromAjaxResponse({response} as AjaxResponse));
}
);
- spyOn(localStorage, 'clear').and.callFake(() => {
- store = {};
- });
+
+ testHostFixture = TestBed.createComponent(TestHostComponent);
+ testHostComponent = testHostFixture.componentInstance;
+ testHostFixture.detectChanges();
+
+ overlayContainer = TestBed.inject(OverlayContainer);
+ rootLoader = TestbedHarnessEnvironment.documentRootLoader(testHostFixture);
+
+ // mock projects endpoint
+ (dspConnSpy.admin.projectsEndpoint as jasmine.SpyObj).getProjectByShortcode.and.callFake(
+ () => {
+ const response = new ProjectResponse();
+
+ const mockProjects = MockProjects.mockProjects();
+
+ response.project = mockProjects.body.projects[0];
+
+ return of(ApiResponseData.fromAjaxResponse({response} as AjaxResponse));
+ }
+ );
+
+ expect(testHostComponent.listComponent.session).toBeTruthy();
+ expect(testHostComponent).toBeTruthy();
});
- beforeEach(() => {
- localStorage.setItem('session', JSON.stringify(TestConfig.CurrentSession));
+ afterEach(async () => {
+ const dialogs = await rootLoader.getAllHarnesses(MatDialogHarness);
+ await Promise.all(dialogs.map(async d => await d.close()));
+
+ // angular won't call this for us so we need to do it ourselves to avoid leaks.
+ overlayContainer.ngOnDestroy();
+ });
+
+ it('should get the project', () => {
+ const mockProject = MockProjects.mockProjects().body.projects[0];
+
+ expect(testHostComponent.listComponent.project).toEqual(mockProject);
+ });
+
+ it('should initialize the list of lists', () => {
+ const listOfLists = new Array();
+
+ const list1 = new ListNodeInfo();
+ list1.comments = [];
+ list1.id = 'http://rdfh.ch/lists/0001/mockList01';
+ list1.isRootNode = true;
+ list1.labels = [{language: 'en', value: 'Mock List 01'}];
+ list1.projectIri = 'http://rdfh.ch/projects/myProjectIri';
+
+ const list2 = new ListNodeInfo();
+ list2.comments = [];
+ list2.id = 'http://rdfh.ch/lists/0001/mockList02';
+ list2.isRootNode = true;
+ list2.labels = [{language: 'en', value: 'Mock List 02'}];
+ list2.projectIri = 'http://rdfh.ch/projects/myProjectIri';
- fixture = TestBed.createComponent(ListComponent);
- component = fixture.componentInstance;
- fixture.detectChanges();
+ listOfLists.push(list1, list2);
+
+ expect(testHostComponent.listComponent.lists).toEqual(listOfLists);
});
- it('should create', () => {
- expect(localStorage.getItem('session')).toBe(
- JSON.stringify(TestConfig.CurrentSession)
+ it('should open a list', async () => {
+ const select = await rootLoader.getHarness(MatSelectHarness);
+
+ await select.open();
+
+ const options = await select.getOptions({ text: 'Mock List 01 (en)'});
+
+ expect(options.length).toEqual(1);
+
+ await options[0].click();
+
+ expect(testHostComponent.listComponent.list.id).toEqual('http://rdfh.ch/lists/0001/mockList01');
+ });
+
+ it('should delete a root node', async () => {
+ const deleteListResponse: DeleteListResponse = new DeleteListResponse();
+ deleteListResponse.deleted = true;
+ deleteListResponse.iri = 'http://rdfh.ch/lists/0001/mockList01';
+
+ const listSpy = TestBed.inject(DspApiConnectionToken);
+
+ // mock delete list root node response
+ (listSpy.admin.listsEndpoint as jasmine.SpyObj).deleteListNode.and.callFake(
+ () => {
+ const response = deleteListResponse;
+
+ return of(ApiResponseData.fromAjaxResponse({response} as AjaxResponse));
+ }
);
- expect(component).toBeTruthy();
+
+ // mock the call to the API again because we call the API again after deletion to get the lists
+ // therefore the mock call in the BeforeEach method above will not work since it returns two lists
+ // after deletion, we should only have one list
+ (listSpy.admin.listsEndpoint as jasmine.SpyObj).getListsInProject.and.callFake(
+ () => {
+ const response = new ListsResponse();
+
+ response.lists = new Array();
+
+ const mockList2 = new ListNodeInfo();
+ mockList2.comments = [];
+ mockList2.id = 'http://rdfh.ch/lists/0001/mockList02';
+ mockList2.isRootNode = true;
+ mockList2.labels = [{language: 'en', value: 'Mock List 02'}];
+ mockList2.projectIri = 'http://rdfh.ch/projects/myProjectIri';
+
+ response.lists.push(mockList2);
+
+ return of(ApiResponseData.fromAjaxResponse({response} as AjaxResponse));
+ }
+ );
+
+ // open first list among lists
+ const select = await rootLoader.getHarness(MatSelectHarness);
+
+ await select.open();
+
+ const options = await select.getOptions({ text: 'Mock List 01 (en)'});
+
+ expect(options.length).toEqual(1);
+
+ await options[0].click();
+
+ // click delete button
+ const deleteButton = await rootLoader.getHarness(MatButtonHarness.with({selector: '.delete'}));
+ await deleteButton.click();
+
+ // get dialog harness
+ const dialogHarnesses = await rootLoader.getAllHarnesses(MatDialogHarness);
+
+ expect(dialogHarnesses.length).toEqual(1);
+
+ // click confirm button
+ const confirmButton = await rootLoader.getHarness(MatButtonHarness.with({selector: '.confirm-button'}));
+
+ await confirmButton.click();
+
+ testHostFixture.whenStable().then(() => {
+ expect(listSpy.admin.listsEndpoint.deleteListNode).toHaveBeenCalledWith('http://rdfh.ch/lists/0001/mockList01');
+ expect(listSpy.admin.listsEndpoint.deleteListNode).toHaveBeenCalledTimes(1);
+
+ expect(testHostComponent.listComponent.lists.length).toEqual(1);
+ expect(testHostComponent.listComponent.lists[0].id).toEqual('http://rdfh.ch/lists/0001/mockList02');
+
+ expect(testHostComponent.listComponent.listIri).toEqual(testHostComponent.listComponent.lists[0].id);
+ });
});
});
diff --git a/src/app/project/list/list.component.ts b/src/app/project/list/list.component.ts
index 6c1d438727..4dbb7bc581 100644
--- a/src/app/project/list/list.component.ts
+++ b/src/app/project/list/list.component.ts
@@ -6,6 +6,7 @@ import { ActivatedRoute, Params, Router } from '@angular/router';
import {
ApiResponseData,
ApiResponseError,
+ DeleteListResponse,
KnoraApiConnection,
ListNodeInfo,
ListsResponse,
@@ -216,7 +217,35 @@ export class ListComponent implements OnInit {
dialogConfig
);
- dialogRef.afterClosed().subscribe(() => {
+ dialogRef.afterClosed().subscribe((data) => {
+ if (mode === 'deleteList' && typeof(data) === 'boolean' && data === true) {
+ this._dspApiConnection.admin.listsEndpoint.deleteListNode(this.listIri).subscribe(
+ (res: ApiResponseData) => {
+ this.lists = this.lists.filter(list => list.id !== res.body.iri);
+ this.listIri = this.lists[0].id;
+ this.listForm.controls.list.setValue(this.listIri);
+ this.openList(this.listIri);
+ },
+ (error: ApiResponseError) => {
+ // if DSP-API returns a 400, it is likely that the list node is in use so we inform the user of this
+ if (error.status === 400) {
+ const errorDialogConfig: MatDialogConfig = {
+ width: '640px',
+ position: {
+ top: '112px'
+ },
+ data: { mode: 'deleteListNodeError'}
+ };
+
+ // open the dialog box
+ this._dialog.open(DialogComponent, errorDialogConfig);
+ } else {
+ // use default error behavior
+ this._errorHandler.showMessage(error);
+ }
+ }
+ );
+ }
// update the view
this.initList();
});