diff --git a/src/app/child-dev-project/children/demo-data-generators/demo-school-generator.service.ts b/src/app/child-dev-project/children/demo-data-generators/demo-school-generator.service.ts index 695e066c32..eff15966a5 100644 --- a/src/app/child-dev-project/children/demo-data-generators/demo-school-generator.service.ts +++ b/src/app/child-dev-project/children/demo-data-generators/demo-school-generator.service.ts @@ -56,7 +56,7 @@ export class DemoSchoolGenerator extends DemoDataGenerator { $localize`:School demo timing:11 a.m. - 4 p.m.`, $localize`:School demo timing:6:30-11:00 and 11:30-16:00`, ]); - + school["numberOfTeachers"] = faker.number.int({ min: 3, max: 75 }); school["address"] = faker.geoAddress(); data.push(school); diff --git a/src/app/core/basic-datatypes/date/date-range-filter/date-range-filter.stories.ts b/src/app/core/basic-datatypes/date/date-range-filter/date-range-filter.stories.ts new file mode 100644 index 0000000000..70b425c536 --- /dev/null +++ b/src/app/core/basic-datatypes/date/date-range-filter/date-range-filter.stories.ts @@ -0,0 +1,28 @@ +import { applicationConfig, Meta, StoryObj } from "@storybook/angular"; +import { Entity } from "app/core/entity/model/entity"; +import { provideAnimations } from "@angular/platform-browser/animations"; +import { DateRangeFilterComponent } from "./date-range-filter.component"; +import { DateFilter } from "app/core/filter/filters/dateFilter"; +import { provideNativeDateAdapter } from "@angular/material/core"; + +export default { + title: "Core/> App Layout/Filter/Date Range Filter", + component: DateRangeFilterComponent, + decorators: [ + applicationConfig({ + providers: [provideAnimations(), provideNativeDateAdapter()], + }), + ], +} as Meta; + +const filterConfig: DateFilter = new DateFilter( + "x", + "Demo Date Filter", + [], +); + +export const Default: StoryObj> = { + args: { + filterConfig, + }, +}; diff --git a/src/app/core/basic-datatypes/number/number-range-filter/number-range-filter.component.html b/src/app/core/basic-datatypes/number/number-range-filter/number-range-filter.component.html new file mode 100644 index 0000000000..b5c6a07860 --- /dev/null +++ b/src/app/core/basic-datatypes/number/number-range-filter/number-range-filter.component.html @@ -0,0 +1,7 @@ + + {{ filterConfig.label || filterConfig.name }} + + diff --git a/src/app/core/basic-datatypes/number/number-range-filter/number-range-filter.component.scss b/src/app/core/basic-datatypes/number/number-range-filter/number-range-filter.component.scss new file mode 100644 index 0000000000..21696e7e7d --- /dev/null +++ b/src/app/core/basic-datatypes/number/number-range-filter/number-range-filter.component.scss @@ -0,0 +1,12 @@ +div { + display: flex; +} +input { + border: none; + background: none; + padding: 0; + outline: none; + font: inherit; + text-align: center; + color: currentColor; +} diff --git a/src/app/core/basic-datatypes/number/number-range-filter/number-range-filter.component.spec.ts b/src/app/core/basic-datatypes/number/number-range-filter/number-range-filter.component.spec.ts new file mode 100644 index 0000000000..a9891f708b --- /dev/null +++ b/src/app/core/basic-datatypes/number/number-range-filter/number-range-filter.component.spec.ts @@ -0,0 +1,32 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; + +import { NumberRangeFilterComponent } from "./number-range-filter.component"; +import { Entity } from "app/core/entity/model/entity"; +import { NumberFilter } from "app/core/filter/filters/numberFilter"; +import { NoopAnimationsModule } from "@angular/platform-browser/animations"; + +describe("NumberRangeFilterComponent", () => { + let component: NumberRangeFilterComponent; + let fixture: ComponentFixture>; + + let filterConfig: NumberFilter; + + beforeEach(async () => { + filterConfig = new NumberFilter("x", "Demo Number Filter"); + + await TestBed.configureTestingModule({ + imports: [NumberRangeFilterComponent, NoopAnimationsModule], + }).compileComponents(); + + fixture = TestBed.createComponent(NumberRangeFilterComponent); + component = fixture.componentInstance; + + component.filterConfig = filterConfig; + + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/core/basic-datatypes/number/number-range-filter/number-range-filter.component.ts b/src/app/core/basic-datatypes/number/number-range-filter/number-range-filter.component.ts new file mode 100644 index 0000000000..aec362c1fc --- /dev/null +++ b/src/app/core/basic-datatypes/number/number-range-filter/number-range-filter.component.ts @@ -0,0 +1,41 @@ +import { Component, Input } from "@angular/core"; +import { Entity } from "../../../entity/model/entity"; +import { MatFormFieldModule } from "@angular/material/form-field"; +import { FormControl, ReactiveFormsModule } from "@angular/forms"; +import { + NumericRange, + RangeInputComponent, +} from "./range-input/range-input.component"; +import { NumberFilter } from "../../../filter/filters/numberFilter"; + +@Component({ + selector: "app-date-range-filter", + templateUrl: "./number-range-filter.component.html", + styleUrls: ["./number-range-filter.component.scss"], + standalone: true, + imports: [MatFormFieldModule, ReactiveFormsModule, RangeInputComponent], +}) +export class NumberRangeFilterComponent { + @Input() filterConfig: NumberFilter; + + formControl: FormControl; + from: number; + to: number; + + ngOnInit() { + this.formControl = new FormControl({ + from: Number(this.filterConfig.selectedOptionValues[0]), + to: Number(this.filterConfig.selectedOptionValues[1]), + }); + this.formControl.valueChanges.subscribe((value) => { + this.filterConfig.selectedOptionValues = [ + this.formControl.value.from?.toString() ?? "", + this.formControl.value.to?.toString() ?? "", + ]; + + this.filterConfig.selectedOptionChange.emit( + this.filterConfig.selectedOptionValues, + ); + }); + } +} diff --git a/src/app/core/basic-datatypes/number/number-range-filter/number-range-filter.stories.ts b/src/app/core/basic-datatypes/number/number-range-filter/number-range-filter.stories.ts new file mode 100644 index 0000000000..9db2b88258 --- /dev/null +++ b/src/app/core/basic-datatypes/number/number-range-filter/number-range-filter.stories.ts @@ -0,0 +1,26 @@ +import { applicationConfig, Meta, StoryObj } from "@storybook/angular"; +import { NumberRangeFilterComponent } from "./number-range-filter.component"; +import { Entity } from "app/core/entity/model/entity"; +import { NumberFilter } from "app/core/filter/filters/numberFilter"; +import { provideAnimations } from "@angular/platform-browser/animations"; + +export default { + title: "Core/> App Layout/Filter/Number Range Filter", + component: NumberRangeFilterComponent, + decorators: [ + applicationConfig({ + providers: [provideAnimations()], + }), + ], +} as Meta; + +const filterConfig: NumberFilter = new NumberFilter( + "numberFilter", +); +filterConfig.label = "Demo Number Filter"; + +export const Default: StoryObj> = { + args: { + filterConfig, + }, +}; diff --git a/src/app/core/basic-datatypes/number/number-range-filter/range-input/range-input.component.html b/src/app/core/basic-datatypes/number/number-range-filter/range-input/range-input.component.html new file mode 100644 index 0000000000..598ad44f9b --- /dev/null +++ b/src/app/core/basic-datatypes/number/number-range-filter/range-input/range-input.component.html @@ -0,0 +1,5 @@ +
+ + + +
diff --git a/src/app/core/basic-datatypes/number/number-range-filter/range-input/range-input.component.scss b/src/app/core/basic-datatypes/number/number-range-filter/range-input/range-input.component.scss new file mode 100644 index 0000000000..b30f491d79 --- /dev/null +++ b/src/app/core/basic-datatypes/number/number-range-filter/range-input/range-input.component.scss @@ -0,0 +1,15 @@ +.container { + display: flex; +} + +.input-element { + border: none; + background: none; + padding: 0; + outline: none; + font: inherit; + text-align: center; + color: currentColor; + + max-width: calc(50% - 10px); +} diff --git a/src/app/core/basic-datatypes/number/number-range-filter/range-input/range-input.component.spec.ts b/src/app/core/basic-datatypes/number/number-range-filter/range-input/range-input.component.spec.ts new file mode 100644 index 0000000000..dce02060c7 --- /dev/null +++ b/src/app/core/basic-datatypes/number/number-range-filter/range-input/range-input.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; + +import { RangeInputComponent } from "./range-input.component"; + +describe("RangeInputComponent", () => { + let component: RangeInputComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [RangeInputComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(RangeInputComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/core/basic-datatypes/number/number-range-filter/range-input/range-input.component.ts b/src/app/core/basic-datatypes/number/number-range-filter/range-input/range-input.component.ts new file mode 100644 index 0000000000..4444939191 --- /dev/null +++ b/src/app/core/basic-datatypes/number/number-range-filter/range-input/range-input.component.ts @@ -0,0 +1,97 @@ +import { Component, ElementRef, Input, Optional, Self } from "@angular/core"; +import { + FormControl, + FormControlDirective, + FormGroup, + FormGroupDirective, + NgControl, + NgForm, + ReactiveFormsModule, + ValidationErrors, + ValidatorFn, +} from "@angular/forms"; +import { ErrorStateMatcher } from "@angular/material/core"; +import { MatFormFieldControl } from "@angular/material/form-field"; +import { CustomFormControlDirective } from "app/core/common-components/basic-autocomplete/custom-form-control.directive"; +import { MatInput } from "@angular/material/input"; + +@Component({ + selector: "app-range-input", + standalone: true, + imports: [MatInput, ReactiveFormsModule], + templateUrl: "./range-input.component.html", + styleUrl: "./range-input.component.scss", + providers: [ + { provide: MatFormFieldControl, useExisting: RangeInputComponent }, + ], +}) +export class RangeInputComponent extends CustomFormControlDirective { + formGroup: FormGroup = new FormGroup({ + from: new FormControl(), + to: new FormControl(), + }); + + @Input() override set value(value: NumericRange) { + // update the internal formGroup when the value changes from the outside + this.formGroup.setValue(value, { emitEvent: false }); + super.value = value; + } + override get value(): NumericRange { + return super.value; + } + + /** + * Validation (activated by default) ensures the component has a sensible range + * (e.g. "from" <= "to") + * or otherwise marks the formControl invalid + */ + @Input() activateValidation: boolean = true; + + constructor( + elementRef: ElementRef, + errorStateMatcher: ErrorStateMatcher, + @Optional() @Self() ngControl: NgControl, + @Optional() parentForm: NgForm, + @Optional() parentFormGroup: FormGroupDirective, + @Optional() private formControlDirective: FormControlDirective, + ) { + super( + elementRef, + errorStateMatcher, + ngControl, + parentForm, + parentFormGroup, + ); + + this.formGroup.valueChanges.subscribe((value) => { + this.value = value; + }); + } + + private validatorFunction: ValidatorFn = (): ValidationErrors | null => { + if ( + this.value.from != undefined && + this.value.to != undefined && + this.value.from > this.value.to + ) { + return { + fromGreaterThanTo: "The 'from' value is greater than the 'to' value.", + }; + } else { + return null; + } + }; + + ngAfterViewInit() { + if (this.activateValidation) { + this.formControlDirective.form.addValidators([this.validatorFunction]); + } + } +} + +export class NumericRange { + constructor( + public from: number, + public to: number, + ) {} +} diff --git a/src/app/core/config/config-fix.ts b/src/app/core/config/config-fix.ts index 7314534509..50aeebb157 100644 --- a/src/app/core/config/config-fix.ts +++ b/src/app/core/config/config-fix.ts @@ -387,8 +387,16 @@ export const defaultJsonConfig = { }, "privateSchool", "language", + "numberOfTeachers", + ], + filters: [ + { id: "privateSchool" }, + { + id: "numberOfTeachers", + type: "number-range", + label: "Number of Teachers", + }, ], - filters: [{ id: "privateSchool" }], }, }, "view:school/:id": { @@ -407,7 +415,7 @@ export const defaultJsonConfig = { { fields: ["name", "privateSchool", "parentSchool"] }, { fields: ["address", "phone"] }, { fields: ["language", "timing"] }, - { fields: ["remarks"] }, + { fields: ["remarks", "numberOfTeachers"] }, ], }, }, @@ -1093,6 +1101,10 @@ export const defaultJsonConfig = { dataType: "string", label: $localize`:Label for the timing of a school:School Timing`, }, + numberOfTeachers: { + dataType: "number", + label: $localize`:Label for a school attribute:Number of Teachers`, + }, remarks: { dataType: "string", label: $localize`:Label for the remarks for a school:Remarks`, diff --git a/src/app/core/entity-list/EntityListConfig.ts b/src/app/core/entity-list/EntityListConfig.ts index b2b3424e4a..04fc591604 100644 --- a/src/app/core/entity-list/EntityListConfig.ts +++ b/src/app/core/entity-list/EntityListConfig.ts @@ -72,6 +72,7 @@ export interface GroupConfig { export type FilterConfig = | BasicFilterConfig | BooleanFilterConfig + | NumberFilterConfig | PrebuiltFilterConfig | ConfigurableEnumFilterConfig; @@ -86,6 +87,7 @@ export interface BooleanFilterConfig extends BasicFilterConfig { true: string; false: string; } +export interface NumberFilterConfig extends BasicFilterConfig {} export interface PrebuiltFilterConfig extends BasicFilterConfig { options: FilterSelectionOption[]; diff --git a/src/app/core/filter/filter-generator/filter-generator.service.ts b/src/app/core/filter/filter-generator/filter-generator.service.ts index 208bfafc77..af312ddd7b 100644 --- a/src/app/core/filter/filter-generator/filter-generator.service.ts +++ b/src/app/core/filter/filter-generator/filter-generator.service.ts @@ -22,6 +22,7 @@ import { DateFilter } from "../filters/dateFilter"; import { BooleanFilter } from "../filters/booleanFilter"; import { ConfigurableEnumFilter } from "../filters/configurableEnumFilter"; import { EntityFilter } from "../filters/entityFilter"; +import { NumberFilter } from "../filters/numberFilter"; @Injectable({ providedIn: "root", @@ -66,6 +67,12 @@ export class FilterGeneratorService { label, filterConfig as BooleanFilterConfig, ); + } else if (type == "number-range") { + filter = new NumberFilter( + filterConfig.id, + filterConfig.label || schema.label, + // filterConfig as NumberFilterConfig, + ); } else if (type == "prebuilt") { filter = new SelectableFilter( filterConfig.id, diff --git a/src/app/core/filter/filters/filters.spec.ts b/src/app/core/filter/filters/filters.spec.ts index b068a7dd5b..04a10effd3 100644 --- a/src/app/core/filter/filters/filters.spec.ts +++ b/src/app/core/filter/filters/filters.spec.ts @@ -1,6 +1,7 @@ import { Filter, SelectableFilter } from "./filters"; import { FilterService } from "../filter.service"; import { BooleanFilter } from "./booleanFilter"; +import { NumberFilter } from "./numberFilter"; import { Entity } from "../../entity/model/entity"; describe("Filters", () => { @@ -73,6 +74,54 @@ describe("Filters", () => { testFilter(filter, [recordFalse, recordTrue], [recordFalse, recordTrue]); }); + it("should support a numbers filter", async () => { + const filter = new NumberFilter("value", "My Filter"); + + const recordMinus5 = { value: -5 }; + const record0 = { value: 0 }; + const record1 = { value: 1 }; + const record1_6 = { value: 1.6 }; + const record2 = { value: 2 }; + const record3 = { value: 3 }; + const record10 = { value: 10 }; + const records = [ + recordMinus5, + record0, + record1, + record1_6, + record2, + record3, + record10, + ]; + + filter.selectedOptionValues = ["2", "3"]; + testFilter(filter, records, [record2, record3]); + + filter.selectedOptionValues = ["0", "3"]; + testFilter(filter, records, [ + record0, + record1, + record1_6, + record2, + record3, + ]); + + filter.selectedOptionValues = ["-8", "1"]; + testFilter(filter, records, [recordMinus5, record0, record1]); + + filter.selectedOptionValues = ["10", "10"]; + testFilter(filter, records, [record10]); + + filter.selectedOptionValues = ["10", ""]; + testFilter(filter, records, [record10]); + + filter.selectedOptionValues = ["", "-1"]; + testFilter(filter, records, [recordMinus5]); + + filter.selectedOptionValues = ["", ""]; + testFilter(filter, records, records); + }); + it("should support numbers as options", () => { const filter = new SelectableFilter("counts", [], "Counts"); diff --git a/src/app/core/filter/filters/numberFilter.ts b/src/app/core/filter/filters/numberFilter.ts new file mode 100644 index 0000000000..b4a06c8ffa --- /dev/null +++ b/src/app/core/filter/filters/numberFilter.ts @@ -0,0 +1,34 @@ +import { Entity } from "../../entity/model/entity"; +import { DataFilter, Filter } from "./filters"; +import { NumberRangeFilterComponent } from "app/core/basic-datatypes/number/number-range-filter/number-range-filter.component"; + +/** + * Represents a filter for number values. + */ +export class NumberFilter extends Filter { + override component = NumberRangeFilterComponent; + + constructor( + public override name: string, + public override label: string = name, + ) { + super(name, label); + this.selectedOptionValues = []; + } + + getFilter(): DataFilter { + const filterObject: { $gte?; $lte?: number } = {}; + if (this.selectedOptionValues[0]) { + filterObject.$gte = Number(this.selectedOptionValues[0]); + } + if (this.selectedOptionValues[1]) { + filterObject.$lte = Number(this.selectedOptionValues[1]); + } + if (filterObject.$gte || filterObject.$lte) { + return { + [this.name]: filterObject, + } as DataFilter; + } + return {} as DataFilter; + } +}