diff --git a/angular.json b/angular.json index 270f38e56c..ce9d0bc8a9 100644 --- a/angular.json +++ b/angular.json @@ -23,12 +23,18 @@ "assets": [ "src/assets", "src/favicon.ico", - "src/manifest.json" + "src/manifest.json", + { + "glob": "**/*", + "input": "node_modules/leaflet/dist/images/", + "output": "./assets" + } ], "styles": [ "src/styles/styles.scss", "src/styles/themes/ndb-theme.scss", - "node_modules/flag-icons/css/flag-icons.min.css" + "node_modules/flag-icons/css/flag-icons.min.css", + "node_modules/leaflet/dist/leaflet.css" ], "vendorChunk": true, "extractLicenses": false, diff --git a/build/Dockerfile b/build/Dockerfile index a73d5aa053..d856a23226 100644 --- a/build/Dockerfile +++ b/build/Dockerfile @@ -68,6 +68,8 @@ COPY --from=builder /app/dist/ /usr/share/nginx/html ENV PORT=80 # The url to the CouchDB database ENV COUCHDB_URL="http://localhost" +# The url to a nominatim instance, see https://nominatim.org/ +ENV NOMINATIM_URL="https://nominatim.openstreetmap.org" # variables are inserted into the nginx config -CMD envsubst '$$PORT $$COUCHDB_URL' < /etc/nginx/templates/default.conf > /etc/nginx/conf.d/default.conf &&\ +CMD envsubst '$$PORT $$COUCHDB_URL $$NOMINATIM_URL' < /etc/nginx/templates/default.conf > /etc/nginx/conf.d/default.conf &&\ nginx -g 'daemon off;' diff --git a/build/default.conf b/build/default.conf index 835f7d8f38..4f53c1a3e2 100644 --- a/build/default.conf +++ b/build/default.conf @@ -42,5 +42,15 @@ server { proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Ssl on; } + + location ^~ /nominatim { + rewrite /nominatim/(.*) /$1 break; + proxy_pass ${NOMINATIM_URL}; + proxy_redirect off; + proxy_buffering off; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Ssl on; + } } diff --git a/e2e/integration/MarkingChildAsDropout.cy.ts b/e2e/integration/MarkingChildAsDropout.cy.ts index a1a889d061..1c7e516d5f 100644 --- a/e2e/integration/MarkingChildAsDropout.cy.ts +++ b/e2e/integration/MarkingChildAsDropout.cy.ts @@ -11,19 +11,21 @@ describe("Scenario: Marking a child as dropout - E2E test", function () { cy.contains("Dropout").click(); cy.get("#mat-tab-label-0-7").click(); // click on button with the content "Edit" in Dropout menu. - cy.contains("span", "Edit").should("be.visible").click(); + cy.get(".form-buttons-wrapper:visible").contains("button", "Edit").click(); // select today as the dropout date (which is initially marked as active) - cy.get(".mat-datepicker-toggle-default-icon").click(); - cy.get(".mat-calendar-body-active").click(); + cy.get(".mat-datepicker-toggle-default-icon:visible").click(); + cy.get(".mat-calendar-body-active:visible").click(); // click on button with the content "Save" - cy.contains("span", "Save").should("be.visible").click(); + cy.get(".form-buttons-wrapper:visible").contains("button", "Save").click(); }); it("THEN I should not see this child in the list of all children at first", function () { // click on "Children" menu in navigation cy.get('[ng-reflect-angulartics-label="Children"]').click(); // type to the input "Filter" the name of child - cy.get('[data-placeholder="e.g. name, age"]').type(this.childName); + cy.get('[data-placeholder="e.g. name, age"]').type(this.childName, { + force: true, + }); // find at this table the name of child and it should not exist cy.get("table").contains(this.childName.trim()).should("not.exist"); }); diff --git a/package-lock.json b/package-lock.json index 0f55d10835..c98b23f408 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39,6 +39,7 @@ "hammerjs": "^2.0.8", "json-query": "^2.2.2", "keycloak-js": "^20.0.1", + "leaflet": "^1.9.3", "lodash-es": "^4.17.21", "md5": "^2.3.0", "moment": "^2.29.4", @@ -82,6 +83,7 @@ "@types/hammerjs": "^2.0.41", "@types/jasmine": "~4.3.1", "@types/json-query": "^2.2.3", + "@types/leaflet": "^1.9.0", "@types/lodash-es": "^4.17.6", "@types/md5": "^2.3.2", "@types/node": "^16.0.0", @@ -10562,6 +10564,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/geojson": { + "version": "7946.0.10", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.10.tgz", + "integrity": "sha512-Nmh0K3iWQJzniTuPRcJn5hxXkfB1T1pgB89SBig5PlJQU5yocazeu4jATJlaA0GYFKWMqDdvYemoSnF2pXgLVA==", + "dev": true + }, "node_modules/@types/glob": { "version": "8.0.0", "dev": true, @@ -10653,6 +10661,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/leaflet": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.0.tgz", + "integrity": "sha512-7LeOSj7EloC5UcyOMo+1kc3S1UT3MjJxwqsMT1d2PTyvQz53w0Y0oSSk9nwZnOZubCmBvpSNGceucxiq+ZPEUw==", + "dev": true, + "dependencies": { + "@types/geojson": "*" + } + }, "node_modules/@types/lodash": { "version": "4.14.190", "dev": true, @@ -21389,6 +21406,11 @@ "yarn": ">=1.0.0" } }, + "node_modules/leaflet": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.3.tgz", + "integrity": "sha512-iB2cR9vAkDOu5l3HAay2obcUHZ7xwUBBjph8+PGtmW/2lYhbLizWtG7nTeYht36WfOslixQF9D/uSIzhZgGMfQ==" + }, "node_modules/less": { "version": "4.1.3", "dev": true, @@ -38559,6 +38581,12 @@ "version": "2.0.5", "dev": true }, + "@types/geojson": { + "version": "7946.0.10", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.10.tgz", + "integrity": "sha512-Nmh0K3iWQJzniTuPRcJn5hxXkfB1T1pgB89SBig5PlJQU5yocazeu4jATJlaA0GYFKWMqDdvYemoSnF2pXgLVA==", + "dev": true + }, "@types/glob": { "version": "8.0.0", "dev": true, @@ -38639,6 +38667,15 @@ "version": "0.0.29", "dev": true }, + "@types/leaflet": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.0.tgz", + "integrity": "sha512-7LeOSj7EloC5UcyOMo+1kc3S1UT3MjJxwqsMT1d2PTyvQz53w0Y0oSSk9nwZnOZubCmBvpSNGceucxiq+ZPEUw==", + "dev": true, + "requires": { + "@types/geojson": "*" + } + }, "@types/lodash": { "version": "4.14.190", "dev": true @@ -46023,6 +46060,11 @@ "dotenv-expand": "^5.1.0" } }, + "leaflet": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.3.tgz", + "integrity": "sha512-iB2cR9vAkDOu5l3HAay2obcUHZ7xwUBBjph8+PGtmW/2lYhbLizWtG7nTeYht36WfOslixQF9D/uSIzhZgGMfQ==" + }, "less": { "version": "4.1.3", "dev": true, diff --git a/package.json b/package.json index 72b418c88f..f0a24eda21 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "hammerjs": "^2.0.8", "json-query": "^2.2.2", "keycloak-js": "^20.0.1", + "leaflet": "^1.9.3", "lodash-es": "^4.17.21", "md5": "^2.3.0", "moment": "^2.29.4", @@ -92,6 +93,7 @@ "@types/hammerjs": "^2.0.41", "@types/jasmine": "~4.3.1", "@types/json-query": "^2.2.3", + "@types/leaflet": "^1.9.0", "@types/lodash-es": "^4.17.6", "@types/md5": "^2.3.2", "@types/node": "^16.0.0", diff --git a/proxy.conf.json b/proxy.conf.json index 6c0c5010bc..ed04c094ea 100644 --- a/proxy.conf.json +++ b/proxy.conf.json @@ -7,5 +7,13 @@ "pathRewrite": { "/db": "" } + }, + "/nominatim": { + "target": "https://nominatim.openstreetmap.org", + "secure": true, + "changeOrigin": true, + "pathRewrite": { + "/nominatim": "" + } } } diff --git a/src/app/app.module.ts b/src/app/app.module.ts index c4b1cbca24..b82f5c6e3b 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -89,6 +89,7 @@ import { } from "./core/language/date-adapter-with-formatting"; import { FileModule } from "./features/file/file.module"; import { ConfigSetupModule } from "./core/config-setup/config-setup.module"; +import { LocationModule } from "./features/location/location.module"; import { MatchingEntitiesModule } from "./features/matching-entities/matching-entities.module"; /** @@ -167,6 +168,7 @@ import { MatchingEntitiesModule } from "./features/matching-entities/matching-en HistoricalDataModule, SupportModule, DatabaseModule, + LocationModule, MatchingEntitiesModule, ], providers: [ diff --git a/src/app/child-dev-project/children/demo-data-generators/demo-child-generator.service.ts b/src/app/child-dev-project/children/demo-data-generators/demo-child-generator.service.ts index d641105b9e..45120d5700 100644 --- a/src/app/child-dev-project/children/demo-data-generators/demo-child-generator.service.ts +++ b/src/app/child-dev-project/children/demo-data-generators/demo-child-generator.service.ts @@ -48,6 +48,8 @@ export class DemoChildGenerator extends DemoDataGenerator { child.admissionDate = faker.date.past(calculateAge(child.dateOfBirth) - 4); + child["address"] = faker.geoAddress(); + if (faker.datatype.number(100) > 90) { DemoChildGenerator.makeChildDropout(child); } diff --git a/src/app/child-dev-project/schools/demo-school-generator.service.ts b/src/app/child-dev-project/schools/demo-school-generator.service.ts index ac93898bbb..f2e8b6a698 100644 --- a/src/app/child-dev-project/schools/demo-school-generator.service.ts +++ b/src/app/child-dev-project/schools/demo-school-generator.service.ts @@ -48,7 +48,6 @@ export class DemoSchoolGenerator extends DemoDataGenerator { schoolNameWithType, schoolNameWithLanguage, ]); - school["address"] = faker.address.streetAddress(); school["phone"] = faker.phone.number(); school["privateSchool"] = faker.datatype.boolean(); school["timing"] = faker.helpers.arrayElement([ @@ -57,6 +56,8 @@ export class DemoSchoolGenerator extends DemoDataGenerator { $localize`:School demo timing:6:30-11:00 and 11:30-16:00`, ]); + school["address"] = faker.geoAddress(); + data.push(school); } return data; diff --git a/src/app/core/config/config-fix.ts b/src/app/core/config/config-fix.ts index 4ccf4d6083..be27a94606 100644 --- a/src/app/core/config/config-fix.ts +++ b/src/app/core/config/config-fix.ts @@ -674,16 +674,18 @@ export const defaultJsonConfig = { ["name", "name"], ["motherTongue", "language"], ["address", "address"], + [undefined, "distance"] ], rightSide: { entityType: School.ENTITY_TYPE, filters: [{ "id": "language" }] }, + showMap: ["address", "address"], onMatch: { newEntityType: ChildSchoolRelation.ENTITY_TYPE, newEntityMatchPropertyLeft: "childId", newEntityMatchPropertyRight: "schoolId", - columnsToReview: ["start", "schoolClass", "childId", "schoolId" ] + columnsToReview: ["start", "schoolClass", "childId", "schoolId"] } } } @@ -962,7 +964,7 @@ export const defaultJsonConfig = { { "name": "address", "schema": { - dataType: "string", + dataType: "location", label: $localize`:Label for the address of a child:Address` } }, @@ -1030,7 +1032,7 @@ export const defaultJsonConfig = { { "name": "address", "schema": { - dataType: "string", + dataType: "location", label: $localize`:Label for the address of a school:Address` } }, @@ -1124,7 +1126,7 @@ export const defaultJsonConfig = { ["name", "name"], ["motherTongue", "language"], ["address", "address"], - [null, "privateSchool"], + ["distance", "privateSchool"], ], rightSide: { entityType: School.ENTITY_TYPE, @@ -1132,11 +1134,12 @@ export const defaultJsonConfig = { filters: [{ "id": "language" }], }, leftSide: { entityType: Child.ENTITY_TYPE }, + showMap: ["address", "address"], onMatch: { newEntityType: ChildSchoolRelation.ENTITY_TYPE, newEntityMatchPropertyLeft: "childId", newEntityMatchPropertyRight: "schoolId", - columnsToReview: ["start", "end", "result", "childId", "schoolId" ] + columnsToReview: ["start", "end", "result", "childId", "schoolId"] } } } diff --git a/src/app/core/demo-data/faker.ts b/src/app/core/demo-data/faker.ts index e8c927e956..ee4dfef483 100644 --- a/src/app/core/demo-data/faker.ts +++ b/src/app/core/demo-data/faker.ts @@ -1,4 +1,5 @@ import { faker as originalFaker } from "@faker-js/faker/locale/en_IND"; +import { GeoResult } from "../../features/location/geo.service"; /** * Extension of faker.js implementing additional data generation methods. */ @@ -43,6 +44,17 @@ class CustomFaker { return date; } } + + geoAddress(): GeoResult { + const coordinates = faker.address.nearbyGPSCoordinate([ + 52.4790412, 13.4319106, + ]); + return { + lat: Number.parseFloat(coordinates[0]), + lon: Number.parseFloat(coordinates[1]), + display_name: faker.address.streetAddress(true), + } as GeoResult; + } } /** diff --git a/src/app/core/entity-components/entity-details/entity-details.component.html b/src/app/core/entity-components/entity-details/entity-details.component.html index c1db1b0039..4d232f817a 100644 --- a/src/app/core/entity-components/entity-details/entity-details.component.html +++ b/src/app/core/entity-components/entity-details/entity-details.component.html @@ -57,34 +57,37 @@ - + - - {{ panelConfig.title }} - + + {{ panelConfig.title }} + + + +
+ +
-
- -
+
+

