Skip to content

Commit

Permalink
feat: configurable date range filter for lists (#1487)
Browse files Browse the repository at this point in the history
closes #1448, #1383

MIGRATION REQUIRED: "type": "prebuilt" has to be removed from existing filter configs.

Co-authored-by: Sebastian <sleidig@users.noreply.github.com>
Co-authored-by: Sebastian Leidig <sebastian.leidig@gmail.com>
Co-authored-by: Simon <simon@aam-digital.com>
4 people authored Jun 28, 2023
1 parent a689b02 commit bffeb5c
Showing 32 changed files with 1,061 additions and 441 deletions.
6 changes: 3 additions & 3 deletions e2e/integration/MarkingChildAsDropout.cy.ts
Original file line number Diff line number Diff line change
@@ -3,8 +3,6 @@ describe("Scenario: Marking a child as dropout - E2E test", function () {
// go to a child
cy.visit("child");
cy.get("tr").eq(2).click();
// save the name of this Child to the variable
cy.get(".mat-title > .remove-margin-bottom").invoke("text").as("childName");
});

it("WHEN I select a dropout date for this child", () => {
@@ -19,6 +17,8 @@ describe("Scenario: Marking a child as dropout - E2E test", function () {
cy.get(".mat-calendar-body-active:visible").click();
// click on button with the content "Save"
cy.get(".form-buttons-wrapper:visible").contains("button", "Save").click();
// save the name of this Child to the variable
cy.get(".mat-title > .remove-margin-bottom").invoke("text").as("childName");
});

it("THEN I should not see this child in the list of all children at first", function () {
@@ -34,7 +34,7 @@ describe("Scenario: Marking a child as dropout - E2E test", function () {

it("AND I should see the child when I activate the 'inactive' filter", function () {
// click on the button with the content "Inactive"
cy.get('[ng-reflect-placeholder="isActive"]').click();
cy.contains("span", "Active").click();
cy.contains("span", "Inactive").should("be.visible").click();
// find at this table the name of child and it should exist
cy.get("table").contains(this.childName.trim()).should("exist");
10 changes: 4 additions & 6 deletions e2e/support/commands.ts
Original file line number Diff line number Diff line change
@@ -27,13 +27,11 @@ Cypress.Commands.add("create", create);
// Overwriting default visit function to wait for index creation
Cypress.Commands.overwrite("visit", (originalFun, url, options) => {
originalFun(url, options);
cy.get("app-search", { timeout: 10000 }).should("be.visible");
cy.get("app-search", { timeout: 20000 }).should("be.visible");
// wait for demo data generation
cy.wait(4000);
cy.contains("div", "Generating sample data", {
timeout: 20000,
}).should("not.exist");
// wait for indexing
cy.contains("button", "Continue in background", { timeout: 20000 }).should(
"exist"
);
cy.contains("button", "Continue in background", { timeout: 10000 }).should(
"not.exist"
);
11 changes: 9 additions & 2 deletions src/app/app.module.ts
Original file line number Diff line number Diff line change
@@ -17,7 +17,7 @@

import { BrowserModule } from "@angular/platform-browser";
import { BrowserAnimationsModule } from "@angular/platform-browser/animations";
import { ErrorHandler, LOCALE_ID, NgModule } from "@angular/core";
import { ErrorHandler, Inject, LOCALE_ID, NgModule } from "@angular/core";
import { HttpClientModule } from "@angular/common/http";

import { AppComponent } from "./app.component";
@@ -75,6 +75,8 @@ import { ProgressDashboardWidgetModule } from "./features/progress-dashboard-wid
import { ReportingModule } from "./features/reporting/reporting.module";
import { RouterModule } from "@angular/router";
import { TodosModule } from "./features/todos/todos.module";
import moment from "moment";
import { getLocaleFirstDayOfWeek } from "@angular/common";
import { SessionService } from "./core/session/session-service/session.service";
import { waitForChangeTo } from "./core/session/session-states/session-utils";
import { LoginState } from "./core/session/session-states/login-state.enum";
@@ -160,7 +162,12 @@ import { appInitializers } from "./app-initializers";
bootstrap: [AppComponent],
})
export class AppModule {
constructor(icons: FaIconLibrary) {
constructor(icons: FaIconLibrary, @Inject(LOCALE_ID) locale: string) {
icons.addIconPacks(fas, far);
moment.updateLocale(moment.locale(), {
week: {
dow: getLocaleFirstDayOfWeek(locale),
},
});
}
}
Original file line number Diff line number Diff line change
@@ -45,7 +45,7 @@ describe("AttendanceWeekDashboardComponent", () => {
it("should display children with low attendance", async () => {
const absentChild = new Child();
const presentChild = new Child();
const mondayLastWeek = moment().startOf("week").subtract(6, "days");
const mondayLastWeek = moment().startOf("isoWeek").subtract(7, "days");
const e1 = EventNote.create(mondayLastWeek.toDate());
const e2 = EventNote.create(moment(e1.date).add(1, "day").toDate());
const presentStatus = defaultAttendanceStatusTypes.find(
@@ -92,7 +92,7 @@ describe("AttendanceWeekDashboardComponent", () => {

it("should correctly use the offset", () => {
// default case: last week monday till saturday
const mondayLastWeek = moment().startOf("week").subtract(6, "days");
const mondayLastWeek = moment().startOf("isoWeek").subtract(7, "days");
const saturdayLastWeek = mondayLastWeek.clone().add("5", "days");
mockAttendanceService.getAllActivityAttendancesForPeriod.calls.reset();

@@ -103,7 +103,7 @@ describe("AttendanceWeekDashboardComponent", () => {
).toHaveBeenCalledWith(mondayLastWeek.toDate(), saturdayLastWeek.toDate());

// with offset: this week monday till saturday
const mondayThisWeek = moment().startOf("week").add(1, "day");
const mondayThisWeek = moment().startOf("isoWeek");
const saturdayThisWeek = mondayThisWeek.clone().add(5, "days");
mockAttendanceService.getAllActivityAttendancesForPeriod.calls.reset();

Original file line number Diff line number Diff line change
@@ -3,7 +3,7 @@ import { Note } from "../model/note";
import { NoteDetailsComponent } from "../note-details/note-details.component";
import { ActivatedRoute } from "@angular/router";
import { EntityMapperService } from "../../../core/entity/entity-mapper.service";
import { FilterSelectionOption } from "../../../core/filter/filter-selection/filter-selection";
import { FilterSelectionOption } from "../../../core/filter/filters/filters";
import { SessionService } from "../../../core/session/session-service/session.service";
import { FormDialogService } from "../../../core/form-dialog/form-dialog.service";
import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy";
4 changes: 3 additions & 1 deletion src/app/core/config/config-fix.ts
Original file line number Diff line number Diff line change
@@ -2,6 +2,7 @@ import { Child } from "../../child-dev-project/children/model/child";
import { School } from "../../child-dev-project/schools/model/school";
import { ChildSchoolRelation } from "../../child-dev-project/children/model/childSchoolRelation";
import { EventNote } from "../../child-dev-project/attendance/model/event-note";
import { defaultDateFilters } from "../filter/date-range-filter/date-range-filter-panel/date-range-filter-panel.component";

// prettier-ignore
export const defaultJsonConfig = {
@@ -215,7 +216,8 @@ export const defaultJsonConfig = {
},
{
"id": "date",
"type": "prebuilt"
"default": 1,
"options": defaultDateFilters
},
{
"id": "category"
16 changes: 15 additions & 1 deletion src/app/core/entity-components/entity-list/EntityListConfig.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { FilterSelectionOption } from "../../filter/filter-selection/filter-selection";
import { FilterSelectionOption } from "../../filter/filters/filters";
import { FormFieldConfig } from "../entity-form/entity-form/FormConfig";
import { ExportColumnConfig } from "../../export/data-transformation-service/export-column-config";
import { Sort } from "@angular/material/sort";
import { unitOfTime } from "moment";

export interface EntityListConfig {
/**
@@ -89,6 +90,19 @@ export interface BooleanFilterConfig extends BasicFilterConfig {
all: string;
}

export interface PrebuiltFilterConfig<T> extends BasicFilterConfig {
options: FilterSelectionOption<T>[];
}
export interface DateRangeFilterConfig extends BasicFilterConfig {
options: DateRangeFilterConfigOption[];
}

export interface DateRangeFilterConfigOption {
startOffsets?: { amount: number; unit: unitOfTime.Base }[];
endOffsets?: { amount: number; unit: unitOfTime.Base }[];
label: string;
}

export interface PrebuiltFilterConfig<T> extends BasicFilterConfig {
options: FilterSelectionOption<T>[];
}

This file was deleted.

Original file line number Diff line number Diff line change
@@ -11,7 +11,14 @@ import { Child } from "../../../child-dev-project/children/model/child";
import moment from "moment";
import { MockedTestingModule } from "../../../utils/mocked-testing.module";
import { FilterService } from "../../filter/filter.service";
import { FilterSelectionOption } from "../../filter/filter-selection/filter-selection";
import {
BooleanFilter,
ConfigurableEnumFilter,
DateFilter,
EntityFilter,
FilterSelectionOption,
SelectableFilter,
} from "../../filter/filters/filters";
import { Entity } from "../../entity/model/entity";

describe("FilterGeneratorService", () => {
@@ -36,15 +43,18 @@ describe("FilterGeneratorService", () => {
true: "Private",
false: "Government",
all: "All",
type: "boolean",
};
const schema = School.schema.get("privateSchool");

const filter = (await service.generate([filterConfig], School, []))[0];
const filter = (
await service.generate([filterConfig], School, [])
)[0] as BooleanFilter<School>;

expect(filter.filterSettings.label).toEqual(schema.label);
expect(filter.filterSettings.name).toEqual("privateSchool");
expect(filter.label).toEqual(schema.label);
expect(filter.name).toEqual("privateSchool");
expect(
filter.filterSettings.options.map((option) => {
filter.options.map((option) => {
return { key: option.key, label: option.label };
})
).toEqual([
@@ -63,13 +73,16 @@ describe("FilterGeneratorService", () => {
);
const schema = Note.schema.get("category");

let filterSettings = (
let filterOptions = (
await service.generate([{ id: "category" }], Note, [])
)[0].filterSettings;
)[0] as ConfigurableEnumFilter<Note>;

expect(filterSettings.label).toEqual(schema.label);
expect(filterSettings.name).toEqual("category");
expect(filterSettings.options).toEqual(
expect(filterOptions.label).toEqual(schema.label);
expect(filterOptions.name).toEqual("category");
let comparableOptions = filterOptions.options.map((option) => {
return { key: option.key, label: option.label };
});
expect(comparableOptions).toEqual(
jasmine.arrayWithExactContents(interactionTypes)
);

@@ -80,11 +93,14 @@ describe("FilterGeneratorService", () => {
};
Note.schema.set("otherEnum", schemaAdditional);

filterSettings = (
filterOptions = (
await service.generate([{ id: "otherEnum" }], Note, [])
)[0].filterSettings;
)[0] as ConfigurableEnumFilter<Note>;

expect(filterSettings.options).toEqual(
comparableOptions = filterOptions.options.map((option) => {
return { key: option.key, label: option.label };
});
expect(comparableOptions).toEqual(
jasmine.arrayWithExactContents(interactionTypes)
);

@@ -96,11 +112,13 @@ describe("FilterGeneratorService", () => {
};
Note.schema.set("otherEnum", schemaArray);

filterSettings = (
filterOptions = (
await service.generate([{ id: "otherEnum" }], Note, [])
)[0].filterSettings;

expect(filterSettings.options).toEqual(
)[0] as ConfigurableEnumFilter<Note>;
comparableOptions = filterOptions.options.map((option) => {
return { key: option.key, label: option.label };
});
expect(comparableOptions).toEqual(
jasmine.arrayWithExactContents(interactionTypes)
);

@@ -111,14 +129,14 @@ describe("FilterGeneratorService", () => {
];

// indices are increased by one as first option is "all"
expect(filter([note], filterSettings.options[2])).toEqual([note]);
expect(filter([note], filterSettings.options[3])).toEqual([note]);
expect(filter([note], filterSettings.options[4])).toEqual([]);
expect(filter([note], filterOptions.options[2])).toEqual([note]);
expect(filter([note], filterOptions.options[3])).toEqual([note]);
expect(filter([note], filterOptions.options[4])).toEqual([]);

Note.schema.delete("otherEnum");
});

it("should create a entity filter", async () => {
it("should create an entity filter", async () => {
const school1 = new School();
school1.name = "First School";
const school2 = new School();
@@ -136,22 +154,20 @@ describe("FilterGeneratorService", () => {

const filterOptions = (
await service.generate([{ id: "schoolId" }], ChildSchoolRelation, [])
)[0];
)[0] as EntityFilter<Child>;

expect(filterOptions.filterSettings.label).toEqual(schema.label);
expect(filterOptions.filterSettings.name).toEqual("schoolId");
expect(filterOptions.label).toEqual(schema.label);
expect(filterOptions.name).toEqual("schoolId");
const allRelations = [csr1, csr2, csr3, csr4];
const allFilter = filterOptions.filterSettings.options.find(
(opt) => opt.key === "all"
);
const allFilter = filterOptions.options.find((opt) => opt.key === "all");
expect(allFilter.label).toEqual("All");
expect(filter(allRelations, allFilter)).toEqual(allRelations);
const school1Filter = filterOptions.filterSettings.options.find(
const school1Filter = filterOptions.options.find(
(opt) => opt.key === school1.getId()
);
expect(school1Filter.label).toEqual(school1.name);
expect(filter(allRelations, school1Filter)).toEqual([csr1, csr4]);
const school2Filter = filterOptions.filterSettings.options.find(
const school2Filter = filterOptions.options.find(
(opt) => opt.key === school2.getId()
);
expect(school2Filter.label).toEqual(school2.name);
@@ -173,11 +189,11 @@ describe("FilterGeneratorService", () => {
child2,
child3,
])
)[0];
)[0] as SelectableFilter<Child>;

expect(filter.filterSettings.label).toEqual(schema.label);
expect(filter.filterSettings.name).toEqual("religion");
const comparableOptions = filter.filterSettings.options.map((option) => {
expect(filter.label).toEqual(schema.label);
expect(filter.name).toEqual("religion");
const comparableOptions = filter.options.map((option) => {
return { key: option.key, label: option.label };
});
expect(comparableOptions).toEqual(
@@ -192,7 +208,7 @@ describe("FilterGeneratorService", () => {
it("should use values from a prebuilt filter", async () => {
const today = moment().format("YYYY-MM-DD");
const prebuiltFilter = {
id: "date",
id: "someID",
type: "prebuilt",
label: "Date",
default: "today",
@@ -213,34 +229,31 @@ describe("FilterGeneratorService", () => {

const filterOptions = (
await service.generate([prebuiltFilter], Note, [])
)[0];
)[0] as SelectableFilter<Note>;

expect(filterOptions.filterSettings.label).toEqual(prebuiltFilter.label);
expect(filterOptions.filterSettings.name).toEqual(prebuiltFilter.id);
expect(filterOptions.filterSettings.options).toEqual(
prebuiltFilter.options
);
expect(filterOptions.label).toEqual(prebuiltFilter.label);
expect(filterOptions.name).toEqual(prebuiltFilter.id);
expect(filterOptions.options).toEqual(prebuiltFilter.options);
expect(filterOptions.selectedOption).toEqual(prebuiltFilter.default);

const todayNote = new Note();
todayNote.date = new Date();
const yesterdayNote = new Note();
const notes = [todayNote, yesterdayNote];
yesterdayNote.date = moment().subtract(1, "day").toDate();
const allFilter = filterOptions.filterSettings.options.find(
(f) => f.key === ""
);
const allFilter = filterOptions.options.find((f) => f.key === "");
expect(filter(notes, allFilter)).toEqual(notes);
const todayFilter = filterOptions.filterSettings.options.find(
(f) => f.key === "today"
);
const todayFilter = filterOptions.options.find((f) => f.key === "today");
expect(filter(notes, todayFilter)).toEqual([todayNote]);
const beforeFilter = filterOptions.filterSettings.options.find(
(f) => f.key === "before"
);
const beforeFilter = filterOptions.options.find((f) => f.key === "before");
expect(filter(notes, beforeFilter)).toEqual([yesterdayNote]);
});

it("should create a date range filter", async () => {
let generatedFilter = await service.generate([{ id: "date" }], Note, []);
expect(generatedFilter[0]).toBeInstanceOf(DateFilter);
});

function filter<T extends Entity>(
data: T[],
option: FilterSelectionOption<T>
226 changes: 78 additions & 148 deletions src/app/core/entity-components/entity-list/filter-generator.service.ts
Original file line number Diff line number Diff line change
@@ -1,190 +1,120 @@
import { Injectable } from "@angular/core";
import {
FilterSelection,
FilterSelectionOption,
} from "../../filter/filter-selection/filter-selection";
DateFilter,
SelectableFilter,
BooleanFilter,
ConfigurableEnumFilter,
EntityFilter,
Filter,
} from "../../filter/filters/filters";
import {
BooleanFilterConfig,
DateRangeFilterConfig,
FilterConfig,
PrebuiltFilterConfig,
} from "./EntityListConfig";
import { Entity, EntityConstructor } from "../../entity/model/entity";
import { LoggingService } from "../../logging/logging.service";
import { EntitySchemaField } from "../../entity/schema/entity-schema-field";
import { FilterComponentSettings } from "./filter-component.settings";
import { EntityMapperService } from "../../entity/entity-mapper.service";
import { EntityRegistry } from "../../entity/database-entity.decorator";
import { FilterService } from "../../filter/filter.service";
import { ConfigurableEnumService } from "../../configurable-enum/configurable-enum.service";
import { FilterService } from "app/core/filter/filter.service";
import { defaultDateFilters } from "../../filter/date-range-filter/date-range-filter-panel/date-range-filter-panel.component";
import { dateDataTypes } from "../../entity/schema-datatypes/date-datatypes";

@Injectable({
providedIn: "root",
})
export class FilterGeneratorService {
constructor(
private enumService: ConfigurableEnumService,
private loggingService: LoggingService,
private entities: EntityRegistry,
private entityMapperService: EntityMapperService,
private filterService: FilterService
) {}

/**
*
* @param filtersConfig
* @param filterConfigs
* @param entityConstructor
* @param data
* @param onlyShowUsedOptions (Optional) whether to remove those filter options for selection that are not present in the data
*/
async generate<T extends Entity>(
filtersConfig: FilterConfig[],
filterConfigs: FilterConfig[],
entityConstructor: EntityConstructor<T>,
data: T[],
onlyShowUsedOptions = false
): Promise<FilterComponentSettings<T>[]> {
const filterSettings: FilterComponentSettings<T>[] = [];
for (const filter of filtersConfig) {
const schema = entityConstructor.schema.get(filter.id) || {};
const fs: FilterComponentSettings<T> = {
filterSettings: new FilterSelection(
filter.id,
[],
filter.label || schema.label
),
};
try {
fs.filterSettings.options = await this.getFilterOptions(
filter,
schema,
data
): Promise<Filter<T>[]> {
const filters: Filter<T>[] = [];
for (const filterConfig of filterConfigs) {
const schema = entityConstructor.schema.get(filterConfig.id) || {};
let filter: Filter<T>;
const type = filterConfig.type ?? schema.dataType;
if (
type == "configurable-enum" ||
schema.innerDataType === "configurable-enum"
) {
filter = new ConfigurableEnumFilter(
filterConfig.id,
filterConfig.label || schema.label,
this.enumService.getEnumValues(
schema.additional ?? schema.innerDataType
)
);
} catch (e) {
this.loggingService.warn(`Could not init filter: ${filter.id}: ${e}`);
}

if (onlyShowUsedOptions) {
fs.filterSettings.options = fs.filterSettings.options.filter((option) =>
data.some(this.filterService.getFilterPredicate(option.filter))
} else if (type == "boolean") {
filter = new BooleanFilter(
filterConfig.id,
filterConfig.label || schema.label,
filterConfig as BooleanFilterConfig
);
} else if (type == "prebuilt") {
filter = new SelectableFilter(
filterConfig.id,
(filterConfig as PrebuiltFilterConfig<T>).options,
filterConfig.label
);
} else if (dateDataTypes.includes(type)) {
filter = new DateFilter(
filterConfig.id,
filterConfig.label || schema.label,
(filterConfig as DateRangeFilterConfig).options ?? defaultDateFilters
);
} else if (
this.entities.has(filterConfig.type) ||
this.entities.has(schema.additional)
) {
const entityType = filterConfig.type || schema.additional;
const filterEntities = await this.entityMapperService.loadType(
entityType
);
filter = new EntityFilter(filterConfig.id, entityType, filterEntities);
} else {
const options = [...new Set(data.map((c) => c[filterConfig.id]))];
const fSO = SelectableFilter.generateOptions(options, filterConfig.id);
filter = new SelectableFilter<T>(
filterConfig.id,
fSO,
filterConfig.label || schema.label
);
}

// Filters should only be added, if they have more than one (the default) option
if (fs.filterSettings.options?.length > 1) {
fs.selectedOption = filter.hasOwnProperty("default")
? filter.default
: fs.filterSettings.options[0].key;
filterSettings.push(fs);
if (filterConfig.hasOwnProperty("default")) {
filter.selectedOption = filterConfig.default;
}
}
return filterSettings;
}

private async getFilterOptions<T extends Entity>(
config: FilterConfig,
schema: EntitySchemaField,
data: T[]
): Promise<FilterSelectionOption<T>[]> {
if (config.type === "prebuilt") {
return (config as PrebuiltFilterConfig<T>).options;
} else if (schema.dataType === "boolean" || config.type === "boolean") {
return this.createBooleanFilterOptions(config as BooleanFilterConfig);
} else if (
schema.dataType === "configurable-enum" ||
schema.innerDataType === "configurable-enum"
) {
return this.createConfigurableEnumFilterOptions(
config.id,
schema.additional ?? schema.innerDataType
);
} else if (
this.entities.has(config.type) ||
this.entities.has(schema.additional)
) {
return this.createEntityFilterOption(
config.id,
config.type || schema.additional
);
} else {
const options = [...new Set(data.map((c) => c[config.id]))];
return FilterSelection.generateOptions(options, config.id);
}
}

private createBooleanFilterOptions<T extends Entity>(
filter: BooleanFilterConfig
): FilterSelectionOption<T>[] {
return [
{
key: "all",
label: filter.all ?? $localize`:Filter label:All`,
filter: {},
},
{
key: "true",
label: filter.true ?? $localize`:Filter label default boolean true:Yes`,
filter: { [filter.id]: true },
},
{
key: "false",
label: filter.false ?? $localize`:Filter label default boolean true:No`,
filter: { [filter.id]: false },
},
];
}

private createConfigurableEnumFilterOptions<T extends Entity>(
property: string,
enumId: string
): FilterSelectionOption<T>[] {
const options: FilterSelectionOption<T>[] = [
{
key: "all",
label: $localize`:Filter label:All`,
filter: {},
},
];

const enumValues = this.enumService.getEnumValues(enumId);
const key = property + ".id";

for (const enumValue of enumValues) {
options.push({
key: enumValue.id,
label: enumValue.label,
color: enumValue.color,
filter: { [key]: enumValue.id },
});
if (filter instanceof SelectableFilter) {
if (onlyShowUsedOptions) {
filter.options = filter.options.filter((option) =>
data.some(this.filterService.getFilterPredicate(option.filter))
);
}
// Filters should only be added, if they have more than one (the default) option
if (filter.options?.length <= 1) {
continue;
}
}
filters.push(filter);
}

return options;
}

private async createEntityFilterOption<T extends Entity>(
property: string,
entityType: string
): Promise<FilterSelectionOption<T>[]> {
const filterEntities = await this.entityMapperService.loadType(entityType);
filterEntities.sort((a, b) => a.toString().localeCompare(b.toString()));

const options = [
{
key: "all",
label: $localize`:Filter option:All`,
filter: {},
},
];
options.push(
...filterEntities.map((filterEntity) => ({
key: filterEntity.getId(),
label: filterEntity.toString(),
filter: {
$or: [
{ [property]: filterEntity.getId() },
{ [property]: { $elemMatch: { $eq: filterEntity.getId() } } },
],
},
}))
);
return options;
return filters;
}
}
11 changes: 1 addition & 10 deletions src/app/core/entity/schema-datatypes/datatype-date-only.ts
Original file line number Diff line number Diff line change
@@ -16,6 +16,7 @@
*/

import { EntitySchemaDatatype } from "../schema/entity-schema-datatype";
import { dateToString } from "../../../utils/utils";

/**
* Datatype for the EntitySchemaService transforming Date values to/from a date string format ("YYYY-mm-dd").
@@ -57,16 +58,6 @@ export const dateOnlyEntitySchemaDatatype: EntitySchemaDatatype<Date, string> =
},
};

function dateToString(value: Date) {
return (
value.getFullYear() +
"-" +
(value.getMonth() + 1).toString().padStart(2, "0") +
"-" +
value.getDate().toString().padStart(2, "0")
);
}

function migrateIsoDatesToInferredDateOnly(value: string): string {
if (!value.match(/\d\d\d\d-\d\d-\d\dT\d\d:\d\d:\d\d\.\d\d\dZ/)) {
// not ISO Date format (2023-01-06T10:03:35.726Z)
11 changes: 11 additions & 0 deletions src/app/core/entity/schema-datatypes/date-datatypes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { dateEntitySchemaDatatype } from "./datatype-date";
import { dateOnlyEntitySchemaDatatype } from "./datatype-date-only";
import { monthEntitySchemaDatatype } from "./datatype-month";
import { dateWithAgeEntitySchemaDatatype } from "./datatype-date-with-age";

export const dateDataTypes = [
dateEntitySchemaDatatype,
dateOnlyEntitySchemaDatatype,
monthEntitySchemaDatatype,
dateWithAgeEntitySchemaDatatype,
].map((dataType) => dataType.name);
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<div mat-dialog-title i18n="Title of dialog">Select a date range</div>
<mat-dialog-content>
<div class="container">
<mat-calendar
class="calendar"
(selectedChange)="selectedRangeChange($event)"
[selected]="selectedRangeValue"
[comparisonStart]="comparisonRange.start"
[comparisonEnd]="comparisonRange.end"
>
</mat-calendar>
<div class="panel">
<div
class="button-div"
*ngFor="let item of filter.rangeOptions; let selectedIndexOfDateRanges = index"
>
<button
class="button"
mat-button
color="primary"
[class.selected-option]="item === selectedOption"
(mouseenter)="preselectRange(item)"
(mouseleave)="unselectRange()"
(click)="selectRangeAndClose(selectedIndexOfDateRanges)"
>
{{ item.label }}
</button>
</div>
</div>
</div>
</mat-dialog-content>
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
.container {
display: flex;
}

.calendar {
width: 60%;
}

.panel {
width: 40%;
margin: auto;
}

.button-div {
display: block;
text-align: left;
line-height: 100%;
}

.button {
display: block;
text-align: left;
}

.selected-option {
font-weight: bold;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import { ComponentFixture, TestBed } from "@angular/core/testing";

import {
DateRangeFilterPanelComponent,
calculateDateRange,
defaultDateFilters,
} from "./date-range-filter-panel.component";
import { MatNativeDateModule } from "@angular/material/core";
import { MAT_DIALOG_DATA, MatDialogRef } from "@angular/material/dialog";
import { TestbedHarnessEnvironment } from "@angular/cdk/testing/testbed";
import { HarnessLoader } from "@angular/cdk/testing";
import { DateRange } from "@angular/material/datepicker";
import { MatCalendarHarness } from "@angular/material/datepicker/testing";
import moment from "moment";
import { DateFilter } from "../../filters/filters";

describe("DateRangeFilterPanelComponent", () => {
let component: DateRangeFilterPanelComponent;
let fixture: ComponentFixture<DateRangeFilterPanelComponent>;
let loader: HarnessLoader;
let dateFilter: DateFilter<any>;

beforeEach(async () => {
dateFilter = new DateFilter("test", "Test", defaultDateFilters);
dateFilter.selectedOption = "1";
jasmine.clock().mockDate(new Date("2023-04-08"));
await TestBed.configureTestingModule({
imports: [MatNativeDateModule],
providers: [
{ provide: MAT_DIALOG_DATA, useValue: dateFilter },
{ provide: MatDialogRef, useValue: { close: () => undefined } },
],
}).compileComponents();
fixture = TestBed.createComponent(DateRangeFilterPanelComponent);
loader = TestbedHarnessEnvironment.loader(fixture);
component = fixture.componentInstance;
fixture.detectChanges();
});

it("should create", () => {
expect(component).toBeTruthy();
});

it("should highlight the currently selected option", () => {
expect(component.selectedOption).toEqual(defaultDateFilters[1]);
});

it("should display selected dates in the calendar", async () => {
const fromDate = moment().startOf("month");
const toDate = moment().startOf("month").add(13, "days");
component.selectedRangeValue = new DateRange(
fromDate.toDate(),
toDate.toDate()
);
fixture.detectChanges();
const calendar = await loader.getHarness(MatCalendarHarness);
const cells = await calendar.getCells();
for (let i = 0; i < cells.length; i++) {
if (i <= 13) {
await expectAsync(cells[i].isInRange()).toBeResolvedTo(true);
} else {
await expectAsync(cells[i].isInRange()).toBeResolvedTo(false);
}
}
});

it("should set the manually selected dates", async () => {
const calendar = await loader.getHarness(MatCalendarHarness);
const cells = await calendar.getCells();
await cells[7].select();
await cells[12].select();

const filterRange = dateFilter.getDateRange();
expect(filterRange.start).toEqual(new Date("2023-04-08"));
expect(filterRange.end).toEqual(new Date("2023-04-13"));
});

it("should set the dates selected via the preset options", async () => {
component.selectRangeAndClose(0);

const filterRange = dateFilter.getDateRange();
expect(filterRange.start).toEqual(
moment("2023-04-08").startOf("day").toDate()
);
expect(filterRange.end).toEqual(moment("2023-04-08").endOf("day").toDate());
expect(dateFilter.selectedOption).toBe("0");
});

it("should highlight the date range when hovering over a option", async () => {
const calendar = await loader.getHarness(MatCalendarHarness);
const cells = await calendar.getCells();
component.preselectRange({
endOffsets: [{ amount: 1, unit: "months" }],
label: "This and the coming month",
});
const currentDayOfMonth = new Date().getDate();
for (let i = 0; i < cells.length; i++) {
if (i < currentDayOfMonth - 1) {
await expectAsync(cells[i].isInComparisonRange()).toBeResolvedTo(false);
} else {
await expectAsync(cells[i].isInComparisonRange()).toBeResolvedTo(true);
}
}
});

it("should correctly calculate date ranges based on the config", () => {
let res = calculateDateRange({ label: "Today" });
let fromDate = moment().startOf("day").toDate();
let toDate = moment().endOf("day").toDate();
expect(res).toEqual(new DateRange(fromDate, toDate));

let mockedToday = moment("2023-06-08").toDate();
jasmine.clock().mockDate(mockedToday);

res = calculateDateRange({
startOffsets: [{ amount: 0, unit: "weeks" }],
endOffsets: [{ amount: 0, unit: "weeks" }],
label: "This week",
});
fromDate = moment("2023-06-04").startOf("day").toDate();
toDate = moment("2023-06-10").endOf("day").toDate();
expect(res).toEqual(new DateRange(fromDate, toDate));

res = calculateDateRange({
startOffsets: [{ amount: 1, unit: "weeks" }],
endOffsets: [{ amount: 1, unit: "weeks" }],
label: "Next week",
});
fromDate = moment("2023-06-11").startOf("day").toDate();
toDate = moment("2023-06-17").endOf("day").toDate();
expect(res).toEqual(new DateRange(fromDate, toDate));

res = calculateDateRange({
endOffsets: [
{ amount: -2, unit: "week" },
{ amount: 3, unit: "months" },
],
label:
"From today until endOf(today minus 2 weeks) and then endOf(this date plus 3 months)",
});
fromDate = moment("2023-06-08").startOf("day").toDate();
toDate = moment("2023-08-26").endOf("day").toDate();
expect(res).toEqual(new DateRange(fromDate, toDate));

res = calculateDateRange({
endOffsets: [
{ amount: 3, unit: "months" },
{ amount: -2, unit: "week" },
],
label:
"From today until endOf(today minus 2 weeks) and then endOf(this date plus 3 months)",
});
fromDate = moment("2023-06-08").startOf("day").toDate();
toDate = moment("2023-08-31").endOf("day").toDate();
expect(res).toEqual(new DateRange(fromDate, toDate));
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { Component, Inject } from "@angular/core";
import {
DateRange,
MatDatepickerModule,
MatDateSelectionModel,
MatRangeDateSelectionModel,
MAT_RANGE_DATE_SELECTION_MODEL_PROVIDER,
} from "@angular/material/datepicker";
import {
MAT_DIALOG_DATA,
MatDialogModule,
MatDialogRef,
} from "@angular/material/dialog";
import { MatButtonModule } from "@angular/material/button";
import { NgForOf } from "@angular/common";
import { DateRangeFilterConfigOption } from "app/core/entity-components/entity-list/EntityListConfig";
import moment from "moment";
import { FormsModule } from "@angular/forms";
import { DateFilter } from "../../filters/filters";
import { dateToString } from "../../../../utils/utils";

export const defaultDateFilters: DateRangeFilterConfigOption[] = [
{
label: $localize`:Filter label:Today`,
},
{
startOffsets: [{ amount: 0, unit: "weeks" }],
endOffsets: [{ amount: 0, unit: "weeks" }],
label: $localize`:Filter label:This week`,
},
{
startOffsets: [{ amount: -1, unit: "weeks" }],
label: $localize`:Filter label:Since last week`,
},
{
startOffsets: [{ amount: 0, unit: "months" }],
endOffsets: [{ amount: 0, unit: "months" }],
label: $localize`:Filter label:This month`,
},
{
startOffsets: [{ amount: -1, unit: "months" }],
endOffsets: [{ amount: -1, unit: "months" }],
label: $localize`:Filter label:Last month`,
},
];

@Component({
selector: "app-date-range-filter-panel",
templateUrl: "./date-range-filter-panel.component.html",
styleUrls: ["./date-range-filter-panel.component.scss"],
providers: [
{ provide: MatDateSelectionModel, useClass: MatRangeDateSelectionModel },
MAT_RANGE_DATE_SELECTION_MODEL_PROVIDER,
],
standalone: true,
imports: [
MatDialogModule,
MatButtonModule,
MatDatepickerModule,
NgForOf,
FormsModule,
],
})
export class DateRangeFilterPanelComponent {
selectedRangeValue = this.filter.getDateRange();
selectedOption = this.filter.getSelectedOption();
comparisonRange: DateRange<Date> = new DateRange(null, null);

constructor(
@Inject(MAT_DIALOG_DATA) public filter: DateFilter<any>,
private dialogRef: MatDialogRef<DateRangeFilterPanelComponent>
) {}

preselectRange(dateRangeOption): void {
this.comparisonRange = calculateDateRange(dateRangeOption);
}

unselectRange() {
this.comparisonRange = new DateRange(null, null);
}

selectRangeAndClose(index: number): void {
this.filter.selectedOption = index.toString();
this.dialogRef.close();
}

selectedRangeChange(selectedDate: Date) {
if (!this.selectedRangeValue?.start || this.selectedRangeValue?.end) {
this.selectedRangeValue = new DateRange(selectedDate, null);
} else {
const start = this.selectedRangeValue.start;
this.filter.selectedOption =
start < selectedDate
? dateToString(start) + "_" + dateToString(selectedDate)
: dateToString(selectedDate) + "_" + dateToString(start);
this.dialogRef.close();
}
}
}

export function calculateDateRange(
dateRangeOption: DateRangeFilterConfigOption
): DateRange<Date> {
const startOffsets = dateRangeOption.startOffsets ?? [
{ amount: 0, unit: "days" },
];
const endOffsets = dateRangeOption.endOffsets ?? [
{ amount: 0, unit: "days" },
];

const start = moment();
const end = moment();

startOffsets.forEach((offset) => start.add(offset.amount, offset.unit));
endOffsets.forEach((offset) => end.add(offset.amount, offset.unit));

start.startOf(startOffsets[0].unit);
end.endOf(endOffsets[0].unit);

return new DateRange(start.toDate(), end.toDate());
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<mat-form-field>
<mat-label i18n="Input label">Enter a date range</mat-label>

<mat-date-range-input>
<input
matStartDate
(dateChange)="dateChangedManually()"
[(ngModel)]="fromDate"
i18n-placeholder="Date selection"
placeholder="Start date"
/>
<input
matEndDate
(dateChange)="dateChangedManually()"
[(ngModel)]="toDate"
i18n-placeholder="Date selection"
placeholder="End date"
/>
</mat-date-range-input>

<mat-datepicker-toggle
matSuffix
(click)="openDialog($event)"
></mat-datepicker-toggle>
</mat-form-field>
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { ComponentFixture, TestBed } from "@angular/core/testing";

import { DateRangeFilterComponent } from "./date-range-filter.component";
import { MatDialog } from "@angular/material/dialog";
import { MatNativeDateModule } from "@angular/material/core";
import { NoopAnimationsModule } from "@angular/platform-browser/animations";
import { DateFilter } from "../filters/filters";
import { defaultDateFilters } from "./date-range-filter-panel/date-range-filter-panel.component";

describe("DateRangeFilterComponent", () => {
let component: DateRangeFilterComponent<any>;
let fixture: ComponentFixture<DateRangeFilterComponent<any>>;

beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [MatNativeDateModule, NoopAnimationsModule],
providers: [{ provide: MatDialog, useValue: null }],
}).compileComponents();

fixture = TestBed.createComponent(DateRangeFilterComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});

it("should create", () => {
expect(component).toBeTruthy();
});

it("should set the correct date filter when a new option is selected", () => {
const dateFilter = new DateFilter("test", "Test", defaultDateFilters);

dateFilter.selectedOption = "9";
component.filterConfig = dateFilter;
expect(component.dateFilter.getFilter()).toBe(undefined);

jasmine.clock().mockDate(new Date("2023-05-18"));
dateFilter.selectedOption = "0";
component.filterConfig = dateFilter;
let expectedDataFilter = {
test: {
$gte: "2023-05-18",
$lte: "2023-05-18",
},
};
expect(component.dateFilter.getFilter()).toEqual(expectedDataFilter);

dateFilter.selectedOption = "1";
component.filterConfig = dateFilter;
expectedDataFilter = {
test: {
$gte: "2023-05-14",
$lte: "2023-05-20",
},
};
expect(component.dateFilter.getFilter()).toEqual(expectedDataFilter);
});

it("should set the correct date filter when inputting a specific date range via the URL", () => {
let dateFilter = new DateFilter("test", "test", []);

dateFilter.selectedOption = "1_2_3";
component.filterConfig = dateFilter;
expect(component.dateFilter.getFilter()).toBe(undefined);

dateFilter.selectedOption = "2022-9-18_2023-01-3";
component.filterConfig = dateFilter;
let expectedDataFilter = {
test: {
$gte: "2022-09-18",
$lte: "2023-01-03",
},
};
expect(component.dateFilter.getFilter()).toEqual(expectedDataFilter);
});

it("should set the correct date filter when changing the date range manually", () => {
component.filterConfig = new DateFilter("test", "test", []);
component.fromDate = new Date("2021-10-28");
component.toDate = new Date("2024-02-12");

component.dateChangedManually();

expect(component.dateFilter.selectedOption).toEqual(
"2021-10-28_2024-02-12"
);
let expectedDataFilter = {
test: {
$gte: "2021-10-28",
$lte: "2024-02-12",
},
};
expect(component.dateFilter.getFilter()).toEqual(expectedDataFilter);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { Component, EventEmitter, Input, Output } from "@angular/core";
import { MatDialog } from "@angular/material/dialog";
import { Entity } from "app/core/entity/model/entity";
import { DateFilter, Filter } from "../filters/filters";
import { DateRangeFilterPanelComponent } from "./date-range-filter-panel/date-range-filter-panel.component";
import { MatFormFieldModule } from "@angular/material/form-field";
import { MatDatepickerModule } from "@angular/material/datepicker";
import { FormsModule } from "@angular/forms";
import { dateToString, isValidDate } from "app/utils/utils";

@Component({
selector: "app-date-range-filter",
templateUrl: "./date-range-filter.component.html",
styleUrls: ["./date-range-filter.component.scss"],
standalone: true,
imports: [MatFormFieldModule, MatDatepickerModule, FormsModule],
})
export class DateRangeFilterComponent<T extends Entity> {
fromDate: Date;
toDate: Date;
dateFilter: DateFilter<T>;

@Output() selectedOptionChange = new EventEmitter<string>();

@Input() set filterConfig(value: Filter<T>) {
this.dateFilter = value as DateFilter<T>;
this.initDates();
}

constructor(private dialog: MatDialog) {}

private initDates() {
const range = this.dateFilter.getDateRange();
if (range.start !== this.fromDate && range.end !== this.toDate) {
this.fromDate = range.start;
this.toDate = range.end;
this.selectedOptionChange.emit(this.dateFilter.selectedOption);
}
}

dateChangedManually() {
if (isValidDate(this.fromDate) && isValidDate(this.toDate)) {
this.dateFilter.selectedOption =
dateToString(this.fromDate) + "_" + dateToString(this.toDate);
}
this.selectedOptionChange.emit(this.dateFilter.selectedOption);
}

openDialog(e: Event) {
e.stopPropagation();
this.dialog
.open(DateRangeFilterPanelComponent, {
width: "600px",
minWidth: "400px",
data: this.dateFilter,
})
.afterClosed()
.subscribe(() => this.initDates());
}
}
127 changes: 0 additions & 127 deletions src/app/core/filter/filter-selection/filter-selection.ts

This file was deleted.

31 changes: 20 additions & 11 deletions src/app/core/filter/filter/filter.component.html
Original file line number Diff line number Diff line change
@@ -1,11 +1,20 @@
<app-list-filter
*ngFor="let filter of filterSelections"
[filterConfig]="filter.filterSettings"
[(selectedOption)]="filter.selectedOption"
(selectedOptionChange)="filterOptionSelected(filter, $event)"
angulartics2On="click"
[angularticsCategory]="entityType?.ENTITY_TYPE"
[angularticsAction]="'filter_' + urlPath"
[angularticsLabel]="filter.filterSettings.label"
>
</app-list-filter>
<ng-container *ngFor="let filter of filterSelections">
<app-list-filter
*ngIf="filter.name !== 'date'"
[filterConfig]="filter"
[(selectedOption)]="filter.selectedOption"
(selectedOptionChange)="filterOptionSelected(filter, $event)"
angulartics2On="click"
[angularticsCategory]="entityType?.ENTITY_TYPE"
[angularticsAction]="'filter_' + urlPath"
[angularticsLabel]="filter.label"
>
</app-list-filter>

<app-date-range-filter
*ngIf="filter.name === 'date'"
[filterConfig]="filter"
(selectedOptionChange)="filterOptionSelected(filter, $event)"
>
</app-date-range-filter>
</ng-container>
32 changes: 16 additions & 16 deletions src/app/core/filter/filter/filter.component.ts
Original file line number Diff line number Diff line change
@@ -8,22 +8,29 @@ import {
} from "@angular/core";
import { FilterConfig } from "../../entity-components/entity-list/EntityListConfig";
import { Entity, EntityConstructor } from "../../entity/model/entity";
import { FilterComponentSettings } from "../../entity-components/entity-list/filter-component.settings";
import { DataFilter } from "../../entity-components/entity-subrecord/entity-subrecord/entity-subrecord-config";
import { FilterGeneratorService } from "../../entity-components/entity-list/filter-generator.service";
import { ActivatedRoute, Params, Router } from "@angular/router";
import { getUrlWithoutParams } from "../../../utils/utils";
import { ListFilterComponent } from "../list-filter/list-filter.component";
import { NgForOf } from "@angular/common";
import { NgForOf, NgIf } from "@angular/common";
import { Angulartics2Module } from "angulartics2";
import { DateRangeFilterComponent } from "../date-range-filter/date-range-filter.component";
import { Filter } from "../filters/filters";

/**
* This component can be used to display filters, for example above tables.
*/
@Component({
selector: "app-filter",
templateUrl: "./filter.component.html",
imports: [ListFilterComponent, NgForOf, Angulartics2Module],
imports: [
ListFilterComponent,
NgForOf,
Angulartics2Module,
DateRangeFilterComponent,
NgIf,
],
standalone: true,
})
export class FilterComponent<T extends Entity = Entity> implements OnChanges {
@@ -60,7 +67,7 @@ export class FilterComponent<T extends Entity = Entity> implements OnChanges {
*/
@Output() filterObjChange = new EventEmitter<DataFilter<T>>();

filterSelections: FilterComponentSettings<T>[] = [];
filterSelections: Filter<T>[] = [];
urlPath = getUrlWithoutParams(this.router);

constructor(
@@ -82,25 +89,18 @@ export class FilterComponent<T extends Entity = Entity> implements OnChanges {
}
}

filterOptionSelected(
filter: FilterComponentSettings<T>,
selectedOption: string
) {
filterOptionSelected(filter: Filter<T>, selectedOption: string) {
filter.selectedOption = selectedOption;
this.applyFilterSelections();
if (this.useUrlQueryParams) {
this.updateUrl(filter.filterSettings.name, selectedOption);
this.updateUrl(filter.name, selectedOption);
}
}

private applyFilterSelections() {
const previousFilter = JSON.stringify(this.filterObj);
const newFilter = this.filterSelections.reduce(
(obj, filter) =>
Object.assign(
obj,
filter.filterSettings.getFilter(filter.selectedOption)
),
(obj, filter) => Object.assign(obj, filter.getFilter()),
{} as DataFilter<T>
);

@@ -128,8 +128,8 @@ export class FilterComponent<T extends Entity = Entity> implements OnChanges {
}
const params = parameters || this.route.snapshot.queryParams;
this.filterSelections.forEach((f) => {
if (params.hasOwnProperty(f.filterSettings.name)) {
f.selectedOption = params[f.filterSettings.name];
if (params.hasOwnProperty(f.name)) {
f.selectedOption = params[f.name];
}
});
}
Original file line number Diff line number Diff line change
@@ -1,32 +1,38 @@
import { FilterSelection } from "./filter-selection";
import { SelectableFilter } from "./filters";
import { FilterService } from "../filter.service";

describe("FilterSelection", () => {
describe("Filters", () => {
const filterService = new FilterService(undefined);
it("create an instance", () => {
const fs = new FilterSelection("", []);
const fs = new SelectableFilter(
"",
[{ key: "", label: "", filter: "" }],
""
);
expect(fs).toBeTruthy();
});

it("init new options", () => {
const fs = new FilterSelection("", []);
const fs = new SelectableFilter(
"",
[{ key: "", label: "", filter: "" }],
""
);

const keys = ["x", "y"];
fs.options = FilterSelection.generateOptions(keys, "category");
fs.options = SelectableFilter.generateOptions(keys, "category");

expect(fs.options).toHaveSize(keys.length + 1);

const testData = [
{ id: 1, category: "x" },
{ id: 2, category: "y" },
] as any;
const selectedCategory = "x";
const predicate = filterService.getFilterPredicate(
fs.getFilter(selectedCategory)
);
fs.selectedOption = "x";
const predicate = filterService.getFilterPredicate(fs.getFilter());
const filteredData = testData.filter(predicate);

expect(filteredData).toHaveSize(1);
expect(filteredData[0].category).toBe(selectedCategory);
expect(filteredData[0].category).toBe("x");
});
});
275 changes: 275 additions & 0 deletions src/app/core/filter/filters/filters.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,275 @@
/*
* This file is part of ndb-core.
*
* ndb-core is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* ndb-core is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with ndb-core. If not, see <http://www.gnu.org/licenses/>.
*/

import { ConfigurableEnumValue } from "app/core/configurable-enum/configurable-enum.interface";
import {
BooleanFilterConfig,
DateRangeFilterConfigOption,
} from "app/core/entity-components/entity-list/EntityListConfig";
import { DataFilter } from "../../entity-components/entity-subrecord/entity-subrecord/entity-subrecord-config";
import { Entity } from "../../entity/model/entity";
import { DateRange } from "@angular/material/datepicker";
import { isValidDate } from "../../../utils/utils";
import { calculateDateRange } from "../date-range-filter/date-range-filter-panel/date-range-filter-panel.component";
import moment from "moment/moment";

export abstract class Filter<T extends Entity> {
public selectedOption: string;

constructor(public name: string, public label: string = name) {}

abstract getFilter(): DataFilter<T>;
}

/**
* Represents a filter for date values.
* The filter can either be one of the predefined options or two manually entered dates.
*/
export class DateFilter<T extends Entity> extends Filter<T> {
constructor(
public name: string,
public label: string = name,
public rangeOptions: DateRangeFilterConfigOption[]
) {
super(name, label);
this.selectedOption = "1";
}

/**
* Returns the date range according to the selected option or dates
*/
getDateRange(): DateRange<Date> {
if (this.getSelectedOption()) {
return calculateDateRange(this.getSelectedOption());
}
const dates = this.selectedOption?.split("_");
if (dates?.length == 2) {
const firstDate = new Date(dates[0]);
const secondDate = new Date(dates[1]);
if (isValidDate(firstDate) && isValidDate(secondDate)) {
return new DateRange(firstDate, secondDate);
}
}
return new DateRange(undefined, undefined);
}

getFilter(): DataFilter<T> {
const range = this.getDateRange();
if (range.start && range.end) {
return {
[this.name]: {
$gte: moment(range.start).format("YYYY-MM-DD"),
$lte: moment(range.end).format("YYYY-MM-DD"),
},
} as DataFilter<T>;
}
}

getSelectedOption() {
return this.rangeOptions[this.selectedOption as any];
}
}

/**
* Generic configuration for a filter with different selectable {@link FilterSelectionOption} options.
*
* This is a reusable format for any kind of dropdown or selection component that offers the user a choice
* to narrow down a list of data items.
* As the filter function is provided as part of each {@link FilterSelectionOption}
* an instance of this FilterSelection class can manage all filter selection logic.
*/
export class SelectableFilter<T extends Entity> extends Filter<T> {
/**
* Generate filter options dynamically from the given value to be matched.
*
* This is a utility function to make it easier to generate {@link FilterSelectionOption}s for standard cases
* if you simply want each option to filter items having the given attribute matching different values.
* If you have more sophisticated filtering needs, use the constructor to set {@link FilterSelectionOption}s that
* you created yourself.
*
* @param valuesToMatchAsOptions An array of values to be matched.
* A separate FilterSelectionOption is created for each value with a filter
* that is true of a data item's property exactly matches that value.
* @param attributeName The name of the property of a data item that is compared to the value in the filter function.
*/
public static generateOptions<T extends Entity>(
valuesToMatchAsOptions: string[],
attributeName: string
): FilterSelectionOption<T>[] {
const options = [
{
key: "",
label: $localize`:generic filter option showing all entries:All`,
filter: {} as DataFilter<T>,
},
];

options.push(
...valuesToMatchAsOptions
.filter((k) => !!k)
.map((k) => ({
key: k.toLowerCase(),
label: k.toString(),
filter: { [attributeName]: k } as DataFilter<T>,
}))
);

return options;
}

/**
* Create a FilterSelection with different options to be selected.
* @param name The name or id describing this filter
* @param options An array of different filtering variants to chose between
* @param label The user-friendly label describing this filter-selection
* (optional, defaults to the name of the selection)
*/
constructor(
public name: string,
public options: FilterSelectionOption<T>[],
public label: string = name
) {
super(name, label);
this.selectedOption = this.options[0]?.key;
}

/** default filter will keep all items in the result */
defaultFilter = {};

/**
* Get the full filter option by its key.
* @param key The identifier of the requested option
*/
getOption(key: string): FilterSelectionOption<T> {
return this.options.find((option) => option.key === key);
}

/**
* Get the filter query for the given option.
* If the given key is undefined or invalid, the returned filter matches any elements.
*/
public getFilter(): DataFilter<T> {
const option = this.getOption(this.selectedOption);

if (!option) {
return this.defaultFilter as DataFilter<T>;
} else {
return option.filter;
}
}
}

export class BooleanFilter<T extends Entity> extends SelectableFilter<T> {
constructor(name: string, label: string, config?: BooleanFilterConfig) {
super(
name,
[
{
key: "all",
label: config.all ?? $localize`:Filter label:All`,
filter: {},
},
{
key: "true",
label:
config.true ?? $localize`:Filter label default boolean true:Yes`,
filter: { [config.id]: true },
},
{
key: "false",
label:
config.false ?? $localize`:Filter label default boolean true:No`,
filter: { [config.id]: false },
},
],
label
);
}
}

export class ConfigurableEnumFilter<
T extends Entity
> extends SelectableFilter<T> {
constructor(
name: string,
label: string,
enumValues: ConfigurableEnumValue[]
) {
let options: FilterSelectionOption<T>[] = [
{
key: "all",
label: $localize`:Filter label:All`,
filter: {},
},
];
options.push(
...enumValues.map((enumValue) => ({
key: enumValue.id,
label: enumValue.label,
color: enumValue.color,
filter: { [name + ".id"]: enumValue.id },
}))
);
super(name, options, label);
}
}

export class EntityFilter<T extends Entity> extends SelectableFilter<T> {
constructor(name: string, label: string, filterEntities) {
filterEntities.sort((a, b) => a.toString().localeCompare(b.toString()));
const options: FilterSelectionOption<T>[] = [
{
key: "all",
label: $localize`:Filter label:All`,
filter: {},
},
];
options.push(
...filterEntities.map((filterEntity) => ({
key: filterEntity.getId(),
label: filterEntity.toString(),
filter: {
$or: [
{ [name]: filterEntity.getId() },
{ [name]: { $elemMatch: { $eq: filterEntity.getId() } } },
],
},
}))
);
super(name, options, label);
}
}

/**
* Represents one specific option to filter data in a certain way.
* used by {@link SelectableFilter}
*/
export interface FilterSelectionOption<T> {
/** identifier for this option in the parent FilterSelection instance */
key: string;

/** label displayed for this option to the user in the UI */
label: string;

/** Optional color */
color?: string;

/**
* The filter query which should be used if this filter is selected
*/
filter: DataFilter<T> | any;
}
10 changes: 3 additions & 7 deletions src/app/core/filter/list-filter/list-filter.component.html
Original file line number Diff line number Diff line change
@@ -1,12 +1,8 @@
<mat-form-field appearance="fill" class="full-width">
<mat-label>{{ filterConfig.label || filterConfig.name }}</mat-label>
<mat-select
[id]="filterConfig.name"
[value]="selectedOption"
[placeholder]="filterConfig.name"
>
<mat-label>{{ _filterConfig.label || _filterConfig.name }}</mat-label>
<mat-select [id]="_filterConfig.name" [value]="selectedOption">
<mat-option
*ngFor="let option of filterConfig.options"
*ngFor="let option of _filterConfig.options"
[value]="option.key"
(click)="selectOption(option.key)"
[appBorderHighlight]="option.color"
4 changes: 2 additions & 2 deletions src/app/core/filter/list-filter/list-filter.component.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { ComponentFixture, TestBed, waitForAsync } from "@angular/core/testing";

import { ListFilterComponent } from "./list-filter.component";
import { FilterSelection } from "../filter-selection/filter-selection";
import { SelectableFilter } from "../filters/filters";
import { MockedTestingModule } from "../../../utils/mocked-testing.module";

describe("ListFilterComponent", () => {
@@ -17,7 +17,7 @@ describe("ListFilterComponent", () => {
beforeEach(() => {
fixture = TestBed.createComponent(ListFilterComponent);
component = fixture.componentInstance;
component.filterConfig = new FilterSelection<any>("test", []);
component.filterConfig = new SelectableFilter<any>("test", []);
fixture.detectChanges();
});

8 changes: 6 additions & 2 deletions src/app/core/filter/list-filter/list-filter.component.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Component, EventEmitter, Input, Output } from "@angular/core";
import { FilterSelection } from "../filter-selection/filter-selection";
import { Filter, SelectableFilter } from "../filters/filters";
import { Entity } from "../../entity/model/entity";
import { MatFormFieldModule } from "@angular/material/form-field";
import { MatSelectModule } from "@angular/material/select";
@@ -18,7 +18,11 @@ import { NgForOf } from "@angular/common";
standalone: true,
})
export class ListFilterComponent<E extends Entity> {
@Input() filterConfig: FilterSelection<E>;
@Input()
public set filterConfig(value: Filter<E>) {
this._filterConfig = value as SelectableFilter<E>;
}
_filterConfig: SelectableFilter<E>;
@Input() selectedOption: string;
@Output() selectedOptionChange = new EventEmitter<string>();

5 changes: 5 additions & 0 deletions src/app/core/language/date-adapter-with-formatting.ts
Original file line number Diff line number Diff line change
@@ -5,6 +5,7 @@ import {
} from "@angular/material/core";
import moment from "moment";
import { Injectable } from "@angular/core";
import { getLocaleFirstDayOfWeek } from "@angular/common";

/**
* Extend MAT_NATIVE_DATE_FORMATS to also support parsing.
@@ -27,4 +28,8 @@ export class DateAdapterWithFormatting extends NativeDateAdapter {
}
return value ? moment(value, true).locale(this.locale).toDate() : null;
}

override getFirstDayOfWeek(): number {
return getLocaleFirstDayOfWeek(this.locale);
}
}
14 changes: 2 additions & 12 deletions src/app/features/data-import/data-import.service.ts
Original file line number Diff line number Diff line change
@@ -6,31 +6,21 @@ import { MatSnackBar } from "@angular/material/snack-bar";
import { ImportMetaData } from "./import-meta-data.type";
import { v4 as uuid } from "uuid";
import { Entity, EntityConstructor } from "../../core/entity/model/entity";
import { dateEntitySchemaDatatype } from "../../core/entity/schema-datatypes/datatype-date";
import { dateOnlyEntitySchemaDatatype } from "../../core/entity/schema-datatypes/datatype-date-only";
import { monthEntitySchemaDatatype } from "../../core/entity/schema-datatypes/datatype-month";
import moment from "moment";
import { EntityRegistry } from "../../core/entity/database-entity.decorator";
import { dateWithAgeEntitySchemaDatatype } from "../../core/entity/schema-datatypes/datatype-date-with-age";
import { isArrayDataType } from "../../core/entity-components/entity-utils/entity-utils";
import { School } from "../../child-dev-project/schools/model/school";
import { RecurringActivity } from "../../child-dev-project/attendance/model/recurring-activity";
import { Child } from "app/child-dev-project/children/model/child";
import { EntityMapperService } from "../../core/entity/entity-mapper.service";
import { ChildSchoolRelation } from "../../child-dev-project/children/model/childSchoolRelation";
import { dateDataTypes } from "../../core/entity/schema-datatypes/date-datatypes";

/**
* This service handels the parsing of CSV files and importing of data
*/
@Injectable({ providedIn: "root" })
export class DataImportService {
private readonly dateDataTypes = [
dateEntitySchemaDatatype,
dateOnlyEntitySchemaDatatype,
monthEntitySchemaDatatype,
dateWithAgeEntitySchemaDatatype,
].map((dataType) => dataType.name);

private linkableEntities: {
[key: string]: [
EntityConstructor,
@@ -163,7 +153,7 @@ export class DataImportService {
): any {
if (property === "_id") {
return Entity.createPrefixedId(importMeta.entityType, value);
} else if (importMeta.dateFormat && this.dateDataTypes.includes(dataType)) {
} else if (importMeta.dateFormat && dateDataTypes.includes(dataType)) {
return this.transform2Date(value, importMeta.dateFormat);
} else if (isArrayDataType(dataType)) {
return this.parseArrayValue(value);
2 changes: 1 addition & 1 deletion src/app/features/todos/todo-list/todo-list.component.ts
Original file line number Diff line number Diff line change
@@ -13,7 +13,7 @@ import { TodoDetailsComponent } from "../todo-details/todo-details.component";
import { LoggingService } from "../../../core/logging/logging.service";
import moment from "moment";
import { EntityListComponent } from "../../../core/entity-components/entity-list/entity-list.component";
import { FilterSelectionOption } from "../../../core/filter/filter-selection/filter-selection";
import { FilterSelectionOption } from "../../../core/filter/filters/filters";

@RouteTarget("TodoList")
@Component({
10 changes: 10 additions & 0 deletions src/app/utils/utils.ts
Original file line number Diff line number Diff line change
@@ -15,6 +15,16 @@ export function isValidDate(date: any): boolean {
);
}

export function dateToString(value: Date) {
return (
value.getFullYear() +
"-" +
(value.getMonth() + 1).toString().padStart(2, "0") +
"-" +
value.getDate().toString().padStart(2, "0")
);
}

export function getUrlWithoutParams(router: Router): string {
const urlTree = router.parseUrl(router.url);
urlTree.queryParams = {};

0 comments on commit bffeb5c

Please sign in to comment.