Merged PR 1950: 5343-Filter-NumberRange

Related work items: #5343
This commit is contained in:
Lorenz Hilpert
2025-09-16 09:54:29 +00:00
committed by Nino Righi
parent 707802ce0d
commit c5d057e3a7
27 changed files with 1117 additions and 361 deletions

View File

@@ -193,19 +193,18 @@ export class FilterService {
return input;
}
const isParent =
const hasChildren =
Array.isArray(checkboxOption.values) &&
checkboxOption.values.length > 0;
let keys: string[] = [];
if (isParent) {
// If the option has children, we need to include all child keys
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
keys = checkboxOption.values!.map((v) =>
checkboxOptionKeysHelper(v).join('.'),
// Collect the keys we want to add/remove. When the option has children we only include the
// children's keys; otherwise we include the option's own key. Avoids non-null assertion.
let keys: string[];
if (hasChildren) {
const children = checkboxOption.values as CheckboxFilterInputOption[]; // safe due to hasChildren
keys = children.map((child) =>
checkboxOptionKeysHelper(child).join('.'),
);
} else {
// If no children, just use the current option key
keys = [optionKeys.join('.')];
}
@@ -361,6 +360,45 @@ export class FilterService {
}
}
/**
* Sets the number range values for an input with the specified key.
*
* This updates the current minimum (min) and maximum (max) values for a
* NumberRange filter input. Passing `undefined` for either side will clear that
* boundary while leaving the other intact.
*
* @param key - The key of the number range input to update
* @param min - The minimum numeric value (inclusive) or undefined to clear
* @param max - The maximum numeric value (inclusive) or undefined to clear
* @param options - Optional parameters
* @param options.commit - If true, commits the changes immediately
*/
setInputNumberRangeValue(
key: string,
min?: number,
max?: number,
options?: { commit: boolean },
): void {
const inputs = this.#state.inputs().map((input) => {
if (input.key !== key) {
return input;
}
if (input.type === InputType.NumberRange) {
return { ...input, min, max };
}
this.logUnsupportedInputType(input, 'setInputNumberRangeValue');
return input;
});
patchState(this.#state, { inputs });
if (options?.commit) {
this.commit();
}
}
/**
* Helper method to consistently log unsupported input type warnings
* @private
@@ -809,6 +847,17 @@ export class FilterService {
result[input.key] = '';
}
break;
case InputType.NumberRange: {
const { min: minVal, max: maxVal } = input;
if (minVal != null && maxVal != null) {
result[input.key] = `"${minVal}-${maxVal}"`;
} else if (minVal != null) {
result[input.key] = `"${minVal}-"`;
} else if (maxVal != null) {
result[input.key] = `"-${maxVal}"`;
}
break;
}
}
}
@@ -927,6 +976,18 @@ export class FilterService {
this.setInputDateRangeValue(key, start, stop);
break;
}
case InputType.NumberRange: {
const decoded = decodeURIComponent(params[key]);
const [minRaw, maxRaw] = decoded.split('-"');
const min = minRaw
? Number(minRaw.replace(/"-/g, '').replace(/"/g, ''))
: undefined;
const max = maxRaw
? Number(maxRaw.replace(/"-/g, '').replace(/"/g, ''))
: undefined;
this.setInputNumberRangeValue(key, min, max);
break;
}
default:
this.logUnsupportedInputType(input, 'parseQueryParams');
break;

View File

@@ -50,6 +50,15 @@ export function mapFilterInputToRecord(
if (start || stop) {
acc[input.key] = `${start}-${stop}`;
}
} else if (input.type === InputType.NumberRange) {
const { min, max } = input;
if (min && max) {
acc[input.key] = `"${min}-${max}"`;
} else if (min) {
acc[input.key] = `"${min}-"`;
} else if (max) {
acc[input.key] = `"-${max}"`;
}
}
return acc;
}, {});

View File

@@ -119,22 +119,4 @@ describe('filterInputMapping', () => {
mapped: 'dateRange',
});
});
it('should throw error for unknown input type', () => {
// Arrange
const group = 'testGroup';
const input: Input = {
key: 'unknownInput',
label: 'Unknown Input',
type: 999 as unknown as InputType, // Invalid input type
};
// Act & Assert
expect(() => filterInputMapping(group, input)).toThrow(
'Unknown input type: 999',
);
expect(mockTextFilterInputMapping).not.toHaveBeenCalled();
expect(mockCheckboxFilterInputMapping).not.toHaveBeenCalled();
expect(mockDateRangeFilterInputMapping).not.toHaveBeenCalled();
});
});

View File

@@ -1,29 +1,32 @@
import { Input, InputType } from '../../types';
import { FilterInput } from '../schemas';
import { checkboxFilterInputMapping } from './checkbox-filter-input.mapping';
import { dateRangeFilterInputMapping } from './date-range-filter-input.mapping';
import { textFilterInputMapping } from './text-filter-input.mapping';
/**
* Maps an Input object to the appropriate FilterInput type based on its input type
*
* This function serves as a router that delegates to the specific mapping function
* based on the input type (Text, Checkbox, DateRange). It ensures that each input
* is converted to its corresponding strongly-typed filter input object.
*
* @param group - The group identifier that this input belongs to
* @param input - The source input object to map
* @returns A validated FilterInput object of the appropriate subtype
* @throws Error if the input type is not supported
*/
export function filterInputMapping(group: string, input: Input): FilterInput {
switch (input.type) {
case InputType.Text:
return textFilterInputMapping(group, input);
case InputType.Checkbox:
return checkboxFilterInputMapping(group, input);
case InputType.DateRange:
return dateRangeFilterInputMapping(group, input);
}
throw new Error(`Unknown input type: ${input.type}`);
}
import { Input, InputType } from '../../types';
import { FilterInput } from '../schemas';
import { checkboxFilterInputMapping } from './checkbox-filter-input.mapping';
import { dateRangeFilterInputMapping } from './date-range-filter-input.mapping';
import { numberRangeFilterInputMapping } from './number-range-filter-input.mapping';
import { textFilterInputMapping } from './text-filter-input.mapping';
/**
* Maps an Input object to the appropriate FilterInput type based on its input type
*
* This function serves as a router that delegates to the specific mapping function
* based on the input type (Text, Checkbox, DateRange). It ensures that each input
* is converted to its corresponding strongly-typed filter input object.
*
* @param group - The group identifier that this input belongs to
* @param input - The source input object to map
* @returns A validated FilterInput object of the appropriate subtype
* @throws Error if the input type is not supported
*/
export function filterInputMapping(group: string, input: Input): FilterInput {
switch (input.type) {
case InputType.Text:
return textFilterInputMapping(group, input);
case InputType.Checkbox:
return checkboxFilterInputMapping(group, input);
case InputType.DateRange:
return dateRangeFilterInputMapping(group, input);
case InputType.NumberRange:
return numberRangeFilterInputMapping(group, input);
}
console.warn('filterInputMapping: Unknown input type', input);
}

