Skip to content

Commit

Permalink
feat(core): generalize related entities with summary component
Browse files Browse the repository at this point in the history
to be usable with any entity type, not just EducationalMaterial

closes #2004
  • Loading branch information
sleidig committed Oct 13, 2023
1 parent 27bf9d6 commit 2b1d059
Show file tree
Hide file tree
Showing 4 changed files with 117 additions and 104 deletions.
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
<app-entity-subrecord
[records]="records"
[columns]="config.columns"
[newRecordFactory]="newRecordFactory"
>
</app-entity-subrecord>
[records]="data"
[filter]="filter"
[columns]="columns"
[newRecordFactory]="createNewRecordFactory()"
[isLoading]="isLoading"
></app-entity-subrecord>

<div class="margin-top-large">
<ng-container *ngIf="summary">
<strong i18n>Total:</strong> {{ summary }} <br>
<strong i18n>Total:</strong> {{ summary }} <br />
</ng-container>
<ng-container *ngIf="avgSummary">
<strong i18n>Average:</strong> {{ avgSummary }} <br>
<strong i18n>Average:</strong> {{ avgSummary }} <br />
</ng-container>
</div>
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import { ComponentFixture, TestBed, waitForAsync } from "@angular/core/testing";
import {
ComponentFixture,
fakeAsync,
TestBed,
tick,
waitForAsync,
} from "@angular/core/testing";

import { EducationalMaterialComponent } from "./educational-material.component";
import { Child } from "../../model/child";
Expand Down Expand Up @@ -35,7 +41,16 @@ describe("EducationalMaterialComponent", () => {
fixture = TestBed.createComponent(EducationalMaterialComponent);
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();
});

Expand All @@ -44,7 +59,7 @@ 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);
Expand All @@ -53,7 +68,7 @@ describe("EducationalMaterialComponent", () => {
function setRecordsAndGenerateSummary(
...records: Partial<EducationalMaterial>[]
) {
component.records = records.map(EducationalMaterial.create);
component.data = records.map(EducationalMaterial.create);
component.updateSummary();
}

Expand All @@ -69,7 +84,9 @@ describe("EducationalMaterialComponent", () => {
{ materialType: RULER, materialAmount: 1 },
);
expect(component.summary).toEqual(`${PENCIL.label}: 2, ${RULER.label}: 1`);
expect(component.avgSummary).toEqual(`${PENCIL.label}: 2, ${RULER.label}: 1`);
expect(component.avgSummary).toEqual(
`${PENCIL.label}: 2, ${RULER.label}: 1`,
);
});

it("produces a summary of all records when there are duplicates", () => {
Expand All @@ -80,11 +97,14 @@ describe("EducationalMaterialComponent", () => {
);

expect(component.summary).toEqual(`${PENCIL.label}: 4, ${RULER.label}: 1`);
expect(component.avgSummary).toEqual(`${PENCIL.label}: 2, ${RULER.label}: 1`);
expect(component.avgSummary).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 },
Expand All @@ -96,39 +116,44 @@ describe("EducationalMaterialComponent", () => {
});

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.avgSummary).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(``);
});

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.avgSummary).toEqual(
`${PENCIL.label}: 2, ${RULER.label}: 1`,
);
});

it("loads all education data associated with a child and updates the summary", async () => {
Expand All @@ -142,37 +167,38 @@ 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.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.data).toEqual([update1]);
expect(component.summary).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.data).toEqual([update2]);
expect(component.summary).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.data).toEqual([update2]);
expect(component.summary).toBe(`${PENCIL.label}: 2`);
});
}));
});
Original file line number Diff line number Diff line change
@@ -1,120 +1,106 @@
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";
import { RelatedEntitiesComponent } from "../../../../core/entity-details/related-entities/related-entities.component";
import { Entity } from "../../../../core/entity/model/entity";
import { filter } from "rxjs/operators";
import { applyUpdate } from "../../../../core/entity/model/entity-update";

/**
* Displays educational materials of a child, such as a pencil, rulers, e.t.c
* as well as a summary
* Load and display a list of entity subrecords (entities related to the current entity details view)
* including a summary below the table.
*/
@DynamicComponent("EducationalMaterial")
@UntilDestroy()
@Component({
selector: "app-educational-material",
templateUrl: "./educational-material.component.html",
imports: [
EntitySubrecordComponent,
NgIf,
NgFor
],
imports: [EntitySubrecordComponent, NgIf, NgFor],
standalone: true,
})
export class EducationalMaterialComponent implements OnInit {
@Input() entity: Child;
@Input() summaries: { total?: boolean; average?: boolean } = { total: true };
records: EducationalMaterial[] = [];
export class EducationalMaterialComponent<E extends Entity = Entity>
extends RelatedEntitiesComponent<E>
implements OnInit
{
/**
* Configuration of what numbers should be summarized below the table.
*/
@Input() summaries?: {
countProperty: string;
groupBy?: string;
total?: boolean;
average?: boolean;
};

summary = "";
avgSummary = "";

@Input() config: { columns: FormFieldConfig[] } = {
columns: [
{ id: "date", visibleFrom: "xs" },
{ id: "materialType", visibleFrom: "xs" },
{ id: "materialAmount", visibleFrom: "md" },
{ id: "description", visibleFrom: "md" },
],
};
async ngOnInit() {
await super.ngOnInit();
this.updateSummary();

constructor(private entityMapper: EntityMapperService) {
this.entityMapper
.receiveUpdates(EducationalMaterial)
.receiveUpdates(this.entityCtr)
.pipe(
untilDestroyed(this),
filter(
({ entity, type }) =>
type === "remove" || entity.child === this.entity.getId(),
type === "remove" || entity[this.property] === this.entity.getId(),
),
)
.subscribe((update) => {
this.records = applyUpdate(this.records, update);
this.data = applyUpdate(this.data, 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() {
if (!this.summaries) {
this.summary = "";
this.avgSummary = "";
return;
}

const summary = new Map<string, { count: number; sum: number }>();
const average = new Map<string, number>();

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;
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 summaryArray = Array.from(summary.entries(), ([label, { sum }]) => `${label}: ${sum}`);

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}`;
});

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(", ");
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ export class RelatedEntitiesComponent<E extends Entity> implements OnInit {
protected entityCtr: EntityConstructor<E>;

constructor(
private entityMapper: EntityMapperService,
protected entityMapper: EntityMapperService,
private entities: EntityRegistry,
) {}

Expand Down

0 comments on commit 2b1d059

Please sign in to comment.