From dca19a44eecf227085fa5b773f6558cf46748d59 Mon Sep 17 00:00:00 2001 From: Anro Date: Wed, 15 Jan 2025 17:47:02 +0200 Subject: [PATCH 1/3] fix: prototype duplicate prevention --- webapp/package-lock.json | 26 +++ webapp/src/css/enketo/medic.less | 52 +++++ .../duplicate-info.component.html | 53 +++++ .../duplicate-info.component.ts | 40 ++++ .../components/enketo/enketo.component.html | 1 + .../contacts/contacts-edit.component.html | 6 +- .../contacts/contacts-edit.component.ts | 28 ++- webapp/src/ts/services/form.service.ts | 61 ++++- webapp/src/ts/services/utils/deduplicate.ts | 94 ++++++++ .../xml-forms-context-utils.service.ts | 9 + .../contacts/contacts-edit.component.spec.ts | 12 +- .../karma/ts/services/form.service.spec.ts | 219 +++++++++++++++++- .../ts/services/utils/deduplicate.spec.ts | 156 +++++++++++++ 13 files changed, 742 insertions(+), 15 deletions(-) create mode 100644 webapp/src/ts/components/duplicate-info/duplicate-info.component.html create mode 100644 webapp/src/ts/components/duplicate-info/duplicate-info.component.ts create mode 100644 webapp/src/ts/services/utils/deduplicate.ts create mode 100644 webapp/tests/karma/ts/services/utils/deduplicate.spec.ts diff --git a/webapp/package-lock.json b/webapp/package-lock.json index 77a7db3cd0..e0cdaff842 100644 --- a/webapp/package-lock.json +++ b/webapp/package-lock.json @@ -34,6 +34,7 @@ "eurodigit": "^3.1.3", "font-awesome": "^4.7.0", "jquery": "3.5.1", + "levenshtein": "1.0.5", "lodash-es": "^4.17.21", "moment-locales-webpack-plugin": "^1.2.0", "ngrx-store-logger": "^0.2.4", @@ -5343,6 +5344,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/levenshtein": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@types/levenshtein/-/levenshtein-1.0.4.tgz", + "integrity": "sha512-QiNzDEGuAHoNVa7xjTPGQRecXScckE8bAEpuHipG8lEFPZh4eIBK0dw0K5mu9XdiTiVD8AxwYY8lOxYaP1rZUA==", + "license": "MIT" + }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -9216,6 +9223,15 @@ "node": ">=0.10.0" } }, + "node_modules/levenshtein": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/levenshtein/-/levenshtein-1.0.5.tgz", + "integrity": "sha512-UQf1nnmxjl7O0+snDXj2YF2r74Gkya8ZpnegrUBYN9tikh2dtxV/ey8e07BO5wwo0i76yjOvbDhFHdcPEiH9aA==", + "engines": [ + "node >=0.2.0" + ], + "license": "Public Domain" + }, "node_modules/license-webpack-plugin": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/license-webpack-plugin/-/license-webpack-plugin-4.0.2.tgz", @@ -16882,6 +16898,11 @@ "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "dev": true }, + "@types/levenshtein": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@types/levenshtein/-/levenshtein-1.0.4.tgz", + "integrity": "sha512-QiNzDEGuAHoNVa7xjTPGQRecXScckE8bAEpuHipG8lEFPZh4eIBK0dw0K5mu9XdiTiVD8AxwYY8lOxYaP1rZUA==" + }, "@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -19508,6 +19529,11 @@ "integrity": "sha512-MYUxjSQSBUQmowc0l5nPieOYwMzGPUaTzB6inNW/bdPEG9zOL3eAAD1Qw5ZxSPk7we5dMojHwNODYMV1hq4EVg==", "dev": true }, + "levenshtein": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/levenshtein/-/levenshtein-1.0.5.tgz", + "integrity": "sha512-UQf1nnmxjl7O0+snDXj2YF2r74Gkya8ZpnegrUBYN9tikh2dtxV/ey8e07BO5wwo0i76yjOvbDhFHdcPEiH9aA==" + }, "license-webpack-plugin": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/license-webpack-plugin/-/license-webpack-plugin-4.0.2.tgz", diff --git a/webapp/src/css/enketo/medic.less b/webapp/src/css/enketo/medic.less index f4801f4a7d..0785b78238 100644 --- a/webapp/src/css/enketo/medic.less +++ b/webapp/src/css/enketo/medic.less @@ -443,6 +443,58 @@ .pages.or .or-repeat-info[role="page"] { display: block; } + + #duplicate_info { + width: 100%; + min-height: 20px; + padding-left: 20px; + padding-right: 20px; + background-color: #ffe7e8; + + .results_header { + font-size: large; + color: #e33030; + } + + .acknowledge_label { + -webkit-user-select: none; -ms-user-select: none; user-select: none; + } + + .acknowledge_checkbox { + margin-right: 5px; + } + + .divider { + background-color: #e33030; + height: 1px; + margin-top: 5px; + margin-bottom: 5px; + } + + .card { + border: 1px solid #ddd; + padding: 1rem; + margin-bottom: 1rem; + border-radius: 5px; + } + + .nested-section { + margin-left: 1.5rem; + } + + .toggle-button { + background: none; + border: none; + color: #007bff; + cursor: pointer; + font-weight: bold; + padding-left: 0px; + } + + .toggle-button:hover { + text-decoration: underline; + } + } } @media (max-width: @media-mobile) { diff --git a/webapp/src/ts/components/duplicate-info/duplicate-info.component.html b/webapp/src/ts/components/duplicate-info/duplicate-info.component.html new file mode 100644 index 0000000000..5e061f5f16 --- /dev/null +++ b/webapp/src/ts/components/duplicate-info/duplicate-info.component.html @@ -0,0 +1,53 @@ +
+

{{ duplicates.length }} {{'potential duplicate item(s) found:' | translate }}

+
+
+ +
+
+
+
+ {{ 'Item number:' | translate }} {{ i + 1 }} +
+

+ {{ 'Name:' | translate }} {{ duplicate.name }} +
+ {{ 'Created on:' | translate }} {{ duplicate.reported_date | date: 'EEE MMM dd yyyy HH:mm:ss' }} +

