src/app/features/dashboard-widgets/entity-count-dashboard-widget/entity-count-dashboard/entity-count-dashboard.component.ts

Metadata

Relationships

Used by

No results matching.

Depends on

Index

Properties
Methods
Inputs

Constructor

constructor()

Inputs

entityType
Type : string
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

Methods

getNext
getNext()
Returns : void
getPrev
getPrev()
Returns : void
Static getRequiredEntities
getRequiredEntities(config: EntityCountDashboardConfig)
Parameters :
Name Type Optional
config EntityCountDashboardConfig No
Returns : string | undefined
goToEntityList
goToEntityList(filterId: string)
Parameters :
Name Type Optional
filterId string No
Returns : void

Properties

currentGroupField
Type : unknown
Default value : computed(() => { const groupBy = this.groupBy(); if (groupBy.length === 0) { return undefined; } return groupBy[this.currentGroupIndex() % groupBy.length]; })
currentGroupIndex
Type : unknown
Default value : signal(0)
entityDefinition
Type : unknown
Default value : computed(() => { const type = this.entityType(); return type && this.entities.has(type) ? this.entities.get(type) : undefined; })

undefined when unconfigured or the configured type isn't registered, rather than throwing.

entityGroupCounts
Type : unknown
Default value : computed<Record<string, GroupCountRow[]>>(() => { const result: Record<string, GroupCountRow[]> = {}; const entityDefinition = this.entityDefinition(); if (!entityDefinition) { return result; } const activeEntities = this.rawEntities().filter( (entity) => !entity.inactive, ); for (const groupByField of this.groupBy()) { result[groupByField] = this.calculateGroupCounts( activeEntities, groupByField, entityDefinition, ); } return result; })
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<string>();
  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.`,
  );

  /** `undefined` when unconfigured or the configured type isn't registered, rather than throwing. */
  entityDefinition = computed(() => {
    const type = this.entityType();
    return type && this.entities.has(type)
      ? this.entities.get(type)
      : undefined;
  });
  totalEntities = computed(
    () => this.rawEntities().filter((entity) => !entity.inactive).length,
  );

  entityGroupCounts = computed<Record<string, GroupCountRow[]>>(() => {
    const result: Record<string, GroupCountRow[]> = {};
    const entityDefinition = this.entityDefinition();
    if (!entityDefinition) {
      return result;
    }
    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();

      this.rawEntities.set([]);
      this.currentGroupIndex.set(0);

      if (!entityType || !entityDefinition) {
        return;
      }

      let isCurrent = true;

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

  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();
    const entityDefinition = this.entityDefinition();
    if (!field || !entityDefinition) {
      return;
    }

    const params = {};
    params[field] = encodeURIComponent(filterId);

    this.router.navigate([getEntityRuntimeRoute(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;
}
@if (entityDefinition(); as definition) {
  <app-dashboard-list-widget
    [icon]="definition.icon"
    theme="child"
    [title]="totalEntities()"
    [subtitle]="subtitle() ?? definition.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"
            class="mirror-in-rtl"
          ></fa-icon>
        </button>

        <span
          class="flex-grow groupby-label"
          i18n="category of disagregation in EntityCountDashboard"
        >
          by
          <app-entity-field-label
            [entityType]="definition"
            [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" class="mirror-in-rtl"></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-start-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;
}
Legend
Html element
Component
Html element with directive

results matching ""

    No results matching ""