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}}

- + 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(); });