+ +
+ +
+
+ +
+
+ +
+
+ +
+ +
+
+ + {{ key.key }}: {{ key.value }} + +
+
+
diff --git a/webapp/src/ts/components/duplicate-info/duplicate-info.component.ts b/webapp/src/ts/components/duplicate-info/duplicate-info.component.ts new file mode 100644 index 0000000000..844088f922 --- /dev/null +++ b/webapp/src/ts/components/duplicate-info/duplicate-info.component.ts @@ -0,0 +1,40 @@ +import { Component, EventEmitter, Input, Output } from '@angular/core'; + +@Component({ + selector: 'mm-duplicate-info', + templateUrl: './duplicate-info.component.html', +}) +export class DuplicateInfoComponent { + @Input() acknowledged: boolean = false; + @Output() acknowledgedChange = new EventEmitter(); + @Output() navigateToDuplicate = new EventEmitter(); + @Input() duplicates: { _id: string; name: string; reported_date: string | Date; [key: string]: string | Date }[] = []; + + toggleAcknowledged() { + this.acknowledged = !this.acknowledged; + this.acknowledgedChange.emit(this.acknowledged); + } + + _navigateToDuplicate(_id: string){ + this.navigateToDuplicate.emit(_id); + } + + // Handles collapse / expand of duplicate doc details + expandedSections = new Map(); + + toggleSection(path: string): void { + this.expandedSections.set(path, !this.expandedSections.get(path)); + } + + isExpanded(path: string): boolean { + return this.expandedSections.get(path) || false; + } + + isObject(value: any): boolean { + return value && typeof value === 'object' && !Array.isArray(value); + } + + getPath(parentPath: string, key: string): string { + return parentPath ? `${parentPath}.${key}` : key; + } +} diff --git a/webapp/src/ts/components/enketo/enketo.component.html b/webapp/src/ts/components/enketo/enketo.component.html index 1a4f81d24b..773c8d0b6b 100644 --- a/webapp/src/ts/components/enketo/enketo.component.html +++ b/webapp/src/ts/components/enketo/enketo.component.html @@ -1,5 +1,6 @@
+
- + +
+ +
+
diff --git a/webapp/src/ts/modules/contacts/contacts-edit.component.ts b/webapp/src/ts/modules/contacts/contacts-edit.component.ts index 76b9573722..0b33d12e2f 100644 --- a/webapp/src/ts/modules/contacts/contacts-edit.component.ts +++ b/webapp/src/ts/modules/contacts/contacts-edit.component.ts @@ -5,7 +5,7 @@ import { isEqual as _isEqual } from 'lodash-es'; import { ActivatedRoute, Router } from '@angular/router'; import { LineageModelGeneratorService } from '@mm-services/lineage-model-generator.service'; -import { FormService } from '@mm-services/form.service'; +import { FormService, DuplicatesFoundError, Duplicate } from '@mm-services/form.service'; import { EnketoFormContext } from '@mm-services/enketo.service'; import { ContactTypesService } from '@mm-services/contact-types.service'; import { DbService } from '@mm-services/db.service'; @@ -59,6 +59,18 @@ export class ContactsEditComponent implements OnInit, OnDestroy, AfterViewInit { private trackSave; private trackMetadata = { action: '', form: '' }; + private duplicateCheck; + acknowledged = false; + onAcknowledgeChange(value: boolean) { + this.acknowledged = value; + } + + onNavigateToDuplicate(_id: string){ + this.router.navigate(['/contacts', _id, 'edit']); + } + + duplicates: Duplicate[] = []; + ngOnInit() { this.trackRender = this.performanceService.track(); this.subscribeToStore(); @@ -157,6 +169,10 @@ export class ContactsEditComponent implements OnInit, OnDestroy, AfterViewInit { this.contentError = false; this.errorTranslationKey = false; + // Reset when when navigated to duplicate + this.duplicates = []; + this.acknowledged = false; + try { const contact = await this.getContact(); const contactTypeId = this.contactTypesService.getTypeId(contact) || this.routeSnapshot.params?.type; @@ -276,6 +292,7 @@ export class ContactsEditComponent implements OnInit, OnDestroy, AfterViewInit { private async renderForm(formId: string, titleKey: string) { const formDoc = await this.dbService.get().get(formId); this.xmlVersion = formDoc.xmlVersion; + this.duplicateCheck = formDoc.context?.duplicate_check; this.globalActions.setEnketoEditedStatus(false); @@ -330,7 +347,9 @@ export class ContactsEditComponent implements OnInit, OnDestroy, AfterViewInit { $('form.or').trigger('beforesave'); return this.formService - .saveContact(form, docId, this.enketoContact.type, this.xmlVersion) + .saveContact({ + form, docId, type: this.enketoContact.type, xmlVersion: this.xmlVersion + }, this.duplicateCheck, this.acknowledged) .then((result) => { console.debug('saved contact', result); @@ -349,6 +368,11 @@ export class ContactsEditComponent implements OnInit, OnDestroy, AfterViewInit { this.router.navigate(['/contacts', result.docId]); }) .catch((err) => { + if (err instanceof DuplicatesFoundError){ + this.duplicates = err.duplicates; + err = Error(err.message); + } + console.error('Error submitting form data', err); this.globalActions.setEnketoSavingStatus(false); diff --git a/webapp/src/ts/services/form.service.ts b/webapp/src/ts/services/form.service.ts index 2329c82040..65225f3f3d 100644 --- a/webapp/src/ts/services/form.service.ts +++ b/webapp/src/ts/services/form.service.ts @@ -26,6 +26,9 @@ import { reduce as _reduce } from 'lodash-es'; import { ContactTypesService } from '@mm-services/contact-types.service'; import { TargetAggregatesService } from '@mm-services/target-aggregates.service'; import { ContactViewModelGeneratorService } from '@mm-services/contact-view-model-generator.service'; +import { ParseProvider } from '@mm-providers/parse.provider'; +import { XmlFormsContextUtilsService } from '@mm-services/xml-forms-context-utils.service'; +import { extractExpression, requestSiblings, getDuplicates, Doc, DuplicateCheck } from './utils/deduplicate'; /** * Service for interacting with forms. This is the primary entry-point for CHT code to render forms and save the @@ -58,6 +61,8 @@ export class FormService { private enketoService: EnketoService, private targetAggregatesService: TargetAggregatesService, private contactViewModelGeneratorService: ContactViewModelGeneratorService, + private readonly parseProvider: ParseProvider, + private readonly xmlFormsDuplicateUtilsService: XmlFormsContextUtilsService, ) { this.inited = this.init(); this.globalActions = new GlobalActions(store); @@ -326,7 +331,39 @@ export class FormService { }, null); } - async saveContact(form, docId, type, xmlVersion) { + async checkForDuplicates(doc, duplicateCheck, acknowledged) { + const parentId = doc ? doc.parent?._id : undefined; + const contactType = doc ? doc.contact_type ?? doc.type : undefined; + const siblings = await requestSiblings(this.dbService, parentId, contactType); + const expression = extractExpression(duplicateCheck); + const isCanonical = doc.is_canonical ? doc.is_canonical === 'true' : false; + acknowledged = acknowledged ?? false; + + if (!isCanonical && expression && !acknowledged){ + const duplicates = getDuplicates( + doc, + siblings, + { + expression, + parseProvider: this.parseProvider, + xmlFormsContextUtilsService: this.xmlFormsDuplicateUtilsService + } + ); + return duplicates; + } + } + + async saveContact( + contactInfo: { + form: any; + docId: string| undefined; + type: string | undefined; + xmlVersion: string | undefined; + }, + duplicateCheck: DuplicateCheck, + acknowledged: boolean + ) { + const { form, docId, type, xmlVersion } = contactInfo; const typeFields = this.contactTypesService.isHardcodedType(type) ? { type } : { type: 'contact', contact_type: type }; @@ -335,6 +372,16 @@ export class FormService { const preparedDocs = await this.applyTransitions(docs); const primaryDoc = preparedDocs.preparedDocs.find(doc => doc.type === type); + + const duplicates = await this.checkForDuplicates( + primaryDoc || preparedDocs.preparedDocs[0], + duplicateCheck, + acknowledged + ); + if (duplicates && duplicates.length > 0){ + throw new DuplicatesFoundError('Duplicates found', duplicates); + } + this.servicesActions.setLastChangedDoc(primaryDoc || preparedDocs.preparedDocs[0]); const bulkDocsResult = await this.dbService.get().bulkDocs(preparedDocs.preparedDocs); const failureMessage = this.generateFailureMessage(bulkDocsResult); @@ -350,4 +397,14 @@ export class FormService { this.enketoService.unload(form); } } - +export class DuplicatesFoundError extends Error { + duplicates: Duplicate[]; + + constructor(message: string, duplicates: Duplicate[]) { + super(message); + this.message = message; + this.duplicates = duplicates; + this.name = 'DuplicatesFoundError'; + } +} +export type Duplicate = Doc; diff --git a/webapp/src/ts/services/utils/deduplicate.ts b/webapp/src/ts/services/utils/deduplicate.ts new file mode 100644 index 0000000000..2aa7a3f623 --- /dev/null +++ b/webapp/src/ts/services/utils/deduplicate.ts @@ -0,0 +1,94 @@ +import * as Levenshtein from 'levenshtein'; +import { DbService } from '@mm-services/db.service'; +import { ParseProvider } from '@mm-providers/parse.provider'; +import { XmlFormsContextUtilsService } from '@mm-services/xml-forms-context-utils.service'; + + +export type Doc = { _id: string; name: string; reported_date: number;[key: string]: any }; + +const DEFAULT_CONTACT_DUPLICATE_EXPRESSION = 'levenshteinEq(3, current.name, existing.name)'; + +// Normalize the distance by dividing by the length of the longer string. +// This can make the metric more adaptable across different string lengths +const normalizedLevenshteinEq = function (str1: string, str2: string) { + const distance = levenshteinEq(str1, str2); + const maxLen = Math.max(str1.length, str2.length); + return (maxLen === 0) ? 0 : (distance / maxLen); +}; + +// The Levenshtein distance is a measure of the number of edits (insertions, deletions, and substitutions) +// required to change one string into another. +const levenshteinEq = function (str1: string, str2: string): number { + return new Levenshtein(str1, str2).distance; +}; + + +const requestSiblings = async function (dbService: DbService, parentId: string, contactType: string) { + const siblings: Doc[] = []; + const results = parentId && contactType && await dbService.get().query('medic-client/contacts_by_parent', { + startkey: [parentId, contactType], + endkey: [parentId, contactType, {}], + include_docs: true + }); + + if (results) { + // Desc order - reverse order by switching props + siblings.push(...results.rows.map((row: { doc: Doc }) => row.doc) + .sort((a: Doc, b: Doc) => (b.reported_date || 0) - (a.reported_date || 0))); + } + return siblings; +}; + +export type DuplicateCheck = { expression?: string; disabled?: boolean } | undefined; +const extractExpression = function (duplicateCheck: DuplicateCheck) { + // eslint-disable-next-line eqeqeq + if (duplicateCheck != null) { + if (Object.prototype.hasOwnProperty.call(duplicateCheck, 'expression')) { + return duplicateCheck.expression as string; + } else if (Object.prototype.hasOwnProperty.call(duplicateCheck, 'disabled') && duplicateCheck.disabled) { + return null; // No duplicate check should be performed + } + } + + return DEFAULT_CONTACT_DUPLICATE_EXPRESSION; +}; + +const getDuplicates = function ( + doc: Doc, + siblings: Array, + config: { + expression: string; + parseProvider: ParseProvider; + xmlFormsContextUtilsService: XmlFormsContextUtilsService; + } +) { + const { expression, parseProvider, xmlFormsContextUtilsService } = config; + // eslint-disable-next-line eqeqeq + const _siblings: Doc[] = siblings.filter((s) => !((doc._id != null && s._id === doc._id))); + // Remove the currently edited doc from the sibling list + + const duplicates: Array = []; + for (const sibling of _siblings) { + const parsed = parseProvider.parse(expression); + const test = parsed(xmlFormsContextUtilsService, { + current: doc, + existing: sibling, + }); + if (test) { + duplicates.push(sibling); + } + } + + return duplicates; +}; + +export { + normalizedLevenshteinEq, + levenshteinEq, + + requestSiblings, + extractExpression, + getDuplicates, + + DEFAULT_CONTACT_DUPLICATE_EXPRESSION +}; diff --git a/webapp/src/ts/services/xml-forms-context-utils.service.ts b/webapp/src/ts/services/xml-forms-context-utils.service.ts index a3d86cfc2a..74e8f88794 100644 --- a/webapp/src/ts/services/xml-forms-context-utils.service.ts +++ b/webapp/src/ts/services/xml-forms-context-utils.service.ts @@ -1,5 +1,6 @@ import * as moment from 'moment'; import { Injectable } from '@angular/core'; +import { normalizedLevenshteinEq, levenshteinEq } from './utils/deduplicate'; /** * Util functions available to a form doc's `.context` function for checking if @@ -31,4 +32,12 @@ export class XmlFormsContextUtilsService { return this.getDateDiff(contact, 'years'); } + levenshteinEq(threshold: number, current: string, existing: string){ + return current && existing ? levenshteinEq(current, existing) < threshold : false; + } + + normalizedLevenshteinEq(threshold: number, current: string, existing: string){ + return current && existing ? normalizedLevenshteinEq(current, existing) < threshold : false; + } + } diff --git a/webapp/tests/karma/ts/modules/contacts/contacts-edit.component.spec.ts b/webapp/tests/karma/ts/modules/contacts/contacts-edit.component.spec.ts index 165818098a..cb38dbafb2 100644 --- a/webapp/tests/karma/ts/modules/contacts/contacts-edit.component.spec.ts +++ b/webapp/tests/karma/ts/modules/contacts/contacts-edit.component.spec.ts @@ -705,7 +705,9 @@ describe('ContactsEdit component', () => { expect(setEnketoSavingStatus.args).to.deep.equal([[true], [false]]); expect(setEnketoError.callCount).to.equal(1); expect(formService.saveContact.callCount).to.equal(1); - expect(formService.saveContact.args[0]).to.deep.equal([ form, null, 'clinic', undefined ]); + expect(formService.saveContact.args[0]).to.deep.equal([ + { form, docId: null, type: 'clinic', xmlVersion: undefined }, undefined, false + ]); expect(router.navigate.callCount).to.equal(1); expect(router.navigate.args[0]).to.deep.equal([['/contacts', 'new_clinic_id']]); }); @@ -740,7 +742,9 @@ describe('ContactsEdit component', () => { expect(setEnketoSavingStatus.args).to.deep.equal([[true], [false]]); expect(setEnketoError.callCount).to.equal(1); expect(formService.saveContact.callCount).to.equal(1); - expect(formService.saveContact.args[0]).to.deep.equal([ form, 'the_person', 'person', undefined ]); + expect(formService.saveContact.args[0]).to.deep.equal( + [ {form, docId: 'the_person', type: 'person', xmlVersion: undefined}, undefined, false ] + ); expect(router.navigate.callCount).to.equal(1); expect(router.navigate.args[0]).to.deep.equal([['/contacts', 'the_person']]); expect(performanceService.track.calledThrice).to.be.true; @@ -788,7 +792,9 @@ describe('ContactsEdit component', () => { expect(setEnketoSavingStatus.args).to.deep.equal([[true], [false]]); expect(setEnketoError.callCount).to.equal(1); expect(formService.saveContact.callCount).to.equal(1); - expect(formService.saveContact.args[0]).to.deep.equal([ form, 'the_patient', 'patient', undefined ]); + expect(formService.saveContact.args[0]).to.deep.equal( + [ { form, docId: 'the_patient', type: 'patient', xmlVersion: undefined }, undefined, false ] + ); expect(router.navigate.callCount).to.equal(1); expect(router.navigate.args[0]).to.deep.equal([['/contacts', 'the_patient']]); expect(performanceService.track.calledThrice).to.be.true; diff --git a/webapp/tests/karma/ts/services/form.service.spec.ts b/webapp/tests/karma/ts/services/form.service.spec.ts index 5b8599081a..eec8501a38 100644 --- a/webapp/tests/karma/ts/services/form.service.spec.ts +++ b/webapp/tests/karma/ts/services/form.service.spec.ts @@ -37,6 +37,9 @@ import * as FileManager from '../../../../src/js/enketo/file-manager.js'; import { TargetAggregatesService } from '@mm-services/target-aggregates.service'; import { ContactViewModelGeneratorService } from '@mm-services/contact-view-model-generator.service'; +import { ParseProvider } from '@mm-providers/parse.provider'; +import { XmlFormsContextUtilsService } from '@mm-services/xml-forms-context-utils.service'; + describe('Form service', () => { // return a mock form ready for putting in #dbContent const mockEnketoDoc = formInternalId => { @@ -63,6 +66,7 @@ describe('Form service', () => { let dbGetAttachment; let dbGet; let dbBulkDocs; + let dbQuery; let ContactSummary; let Form2Sms; let UserContact; @@ -94,12 +98,15 @@ describe('Form service', () => { let extractLineageService; let targetAggregatesService; let contactViewModelGeneratorService; + let parserProvider; + let xmlFormsContextUtilsService; beforeEach(() => { enketoInit = sinon.stub(); dbGetAttachment = sinon.stub(); dbGet = sinon.stub(); dbBulkDocs = sinon.stub(); + dbQuery = sinon.stub(); ContactSummary = sinon.stub(); Form2Sms = sinon.stub(); UserContact = sinon.stub(); @@ -163,13 +170,16 @@ describe('Form service', () => { targetAggregatesService = { getTargetDocs: sinon.stub() }; contactViewModelGeneratorService = { loadReports: sinon.stub() }; + parserProvider = sinon.stub(); + xmlFormsContextUtilsService = sinon.stub(); + TestBed.configureTestingModule({ providers: [ provideMockStore(), { provide: DbService, useValue: { - get: () => ({ getAttachment: dbGetAttachment, get: dbGet, bulkDocs: dbBulkDocs }) + get: () => ({ getAttachment: dbGetAttachment, get: dbGet, bulkDocs: dbBulkDocs, query: dbQuery }) } }, { provide: ContactSummaryService, useValue: { get: ContactSummary } }, @@ -193,6 +203,8 @@ describe('Form service', () => { { provide: ExtractLineageService, useValue: extractLineageService }, { provide: TargetAggregatesService, useValue: targetAggregatesService }, { provide: ContactViewModelGeneratorService, useValue: contactViewModelGeneratorService }, + { provide: ParseProvider, useValue: parserProvider}, + { provide: XmlFormsContextUtilsService, useValue: xmlFormsContextUtilsService } ], }); @@ -1299,19 +1311,24 @@ describe('Form service', () => { let extractLineageService; let enketoTranslationService; + let parse; + beforeEach(() => { extractLineageService = { extract: sinon.stub() }; enketoTranslationService = { contactRecordToJs: sinon.stub(), }; + parse = sinon.stub(); + parserProvider = { parse }; + TestBed.configureTestingModule({ providers: [ provideMockStore(), { provide: DbService, useValue: { - get: () => ({ getAttachment: dbGetAttachment, get: dbGet, bulkDocs: dbBulkDocs }) + get: () => ({ getAttachment: dbGetAttachment, get: dbGet, bulkDocs: dbBulkDocs, query: dbQuery }) } }, { provide: ContactSummaryService, useValue: { get: ContactSummary } }, @@ -1334,6 +1351,8 @@ describe('Form service', () => { { provide: TranslateService, useValue: translateService }, { provide: TrainingCardsService, useValue: trainingCardsService }, { provide: FeedbackService, useValue: feedbackService }, + { provide: ParseProvider, useValue: parserProvider}, + { provide: XmlFormsContextUtilsService, useValue: xmlFormsContextUtilsService }, ], }); @@ -1357,7 +1376,7 @@ describe('Form service', () => { extractLineageService.extract.returns({ _id: 'abc', parent: { _id: 'def' } }); return service - .saveContact(form, docId, type) + .saveContact({form, docId, type}) .then(() => { assert.equal(dbGet.callCount, 1); assert.equal(dbGet.args[0][0], 'abc'); @@ -1391,7 +1410,7 @@ describe('Form service', () => { extractLineageService.extract.returns({ _id: 'abc', parent: { _id: 'def' } }); return service - .saveContact(form, docId, type) + .saveContact({form, docId, type}) .then(() => { assert.equal(dbGet.callCount, 1); assert.equal(dbGet.args[0][0], 'abc'); @@ -1435,7 +1454,7 @@ describe('Form service', () => { dbBulkDocs.resolves([]); return service - .saveContact(form, docId, type) + .saveContact({form, docId, type}) .then(() => { assert.isTrue(dbBulkDocs.calledOnce); @@ -1494,7 +1513,7 @@ describe('Form service', () => { clock = sinon.useFakeTimers(5000); return service - .saveContact(form, docId, type) + .saveContact({form, docId, type}) .then(() => { assert.equal(dbGet.callCount, 2); assert.deepEqual(dbGet.args[0], ['main1']); @@ -1543,7 +1562,7 @@ describe('Form service', () => { clock = sinon.useFakeTimers(1000); return service - .saveContact(form, docId, type) + .saveContact({form, docId, type}) .then(() => { assert.equal(dbGet.callCount, 1); assert.equal(dbGet.args[0][0], 'abc'); @@ -1580,5 +1599,191 @@ describe('Form service', () => { assert.deepEqual(setLastChangedDoc.args[0], [savedDocs[0]]); }); }); + + it('should throw an error with duplicates found', async function () { + const form = { getDataStr: () => '' }; + const docId = null; + const type = 'some-contact-type'; + + dbGet.resolves({ }); + enketoTranslationService.contactRecordToJs.returns({ + doc: { _id: 'main1', name: 'Main', type: 'main', parent: { _id: 'parent1' } } + }); + extractLineageService.extract.returns({ _id: 'parent1'}); + transitionsService.applyTransitions.callsFake((docs) => { + docs[0].transitioned = true; + return Promise.resolve(docs); + }); + dbQuery.resolves({ + offset: 0, + rows: [ + { id: 'sib1', + doc: { + _id: 'sib1', + name: 'Sibling1', + parent: { _id: 'parent1' }, + type, + reported_date: 1736845534000 + } + }, + { + id: 'sib2', + doc: { + _id: 'sib2', + name: 'Sibling2', + parent: { _id: 'parent1' }, + type, + reported_date: 1736845534000 + } + }, + ], + total_rows: 2 + }); + dbBulkDocs.resolves([]); + clock = sinon.useFakeTimers(1000); + // eslint-disable-next-line @typescript-eslint/no-unused-vars, no-undef + parse.callsFake(() => (XmlFormsContextUtilsService, ctx) => true); + try { + await service.saveContact({form, docId, type, xmlVersion: undefined}, undefined, false); + // Fail the test if no error is thrown + throw new Error('Expected saveContact to throw an error, but it did not.'); + } catch (e) { + expect(e.message).to.include('Duplicates found'); + expect(e.duplicates).to.have.lengthOf(2); + expect(e.duplicates).to.deep.equal([ + { + _id: 'sib1', + name: 'Sibling1', + parent: { _id: 'parent1' }, + type, + reported_date: 1736845534000 + }, + { + _id: 'sib2', + name: 'Sibling2', + parent: { _id: 'parent1' }, + type, + reported_date: 1736845534000 + } + ]); + } + }); + + it('should pass duplicate check when duplicates are acknowledged', async function () { + const form = { getDataStr: () => '' }; + const docId = null; + const type = 'some-contact-type'; + + dbGet.resolves({ }); + enketoTranslationService.contactRecordToJs.returns({ + doc: { _id: 'main1', name: 'Main', type: 'main', parent: { _id: 'parent1' } } + }); + extractLineageService.extract.returns({ _id: 'parent1'}); + transitionsService.applyTransitions.callsFake((docs) => { + docs[0].transitioned = true; + return Promise.resolve(docs); + }); + dbQuery.resolves({ + offset: 0, + rows: [ + { id: 'sib1', + doc: { + _id: 'sib1', + name: 'Sibling1', + parent: { _id: 'parent1' }, + type, + reported_date: 1736845534000 + } + }, + { + id: 'sib2', + doc: { + _id: 'sib2', + name: 'Sibling2', + parent: { _id: 'parent1' }, + type, + reported_date: 1736845534000 + } + }, + ], + total_rows: 2 + }); + dbBulkDocs.resolves([]); + clock = sinon.useFakeTimers(1000); + + await service.saveContact({form, docId, type, xmlVersion: undefined}, undefined, true); + assert.equal(transitionsService.applyTransitions.callCount, 1); + assert.deepEqual(transitionsService.applyTransitions.args[0], [[ + { + _id: 'main1', + name: 'Main', + type: 'contact', + contact_type: type, + parent: { _id: 'parent1' }, + reported_date: 1000, + contact: undefined, + transitioned: true + } + ]]); + }); + + it('should pass duplicate check when record is marked as canonical', async function () { + const form = { getDataStr: () => '' }; + const docId = null; + const type = 'some-contact-type'; + + dbGet.resolves({ }); + enketoTranslationService.contactRecordToJs.returns({ + doc: { _id: 'main1', name: 'Main', type: 'main', parent: { _id: 'parent1' }, is_canonical: 'true' } + }); + extractLineageService.extract.returns({ _id: 'parent1'}); + transitionsService.applyTransitions.callsFake((docs) => { + docs[0].transitioned = true; + return Promise.resolve(docs); + }); + dbQuery.resolves({ + offset: 0, + rows: [ + { id: 'sib1', + doc: { + _id: 'sib1', + name: 'Sibling1', + parent: { _id: 'parent1' }, + type, + reported_date: 1736845534000 + } + }, + { + id: 'sib2', + doc: { + _id: 'sib2', + name: 'Sibling2', + parent: { _id: 'parent1' }, + type, + reported_date: 1736845534000 + } + }, + ], + total_rows: 2 + }); + dbBulkDocs.resolves([]); + clock = sinon.useFakeTimers(1000); + + await service.saveContact({form, docId, type, xmlVersion: undefined}, undefined, false); + assert.equal(transitionsService.applyTransitions.callCount, 1); + assert.deepEqual(transitionsService.applyTransitions.args[0], [[ + { + _id: 'main1', + name: 'Main', + type: 'contact', + contact_type: type, + parent: { _id: 'parent1' }, + reported_date: 1000, + contact: undefined, + transitioned: true, + is_canonical: 'true' + } + ]]); + }); }); }); diff --git a/webapp/tests/karma/ts/services/utils/deduplicate.spec.ts b/webapp/tests/karma/ts/services/utils/deduplicate.spec.ts new file mode 100644 index 0000000000..be5a6e89d3 --- /dev/null +++ b/webapp/tests/karma/ts/services/utils/deduplicate.spec.ts @@ -0,0 +1,156 @@ +import { TestBed } from '@angular/core/testing'; +import sinon from 'sinon'; +import { expect } from 'chai'; + +import { DbService } from '@mm-services/db.service'; +import { ParseProvider } from '@mm-providers/parse.provider'; +import { XmlFormsContextUtilsService } from '@mm-services/xml-forms-context-utils.service'; +import { + normalizedLevenshteinEq, + levenshteinEq, + requestSiblings, + extractExpression, + DEFAULT_CONTACT_DUPLICATE_EXPRESSION, + getDuplicates, +} from '../../../../../src/ts/services/utils/deduplicate'; + +describe('Deduplicate', () => { + let dbService; + let query; + + beforeEach(() => { + query = sinon.stub(); + dbService = { + get: () => ({ query }) + }; + + TestBed.configureTestingModule({ + providers: [ + { provide: DbService, useValue: dbService } + ] + }); + }); + + afterEach(() => { + sinon.restore(); + }); + + describe('normalizedLevenshteinEq', () => { + it('should return return a score of 3', () => { + // Score/distance / maxLength + // 3 (3 characters need to be added to make str1 = str2) / 5 (Test123 is the larger string) + // ~ 0.42857142857142855 + expect(normalizedLevenshteinEq('Test123', 'Test')).lessThanOrEqual(0.42857142857142855); + }); + }); + + describe('levenshteinEq', () => { + it('should return return a score of 3', () => { + expect(levenshteinEq('Test123', 'Test')).to.equal(3); + }); + }); + + describe('requestSiblings', () => { + it('should return results filtered by parent and contact type', async function () { + query.resolves({ + offset: 0, + rows: [ + { id: 'sib1', doc: { _id: 'sib1', name: 'Sibling1', parent: { _id: 'parent1' }, contact_type: 'some_type' } }, + { id: 'sib2', doc: { _id: 'sib2', name: 'Sibling2', parent: { _id: 'parent1' }, contact_type: 'some_type' }}, + ], + total_rows: 6 + }); + const siblings = await requestSiblings(dbService, 'parent1', 'some_type'); + expect(siblings.length).to.equal(2); + expect(siblings).to.deep.equal([ + { _id: 'sib1', name: 'Sibling1', parent: { _id: 'parent1' }, contact_type: 'some_type' }, + { _id: 'sib2', name: 'Sibling2', parent: { _id: 'parent1' }, contact_type: 'some_type' }, + ]); + }); + }); + + describe('extractExpression', () => { + it('should return a default expression when none is provided', () => { + expect(extractExpression(undefined)).to.equal(DEFAULT_CONTACT_DUPLICATE_EXPRESSION); + }); + }); + + describe('getDuplicates', () => { + let pipesService; + let parseProvider; + beforeEach(() => { + pipesService = { + getPipeNameVsIsPureMap: sinon.stub().returns(new Map([['date', { pure: true }]])), + meta: sinon.stub(), + getInstance: sinon.stub(), + }; + parseProvider = new ParseProvider(pipesService); + }); + + it('should return duplicates based on default matching', () => { + const doc = { + _id: 'new', + name: 'Test', + parent: { _id: 'parent1' }, + contact_type: 'some_type', + reported_date: 1736845534000 + }; + const siblings = [ + { + _id: 'sib1', + name: 'Test1', + parent: { _id: 'parent1' }, + contact_type: 'some_type', + reported_date: 1736845534000 + }, + { + _id: 'sib2', + name: 'Test2', + parent: { _id: 'parent1' }, + contact_type: 'some_type', + reported_date: 1736845534000 + }, + { + _id: 'sib3', + name: 'Test the things', + parent: { _id: 'parent1' }, + contact_type: 'some_type', + reported_date: 1736845534000 + }, + { + _id: 'sib4', + name: 'Testimony', + parent: { _id: 'parent1' }, + contact_type: 'some_type', + reported_date: 1736845534000 + }, + ]; + const results = getDuplicates( + doc, + siblings, + { + expression: DEFAULT_CONTACT_DUPLICATE_EXPRESSION, + parseProvider, + xmlFormsContextUtilsService: new XmlFormsContextUtilsService() + } + ); + expect(results.length).equal(2); + expect(results).to.deep.equal([ + { + _id: 'sib1', + name: 'Test1', + parent: { _id: 'parent1' }, + contact_type: 'some_type', + reported_date: 1736845534000 + }, + { + _id: 'sib2', + name: 'Test2', + parent: { _id: 'parent1' }, + contact_type: 'some_type', + reported_date: 1736845534000 + }, + ]); + }); + }); +}); From ea2cb9b90a7023141313c7d09b2a8cb3e9382841 Mon Sep 17 00:00:00 2001 From: Anro Date: Wed, 26 Feb 2025 15:26:40 +0200 Subject: [PATCH 2/3] fix: review changes --- .../translations/messages-ar.properties | 7 + .../translations/messages-bm.properties | 7 + .../translations/messages-en.properties | 7 + .../translations/messages-es.properties | 7 + .../translations/messages-fr.properties | 7 + .../translations/messages-hi.properties | 7 + .../translations/messages-id.properties | 7 + .../translations/messages-ne.properties | 7 + .../translations/messages-sw.properties | 7 + webapp/package-lock.json | 58 ++-- webapp/package.json | 3 +- webapp/src/css/enketo/medic.less | 14 +- .../contact-summary-content.component.html | 13 + .../contact-summary-content.component.ts | 12 + .../duplicate-info.component.html | 47 ++- .../duplicate-info.component.ts | 27 +- .../contacts/contacts-content.component.html | 17 +- .../contacts/contacts-edit.component.html | 10 +- .../contacts/contacts-edit.component.ts | 75 +++-- webapp/src/ts/services/deduplicate.service.ts | 68 +++++ webapp/src/ts/services/form.service.ts | 77 +++-- webapp/src/ts/services/utils/deduplicate.ts | 94 ------ .../xml-forms-context-utils.service.ts | 20 +- .../contacts-content.component.spec.ts | 1 + .../contacts/contacts-edit.component.spec.ts | 196 ++++++++++++- .../ts/services/deduplicate.service.spec.ts | 151 ++++++++++ .../karma/ts/services/form.service.spec.ts | 270 ++++++++++-------- .../ts/services/utils/deduplicate.spec.ts | 156 ---------- .../xml-forms-context-utils.service.spec.ts | 17 ++ 29 files changed, 850 insertions(+), 539 deletions(-) create mode 100644 webapp/src/ts/components/contact-summary-content/contact-summary-content.component.html create mode 100644 webapp/src/ts/components/contact-summary-content/contact-summary-content.component.ts create mode 100644 webapp/src/ts/services/deduplicate.service.ts delete mode 100644 webapp/src/ts/services/utils/deduplicate.ts create mode 100644 webapp/tests/karma/ts/services/deduplicate.service.spec.ts delete mode 100644 webapp/tests/karma/ts/services/utils/deduplicate.spec.ts diff --git a/api/resources/translations/messages-ar.properties b/api/resources/translations/messages-ar.properties index 561bd1b5ce..01c6ff2470 100644 --- a/api/resources/translations/messages-ar.properties +++ b/api/resources/translations/messages-ar.properties @@ -575,6 +575,13 @@ contact.type.place.edit = تعديل المكان contact.type.place.new = مكان جديد contact.type.wrong = النوع خطأ، جهة الاتصال ليست شخصاً. contact.updated = تم تحديث جهة الاتصال +duplicate_check.contact.duplication_message = تم العثور على {{count}} أخوة مكررة +duplicate_check.contact.acknowledge = اعترف بالأخوة المكررة واستمر في الإرسال +duplicate_check.contact.item_number = رقم العنصر: +duplicate_check.contact.name = الاسم: +duplicate_check.contact.created_on = تاريخ الإنشاء: +duplicate_check.contact.load_summary_info = تحميل معلومات ملخص الاتصال +duplicate_check.contact.go_to = انقلني إلى هناك contacts.imported = تم استيراد جهات الاتصال بنجاح contacts.results.sort = فرز النتائج contacts.results.sort.alpha = أبجدياً diff --git a/api/resources/translations/messages-bm.properties b/api/resources/translations/messages-bm.properties index 93e15c35a6..2010005b2e 100644 --- a/api/resources/translations/messages-bm.properties +++ b/api/resources/translations/messages-bm.properties @@ -552,6 +552,13 @@ contact.type.place.edit = contact.type.place.new = contact.type.wrong = contact.updated = +duplicate_check.contact.duplication_message = {{count}} sibilikɛnw kɛnɛ fɛɛrɛ tugu +duplicate_check.contact.acknowledge = Sira sibilikɛnw ye, ka fɔ submission kɔrɔ +duplicate_check.contact.item_number = Nɔrɔ kɛnɛ: +duplicate_check.contact.name = Sɔn: +duplicate_check.contact.created_on = Baro kɛ sisan: +duplicate_check.contact.load_summary_info = Ka fɔ contact summary info +duplicate_check.contact.go_to = A la an ka taa contacts.imported = contacts.results.sort = contacts.results.sort.alpha = diff --git a/api/resources/translations/messages-en.properties b/api/resources/translations/messages-en.properties index 2ad8732a48..b873cdbe8e 100644 --- a/api/resources/translations/messages-en.properties +++ b/api/resources/translations/messages-en.properties @@ -575,6 +575,13 @@ contact.type.place.edit = Edit place contact.type.place.new = New place contact.type.wrong = Wrong type, contact is not a person. contact.updated = Contact updated +duplicate_check.contact.duplication_message = {{count}} duplicate siblings found +duplicate_check.contact.acknowledge = Acknowledge duplicate siblings and proceed with submission +duplicate_check.contact.item_number = Item number: +duplicate_check.contact.name = Name: +duplicate_check.contact.created_on = Created on: +duplicate_check.contact.load_summary_info = Load contact summary info +duplicate_check.contact.go_to = Take me there contacts.imported = Contacts successfully imported contacts.results.sort = Sort results contacts.results.sort.alpha = Alphabetically diff --git a/api/resources/translations/messages-es.properties b/api/resources/translations/messages-es.properties index 418cfdef6b..75dcc38510 100644 --- a/api/resources/translations/messages-es.properties +++ b/api/resources/translations/messages-es.properties @@ -575,6 +575,13 @@ contact.type.place.edit = Editar lugar contact.type.place.new = Nuevo lugar contact.type.wrong = Tipo incorrecto, el contacto no es una persona. contact.updated = Contacto actualizado +duplicate_check.contact.duplication_message = Se encontraron {{count}} hermanos duplicados +duplicate_check.contact.acknowledge = Reconocer hermanos duplicados y continuar con el envío +duplicate_check.contact.item_number = Número de ítem: +duplicate_check.contact.name = Nombre: +duplicate_check.contact.created_on = Creado el: +duplicate_check.contact.load_summary_info = Cargar información resumen de contacto +duplicate_check.contact.go_to = Llévame allí contacts.imported = Los contactos se han importado correctamente contacts.results.sort = Ordenar resultados contacts.results.sort.alpha = Alfabéticamente diff --git a/api/resources/translations/messages-fr.properties b/api/resources/translations/messages-fr.properties index 7fe38a5b70..545ab36eb1 100644 --- a/api/resources/translations/messages-fr.properties +++ b/api/resources/translations/messages-fr.properties @@ -575,6 +575,13 @@ contact.type.place.edit = Editer place contact.type.place.new = Nouvelle place contact.type.wrong = Mauvais type, le contact n'est pas une personne. contact.updated = Contact mis à jour +duplicate_check.contact.duplication_message = {{count}} doublons de frères et sœurs trouvés +duplicate_check.contact.acknowledge = Reconnaître les doublons de frères et sœurs et procéder à la soumission +duplicate_check.contact.item_number = Numéro d'article: +duplicate_check.contact.name = Nom: +duplicate_check.contact.created_on = Créé le: +duplicate_check.contact.load_summary_info = Charger les informations du résumé du contact +duplicate_check.contact.go_to = Emmène-moi là-bas contacts.imported = Contacts importés avec succès contacts.results.sort = Trier les résultats contacts.results.sort.alpha = Alphabétique diff --git a/api/resources/translations/messages-hi.properties b/api/resources/translations/messages-hi.properties index e39d2bf051..ce5d9b0ab6 100644 --- a/api/resources/translations/messages-hi.properties +++ b/api/resources/translations/messages-hi.properties @@ -552,6 +552,13 @@ contact.type.place.edit = स्थान बदलें contact.type.place.new = नया स्थान contact.type.wrong = contact.updated = कॉंटेक्ट अपडेट किया गया +duplicate_check.contact.duplication_message = {{count}} डुप्लीकेट भाई-बहन पाए गए +duplicate_check.contact.acknowledge = डुप्लीकेट भाई-बहनों को स्वीकार करें और सबमिशन जारी रखें +duplicate_check.contact.item_number = आइटम नंबर: +duplicate_check.contact.name = नाम: +duplicate_check.contact.created_on = पर बनाया गया: +duplicate_check.contact.load_summary_info = संपर्क सारांश जानकारी लोड करें +duplicate_check.contact.go_to = मुझे वहां ले जाएँ contacts.imported = सफलतापूर्वक सभी कांटेक्ट का इम्पोर्ट हुआ contacts.results.sort = परिणामों को सॉर्ट करें contacts.results.sort.alpha = वर्णक्रम diff --git a/api/resources/translations/messages-id.properties b/api/resources/translations/messages-id.properties index e90460ffc5..654aa0e454 100644 --- a/api/resources/translations/messages-id.properties +++ b/api/resources/translations/messages-id.properties @@ -563,6 +563,13 @@ contact.type.place.edit = Mengedit tempat contact.type.place.new = Tempat Baru contact.type.wrong = contact.updated = Kontak diperbaharui +duplicate_check.contact.duplication_message = Ditemukan {{count}} saudara duplikat +duplicate_check.contact.acknowledge = Akui saudara duplikat dan lanjutkan pengiriman +duplicate_check.contact.item_number = Nomor item: +duplicate_check.contact.name = Nama: +duplicate_check.contact.created_on = Dibuat pada: +duplicate_check.contact.load_summary_info = Muat info ringkasan kontak +duplicate_check.contact.go_to = Arahkan saya ke sana contacts.imported = Kontak berhasil diimpor contacts.results.sort = Urutkan hasil contacts.results.sort.alpha = Menurut urutan alfabet diff --git a/api/resources/translations/messages-ne.properties b/api/resources/translations/messages-ne.properties index 19def970b1..1b2ef40f42 100644 --- a/api/resources/translations/messages-ne.properties +++ b/api/resources/translations/messages-ne.properties @@ -575,6 +575,13 @@ contact.type.place.edit = स्थान सच्याउने contact.type.place.new = नयाँ स्थान contact.type.wrong = गलत प्रकारको सम्पर्क। छानिएको सम्पर्क व्यक्ति होइन। contact.updated = सम्पर्क अद्यावधिक +duplicate_check.contact.duplication_message = {{count}} डुप्लिकेट दाजुभाइ दिदीबहिनीहरू फेला परे +duplicate_check.contact.acknowledge = डुप्लिकेट दाजुभाइ दिदीबहिनीहरू स्वीकार गर्नुहोस् र सबमिशन जारी राख्नुहोस् +duplicate_check.contact.item_number = वस्तुको संख्या: +duplicate_check.contact.name = नाम: +duplicate_check.contact.created_on = निर्माण गरिएको: +duplicate_check.contact.load_summary_info = सम्पर्क सारांश जानकारी लोड गर्नुहोस् +duplicate_check.contact.go_to = मलाई त्यहाँ लग्नुहोस् contacts.imported = सम्पर्कहरू सफलतापूर्वक आयात गरियो contacts.results.sort = नतिजाहरूलाई क्रमबद्ध गर्नुहोस्। contacts.results.sort.alpha = नामको वर्णानुक्रममा diff --git a/api/resources/translations/messages-sw.properties b/api/resources/translations/messages-sw.properties index 590b6bd211..cff2ce3ffd 100644 --- a/api/resources/translations/messages-sw.properties +++ b/api/resources/translations/messages-sw.properties @@ -577,6 +577,13 @@ contact.type.place.edit = Badilisha Mahali contact.type.place.new = Eneo jipya contact.type.wrong = Aina isiyo sahihi, mwasiliani sio mtu contact.updated = urekebesho ya mawasiliano +duplicate_check.contact.duplication_message = {{count}} nakala za ndugu zilipatikana +duplicate_check.contact.acknowledge = Kubali nakala za ndugu na endelea na uwasilishaji +duplicate_check.contact.item_number = Nambari ya kipengele: +duplicate_check.contact.name = Jina: +duplicate_check.contact.created_on = Imeundwa tarehe: +duplicate_check.contact.load_summary_info = Pakua taarifa za muhtasari wa mawasiliano +duplicate_check.contact.go_to = Nipe njia kwenda hapo contacts.imported = Jumbe za mawasiliano contacts.results.sort = Panga matokeo contacts.results.sort.alpha = Kialfabeti diff --git a/webapp/package-lock.json b/webapp/package-lock.json index e0cdaff842..296d6de219 100644 --- a/webapp/package-lock.json +++ b/webapp/package-lock.json @@ -32,9 +32,9 @@ "core-js": "^3.40.0", "enketo-core": "^7.2.5", "eurodigit": "^3.1.3", + "fastest-levenshtein": "1.0.5", "font-awesome": "^4.7.0", "jquery": "3.5.1", - "levenshtein": "1.0.5", "lodash-es": "^4.17.21", "moment-locales-webpack-plugin": "^1.2.0", "ngrx-store-logger": "^0.2.4", @@ -5344,12 +5344,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/levenshtein": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@types/levenshtein/-/levenshtein-1.0.4.tgz", - "integrity": "sha512-QiNzDEGuAHoNVa7xjTPGQRecXScckE8bAEpuHipG8lEFPZh4eIBK0dw0K5mu9XdiTiVD8AxwYY8lOxYaP1rZUA==", - "license": "MIT" - }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -7913,6 +7907,15 @@ ], "license": "BSD-3-Clause" }, + "node_modules/fastest-levenshtein": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.5.tgz", + "integrity": "sha512-vqVqAjWp4Vhn2rQzhG4SzAvAv2967qn3opdJkYqkSyQ3ojZp+4OnQkdRQWAYdmjt291MAUI2kmfQGdVQHdM0/A==", + "license": "MIT", + "dependencies": { + "levenshtein-edit-distance": "^2.0.5" + } + }, "node_modules/fastq": { "version": "1.19.0", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.0.tgz", @@ -9223,14 +9226,18 @@ "node": ">=0.10.0" } }, - "node_modules/levenshtein": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/levenshtein/-/levenshtein-1.0.5.tgz", - "integrity": "sha512-UQf1nnmxjl7O0+snDXj2YF2r74Gkya8ZpnegrUBYN9tikh2dtxV/ey8e07BO5wwo0i76yjOvbDhFHdcPEiH9aA==", - "engines": [ - "node >=0.2.0" - ], - "license": "Public Domain" + "node_modules/levenshtein-edit-distance": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/levenshtein-edit-distance/-/levenshtein-edit-distance-2.0.5.tgz", + "integrity": "sha512-Yuraz7QnMX/JENJU1HA6UtdsbhRzoSFnGpVGVryjQgHtl2s/YmVgmNYkVs5yzVZ9aAvQR9wPBUH3lG755ylxGA==", + "license": "MIT", + "bin": { + "levenshtein-edit-distance": "cli.js" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } }, "node_modules/license-webpack-plugin": { "version": "4.0.2", @@ -16898,11 +16905,6 @@ "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "dev": true }, - "@types/levenshtein": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@types/levenshtein/-/levenshtein-1.0.4.tgz", - "integrity": "sha512-QiNzDEGuAHoNVa7xjTPGQRecXScckE8bAEpuHipG8lEFPZh4eIBK0dw0K5mu9XdiTiVD8AxwYY8lOxYaP1rZUA==" - }, "@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -18678,6 +18680,14 @@ "integrity": "sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==", "dev": true }, + "fastest-levenshtein": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.5.tgz", + "integrity": "sha512-vqVqAjWp4Vhn2rQzhG4SzAvAv2967qn3opdJkYqkSyQ3ojZp+4OnQkdRQWAYdmjt291MAUI2kmfQGdVQHdM0/A==", + "requires": { + "levenshtein-edit-distance": "^2.0.5" + } + }, "fastq": { "version": "1.19.0", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.0.tgz", @@ -19529,10 +19539,10 @@ "integrity": "sha512-MYUxjSQSBUQmowc0l5nPieOYwMzGPUaTzB6inNW/bdPEG9zOL3eAAD1Qw5ZxSPk7we5dMojHwNODYMV1hq4EVg==", "dev": true }, - "levenshtein": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/levenshtein/-/levenshtein-1.0.5.tgz", - "integrity": "sha512-UQf1nnmxjl7O0+snDXj2YF2r74Gkya8ZpnegrUBYN9tikh2dtxV/ey8e07BO5wwo0i76yjOvbDhFHdcPEiH9aA==" + "levenshtein-edit-distance": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/levenshtein-edit-distance/-/levenshtein-edit-distance-2.0.5.tgz", + "integrity": "sha512-Yuraz7QnMX/JENJU1HA6UtdsbhRzoSFnGpVGVryjQgHtl2s/YmVgmNYkVs5yzVZ9aAvQR9wPBUH3lG755ylxGA==" }, "license-webpack-plugin": { "version": "4.0.2", diff --git a/webapp/package.json b/webapp/package.json index 6e9ca765e0..f2a0736565 100644 --- a/webapp/package.json +++ b/webapp/package.json @@ -61,7 +61,8 @@ "select2": "4.0.13", "signature_pad": "2.3.x", "tslib": "^2.8.1", - "zone.js": "^0.15.0" + "zone.js": "^0.15.0", + "fastest-levenshtein": "1.0.5" }, "overrides": { "minimist": ">=1.2.6" diff --git a/webapp/src/css/enketo/medic.less b/webapp/src/css/enketo/medic.less index 0785b78238..9bd54b0bb3 100644 --- a/webapp/src/css/enketo/medic.less +++ b/webapp/src/css/enketo/medic.less @@ -479,10 +479,16 @@ } .nested-section { - margin-left: 1.5rem; + display: block; + border: 1px solid lightgray; + border-radius: 5px; + + label { + padding-left: 0%; + } } - .toggle-button { + .load-more-button { background: none; border: none; color: #007bff; @@ -494,6 +500,10 @@ .toggle-button:hover { text-decoration: underline; } + + .error_msg { + color: #e33030; + } } } diff --git a/webapp/src/ts/components/contact-summary-content/contact-summary-content.component.html b/webapp/src/ts/components/contact-summary-content/contact-summary-content.component.html new file mode 100644 index 0000000000..1e443e9599 --- /dev/null +++ b/webapp/src/ts/components/contact-summary-content/contact-summary-content.component.html @@ -0,0 +1,13 @@ +
+
+
+ +
+ +

{{field.value}}

+

+

{{field.value}}

+
+
+
+
diff --git a/webapp/src/ts/components/contact-summary-content/contact-summary-content.component.ts b/webapp/src/ts/components/contact-summary-content/contact-summary-content.component.ts new file mode 100644 index 0000000000..70bfe70e13 --- /dev/null +++ b/webapp/src/ts/components/contact-summary-content/contact-summary-content.component.ts @@ -0,0 +1,12 @@ +import { Component, Input } from '@angular/core'; + +@Component({ + selector: 'mm-contact-summary-content', + templateUrl: './contact-summary-content.component.html' +}) +export class ContactSummaryContentComponent { + @Input() contactsLoadingSummary; + @Input() fields; + + constructor() { } +} diff --git a/webapp/src/ts/components/duplicate-info/duplicate-info.component.html b/webapp/src/ts/components/duplicate-info/duplicate-info.component.html index 5e061f5f16..bfbd45fbab 100644 --- a/webapp/src/ts/components/duplicate-info/duplicate-info.component.html +++ b/webapp/src/ts/components/duplicate-info/duplicate-info.component.html @@ -1,7 +1,11 @@
-

{{ duplicates.length }} {{'potential duplicate item(s) found:' | translate }}

+

+ {{ ('duplicate_check.contact.' + entityType + '.duplication_message' | translate: { count: duplicates.length }) !== ('duplicate_check.contact.' + entityType + '.duplication_message') + ? ('duplicate_check.contact.' + entityType + '.duplication_message' | translate: { count: duplicates.length }) + : ('duplicate_check.contact.duplication_message' | translate: { count: duplicates.length }) }} +

- {{ 'Item number:' | translate }} {{ i + 1 }} + {{ 'duplicate_check.contact.item_number' | translate }} {{ i + 1 }}

- {{ 'Name:' | translate }} {{ duplicate.name }} + {{ 'duplicate_check.contact.name' | translate }} {{ duplicate.name }}
- {{ 'Created on:' | translate }} {{ duplicate.reported_date | date: 'EEE MMM dd yyyy HH:mm:ss' }} + {{ 'duplicate_check.contact.created_on' | translate }} {{ duplicate.reported_date | date: 'EEE MMM dd yyyy HH:mm:ss' }}

- -
- +
+
+ +

- +
- -
-
- -
- -
-
- - {{ key.key }}: {{ key.value }} - -
-
diff --git a/webapp/src/ts/components/duplicate-info/duplicate-info.component.ts b/webapp/src/ts/components/duplicate-info/duplicate-info.component.ts index 844088f922..406bee75a9 100644 --- a/webapp/src/ts/components/duplicate-info/duplicate-info.component.ts +++ b/webapp/src/ts/components/duplicate-info/duplicate-info.component.ts @@ -5,10 +5,16 @@ import { Component, EventEmitter, Input, Output } from '@angular/core'; templateUrl: './duplicate-info.component.html', }) export class DuplicateInfoComponent { + @Input() entityType: string = ''; @Input() acknowledged: boolean = false; @Output() acknowledgedChange = new EventEmitter(); @Output() navigateToDuplicate = new EventEmitter(); - @Input() duplicates: { _id: string; name: string; reported_date: string | Date; [key: string]: string | Date }[] = []; + @Input() duplicates: { _id: string; name: string; reported_date: number; [key: string]: string | number }[] = []; + @Output() loadContactSummary = new EventEmitter(); + // We need the loading prop to prohibit additional requests + // We need the contact id in order to appropriately indicate the loading & error elements + // In the latter case a 'retry' button should be displayed + @Input() summaryRequestInfo?: { contact_id: string, isLoading: boolean, error?: string }; toggleAcknowledged() { this.acknowledged = !this.acknowledged; @@ -19,22 +25,7 @@ export class DuplicateInfoComponent { this.navigateToDuplicate.emit(_id); } - // Handles collapse / expand of duplicate doc details - expandedSections = new Map(); - - toggleSection(path: string): void { - this.expandedSections.set(path, !this.expandedSections.get(path)); - } - - isExpanded(path: string): boolean { - return this.expandedSections.get(path) || false; - } - - isObject(value: any): boolean { - return value && typeof value === 'object' && !Array.isArray(value); - } - - getPath(parentPath: string, key: string): string { - return parentPath ? `${parentPath}.${key}` : key; + _loadContactSummary(_id: string){ + this.loadContactSummary.emit(_id); } } diff --git a/webapp/src/ts/modules/contacts/contacts-content.component.html b/webapp/src/ts/modules/contacts/contacts-content.component.html index fdfa9a9917..f281e54561 100644 --- a/webapp/src/ts/modules/contacts/contacts-content.component.html +++ b/webapp/src/ts/modules/contacts/contacts-content.component.html @@ -37,19 +37,10 @@

{{selectedContact?.doc?.name}}

-
-
-
- -
- -

{{field.value}}

-

-

{{field.value}}

-
-
-
-
+ + diff --git a/webapp/src/ts/modules/contacts/contacts-edit.component.html b/webapp/src/ts/modules/contacts/contacts-edit.component.html index 1a99d55466..dc579916ad 100644 --- a/webapp/src/ts/modules/contacts/contacts-edit.component.html +++ b/webapp/src/ts/modules/contacts/contacts-edit.component.html @@ -11,7 +11,15 @@
- + +
diff --git a/webapp/src/ts/modules/contacts/contacts-edit.component.ts b/webapp/src/ts/modules/contacts/contacts-edit.component.ts index 0b33d12e2f..f21b743a2d 100644 --- a/webapp/src/ts/modules/contacts/contacts-edit.component.ts +++ b/webapp/src/ts/modules/contacts/contacts-edit.component.ts @@ -5,7 +5,7 @@ import { isEqual as _isEqual } from 'lodash-es'; import { ActivatedRoute, Router } from '@angular/router'; import { LineageModelGeneratorService } from '@mm-services/lineage-model-generator.service'; -import { FormService, DuplicatesFoundError, Duplicate } from '@mm-services/form.service'; +import { FormService, DuplicatesFoundError, Duplicate, DuplicatesCheck } from '@mm-services/form.service'; import { EnketoFormContext } from '@mm-services/enketo.service'; import { ContactTypesService } from '@mm-services/contact-types.service'; import { DbService } from '@mm-services/db.service'; @@ -23,15 +23,15 @@ import { TranslatePipe } from '@ngx-translate/core'; }) export class ContactsEditComponent implements OnInit, OnDestroy, AfterViewInit { constructor( - private store:Store, - private route:ActivatedRoute, - private router:Router, - private lineageModelGeneratorService:LineageModelGeneratorService, - private formService:FormService, - private contactTypesService:ContactTypesService, - private dbService:DbService, - private performanceService:PerformanceService, - private translateService:TranslateService, + private store: Store, + private route: ActivatedRoute, + private router: Router, + private lineageModelGeneratorService: LineageModelGeneratorService, + private formService: FormService, + private contactTypesService: ContactTypesService, + private dbService: DbService, + private performanceService: PerformanceService, + private translateService: TranslateService, ) { this.globalActions = new GlobalActions(store); } @@ -59,17 +59,46 @@ export class ContactsEditComponent implements OnInit, OnDestroy, AfterViewInit { private trackSave; private trackMetadata = { action: '', form: '' }; - private duplicateCheck; + private duplicateCheck?: DuplicatesCheck; acknowledged = false; onAcknowledgeChange(value: boolean) { this.acknowledged = value; } - onNavigateToDuplicate(_id: string){ - this.router.navigate(['/contacts', _id, 'edit']); + onNavigateToDuplicate(_id: string) { + this.router.navigate(['/contacts', _id]); + } + + private readonly omitProperties = ['_summary', 'reported_date', 'name']; + summaryRequestInfo?: { contact_id: string, isLoading: boolean, error?: string } = undefined; + async onLoadContactSummary(id: string) { + this.summaryRequestInfo = { contact_id: id, isLoading: true, error: undefined }; + try { + const contact = this.duplicates.find(x => x._id === id); + + if (!contact) { + throw new Error(`Contact with ID ${id} not found`); + } + + // Remove "reserved" fields + const sanitizedContact = Object.keys(contact).reduce((acc, key) => { + if (!this.omitProperties.includes(key)) { + acc[key] = contact[key]; + } + return acc; + }, {}); + + contact._summary = await this.formService.loadContactSummary(sanitizedContact); + } catch (e) { + console.error(e); + this.summaryRequestInfo.error = `Unable to load summary data for contact ${id}`; + } finally { + this.summaryRequestInfo.isLoading = false; + } } duplicates: Duplicate[] = []; + entityType: string = ''; ngOnInit() { this.trackRender = this.performanceService.track(); @@ -139,7 +168,7 @@ export class ContactsEditComponent implements OnInit, OnDestroy, AfterViewInit { } private setCancelCallback() { - const cancelCallback = (router:Router, routeSnapshot) => { + const cancelCallback = (router: Router, routeSnapshot) => { if (routeSnapshot.queryParams?.from === 'list') { router.navigate(['/contacts']); } else { @@ -169,13 +198,10 @@ export class ContactsEditComponent implements OnInit, OnDestroy, AfterViewInit { this.contentError = false; this.errorTranslationKey = false; - // Reset when when navigated to duplicate - this.duplicates = []; - this.acknowledged = false; - try { const contact = await this.getContact(); const contactTypeId = this.contactTypesService.getTypeId(contact) || this.routeSnapshot.params?.type; + this.entityType = contactTypeId; const contactType = await this.contactTypesService.get(contactTypeId); if (!contactType) { throw new Error(`Unknown contact type "${contactTypeId}"`); @@ -292,7 +318,7 @@ export class ContactsEditComponent implements OnInit, OnDestroy, AfterViewInit { private async renderForm(formId: string, titleKey: string) { const formDoc = await this.dbService.get().get(formId); this.xmlVersion = formDoc.xmlVersion; - this.duplicateCheck = formDoc.context?.duplicate_check; + this.duplicateCheck = formDoc.duplicate_check; this.globalActions.setEnketoEditedStatus(false); @@ -304,7 +330,7 @@ export class ContactsEditComponent implements OnInit, OnDestroy, AfterViewInit { this.trackMetadata.form = formId; this.trackRender?.stop({ - name: [ 'enketo', 'contacts', this.trackMetadata.form, this.trackMetadata.action, 'render' ].join(':'), + name: ['enketo', 'contacts', this.trackMetadata.form, this.trackMetadata.action, 'render'].join(':'), recordApdex: true, }); this.trackEditDuration = this.performanceService.track(); @@ -327,7 +353,7 @@ export class ContactsEditComponent implements OnInit, OnDestroy, AfterViewInit { } this.trackEditDuration?.stop({ - name: [ 'enketo', 'contacts', this.trackMetadata.form, this.trackMetadata.action, 'user_edit_time' ].join(':'), + name: ['enketo', 'contacts', this.trackMetadata.form, this.trackMetadata.action, 'user_edit_time'].join(':'), }); this.trackSave = this.performanceService.track(); @@ -349,7 +375,7 @@ export class ContactsEditComponent implements OnInit, OnDestroy, AfterViewInit { return this.formService .saveContact({ form, docId, type: this.enketoContact.type, xmlVersion: this.xmlVersion - }, this.duplicateCheck, this.acknowledged) + }, this.acknowledged, this.duplicateCheck) .then((result) => { console.debug('saved contact', result); @@ -357,7 +383,7 @@ export class ContactsEditComponent implements OnInit, OnDestroy, AfterViewInit { this.globalActions.setEnketoEditedStatus(false); this.trackSave?.stop({ - name: [ 'enketo', 'contacts', this.trackMetadata.form, this.trackMetadata.action, 'save' ].join(':'), + name: ['enketo', 'contacts', this.trackMetadata.form, this.trackMetadata.action, 'save'].join(':'), recordApdex: true, }); @@ -368,9 +394,8 @@ export class ContactsEditComponent implements OnInit, OnDestroy, AfterViewInit { this.router.navigate(['/contacts', result.docId]); }) .catch((err) => { - if (err instanceof DuplicatesFoundError){ + if (err instanceof DuplicatesFoundError) { this.duplicates = err.duplicates; - err = Error(err.message); } console.error('Error submitting form data', err); diff --git a/webapp/src/ts/services/deduplicate.service.ts b/webapp/src/ts/services/deduplicate.service.ts new file mode 100644 index 0000000000..b1be349780 --- /dev/null +++ b/webapp/src/ts/services/deduplicate.service.ts @@ -0,0 +1,68 @@ +import { Injectable } from '@angular/core'; +import { DbService } from '@mm-services/db.service'; +import { ParseProvider } from '@mm-providers/parse.provider'; +import { XmlFormsContextUtilsService } from '@mm-services/xml-forms-context-utils.service'; + +export type Doc = { _id: string; name: string; reported_date: number;[key: string]: any }; +const DEFAULT_CONTACT_DUPLICATE_EXPRESSION = + 'levenshteinEq(current.name, existing.name, 3) && ' + + 'ageInYears(current.date_of_birth) === ageInYears(existing.date_of_birth)'; +export type DuplicateCheck = { expression?: string; disabled?: boolean }; +@Injectable({ + providedIn: 'root' +}) +export class DeduplicateService { + constructor( + private readonly dbService: DbService, + private readonly parseProvider: ParseProvider, + private readonly xmlFormsContextUtilsService: XmlFormsContextUtilsService, + ) { } + + async requestSiblings(parentId: string, contactType: string) { + const siblings: Doc[] = []; + // Generally, Only reason why we won't have a "parent_id" is if we're creating/editing a top-level place. + const results = contactType && (parentId ? await this.dbService.get().query('medic-client/contacts_by_parent', { + startkey: [parentId, contactType], + endkey: [parentId, contactType, {}], + include_docs: true + }) : await this.dbService.get().query('medic-client/contacts_by_type', { + startkey: [contactType], + endkey: [contactType, {}], + include_docs: true + })); + if (results) { + siblings.push(...results.rows.map((row: { doc: Doc }) => row.doc)); + } + return siblings; + } + + extractExpression(duplicateCheck?: DuplicateCheck) { + if (duplicateCheck) { + if (duplicateCheck.disabled === true) { + return null; // No duplicate check should be performed + } else if (typeof duplicateCheck.expression === 'string') { + return duplicateCheck.expression; + } + } + + return DEFAULT_CONTACT_DUPLICATE_EXPRESSION; + } + + getDuplicates( + doc: Doc, + siblings: Array, + expression: string + ) { + const _siblings: Doc[] = siblings.filter(({ _id }) => _id !== doc._id); + // Remove the currently edited doc from the sibling list + + return _siblings.filter((sibling) => { + const parsed = this.parseProvider.parse(expression); + return parsed(this.xmlFormsContextUtilsService, { + current: doc, + existing: sibling, + }); + }).sort((a: Doc, b: Doc) => (b.reported_date || 0) - (a.reported_date || 0)); + // Desc order - reverse order by switching props + } +} diff --git a/webapp/src/ts/services/form.service.ts b/webapp/src/ts/services/form.service.ts index 65225f3f3d..1b5a49a8ac 100644 --- a/webapp/src/ts/services/form.service.ts +++ b/webapp/src/ts/services/form.service.ts @@ -26,9 +26,7 @@ import { reduce as _reduce } from 'lodash-es'; import { ContactTypesService } from '@mm-services/contact-types.service'; import { TargetAggregatesService } from '@mm-services/target-aggregates.service'; import { ContactViewModelGeneratorService } from '@mm-services/contact-view-model-generator.service'; -import { ParseProvider } from '@mm-providers/parse.provider'; -import { XmlFormsContextUtilsService } from '@mm-services/xml-forms-context-utils.service'; -import { extractExpression, requestSiblings, getDuplicates, Doc, DuplicateCheck } from './utils/deduplicate'; +import { DeduplicateService, Doc, DuplicateCheck } from '@mm-services/deduplicate.service'; /** * Service for interacting with forms. This is the primary entry-point for CHT code to render forms and save the @@ -42,15 +40,15 @@ import { extractExpression, requestSiblings, getDuplicates, Doc, DuplicateCheck export class FormService { constructor( private store: Store, - private contactSaveService:ContactSaveService, + private contactSaveService: ContactSaveService, private contactSummaryService: ContactSummaryService, - private contactTypesService:ContactTypesService, + private contactTypesService: ContactTypesService, private dbService: DbService, private fileReaderService: FileReaderService, private lineageModelGeneratorService: LineageModelGeneratorService, private submitFormBySmsService: SubmitFormBySmsService, private userContactService: UserContactService, - private userSettingsService:UserSettingsService, + private userSettingsService: UserSettingsService, private xmlFormsService: XmlFormsService, private zScoreService: ZScoreService, private trainingCardsService: TrainingCardsService, @@ -61,8 +59,7 @@ export class FormService { private enketoService: EnketoService, private targetAggregatesService: TargetAggregatesService, private contactViewModelGeneratorService: ContactViewModelGeneratorService, - private readonly parseProvider: ParseProvider, - private readonly xmlFormsDuplicateUtilsService: XmlFormsContextUtilsService, + private readonly deduplicateService: DeduplicateService ) { this.inited = this.init(); this.globalActions = new GlobalActions(store); @@ -139,11 +136,7 @@ export class FormService { return this.targetAggregatesService.getTargetDocs(contact, this.userFacilityIds, this.userContactId); } - private getContactSummary(doc, instanceData) { - const contact = instanceData?.contact; - if (!doc.hasContactSummary || !contact) { - return Promise.resolve(); - } + loadContactSummary(contact) { return Promise .all([ this.getContactReports(contact), @@ -153,6 +146,14 @@ export class FormService { .then(([reports, lineage, targetDocs]) => this.contactSummaryService.get(contact, reports, lineage, targetDocs)); } + private getContactSummary(doc, instanceData) { + const contact = instanceData?.contact; + if (!doc.hasContactSummary || !contact) { + return Promise.resolve(); + } + return this.loadContactSummary(contact); + } + private canAccessForm(formContext: EnketoFormContext) { return this.xmlFormsService.canAccessForm( formContext.formDoc, @@ -170,7 +171,7 @@ export class FormService { try { this.unload(this.enketoService.getCurrentForm()); - const [ doc, userSettings ] = await Promise.all([ + const [doc, userSettings] = await Promise.all([ this.transformXml(formDoc), this.userSettingsService.getWithLanguage() ]); @@ -220,7 +221,7 @@ export class FormService { }); } - private async getUserContact(requiresContact:boolean) { + private async getUserContact(requiresContact: boolean) { const contact = await this.userContactService.get(); if (requiresContact && !contact) { const err: any = new Error('Your user does not have an associated contact, or does not have access to the ' + @@ -331,37 +332,32 @@ export class FormService { }, null); } - async checkForDuplicates(doc, duplicateCheck, acknowledged) { + private async checkForDuplicates(doc, acknowledged: boolean, duplicateCheck?: DuplicateCheck): Promise> { const parentId = doc ? doc.parent?._id : undefined; const contactType = doc ? doc.contact_type ?? doc.type : undefined; - const siblings = await requestSiblings(this.dbService, parentId, contactType); - const expression = extractExpression(duplicateCheck); - const isCanonical = doc.is_canonical ? doc.is_canonical === 'true' : false; + const expression = this.deduplicateService.extractExpression(duplicateCheck); acknowledged = acknowledged ?? false; - if (!isCanonical && expression && !acknowledged){ - const duplicates = getDuplicates( - doc, - siblings, - { - expression, - parseProvider: this.parseProvider, - xmlFormsContextUtilsService: this.xmlFormsDuplicateUtilsService - } - ); - return duplicates; + if (!expression || acknowledged) { + return []; } + + return this.deduplicateService.getDuplicates( + doc, + await this.deduplicateService.requestSiblings(parentId, contactType), + expression + ); } async saveContact( - contactInfo: { - form: any; - docId: string| undefined; + contactInfo: { + form: any; + docId: string | undefined; type: string | undefined; xmlVersion: string | undefined; - }, - duplicateCheck: DuplicateCheck, - acknowledged: boolean + }, + acknowledged: boolean, + duplicateCheck?: DuplicateCheck ) { const { form, docId, type, xmlVersion } = contactInfo; const typeFields = this.contactTypesService.isHardcodedType(type) @@ -374,11 +370,11 @@ export class FormService { const primaryDoc = preparedDocs.preparedDocs.find(doc => doc.type === type); const duplicates = await this.checkForDuplicates( - primaryDoc || preparedDocs.preparedDocs[0], - duplicateCheck, - acknowledged + primaryDoc || preparedDocs.preparedDocs[0], + acknowledged, + duplicateCheck ); - if (duplicates && duplicates.length > 0){ + if (duplicates.length) { throw new DuplicatesFoundError('Duplicates found', duplicates); } @@ -408,3 +404,4 @@ export class DuplicatesFoundError extends Error { } } export type Duplicate = Doc; +export type DuplicatesCheck = DuplicateCheck; diff --git a/webapp/src/ts/services/utils/deduplicate.ts b/webapp/src/ts/services/utils/deduplicate.ts deleted file mode 100644 index 2aa7a3f623..0000000000 --- a/webapp/src/ts/services/utils/deduplicate.ts +++ /dev/null @@ -1,94 +0,0 @@ -import * as Levenshtein from 'levenshtein'; -import { DbService } from '@mm-services/db.service'; -import { ParseProvider } from '@mm-providers/parse.provider'; -import { XmlFormsContextUtilsService } from '@mm-services/xml-forms-context-utils.service'; - - -export type Doc = { _id: string; name: string; reported_date: number;[key: string]: any }; - -const DEFAULT_CONTACT_DUPLICATE_EXPRESSION = 'levenshteinEq(3, current.name, existing.name)'; - -// Normalize the distance by dividing by the length of the longer string. -// This can make the metric more adaptable across different string lengths -const normalizedLevenshteinEq = function (str1: string, str2: string) { - const distance = levenshteinEq(str1, str2); - const maxLen = Math.max(str1.length, str2.length); - return (maxLen === 0) ? 0 : (distance / maxLen); -}; - -// The Levenshtein distance is a measure of the number of edits (insertions, deletions, and substitutions) -// required to change one string into another. -const levenshteinEq = function (str1: string, str2: string): number { - return new Levenshtein(str1, str2).distance; -}; - - -const requestSiblings = async function (dbService: DbService, parentId: string, contactType: string) { - const siblings: Doc[] = []; - const results = parentId && contactType && await dbService.get().query('medic-client/contacts_by_parent', { - startkey: [parentId, contactType], - endkey: [parentId, contactType, {}], - include_docs: true - }); - - if (results) { - // Desc order - reverse order by switching props - siblings.push(...results.rows.map((row: { doc: Doc }) => row.doc) - .sort((a: Doc, b: Doc) => (b.reported_date || 0) - (a.reported_date || 0))); - } - return siblings; -}; - -export type DuplicateCheck = { expression?: string; disabled?: boolean } | undefined; -const extractExpression = function (duplicateCheck: DuplicateCheck) { - // eslint-disable-next-line eqeqeq - if (duplicateCheck != null) { - if (Object.prototype.hasOwnProperty.call(duplicateCheck, 'expression')) { - return duplicateCheck.expression as string; - } else if (Object.prototype.hasOwnProperty.call(duplicateCheck, 'disabled') && duplicateCheck.disabled) { - return null; // No duplicate check should be performed - } - } - - return DEFAULT_CONTACT_DUPLICATE_EXPRESSION; -}; - -const getDuplicates = function ( - doc: Doc, - siblings: Array, - config: { - expression: string; - parseProvider: ParseProvider; - xmlFormsContextUtilsService: XmlFormsContextUtilsService; - } -) { - const { expression, parseProvider, xmlFormsContextUtilsService } = config; - // eslint-disable-next-line eqeqeq - const _siblings: Doc[] = siblings.filter((s) => !((doc._id != null && s._id === doc._id))); - // Remove the currently edited doc from the sibling list - - const duplicates: Array = []; - for (const sibling of _siblings) { - const parsed = parseProvider.parse(expression); - const test = parsed(xmlFormsContextUtilsService, { - current: doc, - existing: sibling, - }); - if (test) { - duplicates.push(sibling); - } - } - - return duplicates; -}; - -export { - normalizedLevenshteinEq, - levenshteinEq, - - requestSiblings, - extractExpression, - getDuplicates, - - DEFAULT_CONTACT_DUPLICATE_EXPRESSION -}; diff --git a/webapp/src/ts/services/xml-forms-context-utils.service.ts b/webapp/src/ts/services/xml-forms-context-utils.service.ts index 74e8f88794..2718bfcf5a 100644 --- a/webapp/src/ts/services/xml-forms-context-utils.service.ts +++ b/webapp/src/ts/services/xml-forms-context-utils.service.ts @@ -1,6 +1,6 @@ import * as moment from 'moment'; +const { distance } = require('fastest-levenshtein'); import { Injectable } from '@angular/core'; -import { normalizedLevenshteinEq, levenshteinEq } from './utils/deduplicate'; /** * Util functions available to a form doc's `.context` function for checking if @@ -32,12 +32,20 @@ export class XmlFormsContextUtilsService { return this.getDateDiff(contact, 'years'); } - levenshteinEq(threshold: number, current: string, existing: string){ - return current && existing ? levenshteinEq(current, existing) < threshold : false; + // The Levenshtein distance is a measure of the number of edits (insertions, deletions, and substitutions) + // required to change one string into another. + levenshteinEq(current: string, existing: string, threshold: number = 3){ + return current && existing ? distance(current, existing) <= threshold : false; } - normalizedLevenshteinEq(threshold: number, current: string, existing: string){ - return current && existing ? normalizedLevenshteinEq(current, existing) < threshold : false; - } + private readonly _normalizedLevenshteinEq = (str1: string, str2: string) :number => { + const maxLen = Math.max(str1.length, str2.length); + return (maxLen === 0) ? 0 : (distance(str1, str2) / maxLen); + }; + // Normalize the distance by dividing by the length of the longer string. + // This can make the metric more adaptable across different string lengths + normalizedLevenshteinEq(current: string, existing: string, threshold: number = 3){ + return current && existing ? this._normalizedLevenshteinEq(current, existing) <= threshold : false; + } } diff --git a/webapp/tests/karma/ts/modules/contacts/contacts-content.component.spec.ts b/webapp/tests/karma/ts/modules/contacts/contacts-content.component.spec.ts index d6c9349d84..e9fa9ea1ed 100644 --- a/webapp/tests/karma/ts/modules/contacts/contacts-content.component.spec.ts +++ b/webapp/tests/karma/ts/modules/contacts/contacts-content.component.spec.ts @@ -30,6 +30,7 @@ import { AuthService } from '@mm-services/auth.service'; import { MatBottomSheet } from '@angular/material/bottom-sheet'; import { MatDialog } from '@angular/material/dialog'; import { SearchTelemetryService } from '@mm-services/search-telemetry.service'; +import { ComponentsModule } from '@mm-components/components.module'; describe('Contacts content component', () => { let component: ContactsContentComponent; diff --git a/webapp/tests/karma/ts/modules/contacts/contacts-edit.component.spec.ts b/webapp/tests/karma/ts/modules/contacts/contacts-edit.component.spec.ts index cb38dbafb2..f68c7aac6f 100644 --- a/webapp/tests/karma/ts/modules/contacts/contacts-edit.component.spec.ts +++ b/webapp/tests/karma/ts/modules/contacts/contacts-edit.component.spec.ts @@ -15,7 +15,7 @@ import { PerformanceService } from '@mm-services/performance.service'; import { DbService } from '@mm-services/db.service'; import { Selectors } from '@mm-selectors/index'; import { LineageModelGeneratorService } from '@mm-services/lineage-model-generator.service'; -import { FormService } from '@mm-services/form.service'; +import { FormService, DuplicatesFoundError } from '@mm-services/form.service'; import { GlobalActions } from '@mm-actions/global'; @@ -33,6 +33,7 @@ describe('ContactsEdit component', () => { let routeSnapshot; let stopPerformanceTrackStub; let performanceService; + const loadContactSummary = sinon.stub(); beforeEach(() => { contactTypesService = { @@ -54,16 +55,17 @@ describe('ContactsEdit component', () => { formService = { render: sinon.stub(), unload: sinon.stub(), - saveContact: sinon.stub() + saveContact: sinon.stub(), + loadContactSummary: loadContactSummary, }; stopPerformanceTrackStub = sinon.stub(); performanceService = { track: sinon.stub().returns({ stop: stopPerformanceTrackStub }) }; - lineageModelGeneratorService = { contact: sinon.stub().resolves({ doc: { } }) }; + lineageModelGeneratorService = { contact: sinon.stub().resolves({ doc: {} }) }; sinon.stub(console, 'error'); const mockedSelectors = [ - { selector: Selectors.getEnketoStatus, value: { } }, + { selector: Selectors.getEnketoStatus, value: {} }, { selector: Selectors.getEnketoSavingStatus, value: false }, { selector: Selectors.getEnketoEditedStatus, value: false }, { selector: Selectors.getEnketoError, value: false }, @@ -114,7 +116,7 @@ describe('ContactsEdit component', () => { cancelCallback(); expect(router.navigate.callCount).to.equal(1); - expect(router.navigate.args[0]).to.deep.equal([[ '/contacts' ]]); + expect(router.navigate.args[0]).to.deep.equal([['/contacts']]); }); it('cancelling falls back to parent contact if new contact and query `from` param is not equal to `list`', @@ -130,7 +132,7 @@ describe('ContactsEdit component', () => { cancelCallback(); expect(router.navigate.callCount).to.equal(1); - expect(router.navigate.args[0]).to.deep.equal([[ '/contacts', 'parent_id' ]]); + expect(router.navigate.args[0]).to.deep.equal([['/contacts', 'parent_id']]); }); it('cancelling falls back to parent contact if new contact and query does not have `from` param', async () => { @@ -144,7 +146,7 @@ describe('ContactsEdit component', () => { cancelCallback(); expect(router.navigate.callCount).to.equal(1); - expect(router.navigate.args[0]).to.deep.equal([[ '/contacts', 'parent_id' ]]); + expect(router.navigate.args[0]).to.deep.equal([['/contacts', 'parent_id']]); }); it('cancelling falls back to contact if edit contact', async () => { @@ -158,7 +160,7 @@ describe('ContactsEdit component', () => { cancelCallback(); expect(router.navigate.callCount).to.equal(1); - expect(router.navigate.args[0]).to.deep.equal([[ '/contacts', 'id' ]]); + expect(router.navigate.args[0]).to.deep.equal([['/contacts', 'id']]); }); }); @@ -624,7 +626,7 @@ describe('ContactsEdit component', () => { expect(setEnketoError.callCount).to.equal(0); }); - it('should not save when invalid', async() => { + it('should not save when invalid', async () => { await createComponent(); await fixture.whenStable(); @@ -705,8 +707,8 @@ describe('ContactsEdit component', () => { expect(setEnketoSavingStatus.args).to.deep.equal([[true], [false]]); expect(setEnketoError.callCount).to.equal(1); expect(formService.saveContact.callCount).to.equal(1); - expect(formService.saveContact.args[0]).to.deep.equal([ - { form, docId: null, type: 'clinic', xmlVersion: undefined }, undefined, false + expect(formService.saveContact.args[0]).to.deep.equal([ + { form, docId: null, type: 'clinic', xmlVersion: undefined }, false, undefined ]); expect(router.navigate.callCount).to.equal(1); expect(router.navigate.args[0]).to.deep.equal([['/contacts', 'new_clinic_id']]); @@ -743,7 +745,7 @@ describe('ContactsEdit component', () => { expect(setEnketoError.callCount).to.equal(1); expect(formService.saveContact.callCount).to.equal(1); expect(formService.saveContact.args[0]).to.deep.equal( - [ {form, docId: 'the_person', type: 'person', xmlVersion: undefined}, undefined, false ] + [{ form, docId: 'the_person', type: 'person', xmlVersion: undefined }, false, undefined] ); expect(router.navigate.callCount).to.equal(1); expect(router.navigate.args[0]).to.deep.equal([['/contacts', 'the_person']]); @@ -793,7 +795,7 @@ describe('ContactsEdit component', () => { expect(setEnketoError.callCount).to.equal(1); expect(formService.saveContact.callCount).to.equal(1); expect(formService.saveContact.args[0]).to.deep.equal( - [ { form, docId: 'the_patient', type: 'patient', xmlVersion: undefined }, undefined, false ] + [{ form, docId: 'the_patient', type: 'patient', xmlVersion: undefined }, false, undefined] ); expect(router.navigate.callCount).to.equal(1); expect(router.navigate.args[0]).to.deep.equal([['/contacts', 'the_patient']]); @@ -811,5 +813,173 @@ describe('ContactsEdit component', () => { recordApdex: true, }); }); + + it('should catch duplicate siblings', async () => { + routeSnapshot.params = { type: 'clinic', parent_id: 'the_district' }; + contactTypesService.getChildren.resolves([{ id: 'clinic' }]); + contactTypesService.get.resolves({ + create_form: 'clinic_create_form_id', + create_key: 'clinic_create_key', + }); + dbGet + .withArgs('the_district') + .resolves({ _id: 'the_district', type: 'clinic' }); + dbGet.resolves({ _id: 'clinic_create_form_id', the: 'form' }); + const form = { + validate: sinon.stub().resolves(true), + }; + formService.render.resolves(form); + + await createComponent(); + await fixture.whenStable(); + + formService.saveContact.rejects(new DuplicatesFoundError('Duplicates found', [ + { + _id: 'sib2', + name: 'Sibling2', + parent: { _id: 'parent1' }, + type: 'the_district', + reported_date: 1736845534000 + } + ])); + + await component.save(); + + expect(setEnketoSavingStatus.callCount).to.equal(2); + expect(setEnketoSavingStatus.args).to.deep.equal([[true], [false]]); + expect(component.enketoContact.formInstance.validate.callCount).to.equal(1); + expect(formService.saveContact.callCount).to.equal(1); + expect(setEnketoError.callCount).to.equal(2); + expect(component.duplicates.length).to.equal(1); + }); + }); + + describe('onNavigateDuplicate', () => { + it('should navigate to the duplicate item', async () => { + routeSnapshot.params = { id: 'the_person' }; + lineageModelGeneratorService.contact.resolves({ + doc: { + _id: 'the_person', + type: 'person', + } + }); + contactTypesService.get.resolves({ + create_form: 'person_create_form_id', + edit_form: 'person_edit_form_id', + create_key: 'person_create_key', + }); + dbGet.resolves({ _id: 'person_edit_form_id', the: 'form' }); + const form = { + validate: sinon.stub().resolves(true), + }; + formService.render.resolves(form); + + await createComponent(); + await fixture.whenStable(); + + component.onNavigateToDuplicate('my_duplicate_id'); + + expect(router.navigate.callCount).to.equal(1); + expect(router.navigate.args[0]).to.deep.equal([['/contacts', 'my_duplicate_id']]); + }); + }); + describe('onAcknowledgeChange', () => { + it('should set acknowledge to true', async () => { + routeSnapshot.params = { type: 'clinic', parent_id: 'the_district' }; + contactTypesService.getChildren.resolves([{ id: 'clinic' }]); + contactTypesService.get.resolves({ + create_form: 'clinic_create_form_id', + create_key: 'clinic_create_key', + }); + dbGet + .withArgs('the_district') + .resolves({ _id: 'the_district', type: 'clinic' }); + dbGet.resolves({ _id: 'clinic_create_form_id', the: 'form' }); + const form = { + validate: sinon.stub().resolves(true), + }; + formService.render.resolves(form); + await createComponent(); + await fixture.whenStable(); + + component.onAcknowledgeChange(true); + formService.saveContact.resolves({ docId: 'new_clinic_id' }); + await component.save(); + expect(formService.saveContact.args).to.deep.equal( + [[{ form, docId: null, type: 'clinic', xmlVersion: undefined }, true, undefined]] + ); + }); + }); + + describe('onLoadContactSummary', () => { + it('should return a contact summary', async () => { + loadContactSummary.resolves([ + { label: 'label.short_name', value: 'tp1' }, + { label: 'label.dob_type', value: 'exact' }, + ]); + component.duplicates = [ + { + _id: 'some_id', + name: 'test name', + short_name: 'tn', + date_of_birth: '1966-04-11', + dob_type: 'exact', + reported_date: '1740472311000' + } + ]; + await component.onLoadContactSummary('some_id'); + expect(component.summaryRequestInfo).to.deep.equal({ + contact_id: 'some_id', + isLoading: false, + error: undefined + }); + const sanitizedContact = loadContactSummary.getCall(0).args[0]; + expect(sanitizedContact).to.not.have.property('name'); + expect(sanitizedContact).to.not.have.property('reported_date'); + expect(component.duplicates[0]._summary).to.deep.equal([ + { label: 'label.short_name', value: 'tp1' }, + { label: 'label.dob_type', value: 'exact' }, + ]); + }); + + it('should catch summary load errors and add it to the state object for display', async () => { + loadContactSummary.throws(Error('Some error occurred during load')); + component.duplicates = [ + { + _id: 'some_id', + name: 'test name', + short_name: 'tn', + date_of_birth: '1966-04-11', + dob_type: 'exact', + reported_date: '1740472311000' + } + ]; + await component.onLoadContactSummary('some_id'); + expect(component.summaryRequestInfo).to.deep.equal({ + contact_id: 'some_id', + isLoading: false, + error: 'Unable to load summary data for contact some_id' + }); + }); + + it('should catch contact resolution errors', async () => { + loadContactSummary.resetHistory(); + component.duplicates = [ + { + _id: 'different_id', + name: 'test name', + short_name: 'tn', + date_of_birth: '1966-04-11', + dob_type: 'exact', + reported_date: '1740472311000' + } + ]; + try { + await component.onLoadContactSummary('some_id'); + } catch (e){ + expect(e).to.equal('Contact with ID some_id not found'); + } + expect(loadContactSummary.callCount).to.equal(0); + }); }); }); diff --git a/webapp/tests/karma/ts/services/deduplicate.service.spec.ts b/webapp/tests/karma/ts/services/deduplicate.service.spec.ts new file mode 100644 index 0000000000..477c311f22 --- /dev/null +++ b/webapp/tests/karma/ts/services/deduplicate.service.spec.ts @@ -0,0 +1,151 @@ +import { TestBed } from '@angular/core/testing'; +import sinon from 'sinon'; +import { expect } from 'chai'; + +import { DbService } from '@mm-services/db.service'; +import { ParseProvider } from '@mm-providers/parse.provider'; +import { XmlFormsContextUtilsService } from '@mm-services/xml-forms-context-utils.service'; +import { DeduplicateService } from '@mm-services/deduplicate.service'; + +describe('Deduplicate', () => { + let query; + let service; + + beforeEach(async () => { + query = sinon.stub(); + const dbService = { + get: () => ({ query }) + }; + + const pipesService: any = { + getPipeNameVsIsPureMap: sinon.stub().returns(new Map([['date', { pure: true }]])), + meta: sinon.stub(), + getInstance: sinon.stub(), + }; + const parserProvider = new ParseProvider(pipesService); + const xmlFormsContextUtilsService = new XmlFormsContextUtilsService(); + + TestBed.configureTestingModule({ + providers: [ + { provide: DbService, useValue: dbService }, + { provide: ParseProvider, useValue: parserProvider }, + { provide: XmlFormsContextUtilsService, useValue: xmlFormsContextUtilsService }, + ] + }); + + service = TestBed.inject(DeduplicateService); + }); + + afterEach(() => { + sinon.restore(); + }); + + describe('requestSiblings', () => { + it('should return results filtered by parent and contact type', async function () { + query.resolves({ + offset: 0, + rows: [ + { id: 'sib1', doc: { _id: 'sib1', name: 'Sibling1', parent: { _id: 'parent1' }, contact_type: 'some_type' } }, + { id: 'sib2', doc: { _id: 'sib2', name: 'Sibling2', parent: { _id: 'parent1' }, contact_type: 'some_type' } }, + ], + total_rows: 6 + }); + const siblings = await service.requestSiblings('parent1', 'some_type'); + expect(siblings.length).to.equal(2); + expect(siblings).to.deep.equal([ + { _id: 'sib1', name: 'Sibling1', parent: { _id: 'parent1' }, contact_type: 'some_type' }, + { _id: 'sib2', name: 'Sibling2', parent: { _id: 'parent1' }, contact_type: 'some_type' }, + ]); + }); + }); + + describe('extractExpression', () => { + it('should return a default expression when no object is provided', () => { + expect(service.extractExpression(undefined)).to.equal('levenshteinEq(current.name, existing.name, 3) && ' + + 'ageInYears(current.date_of_birth) === ageInYears(existing.date_of_birth)'); + }); + + it('should return the "user defined" expression', () => { + const expression = 'levenshtein("current.phone_number", "existing.phone_number")'; + expect(service.extractExpression({ + expression + })).to.equal(expression); + }); + + it('should return null when a object with the expression and disabled properties is provided', () => { + expect(service.extractExpression({ + expression: 'This should not be returned', + disabled: true, + })).to.equal(null); + }); + + it('should return a default expression when the object has no expression or disable property defined', () => { + expect(service.extractExpression({})).to.equal('levenshteinEq(current.name, existing.name, 3) && ' + + 'ageInYears(current.date_of_birth) === ageInYears(existing.date_of_birth)'); + }); + }); + + describe('getDuplicates', () => { + it('should return duplicates based on default matching', () => { + const doc = { + _id: 'new', + name: 'Test', + parent: { _id: 'parent1' }, + contact_type: 'some_type', + reported_date: 1736845534000 + }; + const siblings = [ + { + _id: 'sib1', + name: 'Test1', + parent: { _id: 'parent1' }, + contact_type: 'some_type', + reported_date: 1736845534000 + }, + { + _id: 'sib2', + name: 'Test2', + parent: { _id: 'parent1' }, + contact_type: 'some_type', + reported_date: 1736845534000 + }, + { + _id: 'sib3', + name: 'Test the things', + parent: { _id: 'parent1' }, + contact_type: 'some_type', + reported_date: 1736845534000 + }, + { + _id: 'sib4', + name: 'Testimony', + parent: { _id: 'parent1' }, + contact_type: 'some_type', + reported_date: 1736845534000 + }, + ]; + const results = service.getDuplicates( + doc, + siblings, + 'levenshteinEq(current.name, existing.name, 3)', + ); + expect(results.length).equal(2); + expect(results).to.deep.equal([ + { + _id: 'sib1', + name: 'Test1', + parent: { _id: 'parent1' }, + contact_type: 'some_type', + reported_date: 1736845534000 + }, + { + _id: 'sib2', + name: 'Test2', + parent: { _id: 'parent1' }, + contact_type: 'some_type', + reported_date: 1736845534000 + }, + ]); + }); + }); +}); diff --git a/webapp/tests/karma/ts/services/form.service.spec.ts b/webapp/tests/karma/ts/services/form.service.spec.ts index eec8501a38..6e704404a2 100644 --- a/webapp/tests/karma/ts/services/form.service.spec.ts +++ b/webapp/tests/karma/ts/services/form.service.spec.ts @@ -36,9 +36,7 @@ import { EnketoTranslationService } from '@mm-services/enketo-translation.servic import * as FileManager from '../../../../src/js/enketo/file-manager.js'; import { TargetAggregatesService } from '@mm-services/target-aggregates.service'; import { ContactViewModelGeneratorService } from '@mm-services/contact-view-model-generator.service'; - -import { ParseProvider } from '@mm-providers/parse.provider'; -import { XmlFormsContextUtilsService } from '@mm-services/xml-forms-context-utils.service'; +import { DeduplicateService } from '@mm-services/deduplicate.service'; describe('Form service', () => { // return a mock form ready for putting in #dbContent @@ -98,8 +96,8 @@ describe('Form service', () => { let extractLineageService; let targetAggregatesService; let contactViewModelGeneratorService; - let parserProvider; - let xmlFormsContextUtilsService; + let deduplicateService; + let getDuplicates; beforeEach(() => { enketoInit = sinon.stub(); @@ -170,8 +168,10 @@ describe('Form service', () => { targetAggregatesService = { getTargetDocs: sinon.stub() }; contactViewModelGeneratorService = { loadReports: sinon.stub() }; - parserProvider = sinon.stub(); - xmlFormsContextUtilsService = sinon.stub(); + const requestSiblings = sinon.stub(); + const extractExpression = sinon.stub(); + getDuplicates = sinon.stub(); + deduplicateService = { requestSiblings, extractExpression, getDuplicates }; TestBed.configureTestingModule({ providers: [ @@ -203,8 +203,7 @@ describe('Form service', () => { { provide: ExtractLineageService, useValue: extractLineageService }, { provide: TargetAggregatesService, useValue: targetAggregatesService }, { provide: ContactViewModelGeneratorService, useValue: contactViewModelGeneratorService }, - { provide: ParseProvider, useValue: parserProvider}, - { provide: XmlFormsContextUtilsService, useValue: xmlFormsContextUtilsService } + { provide: DeduplicateService, useValue: deduplicateService } ], }); @@ -254,7 +253,7 @@ describe('Form service', () => { it('renders error when user does not have associated contact', () => { UserContact.resolves(); return service - .render(new EnketoFormContext('#', 'report', { })) + .render(new EnketoFormContext('#', 'report', {})) .then(() => { expect.fail('Should throw error'); }) @@ -278,11 +277,11 @@ describe('Form service', () => { ContactSummary.resolves({ context: { pregnant: false } }); Search.resolves([{ _id: 'some_report' }]); LineageModelGenerator.contact.resolves({ lineage: [{ _id: 'some_parent' }] }); - const instanceData = { contact: { _id: '123-patient-contact'} }; + const instanceData = { contact: { _id: '123-patient-contact' } }; EnketoPrepopulationData.resolves(''); const expectedErrorTitle = `Failed during the form "myform" rendering : `; - const expectedErrorDetail = [ 'nope', 'still nope' ]; + const expectedErrorDetail = ['nope', 'still nope']; const expectedErrorMessage = expectedErrorTitle + JSON.stringify(expectedErrorDetail); enketoInit.returns(expectedErrorDetail); @@ -401,7 +400,7 @@ describe('Form service', () => { expect(xmlStr).to.equal('true'); expect(contactViewModelGeneratorService.loadReports.callCount).to.equal(1); expect(contactViewModelGeneratorService.loadReports.args[0]).to.deep.equal( - [ { doc: instanceData.contact }, [] ] + [{ doc: instanceData.contact }, []] ); expect(LineageModelGenerator.contact.callCount).to.equal(1); expect(LineageModelGenerator.contact.args[0][0]).to.equal('fffff'); @@ -524,7 +523,7 @@ describe('Form service', () => { }; ContactSummary.resolves({ context: { pregnant: true } }); Search.resolves([{ _id: 'somereport' }]); - const formContext = new EnketoFormContext('div', 'report', mockEnketoDoc('myform'), instanceData); + const formContext = new EnketoFormContext('div', 'report', mockEnketoDoc('myform'), instanceData); return service.render(formContext).then(() => { expect(LineageModelGenerator.contact.callCount).to.equal(1); expect(LineageModelGenerator.contact.args[0][0]).to.equal('fffff'); @@ -545,7 +544,7 @@ describe('Form service', () => { .onFirstCall().resolves('
first form
') .onSecondCall().resolves(VISIT_MODEL); - await service.render(new EnketoFormContext('#div', 'report', mockEnketoDoc('firstForm'))); + await service.render(new EnketoFormContext('#div', 'report', mockEnketoDoc('firstForm'))); expect(form.resetView.notCalled).to.be.true; expect(UserContact.calledOnce).to.be.true; expect(EnketoPrepopulationData.calledOnce).to.be.true; @@ -555,8 +554,8 @@ describe('Form service', () => { expect(enketoInit.calledOnce).to.be.true; expect(form.editStatus).to.be.false; expect(dbGetAttachment.calledTwice).to.be.true; - expect(dbGetAttachment.args[0]).to.have.members([ 'form:firstForm', 'form.html' ]); - expect(dbGetAttachment.args[1]).to.have.members([ 'form:firstForm', 'model.xml' ]); + expect(dbGetAttachment.args[0]).to.have.members(['form:firstForm', 'form.html']); + expect(dbGetAttachment.args[1]).to.have.members(['form:firstForm', 'model.xml']); sinon.resetHistory(); dbGetAttachment @@ -573,8 +572,8 @@ describe('Form service', () => { expect(enketoInit.calledOnce).to.be.true; expect(form.editStatus).to.be.false; expect(dbGetAttachment.calledTwice).to.be.true; - expect(dbGetAttachment.args[0]).to.have.members([ 'form:secondForm', 'form.html' ]); - expect(dbGetAttachment.args[1]).to.have.members([ 'form:secondForm', 'model.xml' ]); + expect(dbGetAttachment.args[0]).to.have.members(['form:secondForm', 'form.html']); + expect(dbGetAttachment.args[1]).to.have.members(['form:secondForm', 'model.xml']); }); it('should throw exception if fails to get user settings', fakeAsync(async () => { @@ -687,7 +686,7 @@ describe('Form service', () => { trainingCardsService.isTrainingCardForm.returns(true); trainingCardsService.getTrainingCardDocId.returns('training:user-jim:'); form.validate.resolves(true); - dbBulkDocs.callsFake(docs => Promise.resolve([ { ok: true, id: docs[0]._id, rev: '1-abc' } ])); + dbBulkDocs.callsFake(docs => Promise.resolve([{ ok: true, id: docs[0]._id, rev: '1-abc' }])); UserContact.resolves({ _id: '123', phone: '555' }); return service @@ -1081,7 +1080,7 @@ describe('Form service', () => { describe('Saving attachments', () => { it('should save attachments', async () => { - const file = { name: 'my_file', type: 'image', foo: 'bar' }; + const file = { name: 'my_file', type: 'image', foo: 'bar' }; sinon .stub(FileManager, 'getCurrentFiles') .returns([file]); @@ -1311,16 +1310,15 @@ describe('Form service', () => { let extractLineageService; let enketoTranslationService; - let parse; - beforeEach(() => { extractLineageService = { extract: sinon.stub() }; enketoTranslationService = { contactRecordToJs: sinon.stub(), }; - parse = sinon.stub(); - parserProvider = { parse }; + // eslint-disable-next-line @typescript-eslint/no-unused-vars, no-undef + getDuplicates = (doc, siblings, expression) => siblings; + deduplicateService = { ...deduplicateService, getDuplicates }; TestBed.configureTestingModule({ providers: [ @@ -1351,8 +1349,7 @@ describe('Form service', () => { { provide: TranslateService, useValue: translateService }, { provide: TrainingCardsService, useValue: trainingCardsService }, { provide: FeedbackService, useValue: feedbackService }, - { provide: ParseProvider, useValue: parserProvider}, - { provide: XmlFormsContextUtilsService, useValue: xmlFormsContextUtilsService }, + { provide: DeduplicateService, useValue: deduplicateService }, ], }); @@ -1376,7 +1373,7 @@ describe('Form service', () => { extractLineageService.extract.returns({ _id: 'abc', parent: { _id: 'def' } }); return service - .saveContact({form, docId, type}) + .saveContact({ form, docId, type }) .then(() => { assert.equal(dbGet.callCount, 1); assert.equal(dbGet.args[0][0], 'abc'); @@ -1410,7 +1407,7 @@ describe('Form service', () => { extractLineageService.extract.returns({ _id: 'abc', parent: { _id: 'def' } }); return service - .saveContact({form, docId, type}) + .saveContact({ form, docId, type }) .then(() => { assert.equal(dbGet.callCount, 1); assert.equal(dbGet.args[0][0], 'abc'); @@ -1437,12 +1434,12 @@ describe('Form service', () => { const type = 'some-contact-type'; enketoTranslationService.contactRecordToJs.returns({ - doc: { _id: 'main1', type: 'main', contact: 'NEW'}, + doc: { _id: 'main1', type: 'main', contact: 'NEW' }, siblings: { contact: { _id: 'sis1', type: 'sister', parent: 'PARENT', }, }, repeats: { - child_data: [ { _id: 'kid1', type: 'child', parent: 'PARENT', } ], + child_data: [{ _id: 'kid1', type: 'child', parent: 'PARENT', }], }, }); @@ -1454,7 +1451,7 @@ describe('Form service', () => { dbBulkDocs.resolves([]); return service - .saveContact({form, docId, type}) + .saveContact({ form, docId, type }) .then(() => { assert.isTrue(dbBulkDocs.calledOnce); @@ -1513,7 +1510,7 @@ describe('Form service', () => { clock = sinon.useFakeTimers(5000); return service - .saveContact({form, docId, type}) + .saveContact({ form, docId, type }) .then(() => { assert.equal(dbGet.callCount, 2); assert.deepEqual(dbGet.args[0], ['main1']); @@ -1562,7 +1559,7 @@ describe('Form service', () => { clock = sinon.useFakeTimers(1000); return service - .saveContact({form, docId, type}) + .saveContact({ form, docId, type }) .then(() => { assert.equal(dbGet.callCount, 1); assert.equal(dbGet.args[0][0], 'abc'); @@ -1605,80 +1602,69 @@ describe('Form service', () => { const docId = null; const type = 'some-contact-type'; - dbGet.resolves({ }); + dbGet.resolves({}); enketoTranslationService.contactRecordToJs.returns({ doc: { _id: 'main1', name: 'Main', type: 'main', parent: { _id: 'parent1' } } }); - extractLineageService.extract.returns({ _id: 'parent1'}); + extractLineageService.extract.returns({ _id: 'parent1' }); transitionsService.applyTransitions.callsFake((docs) => { docs[0].transitioned = true; return Promise.resolve(docs); }); - dbQuery.resolves({ - offset: 0, - rows: [ - { id: 'sib1', - doc: { - _id: 'sib1', - name: 'Sibling1', - parent: { _id: 'parent1' }, - type, - reported_date: 1736845534000 - } - }, - { - id: 'sib2', - doc: { - _id: 'sib2', - name: 'Sibling2', - parent: { _id: 'parent1' }, - type, - reported_date: 1736845534000 - } - }, - ], - total_rows: 2 - }); dbBulkDocs.resolves([]); clock = sinon.useFakeTimers(1000); - // eslint-disable-next-line @typescript-eslint/no-unused-vars, no-undef - parse.callsFake(() => (XmlFormsContextUtilsService, ctx) => true); + + deduplicateService.requestSiblings.resolves([{ + _id: 'sib1', + name: 'Sibling1', + parent: { _id: 'parent1' }, + type, + reported_date: 1736845534000 + }, + { + _id: 'sib2', + name: 'Sibling2', + parent: { _id: 'parent1' }, + type, + reported_date: 1736845534000 + },]); + deduplicateService.extractExpression.returns('levenshteinEq(current.name, existing.name, 3)'); + try { - await service.saveContact({form, docId, type, xmlVersion: undefined}, undefined, false); + await service.saveContact({ form, docId, type, xmlVersion: undefined }, false, undefined); // Fail the test if no error is thrown throw new Error('Expected saveContact to throw an error, but it did not.'); } catch (e) { expect(e.message).to.include('Duplicates found'); expect(e.duplicates).to.have.lengthOf(2); expect(e.duplicates).to.deep.equal([ - { - _id: 'sib1', - name: 'Sibling1', - parent: { _id: 'parent1' }, - type, - reported_date: 1736845534000 + { + _id: 'sib1', + name: 'Sibling1', + parent: { _id: 'parent1' }, + type, + reported_date: 1736845534000 }, - { - _id: 'sib2', - name: 'Sibling2', - parent: { _id: 'parent1' }, - type, - reported_date: 1736845534000 + { + _id: 'sib2', + name: 'Sibling2', + parent: { _id: 'parent1' }, + type, + reported_date: 1736845534000 } ]); } }); - it('should pass duplicate check when duplicates are acknowledged', async function () { const form = { getDataStr: () => '' }; const docId = null; const type = 'some-contact-type'; - dbGet.resolves({ }); + dbGet.resolves({}); enketoTranslationService.contactRecordToJs.returns({ doc: { _id: 'main1', name: 'Main', type: 'main', parent: { _id: 'parent1' } } }); - extractLineageService.extract.returns({ _id: 'parent1'}); + extractLineageService.extract.returns({ _id: 'parent1' }); transitionsService.applyTransitions.callsFake((docs) => { docs[0].transitioned = true; return Promise.resolve(docs); @@ -1686,23 +1672,24 @@ describe('Form service', () => { dbQuery.resolves({ offset: 0, rows: [ - { id: 'sib1', - doc: { - _id: 'sib1', - name: 'Sibling1', - parent: { _id: 'parent1' }, - type, - reported_date: 1736845534000 - } + { + id: 'sib1', + doc: { + _id: 'sib1', + name: 'Sibling1', + parent: { _id: 'parent1' }, + type, + reported_date: 1736845534000 + } }, - { - id: 'sib2', - doc: { - _id: 'sib2', - name: 'Sibling2', - parent: { _id: 'parent1' }, - type, - reported_date: 1736845534000 + { + id: 'sib2', + doc: { + _id: 'sib2', + name: 'Sibling2', + parent: { _id: 'parent1' }, + type, + reported_date: 1736845534000 } }, ], @@ -1711,32 +1698,31 @@ describe('Form service', () => { dbBulkDocs.resolves([]); clock = sinon.useFakeTimers(1000); - await service.saveContact({form, docId, type, xmlVersion: undefined}, undefined, true); + await service.saveContact({ form, docId, type, xmlVersion: undefined }, true, undefined); assert.equal(transitionsService.applyTransitions.callCount, 1); assert.deepEqual(transitionsService.applyTransitions.args[0], [[ - { + { _id: 'main1', name: 'Main', type: 'contact', contact_type: type, parent: { _id: 'parent1' }, reported_date: 1000, - contact: undefined, + contact: undefined, transitioned: true } ]]); }); - it('should pass duplicate check when record is marked as canonical', async function () { const form = { getDataStr: () => '' }; const docId = null; const type = 'some-contact-type'; - dbGet.resolves({ }); + dbGet.resolves({}); enketoTranslationService.contactRecordToJs.returns({ doc: { _id: 'main1', name: 'Main', type: 'main', parent: { _id: 'parent1' }, is_canonical: 'true' } }); - extractLineageService.extract.returns({ _id: 'parent1'}); + extractLineageService.extract.returns({ _id: 'parent1' }); transitionsService.applyTransitions.callsFake((docs) => { docs[0].transitioned = true; return Promise.resolve(docs); @@ -1744,23 +1730,24 @@ describe('Form service', () => { dbQuery.resolves({ offset: 0, rows: [ - { id: 'sib1', - doc: { - _id: 'sib1', - name: 'Sibling1', - parent: { _id: 'parent1' }, - type, - reported_date: 1736845534000 - } + { + id: 'sib1', + doc: { + _id: 'sib1', + name: 'Sibling1', + parent: { _id: 'parent1' }, + type, + reported_date: 1736845534000 + } }, - { - id: 'sib2', - doc: { - _id: 'sib2', - name: 'Sibling2', - parent: { _id: 'parent1' }, - type, - reported_date: 1736845534000 + { + id: 'sib2', + doc: { + _id: 'sib2', + name: 'Sibling2', + parent: { _id: 'parent1' }, + type, + reported_date: 1736845534000 } }, ], @@ -1769,21 +1756,64 @@ describe('Form service', () => { dbBulkDocs.resolves([]); clock = sinon.useFakeTimers(1000); - await service.saveContact({form, docId, type, xmlVersion: undefined}, undefined, false); + await service.saveContact({ form, docId, type, xmlVersion: undefined }, false, undefined); assert.equal(transitionsService.applyTransitions.callCount, 1); assert.deepEqual(transitionsService.applyTransitions.args[0], [[ - { + { _id: 'main1', name: 'Main', type: 'contact', contact_type: type, parent: { _id: 'parent1' }, reported_date: 1000, - contact: undefined, + contact: undefined, transitioned: true, is_canonical: 'true' } ]]); }); }); + + describe('load contact summary', () => { + beforeEach(() => { + service = TestBed.inject(FormService); + }); + + it('should produce a summary for the provided contact', async function () { + const contact = { + _id: '123456789', + name: 'Test person', + short_name: 'tp1', + phone_number: '+27723301855', + date_of_birth: '1966-01-11', + dob_type: 'exact' + }; + contactViewModelGeneratorService.loadReports.resolves([{ _id: 'somereport' }]); + targetAggregatesService.getTargetDocs.resolves([{ _id: 't1' }, { _id: 't2' }]); + LineageModelGenerator.contact.resolves({ lineage: [{ _id: 'someparent' }] }); + ContactSummary.resolves({ + context: { pregnant: true }, + fields: [ + { label: 'label.short_name', value: contact.short_name }, + { label: 'label.dob_type', value: contact.dob_type }, + ], + cards: [] + }); + const summary = await service.loadContactSummary(contact); + expect(ContactSummary.callCount).to.equal(1); + expect(ContactSummary.args[0][0]._id).to.equal('123456789'); + expect(contactViewModelGeneratorService.loadReports.callCount).to.equal(1); + expect(targetAggregatesService.getTargetDocs.callCount).to.equal(1); + expect(summary).to.deep.equal({ + cards: [], + context: { + pregnant: true, + }, + fields: [ + { label: 'label.short_name', value: 'tp1' }, + { label: 'label.dob_type', value: 'exact' }, + ], + }); + }); + }); }); diff --git a/webapp/tests/karma/ts/services/utils/deduplicate.spec.ts b/webapp/tests/karma/ts/services/utils/deduplicate.spec.ts deleted file mode 100644 index be5a6e89d3..0000000000 --- a/webapp/tests/karma/ts/services/utils/deduplicate.spec.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { TestBed } from '@angular/core/testing'; -import sinon from 'sinon'; -import { expect } from 'chai'; - -import { DbService } from '@mm-services/db.service'; -import { ParseProvider } from '@mm-providers/parse.provider'; -import { XmlFormsContextUtilsService } from '@mm-services/xml-forms-context-utils.service'; -import { - normalizedLevenshteinEq, - levenshteinEq, - requestSiblings, - extractExpression, - DEFAULT_CONTACT_DUPLICATE_EXPRESSION, - getDuplicates, -} from '../../../../../src/ts/services/utils/deduplicate'; - -describe('Deduplicate', () => { - let dbService; - let query; - - beforeEach(() => { - query = sinon.stub(); - dbService = { - get: () => ({ query }) - }; - - TestBed.configureTestingModule({ - providers: [ - { provide: DbService, useValue: dbService } - ] - }); - }); - - afterEach(() => { - sinon.restore(); - }); - - describe('normalizedLevenshteinEq', () => { - it('should return return a score of 3', () => { - // Score/distance / maxLength - // 3 (3 characters need to be added to make str1 = str2) / 5 (Test123 is the larger string) - // ~ 0.42857142857142855 - expect(normalizedLevenshteinEq('Test123', 'Test')).lessThanOrEqual(0.42857142857142855); - }); - }); - - describe('levenshteinEq', () => { - it('should return return a score of 3', () => { - expect(levenshteinEq('Test123', 'Test')).to.equal(3); - }); - }); - - describe('requestSiblings', () => { - it('should return results filtered by parent and contact type', async function () { - query.resolves({ - offset: 0, - rows: [ - { id: 'sib1', doc: { _id: 'sib1', name: 'Sibling1', parent: { _id: 'parent1' }, contact_type: 'some_type' } }, - { id: 'sib2', doc: { _id: 'sib2', name: 'Sibling2', parent: { _id: 'parent1' }, contact_type: 'some_type' }}, - ], - total_rows: 6 - }); - const siblings = await requestSiblings(dbService, 'parent1', 'some_type'); - expect(siblings.length).to.equal(2); - expect(siblings).to.deep.equal([ - { _id: 'sib1', name: 'Sibling1', parent: { _id: 'parent1' }, contact_type: 'some_type' }, - { _id: 'sib2', name: 'Sibling2', parent: { _id: 'parent1' }, contact_type: 'some_type' }, - ]); - }); - }); - - describe('extractExpression', () => { - it('should return a default expression when none is provided', () => { - expect(extractExpression(undefined)).to.equal(DEFAULT_CONTACT_DUPLICATE_EXPRESSION); - }); - }); - - describe('getDuplicates', () => { - let pipesService; - let parseProvider; - beforeEach(() => { - pipesService = { - getPipeNameVsIsPureMap: sinon.stub().returns(new Map([['date', { pure: true }]])), - meta: sinon.stub(), - getInstance: sinon.stub(), - }; - parseProvider = new ParseProvider(pipesService); - }); - - it('should return duplicates based on default matching', () => { - const doc = { - _id: 'new', - name: 'Test', - parent: { _id: 'parent1' }, - contact_type: 'some_type', - reported_date: 1736845534000 - }; - const siblings = [ - { - _id: 'sib1', - name: 'Test1', - parent: { _id: 'parent1' }, - contact_type: 'some_type', - reported_date: 1736845534000 - }, - { - _id: 'sib2', - name: 'Test2', - parent: { _id: 'parent1' }, - contact_type: 'some_type', - reported_date: 1736845534000 - }, - { - _id: 'sib3', - name: 'Test the things', - parent: { _id: 'parent1' }, - contact_type: 'some_type', - reported_date: 1736845534000 - }, - { - _id: 'sib4', - name: 'Testimony', - parent: { _id: 'parent1' }, - contact_type: 'some_type', - reported_date: 1736845534000 - }, - ]; - const results = getDuplicates( - doc, - siblings, - { - expression: DEFAULT_CONTACT_DUPLICATE_EXPRESSION, - parseProvider, - xmlFormsContextUtilsService: new XmlFormsContextUtilsService() - } - ); - expect(results.length).equal(2); - expect(results).to.deep.equal([ - { - _id: 'sib1', - name: 'Test1', - parent: { _id: 'parent1' }, - contact_type: 'some_type', - reported_date: 1736845534000 - }, - { - _id: 'sib2', - name: 'Test2', - parent: { _id: 'parent1' }, - contact_type: 'some_type', - reported_date: 1736845534000 - }, - ]); - }); - }); -}); diff --git a/webapp/tests/karma/ts/services/xml-forms-context-utils.service.spec.ts b/webapp/tests/karma/ts/services/xml-forms-context-utils.service.spec.ts index 0a21ca9dd7..cd9c88484e 100644 --- a/webapp/tests/karma/ts/services/xml-forms-context-utils.service.spec.ts +++ b/webapp/tests/karma/ts/services/xml-forms-context-utils.service.spec.ts @@ -120,4 +120,21 @@ describe('XmlFormsContextUtils service', () => { }); }); + + describe('Levenshtein', () => { + describe('normalizedLevenshteinEq', () => { + it('should return true for a threshold of 0.4285', () => { + // Score/distance / maxLength + // 3 (3 characters need to be added to make str1 = str2) / 5 (Test123 is the larger string) + // ~ 0.42857142857142855 + expect(service.normalizedLevenshteinEq('Test123', 'Test', 0.42857142857142855)).to.equal(true); + }); + }); + + describe('levenshteinEq', () => { + it('should return true for a threshold of 3', () => { + expect(service.levenshteinEq('Test123', 'Test', 3)).to.equal(true); + }); + }); + }); }); From 020164218a2bf7ed8ba5e7cb5b1c50720f29f787 Mon Sep 17 00:00:00 2001 From: Anro Date: Wed, 26 Feb 2025 16:58:43 +0200 Subject: [PATCH 3/3] fix: rebase chances --- .../contact-summary-content.component.ts | 12 +++++++++++- .../duplicate-info/duplicate-info.component.ts | 13 +++++++++++++ .../modules/contacts/contacts-content.component.ts | 6 +++++- .../ts/modules/contacts/contacts-edit.component.ts | 3 ++- .../contacts/contacts-content.component.spec.ts | 1 - 5 files changed, 31 insertions(+), 4 deletions(-) diff --git a/webapp/src/ts/components/contact-summary-content/contact-summary-content.component.ts b/webapp/src/ts/components/contact-summary-content/contact-summary-content.component.ts index 70bfe70e13..6a5316d5b6 100644 --- a/webapp/src/ts/components/contact-summary-content/contact-summary-content.component.ts +++ b/webapp/src/ts/components/contact-summary-content/contact-summary-content.component.ts @@ -1,8 +1,18 @@ import { Component, Input } from '@angular/core'; +import { NgIf, NgFor, LowerCasePipe } from '@angular/common'; +import { ResourceIconPipe } from '@mm-pipes/resource-icon.pipe'; +import { TranslateDirective } from '@ngx-translate/core'; @Component({ selector: 'mm-contact-summary-content', - templateUrl: './contact-summary-content.component.html' + templateUrl: './contact-summary-content.component.html', + imports: [ + NgIf, + NgFor, + LowerCasePipe, + TranslateDirective, + ResourceIconPipe, + ] }) export class ContactSummaryContentComponent { @Input() contactsLoadingSummary; diff --git a/webapp/src/ts/components/duplicate-info/duplicate-info.component.ts b/webapp/src/ts/components/duplicate-info/duplicate-info.component.ts index 406bee75a9..270a17aad1 100644 --- a/webapp/src/ts/components/duplicate-info/duplicate-info.component.ts +++ b/webapp/src/ts/components/duplicate-info/duplicate-info.component.ts @@ -1,8 +1,21 @@ import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { NgIf, NgFor, NgStyle, DatePipe } from '@angular/common'; +import { TranslatePipe } from '@ngx-translate/core'; +import { + ContactSummaryContentComponent +} from '@mm-components/contact-summary-content/contact-summary-content.component'; @Component({ selector: 'mm-duplicate-info', templateUrl: './duplicate-info.component.html', + imports: [ + NgIf, + NgFor, + NgStyle, + DatePipe, + TranslatePipe, + ContactSummaryContentComponent + ] }) export class DuplicateInfoComponent { @Input() entityType: string = ''; diff --git a/webapp/src/ts/modules/contacts/contacts-content.component.ts b/webapp/src/ts/modules/contacts/contacts-content.component.ts index b02316513c..4523fd6ac5 100644 --- a/webapp/src/ts/modules/contacts/contacts-content.component.ts +++ b/webapp/src/ts/modules/contacts/contacts-content.component.ts @@ -32,6 +32,9 @@ import { ResourceIconPipe } from '@mm-pipes/resource-icon.pipe'; import { SummaryPipe } from '@mm-pipes/message.pipe'; import { FormIconNamePipe } from '@mm-pipes/form-icon-name.pipe'; import { LocalizeNumberPipe } from '@mm-pipes/number.pipe'; +import { + ContactSummaryContentComponent +} from '@mm-components/contact-summary-content/contact-summary-content.component'; @Component({ selector: 'contacts-content', @@ -51,7 +54,8 @@ import { LocalizeNumberPipe } from '@mm-pipes/number.pipe'; ResourceIconPipe, SummaryPipe, FormIconNamePipe, - LocalizeNumberPipe + LocalizeNumberPipe, + ContactSummaryContentComponent ] }) export class ContactsContentComponent implements OnInit, OnDestroy { diff --git a/webapp/src/ts/modules/contacts/contacts-edit.component.ts b/webapp/src/ts/modules/contacts/contacts-edit.component.ts index f21b743a2d..a1ce1098ad 100644 --- a/webapp/src/ts/modules/contacts/contacts-edit.component.ts +++ b/webapp/src/ts/modules/contacts/contacts-edit.component.ts @@ -16,10 +16,11 @@ import { TranslateService } from '@mm-services/translate.service'; import { NgIf } from '@angular/common'; import { EnketoComponent } from '@mm-components/enketo/enketo.component'; import { TranslatePipe } from '@ngx-translate/core'; +import { DuplicateInfoComponent } from '@mm-components/duplicate-info/duplicate-info.component'; @Component({ templateUrl: './contacts-edit.component.html', - imports: [NgIf, EnketoComponent, TranslatePipe] + imports: [NgIf, EnketoComponent, TranslatePipe, DuplicateInfoComponent] }) export class ContactsEditComponent implements OnInit, OnDestroy, AfterViewInit { constructor( diff --git a/webapp/tests/karma/ts/modules/contacts/contacts-content.component.spec.ts b/webapp/tests/karma/ts/modules/contacts/contacts-content.component.spec.ts index e9fa9ea1ed..d6c9349d84 100644 --- a/webapp/tests/karma/ts/modules/contacts/contacts-content.component.spec.ts +++ b/webapp/tests/karma/ts/modules/contacts/contacts-content.component.spec.ts @@ -30,7 +30,6 @@ import { AuthService } from '@mm-services/auth.service'; import { MatBottomSheet } from '@angular/material/bottom-sheet'; import { MatDialog } from '@angular/material/dialog'; import { SearchTelemetryService } from '@mm-services/search-telemetry.service'; -import { ComponentsModule } from '@mm-components/components.module'; describe('Contacts content component', () => { let component: ContactsContentComponent;