View File

@@ -31,8 +31,14 @@ export function filterMapping(settings: QuerySettings): Filter {
for (const group of groups) {
filter.groups.push(filterGroupMapping(group));
for (const input of group.input) {
filter.inputs.push(filterInputMapping(group.group, input));
if (Array.isArray(group.input)) {
for (const input of group.input) {
const inputs = filterInputMapping(group.group, input);
if (inputs === undefined) {
continue;
}
filter.inputs.push(inputs);
}
}
}

View File

@@ -0,0 +1,32 @@
import { Input, InputType } from '../../types';
import {
NumberRangeFilterInput,
NumberRangeFilterInputSchema,
} from '../schemas';
export function numberRangeFilterInputMapping(
group: string,
input: Input,
): NumberRangeFilterInput {
return NumberRangeFilterInputSchema.parse({
group,
key: input.key,
label: input.label,
description: input.description,
type: InputType.NumberRange,
min: input.options?.values?.[0]?.value
? Number(input.options?.values?.[0]?.value)
: undefined,
max: input.options?.values?.[1]?.value
? Number(input.options?.values?.[1]?.value)
: undefined,
minValue: input.options?.values?.[0]?.minValue
? Number(input.options?.values?.[0]?.minValue.replace('-', ''))
: undefined,
maxValue: input.options?.values?.[0]?.maxValue
? Number(input.options?.values?.[0]?.maxValue.replace('-', ''))
: undefined,
minLabel: input.options?.values?.[0]?.label || 'Min',
maxLabel: input.options?.values?.[1]?.label || 'Max',
});
}

View File

@@ -1,21 +1,23 @@
import { z } from 'zod';
import { CheckboxFilterInputSchema } from './checkbox-filter-input.schema';
import { DateRangeFilterInputSchema } from './date-range-filter-input.schema';
import { TextFilterInputSchema } from './text-filter-input.schema';
/**
* A union schema representing all possible filter input types in the system.
* This schema allows for type discrimination based on the `type` property.
*
* Supported filter input types:
* - TextFilterInput: Simple text input fields
* - CheckboxFilterInput: Multiple-choice checkbox selections
* - DateRangeFilterInput: Date range selectors for time-based filtering
*/
export const FilterInputSchema = z.union([
TextFilterInputSchema,
CheckboxFilterInputSchema,
DateRangeFilterInputSchema,
]);
export type FilterInput = z.infer<typeof FilterInputSchema>;
import { z } from 'zod';
import { CheckboxFilterInputSchema } from './checkbox-filter-input.schema';
import { DateRangeFilterInputSchema } from './date-range-filter-input.schema';
import { TextFilterInputSchema } from './text-filter-input.schema';
import { NumberRangeFilterInputSchema } from './number-range-filter-input.schema';
/**
* A union schema representing all possible filter input types in the system.
* This schema allows for type discrimination based on the `type` property.
*
* Supported filter input types:
* - TextFilterInput: Simple text input fields
* - CheckboxFilterInput: Multiple-choice checkbox selections
* - DateRangeFilterInput: Date range selectors for time-based filtering
*/
export const FilterInputSchema = z.union([
TextFilterInputSchema,
CheckboxFilterInputSchema,
DateRangeFilterInputSchema,
NumberRangeFilterInputSchema,
]);
export type FilterInput = z.infer<typeof FilterInputSchema>;

View File

@@ -1,12 +1,13 @@
export * from './base-filter-input.schema';
export * from './checkbox-filter-input-option.schema';
export * from './checkbox-filter-input.schema';
export * from './date-range-filter-input.schema';
export * from './filter-group.schema';
export * from './filter-input.schema';
export * from './filter.schema';
export * from './order-by-direction.schema';
export * from './order-by-option.schema';
export * from './query-order.schema';
export * from './query.schema';
export * from './text-filter-input.schema';
export * from './base-filter-input.schema';
export * from './checkbox-filter-input-option.schema';
export * from './checkbox-filter-input.schema';
export * from './date-range-filter-input.schema';
export * from './filter-group.schema';
export * from './filter-input.schema';
export * from './filter.schema';
export * from './number-range-filter-input.schema';
export * from './order-by-direction.schema';
export * from './order-by-option.schema';
export * from './query-order.schema';
export * from './query.schema';
export * from './text-filter-input.schema';

View File

@@ -0,0 +1,31 @@
import { z } from 'zod';
import { BaseFilterInputSchema } from './base-filter-input.schema';
import { InputType } from '../../types';
export const NumberRangeFilterInputSchema = BaseFilterInputSchema.extend({
type: z
.literal(InputType.NumberRange)
.describe(
'Specifies this as a number range input type. Must be InputType.NumberRange.',
),
min: z.number().optional().describe('Current minimum value of the range.'),
max: z.number().optional().describe('Current maximum value of the range.'),
minValue: z
.number()
.optional()
.describe(
'Minimum value of the number range. Optional if only a maximum is needed.',
),
maxValue: z
.number()
.optional()
.describe(
'Maximum value of the number range. Optional if only a minimum is needed.',
),
minLabel: z.string().describe('Label for the minimum input field.'),
maxLabel: z.string().describe('Label for the maximum input field.'),
}).describe('NumberRangeFilterInput');
export type NumberRangeFilterInput = z.infer<
typeof NumberRangeFilterInputSchema
>;

View File

@@ -1,15 +1,19 @@
@switch (filterInput().type) {
@case (InputType.Checkbox) {
<filter-checkbox-input [inputKey]="filterInput().key">
</filter-checkbox-input>
}
@case (InputType.DateRange) {
<filter-datepicker-range-input [inputKey]="filterInput().key">
</filter-datepicker-range-input>
}
@default {
<div class="text-isa-accent-red isa-text-body-1-bold">
Fehler: Kein Template für diesen Typ gefunden! {{ filterInput().type }}
</div>
}
}
@switch (filterInput().type) {
@case (InputType.Checkbox) {
<filter-checkbox-input [inputKey]="filterInput().key">
</filter-checkbox-input>
}
@case (InputType.DateRange) {
<filter-datepicker-range-input [inputKey]="filterInput().key">
</filter-datepicker-range-input>
}
@case (InputType.NumberRange) {
<filter-number-range-input [inputKey]="filterInput().key">
</filter-number-range-input>
}
@default {
<div class="text-isa-accent-red isa-text-body-1-bold">
Fehler: Kein Template für diesen Typ gefunden! {{ filterInput().type }}
</div>
}
}

View File

@@ -1,27 +1,32 @@
import {
ChangeDetectionStrategy,
Component,
input,
ViewEncapsulation,
} from '@angular/core';
import { CheckboxInputComponent } from '../checkbox-input';
import { DatepickerRangeInputComponent } from '../datepicker-range-input';
import { FilterInput } from '../../core';
import { InputType } from '../../types';
@Component({
selector: 'filter-input-renderer',
templateUrl: 'input-renderer.component.html',
styleUrls: ['./input-renderer.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
standalone: true,
imports: [CheckboxInputComponent, DatepickerRangeInputComponent],
host: {
'[class]': "['filter-input-renderer']",
},
})
export class InputRendererComponent {
filterInput = input.required<FilterInput>();
InputType = InputType;
}
import {
ChangeDetectionStrategy,
Component,
input,
ViewEncapsulation,
} from '@angular/core';
import { CheckboxInputComponent } from '../checkbox-input';
import { DatepickerRangeInputComponent } from '../datepicker-range-input';
import { NumberRangeInputComponent } from '../number-range-input';
import { FilterInput } from '../../core';
import { InputType } from '../../types';
@Component({
selector: 'filter-input-renderer',
templateUrl: 'input-renderer.component.html',
styleUrls: ['./input-renderer.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
standalone: true,
imports: [
CheckboxInputComponent,
DatepickerRangeInputComponent,
NumberRangeInputComponent,
],
host: {
'[class]': "['filter-input-renderer']",
},
})
export class InputRendererComponent {
filterInput = input.required<FilterInput>();
InputType = InputType;
}

View File

@@ -0,0 +1 @@
export * from './number-range-input.component';

View File

@@ -0,0 +1,43 @@
@let inp = input();
<ui-inline-input>
<label>{{ inp.minLabel }}</label>
<input
type="number"
uiInputControl
[(ngModel)]="minValue"
[min]="minInputLowerLimit()"
[max]="minInputUpperLimit()"
#minModel="ngModel"
/>
@if (minModel.invalid && minModel.touched) {
<div class="text-isa-accent-red isa-text-caption-regular">
@if (minModel.errors?.['min']) {
Mindestens {{ minInputLowerLimit() }}.
}
@if (minModel.errors?.['max']) {
Maximal {{ minInputUpperLimit() }}.
}
</div>
}
</ui-inline-input>
<ui-inline-input>
<label>{{ inp.maxLabel }}</label>
<input
type="number"
uiInputControl
[(ngModel)]="maxValue"
[min]="maxInputLowerLimit()"
[max]="maxInputUpperLimit()"
#maxModel="ngModel"
/>
@if (minModel.invalid && minModel.touched) {
<div class="text-isa-accent-red isa-text-caption-regular">
@if (minModel.errors?.['min']) {
Mindestens {{ minInputLowerLimit() }}.
}
@if (minModel.errors?.['max']) {
Maximal {{ minInputUpperLimit() }}.
}
</div>
}
</ui-inline-input>

View File

@@ -0,0 +1,7 @@
:host {
@apply grid grid-cols-2 gap-2 p-5;
}
.ui-input-control {
@apply w-full;
}

View File

@@ -0,0 +1,87 @@
import {
ChangeDetectionStrategy,
Component,
computed,
effect,
inject,
input,
linkedSignal,
} from '@angular/core';
import { FilterService, NumberRangeFilterInput } from '../../core';
import { InputType } from '../../types';
import {
InputControlDirective,
InlineInputComponent,
} from '@isa/ui/input-controls';
import { logger } from '@isa/core/logging';
import { FormsModule } from '@angular/forms';
@Component({
selector: 'filter-number-range-input',
templateUrl: './number-range-input.component.html',
styleUrls: ['./number-range-input.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [InlineInputComponent, InputControlDirective, FormsModule],
host: { '[class]': "['filter-number-range-input']" },
})
export class NumberRangeInputComponent {
#logger = logger(() => ({ component: 'NumberRangeInputComponent' }));
#filterService = inject(FilterService);
inputKey = input.required<string>();
input = computed<NumberRangeFilterInput>(() => {
const inputs = this.#filterService.inputs();
const input = inputs.find(
(input) =>
input.key === this.inputKey() && input.type === InputType.NumberRange,
) as NumberRangeFilterInput;
if (!input) {
const err = new Error(`Input not found for key: ${this.inputKey()}`);
this.#logger.error('Input not found', { error: err });
}
return input;
});
minValue = linkedSignal(() => this.input().min);
// Overall domain bounds coming from the filter input definition
overallMinLimit = computed(() => this.input().minValue ?? 0);
overallMaxLimit = computed(() => this.input().maxValue ?? Infinity);
// Allowed bounds for the minimum value input
minInputLowerLimit = computed(() => this.overallMinLimit());
minInputUpperLimit = computed(() =>
Math.min(this.maxValue() ?? Infinity, this.overallMaxLimit()),
);
maxValue = linkedSignal(() => this.input().max);
// Allowed bounds for the maximum value input
maxInputLowerLimit = computed(() =>
Math.max(this.minValue() ?? 0, this.overallMinLimit()),
);
maxInputUpperLimit = computed(() => this.overallMaxLimit());
constructor() {
effect(() => {
const minValue = this.minValue();
const maxValue = this.maxValue();
const currentMin = this.input().min;
const currentMax = this.input().max;
if (minValue === currentMin && maxValue === currentMax) {
return;
}
this.#filterService.setInputNumberRangeValue(
this.inputKey(),
minValue,
maxValue,
);
});
}
}

View File

@@ -2,13 +2,14 @@ export enum InputType {
Text = 1,
Checkbox = 2,
DateRange = 128,
NumberRange = 4096,
}
/**
* Enumeration of search trigger types that indicate how a search was initiated.
* Used throughout the filter system to track user interaction patterns and
* optimize search behavior based on the trigger source.
*
*
* @example
* ```typescript
* // Handle different search triggers