diff --git a/src/app/child-dev-project/children/children-components.ts b/src/app/child-dev-project/children/children-components.ts index bf7e52a181..569caea244 100644 --- a/src/app/child-dev-project/children/children-components.ts +++ b/src/app/child-dev-project/children/children-components.ts @@ -45,11 +45,11 @@ export const childrenComponents: ComponentTuple[] = [ ).then((c) => c.ChildrenBmiDashboardComponent), ], [ - "EducationalMaterial", + "RelatedEntitiesWithSummary", () => import( - "./educational-material/educational-material-component/educational-material.component" - ).then((c) => c.EducationalMaterialComponent), + "../../core/entity-details/related-entities-with-summary/related-entities-with-summary.component" + ).then((c) => c.RelatedEntitiesWithSummaryComponent), ], [ "BmiBlock", diff --git a/src/app/child-dev-project/children/educational-material/educational-material-component/educational-material.component.html b/src/app/child-dev-project/children/educational-material/educational-material-component/educational-material.component.html deleted file mode 100644 index abfd833c5c..0000000000 --- a/src/app/child-dev-project/children/educational-material/educational-material-component/educational-material.component.html +++ /dev/null @@ -1,15 +0,0 @@ - - - -
- - Total: {{ summary }}
-
- - Average: {{ avgSummary }}
-
-
diff --git a/src/app/child-dev-project/children/educational-material/educational-material-component/educational-material.component.ts b/src/app/child-dev-project/children/educational-material/educational-material-component/educational-material.component.ts deleted file mode 100644 index 5a065632c3..0000000000 --- a/src/app/child-dev-project/children/educational-material/educational-material-component/educational-material.component.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { Component, Input, OnInit } from "@angular/core"; -import { NgFor, NgIf } from "@angular/common"; -import { EducationalMaterial } from "../model/educational-material"; -import { Child } from "../../model/child"; -import { FormFieldConfig } from "../../../../core/common-components/entity-form/entity-form/FormConfig"; -import { DynamicComponent } from "../../../../core/config/dynamic-components/dynamic-component.decorator"; -import { EntityMapperService } from "../../../../core/entity/entity-mapper/entity-mapper.service"; -import { applyUpdate } from "../../../../core/entity/model/entity-update"; -import { filter } from "rxjs/operators"; -import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy"; -import { EntitySubrecordComponent } from "../../../../core/common-components/entity-subrecord/entity-subrecord/entity-subrecord.component"; - -/** - * Displays educational materials of a child, such as a pencil, rulers, e.t.c - * as well as a summary - */ -@DynamicComponent("EducationalMaterial") -@UntilDestroy() -@Component({ - selector: "app-educational-material", - templateUrl: "./educational-material.component.html", - imports: [ - EntitySubrecordComponent, - NgIf, - NgFor - ], - standalone: true, -}) -export class EducationalMaterialComponent implements OnInit { - @Input() entity: Child; - @Input() summaries: { total?: boolean; average?: boolean } = { total: true }; - records: EducationalMaterial[] = []; - summary = ""; - avgSummary = ""; - - @Input() config: { columns: FormFieldConfig[] } = { - columns: [ - { id: "date", visibleFrom: "xs" }, - { id: "materialType", visibleFrom: "xs" }, - { id: "materialAmount", visibleFrom: "md" }, - { id: "description", visibleFrom: "md" }, - ], - }; - - constructor(private entityMapper: EntityMapperService) { - this.entityMapper - .receiveUpdates(EducationalMaterial) - .pipe( - untilDestroyed(this), - filter( - ({ entity, type }) => - type === "remove" || entity.child === this.entity.getId(), - ), - ) - .subscribe((update) => { - this.records = applyUpdate(this.records, update); - this.updateSummary(); - }); - } - - ngOnInit() { - return this.loadData(); - } - - /** - * Loads the data for a given child and updates the summary - * @param id The id of the child to load the data for - */ - private async loadData() { - const allMaterials = await this.entityMapper.loadType(EducationalMaterial); - this.records = allMaterials.filter( - (mat) => mat.child === this.entity.getId(), - ); - this.updateSummary(); - } - - newRecordFactory = () => { - const newAtt = new EducationalMaterial(Date.now().toString()); - - // use last entered date as default, otherwise today's date - newAtt.date = this.records.length > 0 ? this.records[0].date : new Date(); - newAtt.child = this.entity.getId(); - - return newAtt; - }; - - /** - * update the summary or generate a new one. - * The summary contains no duplicates and is in a - * human-readable format - */ - updateSummary() { - const summary = new Map(); - const average = new Map(); - - this.records.forEach((m) => { - const { materialType, materialAmount } = m; - const label = materialType?.label; - - if (label) { - summary.set(label, (summary.get(label) || { count: 0, sum: 0 })); - summary.get(label)!.count++; - summary.get(label)!.sum += materialAmount; - } - }); - - if(this.summaries.total) { - const summaryArray = Array.from(summary.entries(), ([label, { sum }]) => `${label}: ${sum}`); - this.summary = summaryArray.join(", "); - } - - if(this.summaries.average) { - const avgSummaryArray = Array.from(summary.entries(), ([label, { count, sum }]) => { - const avg = parseFloat((sum / count).toFixed(2)); - average.set(label, avg); - return `${label}: ${avg}`; - }); - this.avgSummary = avgSummaryArray.join(", "); - } - } -} diff --git a/src/app/core/config/config-fix.ts b/src/app/core/config/config-fix.ts index 333e83ca6f..0c160bf969 100644 --- a/src/app/core/config/config-fix.ts +++ b/src/app/core/config/config-fix.ts @@ -706,14 +706,24 @@ export const defaultJsonConfig = { "components": [ { "title": "", - "component": "EducationalMaterial", + "component": "RelatedEntitiesWithSummary", "config": { - "summaries": { - total: true, - average: true, + "entityType": EducationalMaterial.ENTITY_TYPE, + "property": "child", + "columns": [ + { "id": "date", "visibleFrom": "xs" }, + { "id": "materialType", "visibleFrom": "xs" }, + { "id": "materialAmount", "visibleFrom": "md" }, + { "id": "description", "visibleFrom": "md" }, + ], + "summaries": { + "countProperty": "materialAmount", + "groupBy": "materialType", + "total": true, + "average": false } } - } + } ] }, { diff --git a/src/app/core/entity-details/related-entities-with-summary/related-entities-with-summary.component.html b/src/app/core/entity-details/related-entities-with-summary/related-entities-with-summary.component.html new file mode 100644 index 0000000000..c59e53e42c --- /dev/null +++ b/src/app/core/entity-details/related-entities-with-summary/related-entities-with-summary.component.html @@ -0,0 +1,16 @@ + + +
+ + Total: {{ summarySum }}
+
+ + Average: {{ summaryAvg }}
+
+
diff --git a/src/app/child-dev-project/children/educational-material/educational-material-component/educational-material.component.spec.ts b/src/app/core/entity-details/related-entities-with-summary/related-entities-with-summary.component.spec.ts similarity index 50% rename from src/app/child-dev-project/children/educational-material/educational-material-component/educational-material.component.spec.ts rename to src/app/core/entity-details/related-entities-with-summary/related-entities-with-summary.component.spec.ts index 9068e33c8e..5c17e54951 100644 --- a/src/app/child-dev-project/children/educational-material/educational-material-component/educational-material.component.spec.ts +++ b/src/app/core/entity-details/related-entities-with-summary/related-entities-with-summary.component.spec.ts @@ -1,17 +1,23 @@ -import { ComponentFixture, TestBed, waitForAsync } from "@angular/core/testing"; - -import { EducationalMaterialComponent } from "./educational-material.component"; -import { Child } from "../../model/child"; -import { MockedTestingModule } from "../../../../utils/mocked-testing.module"; -import { EducationalMaterial } from "../model/educational-material"; -import { ConfigurableEnumValue } from "../../../../core/basic-datatypes/configurable-enum/configurable-enum.interface"; -import { EntityMapperService } from "../../../../core/entity/entity-mapper/entity-mapper.service"; +import { + ComponentFixture, + fakeAsync, + TestBed, + tick, + waitForAsync, +} from "@angular/core/testing"; + +import { RelatedEntitiesWithSummaryComponent } from "./related-entities-with-summary.component"; +import { Child } from "../../../child-dev-project/children/model/child"; +import { MockedTestingModule } from "../../../utils/mocked-testing.module"; +import { EducationalMaterial } from "../../../child-dev-project/children/educational-material/model/educational-material"; +import { ConfigurableEnumValue } from "../../basic-datatypes/configurable-enum/configurable-enum.interface"; +import { EntityMapperService } from "../../entity/entity-mapper/entity-mapper.service"; import { Subject } from "rxjs"; -import { UpdatedEntity } from "../../../../core/entity/model/entity-update"; +import { UpdatedEntity } from "../../entity/model/entity-update"; -describe("EducationalMaterialComponent", () => { - let component: EducationalMaterialComponent; - let fixture: ComponentFixture; +describe("RelatedEntitiesWithSummaryComponent", () => { + let component: RelatedEntitiesWithSummaryComponent; + let fixture: ComponentFixture; const updates = new Subject>(); const child = new Child("22"); const PENCIL: ConfigurableEnumValue = { @@ -25,17 +31,29 @@ describe("EducationalMaterialComponent", () => { beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ - imports: [EducationalMaterialComponent, MockedTestingModule.withState()], + imports: [ + RelatedEntitiesWithSummaryComponent, + MockedTestingModule.withState(), + ], }).compileComponents(); const entityMapper = TestBed.inject(EntityMapperService); spyOn(entityMapper, "receiveUpdates").and.returnValue(updates); })); beforeEach(() => { - fixture = TestBed.createComponent(EducationalMaterialComponent); + fixture = TestBed.createComponent(RelatedEntitiesWithSummaryComponent); component = fixture.componentInstance; component.entity = child; - component.summaries = {total: true, average: true}; + component.entityType = EducationalMaterial.ENTITY_TYPE; + component.property = "child"; + + component.summaries = { + countProperty: "materialAmount", + groupBy: "materialType", + total: true, + average: true, + }; + fixture.detectChanges(); }); @@ -44,32 +62,47 @@ describe("EducationalMaterialComponent", () => { }); it("produces an empty summary when there are no records", () => { - component.records = []; + component.data = []; component.updateSummary(); - expect(component.summary).toHaveSize(0); - expect(component.avgSummary).toHaveSize(0); + expect(component.summarySum).toHaveSize(0); + expect(component.summaryAvg).toHaveSize(0); }); function setRecordsAndGenerateSummary( ...records: Partial[] ) { - component.records = records.map(EducationalMaterial.create); + component.data = records.map(EducationalMaterial.create); component.updateSummary(); } it("produces a singleton summary when there is a single record", () => { setRecordsAndGenerateSummary({ materialType: PENCIL, materialAmount: 1 }); - expect(component.summary).toEqual(`${PENCIL.label}: 1`); - expect(component.avgSummary).toEqual(`${PENCIL.label}: 1`); + expect(component.summarySum).toEqual(`${PENCIL.label}: 1`); + expect(component.summaryAvg).toEqual(`${PENCIL.label}: 1`); }); it("produces a summary of all records when they are all different", () => { setRecordsAndGenerateSummary( { materialType: PENCIL, materialAmount: 2 }, { materialType: RULER, materialAmount: 1 }, + { materialAmount: 1 }, + ); + expect(component.summarySum).toEqual( + `${PENCIL.label}: 2, ${RULER.label}: 1, undefined: 1`, ); - expect(component.summary).toEqual(`${PENCIL.label}: 2, ${RULER.label}: 1`); - expect(component.avgSummary).toEqual(`${PENCIL.label}: 2, ${RULER.label}: 1`); + expect(component.summaryAvg).toEqual( + `${PENCIL.label}: 2, ${RULER.label}: 1, undefined: 1`, + ); + }); + + it("produces a singly summary without grouping, if `groupBy` is not given (or the group value undefined)", () => { + component.data = [{ amount: 1 }, { amount: 5 }] as any[]; + delete component.summaries.groupBy; + component.summaries.countProperty = "amount"; + component.updateSummary(); + + expect(component.summarySum).toEqual(`6`); + expect(component.summaryAvg).toEqual(`3`); }); it("produces a summary of all records when there are duplicates", () => { @@ -79,56 +112,70 @@ describe("EducationalMaterialComponent", () => { { materialType: PENCIL, materialAmount: 3 }, ); - expect(component.summary).toEqual(`${PENCIL.label}: 4, ${RULER.label}: 1`); - expect(component.avgSummary).toEqual(`${PENCIL.label}: 2, ${RULER.label}: 1`); + expect(component.summarySum).toEqual( + `${PENCIL.label}: 4, ${RULER.label}: 1`, + ); + expect(component.summaryAvg).toEqual( + `${PENCIL.label}: 2, ${RULER.label}: 1`, + ); }); it("produces summary of all records when average is false and total is true", () => { - component.summaries = { total: true, average: false } + component.summaries.total = true; + component.summaries.average = false; setRecordsAndGenerateSummary( { materialType: PENCIL, materialAmount: 1 }, { materialType: RULER, materialAmount: 1 }, { materialType: PENCIL, materialAmount: 3 }, ); - expect(component.summary).toEqual(`${PENCIL.label}: 4, ${RULER.label}: 1`); - expect(component.avgSummary).toEqual(``); + expect(component.summarySum).toEqual( + `${PENCIL.label}: 4, ${RULER.label}: 1`, + ); + expect(component.summaryAvg).toEqual(``); }); it("produces summary of all records when average is true and total is false", () => { - component.summaries = { total: false, average: true }; + component.summaries.total = false; + component.summaries.average = true; setRecordsAndGenerateSummary( { materialType: PENCIL, materialAmount: 1 }, { materialType: RULER, materialAmount: 1 }, { materialType: PENCIL, materialAmount: 3 }, ); - expect(component.summary).toEqual(``); - expect(component.avgSummary).toEqual(`${PENCIL.label}: 2, ${RULER.label}: 1`); + expect(component.summarySum).toEqual(``); + expect(component.summaryAvg).toEqual( + `${PENCIL.label}: 2, ${RULER.label}: 1`, + ); }); it("does not produces summary of all records when both average and total are false", () => { - component.summaries = { total: false, average: false }; + component.summaries.total = false; + component.summaries.average = false; setRecordsAndGenerateSummary( { materialType: PENCIL, materialAmount: 1 }, { materialType: RULER, materialAmount: 1 }, { materialType: PENCIL, materialAmount: 3 }, ); - - expect(component.summary).toEqual(``); - expect(component.avgSummary).toEqual(``); + + expect(component.summarySum).toEqual(``); + expect(component.summaryAvg).toEqual(``); }); it("produces summary of all records when both average and total are true", () => { - component.summaries = { total: true, average: true }; setRecordsAndGenerateSummary( { materialType: PENCIL, materialAmount: 1 }, { materialType: RULER, materialAmount: 1 }, { materialType: PENCIL, materialAmount: 3 }, ); - expect(component.summary).toEqual(`${PENCIL.label}: 4, ${RULER.label}: 1`); - expect(component.avgSummary).toEqual(`${PENCIL.label}: 2, ${RULER.label}: 1`); + expect(component.summarySum).toEqual( + `${PENCIL.label}: 4, ${RULER.label}: 1`, + ); + expect(component.summaryAvg).toEqual( + `${PENCIL.label}: 2, ${RULER.label}: 1`, + ); }); it("loads all education data associated with a child and updates the summary", async () => { @@ -141,38 +188,41 @@ describe("EducationalMaterialComponent", () => { ); component.entity = new Child("22"); await component.ngOnInit(); - expect(component.summary).toEqual(`${PENCIL.label}: 1, ${RULER.label}: 2`); - expect(component.records).toEqual(educationalData); + expect(component.summarySum).toEqual( + `${PENCIL.label}: 1, ${RULER.label}: 2`, + ); + expect(component.data).toEqual(educationalData); }); - it("associates a new record with the current child", () => { - const newRecord = component.newRecordFactory(); - expect(newRecord.child).toBe(child.getId()); - }); + it("should update the summary when entity updates are received", fakeAsync(() => { + component.ngOnInit(); + tick(); - it("should update the summary when entity updates are received", async () => { const update1 = EducationalMaterial.create({ child: child.getId(), materialType: PENCIL, materialAmount: 1, }); updates.next({ entity: update1, type: "new" }); + tick(); - expect(component.records).toEqual([update1]); - expect(component.summary).toBe(`${PENCIL.label}: 1`); + expect(component.data).toEqual([update1]); + expect(component.summarySum).toBe(`${PENCIL.label}: 1`); const update2 = update1.copy() as EducationalMaterial; update2.materialAmount = 2; updates.next({ entity: update2, type: "update" }); + tick(); - expect(component.records).toEqual([update2]); - expect(component.summary).toBe(`${PENCIL.label}: 2`); + expect(component.data).toEqual([update2]); + expect(component.summarySum).toBe(`${PENCIL.label}: 2`); const unrelatedUpdate = update1.copy() as EducationalMaterial; unrelatedUpdate.child = "differentChild"; updates.next({ entity: unrelatedUpdate, type: "new" }); + tick(); // No change - expect(component.records).toEqual([update2]); - expect(component.summary).toBe(`${PENCIL.label}: 2`); - }); + expect(component.data).toEqual([update2]); + expect(component.summarySum).toBe(`${PENCIL.label}: 2`); + })); }); diff --git a/src/app/core/entity-details/related-entities-with-summary/related-entities-with-summary.component.ts b/src/app/core/entity-details/related-entities-with-summary/related-entities-with-summary.component.ts new file mode 100644 index 0000000000..2055435762 --- /dev/null +++ b/src/app/core/entity-details/related-entities-with-summary/related-entities-with-summary.component.ts @@ -0,0 +1,113 @@ +import { Component, Input, OnInit } from "@angular/core"; +import { NgFor, NgIf } from "@angular/common"; +import { DynamicComponent } from "../../config/dynamic-components/dynamic-component.decorator"; +import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy"; +import { EntitySubrecordComponent } from "../../common-components/entity-subrecord/entity-subrecord/entity-subrecord.component"; +import { RelatedEntitiesComponent } from "../related-entities/related-entities.component"; +import { Entity } from "../../entity/model/entity"; +import { filter } from "rxjs/operators"; +import { applyUpdate } from "../../entity/model/entity-update"; + +/** + * Load and display a list of entity subrecords (entities related to the current entity details view) + * including a summary below the table. + */ +@DynamicComponent("RelatedEntitiesWithSummary") +@UntilDestroy() +@Component({ + selector: "app-related-entities-with-summary", + templateUrl: "./related-entities-with-summary.component.html", + imports: [EntitySubrecordComponent, NgIf, NgFor], + standalone: true, +}) +export class RelatedEntitiesWithSummaryComponent + extends RelatedEntitiesComponent + implements OnInit +{ + /** + * Configuration of what numbers should be summarized below the table. + */ + @Input() summaries?: { + countProperty: string; + groupBy?: string; + total?: boolean; + average?: boolean; + }; + + summarySum = ""; + summaryAvg = ""; + + async ngOnInit() { + await super.ngOnInit(); + this.updateSummary(); + + this.entityMapper + .receiveUpdates(this.entityCtr) + .pipe( + untilDestroyed(this), + filter( + ({ entity, type }) => + type === "remove" || entity[this.property] === this.entity.getId(), + ), + ) + .subscribe((update) => { + this.data = applyUpdate(this.data, update); + this.updateSummary(); + }); + } + + /** + * update the summary or generate a new one. + * The summary contains no duplicates and is in a + * human-readable format + */ + updateSummary() { + if (!this.summaries) { + this.summarySum = ""; + this.summaryAvg = ""; + return; + } + + const summary = new Map(); + const average = new Map(); + + this.data.forEach((m) => { + const amount = m[this.summaries.countProperty]; + let groupLabel; + if (this.summaries.groupBy) { + groupLabel = + m[this.summaries.groupBy]?.label ?? m[this.summaries.groupBy]; + } + + summary.set(groupLabel, summary.get(groupLabel) || { count: 0, sum: 0 }); + summary.get(groupLabel).count++; + summary.get(groupLabel).sum += amount; + }); + + if (this.summaries.total) { + const summarySumArray = Array.from( + summary.entries(), + ([label, { sum }]) => `${label}: ${sum}`, + ); + this.summarySum = summarySumArray.join(", "); + } + + if (this.summaries.average) { + const summaryAvgArray = Array.from( + summary.entries(), + ([label, { count, sum }]) => { + const avg = parseFloat((sum / count).toFixed(2)); + average.set(label, avg); + return `${label}: ${avg}`; + }, + ); + this.summaryAvg = summaryAvgArray.join(", "); + } + + if (summary.size === 1 && summary.has(undefined)) { + // display only single summary without group label (this also applies if no groupBy is given) + this.summarySum = this.summarySum.replace("undefined: ", ""); + this.summaryAvg = this.summaryAvg.replace("undefined: ", ""); + } + } +} diff --git a/src/app/core/entity-details/related-entities/related-entities.component.spec.ts b/src/app/core/entity-details/related-entities/related-entities.component.spec.ts index 44c9b0eebd..49a1a1f2f5 100644 --- a/src/app/core/entity-details/related-entities/related-entities.component.spec.ts +++ b/src/app/core/entity-details/related-entities/related-entities.component.spec.ts @@ -30,7 +30,7 @@ describe("RelatedEntitiesComponent", () => { expect(component).toBeTruthy(); }); - it("should load the entities which are linked with the passed one", async () => { + it("should load only the entities which are linked with the passed one", async () => { const c1 = new Child(); const c2 = new Child(); const r1 = new ChildSchoolRelation(); @@ -52,7 +52,7 @@ describe("RelatedEntitiesComponent", () => { await component.ngOnInit(); expect(component.columns).toBe(columns); - expect(component.data).toEqual([r1, r2, r3]); + expect(component.data).toEqual([r1, r2]); expect(component.filter).toEqual({ ...filter, childId: c1.getId() }); }); diff --git a/src/app/core/entity-details/related-entities/related-entities.component.ts b/src/app/core/entity-details/related-entities/related-entities.component.ts index a6c9b1507a..7e5a4eb93e 100644 --- a/src/app/core/entity-details/related-entities/related-entities.component.ts +++ b/src/app/core/entity-details/related-entities/related-entities.component.ts @@ -50,7 +50,7 @@ export class RelatedEntitiesComponent implements OnInit { protected entityCtr: EntityConstructor; constructor( - private entityMapper: EntityMapperService, + protected entityMapper: EntityMapperService, private entities: EntityRegistry, ) {} @@ -64,7 +64,12 @@ export class RelatedEntitiesComponent implements OnInit { this.entityCtr = this.entities.get(this.entityType) as EntityConstructor; this.isArray = isArrayProperty(this.entityCtr, this.property); - this.data = await this.entityMapper.loadType(this.entityType); + this.data = (await this.entityMapper.loadType(this.entityType)).filter( + (e) => + this.isArray + ? e[this.property].includes(this.entity.getId()) + : e[this.property] === this.entity.getId(), + ); this.filter = { ...this.filter, [this.property]: this.isArray diff --git a/src/app/core/entity-details/related-time-period-entities/related-time-period-entities.component.spec.ts b/src/app/core/entity-details/related-time-period-entities/related-time-period-entities.component.spec.ts index 85bcc2e268..e5ad40422f 100644 --- a/src/app/core/entity-details/related-time-period-entities/related-time-period-entities.component.spec.ts +++ b/src/app/core/entity-details/related-time-period-entities/related-time-period-entities.component.spec.ts @@ -124,13 +124,14 @@ describe("RelatedTimePeriodEntitiesComponent", () => { }); it("should create a new entity with the start date inferred from previous relations", async () => { + const child = new Child(); const existingRelation = new ChildSchoolRelation(); existingRelation.start = moment().subtract(1, "year").toDate(); existingRelation.end = moment().subtract(1, "week").toDate(); + existingRelation.childId = child.getId(false); const loadType = spyOn(entityMapper, "loadType"); loadType.and.resolveTo([existingRelation]); - const child = new Child(); component.entity = child; await component.ngOnInit();