From d3e60ea3407430a1feef3422d26095e6a324e37f Mon Sep 17 00:00:00 2001 From: Sebastian Leidig Date: Fri, 27 Dec 2019 14:01:50 +0100 Subject: [PATCH 1/8] fix(database): allow options parameter for `get` needed for pagination etc. --- src/app/core/database/database.ts | 3 ++- src/app/core/database/pouch-database.ts | 5 +++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/app/core/database/database.ts b/src/app/core/database/database.ts index 504fc9bae6..32c46ff80a 100644 --- a/src/app/core/database/database.ts +++ b/src/app/core/database/database.ts @@ -27,8 +27,9 @@ export abstract class Database { /** * Load a single document by id from the database. * @param id The primary key of the document to be loaded + * @param options Optional options for the database engine (PouchDB) */ - abstract get(id: string): Promise; + abstract get(id: string, options?: any): Promise; /** * Load all documents (matching the given PouchDB options) from the database. diff --git a/src/app/core/database/pouch-database.ts b/src/app/core/database/pouch-database.ts index 061c8274f5..e6bcb755ae 100644 --- a/src/app/core/database/pouch-database.ts +++ b/src/app/core/database/pouch-database.ts @@ -43,10 +43,11 @@ export class PouchDatabase extends Database { * Load a single document by id from the database. * (see {@link Database}) * @param id The primary key of the document to be loaded + * @param options Optional PouchDB options for the request */ - get(id: string) { + get(id: string, options: any = {}) { this.alertService.addDebug('DB_READ'); - return this._pouchDB.get(id) + return this._pouchDB.get(id, options) .catch((err) => { this.notifyError(err); throw err; From 0ede4aa9f96ba55d4a5c71acdae46762e85981e7 Mon Sep 17 00:00:00 2001 From: Sebastian Leidig Date: Fri, 27 Dec 2019 14:03:56 +0100 Subject: [PATCH 2/8] feat(database): implement QueryDataSource to allow easy pagination of data in tables --- src/app/core/database/query-data-source.ts | 61 ++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 src/app/core/database/query-data-source.ts diff --git a/src/app/core/database/query-data-source.ts b/src/app/core/database/query-data-source.ts new file mode 100644 index 0000000000..5b16b92a2c --- /dev/null +++ b/src/app/core/database/query-data-source.ts @@ -0,0 +1,61 @@ +import { CollectionViewer, DataSource } from '@angular/cdk/collections'; +import { BehaviorSubject, Observable } from 'rxjs'; +import { MatPaginator } from '@angular/material/paginator'; +import { Entity } from '../entity/entity'; +import { Database } from './database'; + +export class QueryDataSource implements DataSource { + private dataSubject = new BehaviorSubject([]); + private loadingSubject = new BehaviorSubject(false); + + public loading$ = this.loadingSubject.asObservable(); + + private _paginator: MatPaginator | null; + get paginator(): MatPaginator | null { + return this._paginator; + } + set paginator(value: MatPaginator | null) { + this._paginator = value; + + if (this.paginator) { + this.paginator.page.subscribe(() => this.loadData()); + this.loadData(); + } + } + + + constructor( + private database: Database, + private queryName: string, + ) {} + + connect(collectionViewer: CollectionViewer): Observable { + this.loadData(); + return this.dataSubject.asObservable(); + } + + disconnect(collectionViewer: CollectionViewer): void { + this.dataSubject.complete(); + this.loadingSubject.complete(); + } + + + async loadData() { + this.loadingSubject.next(true); + + const options: any = { + include_docs: true, + }; + if (this.paginator) { + options.limit = this.paginator.pageSize; + options.skip = this.paginator.pageIndex * this.paginator.pageSize; + } + + const results = await this.database.query(this.queryName, options); + + this.paginator.length = results.total_rows; + this.dataSubject.next(results.rows); + + this.loadingSubject.next(false); + } +} From 5f0f0815b03c218ee3c7ab35367821508d057e9e Mon Sep 17 00:00:00 2001 From: Sebastian Leidig Date: Tue, 7 Jan 2020 21:07:43 +0100 Subject: [PATCH 3/8] feat(conflict-resolution): add view to display and resolve pouchdb document conflicts --- .gitignore | 1 + package-lock.json | 16 +- package.json | 5 +- src/app/app.module.ts | 3 + src/app/app.routing.ts | 2 + .../compare-rev/compare-rev.component.html | 44 ++++++ .../compare-rev/compare-rev.component.scss | 20 +++ .../compare-rev/compare-rev.component.spec.ts | 53 +++++++ .../compare-rev/compare-rev.component.ts | 141 ++++++++++++++++++ ...nflict-resolution-strategy.service.spec.ts | 44 ++++++ .../conflict-resolution-strategy.service.ts | 60 ++++++++ .../conflict-resolution.module.ts | 34 +++++ .../conflict-resolution.component.html | 22 +++ .../conflict-resolution.component.scss | 4 + .../conflict-resolution.component.spec.ts | 48 ++++++ .../conflict-resolution.component.ts | 57 +++++++ 16 files changed, 550 insertions(+), 4 deletions(-) create mode 100644 src/app/conflict-resolution/compare-rev/compare-rev.component.html create mode 100644 src/app/conflict-resolution/compare-rev/compare-rev.component.scss create mode 100644 src/app/conflict-resolution/compare-rev/compare-rev.component.spec.ts create mode 100644 src/app/conflict-resolution/compare-rev/compare-rev.component.ts create mode 100644 src/app/conflict-resolution/conflict-resolution-strategy/conflict-resolution-strategy.service.spec.ts create mode 100644 src/app/conflict-resolution/conflict-resolution-strategy/conflict-resolution-strategy.service.ts create mode 100644 src/app/conflict-resolution/conflict-resolution.module.ts create mode 100644 src/app/conflict-resolution/conflict-resolution/conflict-resolution.component.html create mode 100644 src/app/conflict-resolution/conflict-resolution/conflict-resolution.component.scss create mode 100644 src/app/conflict-resolution/conflict-resolution/conflict-resolution.component.spec.ts create mode 100644 src/app/conflict-resolution/conflict-resolution/conflict-resolution.component.ts diff --git a/.gitignore b/.gitignore index d39aec3faa..57928bac73 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ /src/assets/config.json /src/assets/child-photos +proxy.conf.json # compiled output dist diff --git a/package-lock.json b/package-lock.json index e37248e17f..3580d81e6e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "ndb-core", - "version": "2.6.0", + "version": "0.0.0", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -2544,6 +2544,12 @@ "integrity": "sha512-3F8qpwBAiVc5+HPJeXJpbrl+XjawGmciN5LgiO7Gv1pl1RHtjoMNqZpqEksaPJW05ViKe8snYInRs6xB25Xdew==", "dev": true }, + "@types/lodash": { + "version": "4.14.149", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.149.tgz", + "integrity": "sha512-ijGqzZt/b7BfzcK9vTrS6MFljQRPn5BFWOx8oE0GYxribu6uV+aA9zZuXI1zc/etK9E8nrgdoF2+LgUw7+9tJQ==", + "dev": true + }, "@types/marked": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/@types/marked/-/marked-0.7.2.tgz", @@ -5093,6 +5099,11 @@ "integrity": "sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ=", "dev": true }, + "deep-object-diff": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/deep-object-diff/-/deep-object-diff-1.1.0.tgz", + "integrity": "sha512-b+QLs5vHgS+IoSNcUE4n9HP2NwcHj7aqnJWsjPtuG75Rh5TOaGt0OjAYInh77d5T16V5cRDC+Pw/6ZZZiETBGw==" + }, "default-gateway": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/default-gateway/-/default-gateway-4.2.0.tgz", @@ -9323,8 +9334,7 @@ "lodash": { "version": "4.17.15", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.15.tgz", - "integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==", - "dev": true + "integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==" }, "lodash.clonedeep": { "version": "4.5.0", diff --git a/package.json b/package.json index 715772510f..671727151b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ndb-core", - "version": "2.6.0", + "version": "0.0.0", "license": "MIT", "scripts": { "ng": "ng", @@ -30,9 +30,11 @@ "@sentry/browser": "^5.15.4", "core-js": "^2.6.9", "crypto-js": "~3.1.9-1", + "deep-object-diff": "^1.1.0", "faker": "^4.1.0", "file-saver": "^2.0.2", "font-awesome": "^4.7.0", + "lodash": "^4.17.15", "ngx-cookie-service": "^2.3.0", "ngx-filter-pipe": "^2.1.2", "ngx-markdown": "^8.2.2", @@ -55,6 +57,7 @@ "@types/faker": "^4.1.11", "@types/file-saver": "^2.0.1", "@types/jasmine": "^3.5.10", + "@types/lodash": "^4.14.149", "@types/node": "^12.12.27", "@types/pouchdb": "^6.4.0", "codelyzer": "^5.2.2", diff --git a/src/app/app.module.ts b/src/app/app.module.ts index d5057131b3..d9dc5e24c0 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -58,6 +58,7 @@ import { DemoEducationalMaterialGeneratorService } from './child-dev-project/edu import { DemoHealthCheckGeneratorService } from './child-dev-project/health-checkup/demo-data/demo-health-check-generator.service'; import { DemoWidgetGeneratorService } from './child-dev-project/dashboard/demo-widget-generator.service'; import { DemoUserGeneratorService } from './core/user/demo-user-generator.service'; +import { ConflictResolutionModule } from './conflict-resolution/conflict-resolution.module'; /** * Main entry point of the application. @@ -89,6 +90,7 @@ import { DemoUserGeneratorService } from './core/user/demo-user-generator.servic ChildrenModule, SchoolsModule, AdminModule, + ConflictResolutionModule, MatIconModule, HelpModule, MatNativeDateModule, @@ -124,6 +126,7 @@ export class AppModule { _navigationItemsService.addMenuItem(new MenuItem('Notes', 'file-text', ['/note'])); _navigationItemsService.addMenuItem(new MenuItem('Attendance Register', 'table', ['/attendance'])); _navigationItemsService.addMenuItem(new MenuItem('Admin', 'wrench', ['/admin'], true)); + _navigationItemsService.addMenuItem(new MenuItem('Database Conflicts', 'wrench', ['/admin/conflicts'], true)); _navigationItemsService.addMenuItem(new MenuItem('Users', 'user', ['/users'], true)); _navigationItemsService.addMenuItem(new MenuItem('Help', 'question-circle', ['/help'])); } diff --git a/src/app/app.routing.ts b/src/app/app.routing.ts index 5365244e90..10fa7b4a72 100644 --- a/src/app/app.routing.ts +++ b/src/app/app.routing.ts @@ -32,6 +32,7 @@ import { AddDayAttendanceComponent } from './child-dev-project/attendance/add-da import { AttendanceManagerComponent } from './child-dev-project/attendance/attendance-manager/attendance-manager.component'; import { HowToComponent } from './core/help/how-to/how-to.component'; import { UserListComponent } from './core/admin/user-list/user-list.component'; +import { ConflictResolutionComponent } from './conflict-resolution/conflict-resolution/conflict-resolution.component'; /** * All routes configured for the main app routing. @@ -50,6 +51,7 @@ export const routes: Routes = [ {path: 'attendance/add/day', component: AddDayAttendanceComponent}, {path: 'admin', component: AdminComponent, canActivate: [AdminGuard]}, {path: 'users', component: UserListComponent, canActivate: [AdminGuard]}, + {path: 'admin/conflicts', component: ConflictResolutionComponent, canActivate: [AdminGuard]}, {path: 'help', component: HowToComponent}, {path: '**', redirectTo: '/'}, ]; diff --git a/src/app/conflict-resolution/compare-rev/compare-rev.component.html b/src/app/conflict-resolution/compare-rev/compare-rev.component.html new file mode 100644 index 0000000000..6b2d84be19 --- /dev/null +++ b/src/app/conflict-resolution/compare-rev/compare-rev.component.html @@ -0,0 +1,44 @@ + + + + {{rev}} + + + +
+
+ + + +
+ +
+ + + +
+ +
+ + + +
+
+
+ + +
+ Resolved ({{resolution}}) +
diff --git a/src/app/conflict-resolution/compare-rev/compare-rev.component.scss b/src/app/conflict-resolution/compare-rev/compare-rev.component.scss new file mode 100644 index 0000000000..64f2a467c0 --- /dev/null +++ b/src/app/conflict-resolution/compare-rev/compare-rev.component.scss @@ -0,0 +1,20 @@ + +.diffText { + width: 100%; + background: white; +} + +.resolution-btn { + width: 100%; +} + +.conflicting { + background: rgba(255, 0, 0, 0.1); +} +.current { + background: rgba(0, 128, 0, 0.1); +} +.custom { + background: rgba(0, 0, 255, 0.1); + font-weight: bold; +} diff --git a/src/app/conflict-resolution/compare-rev/compare-rev.component.spec.ts b/src/app/conflict-resolution/compare-rev/compare-rev.component.spec.ts new file mode 100644 index 0000000000..e9a6f04c65 --- /dev/null +++ b/src/app/conflict-resolution/compare-rev/compare-rev.component.spec.ts @@ -0,0 +1,53 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { CompareRevComponent } from './compare-rev.component'; +import {MatTooltipModule} from '@angular/material/tooltip'; +import {MatExpansionModule} from '@angular/material/expansion'; +import {FormsModule} from '@angular/forms'; +import {ConfirmationDialogService} from '../../ui-helper/confirmation-dialog/confirmation-dialog.service'; +import {Database} from '../../database/database'; +import {MockDatabase} from '../../database/mock-database'; +import {AlertService} from '../../alerts/alert.service'; +import {ConflictResolutionStrategyService} from '../conflict-resolution-strategy/conflict-resolution-strategy.service'; +import {NoopAnimationsModule} from '@angular/platform-browser/animations'; + +describe('CompareRevComponent', () => { + let component: CompareRevComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + const confDialogMock = { + openDialog: () => {} + }; + spyOn(confDialogMock, 'openDialog'); + + TestBed.configureTestingModule({ + imports: [ + MatTooltipModule, + MatExpansionModule, + FormsModule, + NoopAnimationsModule, + ], + providers: [ + { provide: ConfirmationDialogService, useValue: confDialogMock }, + { provide: Database, useValue: new MockDatabase() }, + { provide: AlertService, useValue: { addDanger: () => {} } }, + ConflictResolutionStrategyService, + ], + declarations: [ + CompareRevComponent, + ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(CompareRevComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/conflict-resolution/compare-rev/compare-rev.component.ts b/src/app/conflict-resolution/compare-rev/compare-rev.component.ts new file mode 100644 index 0000000000..f4eec0dddf --- /dev/null +++ b/src/app/conflict-resolution/compare-rev/compare-rev.component.ts @@ -0,0 +1,141 @@ +import { Component, Input, OnInit } from '@angular/core'; +import { diff } from 'deep-object-diff'; +import { ConflictResolutionStrategyService } from '../conflict-resolution-strategy/conflict-resolution-strategy.service'; +import _ from 'lodash'; +import { ConfirmationDialogService } from '../../core/confirmation-dialog/confirmation-dialog.service'; +import { Database } from '../../core/database/database'; +import { AlertService } from '../../core/alerts/alert.service'; + +/** + * Visualize one specific conflicting document revision and offer resolution options. + */ +@Component({ + selector: 'app-compare-rev', + templateUrl: './compare-rev.component.html', + styleUrls: ['./compare-rev.component.scss'], +}) +export class CompareRevComponent implements OnInit { + @Input() rev; + @Input() doc; + + docString; + revDoc; + diffs; + diffsReverse; + diffsCustom; + resolution = null; + + constructor( + private confirmationDialog: ConfirmationDialogService, + private db: Database, + private alertService: AlertService, + private conflictResolver: ConflictResolutionStrategyService, + ) { } + + ngOnInit() { + this.loadRev(); + } + + + async loadRev() { + this.revDoc = await this.db.get(this.doc._id, { rev: this.rev }); + const diffObject = diff(this.doc, this.revDoc); + this.diffs = this.stringify(diffObject); + + const diffReverseObject = diff(this.revDoc, this.doc); + this.diffsReverse = this.stringify(diffReverseObject); + this.diffsCustom = this.stringify(diffReverseObject); + + const isIrrelevantConflictingDoc = this.conflictResolver.isIrrelevantConflictVersion(this.doc, this.revDoc); + if (isIrrelevantConflictingDoc) { + const success = await this.deleteDoc(this.revDoc); + if (success) { + this.resolution = 'automatically deleted trivial conflict'; + } + } + } + + + stringify(entity: any) { + return JSON.stringify( + entity, + (k, v) => (k === '_rev') ? undefined : v, // ignore "_rev" + 2, + ); + } + + + + public resolveByDelete(docToDelete: any) { + const dialogRef = this.confirmationDialog + .openDialog( + 'Delete Conflicting Version?', + 'Are you sure you want to keep the current version and delete this conflicting version? ' + + this.stringify(docToDelete), + ); + + dialogRef.afterClosed() + .subscribe(async confirmed => { + if (confirmed) { + const success = await this.deleteDoc(docToDelete); + if (success) { + this.resolution = 'deleted conflicting version'; + } + } + }); + } + + private async deleteDoc(docToDelete: any): Promise { + try { + await this.db.remove(docToDelete); + return true; + } catch (e) { + const errorMessage = e.message || e.toString(); + this.alertService.addDanger('Error trying to delete conflicting version: ' + errorMessage); + return false; + } + } + + private async saveDoc(docToSave: any): Promise { + try { + await this.db.put(docToSave); + return true; + } catch (e) { + const errorMessage = e.message || e.toString(); + this.alertService.addDanger('Error trying to save version: ' + errorMessage); + return false; + } + } + + + // TODO: https://www.npmjs.com/package/ngx-text-diff + + public async resolveByManualEdit(diffStringToApply: string) { + const originalDoc = _.merge({}, this.doc); + const diffToApply = JSON.parse(diffStringToApply); + _.merge(this.doc, diffToApply); + + const newChanges = diff(originalDoc, this.doc); + + const dialogRef = this.confirmationDialog + .openDialog( + 'Save Changes for Conflict Resolution?', + 'Are you sure you want to save the following changes and delete the conflicting version? ' + + this.stringify(newChanges), + ); + dialogRef.afterClosed() + .subscribe(async confirmed => { + if (confirmed) { + const successSave = await this.saveDoc(this.doc); + const successDel = await this.deleteDoc(this.revDoc); + if (successSave && successDel) { + if (diffStringToApply === this.diffs) { + this.resolution = 'selected conflicting version'; + } else { + this.resolution = 'resolved manually'; + } + } + } + }); + } +} diff --git a/src/app/conflict-resolution/conflict-resolution-strategy/conflict-resolution-strategy.service.spec.ts b/src/app/conflict-resolution/conflict-resolution-strategy/conflict-resolution-strategy.service.spec.ts new file mode 100644 index 0000000000..7fcf9e334e --- /dev/null +++ b/src/app/conflict-resolution/conflict-resolution-strategy/conflict-resolution-strategy.service.spec.ts @@ -0,0 +1,44 @@ +import {TestBed} from '@angular/core/testing'; + +import {ConflictResolutionStrategyService} from './conflict-resolution-strategy.service'; +import {AttendanceMonth} from '../../children/attendance/attendance-month'; +import {AttendanceDay, AttendanceStatus} from '../../children/attendance/attendance-day'; + +describe('ConflictResolutionStrategyService', () => { + let service: ConflictResolutionStrategyService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.get(ConflictResolutionStrategyService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should delete irrelevant attendance diff conflicts', () => { + const currentDoc = new AttendanceMonth('test1'); + currentDoc.month = new Date(2019, 0); + currentDoc.dailyRegister[0] = new AttendanceDay(new Date(2019, 0, 1), AttendanceStatus.ABSENT); + + const conflictingDoc = new AttendanceMonth('test1'); + conflictingDoc.month = new Date(2019, 0); + // no dailyRegister entries set + + const result = service.isIrrelevantConflictVersion(currentDoc, conflictingDoc); + expect(result).toBe(true); + }); + + it('should not delete complex attendance diff conflicts', () => { + const currentDoc = new AttendanceMonth('test1'); + currentDoc.month = new Date(2019, 0); + currentDoc.dailyRegister[0] = new AttendanceDay(new Date(2019, 0, 1), AttendanceStatus.ABSENT); + + const conflictingDoc = new AttendanceMonth('test1'); + conflictingDoc.month = new Date(2019, 0); + conflictingDoc.dailyRegister[1] = new AttendanceDay(new Date(2019, 0, 1), AttendanceStatus.EXCUSED); + + const result = service.isIrrelevantConflictVersion(currentDoc, conflictingDoc); + expect(result).toBe(false); + }); +}); diff --git a/src/app/conflict-resolution/conflict-resolution-strategy/conflict-resolution-strategy.service.ts b/src/app/conflict-resolution/conflict-resolution-strategy/conflict-resolution-strategy.service.ts new file mode 100644 index 0000000000..928468bc03 --- /dev/null +++ b/src/app/conflict-resolution/conflict-resolution-strategy/conflict-resolution-strategy.service.ts @@ -0,0 +1,60 @@ +import { Injectable } from '@angular/core'; +import { diff } from 'deep-object-diff'; +import _ from 'lodash'; +import { AttendanceMonth } from '../../child-dev-project/attendance/model/attendance-month'; + +/** + * Attempt automatic conflict resolutions or identify trivial conflicts for semi-automatic resolution. + */ +@Injectable({ + providedIn: 'root' +}) +export class ConflictResolutionStrategyService { + + constructor() { } + + public isIrrelevantConflictVersion(currentDoc: any, conflictingDoc: any): boolean { + const currentDocC = _.merge({}, currentDoc); + delete currentDocC._rev; + const conflictingDocC = _.merge({}, conflictingDoc); + delete conflictingDocC._rev; + + if (currentDocC._id.startsWith(AttendanceMonth.ENTITY_TYPE)) { + return this.isIrrelevantAttendanceMonthConflict(currentDocC, conflictingDocC); + } + + return false; + } + + + private isIrrelevantAttendanceMonthConflict(currentDoc: any, conflictingDoc: any): boolean { + const diffObject = diff(currentDoc, conflictingDoc); + + const simplifiedDiff = this.removeTrivialDiffValuesRecursively(diffObject, ['?', '', undefined, null]); + + return _.isObjectLike(simplifiedDiff) && _.isEmpty(simplifiedDiff); + } + + /** + * Changes the given object, deep scanning it to remove any values given as the second argument. + * @param diffObject + * @param trivialValues + */ + private removeTrivialDiffValuesRecursively(diffObject: any, trivialValues: any[]) { + for (const k of Object.keys(diffObject)) { + if (trivialValues.includes(diffObject[k])) { + delete diffObject[k]; + } + + if (typeof diffObject[k] === 'object' && diffObject[k] !== null) { + this.removeTrivialDiffValuesRecursively(diffObject[k], trivialValues); + + if (_.isObjectLike(diffObject[k]) && _.isEmpty(diffObject[k])) { + delete diffObject[k]; + } + } + } + + return diffObject; + } +} diff --git a/src/app/conflict-resolution/conflict-resolution.module.ts b/src/app/conflict-resolution/conflict-resolution.module.ts new file mode 100644 index 0000000000..df961bab59 --- /dev/null +++ b/src/app/conflict-resolution/conflict-resolution.module.ts @@ -0,0 +1,34 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ConflictResolutionComponent } from './conflict-resolution/conflict-resolution.component'; +import {MatTableModule} from '@angular/material/table'; +import {MatSortModule} from '@angular/material/sort'; +import {MatIconModule} from '@angular/material/icon'; +import {MatButtonModule} from '@angular/material/button'; +import {MatPaginatorModule} from '@angular/material/paginator'; +import {MatExpansionModule} from '@angular/material/expansion'; +import { CompareRevComponent } from './compare-rev/compare-rev.component'; +import {FlexLayoutModule} from '@angular/flex-layout'; +import {MatInputModule} from '@angular/material/input'; +import {FormsModule} from '@angular/forms'; +import {MatTooltipModule} from '@angular/material/tooltip'; + + + +@NgModule({ + declarations: [ConflictResolutionComponent, CompareRevComponent], + imports: [ + CommonModule, + MatTableModule, + MatSortModule, + MatIconModule, + MatButtonModule, + MatPaginatorModule, + MatExpansionModule, + FlexLayoutModule, + MatInputModule, + FormsModule, + MatTooltipModule, + ] +}) +export class ConflictResolutionModule { } diff --git a/src/app/conflict-resolution/conflict-resolution/conflict-resolution.component.html b/src/app/conflict-resolution/conflict-resolution/conflict-resolution.component.html new file mode 100644 index 0000000000..3eb053c0fe --- /dev/null +++ b/src/app/conflict-resolution/conflict-resolution/conflict-resolution.component.html @@ -0,0 +1,22 @@ +

conflicts to resolve:

+ + + + + + + + + + + + + + + + +
_id {{row.id}} Data + +
+ + diff --git a/src/app/conflict-resolution/conflict-resolution/conflict-resolution.component.scss b/src/app/conflict-resolution/conflict-resolution/conflict-resolution.component.scss new file mode 100644 index 0000000000..952c8b1a21 --- /dev/null +++ b/src/app/conflict-resolution/conflict-resolution/conflict-resolution.component.scss @@ -0,0 +1,4 @@ + +.col-data { + width: 100%; +} diff --git a/src/app/conflict-resolution/conflict-resolution/conflict-resolution.component.spec.ts b/src/app/conflict-resolution/conflict-resolution/conflict-resolution.component.spec.ts new file mode 100644 index 0000000000..0eaf3c8e66 --- /dev/null +++ b/src/app/conflict-resolution/conflict-resolution/conflict-resolution.component.spec.ts @@ -0,0 +1,48 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ConflictResolutionComponent } from './conflict-resolution.component'; +import {MatTableModule} from '@angular/material/table'; +import {MatTooltipModule} from '@angular/material/tooltip'; +import {CompareRevComponent} from '../compare-rev/compare-rev.component'; +import {MatExpansionModule} from '@angular/material/expansion'; +import {Database} from '../../database/database'; +import {MockDatabase} from '../../database/mock-database'; +import {FormsModule} from '@angular/forms'; +import {MatPaginatorModule} from '@angular/material/paginator'; +import {NoopAnimationsModule} from '@angular/platform-browser/animations'; + +describe('ConflictResolutionComponent', () => { + let component: ConflictResolutionComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + MatTableModule, + MatPaginatorModule, + MatTooltipModule, + MatExpansionModule, + FormsModule, + NoopAnimationsModule, + ], + providers: [ + { provide: Database, useValue: new MockDatabase() }, + ], + declarations: [ + CompareRevComponent, + ConflictResolutionComponent, + ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ConflictResolutionComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/conflict-resolution/conflict-resolution/conflict-resolution.component.ts b/src/app/conflict-resolution/conflict-resolution/conflict-resolution.component.ts new file mode 100644 index 0000000000..3590c927c2 --- /dev/null +++ b/src/app/conflict-resolution/conflict-resolution/conflict-resolution.component.ts @@ -0,0 +1,57 @@ +import { AfterViewInit, Component, OnInit, ViewChild } from '@angular/core'; +import { MatSort } from '@angular/material/sort'; +import { MatPaginator } from '@angular/material/paginator'; +import { QueryDataSource } from '../../core/database/query-data-source'; +import { Entity } from '../../core/entity/entity'; +import { Database } from '../../core/database/database'; + + +/** + * List all document conflicts and allow the user to expand for details and manual resolution. + */ +@Component({ + selector: 'app-conflict-resolution', + templateUrl: './conflict-resolution.component.html', + styleUrls: ['./conflict-resolution.component.scss'], +}) +export class ConflictResolutionComponent implements OnInit, AfterViewInit { + columnsToDisplay = [ 'id', 'data' ]; + @ViewChild(MatSort, { static: true }) sort: MatSort; + dataSource: QueryDataSource; + + @ViewChild(MatPaginator, { static: true }) paginator: MatPaginator; + + constructor( + private db: Database, + ) { } + + async ngOnInit() { + } + + async ngAfterViewInit() { + await this.createConflictView(); + this.dataSource = new QueryDataSource(this.db, 'conflicts/all'); + this.dataSource.paginator = this.paginator; + // this.dataSource.sort = this.sort; + } + + + private createConflictView() { + const designDoc = { + _id: '_design/conflicts', + views: { + all: { + map: '(doc) => { ' + + 'if (doc._conflicts) { emit(doc._conflicts, doc._id); } ' + + '}', + }, + }, + }; + + return this.db.saveDatabaseIndex(designDoc); + } + + stringify(entity: any) { + return JSON.stringify(entity); + } +} From 92ce2f65e82ef0e56642b9bf6e5ff4271807b918 Mon Sep 17 00:00:00 2001 From: Sebastian Leidig Date: Thu, 16 Apr 2020 06:33:34 +0200 Subject: [PATCH 4/8] make lazy-loaded --- src/app/app.module.ts | 2 - src/app/app.routing.ts | 7 +- .../compare-rev/compare-rev.component.spec.ts | 24 +++---- .../compare-rev/compare-rev.component.ts | 70 ++++++++++++++----- .../conflict-resolution-routing.module.ts | 23 ++++++ ...nflict-resolution-strategy.service.spec.ts | 8 +-- .../conflict-resolution-strategy.service.ts | 2 +- .../conflict-resolution.module.ts | 33 +++++---- .../conflict-resolution.component.spec.ts | 20 +++--- .../conflict-resolution.component.ts | 25 +++---- tsconfig.json | 1 + 11 files changed, 138 insertions(+), 77 deletions(-) create mode 100644 src/app/conflict-resolution/conflict-resolution-routing.module.ts diff --git a/src/app/app.module.ts b/src/app/app.module.ts index d9dc5e24c0..249e2b964c 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -58,7 +58,6 @@ import { DemoEducationalMaterialGeneratorService } from './child-dev-project/edu import { DemoHealthCheckGeneratorService } from './child-dev-project/health-checkup/demo-data/demo-health-check-generator.service'; import { DemoWidgetGeneratorService } from './child-dev-project/dashboard/demo-widget-generator.service'; import { DemoUserGeneratorService } from './core/user/demo-user-generator.service'; -import { ConflictResolutionModule } from './conflict-resolution/conflict-resolution.module'; /** * Main entry point of the application. @@ -90,7 +89,6 @@ import { ConflictResolutionModule } from './conflict-resolution/conflict-resolut ChildrenModule, SchoolsModule, AdminModule, - ConflictResolutionModule, MatIconModule, HelpModule, MatNativeDateModule, diff --git a/src/app/app.routing.ts b/src/app/app.routing.ts index 10fa7b4a72..039a8fb598 100644 --- a/src/app/app.routing.ts +++ b/src/app/app.routing.ts @@ -32,7 +32,6 @@ import { AddDayAttendanceComponent } from './child-dev-project/attendance/add-da import { AttendanceManagerComponent } from './child-dev-project/attendance/attendance-manager/attendance-manager.component'; import { HowToComponent } from './core/help/how-to/how-to.component'; import { UserListComponent } from './core/admin/user-list/user-list.component'; -import { ConflictResolutionComponent } from './conflict-resolution/conflict-resolution/conflict-resolution.component'; /** * All routes configured for the main app routing. @@ -51,7 +50,11 @@ export const routes: Routes = [ {path: 'attendance/add/day', component: AddDayAttendanceComponent}, {path: 'admin', component: AdminComponent, canActivate: [AdminGuard]}, {path: 'users', component: UserListComponent, canActivate: [AdminGuard]}, - {path: 'admin/conflicts', component: ConflictResolutionComponent, canActivate: [AdminGuard]}, + { + path: 'admin/conflicts', + canActivate: [AdminGuard], + loadChildren: () => import('./conflict-resolution/conflict-resolution.module').then(m => m.ConflictResolutionModule), + }, {path: 'help', component: HowToComponent}, {path: '**', redirectTo: '/'}, ]; diff --git a/src/app/conflict-resolution/compare-rev/compare-rev.component.spec.ts b/src/app/conflict-resolution/compare-rev/compare-rev.component.spec.ts index e9a6f04c65..c1c927e721 100644 --- a/src/app/conflict-resolution/compare-rev/compare-rev.component.spec.ts +++ b/src/app/conflict-resolution/compare-rev/compare-rev.component.spec.ts @@ -1,15 +1,15 @@ import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { CompareRevComponent } from './compare-rev.component'; -import {MatTooltipModule} from '@angular/material/tooltip'; -import {MatExpansionModule} from '@angular/material/expansion'; -import {FormsModule} from '@angular/forms'; -import {ConfirmationDialogService} from '../../ui-helper/confirmation-dialog/confirmation-dialog.service'; -import {Database} from '../../database/database'; -import {MockDatabase} from '../../database/mock-database'; -import {AlertService} from '../../alerts/alert.service'; -import {ConflictResolutionStrategyService} from '../conflict-resolution-strategy/conflict-resolution-strategy.service'; -import {NoopAnimationsModule} from '@angular/platform-browser/animations'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { MatExpansionModule } from '@angular/material/expansion'; +import { FormsModule } from '@angular/forms'; +import { ConflictResolutionStrategyService } from '../conflict-resolution-strategy/conflict-resolution-strategy.service'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { ConfirmationDialogService } from '../../core/confirmation-dialog/confirmation-dialog.service'; +import { Database } from '../../core/database/database'; +import { MockDatabase } from '../../core/database/mock-database'; +import { MatSnackBarModule } from '@angular/material/snack-bar'; describe('CompareRevComponent', () => { let component: CompareRevComponent; @@ -17,7 +17,7 @@ describe('CompareRevComponent', () => { beforeEach(async(() => { const confDialogMock = { - openDialog: () => {} + openDialog: () => {}, }; spyOn(confDialogMock, 'openDialog'); @@ -25,18 +25,18 @@ describe('CompareRevComponent', () => { imports: [ MatTooltipModule, MatExpansionModule, + MatSnackBarModule, FormsModule, NoopAnimationsModule, ], providers: [ { provide: ConfirmationDialogService, useValue: confDialogMock }, { provide: Database, useValue: new MockDatabase() }, - { provide: AlertService, useValue: { addDanger: () => {} } }, ConflictResolutionStrategyService, ], declarations: [ CompareRevComponent, - ] + ], }) .compileComponents(); })); diff --git a/src/app/conflict-resolution/compare-rev/compare-rev.component.ts b/src/app/conflict-resolution/compare-rev/compare-rev.component.ts index f4eec0dddf..737f6328ae 100644 --- a/src/app/conflict-resolution/compare-rev/compare-rev.component.ts +++ b/src/app/conflict-resolution/compare-rev/compare-rev.component.ts @@ -1,10 +1,10 @@ -import { Component, Input, OnInit } from '@angular/core'; +import { Component, Input } from '@angular/core'; import { diff } from 'deep-object-diff'; import { ConflictResolutionStrategyService } from '../conflict-resolution-strategy/conflict-resolution-strategy.service'; import _ from 'lodash'; import { ConfirmationDialogService } from '../../core/confirmation-dialog/confirmation-dialog.service'; import { Database } from '../../core/database/database'; -import { AlertService } from '../../core/alerts/alert.service'; +import { MatSnackBar } from '@angular/material/snack-bar'; /** * Visualize one specific conflicting document revision and offer resolution options. @@ -14,30 +14,46 @@ import { AlertService } from '../../core/alerts/alert.service'; templateUrl: './compare-rev.component.html', styleUrls: ['./compare-rev.component.scss'], }) -export class CompareRevComponent implements OnInit { - @Input() rev; - @Input() doc; +export class CompareRevComponent { + /** revision key (_rev) of the confliction version to be displayed */ + @Input() rev: string; - docString; - revDoc; + /** document from the database in the current version */ + @Input() doc: any; + + /** used in the template for a tooltip displaying the full document */ + docString: string; + + /** document from the database in the conflicting version */ + revDoc: any; + + /** changes the conflicting doc has compared to the current doc */ diffs; + + /** changes the current doc has compared to the conflicting doc. + * + * This mirrors `diffs` but shows the things that would be added if the current doc would + * overwrite the conflicting version instead of the other way round. + */ diffsReverse; + + /** the user edited diff that can be applied as an alternative resolution (initialized with same value as `diffs`) */ diffsCustom; - resolution = null; + + /** whether/how this conflict has been resolved */ + resolution: string = null; constructor( - private confirmationDialog: ConfirmationDialogService, private db: Database, - private alertService: AlertService, + private confirmationDialog: ConfirmationDialogService, + private snackBar: MatSnackBar, private conflictResolver: ConflictResolutionStrategyService, ) { } - ngOnInit() { - this.loadRev(); - } - - - async loadRev() { + /** + * Load the document version (revision) to be displayed and generate the diffs to be visualized. + */ + public async loadRev() { this.revDoc = await this.db.get(this.doc._id, { rev: this.rev }); const diffObject = diff(this.doc, this.revDoc); this.diffs = this.stringify(diffObject); @@ -56,6 +72,10 @@ export class CompareRevComponent implements OnInit { } + /** + * Generate a human-readable string of the given object. + * @param entity Object to be stringified + */ stringify(entity: any) { return JSON.stringify( entity, @@ -65,7 +85,10 @@ export class CompareRevComponent implements OnInit { } - + /** + * Resolve the displayed conflict by deleting the conflicting revision doc and keeping the current doc. + * @param docToDelete Document to be deleted + */ public resolveByDelete(docToDelete: any) { const dialogRef = this.confirmationDialog .openDialog( @@ -91,7 +114,7 @@ export class CompareRevComponent implements OnInit { return true; } catch (e) { const errorMessage = e.message || e.toString(); - this.alertService.addDanger('Error trying to delete conflicting version: ' + errorMessage); + this.snackBar.open('Error trying to delete conflicting version: ' + errorMessage); return false; } } @@ -102,7 +125,7 @@ export class CompareRevComponent implements OnInit { return true; } catch (e) { const errorMessage = e.message || e.toString(); - this.alertService.addDanger('Error trying to save version: ' + errorMessage); + this.snackBar.open('Error trying to save version: ' + errorMessage); return false; } } @@ -110,6 +133,15 @@ export class CompareRevComponent implements OnInit { // TODO: https://www.npmjs.com/package/ngx-text-diff + /** + * Apply the given diff, save the resulting new document to the database + * and remove the conflicting document, thereby resolving the conflict. + * + * This method is also used to resolve the conflict to keep the conflicting version instead of the current doc. + * Then this simply applies the diff of the existing conflicting version instead of a user-edited diff. + * + * @param diffStringToApply The (user-edited) diff to be applied to the current doc + */ public async resolveByManualEdit(diffStringToApply: string) { const originalDoc = _.merge({}, this.doc); const diffToApply = JSON.parse(diffStringToApply); diff --git a/src/app/conflict-resolution/conflict-resolution-routing.module.ts b/src/app/conflict-resolution/conflict-resolution-routing.module.ts new file mode 100644 index 0000000000..e29c5c1bc9 --- /dev/null +++ b/src/app/conflict-resolution/conflict-resolution-routing.module.ts @@ -0,0 +1,23 @@ +import { ConflictResolutionComponent } from './conflict-resolution/conflict-resolution.component'; +import { RouterModule, Routes } from '@angular/router'; +import { NgModule } from '@angular/core'; + +/** + * Internal routes of the lazy-loaded ConflictResolutionModule. + * These are relative to the route the module is loaded at in the main app. + */ +const routes: Routes = [ + { + path: '', + component: ConflictResolutionComponent, + }, +]; + +/** + * Routing Module for the lazy-loaded {@link ConflictResolutionModule}. + */ +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule], +}) +export class ConflictResolutionRoutingModule { } diff --git a/src/app/conflict-resolution/conflict-resolution-strategy/conflict-resolution-strategy.service.spec.ts b/src/app/conflict-resolution/conflict-resolution-strategy/conflict-resolution-strategy.service.spec.ts index 7fcf9e334e..aee6a67343 100644 --- a/src/app/conflict-resolution/conflict-resolution-strategy/conflict-resolution-strategy.service.spec.ts +++ b/src/app/conflict-resolution/conflict-resolution-strategy/conflict-resolution-strategy.service.spec.ts @@ -1,8 +1,8 @@ -import {TestBed} from '@angular/core/testing'; +import { TestBed } from '@angular/core/testing'; -import {ConflictResolutionStrategyService} from './conflict-resolution-strategy.service'; -import {AttendanceMonth} from '../../children/attendance/attendance-month'; -import {AttendanceDay, AttendanceStatus} from '../../children/attendance/attendance-day'; +import { ConflictResolutionStrategyService } from './conflict-resolution-strategy.service'; +import { AttendanceMonth } from '../../child-dev-project/attendance/model/attendance-month'; +import { AttendanceDay, AttendanceStatus } from '../../child-dev-project/attendance/model/attendance-day'; describe('ConflictResolutionStrategyService', () => { let service: ConflictResolutionStrategyService; diff --git a/src/app/conflict-resolution/conflict-resolution-strategy/conflict-resolution-strategy.service.ts b/src/app/conflict-resolution/conflict-resolution-strategy/conflict-resolution-strategy.service.ts index 928468bc03..876ea3b7ee 100644 --- a/src/app/conflict-resolution/conflict-resolution-strategy/conflict-resolution-strategy.service.ts +++ b/src/app/conflict-resolution/conflict-resolution-strategy/conflict-resolution-strategy.service.ts @@ -7,7 +7,7 @@ import { AttendanceMonth } from '../../child-dev-project/attendance/model/attend * Attempt automatic conflict resolutions or identify trivial conflicts for semi-automatic resolution. */ @Injectable({ - providedIn: 'root' + providedIn: 'root', }) export class ConflictResolutionStrategyService { diff --git a/src/app/conflict-resolution/conflict-resolution.module.ts b/src/app/conflict-resolution/conflict-resolution.module.ts index df961bab59..726e1a318e 100644 --- a/src/app/conflict-resolution/conflict-resolution.module.ts +++ b/src/app/conflict-resolution/conflict-resolution.module.ts @@ -1,23 +1,26 @@ import { NgModule } from '@angular/core'; import { CommonModule } from '@angular/common'; import { ConflictResolutionComponent } from './conflict-resolution/conflict-resolution.component'; -import {MatTableModule} from '@angular/material/table'; -import {MatSortModule} from '@angular/material/sort'; -import {MatIconModule} from '@angular/material/icon'; -import {MatButtonModule} from '@angular/material/button'; -import {MatPaginatorModule} from '@angular/material/paginator'; -import {MatExpansionModule} from '@angular/material/expansion'; +import { MatTableModule } from '@angular/material/table'; +import { MatSortModule } from '@angular/material/sort'; +import { MatIconModule } from '@angular/material/icon'; +import { MatButtonModule } from '@angular/material/button'; +import { MatPaginatorModule } from '@angular/material/paginator'; +import { MatExpansionModule } from '@angular/material/expansion'; import { CompareRevComponent } from './compare-rev/compare-rev.component'; -import {FlexLayoutModule} from '@angular/flex-layout'; -import {MatInputModule} from '@angular/material/input'; -import {FormsModule} from '@angular/forms'; -import {MatTooltipModule} from '@angular/material/tooltip'; - +import { FlexLayoutModule } from '@angular/flex-layout'; +import { MatInputModule } from '@angular/material/input'; +import { FormsModule } from '@angular/forms'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { ConflictResolutionRoutingModule } from './conflict-resolution-routing.module'; +/** + * Display and resolve document conflicts in the database through a simple user interface for administrators. + */ @NgModule({ - declarations: [ConflictResolutionComponent, CompareRevComponent], imports: [ + ConflictResolutionRoutingModule, CommonModule, MatTableModule, MatSortModule, @@ -29,6 +32,10 @@ import {MatTooltipModule} from '@angular/material/tooltip'; MatInputModule, FormsModule, MatTooltipModule, - ] + ], + declarations: [ + ConflictResolutionComponent, + CompareRevComponent, + ], }) export class ConflictResolutionModule { } diff --git a/src/app/conflict-resolution/conflict-resolution/conflict-resolution.component.spec.ts b/src/app/conflict-resolution/conflict-resolution/conflict-resolution.component.spec.ts index 0eaf3c8e66..677979ac02 100644 --- a/src/app/conflict-resolution/conflict-resolution/conflict-resolution.component.spec.ts +++ b/src/app/conflict-resolution/conflict-resolution/conflict-resolution.component.spec.ts @@ -1,15 +1,15 @@ import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { ConflictResolutionComponent } from './conflict-resolution.component'; -import {MatTableModule} from '@angular/material/table'; -import {MatTooltipModule} from '@angular/material/tooltip'; -import {CompareRevComponent} from '../compare-rev/compare-rev.component'; -import {MatExpansionModule} from '@angular/material/expansion'; -import {Database} from '../../database/database'; -import {MockDatabase} from '../../database/mock-database'; -import {FormsModule} from '@angular/forms'; -import {MatPaginatorModule} from '@angular/material/paginator'; -import {NoopAnimationsModule} from '@angular/platform-browser/animations'; +import { MatTableModule } from '@angular/material/table'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { CompareRevComponent } from '../compare-rev/compare-rev.component'; +import { MatExpansionModule } from '@angular/material/expansion'; +import { FormsModule } from '@angular/forms'; +import { MatPaginatorModule } from '@angular/material/paginator'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { Database } from '../../core/database/database'; +import { MockDatabase } from '../../core/database/mock-database'; describe('ConflictResolutionComponent', () => { let component: ConflictResolutionComponent; @@ -31,7 +31,7 @@ describe('ConflictResolutionComponent', () => { declarations: [ CompareRevComponent, ConflictResolutionComponent, - ] + ], }) .compileComponents(); })); diff --git a/src/app/conflict-resolution/conflict-resolution/conflict-resolution.component.ts b/src/app/conflict-resolution/conflict-resolution/conflict-resolution.component.ts index 3590c927c2..e4238768ca 100644 --- a/src/app/conflict-resolution/conflict-resolution/conflict-resolution.component.ts +++ b/src/app/conflict-resolution/conflict-resolution/conflict-resolution.component.ts @@ -1,5 +1,4 @@ -import { AfterViewInit, Component, OnInit, ViewChild } from '@angular/core'; -import { MatSort } from '@angular/material/sort'; +import { AfterViewInit, Component, ViewChild } from '@angular/core'; import { MatPaginator } from '@angular/material/paginator'; import { QueryDataSource } from '../../core/database/query-data-source'; import { Entity } from '../../core/entity/entity'; @@ -14,29 +13,31 @@ import { Database } from '../../core/database/database'; templateUrl: './conflict-resolution.component.html', styleUrls: ['./conflict-resolution.component.scss'], }) -export class ConflictResolutionComponent implements OnInit, AfterViewInit { +export class ConflictResolutionComponent implements AfterViewInit { + /** visible table columns in the template */ columnsToDisplay = [ 'id', 'data' ]; - @ViewChild(MatSort, { static: true }) sort: MatSort; + + /** data for the table in the template */ dataSource: QueryDataSource; + /** reference to mat-table paginator from template, required to set up pagination */ @ViewChild(MatPaginator, { static: true }) paginator: MatPaginator; constructor( private db: Database, ) { } - async ngOnInit() { - } - async ngAfterViewInit() { - await this.createConflictView(); + await this.createDatabaseIndexForConflicts(); this.dataSource = new QueryDataSource(this.db, 'conflicts/all'); this.dataSource.paginator = this.paginator; - // this.dataSource.sort = this.sort; } - private createConflictView() { + /** + * Create the database index to query document conflicts, if the index doesn't exist already. + */ + private createDatabaseIndexForConflicts() { const designDoc = { _id: '_design/conflicts', views: { @@ -50,8 +51,4 @@ export class ConflictResolutionComponent implements OnInit, AfterViewInit { return this.db.saveDatabaseIndex(designDoc); } - - stringify(entity: any) { - return JSON.stringify(entity); - } } diff --git a/tsconfig.json b/tsconfig.json index a34aad4705..45b889d898 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,6 +5,7 @@ "baseUrl": "src", "sourceMap": true, "declaration": false, + "module": "esnext", "moduleResolution": "node", "emitDecoratorMetadata": true, "experimentalDecorators": true, From 62b232fd9532d5ce8023d907fc8860b89e53001a Mon Sep 17 00:00:00 2001 From: Sebastian Leidig Date: Thu, 21 May 2020 15:50:33 +0200 Subject: [PATCH 5/8] add tests --- .../compare-rev/compare-rev.component.spec.ts | 86 +++++++++++++++++-- .../conflict-resolution.component.spec.ts | 16 +++- 2 files changed, 95 insertions(+), 7 deletions(-) diff --git a/src/app/conflict-resolution/compare-rev/compare-rev.component.spec.ts b/src/app/conflict-resolution/compare-rev/compare-rev.component.spec.ts index 3d419ec8e7..4b87734d1a 100644 --- a/src/app/conflict-resolution/compare-rev/compare-rev.component.spec.ts +++ b/src/app/conflict-resolution/compare-rev/compare-rev.component.spec.ts @@ -1,4 +1,10 @@ -import { async, ComponentFixture, TestBed } from "@angular/core/testing"; +import { + async, + ComponentFixture, + fakeAsync, + TestBed, + tick, +} from "@angular/core/testing"; import { CompareRevComponent } from "./compare-rev.component"; import { MatTooltipModule } from "@angular/material/tooltip"; @@ -8,18 +14,31 @@ import { ConflictResolutionStrategyService } from "../conflict-resolution-strate import { NoopAnimationsModule } from "@angular/platform-browser/animations"; import { ConfirmationDialogService } from "../../core/confirmation-dialog/confirmation-dialog.service"; import { Database } from "../../core/database/database"; -import { MockDatabase } from "../../core/database/mock-database"; import { MatSnackBarModule } from "@angular/material/snack-bar"; +import { BehaviorSubject } from "rxjs"; describe("CompareRevComponent", () => { let component: CompareRevComponent; let fixture: ComponentFixture; + let mockDatabase: jasmine.SpyObj; + + const testDoc = { _id: "abc", _rev: "rev-1a", value: 1 }; + const testConflictDoc = { _id: "abc", _rev: "rev-1b", value: 2 }; + beforeEach(async(() => { + mockDatabase = jasmine.createSpyObj("mockDatabase", [ + "get", + "remove", + "put", + ]); + mockDatabase.get.and.returnValue(Promise.resolve(testConflictDoc)); + const confDialogMock = { - openDialog: () => {}, + // by default immediately simulate a confirmed dialog result + openDialog: () => ({ afterClosed: () => new BehaviorSubject(true) }), }; - spyOn(confDialogMock, "openDialog"); + spyOn(confDialogMock, "openDialog").and.callThrough(); TestBed.configureTestingModule({ imports: [ @@ -31,7 +50,7 @@ describe("CompareRevComponent", () => { ], providers: [ { provide: ConfirmationDialogService, useValue: confDialogMock }, - { provide: Database, useValue: new MockDatabase() }, + { provide: Database, useValue: mockDatabase }, ConflictResolutionStrategyService, ], declarations: [CompareRevComponent], @@ -41,10 +60,67 @@ describe("CompareRevComponent", () => { beforeEach(() => { fixture = TestBed.createComponent(CompareRevComponent); component = fixture.componentInstance; + component.doc = testDoc; // @Input + component.rev = testConflictDoc._rev; // @Input fixture.detectChanges(); }); it("should create", () => { expect(component).toBeTruthy(); }); + + it("should load and analyse the given doc revision", async () => { + await component.loadRev(); + + expect(mockDatabase.get).toHaveBeenCalledWith(testDoc._id, { + rev: testConflictDoc._rev, + }); + expect(component.revDoc).toBe(testConflictDoc); + expect(component.diffs).toBeDefined(); + expect(component.diffsReverse).toBeDefined(); + expect(component.diffsCustom).toBeDefined(); + }); + + it("should automatically resolve (delete) trivial conflict", async () => { + const conflictResolutionService = TestBed.get( + ConflictResolutionStrategyService + ); + mockDatabase.get.and.returnValue(Promise.resolve(testConflictDoc)); + spyOn( + conflictResolutionService, + "isIrrelevantConflictVersion" + ).and.returnValue(true); + + await component.loadRev(); + + expect( + conflictResolutionService.isIrrelevantConflictVersion + ).toHaveBeenCalled(); + expect(mockDatabase.remove).toHaveBeenCalledWith(testConflictDoc); + expect(component.resolution).toBeTruthy(); + }); + + it("should resolveByDelete, deleting giving doc", fakeAsync(() => { + component.loadRev(); + tick(); + + component.resolveByDelete(testConflictDoc); + tick(); + + expect(mockDatabase.remove).toHaveBeenCalledWith(testConflictDoc); + expect(mockDatabase.put).not.toHaveBeenCalled(); + expect(component.resolution).toBeTruthy(); + })); + + it("should resolveByManualEdit, saving new version and removing conflict", fakeAsync(() => { + component.loadRev(); + tick(); + + component.resolveByManualEdit(component.diffsCustom); + tick(); + + expect(mockDatabase.remove).toHaveBeenCalledWith(testConflictDoc); + expect(mockDatabase.put).toHaveBeenCalled(); + expect(component.resolution).toBeTruthy(); + })); }); diff --git a/src/app/conflict-resolution/conflict-resolution/conflict-resolution.component.spec.ts b/src/app/conflict-resolution/conflict-resolution/conflict-resolution.component.spec.ts index 19857ce93b..c43d4da968 100644 --- a/src/app/conflict-resolution/conflict-resolution/conflict-resolution.component.spec.ts +++ b/src/app/conflict-resolution/conflict-resolution/conflict-resolution.component.spec.ts @@ -9,13 +9,19 @@ import { FormsModule } from "@angular/forms"; import { MatPaginatorModule } from "@angular/material/paginator"; import { NoopAnimationsModule } from "@angular/platform-browser/animations"; import { Database } from "../../core/database/database"; -import { MockDatabase } from "../../core/database/mock-database"; describe("ConflictResolutionComponent", () => { let component: ConflictResolutionComponent; let fixture: ComponentFixture; + let mockDatabase: jasmine.SpyObj; + beforeEach(async(() => { + mockDatabase = jasmine.createSpyObj("mockDatabase", [ + "saveDatabaseIndex", + "query", + ]); + TestBed.configureTestingModule({ imports: [ MatTableModule, @@ -25,7 +31,7 @@ describe("ConflictResolutionComponent", () => { FormsModule, NoopAnimationsModule, ], - providers: [{ provide: Database, useValue: new MockDatabase() }], + providers: [{ provide: Database, useValue: mockDatabase }], declarations: [CompareRevComponent, ConflictResolutionComponent], }).compileComponents(); })); @@ -39,4 +45,10 @@ describe("ConflictResolutionComponent", () => { it("should create", () => { expect(component).toBeTruthy(); }); + + it("should create database index for querying conflicts", async () => { + await component.ngAfterViewInit(); + + expect(mockDatabase.saveDatabaseIndex).toHaveBeenCalled(); + }); }); From 3ad6870dbad69b09b148eeb7f47a053909d428db Mon Sep 17 00:00:00 2001 From: Sebastian Leidig Date: Thu, 21 May 2020 20:34:11 +0200 Subject: [PATCH 6/8] allow easy providing of multi conflict resolution strategies --- .gitignore | 1 - proxy.conf.json | 2 +- ...nce-month-conflict-resolution-strategy.ts} | 46 ++++++++----- ...endance-month-conflict-resolution.spec.ts} | 28 ++++---- .../children/children.module.ts | 13 +++- .../auto-resolution.service.spec.ts | 68 +++++++++++++++++++ .../auto-resolution.service.ts | 44 ++++++++++++ .../conflict-resolution-strategy.ts | 21 ++++++ .../compare-rev/compare-rev.component.spec.ts | 22 +++--- .../compare-rev/compare-rev.component.ts | 6 +- .../conflict-resolution.module.ts | 19 ++++++ .../conflict-resolution.component.html | 36 ++++++---- .../conflict-resolution.component.spec.ts | 3 + .../conflict-resolution.component.ts | 27 +++++++- 14 files changed, 270 insertions(+), 66 deletions(-) rename src/app/{conflict-resolution/conflict-resolution-strategy/conflict-resolution-strategy.service.ts => child-dev-project/attendance/attendance-month-conflict-resolution-strategy.ts} (54%) rename src/app/{conflict-resolution/conflict-resolution-strategy/conflict-resolution-strategy.service.spec.ts => child-dev-project/attendance/attendance-month-conflict-resolution.spec.ts} (59%) create mode 100644 src/app/conflict-resolution/auto-resolution/auto-resolution.service.spec.ts create mode 100644 src/app/conflict-resolution/auto-resolution/auto-resolution.service.ts create mode 100644 src/app/conflict-resolution/auto-resolution/conflict-resolution-strategy.ts diff --git a/.gitignore b/.gitignore index 57928bac73..d39aec3faa 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,6 @@ /src/assets/config.json /src/assets/child-photos -proxy.conf.json # compiled output dist diff --git a/proxy.conf.json b/proxy.conf.json index 0ee8298156..93402e8153 100644 --- a/proxy.conf.json +++ b/proxy.conf.json @@ -1,6 +1,6 @@ { "/db": { - "target": "https://demo.aam-digital.com", + "target": "https://dev.aam-digital.com", "secure": true, "logLevel": "debug", "changeOrigin": true diff --git a/src/app/conflict-resolution/conflict-resolution-strategy/conflict-resolution-strategy.service.ts b/src/app/child-dev-project/attendance/attendance-month-conflict-resolution-strategy.ts similarity index 54% rename from src/app/conflict-resolution/conflict-resolution-strategy/conflict-resolution-strategy.service.ts rename to src/app/child-dev-project/attendance/attendance-month-conflict-resolution-strategy.ts index 5e5dd245d5..dd443fea93 100644 --- a/src/app/conflict-resolution/conflict-resolution-strategy/conflict-resolution-strategy.service.ts +++ b/src/app/child-dev-project/attendance/attendance-month-conflict-resolution-strategy.ts @@ -1,36 +1,46 @@ import { Injectable } from "@angular/core"; -import { diff } from "deep-object-diff"; +import { ConflictResolutionStrategy } from "../../conflict-resolution/auto-resolution/conflict-resolution-strategy"; +import { AttendanceMonth } from "./model/attendance-month"; import _ from "lodash"; -import { AttendanceMonth } from "../../child-dev-project/attendance/model/attendance-month"; +import { diff } from "deep-object-diff"; /** - * Attempt automatic conflict resolutions or identify trivial conflicts for semi-automatic resolution. + * Auto resolve simple database document conflicts concerning {@link AttendanceMonth} entities. */ -@Injectable({ - providedIn: "root", -}) -export class ConflictResolutionStrategyService { - constructor() {} - - public isIrrelevantConflictVersion( +@Injectable() +export class AttendanceMonthConflictResolutionStrategy + implements ConflictResolutionStrategy { + /** + * Checks if the given conflict is about AttendanceMonth entities (otherwise this strategy doesn't apply) + * and suggests whether the conflict is trivial and can be automatically deleted. + * @param currentDoc The currently active revision + * @param conflictingDoc The conflicting revision to be checked whether it can be deleted + */ + public autoDeleteConflictingRevision( currentDoc: any, conflictingDoc: any ): boolean { + console.log("checking conflict"); + if (!currentDoc._id.startsWith(AttendanceMonth.ENTITY_TYPE)) { + return false; + } + const currentDocC = _.merge({}, currentDoc); delete currentDocC._rev; const conflictingDocC = _.merge({}, conflictingDoc); delete conflictingDocC._rev; - if (currentDocC._id.startsWith(AttendanceMonth.ENTITY_TYPE)) { - return this.isIrrelevantAttendanceMonthConflict( - currentDocC, - conflictingDocC - ); - } - - return false; + return this.isIrrelevantAttendanceMonthConflict( + currentDocC, + conflictingDocC + ); } + /** + * Calculate a diff between the two objects discarding trivial differences. + * @param currentDoc The object to compare against + * @param conflictingDoc The conflicting object version to compare + */ private isIrrelevantAttendanceMonthConflict( currentDoc: any, conflictingDoc: any diff --git a/src/app/conflict-resolution/conflict-resolution-strategy/conflict-resolution-strategy.service.spec.ts b/src/app/child-dev-project/attendance/attendance-month-conflict-resolution.spec.ts similarity index 59% rename from src/app/conflict-resolution/conflict-resolution-strategy/conflict-resolution-strategy.service.spec.ts rename to src/app/child-dev-project/attendance/attendance-month-conflict-resolution.spec.ts index c664440e81..d85c4f64fa 100644 --- a/src/app/conflict-resolution/conflict-resolution-strategy/conflict-resolution-strategy.service.spec.ts +++ b/src/app/child-dev-project/attendance/attendance-month-conflict-resolution.spec.ts @@ -1,25 +1,23 @@ import { TestBed } from "@angular/core/testing"; +import { AttendanceMonthConflictResolutionStrategy } from "./attendance-month-conflict-resolution-strategy"; +import { AttendanceMonth } from "./model/attendance-month"; +import { AttendanceDay, AttendanceStatus } from "./model/attendance-day"; -import { ConflictResolutionStrategyService } from "./conflict-resolution-strategy.service"; -import { AttendanceMonth } from "../../child-dev-project/attendance/model/attendance-month"; -import { - AttendanceDay, - AttendanceStatus, -} from "../../child-dev-project/attendance/model/attendance-day"; - -describe("ConflictResolutionStrategyService", () => { - let service: ConflictResolutionStrategyService; +describe("AutoResolutionService", () => { + let service: AttendanceMonthConflictResolutionStrategy; beforeEach(() => { - TestBed.configureTestingModule({}); - service = TestBed.get(ConflictResolutionStrategyService); + TestBed.configureTestingModule({ + providers: [AttendanceMonthConflictResolutionStrategy], + }); + service = TestBed.get(AttendanceMonthConflictResolutionStrategy); }); it("should be created", () => { expect(service).toBeTruthy(); }); - it("should delete irrelevant attendance diff conflicts", () => { + it("should suggest deleting irrelevant/trivial conflict", () => { const currentDoc = new AttendanceMonth("test1"); currentDoc.month = new Date(2019, 0); currentDoc.dailyRegister[0] = new AttendanceDay( @@ -31,14 +29,14 @@ describe("ConflictResolutionStrategyService", () => { conflictingDoc.month = new Date(2019, 0); // no dailyRegister entries set - const result = service.isIrrelevantConflictVersion( + const result = service.autoDeleteConflictingRevision( currentDoc, conflictingDoc ); expect(result).toBe(true); }); - it("should not delete complex attendance diff conflicts", () => { + it("should not suggest deleting complex attendance diff conflicts", () => { const currentDoc = new AttendanceMonth("test1"); currentDoc.month = new Date(2019, 0); currentDoc.dailyRegister[0] = new AttendanceDay( @@ -53,7 +51,7 @@ describe("ConflictResolutionStrategyService", () => { AttendanceStatus.EXCUSED ); - const result = service.isIrrelevantConflictVersion( + const result = service.autoDeleteConflictingRevision( currentDoc, conflictingDoc ); diff --git a/src/app/child-dev-project/children/children.module.ts b/src/app/child-dev-project/children/children.module.ts index 47e519d02d..ce46468f8f 100644 --- a/src/app/child-dev-project/children/children.module.ts +++ b/src/app/child-dev-project/children/children.module.ts @@ -74,6 +74,8 @@ import { MatProgressSpinnerModule } from "@angular/material/progress-spinner"; import { RecentNotesDashboardComponent } from "../notes/dashboard-widgets/recent-notes-dashboard/recent-notes-dashboard.component"; import { FormDialogModule } from "../../core/form-dialog/form-dialog.module"; import { ConfirmationDialogModule } from "../../core/confirmation-dialog/confirmation-dialog.module"; +import { CONFLICT_RESOLUTION_STRATEGY } from "../../conflict-resolution/auto-resolution/conflict-resolution-strategy"; +import { AttendanceMonthConflictResolutionStrategy } from "../attendance/attendance-month-conflict-resolution-strategy"; @NgModule({ imports: [ @@ -139,7 +141,16 @@ import { ConfirmationDialogModule } from "../../core/confirmation-dialog/confirm HealthCheckupComponent, PreviousSchoolsComponent, ], - providers: [ChildrenService, DatePipe, PercentPipe], + providers: [ + ChildrenService, + DatePipe, + PercentPipe, + { + provide: CONFLICT_RESOLUTION_STRATEGY, + useClass: AttendanceMonthConflictResolutionStrategy, + multi: true, + }, + ], exports: [ ChildBlockComponent, ChildSelectComponent, diff --git a/src/app/conflict-resolution/auto-resolution/auto-resolution.service.spec.ts b/src/app/conflict-resolution/auto-resolution/auto-resolution.service.spec.ts new file mode 100644 index 0000000000..e0e92974c1 --- /dev/null +++ b/src/app/conflict-resolution/auto-resolution/auto-resolution.service.spec.ts @@ -0,0 +1,68 @@ +import { TestBed } from "@angular/core/testing"; + +import { AutoResolutionService } from "./auto-resolution.service"; +import { + CONFLICT_RESOLUTION_STRATEGY, + ConflictResolutionStrategy, +} from "./conflict-resolution-strategy"; + +describe("AutoResolutionService", () => { + let service: AutoResolutionService; + + let mockResolutionStrategy: jasmine.SpyObj; + + beforeEach(() => { + mockResolutionStrategy = jasmine.createSpyObj("mockResolutionStrategy", [ + "autoDeleteConflictingRevision", + ]); + + TestBed.configureTestingModule({ + providers: [ + { + provide: CONFLICT_RESOLUTION_STRATEGY, + useValue: mockResolutionStrategy, + multi: true, + }, + ], + }); + service = TestBed.get(AutoResolutionService); + }); + + it("should be created", () => { + expect(service).toBeTruthy(); + }); + + it("should suggest auto delete conflict if a strategy applies", () => { + const testDoc = { _id: "abc", _rev: "rev-1a", value: 1 }; + const testConflictDoc = { _id: "abc", _rev: "rev-1b", value: 2 }; + + mockResolutionStrategy.autoDeleteConflictingRevision.and.returnValue(true); + + const result = service.shouldDeleteConflictingRevision( + testDoc, + testConflictDoc + ); + + expect( + mockResolutionStrategy.autoDeleteConflictingRevision + ).toHaveBeenCalled(); + expect(result).toBe(true); + }); + + it("should not suggest auto delete conflicts if no strategy applies", () => { + const testDoc = { _id: "abc", _rev: "rev-1a", value: 1 }; + const testConflictDoc = { _id: "abc", _rev: "rev-1b", value: 2 }; + + mockResolutionStrategy.autoDeleteConflictingRevision.and.returnValue(false); + + const result = service.shouldDeleteConflictingRevision( + testDoc, + testConflictDoc + ); + + expect( + mockResolutionStrategy.autoDeleteConflictingRevision + ).toHaveBeenCalled(); + expect(result).toBe(false); + }); +}); diff --git a/src/app/conflict-resolution/auto-resolution/auto-resolution.service.ts b/src/app/conflict-resolution/auto-resolution/auto-resolution.service.ts new file mode 100644 index 0000000000..554cea7eea --- /dev/null +++ b/src/app/conflict-resolution/auto-resolution/auto-resolution.service.ts @@ -0,0 +1,44 @@ +import { Inject, Injectable } from "@angular/core"; +import { + CONFLICT_RESOLUTION_STRATEGY, + ConflictResolutionStrategy, +} from "./conflict-resolution-strategy"; + +/** + * Attempt automatic conflict resolutions or identify trivial conflicts for semi-automatic resolution. + */ +@Injectable({ + providedIn: "root", +}) +export class AutoResolutionService { + constructor( + @Inject(CONFLICT_RESOLUTION_STRATEGY) + private resolutionStrategies: ConflictResolutionStrategy[] + ) {} + + /** + * Checks whether any registered resolution strategy suggests that the conflicting version should be automatically deleted. + * + * This method does not delete the conflict. It only suggests whether it should be deleted automatically. + * + * @param currentDoc The currently active revision of the doc + * @param conflictingDoc The conflicting revision of the doc to be checked whether it can be deleted + */ + public shouldDeleteConflictingRevision( + currentDoc: any, + conflictingDoc: any + ): boolean { + for (const resolutionStrategy of this.resolutionStrategies) { + if ( + resolutionStrategy.autoDeleteConflictingRevision( + currentDoc, + conflictingDoc + ) + ) { + return true; + } + } + + return false; + } +} diff --git a/src/app/conflict-resolution/auto-resolution/conflict-resolution-strategy.ts b/src/app/conflict-resolution/auto-resolution/conflict-resolution-strategy.ts new file mode 100644 index 0000000000..00cddbad62 --- /dev/null +++ b/src/app/conflict-resolution/auto-resolution/conflict-resolution-strategy.ts @@ -0,0 +1,21 @@ +import { InjectionToken } from "@angular/core"; + +/** + * Use this token to provide (and thereby register) custom implementations of {@link ConflictResolutionStrategy}. + * + * `{ provide: CONFLICT_RESOLUTION_STRATEGY, useClass: MyConflictResolutionStrategy, multi: true }` + * + * see {@link ConflictResolutionModule} + */ +export const CONFLICT_RESOLUTION_STRATEGY = new InjectionToken< + ConflictResolutionStrategy +>("ConflictResolutionStrategy"); + +/** + * Implement this interface to provide custom strategies how certain conflicts of an Entity type can be resolved automatically. + * + * see {@link ConflictResolutionModule} + */ +export interface ConflictResolutionStrategy { + autoDeleteConflictingRevision(currentDoc: any, conflictingDoc: any): boolean; +} diff --git a/src/app/conflict-resolution/compare-rev/compare-rev.component.spec.ts b/src/app/conflict-resolution/compare-rev/compare-rev.component.spec.ts index 4b87734d1a..36ebce81f9 100644 --- a/src/app/conflict-resolution/compare-rev/compare-rev.component.spec.ts +++ b/src/app/conflict-resolution/compare-rev/compare-rev.component.spec.ts @@ -10,18 +10,19 @@ import { CompareRevComponent } from "./compare-rev.component"; import { MatTooltipModule } from "@angular/material/tooltip"; import { MatExpansionModule } from "@angular/material/expansion"; import { FormsModule } from "@angular/forms"; -import { ConflictResolutionStrategyService } from "../conflict-resolution-strategy/conflict-resolution-strategy.service"; import { NoopAnimationsModule } from "@angular/platform-browser/animations"; import { ConfirmationDialogService } from "../../core/confirmation-dialog/confirmation-dialog.service"; import { Database } from "../../core/database/database"; import { MatSnackBarModule } from "@angular/material/snack-bar"; import { BehaviorSubject } from "rxjs"; +import { AutoResolutionService } from "../auto-resolution/auto-resolution.service"; describe("CompareRevComponent", () => { let component: CompareRevComponent; let fixture: ComponentFixture; let mockDatabase: jasmine.SpyObj; + let mockResolutionService: jasmine.SpyObj; const testDoc = { _id: "abc", _rev: "rev-1a", value: 1 }; const testConflictDoc = { _id: "abc", _rev: "rev-1b", value: 2 }; @@ -34,6 +35,13 @@ describe("CompareRevComponent", () => { ]); mockDatabase.get.and.returnValue(Promise.resolve(testConflictDoc)); + mockResolutionService = jasmine.createSpyObj("mockResolutionService", [ + "shouldDeleteConflictingRevision", + ]); + mockResolutionService.shouldDeleteConflictingRevision.and.returnValue( + false + ); + const confDialogMock = { // by default immediately simulate a confirmed dialog result openDialog: () => ({ afterClosed: () => new BehaviorSubject(true) }), @@ -51,7 +59,7 @@ describe("CompareRevComponent", () => { providers: [ { provide: ConfirmationDialogService, useValue: confDialogMock }, { provide: Database, useValue: mockDatabase }, - ConflictResolutionStrategyService, + { provide: AutoResolutionService, useValue: mockResolutionService }, ], declarations: [CompareRevComponent], }).compileComponents(); @@ -82,19 +90,13 @@ describe("CompareRevComponent", () => { }); it("should automatically resolve (delete) trivial conflict", async () => { - const conflictResolutionService = TestBed.get( - ConflictResolutionStrategyService - ); mockDatabase.get.and.returnValue(Promise.resolve(testConflictDoc)); - spyOn( - conflictResolutionService, - "isIrrelevantConflictVersion" - ).and.returnValue(true); + mockResolutionService.shouldDeleteConflictingRevision.and.returnValue(true); await component.loadRev(); expect( - conflictResolutionService.isIrrelevantConflictVersion + mockResolutionService.shouldDeleteConflictingRevision ).toHaveBeenCalled(); expect(mockDatabase.remove).toHaveBeenCalledWith(testConflictDoc); expect(component.resolution).toBeTruthy(); diff --git a/src/app/conflict-resolution/compare-rev/compare-rev.component.ts b/src/app/conflict-resolution/compare-rev/compare-rev.component.ts index 1f7f1d4595..46bdbb2d1a 100644 --- a/src/app/conflict-resolution/compare-rev/compare-rev.component.ts +++ b/src/app/conflict-resolution/compare-rev/compare-rev.component.ts @@ -1,10 +1,10 @@ import { Component, Input } from "@angular/core"; import { diff } from "deep-object-diff"; -import { ConflictResolutionStrategyService } from "../conflict-resolution-strategy/conflict-resolution-strategy.service"; import _ from "lodash"; import { ConfirmationDialogService } from "../../core/confirmation-dialog/confirmation-dialog.service"; import { Database } from "../../core/database/database"; import { MatSnackBar } from "@angular/material/snack-bar"; +import { AutoResolutionService } from "../auto-resolution/auto-resolution.service"; /** * Visualize one specific conflicting document revision and offer resolution options. @@ -47,7 +47,7 @@ export class CompareRevComponent { private db: Database, private confirmationDialog: ConfirmationDialogService, private snackBar: MatSnackBar, - private conflictResolver: ConflictResolutionStrategyService + private conflictResolver: AutoResolutionService ) {} /** @@ -62,7 +62,7 @@ export class CompareRevComponent { this.diffsReverse = this.stringify(diffReverseObject); this.diffsCustom = this.stringify(diffReverseObject); - const isIrrelevantConflictingDoc = this.conflictResolver.isIrrelevantConflictVersion( + const isIrrelevantConflictingDoc = this.conflictResolver.shouldDeleteConflictingRevision( this.doc, this.revDoc ); diff --git a/src/app/conflict-resolution/conflict-resolution.module.ts b/src/app/conflict-resolution/conflict-resolution.module.ts index 47df669b1b..486cc5a6cb 100644 --- a/src/app/conflict-resolution/conflict-resolution.module.ts +++ b/src/app/conflict-resolution/conflict-resolution.module.ts @@ -13,9 +13,28 @@ import { MatInputModule } from "@angular/material/input"; import { FormsModule } from "@angular/forms"; import { MatTooltipModule } from "@angular/material/tooltip"; import { ConflictResolutionRoutingModule } from "./conflict-resolution-routing.module"; +import { ConflictResolutionStrategy } from "./auto-resolution/conflict-resolution-strategy"; /** * Display and resolve document conflicts in the database through a simple user interface for administrators. + * + * You can register additional custom strategies to auto-resolve conflicts + * by implementing {@link ConflictResolutionStrategy} + * and registering your implementation as a provider in your Module: + * `{ provide: CONFLICT_RESOLUTION_STRATEGY, useClass: MyConflictResolutionStrategy, multi: true }` + * + * Import this as a "lazy-loaded" module in your main routing: + * @example +routes: Routes = [ + { + path: "admin/conflicts", + canActivate: [AdminGuard], + loadChildren: () => + import("./conflict-resolution/conflict-resolution.module").then( + (m) => m.ConflictResolutionModule + ), + } +]; */ @NgModule({ imports: [ diff --git a/src/app/conflict-resolution/conflict-resolution/conflict-resolution.component.html b/src/app/conflict-resolution/conflict-resolution/conflict-resolution.component.html index 3eb053c0fe..3a2e9021c1 100644 --- a/src/app/conflict-resolution/conflict-resolution/conflict-resolution.component.html +++ b/src/app/conflict-resolution/conflict-resolution/conflict-resolution.component.html @@ -1,22 +1,28 @@

conflicts to resolve:

- +
+
- - - - + + + + - - - - + + + + - - -
_id {{row.id}} _id {{row.id}} Data - - Data + +
+ + + - + + + +
+ +
diff --git a/src/app/conflict-resolution/conflict-resolution/conflict-resolution.component.spec.ts b/src/app/conflict-resolution/conflict-resolution/conflict-resolution.component.spec.ts index c43d4da968..6d294c9f1a 100644 --- a/src/app/conflict-resolution/conflict-resolution/conflict-resolution.component.spec.ts +++ b/src/app/conflict-resolution/conflict-resolution/conflict-resolution.component.spec.ts @@ -21,6 +21,9 @@ describe("ConflictResolutionComponent", () => { "saveDatabaseIndex", "query", ]); + mockDatabase.query.and.returnValue( + Promise.resolve({ total_rows: 0, rows: [] }) + ); TestBed.configureTestingModule({ imports: [ diff --git a/src/app/conflict-resolution/conflict-resolution/conflict-resolution.component.ts b/src/app/conflict-resolution/conflict-resolution/conflict-resolution.component.ts index 841658edb8..5caef6af91 100644 --- a/src/app/conflict-resolution/conflict-resolution/conflict-resolution.component.ts +++ b/src/app/conflict-resolution/conflict-resolution/conflict-resolution.component.ts @@ -1,8 +1,12 @@ -import { AfterViewInit, Component, ViewChild } from "@angular/core"; +import { AfterViewInit, Component, Optional, ViewChild } from "@angular/core"; import { MatPaginator } from "@angular/material/paginator"; import { QueryDataSource } from "../../core/database/query-data-source"; import { Entity } from "../../core/entity/entity"; import { Database } from "../../core/database/database"; +import PouchDB from "pouchdb-browser"; +import { AppConfig } from "../../core/app-config/app-config"; +import { AttendanceMonth } from "../../child-dev-project/attendance/model/attendance-month"; +import { EntitySchemaService } from "../../core/entity/schema/entity-schema.service"; /** * List all document conflicts and allow the user to expand for details and manual resolution. @@ -22,7 +26,10 @@ export class ConflictResolutionComponent implements AfterViewInit { /** reference to mat-table paginator from template, required to set up pagination */ @ViewChild(MatPaginator, { static: true }) paginator: MatPaginator; - constructor(private db: Database) {} + constructor( + private db: Database, + @Optional() private entitySchemaService: EntitySchemaService + ) {} async ngAfterViewInit() { await this.createDatabaseIndexForConflicts(); @@ -48,4 +55,20 @@ export class ConflictResolutionComponent implements AfterViewInit { return this.db.saveDatabaseIndex(designDoc); } + + // TODO: remove this before merging + async createTestConflicts() { + const pouchdb = new PouchDB(AppConfig.settings.database.name); + + const doc = this.entitySchemaService.transformEntityToDatabaseFormat( + AttendanceMonth.createAttendanceMonth("0", "school") + ); + doc._id = "AttendanceMonth:0"; + doc._rev = "1-0000"; + await pouchdb.put(doc, { force: true }); + doc.dailyRegister[0].status = "A" as any; + await pouchdb.put(doc, { force: true }); + + await this.dataSource.loadData(); + } } From 01f9c7970be68ece84d918b79d8f59b06e617839 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Tue, 23 Jun 2020 21:12:37 +0200 Subject: [PATCH 7/8] Apply suggestions from code review Co-authored-by: vlle1 <36682087+vlle1@users.noreply.github.com> --- ...ndance-month-conflict-resolution-strategy.ts | 1 - .../compare-rev/compare-rev.component.ts | 1 - .../conflict-resolution.component.html | 4 ---- .../conflict-resolution.component.ts | 17 +---------------- 4 files changed, 1 insertion(+), 22 deletions(-) diff --git a/src/app/child-dev-project/attendance/attendance-month-conflict-resolution-strategy.ts b/src/app/child-dev-project/attendance/attendance-month-conflict-resolution-strategy.ts index dd443fea93..2e89904ff4 100644 --- a/src/app/child-dev-project/attendance/attendance-month-conflict-resolution-strategy.ts +++ b/src/app/child-dev-project/attendance/attendance-month-conflict-resolution-strategy.ts @@ -20,7 +20,6 @@ export class AttendanceMonthConflictResolutionStrategy currentDoc: any, conflictingDoc: any ): boolean { - console.log("checking conflict"); if (!currentDoc._id.startsWith(AttendanceMonth.ENTITY_TYPE)) { return false; } diff --git a/src/app/conflict-resolution/compare-rev/compare-rev.component.ts b/src/app/conflict-resolution/compare-rev/compare-rev.component.ts index 46bdbb2d1a..107e3065b4 100644 --- a/src/app/conflict-resolution/compare-rev/compare-rev.component.ts +++ b/src/app/conflict-resolution/compare-rev/compare-rev.component.ts @@ -131,7 +131,6 @@ export class CompareRevComponent { } } - // TODO: https://www.npmjs.com/package/ngx-text-diff /** * Apply the given diff, save the resulting new document to the database diff --git a/src/app/conflict-resolution/conflict-resolution/conflict-resolution.component.html b/src/app/conflict-resolution/conflict-resolution/conflict-resolution.component.html index 3a2e9021c1..872b8f3a0f 100644 --- a/src/app/conflict-resolution/conflict-resolution/conflict-resolution.component.html +++ b/src/app/conflict-resolution/conflict-resolution/conflict-resolution.component.html @@ -22,7 +22,3 @@ - -
- -
diff --git a/src/app/conflict-resolution/conflict-resolution/conflict-resolution.component.ts b/src/app/conflict-resolution/conflict-resolution/conflict-resolution.component.ts index 5caef6af91..ff56f04a53 100644 --- a/src/app/conflict-resolution/conflict-resolution/conflict-resolution.component.ts +++ b/src/app/conflict-resolution/conflict-resolution/conflict-resolution.component.ts @@ -24,7 +24,7 @@ export class ConflictResolutionComponent implements AfterViewInit { dataSource: QueryDataSource; /** reference to mat-table paginator from template, required to set up pagination */ - @ViewChild(MatPaginator, { static: true }) paginator: MatPaginator; + @ViewChild(MatPaginator) paginator: MatPaginator; constructor( private db: Database, @@ -56,19 +56,4 @@ export class ConflictResolutionComponent implements AfterViewInit { return this.db.saveDatabaseIndex(designDoc); } - // TODO: remove this before merging - async createTestConflicts() { - const pouchdb = new PouchDB(AppConfig.settings.database.name); - - const doc = this.entitySchemaService.transformEntityToDatabaseFormat( - AttendanceMonth.createAttendanceMonth("0", "school") - ); - doc._id = "AttendanceMonth:0"; - doc._rev = "1-0000"; - await pouchdb.put(doc, { force: true }); - doc.dailyRegister[0].status = "A" as any; - await pouchdb.put(doc, { force: true }); - - await this.dataSource.loadData(); - } } From 5712e977663df7b70272214ccdfe2bc0ef9268a5 Mon Sep 17 00:00:00 2001 From: Sebastian Leidig Date: Tue, 23 Jun 2020 21:51:04 +0200 Subject: [PATCH 8/8] rename and comment --- .../compare-rev/compare-rev.component.ts | 1 - .../conflict-resolution-list.component.html} | 4 +++ .../conflict-resolution-list.component.scss} | 0 ...onflict-resolution-list.component.spec.ts} | 12 ++++----- .../conflict-resolution-list.component.ts} | 12 +++------ .../conflict-resolution-routing.module.ts | 4 +-- .../conflict-resolution.module.ts | 6 +++-- src/app/core/database/query-data-source.ts | 25 +++++++++++++++++++ 8 files changed, 45 insertions(+), 19 deletions(-) rename src/app/conflict-resolution/{conflict-resolution/conflict-resolution.component.html => conflict-resolution-list/conflict-resolution-list.component.html} (88%) rename src/app/conflict-resolution/{conflict-resolution/conflict-resolution.component.scss => conflict-resolution-list/conflict-resolution-list.component.scss} (100%) rename src/app/conflict-resolution/{conflict-resolution/conflict-resolution.component.spec.ts => conflict-resolution-list/conflict-resolution-list.component.spec.ts} (79%) rename src/app/conflict-resolution/{conflict-resolution/conflict-resolution.component.ts => conflict-resolution-list/conflict-resolution-list.component.ts} (79%) diff --git a/src/app/conflict-resolution/compare-rev/compare-rev.component.ts b/src/app/conflict-resolution/compare-rev/compare-rev.component.ts index 107e3065b4..9f4636e919 100644 --- a/src/app/conflict-resolution/compare-rev/compare-rev.component.ts +++ b/src/app/conflict-resolution/compare-rev/compare-rev.component.ts @@ -131,7 +131,6 @@ export class CompareRevComponent { } } - /** * Apply the given diff, save the resulting new document to the database * and remove the conflicting document, thereby resolving the conflict. diff --git a/src/app/conflict-resolution/conflict-resolution/conflict-resolution.component.html b/src/app/conflict-resolution/conflict-resolution-list/conflict-resolution-list.component.html similarity index 88% rename from src/app/conflict-resolution/conflict-resolution/conflict-resolution.component.html rename to src/app/conflict-resolution/conflict-resolution-list/conflict-resolution-list.component.html index 872b8f3a0f..1ec2bdaa83 100644 --- a/src/app/conflict-resolution/conflict-resolution/conflict-resolution.component.html +++ b/src/app/conflict-resolution/conflict-resolution-list/conflict-resolution-list.component.html @@ -1,6 +1,10 @@

conflicts to resolve:

+
+ +
+ diff --git a/src/app/conflict-resolution/conflict-resolution/conflict-resolution.component.scss b/src/app/conflict-resolution/conflict-resolution-list/conflict-resolution-list.component.scss similarity index 100% rename from src/app/conflict-resolution/conflict-resolution/conflict-resolution.component.scss rename to src/app/conflict-resolution/conflict-resolution-list/conflict-resolution-list.component.scss diff --git a/src/app/conflict-resolution/conflict-resolution/conflict-resolution.component.spec.ts b/src/app/conflict-resolution/conflict-resolution-list/conflict-resolution-list.component.spec.ts similarity index 79% rename from src/app/conflict-resolution/conflict-resolution/conflict-resolution.component.spec.ts rename to src/app/conflict-resolution/conflict-resolution-list/conflict-resolution-list.component.spec.ts index 6d294c9f1a..3c9bfa244f 100644 --- a/src/app/conflict-resolution/conflict-resolution/conflict-resolution.component.spec.ts +++ b/src/app/conflict-resolution/conflict-resolution-list/conflict-resolution-list.component.spec.ts @@ -1,6 +1,6 @@ import { async, ComponentFixture, TestBed } from "@angular/core/testing"; -import { ConflictResolutionComponent } from "./conflict-resolution.component"; +import { ConflictResolutionListComponent } from "./conflict-resolution-list.component"; import { MatTableModule } from "@angular/material/table"; import { MatTooltipModule } from "@angular/material/tooltip"; import { CompareRevComponent } from "../compare-rev/compare-rev.component"; @@ -10,9 +10,9 @@ import { MatPaginatorModule } from "@angular/material/paginator"; import { NoopAnimationsModule } from "@angular/platform-browser/animations"; import { Database } from "../../core/database/database"; -describe("ConflictResolutionComponent", () => { - let component: ConflictResolutionComponent; - let fixture: ComponentFixture; +describe("ConflictResolutionListComponent", () => { + let component: ConflictResolutionListComponent; + let fixture: ComponentFixture; let mockDatabase: jasmine.SpyObj; @@ -35,12 +35,12 @@ describe("ConflictResolutionComponent", () => { NoopAnimationsModule, ], providers: [{ provide: Database, useValue: mockDatabase }], - declarations: [CompareRevComponent, ConflictResolutionComponent], + declarations: [CompareRevComponent, ConflictResolutionListComponent], }).compileComponents(); })); beforeEach(() => { - fixture = TestBed.createComponent(ConflictResolutionComponent); + fixture = TestBed.createComponent(ConflictResolutionListComponent); component = fixture.componentInstance; fixture.detectChanges(); }); diff --git a/src/app/conflict-resolution/conflict-resolution/conflict-resolution.component.ts b/src/app/conflict-resolution/conflict-resolution-list/conflict-resolution-list.component.ts similarity index 79% rename from src/app/conflict-resolution/conflict-resolution/conflict-resolution.component.ts rename to src/app/conflict-resolution/conflict-resolution-list/conflict-resolution-list.component.ts index ff56f04a53..cdc17397a5 100644 --- a/src/app/conflict-resolution/conflict-resolution/conflict-resolution.component.ts +++ b/src/app/conflict-resolution/conflict-resolution-list/conflict-resolution-list.component.ts @@ -3,20 +3,17 @@ import { MatPaginator } from "@angular/material/paginator"; import { QueryDataSource } from "../../core/database/query-data-source"; import { Entity } from "../../core/entity/entity"; import { Database } from "../../core/database/database"; -import PouchDB from "pouchdb-browser"; -import { AppConfig } from "../../core/app-config/app-config"; -import { AttendanceMonth } from "../../child-dev-project/attendance/model/attendance-month"; import { EntitySchemaService } from "../../core/entity/schema/entity-schema.service"; /** * List all document conflicts and allow the user to expand for details and manual resolution. */ @Component({ - selector: "app-conflict-resolution", - templateUrl: "./conflict-resolution.component.html", - styleUrls: ["./conflict-resolution.component.scss"], + selector: "app-conflict-resolution-list", + templateUrl: "./conflict-resolution-list.component.html", + styleUrls: ["./conflict-resolution-list.component.scss"], }) -export class ConflictResolutionComponent implements AfterViewInit { +export class ConflictResolutionListComponent implements AfterViewInit { /** visible table columns in the template */ columnsToDisplay = ["id", "data"]; @@ -55,5 +52,4 @@ export class ConflictResolutionComponent implements AfterViewInit { return this.db.saveDatabaseIndex(designDoc); } - } diff --git a/src/app/conflict-resolution/conflict-resolution-routing.module.ts b/src/app/conflict-resolution/conflict-resolution-routing.module.ts index 42d2f36caa..bbe8b3e450 100644 --- a/src/app/conflict-resolution/conflict-resolution-routing.module.ts +++ b/src/app/conflict-resolution/conflict-resolution-routing.module.ts @@ -1,4 +1,4 @@ -import { ConflictResolutionComponent } from "./conflict-resolution/conflict-resolution.component"; +import { ConflictResolutionListComponent } from "./conflict-resolution-list/conflict-resolution-list.component"; import { RouterModule, Routes } from "@angular/router"; import { NgModule } from "@angular/core"; @@ -9,7 +9,7 @@ import { NgModule } from "@angular/core"; const routes: Routes = [ { path: "", - component: ConflictResolutionComponent, + component: ConflictResolutionListComponent, }, ]; diff --git a/src/app/conflict-resolution/conflict-resolution.module.ts b/src/app/conflict-resolution/conflict-resolution.module.ts index 486cc5a6cb..0049004b34 100644 --- a/src/app/conflict-resolution/conflict-resolution.module.ts +++ b/src/app/conflict-resolution/conflict-resolution.module.ts @@ -1,6 +1,6 @@ import { NgModule } from "@angular/core"; import { CommonModule } from "@angular/common"; -import { ConflictResolutionComponent } from "./conflict-resolution/conflict-resolution.component"; +import { ConflictResolutionListComponent } from "./conflict-resolution-list/conflict-resolution-list.component"; import { MatTableModule } from "@angular/material/table"; import { MatSortModule } from "@angular/material/sort"; import { MatIconModule } from "@angular/material/icon"; @@ -14,6 +14,7 @@ import { FormsModule } from "@angular/forms"; import { MatTooltipModule } from "@angular/material/tooltip"; import { ConflictResolutionRoutingModule } from "./conflict-resolution-routing.module"; import { ConflictResolutionStrategy } from "./auto-resolution/conflict-resolution-strategy"; +import { MatProgressBarModule } from "@angular/material/progress-bar"; /** * Display and resolve document conflicts in the database through a simple user interface for administrators. @@ -50,7 +51,8 @@ routes: Routes = [ MatInputModule, FormsModule, MatTooltipModule, + MatProgressBarModule, ], - declarations: [ConflictResolutionComponent, CompareRevComponent], + declarations: [ConflictResolutionListComponent, CompareRevComponent], }) export class ConflictResolutionModule {} diff --git a/src/app/core/database/query-data-source.ts b/src/app/core/database/query-data-source.ts index 437f9d793d..9837e4259b 100644 --- a/src/app/core/database/query-data-source.ts +++ b/src/app/core/database/query-data-source.ts @@ -4,10 +4,23 @@ import { MatPaginator } from "@angular/material/paginator"; import { Entity } from "../entity/entity"; import { Database } from "./database"; +/** + * Implementation of a datasource that directly queries an index on the {@link Database} + * supporting optional pagination to only load a subset of the data as required by a paginator. + * + * An instance of QueryDataSource can be created and used as source for a mat-table component. + * + * also see https://material.angular.io/cdk/table/overview#connecting-the-table-to-a-data-source + * and https://medium.com/angular-in-depth/angular-material-pagination-datasource-73080d3457fe + */ export class QueryDataSource implements DataSource { + /** internal observable to emit new result data. This is provided to users calling .connect() */ private dataSubject = new BehaviorSubject([]); + + /** internal observable to emit new loading status. This is provided to users through the public .loading$ */ private loadingSubject = new BehaviorSubject(false); + /** Indicates whether the datasource is currently loading new data */ public loading$ = this.loadingSubject.asObservable(); private _paginator: MatPaginator | null; @@ -25,16 +38,28 @@ export class QueryDataSource implements DataSource { constructor(private database: Database, private queryName: string) {} + /** + * Connect to the datasource and receive an observable to subscribe to loaded data. + * Whenever pagination is changed this will emit new datasets. + * @param collectionViewer (not necessary) + */ connect(collectionViewer: CollectionViewer): Observable { this.loadData(); return this.dataSubject.asObservable(); } + /** + * Disconnect and discard open observables for this datasource. + * @param collectionViewer (not necessary) + */ disconnect(collectionViewer: CollectionViewer): void { this.dataSubject.complete(); this.loadingSubject.complete(); } + /** + * (re)load data from the database for the given query and (if set) to current pagination values. + */ async loadData() { this.loadingSubject.next(true);