src/app/features/dashboard-widgets/entity-count-dashboard-widget/entity-count-dashboard/entity-count-dashboard.component.ts
| changeDetection | ChangeDetectionStrategy.OnPush |
| selector | app-entity-count-dashboard-widget |
| standalone | true |
| imports |
MatTableModule
FontAwesomeModule
Angulartics2Module
MatTooltipModule
MatIconButton
|
| styleUrls | ./entity-count-dashboard.component.scss |
| templateUrl | ./entity-count-dashboard.component.html |
No results matching.
MatTableModule
FontAwesomeModule
Angulartics2Module
DashboardListWidgetComponent
MatTooltipModule
MatIconButton
EntityFieldLabelComponent
EntityFieldViewComponent
Properties |
Methods |
Inputs |
constructor()
|
| entityType | |
Type : string
|
|
Default value : "Child"
|
|
| explanation | |
Type : string
|
|
Default value : $localize`:dashboard widget explanation:Counting all "active" records. If configured, you can view different disaggregations by using the arrows below.`
|
|
| groupBy | |
Type : string[]
|
|
Default value : ["center", "gender"]
|
|
| subtitle | |
Type : string
|
|
| getNext |
getNext()
|
|
Returns :
void
|
| getPrev |
getPrev()
|
|
Returns :
void
|
| Static getRequiredEntities | ||||||
getRequiredEntities(config: EntityCountDashboardConfig)
|
||||||
|
Parameters :
Returns :
string
|
| goToEntityList | ||||||
goToEntityList(filterId: string)
|
||||||
|
Parameters :
Returns :
void
|
| currentGroupIndex |
Type : unknown
|
Default value : signal(0)
|
| entityDefinition |
Type : unknown
|
Default value : computed(() => this.entities.get(this.entityType()))
|
| totalEntities |
Type : unknown
|
Default value : computed(
() => this.rawEntities().filter((entity) => !entity.inactive).length,
)
|
import {
ChangeDetectionStrategy,
Component,
computed,
effect,
inject,
input,
signal,
untracked,
} from "@angular/core";
import { Router } from "@angular/router";
import { MatIconButton } from "@angular/material/button";
import { MatTableModule } from "@angular/material/table";
import { MatTooltipModule } from "@angular/material/tooltip";
import { FontAwesomeModule } from "@fortawesome/angular-fontawesome";
import { Angulartics2Module } from "angulartics2";
import { ConfigurableEnumService } from "app/core/basic-datatypes/configurable-enum/configurable-enum.service";
import { ConfigurableEnumValue } from "app/core/basic-datatypes/configurable-enum/configurable-enum.types";
import { DynamicComponent } from "#src/app/core/config/dynamic-components/dynamic-component.decorator";
import { DashboardListWidgetComponent } from "#src/app/core/dashboard/dashboard-list-widget/dashboard-list-widget.component";
import { EntityRegistry } from "#src/app/core/entity/database-entity.decorator";
import { getEntityRuntimeRoute } from "#src/app/core/entity/entity-config.service";
import { EntityFieldLabelComponent } from "#src/app/core/entity/entity-field-label/entity-field-label.component";
import { EntityMapperService } from "#src/app/core/entity/entity-mapper/entity-mapper.service";
import { Entity, EntityConstructor } from "#src/app/core/entity/model/entity";
import { applyUpdate } from "#src/app/core/entity/model/entity-update";
import { groupBy } from "#src/app/utils/utils";
import { EntityFieldViewComponent } from "#src/app/core/entity/entity-field-view/entity-field-view.component";
/**
* Configuration (stored in Config document in the DB) for the dashboard widget.
*/
export interface EntityCountDashboardConfig {
entityType?: string;
groupBy?: string[];
}
/**
* Details of one row of disaggregated counts (e.g. for a specific category value) to be displayed.
*/
interface GroupCountRow {
label: string | undefined;
id: string;
/**
* The count of entities part of this group
*/
value: number;
isInvalidOption?: boolean;
color?: string;
/**
* entity with the field value set for display component rendering
*/
entity?: Entity;
/**
* Field name for the display component
*/
fieldName?: string;
}
@DynamicComponent("ChildrenCountDashboard")
@DynamicComponent("EntityCountDashboard")
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: "app-entity-count-dashboard-widget",
templateUrl: "./entity-count-dashboard.component.html",
styleUrls: ["./entity-count-dashboard.component.scss"],
imports: [
MatTableModule,
FontAwesomeModule,
Angulartics2Module,
DashboardListWidgetComponent,
MatTooltipModule,
MatIconButton,
EntityFieldLabelComponent,
EntityFieldViewComponent,
],
})
export class EntityCountDashboardComponent {
private readonly entityMapper = inject(EntityMapperService);
private readonly router = inject(Router);
private readonly entities = inject(EntityRegistry);
private readonly configurableEnum = inject(ConfigurableEnumService);
private rawEntities = signal<Entity[]>([]);
currentGroupIndex = signal(0);
entityType = input("Child");
groupBy = input<string[]>(["center", "gender"]);
subtitle = input<string>();
explanation = input<string>(
$localize`:dashboard widget explanation:Counting all "active" records. If configured, you can view different disaggregations by using the arrows below.`,
);
entityDefinition = computed(() => this.entities.get(this.entityType()));
totalEntities = computed(
() => this.rawEntities().filter((entity) => !entity.inactive).length,
);
entityGroupCounts = computed<Record<string, GroupCountRow[]>>(() => {
const result: Record<string, GroupCountRow[]> = {};
const entityDefinition = this.entityDefinition();
const activeEntities = this.rawEntities().filter(
(entity) => !entity.inactive,
);
for (const groupByField of this.groupBy()) {
result[groupByField] = this.calculateGroupCounts(
activeEntities,
groupByField,
entityDefinition,
);
}
return result;
});
currentGroupField = computed(() => {
const groupBy = this.groupBy();
if (groupBy.length === 0) {
return undefined;
}
return groupBy[this.currentGroupIndex() % groupBy.length];
});
constructor() {
effect((onCleanup) => {
const entityType = this.entityType();
const entityDefinition = this.entityDefinition();
let isCurrent = true;
this.rawEntities.set([]);
this.currentGroupIndex.set(0);
untracked(async () => {
const entities = await this.entityMapper.loadType(entityDefinition);
if (isCurrent) {
this.rawEntities.set(entities);
}
});
const subscription = this.entityMapper
.receiveUpdates(entityType)
.subscribe((update) => {
this.rawEntities.update(
(current) => applyUpdate(current, update) as Entity[],
);
});
onCleanup(() => {
isCurrent = false;
subscription.unsubscribe();
});
});
}
getPrev() {
const groupBy = this.groupBy();
if (groupBy.length === 0) {
return;
}
this.currentGroupIndex.update(
(index) => (index - 1 + groupBy.length) % groupBy.length,
);
}
getNext() {
const groupBy = this.groupBy();
if (groupBy.length === 0) {
return;
}
this.currentGroupIndex.update((index) => (index + 1) % groupBy.length);
}
static getRequiredEntities(config: EntityCountDashboardConfig) {
return config?.entityType || "Child";
}
private calculateGroupCounts(
entities: Entity[],
fieldName: string,
entityDefinition: EntityConstructor,
): GroupCountRow[] {
const field = entityDefinition.schema.get(fieldName);
let groupCounts = this.getGroupCounts(
entities,
fieldName,
entityDefinition,
);
groupCounts = this.mergeNotDefinedGroups(groupCounts);
if (field?.dataType === "configurable-enum") {
return this.getEnumGroupCounts(groupCounts, field);
}
return groupCounts;
}
/** Groups entities by field and returns initial groupCounts array */
private getGroupCounts(
entities: Entity[],
fieldName: string,
entityDefinition: EntityConstructor,
): GroupCountRow[] {
const groups = groupBy(entities, fieldName as keyof Entity);
return groups.map(([group, groupedEntities]) => {
const row: GroupCountRow = {
label: extractHumanReadableLabel(group),
value: groupedEntities.length,
id: extractGroupId(group),
fieldName,
};
if (group !== undefined && group !== null && group !== "") {
const entity = new entityDefinition();
entity[fieldName] = group;
row.entity = entity;
}
return row;
});
}
/** Merges "" and undefined groups into a single group with label: undefined */
private mergeNotDefinedGroups(groupCounts: GroupCountRow[]): GroupCountRow[] {
const notDefinedGroups = groupCounts.filter(
(group) => group.id === "" || group.id === undefined,
);
if (notDefinedGroups.length > 1) {
const merged = {
label: undefined,
value: notDefinedGroups.reduce((sum, group) => sum + group.value, 0),
id: "",
};
return [
merged,
...groupCounts.filter(
(group) => group.id !== "" && group.id !== undefined,
),
];
}
return groupCounts;
}
/** Handles groupCounts for configurable-enum fields, including color and invalid option merging */
private getEnumGroupCounts(
groupCounts: GroupCountRow[],
field: any,
): GroupCountRow[] {
const enumValues = this.configurableEnum.getEnumValues(field.additional);
const validIds = new Set(enumValues.map((enumValue) => enumValue.id));
const groupCountsMap = new Map(
groupCounts.map((aggregate) => [aggregate.id, aggregate]),
);
const invalidGroups = groupCounts.filter(
(group) => group.id && !validIds.has(group.id),
);
let invalidOptionRow: GroupCountRow | undefined;
if (invalidGroups.length > 0) {
invalidOptionRow = {
label: undefined,
value: invalidGroups.reduce((sum, group) => sum + group.value, 0),
id: "__invalid__",
isInvalidOption: true,
};
}
let groupCountSorted = enumValues
.map((enumValue) => {
const group = groupCountsMap.get(enumValue.id);
if (group) {
return enumValue.color !== undefined
? { ...group, color: enumValue.color }
: { ...group };
}
return undefined;
})
.filter(Boolean);
if (groupCountsMap.has("")) {
const mergedGroup = groupCountsMap.get("");
groupCountSorted.unshift(mergedGroup);
}
if (invalidOptionRow) {
groupCountSorted = [invalidOptionRow, ...groupCountSorted];
}
return groupCountSorted;
}
goToEntityList(filterId: string) {
const field = this.currentGroupField();
if (!field) {
return;
}
const params = {};
params[field] = encodeURIComponent(filterId);
this.router.navigate([getEntityRuntimeRoute(this.entityDefinition())], {
queryParams: params,
});
}
}
/**
* Get a human-readable string from the given value as a label.
* @param value
*/
function extractHumanReadableLabel(
value: string | ConfigurableEnumValue | any,
): string | undefined {
if (value === undefined || value === null || value === "") {
return undefined;
}
if (typeof value === "string") {
return value;
}
if (value?.label) {
return value.label;
}
return String(value);
}
/**
* Extract a group ID from a group value (string, object, etc.)
*/
function extractGroupId(group: any): string {
if (group === undefined || group === null || group === "") {
return "";
}
if (typeof group === "object" && "id" in group) {
return group.id;
}
return group;
}
<app-dashboard-list-widget
[icon]="entityDefinition().icon"
theme="child"
[title]="totalEntities()"
[subtitle]="subtitle() ?? entityDefinition().labelPlural"
[explanation]="explanation()"
[entries]="
currentGroupField()
? (entityGroupCounts()[currentGroupField() ?? ''] ?? [])
: []
"
[paginationPageSize]="4"
>
@if (currentGroupField(); as groupField) {
<div class="flex-row navigation-controls">
<button
mat-icon-button
matTooltip="Previous Grouping"
i18n-matTooltip
i18n-caption
caption="Previous"
i18n-aria-label
aria-label="Previous grouping"
(click)="getPrev()"
[style.visibility]="groupBy().length > 1 ? 'visible' : 'hidden'"
[style.transform]="'scale(0.75)'"
>
<fa-icon icon="circle-chevron-left" size="xs"></fa-icon>
</button>
<span
class="flex-grow groupby-label"
i18n="category of disagregation in EntityCountDashboard"
>
by
<app-entity-field-label
[entityType]="entityDefinition()"
[field]="groupField"
>
</app-entity-field-label>
:
</span>
<button
mat-icon-button
matTooltip="Next Grouping"
i18n-matTooltip
i18n-caption
caption="Next"
i18n-aria-label
aria-label="Next grouping"
(click)="getNext()"
[style.visibility]="groupBy().length > 1 ? 'visible' : 'hidden'"
[style.transform]="'scale(0.75)'"
>
<fa-icon icon="circle-chevron-right"></fa-icon>
</button>
</div>
<div class="label-separator"></div>
}
<div class="table-wrapper">
<table
mat-table
i18n-aria-label="Label for children count dashboard"
aria-label="Table showing disaggregation of the beneficiaries"
>
<ng-container matColumnDef="label">
<td *matCellDef="let group" class="label-cell">
@if (group.isInvalidOption) {
<span
class="invalid-label"
matTooltip="records with a value that is not matching any available dropdown option"
i18n-matTooltip
i18n
>
[invalid option]
</span>
} @else if (group.label === undefined) {
<span
class="not-defined-label"
matTooltip="records where this field is empty"
i18n-matTooltip
i18n
>not defined</span
>
} @else if (group.entity && group.fieldName) {
<app-entity-field-view
[entity]="group.entity"
[field]="group.fieldName"
></app-entity-field-view>
}
</td>
</ng-container>
<ng-container matColumnDef="value">
<td *matCellDef="let group" class="text-align-end padding-left-regular">
{{ group.value }}
</td>
</ng-container>
<ng-container matColumnDef="link">
<td *matCellDef class="text-align-end">
<fa-icon icon="external-link-square-alt"></fa-icon>
</td>
</ng-container>
<!-- Table header only for assistive technologies like screen readers -->
<tr class="visually-hidden">
<th scope="col" i18n="The category the count is grouped by">
Category
</th>
<th scope="col" i18n="The count of records in the category">Count</th>
<th scope="col" i18n="A link to the filtered list for that category">
Link
</th>
</tr>
<tr
mat-row
*matRowDef="let row; let i = index; columns: ['label', 'value', 'link']"
(click)="goToEntityList(row.id)"
class="pointer"
angulartics2On="click"
angularticsCategory="Navigation"
angularticsAction="dashboard_children_count_school_link"
[angularticsLabel]="'list-entry-' + i"
></tr>
</table>
</div>
</app-dashboard-list-widget>
./entity-count-dashboard.component.scss
@use "../../../../core/dashboard/dashboard-widget-base";
@use "../../../../core/common-components/special-option-labels.scss";
.groupby-label {
margin: auto;
text-align: center;
overflow: hidden;
text-overflow: ellipsis;
}
.label-separator {
border-top: 1px solid #ccc;
margin: 8px 0 0 0;
}
.mat-mdc-table {
.mat-mdc-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
align-items: center;
column-gap: 8px;
}
}
.label-cell ::ng-deep app-entity-block.display-entity {
display: block;
min-width: 0;
}
.label-cell ::ng-deep app-entity-block .truncate-text {
display: block;
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.navigation-controls {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 10px;
}