+ {{ componentConfig.title }} +

+ +
+
+
-
-

- {{ componentConfig.title }} -

- -
-
diff --git a/src/app/core/entity-components/entity-list/EntityListConfig.ts b/src/app/core/entity-components/entity-list/EntityListConfig.ts index 241851b56a..0cbaba5064 100644 --- a/src/app/core/entity-components/entity-list/EntityListConfig.ts +++ b/src/app/core/entity-components/entity-list/EntityListConfig.ts @@ -85,7 +85,7 @@ export interface ConfigurableEnumFilterConfig extends FilterConfig { enumId: string; } -export interface ViewPropertyConfig { +export interface ViewPropertyConfig { /** * The entity which is being displayed, this should only be used if `value` does not contain enough information */ @@ -101,7 +101,7 @@ export interface ViewPropertyConfig { /** * Further configuration that will be passed to the final component */ - config?: any; + config?: T; /** * A tooltip that describes this property in more detail */ diff --git a/src/app/core/entity-components/entity-utils/entity-property-view/entity-property-view.component.html b/src/app/core/entity-components/entity-utils/entity-property-view/entity-property-view.component.html index 6ae80cf3b3..4baa85a25c 100644 --- a/src/app/core/entity-components/entity-utils/entity-property-view/entity-property-view.component.html +++ b/src/app/core/entity-components/entity-utils/entity-property-view/entity-property-view.component.html @@ -6,9 +6,10 @@ [appDynamicComponent]="{ component: component, config: { - value: entity[property], - id: property, - entity: entity + value: entity[propertyName], + id: propertyName, + entity: entity, + config: additional } }" > diff --git a/src/app/core/entity-components/entity-utils/entity-property-view/entity-property-view.component.spec.ts b/src/app/core/entity-components/entity-utils/entity-property-view/entity-property-view.component.spec.ts index c79d1ba079..8ac397d031 100644 --- a/src/app/core/entity-components/entity-utils/entity-property-view/entity-property-view.component.spec.ts +++ b/src/app/core/entity-components/entity-utils/entity-property-view/entity-property-view.component.spec.ts @@ -57,4 +57,19 @@ describe("EntityPropertyViewComponent", () => { expect(component.label).toBe(Child.schema.get(testProperty).label); }); + + it("should support object as property config", () => { + component.property = { + id: "testId", + label: "Test Label", + view: "DisplayText", + additional: "Some additional information", + }; + component.ngOnInit(); + + expect(component.label).toBe(component.property.label); + expect(component.propertyName).toBe(component.property.id); + expect(component.component).toBe(component.property.view); + expect(component.additional).toBe(component.property.additional); + }); }); diff --git a/src/app/core/entity-components/entity-utils/entity-property-view/entity-property-view.component.ts b/src/app/core/entity-components/entity-utils/entity-property-view/entity-property-view.component.ts index 6bf85b1721..401699cd60 100644 --- a/src/app/core/entity-components/entity-utils/entity-property-view/entity-property-view.component.ts +++ b/src/app/core/entity-components/entity-utils/entity-property-view/entity-property-view.component.ts @@ -6,6 +6,7 @@ import { } from "@angular/core"; import { Entity } from "../../../entity/model/entity"; import { EntitySchemaService } from "../../../entity/schema/entity-schema.service"; +import { ColumnConfig } from "../../entity-subrecord/entity-subrecord/entity-subrecord-config"; @Component({ selector: "app-entity-property-view", @@ -17,7 +18,8 @@ export class EntityPropertyViewComponent implements OnInit { @Input() entity: E; - @Input() property: string; + @Input() property: ColumnConfig; + propertyName: string; /** * (optional) component to be used to display this value. @@ -26,17 +28,25 @@ export class EntityPropertyViewComponent @Input() component?: string; @Input() showLabel: boolean = false; + + additional: any; label: string; constructor(private schemaService: EntitySchemaService) {} ngOnInit() { - if (!this.component) { - this.component = this.schemaService.getComponent( - this.entity.getSchema().get(this.property) - ); + if (typeof this.property === "string") { + const schema = this.entity.getSchema().get(this.property); + if (!this.component) { + this.component = this.schemaService.getComponent(schema); + } + this.label = schema.label ?? this.property; + this.propertyName = this.property; + } else { + this.component = this.property.view; + this.additional = this.property.additional; + this.label = this.property.label; + this.propertyName = this.property.id; } - this.label = - this.entity.getSchema().get(this.property).label ?? this.property; } } diff --git a/src/app/core/entity-components/entity-utils/entity-utils.module.ts b/src/app/core/entity-components/entity-utils/entity-utils.module.ts index 8a2430df71..519e3ff2f5 100644 --- a/src/app/core/entity-components/entity-utils/entity-utils.module.ts +++ b/src/app/core/entity-components/entity-utils/entity-utils.module.ts @@ -63,7 +63,7 @@ import { EntityPropertyViewComponent } from "./entity-property-view/entity-prope FontAwesomeModule, MatButtonModule, ], - exports: [EntityPropertyViewComponent], + exports: [EntityPropertyViewComponent, ReadonlyFunctionComponent], }) export class EntityUtilsModule { static dynamicComponents = [ diff --git a/src/app/core/entity-components/entity-utils/view-components/readonly-function/readonly-function.component.ts b/src/app/core/entity-components/entity-utils/view-components/readonly-function/readonly-function.component.ts index 74880e676c..8124b1c959 100644 --- a/src/app/core/entity-components/entity-utils/view-components/readonly-function/readonly-function.component.ts +++ b/src/app/core/entity-components/entity-utils/view-components/readonly-function/readonly-function.component.ts @@ -11,6 +11,7 @@ import { DynamicComponent } from "../../../../view/dynamic-components/dynamic-co }) export class ReadonlyFunctionComponent extends ViewDirective { @Input() displayFunction: (entity: Entity) => any; + onInitFromDynamicConfig(config: ViewPropertyConfig) { super.onInitFromDynamicConfig(config); this.displayFunction = config.config; diff --git a/src/app/features/location/coordinates.ts b/src/app/features/location/coordinates.ts new file mode 100644 index 0000000000..3d0fc793b2 --- /dev/null +++ b/src/app/features/location/coordinates.ts @@ -0,0 +1,4 @@ +export interface Coordinates { + lat: number; + lon: number; +} diff --git a/src/app/features/location/edit-location/edit-location.component.html b/src/app/features/location/edit-location/edit-location.component.html new file mode 100644 index 0000000000..eab4b14718 --- /dev/null +++ b/src/app/features/location/edit-location/edit-location.component.html @@ -0,0 +1,31 @@ + + + {{ label }} + + + Map data from OpenStreetMap + + + + + Loading results... + Location not found + + {{option.display_name}} + + + diff --git a/src/app/features/location/edit-location/edit-location.component.spec.ts b/src/app/features/location/edit-location/edit-location.component.spec.ts new file mode 100644 index 0000000000..79f19a6830 --- /dev/null +++ b/src/app/features/location/edit-location/edit-location.component.spec.ts @@ -0,0 +1,179 @@ +import { + ComponentFixture, + fakeAsync, + TestBed, + tick, +} from "@angular/core/testing"; + +import { EditLocationComponent } from "./edit-location.component"; +import { LocationModule } from "../location.module"; +import { MockedTestingModule } from "../../../utils/mocked-testing.module"; +import { setupEditComponent } from "../../../core/entity-components/entity-utils/dynamic-form-components/edit-component.spec"; +import { GeoResult, GeoService } from "../geo.service"; +import { of, Subject } from "rxjs"; +import { HarnessLoader, TestElement } from "@angular/cdk/testing"; +import { TestbedHarnessEnvironment } from "@angular/cdk/testing/testbed"; +import { MatInputHarness } from "@angular/material/input/testing"; +import { MatButtonHarness } from "@angular/material/button/testing"; +import { MatDialog } from "@angular/material/dialog"; +import { Coordinates } from "../coordinates"; +import { MapPopupConfig } from "../map-popup/map-popup.component"; + +describe("EditLocationComponent", () => { + let component: EditLocationComponent; + let fixture: ComponentFixture; + let mockGeoService: jasmine.SpyObj; + let mockDialog: jasmine.SpyObj; + let loader: HarnessLoader; + + beforeEach(async () => { + mockGeoService = jasmine.createSpyObj(["lookup", "reverseLookup"]); + mockGeoService.lookup.and.returnValue(of([])); + mockDialog = jasmine.createSpyObj(["open"]); + await TestBed.configureTestingModule({ + imports: [LocationModule, MockedTestingModule.withState()], + providers: [ + { provide: GeoService, useValue: mockGeoService }, + { provide: MatDialog, useValue: mockDialog }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(EditLocationComponent); + loader = TestbedHarnessEnvironment.loader(fixture); + component = fixture.componentInstance; + setupEditComponent(component); + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); + + it("should only lookup results after 1s of not typing", fakeAsync(async () => { + const location: GeoResult = { lat: 0, lon: 0, display_name: "testRes" }; + mockGeoService.lookup.and.returnValue(of([location])); + let options; + component.filteredOptions.subscribe((res) => (options = res)); + const inputElement = await loader + .getHarness(MatInputHarness) + .then((el) => el.host()); + + await inputElement.sendKeys("input 1"); + expect(mockGeoService.lookup).not.toHaveBeenCalled(); + expect(component.loading).toBeFalse(); + + tick(2000); + expect(mockGeoService.lookup).not.toHaveBeenCalled(); + expect(component.loading).toBeTrue(); + await inputElement.clear(); + await inputElement.sendKeys("input 2"); + + tick(1200); + expect(mockGeoService.lookup).not.toHaveBeenCalled(); + expect(component.loading).toBeTrue(); + expect(options).toBeUndefined(); + + tick(2000); + expect(mockGeoService.lookup).toHaveBeenCalledWith("input 2"); + expect(mockGeoService.lookup).not.toHaveBeenCalledWith("input 1"); + expect(component.loading).toBeFalse(); + expect(options).toEqual([location]); + })); + + it("should not call lookup service for trivial inputs", fakeAsync(async () => { + const inputElement = await loader + .getHarness(MatInputHarness) + .then((el) => el.host()); + + // empty input + await expectLookup(" ", false, inputElement); + + // object (as created by autocomplete) + await expectLookup("[object Object]", false, inputElement); + + // same search term as last lookup + await expectLookup("search term", true, inputElement); + mockGeoService.lookup.calls.reset(); + await expectLookup("search term", false, inputElement); + + // value that is already set on the form + const display_name = "already entered location"; + component.formControl.setValue({ display_name } as any); + fixture.detectChanges(); + await expectLookup(display_name, false, inputElement); + })); + + it("should reset form and input when clicking x", async () => { + // First button is cancel button + const clearButton = (await loader.getAllHarnesses(MatButtonHarness))[0]; + const input = await loader.getHarness(MatInputHarness); + component.formControl.setValue({ display_name: "some value" } as any); + await expectAsync(input.getValue()).toBeResolvedTo("some value"); + + await clearButton.click(); + + expect(component.formControl.value).toBeNull(); + await expectAsync(input.getValue()).toBeResolvedTo(""); + }); + + xit("should reset input if nothing was clicked on", async () => { + // test only works headless or if browser is focused + const initial = { display_name: "initial value" } as GeoResult; + component.formControl.setValue(initial); + const input = await loader.getHarness(MatInputHarness); + await expectAsync(input.getValue()).toBeResolvedTo(initial.display_name); + + await input.setValue("some value"); + await expectAsync(input.getValue()).toBeResolvedTo("some value"); + + await input.blur(); + await expectAsync(input.getValue()).toBeResolvedTo(initial.display_name); + }); + + it("should update form if value is selected", async () => { + const input = await loader.getHarness(MatInputHarness); + const selected = { display_name: "selected" } as GeoResult; + + component.selectLocation(selected); + + await expectAsync(input.getValue()).toBeResolvedTo(selected.display_name); + expect(component.formControl).toHaveValue(selected); + }); + + it("should open map and reverse lookup last result", () => { + const location: Coordinates = { lat: 1, lon: 2 }; + const closeSubject = new Subject(); + mockDialog.open.and.returnValue({ afterClosed: () => closeSubject } as any); + const fullLocation = { display_name: "lookup result", ...location }; + mockGeoService.reverseLookup.and.returnValue(of(fullLocation)); + + component.openMap(); + + const dialogData: MapPopupConfig = + mockDialog.open.calls.mostRecent().args[1].data; + dialogData.mapClick.next(location); + + expect(mockGeoService.reverseLookup).not.toHaveBeenCalledWith(location); + expect(component.formControl).not.toHaveValue(fullLocation); + + closeSubject.next(undefined); + + expect(mockGeoService.reverseLookup).toHaveBeenCalledWith(location); + expect(component.formControl).toHaveValue(fullLocation); + }); + + async function expectLookup( + searchTerm: string, + lookupCalled: boolean, + input: TestElement + ) { + await input.clear(); + await input.sendKeys(searchTerm); + tick(3200); + if (lookupCalled) { + expect(mockGeoService.lookup).toHaveBeenCalled(); + } else { + expect(mockGeoService.lookup).not.toHaveBeenCalled(); + } + } +}); diff --git a/src/app/features/location/edit-location/edit-location.component.ts b/src/app/features/location/edit-location/edit-location.component.ts new file mode 100644 index 0000000000..2131e53d7f --- /dev/null +++ b/src/app/features/location/edit-location/edit-location.component.ts @@ -0,0 +1,115 @@ +import { Component, ElementRef, ViewChild } from "@angular/core"; +import { DynamicComponent } from "../../../core/view/dynamic-components/dynamic-component.decorator"; +import { + EditComponent, + EditPropertyConfig, +} from "../../../core/entity-components/entity-utils/dynamic-form-components/edit-component"; +import { BehaviorSubject, concatMap, of, Subject } from "rxjs"; +import { catchError, debounceTime, filter, map, tap } from "rxjs/operators"; +import { MatDialog } from "@angular/material/dialog"; +import { + MapPopupComponent, + MapPopupConfig, +} from "../map-popup/map-popup.component"; +import { GeoResult, GeoService } from "../geo.service"; +import { Coordinates } from "../coordinates"; + +@DynamicComponent("EditLocation") +@Component({ + selector: "app-edit-location", + templateUrl: "./edit-location.component.html", +}) +export class EditLocationComponent extends EditComponent { + filteredOptions = new Subject(); + loading = false; + nothingFound = false; + + @ViewChild("inputElement") private inputElem: ElementRef; + private inputStream = new Subject(); + private lastSearch: string; + + constructor(private location: GeoService, private dialog: MatDialog) { + super(); + } + + onInitFromDynamicConfig(config: EditPropertyConfig) { + super.onInitFromDynamicConfig(config); + this.inputStream + .pipe( + debounceTime(200), + map((input) => input.trim()), + filter((input) => this.isRelevantInput(input)), + tap(() => (this.loading = true)), + debounceTime(3000), + concatMap((res) => this.getGeoLookupResult(res)) + ) + .subscribe((res) => this.filteredOptions.next(res)); + } + + private isRelevantInput(input: string): boolean { + return ( + !!input && + input.length > 3 && + input.localeCompare("[object Object]") !== 0 && + input.localeCompare(this.lastSearch) !== 0 && + input.localeCompare(this.formControl.value?.display_name) !== 0 + ); + } + + selectLocation(selected: GeoResult) { + this.formControl.setValue(selected); + this.filteredOptions.next([]); + } + + triggerInputUpdate() { + this.nothingFound = false; + this.inputStream.next(this.inputElem.nativeElement.value); + } + + clearInput() { + this.formControl.setValue(null); + } + + private getGeoLookupResult(searchTerm) { + return this.location.lookup(searchTerm).pipe( + tap((res) => { + this.lastSearch = searchTerm; + this.loading = false; + this.nothingFound = res.length === 0; + }) + ); + } + + openMap() { + const marked = new BehaviorSubject([this.formControl.value]); + const mapClick = new Subject(); + mapClick.subscribe((res) => marked.next([res])); + const ref = this.dialog.open(MapPopupComponent, { + width: "90%", + data: { + marked, + mapClick, + disabled: this.formControl.disabled, + } as MapPopupConfig, + }); + ref + .afterClosed() + .pipe(concatMap(() => this.lookupCoordinates(marked.value[0]))) + // TODO maybe remove name of building (e.g. CRCLR House) + .subscribe((res) => this.formControl.setValue(res)); + } + + private lookupCoordinates(coords: Coordinates) { + if (!coords) { + return undefined; + } + const fallback = { + display_name: `${coords.lat} - ${coords.lon}`, + ...coords, + }; + return this.location.reverseLookup(coords).pipe( + map((res) => (res["error"] ? fallback : res)), + catchError(() => of(fallback)) + ); + } +} diff --git a/src/app/features/location/edit-location/edit-location.stories.ts b/src/app/features/location/edit-location/edit-location.stories.ts new file mode 100644 index 0000000000..52a1016c26 --- /dev/null +++ b/src/app/features/location/edit-location/edit-location.stories.ts @@ -0,0 +1,59 @@ +import { moduleMetadata } from "@storybook/angular"; +import { StorybookBaseModule } from "../../../utils/storybook-base.module"; +import { Meta, Story } from "@storybook/angular/types-6-0"; +import { LocationModule } from "../location.module"; +import { EntitySchemaService } from "../../../core/entity/schema/entity-schema.service"; +import { EntityFormComponent } from "../../../core/entity-components/entity-form/entity-form/entity-form.component"; +import { EntityFormModule } from "../../../core/entity-components/entity-form/entity-form.module"; +import { DatabaseEntity } from "../../../core/entity/database-entity.decorator"; +import { Entity } from "../../../core/entity/model/entity"; +import { DatabaseField } from "../../../core/entity/database-field.decorator"; +import { EntityMapperService } from "../../../core/entity/entity-mapper.service"; +import { mockEntityMapper } from "../../../core/entity/mock-entity-mapper-service"; +import { AlertsModule } from "../../../core/alerts/alerts.module"; +import { HttpClientModule } from "@angular/common/http"; +import { ConfirmationDialogService } from "../../../core/confirmation-dialog/confirmation-dialog.service"; + +export default { + title: "Features/Location/EditLocation", + component: EntityFormComponent, + decorators: [ + moduleMetadata({ + imports: [ + LocationModule, + StorybookBaseModule, + EntityFormModule, + AlertsModule, + HttpClientModule, + ], + providers: [ + ConfirmationDialogService, + EntitySchemaService, + { provide: EntityMapperService, useValue: mockEntityMapper() }, + ], + }), + ], + parameters: { + controls: { + exclude: ["_columns"], + }, + }, +} as Meta; + +@DatabaseEntity("LocationTest") +class LocationTest extends Entity { + @DatabaseField({ dataType: "location", label: "Location" }) + location: any; +} + +const Template: Story = (args: EntityFormComponent) => ({ + component: EntityFormComponent, + props: args, +}); + +export const Primary = Template.bind({}); +Primary.args = { + columns: [["location"]], + entity: new LocationTest(), + editing: true, +}; diff --git a/src/app/features/location/geo.service.spec.ts b/src/app/features/location/geo.service.spec.ts new file mode 100644 index 0000000000..d113199b61 --- /dev/null +++ b/src/app/features/location/geo.service.spec.ts @@ -0,0 +1,68 @@ +import { TestBed } from "@angular/core/testing"; + +import { GeoService } from "./geo.service"; +import { AnalyticsService } from "../../core/analytics/analytics.service"; +import { ConfigService } from "../../core/config/config.service"; +import { of, Subject } from "rxjs"; +import { HttpClient } from "@angular/common/http"; +import { environment } from "../../../environments/environment"; + +describe("GeoService", () => { + let service: GeoService; + let mockAnalytics: jasmine.SpyObj; + let mockConfigService: jasmine.SpyObj; + let configUpdates = new Subject(); + let mockHttp: jasmine.SpyObj; + + beforeEach(() => { + environment.email = "some@mail.com"; + mockHttp = jasmine.createSpyObj(["get"]); + mockHttp.get.and.returnValue(of(undefined)); + mockConfigService = jasmine.createSpyObj(["getConfig"], { configUpdates }); + mockAnalytics = jasmine.createSpyObj(["eventTrack"]); + TestBed.configureTestingModule({ + providers: [ + { provide: AnalyticsService, useValue: mockAnalytics }, + { provide: ConfigService, useValue: mockConfigService }, + { provide: HttpClient, useValue: mockHttp }, + ], + }); + service = TestBed.inject(GeoService); + }); + + it("should be created", () => { + expect(service).toBeTruthy(); + }); + + it("should use countrycode from config and email from app config", () => { + const countrycodes = "de,en"; + mockConfigService.getConfig.and.returnValue({ countrycodes }); + configUpdates.next(undefined); + + service.lookup("someSearch").subscribe(); + expect(mockHttp.get).toHaveBeenCalledWith("/nominatim/search", { + params: { + q: "someSearch", + format: "json", + countrycodes, + email: "some@mail.com", + }, + }); + }); + + it("should track requests in analytics service", () => { + const searchTerm = "mySearchTerm"; + service.lookup(searchTerm).subscribe(); + expect(mockAnalytics.eventTrack).toHaveBeenCalledWith("lookup_executed", { + category: "Map", + value: searchTerm.length, + }); + + const coordinates = { lat: 1, lon: 1 }; + service.reverseLookup(coordinates).subscribe(); + expect(mockAnalytics.eventTrack).toHaveBeenCalledWith( + "reverse_lookup_executed", + { category: "Map" } + ); + }); +}); diff --git a/src/app/features/location/geo.service.ts b/src/app/features/location/geo.service.ts new file mode 100644 index 0000000000..77faaf2d26 --- /dev/null +++ b/src/app/features/location/geo.service.ts @@ -0,0 +1,74 @@ +import { Injectable } from "@angular/core"; +import { Observable } from "rxjs"; +import { Coordinates } from "./coordinates"; +import { HttpClient } from "@angular/common/http"; +import { ConfigService } from "../../core/config/config.service"; +import { AnalyticsService } from "../../core/analytics/analytics.service"; +import { environment } from "../../../environments/environment"; +import { MAP_CONFIG_KEY, MapConfig } from "./map-config"; + +export interface GeoResult extends Coordinates { + display_name: string; +} + +/** + * A service that uses nominatim to lookup locations {@link https://nominatim.org/} + */ +@Injectable({ + providedIn: "root", +}) +export class GeoService { + private readonly remoteUrl = "/nominatim"; + private countrycodes: string = "de"; + private email = environment.email; + + constructor( + private http: HttpClient, + private analytics: AnalyticsService, + configService: ConfigService + ) { + configService.configUpdates.subscribe(() => { + const config = configService.getConfig(MAP_CONFIG_KEY); + if (config?.countrycodes) { + this.countrycodes = config.countrycodes; + } + }); + } + + /** + * Returns locations that match the search term + * @param searchTerm e.g. `Rollbergstraße Berlin` + */ + lookup(searchTerm: string): Observable { + this.analytics.eventTrack("lookup_executed", { + category: "Map", + value: searchTerm.length, + }); + return this.http.get(`${this.remoteUrl}/search`, { + params: { + q: searchTerm, + format: "json", + countrycodes: this.countrycodes, + email: this.email, + }, + }); + } + + /** + * Returns the location at the provided coordinates + * @param coordinates of a place (`lat` and `lon`) + */ + reverseLookup(coordinates: Coordinates): Observable { + this.analytics.eventTrack("reverse_lookup_executed", { + category: "Map", + }); + return this.http.get(`${this.remoteUrl}/reverse`, { + params: { + lat: coordinates.lat, + lon: coordinates.lon, + format: "json", + email: this.email, + }, + }); + } +} diff --git a/src/app/features/location/location-data-type.ts b/src/app/features/location/location-data-type.ts new file mode 100644 index 0000000000..654b96e33e --- /dev/null +++ b/src/app/features/location/location-data-type.ts @@ -0,0 +1,9 @@ +import { EntitySchemaDatatype } from "../../core/entity/schema/entity-schema-datatype"; + +export const locationEntitySchemaDataType: EntitySchemaDatatype = { + name: "location", + editComponent: "EditLocation", + viewComponent: "ViewLocation", + transformToObjectFormat: (value) => value, + transformToDatabaseFormat: (value) => value, +}; diff --git a/src/app/features/location/location.module.ts b/src/app/features/location/location.module.ts new file mode 100644 index 0000000000..80e5310815 --- /dev/null +++ b/src/app/features/location/location.module.ts @@ -0,0 +1,51 @@ +import { NgModule } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { EditLocationComponent } from "./edit-location/edit-location.component"; +import { ViewLocationComponent } from "./view-location/view-location.component"; +import { EntitySchemaService } from "../../core/entity/schema/entity-schema.service"; +import { locationEntitySchemaDataType } from "./location-data-type"; +import { MatFormFieldModule } from "@angular/material/form-field"; +import { MatAutocompleteModule } from "@angular/material/autocomplete"; +import { MatInputModule } from "@angular/material/input"; +import { FormsModule, ReactiveFormsModule } from "@angular/forms"; +import { FontAwesomeModule } from "@fortawesome/angular-fontawesome"; +import { MatButtonModule } from "@angular/material/button"; +import { MapComponent } from "./map/map.component"; +import { MapPopupComponent } from "./map-popup/map-popup.component"; +import { MatDialogModule } from "@angular/material/dialog"; +import { ViewDistanceComponent } from "./view-distance/view-distance.component"; +import { EntityUtilsModule } from "../../core/entity-components/entity-utils/entity-utils.module"; + +@NgModule({ + declarations: [ + EditLocationComponent, + ViewLocationComponent, + MapComponent, + MapPopupComponent, + ViewDistanceComponent, + ], + imports: [ + CommonModule, + MatFormFieldModule, + MatAutocompleteModule, + MatInputModule, + ReactiveFormsModule, + FormsModule, + FontAwesomeModule, + MatButtonModule, + MatDialogModule, + EntityUtilsModule, + ], + exports: [EditLocationComponent, MapComponent], +}) +export class LocationModule { + dynamicComponents = [ + EditLocationComponent, + ViewLocationComponent, + ViewDistanceComponent, + ]; + + constructor(schemaService: EntitySchemaService) { + schemaService.registerSchemaDatatype(locationEntitySchemaDataType); + } +} diff --git a/src/app/features/location/map-config.ts b/src/app/features/location/map-config.ts new file mode 100644 index 0000000000..44c030dbe3 --- /dev/null +++ b/src/app/features/location/map-config.ts @@ -0,0 +1,16 @@ +export const MAP_CONFIG_KEY = "appConfig:map"; + +/** + * General configuration for the map integration + */ +export interface MapConfig { + /** + * Countries, from which search results will be included + * see {@link https://nominatim.org/release-docs/develop/api/Search/#result-limitation} + */ + countrycodes?: string; + /** + * Start location of map if nothing was selected yet + */ + start?: [number, number]; +} diff --git a/src/app/features/location/map-popup/map-popup.component.html b/src/app/features/location/map-popup/map-popup.component.html new file mode 100644 index 0000000000..e1c3874765 --- /dev/null +++ b/src/app/features/location/map-popup/map-popup.component.html @@ -0,0 +1,11 @@ + +
+ +
diff --git a/src/app/features/location/map-popup/map-popup.component.scss b/src/app/features/location/map-popup/map-popup.component.scss new file mode 100644 index 0000000000..44ca9833e0 --- /dev/null +++ b/src/app/features/location/map-popup/map-popup.component.scss @@ -0,0 +1,5 @@ +div { + display: flex; + justify-content: end; + margin-top: 10px; +} diff --git a/src/app/features/location/map-popup/map-popup.component.spec.ts b/src/app/features/location/map-popup/map-popup.component.spec.ts new file mode 100644 index 0000000000..8dbb9b46de --- /dev/null +++ b/src/app/features/location/map-popup/map-popup.component.spec.ts @@ -0,0 +1,53 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; + +import { MapPopupComponent } from "./map-popup.component"; +import { LocationModule } from "../location.module"; +import { EntitySchemaService } from "../../../core/entity/schema/entity-schema.service"; +import { MAT_DIALOG_DATA } from "@angular/material/dialog"; +import { ConfigService } from "../../../core/config/config.service"; +import { Subject } from "rxjs"; +import { Coordinates } from "../coordinates"; + +describe("MapPopupComponent", () => { + let component: MapPopupComponent; + let fixture: ComponentFixture; + const mapClick = new Subject(); + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [LocationModule], + providers: [ + EntitySchemaService, + { provide: MAT_DIALOG_DATA, useValue: { mapClick } }, + { provide: ConfigService, useValue: { getConfig: () => undefined } }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(MapPopupComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); + + it("should publish coordinates on map clicks", () => { + let coordinates: Coordinates; + mapClick.subscribe((res) => (coordinates = res)); + + component.mapClicked({ lat: 1, lon: 2 }); + + expect(coordinates).toEqual({ lat: 1, lon: 2 }); + }); + + it("should not update coordinates when disabled", () => { + let coordinates: Coordinates; + mapClick.subscribe((res) => (coordinates = res)); + + component.data.disabled = true; + component.mapClicked({ lat: 1, lon: 2 }); + + expect(coordinates).toBeUndefined(); + }); +}); diff --git a/src/app/features/location/map-popup/map-popup.component.ts b/src/app/features/location/map-popup/map-popup.component.ts new file mode 100644 index 0000000000..581e5c2080 --- /dev/null +++ b/src/app/features/location/map-popup/map-popup.component.ts @@ -0,0 +1,34 @@ +import { Component, Inject } from "@angular/core"; +import { MAT_DIALOG_DATA } from "@angular/material/dialog"; +import { Coordinates } from "../coordinates"; +import { Entity } from "../../../core/entity/model/entity"; +import { Observable, Subject } from "rxjs"; +import { LocationEntity } from "../map/map.component"; + +export interface MapPopupConfig { + marked?: Observable; + entities?: Observable; + highlightedEntities?: Observable; + mapClick?: Subject; + entityClick?: Subject; + disabled?: boolean; +} + +@Component({ + selector: "app-map-popup", + templateUrl: "./map-popup.component.html", + styleUrls: ["./map-popup.component.scss"], +}) +export class MapPopupComponent { + constructor( + @Inject(MAT_DIALOG_DATA) + public data: MapPopupConfig + ) {} + + mapClicked(newCoordinates: Coordinates) { + if (this.data.disabled) { + return; + } + this.data.mapClick?.next(newCoordinates); + } +} diff --git a/src/app/features/location/map-utils.ts b/src/app/features/location/map-utils.ts new file mode 100644 index 0000000000..f2d32b55eb --- /dev/null +++ b/src/app/features/location/map-utils.ts @@ -0,0 +1,64 @@ +import { Entity } from "../../core/entity/model/entity"; +import * as L from "leaflet"; +import { Coordinates } from "./coordinates"; +import { getHue } from "../../utils/style-utils"; + +const iconRetinaUrl = "assets/marker-icon-2x.png"; +const iconUrl = "assets/marker-icon.png"; +const shadowUrl = "assets/marker-shadow.png"; +const iconDefault = L.icon({ + iconRetinaUrl, + iconUrl, + shadowUrl, + iconSize: [25, 41], + iconAnchor: [12, 41], + popupAnchor: [1, -34], + tooltipAnchor: [16, -28], + shadowSize: [41, 41], +}); +L.Marker.prototype.options.icon = iconDefault; + +/** + * Translates the color of an entity to the necessary hue-rotate filter + * @param entity to get color from + * @param offset hue offset which should be added on top. + * default 145 (rough guess of the default leaflet marker icon) + * + */ +export function getHueForEntity(entity: Entity, offset = 145): string { + // Grab the hex representation and convert to decimal (base 10). + const color = entity.getConstructor().color; + if (!color) { + return "0"; + } + const r = parseInt(color.substring(1, 3), 16) / 255; + const g = parseInt(color.substring(3, 5), 16) / 255; + const b = parseInt(color.substring(5, 7), 16) / 255; + const hue = getHue(r, g, b); + const offsetHue = (hue * 360 + offset) % 360; + + return offsetHue.toFixed(0); +} + +/** + * Calculate distance between two points + * Source {@link https://henry-rossiter.medium.com/calculating-distance-between-geographic-coordinates-with-javascript-5f3097b61898} + * @param x + * @param y + */ +export function getKmDistance(x: Coordinates, y: Coordinates) { + const R = 6371e3; + const p1 = (x.lat * Math.PI) / 180; + const p2 = (y.lat * Math.PI) / 180; + const deltaP = p2 - p1; + const deltaLon = y.lon - x.lon; + const deltaLambda = (deltaLon * Math.PI) / 180; + const a = + Math.sin(deltaP / 2) * Math.sin(deltaP / 2) + + Math.cos(p1) * + Math.cos(p2) * + Math.sin(deltaLambda / 2) * + Math.sin(deltaLambda / 2); + const d = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)) * R; + return d / 1000; +} diff --git a/src/app/features/location/map/map.component.html b/src/app/features/location/map/map.component.html new file mode 100644 index 0000000000..055d7a550f --- /dev/null +++ b/src/app/features/location/map/map.component.html @@ -0,0 +1,7 @@ +
+
+ +
+
diff --git a/src/app/features/location/map/map.component.scss b/src/app/features/location/map/map.component.scss new file mode 100644 index 0000000000..6724b067e7 --- /dev/null +++ b/src/app/features/location/map/map.component.scss @@ -0,0 +1,16 @@ +.map-frame { + border: 1px solid lightgrey; + border-radius: 2px; +} + +#map { + height: 100%; +} + +button { + position: absolute; + bottom: 3px; + left: 3px; + z-index: 1000; + background-color: white !important; +} diff --git a/src/app/features/location/map/map.component.spec.ts b/src/app/features/location/map/map.component.spec.ts new file mode 100644 index 0000000000..e333a45052 --- /dev/null +++ b/src/app/features/location/map/map.component.spec.ts @@ -0,0 +1,112 @@ +import { + ComponentFixture, + fakeAsync, + TestBed, + tick, +} from "@angular/core/testing"; + +import { MapComponent } from "./map.component"; +import { ConfigService } from "../../../core/config/config.service"; +import * as L from "leaflet"; +import { Coordinates } from "../coordinates"; +import { Child } from "../../../child-dev-project/children/model/child"; +import { MapConfig } from "../map-config"; +import { MatDialog } from "@angular/material/dialog"; +import { MapPopupConfig } from "../map-popup/map-popup.component"; + +describe("MapComponent", () => { + let component: MapComponent; + let fixture: ComponentFixture; + let mockDialog: jasmine.SpyObj; + const config: MapConfig = { start: [52, 13] }; + let map: L.Map; + + beforeEach(async () => { + mockDialog = jasmine.createSpyObj(["open"]); + await TestBed.configureTestingModule({ + declarations: [MapComponent], + providers: [ + { provide: ConfigService, useValue: { getConfig: () => config } }, + { provide: MatDialog, useValue: mockDialog }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(MapComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + map = component["map"]; + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); + + it("should create map centered at start position from config", () => { + expect(map.getCenter()).toEqual(new L.LatLng(...config.start)); + }); + + it("should not emit double clicks on the map", fakeAsync(() => { + let clicked: Coordinates; + component.mapClick.subscribe((res) => (clicked = res)); + + tick(1000); + map.fireEvent("click", { latlng: new L.LatLng(1, 1) }); + tick(300); + map.fireEvent("click", { latlng: new L.LatLng(1, 2) }); + tick(400); + expect(clicked).toBeUndefined(); + + map.fireEvent("click", { latlng: new L.LatLng(1, 3) }); + tick(400); + expect(clicked).toEqual({ lat: 1, lon: 3 }); + })); + + it("should center map around markers and keep zoom", () => { + component.marked = [ + { lat: 1, lon: 1 }, + { lat: 1, lon: 3 }, + ]; + + const center = map.getCenter(); + expect(center.lat).toBeCloseTo(1); + expect(center.lng).toBeCloseTo(2); + }); + + it("should create markers for entities and emit entity when marker is clicked", (done) => { + const child = new Child(); + child["address"] = { lat: 1, lon: 1 }; + component.entities = [{ entity: child, property: "address" }]; + + // Look for marker where entity has been set + let marker: L.Marker; + map.eachLayer((layer) => { + if (layer["entity"]) { + marker = layer as L.Marker; + } + }); + + // marker shows entity information when hovered + expect(marker.getTooltip()["_content"]).toBe(child.toString()); + + component.entityClick.subscribe((res) => { + expect(res).toBe(child); + done(); + }); + + marker.fireEvent("click"); + }); + + it("should open a popup with the same marker data", () => { + const marked = { lat: 1, lon: 1 }; + component.marked = [marked]; + + component.showPopup(); + const dialogData: MapPopupConfig = + mockDialog.open.calls.mostRecent().args[1].data; + + let emitted: Coordinates[]; + dialogData.marked.subscribe((res) => (emitted = res)); + + expect(emitted).toEqual([marked]); + }); +}); diff --git a/src/app/features/location/map/map.component.ts b/src/app/features/location/map/map.component.ts new file mode 100644 index 0000000000..d4c641601d --- /dev/null +++ b/src/app/features/location/map/map.component.ts @@ -0,0 +1,188 @@ +import { + AfterViewInit, + Component, + ElementRef, + EventEmitter, + Input, + Output, + ViewChild, +} from "@angular/core"; +import * as L from "leaflet"; +import { Observable, ReplaySubject, timeInterval } from "rxjs"; +import { debounceTime, filter, map } from "rxjs/operators"; +import { Coordinates } from "../coordinates"; +import { Entity } from "../../../core/entity/model/entity"; +import { getHueForEntity } from "../map-utils"; +import { ConfigService } from "../../../core/config/config.service"; +import { MAP_CONFIG_KEY, MapConfig } from "../map-config"; +import { + MapPopupComponent, + MapPopupConfig, +} from "../map-popup/map-popup.component"; +import { MatDialog } from "@angular/material/dialog"; + +export interface LocationEntity { + entity: Entity; + property: string; +} + +@Component({ + selector: "app-map", + templateUrl: "./map.component.html", + styleUrls: ["./map.component.scss"], +}) +export class MapComponent implements AfterViewInit { + private readonly start_location: L.LatLngTuple = [52.4790412, 13.4319106]; + + @ViewChild("map") private mapElement: ElementRef; + + @Input() height = "200px"; + @Input() expandable = false; + + @Input() set marked(coordinates: Coordinates[]) { + if (!coordinates) { + return; + } + this.clearMarkers(this.markers); + this.markers = this.createMarkers(coordinates); + this.showMarkersOnMap(this.markers); + this._marked.next(coordinates); + } + + private _marked = new ReplaySubject(); + + @Input() set entities(entities: LocationEntity[]) { + if (!entities) { + return; + } + this.clearMarkers(this.markers); + this.markers = this.createEntityMarkers(entities); + this.showMarkersOnMap(this.markers); + this._entities.next(entities); + } + + private _entities = new ReplaySubject(); + + @Input() set highlightedEntities(entities: LocationEntity[]) { + if (!entities) { + return; + } + this.clearMarkers(this.highlightedMarkers); + this.highlightedMarkers = this.createEntityMarkers(entities); + this.showMarkersOnMap(this.highlightedMarkers, true); + this._highlightedEntities.next(entities); + } + + private _highlightedEntities = new ReplaySubject(); + + private map: L.Map; + private markers: L.Marker[]; + private highlightedMarkers: L.Marker[]; + private clickStream = new EventEmitter(); + + @Output() mapClick: Observable = this.clickStream.pipe( + timeInterval(), + debounceTime(400), + filter(({ interval }) => interval >= 400), + map(({ value }) => value) + ); + + @Output() entityClick = new EventEmitter(); + + constructor(configService: ConfigService, private dialog: MatDialog) { + const config = configService.getConfig(MAP_CONFIG_KEY); + if (config?.start) { + this.start_location = config.start; + } + } + + ngAfterViewInit() { + // init Map + this.map = L.map(this.mapElement.nativeElement, { + center: + this.markers?.length > 0 + ? this.markers[0].getLatLng() + : this.start_location, + zoom: 14, + }); + this.map.addEventListener("click", (res) => + this.clickStream.emit({ lat: res.latlng.lat, lon: res.latlng.lng }) + ); + + const tiles = L.tileLayer( + "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", + { + maxZoom: 18, + minZoom: 3, + attribution: + '© OpenStreetMap', + } + ); + tiles.addTo(this.map); + // this is necessary to remove gray spots when directly opening app on a page with the map + setTimeout(() => this.map.invalidateSize()); + + this.showMarkersOnMap(this.markers); + this.showMarkersOnMap(this.highlightedMarkers, true); + } + + private showMarkersOnMap(marker: L.Marker[], highlighted = false) { + if (!marker || !this.map || marker.length === 0) { + return; + } + marker.forEach((m) => this.addMarker(m, highlighted)); + const group = L.featureGroup(marker); + this.map.fitBounds(group.getBounds(), { + padding: [50, 50], + maxZoom: this.map.getZoom(), + }); + } + + private createEntityMarkers(entities: LocationEntity[]) { + return entities + .filter(({ entity, property }) => !!entity?.[property]) + .map(({ entity, property }) => { + const marker = L.marker([entity[property].lat, entity[property].lon]); + marker.bindTooltip(entity.toString()); + marker.on("click", () => this.entityClick.emit(entity)); + marker["entity"] = entity; + return marker; + }); + } + + private clearMarkers(markers: L.Marker[]) { + if (markers?.length > 0 && this.map) { + markers.forEach((marker) => marker.removeFrom(this.map)); + } + } + + private createMarkers(coordinates: Coordinates[]) { + return coordinates + .filter((coord) => !!coord) + .map((coord) => L.marker([coord.lat, coord.lon])); + } + + private addMarker(m: L.Marker, highlighted: boolean = false) { + m.addTo(this.map); + const entity = m["entity"] as Entity; + if (highlighted || entity) { + const degree = highlighted ? "145" : getHueForEntity(entity); + const icon = m["_icon"] as HTMLElement; + icon.style.filter = `hue-rotate(${degree}deg)`; + } + return m; + } + + showPopup() { + this.dialog.open(MapPopupComponent, { + width: "90%", + data: { + marked: this._marked, + entities: this._entities, + highlightedEntities: this._highlightedEntities, + entityClick: this.entityClick, + mapClick: this.clickStream, + } as MapPopupConfig, + }); + } +} diff --git a/src/app/features/location/map/map.stories.ts b/src/app/features/location/map/map.stories.ts new file mode 100644 index 0000000000..dce17fb01c --- /dev/null +++ b/src/app/features/location/map/map.stories.ts @@ -0,0 +1,36 @@ +import { moduleMetadata } from "@storybook/angular"; +import { StorybookBaseModule } from "../../../utils/storybook-base.module"; +import { Meta, Story } from "@storybook/angular/types-6-0"; +import { LocationModule } from "../location.module"; +import { MapComponent } from "./map.component"; +import { EntitySchemaService } from "../../../core/entity/schema/entity-schema.service"; + +export default { + title: "Features/Location/Map", + component: MapComponent, + decorators: [ + moduleMetadata({ + imports: [LocationModule, StorybookBaseModule], + providers: [EntitySchemaService], + }), + ], +} as Meta; + +const Template: Story = (args: MapComponent) => ({ + component: MapComponent, + props: args, +}); + +export const Single = Template.bind({}); +Single.args = { + marked: [{ lat: 52.4790412, lon: 13.4319106 }], +}; + +export const Multiple = Template.bind({}); +Multiple.args = { + marked: [ + { lat: 52.4790412, lon: 13.4319106 }, + { lat: 52.4750412, lon: 13.4319106 }, + ], + expandable: true, +}; diff --git a/src/app/features/location/view-distance/view-distance.component.spec.ts b/src/app/features/location/view-distance/view-distance.component.spec.ts new file mode 100644 index 0000000000..92564bb1f0 --- /dev/null +++ b/src/app/features/location/view-distance/view-distance.component.spec.ts @@ -0,0 +1,60 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; + +import { + ViewDistanceComponent, + ViewDistanceConfig, +} from "./view-distance.component"; +import { ReadonlyFunctionComponent } from "../../../core/entity-components/entity-utils/view-components/readonly-function/readonly-function.component"; +import { EntityFunctionPipe } from "../../../core/entity-components/entity-utils/view-components/readonly-function/entity-function.pipe"; +import { ViewPropertyConfig } from "../../../core/entity-components/entity-list/EntityListConfig"; +import { Child } from "../../../child-dev-project/children/model/child"; +import { Subject } from "rxjs"; +import { Coordinates } from "../coordinates"; + +describe("ViewDistanceComponent", () => { + let component: ViewDistanceComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ + ViewDistanceComponent, + ReadonlyFunctionComponent, + EntityFunctionPipe, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(ViewDistanceComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); + + it("update function and trigger change detection when new coordiantes are emitted", () => { + const entity = new Child(); + entity["address"] = { lat: 52, lon: 13 }; + const compareCoordinates = new Subject(); + const config: ViewPropertyConfig = { + id: "distance", + entity, + value: undefined, + config: { compareCoordinates, coordinatesProperty: "address" }, + }; + const detectChangesSpy = spyOn( + component["changeDetector"], + "detectChanges" + ); + component.onInitFromDynamicConfig(config); + + compareCoordinates.next({ lat: 52.0001, lon: 13 }); + expect(detectChangesSpy).toHaveBeenCalledTimes(1); + expect(component.distanceFunction(entity)).toEqual("0.01 km"); + + compareCoordinates.next({ lat: 52.001, lon: 13 }); + expect(detectChangesSpy).toHaveBeenCalledTimes(2); + expect(component.distanceFunction(entity)).toEqual("0.11 km"); + }); +}); diff --git a/src/app/features/location/view-distance/view-distance.component.ts b/src/app/features/location/view-distance/view-distance.component.ts new file mode 100644 index 0000000000..16b2d652d7 --- /dev/null +++ b/src/app/features/location/view-distance/view-distance.component.ts @@ -0,0 +1,75 @@ +import { ChangeDetectorRef, Component } from "@angular/core"; +import { ViewDirective } from "../../../core/entity-components/entity-utils/view-components/view.directive"; +import { ViewPropertyConfig } from "../../../core/entity-components/entity-list/EntityListConfig"; +import { Entity } from "../../../core/entity/model/entity"; +import { Coordinates } from "../coordinates"; +import { getKmDistance } from "../map-utils"; +import { Observable } from "rxjs"; +import { DynamicComponent } from "../../../core/view/dynamic-components/dynamic-component.decorator"; +import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy"; + +/** + * Config for displaying the distance between two entities + */ +export interface ViewDistanceConfig { + /** + * The name of the `GeoResult`/`Coordinates` property of the first entity + */ + coordinatesProperty: string; + /** + * The updates of coordinates of the second entity. + * A `ReplaySubject` works best for this. + */ + compareCoordinates: Observable; +} + +/** + * Displays the distance between two entities + */ +@UntilDestroy() +@DynamicComponent("DisplayDistance") +@Component({ + selector: "app-view-distance", + template: ` + + `, +}) +export class ViewDistanceComponent extends ViewDirective { + private config: ViewDistanceConfig; + + constructor(private changeDetector: ChangeDetectorRef) { + super(); + } + + distanceFunction = (_entity: Entity) => "-"; + + onInitFromDynamicConfig(config: ViewPropertyConfig) { + super.onInitFromDynamicConfig(config); + this.config = config.config; + this.config.compareCoordinates + .pipe(untilDestroyed(this)) + .subscribe((coordinates) => this.setDistanceFunction(coordinates)); + } + + private setDistanceFunction(compareCoordinates: Coordinates) { + this.distanceFunction = (e: Entity) => + this.calculateDistanceTo( + e[this.config.coordinatesProperty], + compareCoordinates + ); + // somehow changes to `displayFunction` don't trigger the change detection + this.changeDetector.detectChanges(); + } + + private calculateDistanceTo(a: Coordinates, b: Coordinates) { + if (a && b && a !== b) { + const res = getKmDistance(a, b).toFixed(2); + return $localize`:distance with unit|e.g. 5 km:${res} km`; + } else { + return "-"; + } + } +} diff --git a/src/app/features/location/view-location/view-location.component.spec.ts b/src/app/features/location/view-location/view-location.component.spec.ts new file mode 100644 index 0000000000..0090552430 --- /dev/null +++ b/src/app/features/location/view-location/view-location.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ViewLocationComponent } from './view-location.component'; + +describe('ViewLocationComponent', () => { + let component: ViewLocationComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ ViewLocationComponent ] + }) + .compileComponents(); + + fixture = TestBed.createComponent(ViewLocationComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/features/location/view-location/view-location.component.ts b/src/app/features/location/view-location/view-location.component.ts new file mode 100644 index 0000000000..cfb8b94c4f --- /dev/null +++ b/src/app/features/location/view-location/view-location.component.ts @@ -0,0 +1,11 @@ +import { Component } from "@angular/core"; +import { DynamicComponent } from "../../../core/view/dynamic-components/dynamic-component.decorator"; +import { ViewDirective } from "../../../core/entity-components/entity-utils/view-components/view.directive"; +import { GeoResult } from "../geo.service"; + +@DynamicComponent("ViewLocation") +@Component({ + selector: "app-view-location", + template: "{{ value?.display_name }}", +}) +export class ViewLocationComponent extends ViewDirective {} diff --git a/src/app/features/matching-entities/matching-entities.module.ts b/src/app/features/matching-entities/matching-entities.module.ts index 2ce5c99256..3bc8858796 100644 --- a/src/app/features/matching-entities/matching-entities.module.ts +++ b/src/app/features/matching-entities/matching-entities.module.ts @@ -10,6 +10,7 @@ import { MatTooltipModule } from "@angular/material/tooltip"; import { FilterModule } from "../../core/filter/filter.module"; import { FontAwesomeModule } from "@fortawesome/angular-fontawesome"; import { EntityUtilsModule } from "../../core/entity-components/entity-utils/entity-utils.module"; +import { LocationModule } from "../location/location.module"; /** * Facilitate finding suitable entities and connecting them. @@ -27,6 +28,7 @@ import { EntityUtilsModule } from "../../core/entity-components/entity-utils/ent FilterModule, FontAwesomeModule, EntityUtilsModule, + LocationModule, ], exports: [MatchingEntitiesComponent], }) diff --git a/src/app/features/matching-entities/matching-entities/matching-entities-config.ts b/src/app/features/matching-entities/matching-entities/matching-entities-config.ts index f63813438d..fb747179d8 100644 --- a/src/app/features/matching-entities/matching-entities/matching-entities-config.ts +++ b/src/app/features/matching-entities/matching-entities/matching-entities-config.ts @@ -14,10 +14,15 @@ export interface MatchingEntitiesConfig { * * e.g. [["name", "name"], ["motherTongue", "language"]] */ - columns: string[][]; + columns: [string, string][]; - /** whether a map should be displayed in addition to a comparison table */ - showMap?: boolean; + /** + * Mapped properties which should be displayed in a map (of left and right entity). + * The properties need to have the format `{ lat: number, lon: number}`. + * + * e.g. `["address", "location"] + */ + showMap?: [string, string]; /** overwrite the button label to describe the matching action */ matchActionLabel?: string; @@ -50,7 +55,7 @@ export interface MatchingSideConfig { availableFilters?: FilterConfig[]; /** columns of the available entities table. Usually inferred from matching columns of the component */ - columns?: string[]; + columns?: ColumnConfig[]; } export interface NewMatchAction { diff --git a/src/app/features/matching-entities/matching-entities/matching-entities.component.html b/src/app/features/matching-entities/matching-entities/matching-entities.component.html index 52a478bef0..975a422ba3 100644 --- a/src/app/features/matching-entities/matching-entities/matching-entities.component.html +++ b/src/app/features/matching-entities/matching-entities/matching-entities.component.html @@ -7,14 +7,14 @@ {{ side.selected?.toString() ?? '-' }} @@ -31,7 +31,15 @@
-
Map
+