Merged PR 1891: feat: implement multi-level checkbox filter with hierarchical selection

feat: implement multi-level checkbox filter with hierarchical selection

- Add support for hierarchical checkbox options with parent-child relationships
- Implement automatic child selection/deselection when parent is toggled
- Add checkbox-input-control component for individual option management
- Add isCheckboxSelected helper for determining selection states
- Extend FilterService with setInputCheckboxOptionSelected method
- Update checkbox schemas to support nested option structures
- Add comprehensive test coverage for new multi-level functionality

Ref: #5231
This commit is contained in:
Lorenz Hilpert
2025-07-25 13:49:44 +00:00
committed by Nino Righi
parent 0b4aef5f6c
commit b339a6d79f
35 changed files with 4071 additions and 1886 deletions

View File

@@ -1,4 +1,5 @@
export * from './lib/core';
export * from './lib/helpers';
export * from './lib/inputs';
export * from './lib/types';
export * from './lib/actions';

View File

@@ -4,6 +4,7 @@ import { getState, patchState, signalState } from '@ngrx/signals';
import { filterMapping, mapFilterInputToRecord } from './mappings';
import { isEqual } from 'lodash';
import {
CheckboxFilterInputOption,
DateRangeFilterInput,
FilterInput,
OrderByDirection,
@@ -14,6 +15,7 @@ import {
import { FILTER_ON_COMMIT, FILTER_ON_INIT, QUERY_SETTINGS } from './tokens';
import { logger } from '@isa/core/logging';
import { parseISO } from 'date-fns';
import { checkboxOptionKeysHelper } from '../helpers/checkbox-option-keys.helper';
@Injectable()
export class FilterService {
@@ -141,6 +143,122 @@ export class FilterService {
}
}
/**
* Sets the selection state of a specific checkbox option within a hierarchical structure.
*
* This method handles the selection/deselection of checkbox options with automatic
* parent-child relationship management:
* - When selecting a parent option, all child options are implicitly selected
* - When deselecting a parent option, all child options are also deselected
* - Child options can be individually selected/deselected
*
* @param path - Array representing the hierarchical path to the option [group, groupKey, ...optionKeys]
* @param selected - Whether to select (true) or deselect (false) the option
* @param options - Optional parameters
* @param options.commit - If true, commits the changes immediately
*
* @example
* ```typescript
* // Select a specific option
* filterService.setInputCheckboxOptionSelected(
* ['category', 'products', 'electronics', 'phones'],
* true
* );
*
* // Deselect a parent option (also deselects all children)
* filterService.setInputCheckboxOptionSelected(
* ['category', 'products', 'electronics'],
* false,
* { commit: true }
* );
* ```
*/
setInputCheckboxOptionSelected(
checkboxOption: CheckboxFilterInputOption,
// [group, groupKey, ...optionKeys]: string[],
selected: boolean,
options?: { commit: boolean },
): void {
const [group, groupKey, ...optionKeys] = checkboxOption.path;
const inputs = this.#state.inputs().map((input) => {
const target = input.group === group && input.key === groupKey;
if (!target) {
return input;
}
if (input.type !== InputType.Checkbox) {
this.logUnsupportedInputType(input, 'setInputCheckboxValue');
return input;
}
const isParent =
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('.'),
);
} else {
// If no children, just use the current option key
keys = [optionKeys.join('.')];
}
if (selected) {
const newSelected = [...input.selected, ...keys];
return {
...input,
selected: newSelected,
};
} else {
const filteredSelected = input.selected.filter((s) => {
// Remove the key itself and any child keys
return keys.includes(s) === false;
});
return {
...input,
selected: filteredSelected,
};
}
});
patchState(this.#state, { inputs });
if (options?.commit) {
this.commit();
}
}
// getInputCheckboxOptionSelected([group, groupKey, ...optionKeys]: string[]):
// | boolean
// | undefined {
// const input = this.#state.inputs().find((input) => {
// return input.group === group && input.key === groupKey;
// });
// if (!input) {
// this.#logger.warn(`Input not found`, () => ({
// inputGroup: group,
// inputKey: groupKey,
// }));
// return undefined;
// }
// if (input.type !== InputType.Checkbox) {
// this.logUnsupportedInputType(input, 'getInputCheckboxValue');
// return undefined;
// }
// return input.selected.includes(optionKeys.join('.'));
// }
/**
* Sets the date range values for an input with the specified key.
*

View File

@@ -3,15 +3,32 @@ import { CheckboxFilterInput, CheckboxFilterInputSchema } from '../schemas';
import { checkboxOptionMapping } from './checkbox-option.mapping';
/**
* Maps an Input object to a CheckboxFilterInput object
* Maps an Input object to a CheckboxFilterInput object with support for hierarchical options.
*
* This function takes an input of type Checkbox and maps it to a strongly-typed
* CheckboxFilterInput object, validating it against a schema. It also maps all child
* options and tracks which options are selected.
* This function transforms a generic Input object into a strongly-typed CheckboxFilterInput,
* handling nested checkbox options and tracking selection states. The mapping process:
* - Validates the input against the CheckboxFilterInputSchema
* - Recursively maps nested options with proper path tracking
* - Extracts selected values from the option tree
*
* @param group - The group identifier that this input belongs to
* @param input - The source input object to map
* @returns A validated CheckboxFilterInput object
* @param input - The source input object containing checkbox configuration
* @returns A validated CheckboxFilterInput object with hierarchical options
*
* @example
* ```typescript
* const checkboxInput = checkboxFilterInputMapping('filters', {
* key: 'category',
* label: 'Product Category',
* type: 'checkbox',
* options: {
* values: [
* { value: 'electronics', label: 'Electronics', selected: true },
* { value: 'clothing', label: 'Clothing' }
* ]
* }
* });
* ```
*/
export function checkboxFilterInputMapping(
group: string,
@@ -25,7 +42,9 @@ export function checkboxFilterInputMapping(
type: InputType.Checkbox,
defaultValue: input.value,
maxOptions: input.options?.max,
options: input.options?.values?.map(checkboxOptionMapping),
options: input.options?.values?.map((v) =>
checkboxOptionMapping(v, [group, input.key]),
),
selected:
input.options?.values
?.filter((option) => option.selected)

View File

@@ -17,6 +17,7 @@ describe('checkboxOptionMapping', () => {
it('should map option correctly', () => {
// Arrange
const option: Option = {
key: 'Option Key',
label: 'Option Label',
value: 'option-value',
};
@@ -25,14 +26,11 @@ describe('checkboxOptionMapping', () => {
const result = checkboxOptionMapping(option);
// Assert
expect(mockSchemaParser).toHaveBeenCalledWith({
label: 'Option Label',
value: 'option-value',
});
expect(result).toEqual({
key: 'Option Key',
label: 'Option Label',
value: 'option-value',
path: ['Option Key'],
});
});
@@ -48,16 +46,187 @@ describe('checkboxOptionMapping', () => {
const result = checkboxOptionMapping(option);
// Assert
expect(mockSchemaParser).toHaveBeenCalledWith({
label: 'Option Label',
value: 'option-value',
});
expect(result).toEqual({
key: 'option-value',
label: 'Option Label',
value: 'option-value',
path: ['option-value'],
});
// The selected property should not be included in the mapped result
expect(result).not.toHaveProperty('selected');
});
it('should use value as key when key is not provided', () => {
// Arrange
const option: Option = {
label: 'Option Label',
value: 'option-value',
};
// Act
const result = checkboxOptionMapping(option);
// Assert
expect(result).toEqual({
key: 'option-value',
label: 'Option Label',
value: 'option-value',
path: ['option-value'],
});
});
it('should handle custom path parameter', () => {
// Arrange
const option: Option = {
key: 'child',
label: 'Child Option',
value: 'child-value',
};
const path = ['group', 'parent'];
// Act
const result = checkboxOptionMapping(option, path);
// Assert
expect(result).toEqual({
key: 'child',
label: 'Child Option',
value: 'child-value',
path: ['group', 'parent', 'child'],
});
});
it('should map nested options with one level', () => {
// Arrange
const option: Option = {
key: 'parent',
label: 'Parent Option',
value: 'parent-value',
values: [
{
key: 'child1',
label: 'Child 1',
value: 'child1-value',
},
{
key: 'child2',
label: 'Child 2',
value: 'child2-value',
},
],
};
// Act
const result = checkboxOptionMapping(option);
// Assert
expect(result).toEqual({
key: 'parent',
label: 'Parent Option',
value: 'parent-value',
path: ['parent'],
values: [
{
key: 'child1',
label: 'Child 1',
value: 'child1-value',
path: ['parent', 'child1'],
},
{
key: 'child2',
label: 'Child 2',
value: 'child2-value',
path: ['parent', 'child2'],
},
],
});
});
it('should handle options with undefined values array', () => {
// Arrange
const option: Option = {
key: 'leaf',
label: 'Leaf Option',
value: 'leaf-value',
values: undefined,
};
// Act
const result = checkboxOptionMapping(option);
// Assert
expect(result).toEqual({
key: 'leaf',
label: 'Leaf Option',
value: 'leaf-value',
path: ['leaf'],
values: undefined,
});
expect(result.values).toBeUndefined();
});
it('should handle options with empty values array', () => {
// Arrange
const option: Option = {
key: 'empty-parent',
label: 'Empty Parent',
value: 'empty-parent-value',
values: [],
};
// Act
const result = checkboxOptionMapping(option);
// Assert
expect(result).toEqual({
key: 'empty-parent',
label: 'Empty Parent',
value: 'empty-parent-value',
path: ['empty-parent'],
values: [],
});
});
it('should call schema parse with correct data', () => {
// Arrange
const option: Option = {
key: 'test',
label: 'Test',
value: 'test-value',
};
// Act
checkboxOptionMapping(option);
// Assert
expect(mockSchemaParser).toHaveBeenCalledWith({
path: ['test'],
key: 'test',
label: 'Test',
value: 'test-value',
});
});
it('should preserve path immutability when mapping nested options', () => {
// Arrange
const option: Option = {
key: 'parent',
label: 'Parent',
value: 'parent-value',
values: [
{
key: 'child',
label: 'Child',
value: 'child-value',
},
],
};
const initialPath = ['root'];
// Act
checkboxOptionMapping(option, initialPath);
// Assert
expect(initialPath).toEqual(['root']); // Original path should not be modified
});
});

View File

@@ -1,3 +1,4 @@
import { getOptionKeyHelper } from '../../helpers';
import { Option } from '../../types';
import {
CheckboxFilterInputOption,
@@ -5,19 +6,43 @@ import {
} from '../schemas';
/**
* Maps an Option object to a CheckboxFilterInputOption object
* Recursively maps an Option object to a CheckboxFilterInputOption with hierarchical path tracking.
*
* This function converts a generic Option to a strongly-typed
* CheckboxFilterInputOption, validating it against a schema.
* This function transforms a generic Option into a strongly-typed CheckboxFilterInputOption,
* building a hierarchical path that uniquely identifies the option's position in the tree.
* The mapping process:
* - Generates a unique path by combining parent paths with the current option's key
* - Recursively processes nested options to build the complete hierarchy
* - Validates the result against the CheckboxFilterInputOptionSchema
*
* @param option - The source option object to map
* @returns A validated CheckboxFilterInputOption object
* @param option - The source option object containing label, value, and optional nested values
* @param path - Array of strings representing the path from root to the parent of this option
* @returns A validated CheckboxFilterInputOption with complete hierarchical path information
*
* @example
* ```typescript
* const mappedOption = checkboxOptionMapping({
* label: 'Smartphones',
* value: 'smartphones',
* values: [
* { label: 'iPhone', value: 'iphone' },
* { label: 'Android', value: 'android' }
* ]
* }, ['category', 'electronics']);
* // Results in path: ['category', 'electronics', 'smartphones']
* ```
*/
export function checkboxOptionMapping(
option: Option,
path: string[] = [],
): CheckboxFilterInputOption {
const key = getOptionKeyHelper(option);
return CheckboxFilterInputOptionSchema.parse({
path: [...path, key],
key: key, // Use value as key if not provided
label: option.label,
value: option.value,
values: option.values?.map((v) => checkboxOptionMapping(v, [...path, key])),
});
}

View File

@@ -1,18 +1,42 @@
import { mapFilterInputToRecord } from './filter-input-to-record.mapping';
import { InputType } from '../../types';
import { FilterInput } from '../schemas';
import {
CheckboxFilterInput,
DateRangeFilterInput,
FilterInput,
TextFilterInput,
} from '../schemas';
describe('mapFilterInputToRecord', () => {
it('should map text input', () => {
const input: FilterInput[] = [
{ key: 'name', type: InputType.Text, value: 'test' } as any,
{ key: 'name', type: InputType.Text, value: 'test' } as TextFilterInput,
];
expect(mapFilterInputToRecord(input)).toEqual({ name: 'test' });
});
it('should map checkbox input', () => {
const input: FilterInput[] = [
{ key: 'tags', type: InputType.Checkbox, selected: ['a', 'b'] } as any,
{
key: 'tags',
type: InputType.Checkbox,
selected: ['Key A', 'Key B'],
group: 'g',
options: [
{
key: 'Key A',
label: 'Option A',
value: 'a',
path: ['g', 'tags', 'Key A'],
},
{
key: 'Key B',
label: 'Option B',
value: 'b',
path: ['g', 'tags', 'Key B'],
},
],
} as CheckboxFilterInput,
];
expect(mapFilterInputToRecord(input)).toEqual({ tags: 'a;b' });
});
@@ -21,7 +45,12 @@ describe('mapFilterInputToRecord', () => {
const start = '2024-06-01T00:00:00.000Z';
const stop = '2024-06-05T00:00:00.000Z';
const input: FilterInput[] = [
{ key: 'range', type: InputType.DateRange, start, stop } as any,
{
key: 'range',
type: InputType.DateRange,
start,
stop,
} as DateRangeFilterInput,
];
const result = mapFilterInputToRecord(input);
// The stop value is incremented by 1 day and wrapped in quotes
@@ -32,9 +61,251 @@ describe('mapFilterInputToRecord', () => {
it('should skip empty values', () => {
const input: FilterInput[] = [
{ key: 'empty', type: InputType.Text, value: '' } as any,
{ key: 'none', type: InputType.Checkbox, selected: [] } as any,
{ key: 'empty', type: InputType.Text, value: '' } as TextFilterInput,
{
key: 'none',
type: InputType.Checkbox,
selected: [],
group: 'g',
options: [],
} as CheckboxFilterInput,
];
expect(mapFilterInputToRecord(input)).toEqual({ empty: '', none: '' });
});
describe('nested checkbox selections', () => {
it('should handle 2-level path resolution', () => {
const input: FilterInput[] = [
{
key: 'categories',
type: InputType.Checkbox,
selected: ['electronics.phones'],
group: 'g',
options: [
{
key: 'electronics',
label: 'Electronics',
path: ['g', 'categories', 'electronics'],
values: [
{
key: 'phones',
label: 'Phones',
value: 'phone-value',
path: ['g', 'categories', 'electronics', 'phones'],
},
],
},
],
} as CheckboxFilterInput,
];
expect(mapFilterInputToRecord(input)).toEqual({ categories: 'phone-value' });
});
it('should handle parent selection collecting all child values', () => {
const input: FilterInput[] = [
{
key: 'categories',
type: InputType.Checkbox,
selected: ['parent'],
group: 'g',
options: [
{
key: 'parent',
label: 'Parent',
path: ['g', 'categories', 'parent'],
// Parent has no direct value
values: [
{
key: 'child1',
label: 'Child 1',
value: 'child1-value',
path: ['g', 'categories', 'parent', 'child1'],
},
{
key: 'child2',
label: 'Child 2',
value: 'child2-value',
path: ['g', 'categories', 'parent', 'child2'],
},
],
},
],
} as CheckboxFilterInput,
];
expect(mapFilterInputToRecord(input)).toEqual({
categories: 'child1-value;child2-value'
});
});
it('should handle mixed selections of parents and children', () => {
const input: FilterInput[] = [
{
key: 'filters',
type: InputType.Checkbox,
selected: ['parent1', 'parent2.child'],
group: 'g',
options: [
{
key: 'parent1',
label: 'Parent 1',
value: 'parent1-value',
path: ['g', 'filters', 'parent1'],
},
{
key: 'parent2',
label: 'Parent 2',
path: ['g', 'filters', 'parent2'],
values: [
{
key: 'child',
label: 'Child',
value: 'child-value',
path: ['g', 'filters', 'parent2', 'child'],
},
],
},
],
} as CheckboxFilterInput,
];
expect(mapFilterInputToRecord(input)).toEqual({
filters: 'parent1-value;child-value'
});
});
it('should handle selections with non-existent paths', () => {
const input: FilterInput[] = [
{
key: 'categories',
type: InputType.Checkbox,
selected: ['nonexistent.path', 'valid'],
group: 'g',
options: [
{
key: 'valid',
label: 'Valid',
value: 'valid-value',
path: ['g', 'categories', 'valid'],
},
],
} as CheckboxFilterInput,
];
expect(mapFilterInputToRecord(input)).toEqual({ categories: 'valid-value' });
});
it('should handle empty or null selected array', () => {
const input: FilterInput[] = [
{
key: 'categories',
type: InputType.Checkbox,
selected: null as any,
group: 'g',
options: [],
} as CheckboxFilterInput,
];
expect(mapFilterInputToRecord(input)).toEqual({ categories: '' });
});
it('should handle options with both value and child values', () => {
const input: FilterInput[] = [
{
key: 'mixed',
type: InputType.Checkbox,
selected: ['parent'],
group: 'g',
options: [
{
key: 'parent',
label: 'Parent',
value: 'parent-value', // Has its own value
path: ['g', 'mixed', 'parent'],
values: [
{
key: 'child',
label: 'Child',
value: 'child-value',
path: ['g', 'mixed', 'parent', 'child'],
},
],
},
],
} as CheckboxFilterInput,
];
expect(mapFilterInputToRecord(input)).toEqual({
mixed: 'parent-value' // Should use the parent's direct value, not collect children
});
});
});
describe('date range edge cases', () => {
it('should handle only start date', () => {
const input: FilterInput[] = [
{
key: 'range',
type: InputType.DateRange,
start: '2024-06-01T00:00:00.000Z',
stop: undefined,
} as DateRangeFilterInput,
];
expect(mapFilterInputToRecord(input)).toEqual({
range: '"2024-06-01T00:00:00.000Z"-'
});
});
it('should handle only stop date', () => {
const input: FilterInput[] = [
{
key: 'range',
type: InputType.DateRange,
start: undefined,
stop: '2024-06-05T00:00:00.000Z',
} as DateRangeFilterInput,
];
const result = mapFilterInputToRecord(input);
expect(result['range']).toMatch(/^-"2024-06-06T00:00:00.000Z"$/);
});
it('should skip date range with no dates', () => {
const input: FilterInput[] = [
{
key: 'range',
type: InputType.DateRange,
start: undefined,
stop: undefined,
} as DateRangeFilterInput,
];
expect(mapFilterInputToRecord(input)).toEqual({});
});
});
it('should handle multiple filter types together', () => {
const input: FilterInput[] = [
{ key: 'name', type: InputType.Text, value: 'test' } as TextFilterInput,
{
key: 'tags',
type: InputType.Checkbox,
selected: ['tag1'],
group: 'g',
options: [
{
key: 'tag1',
label: 'Tag 1',
value: 'tag1-value',
path: ['g', 'tags', 'tag1'],
},
],
} as CheckboxFilterInput,
{
key: 'date',
type: InputType.DateRange,
start: '2024-01-01T00:00:00.000Z',
stop: '2024-01-31T23:59:59.999Z',
} as DateRangeFilterInput,
];
const result = mapFilterInputToRecord(input);
expect(result).toMatchObject({
name: 'test',
tags: 'tag1-value',
});
expect(result['date']).toMatch(/^"2024-01-01T00:00:00.000Z"-"2024-02-01T/);
});
});

View File

@@ -1,6 +1,6 @@
import { addDays } from 'date-fns';
import { InputType } from '../../types';
import { FilterInput } from '../schemas';
import { FilterInput, CheckboxFilterInputOption } from '../schemas';
export function mapFilterInputToRecord(
inputs: FilterInput[],
@@ -9,7 +9,39 @@ export function mapFilterInputToRecord(
if (input.type === InputType.Text) {
acc[input.key] = input.value || '';
} else if (input.type === InputType.Checkbox) {
acc[input.key] = input.selected?.join(';') || '';
const values: string[] = [];
input.selected?.forEach((value) => {
const path = value.split('.');
let current = path.shift();
let option = input.options?.find((o) => o.key === current);
while (path.length > 0 && option) {
current = path.shift();
option = option?.values?.find((o) => o.key === current);
}
if (option) {
// If the option has no value but has child options, collect all child values
if (!option.value && option.values && option.values.length > 0) {
const collectChildValues = (
opt: CheckboxFilterInputOption,
): string[] => {
const childValues: string[] = [];
if (opt.value) {
childValues.push(opt.value);
}
if (opt.values) {
opt.values.forEach((child) => {
childValues.push(...collectChildValues(child));
});
}
return childValues;
};
values.push(...collectChildValues(option));
} else if (option.value) {
values.push(option.value);
}
}
});
acc[input.key] = values.join(';');
} else if (input.type === InputType.DateRange) {
const start = input.start ? `"${input.start}"` : '';
const stop = input.stop

View File

@@ -26,7 +26,7 @@ export function filterMapping(settings: QuerySettings): Filter {
orderBy: [],
};
const groups = [...settings.filter, ...settings.input];
const groups = [...(settings.filter || []), ...(settings.input || [])];
for (const group of groups) {
filter.groups.push(filterGroupMapping(group));

View File

@@ -1,24 +1,56 @@
import { z } from 'zod';
/**
* Represents a checkbox option within a CheckboxFilterInput.
* Base schema for checkbox filter input options.
*
* @property label - Display text for the checkbox option
* @property value - The value to be used when this option is selected
* Represents a single checkbox option within a hierarchical filter structure.
* Each option has a unique path that identifies its position in the hierarchy.
*/
export const CheckboxFilterInputOptionSchema = z
const CheckboxFilterInputOptionBaseSchema = z
.object({
path: z
.array(z.string())
.describe(
'Path to the option, useful for nested structures. Starts with the group key and includes all parent keys.',
),
key: z.string().describe('Unique identifier for the checkbox option.'),
label: z
.string()
.describe('Display text shown next to the checkbox in the UI.'),
value: z
.string()
.optional()
.describe(
'Underlying value that will be sent in requests when this option is selected.',
),
})
.describe('CheckboxFilterInputOption');
/**
* Schema for checkbox filter input options with support for nested hierarchical structures.
*
* Extends the base checkbox filter input option schema to include an optional `values`
* array that can contain nested CheckboxFilterInputOption instances, enabling multi-level
* checkbox filtering capabilities.
*
* @example
* ```typescript
* const filterOption = {
* // base properties from CheckboxFilterInputOptionBaseSchema
* values: [
* // nested checkbox options
* ]
* };
* ```
*/
export const CheckboxFilterInputOptionSchema =
CheckboxFilterInputOptionBaseSchema.extend({
values: z
.array(CheckboxFilterInputOptionBaseSchema)
.optional()
.describe('Array of CheckboxFilterInputOption for nested options.'),
});
export type CheckboxFilterInputOption = z.infer<
typeof CheckboxFilterInputOptionSchema
>;

View File

@@ -0,0 +1,56 @@
import { checkboxOptionKeysHelper } from './checkbox-option-keys.helper';
import { CheckboxFilterInputOption } from '../core';
describe('checkboxOptionKeysHelper', () => {
it('should extract option keys from path excluding group and groupKey', () => {
const option: CheckboxFilterInputOption = {
path: ['group', 'key', 'level1', 'level2', 'level3'],
key: 'level3',
label: 'Level 3 Option',
value: 'level3',
};
const result = checkboxOptionKeysHelper(option);
expect(result).toEqual(['level1', 'level2', 'level3']);
});
it('should return single key when path has only one option level', () => {
const option: CheckboxFilterInputOption = {
path: ['group', 'key', 'singleOption'],
key: 'singleOption',
label: 'Single Option',
value: 'singleOption',
};
const result = checkboxOptionKeysHelper(option);
expect(result).toEqual(['singleOption']);
});
it('should handle deep nested option paths', () => {
const option: CheckboxFilterInputOption = {
path: ['category', 'products', 'electronics', 'phones', 'smartphones', 'android'],
key: 'android',
label: 'Android Phones',
value: 'android',
};
const result = checkboxOptionKeysHelper(option);
expect(result).toEqual(['electronics', 'phones', 'smartphones', 'android']);
});
it('should return empty array when path only contains group and groupKey', () => {
const option: CheckboxFilterInputOption = {
path: ['group', 'key'],
key: 'key',
label: 'Root Option',
value: 'key',
};
const result = checkboxOptionKeysHelper(option);
expect(result).toEqual([]);
});
});

View File

@@ -0,0 +1,7 @@
import { CheckboxFilterInputOption } from '../core';
export function checkboxOptionKeysHelper(
option: CheckboxFilterInputOption,
): string[] {
return [...option.path].slice(2);
}

View File

@@ -0,0 +1,321 @@
import { checkboxSelectedHelper, checkboxSelectedHelperBoolean } from './checkbox-selected.helper';
import { FilterInput, CheckboxFilterInput, CheckboxFilterInputOption } from '../core';
import { InputType } from '../types';
describe('checkboxSelectedHelper', () => {
const baseInput: CheckboxFilterInput = {
group: 'group',
key: 'key',
type: InputType.Checkbox,
options: [],
selected: [],
label: 'label',
};
it('should return "none" if input not found', () => {
const option: CheckboxFilterInputOption = {
path: ['group', 'key', 'opt'],
key: 'opt',
label: 'Option',
value: 'opt',
};
expect(checkboxSelectedHelper([], option)).toBe('none');
});
it('should return "none" when input type is not checkbox', () => {
const option: CheckboxFilterInputOption = {
path: ['group', 'key', 'opt'],
key: 'opt',
label: 'Option',
value: 'opt',
};
const input: FilterInput = {
group: 'group',
key: 'key',
type: InputType.Text,
value: 'test',
};
expect(checkboxSelectedHelper([input], option)).toBe('none');
});
it('should return "checked" for leaf if selected', () => {
const input: CheckboxFilterInput = {
...baseInput,
selected: ['opt'],
options: [
{ path: ['group', 'key', 'opt'], key: 'opt', label: 'Option', value: 'opt' },
],
};
const option = input.options[0];
expect(checkboxSelectedHelper([input], option)).toBe('checked');
});
it('should return "none" for leaf if not selected', () => {
const input: CheckboxFilterInput = {
...baseInput,
selected: [],
options: [
{ path: ['group', 'key', 'opt'], key: 'opt', label: 'Option', value: 'opt' },
],
};
const option = input.options[0];
expect(checkboxSelectedHelper([input], option)).toBe('none');
});
it('should return "checked" for parent if all children selected', () => {
const input: CheckboxFilterInput = {
...baseInput,
selected: ['parent.child1', 'parent.child2'],
options: [
{
path: ['group', 'key', 'parent'],
key: 'parent',
label: 'Parent',
values: [
{ path: ['group', 'key', 'parent', 'child1'], key: 'child1', label: 'Child1', value: 'child1' },
{ path: ['group', 'key', 'parent', 'child2'], key: 'child2', label: 'Child2', value: 'child2' },
],
},
],
};
const option = input.options[0];
expect(checkboxSelectedHelper([input], option)).toBe('checked');
});
it('should return "indeterminate" for parent if some children selected', () => {
const input: CheckboxFilterInput = {
...baseInput,
selected: ['parent.child1'],
options: [
{
path: ['group', 'key', 'parent'],
key: 'parent',
label: 'Parent',
values: [
{ path: ['group', 'key', 'parent', 'child1'], key: 'child1', label: 'Child1', value: 'child1' },
{ path: ['group', 'key', 'parent', 'child2'], key: 'child2', label: 'Child2', value: 'child2' },
],
},
],
};
const option = input.options[0];
expect(checkboxSelectedHelper([input], option)).toBe('indeterminate');
});
it('should return "none" for parent if no children selected', () => {
const input: CheckboxFilterInput = {
...baseInput,
selected: [],
options: [
{
path: ['group', 'key', 'parent'],
key: 'parent',
label: 'Parent',
values: [
{ path: ['group', 'key', 'parent', 'child1'], key: 'child1', label: 'Child1', value: 'child1' },
{ path: ['group', 'key', 'parent', 'child2'], key: 'child2', label: 'Child2', value: 'child2' },
],
},
],
};
const option = input.options[0];
expect(checkboxSelectedHelper([input], option)).toBe('none');
});
describe('multi-level hierarchies', () => {
it('should handle 2-level deep hierarchies', () => {
const input: CheckboxFilterInput = {
...baseInput,
selected: ['parent.child1', 'parent.child2'],
options: [
{
path: ['group', 'key', 'parent'],
key: 'parent',
label: 'Parent',
values: [
{ path: ['group', 'key', 'parent', 'child1'], key: 'child1', label: 'Child1', value: 'gc1' },
{ path: ['group', 'key', 'parent', 'child2'], key: 'child2', label: 'Child2', value: 'gc2' },
],
},
],
};
const parentOption = input.options[0];
const childOption = parentOption.values![0];
expect(checkboxSelectedHelper([input], parentOption)).toBe('checked');
expect(checkboxSelectedHelper([input], childOption)).toBe('checked');
});
it('should handle indeterminate state in hierarchies', () => {
const input: CheckboxFilterInput = {
...baseInput,
selected: ['parent.child1'],
options: [
{
path: ['group', 'key', 'parent'],
key: 'parent',
label: 'Parent',
values: [
{ path: ['group', 'key', 'parent', 'child1'], key: 'child1', label: 'Child1', value: 'gc1' },
{ path: ['group', 'key', 'parent', 'child2'], key: 'child2', label: 'Child2', value: 'gc2' },
],
},
],
};
const parentOption = input.options[0];
expect(checkboxSelectedHelper([input], parentOption)).toBe('indeterminate');
});
});
describe('parent selection inheritance', () => {
it('should return "checked" for children when parent is selected', () => {
const input: CheckboxFilterInput = {
...baseInput,
selected: ['parent'],
options: [
{
path: ['group', 'key', 'parent'],
key: 'parent',
label: 'Parent',
values: [
{ path: ['group', 'key', 'parent', 'child1'], key: 'child1', label: 'Child1', value: 'child1' },
{ path: ['group', 'key', 'parent', 'child2'], key: 'child2', label: 'Child2', value: 'child2' },
],
},
],
};
const parentOption = input.options[0];
const child1 = parentOption.values![0];
const child2 = parentOption.values![1];
expect(checkboxSelectedHelper([input], parentOption)).toBe('checked');
expect(checkboxSelectedHelper([input], child1)).toBe('checked');
expect(checkboxSelectedHelper([input], child2)).toBe('checked');
});
it('should inherit selection through parent-child levels', () => {
const input: CheckboxFilterInput = {
...baseInput,
selected: ['parent'],
options: [
{
path: ['group', 'key', 'parent'],
key: 'parent',
label: 'Parent',
values: [
{ path: ['group', 'key', 'parent', 'child'], key: 'child', label: 'Child', value: 'gc' },
],
},
],
};
const child = input.options[0].values![0];
expect(checkboxSelectedHelper([input], child)).toBe('checked');
});
});
describe('edge cases', () => {
it('should handle option with empty values array', () => {
const input: CheckboxFilterInput = {
...baseInput,
selected: ['parent'],
options: [
{
path: ['group', 'key', 'parent'],
key: 'parent',
label: 'Parent',
values: [],
},
],
};
const option = input.options[0];
expect(checkboxSelectedHelper([input], option)).toBe('checked');
});
it('should handle null selected array', () => {
const input: CheckboxFilterInput = {
...baseInput,
selected: null as any,
options: [
{ path: ['group', 'key', 'opt'], key: 'opt', label: 'Option', value: 'opt' },
],
};
const option = input.options[0];
expect(checkboxSelectedHelper([input], option)).toBe('none');
});
it('should handle undefined selected array', () => {
const input: CheckboxFilterInput = {
...baseInput,
selected: undefined as any,
options: [
{ path: ['group', 'key', 'opt'], key: 'opt', label: 'Option', value: 'opt' },
],
};
const option = input.options[0];
expect(checkboxSelectedHelper([input], option)).toBe('none');
});
});
});
describe('checkboxSelectedHelperBoolean', () => {
const baseInput: CheckboxFilterInput = {
group: 'group',
key: 'key',
type: InputType.Checkbox,
options: [],
selected: [],
label: 'label',
};
it('should return true for "checked" state', () => {
const input: CheckboxFilterInput = {
...baseInput,
selected: ['opt'],
options: [
{ path: ['group', 'key', 'opt'], key: 'opt', label: 'Option', value: 'opt' },
],
};
const option = input.options[0];
expect(checkboxSelectedHelperBoolean([input], option)).toBe(true);
});
it('should return true for "indeterminate" state', () => {
const input: CheckboxFilterInput = {
...baseInput,
selected: ['parent.child1'],
options: [
{
path: ['group', 'key', 'parent'],
key: 'parent',
label: 'Parent',
values: [
{ path: ['group', 'key', 'parent', 'child1'], key: 'child1', label: 'Child1', value: 'child1' },
{ path: ['group', 'key', 'parent', 'child2'], key: 'child2', label: 'Child2', value: 'child2' },
],
},
],
};
const option = input.options[0];
expect(checkboxSelectedHelperBoolean([input], option)).toBe(true);
});
it('should return false for "none" state', () => {
const input: CheckboxFilterInput = {
...baseInput,
selected: [],
options: [
{ path: ['group', 'key', 'opt'], key: 'opt', label: 'Option', value: 'opt' },
],
};
const option = input.options[0];
expect(checkboxSelectedHelperBoolean([input], option)).toBe(false);
});
});

View File

@@ -0,0 +1,175 @@
import {
CheckboxFilterInput,
CheckboxFilterInputOption,
FilterInput,
} from '../core';
import { InputType } from '../types';
/**
* Represents the possible states of a checkbox in a hierarchical structure
*/
export type CheckboxState = 'checked' | 'indeterminate' | 'none';
/**
* Determines the selection state of a checkbox option within a hierarchical structure.
*
* This helper function calculates whether a checkbox should be displayed as:
* - 'checked': The option and all its children (if any) are selected
* - 'indeterminate': The option has children and only some are selected
* - 'none': The option is not selected
*
* For parent options, the state is determined by their children's selection status.
* For leaf options, the state is determined by whether the option itself or any of its parent paths are selected.
*
* @param inputs - Array of all filter inputs
* @param option - The checkbox option to check the state for
* @returns The current state of the checkbox
*
* @example
* ```typescript
* const state = checkboxSelectedHelper(filterInputs, {
* path: ['category', 'electronics', 'phones'],
* label: 'Phones',
* value: 'phones',
* values: []
* });
* // Returns 'checked' if phones or any parent category is selected
* ```
*/
export function checkboxSelectedHelper(
inputs: FilterInput[],
option: CheckboxFilterInputOption,
): CheckboxState {
const [group, groupKey, ...optionKeys] = option.path;
// Find the input that matches the group and key
const input = inputs.find(
(input) =>
input.group === group &&
input.key === groupKey &&
input.type === InputType.Checkbox,
) as CheckboxFilterInput | undefined;
// If no input found, return 'none'
if (!input) {
return 'none';
}
const isParent = Array.isArray(option.values) && option.values.length > 0;
if (isParent) {
// For parent options, check children selection state
return getParentCheckboxState(input, optionKeys, option);
} else {
// For leaf options, check if this option or any of its parents is selected
return getLeafCheckboxState(input, optionKeys);
}
}
/**
* Calculates the checkbox state for parent options based on their children's selection status.
*
* @param input - The checkbox filter input containing selected paths
* @param optionKeys - Array of keys representing the path to this option
* @param option - The parent checkbox option
* @returns 'checked' if all children are selected, 'indeterminate' if some are selected, 'none' if none are selected
* @private
*/
function getParentCheckboxState(
input: CheckboxFilterInput,
optionKeys: string[],
option: CheckboxFilterInputOption,
): CheckboxState {
const currentPath = optionKeys.join('.');
// If the parent itself is selected, it's checked
if (input.selected?.includes(currentPath)) {
return 'checked';
}
// Check how many children are selected (at any level)
if (input.selected) {
const selectedChildren = input.selected.filter((selected) =>
selected.startsWith(currentPath + '.'),
);
if (selectedChildren.length === 0) {
return 'none';
}
// Count all possible children recursively
const totalChildren = countAllChildren(option);
if (selectedChildren.length === totalChildren) {
return 'checked';
} else {
return 'indeterminate';
}
}
return 'none';
}
/**
* Recursively counts all children (including nested children) of a checkbox option.
*
* @param option - The checkbox option to count children for
* @returns Total number of child options at all levels
* @private
*/
function countAllChildren(option: CheckboxFilterInputOption): number {
if (!option.values || option.values.length === 0) {
return 0;
}
let count = option.values.length;
for (const child of option.values) {
count += countAllChildren(child);
}
return count;
}
/**
* Determines the checkbox state for leaf (non-parent) options.
*
* A leaf option is considered 'checked' if either:
* - The option itself is directly selected
* - Any of its parent paths are selected (inherited selection)
*
* @param input - The checkbox filter input containing selected paths
* @param optionKeys - Array of keys representing the path to this option
* @returns 'checked' if the option or any parent is selected, 'none' otherwise
* @private
*/
function getLeafCheckboxState(
input: CheckboxFilterInput,
optionKeys: string[],
): CheckboxState {
if (input.selected) {
// Check if this option or any of its parents is selected
for (let i = 1; i <= optionKeys.length; i++) {
const parentPath = optionKeys.slice(0, i).join('.');
if (input.selected.includes(parentPath)) {
return 'checked';
}
}
}
return 'none';
}
/**
* Legacy helper function that returns a boolean representation of checkbox selection state.
*
* @deprecated Use checkboxSelectedHelper() instead for more accurate state representation
* @param inputs - Array of all filter inputs
* @param option - The checkbox option to check
* @returns true if the checkbox is checked or indeterminate, false if none
*/
export function checkboxSelectedHelperBoolean(
inputs: FilterInput[],
option: CheckboxFilterInputOption,
): boolean {
const state = checkboxSelectedHelper(inputs, option);
return state === 'checked' || state === 'indeterminate';
}

View File

@@ -0,0 +1,143 @@
import { filterCheckboxOptionsHelper } from './filter-checkbox-options.helper';
import { CheckboxFilterInputOption } from '../core';
describe('filterCheckboxOptionsHelper', () => {
const mockOptions: CheckboxFilterInputOption[] = [
{
path: ['category', 'electronics'],
key: 'electronics',
label: 'Electronics',
values: [
{
path: ['category', 'electronics', 'laptops'],
key: 'laptops',
label: 'Laptops',
},
{
path: ['category', 'electronics', 'phones'],
key: 'phones',
label: 'Mobile Phones',
},
],
},
{
path: ['category', 'clothing'],
key: 'clothing',
label: 'Clothing',
values: [
{
path: ['category', 'clothing', 'shirts'],
key: 'shirts',
label: 'Shirts',
},
{
path: ['category', 'clothing', 'pants'],
key: 'pants',
label: 'Pants',
},
],
},
{
path: ['category', 'books'],
key: 'books',
label: 'Books',
},
];
it('should return all options when query is empty', () => {
// Arrange & Act
const result = filterCheckboxOptionsHelper(mockOptions, '');
// Assert
expect(result).toEqual(mockOptions);
});
it('should return all options when query is empty string', () => {
// Arrange & Act
const result = filterCheckboxOptionsHelper(mockOptions, '');
// Assert
expect(result).toEqual(mockOptions);
});
it('should filter options by top-level label match', () => {
// Arrange & Act
const result = filterCheckboxOptionsHelper(mockOptions, 'clothing');
// Assert
expect(result).toHaveLength(1);
expect(result[0].label).toBe('Clothing');
expect(result[0].values).toHaveLength(2); // Should keep all children
});
it('should filter options by child label match and keep parent', () => {
// Arrange & Act
const result = filterCheckboxOptionsHelper(mockOptions, 'laptops');
// Assert
expect(result).toHaveLength(1);
expect(result[0].label).toBe('Electronics');
expect(result[0].values).toHaveLength(1);
expect(result[0].values?.[0].label).toBe('Laptops');
});
it('should be case insensitive', () => {
// Arrange & Act
const result = filterCheckboxOptionsHelper(mockOptions, 'ELECTRONICS');
// Assert
expect(result).toHaveLength(1);
expect(result[0].label).toBe('Electronics');
});
it('should filter by partial matches', () => {
// Arrange & Act
const result = filterCheckboxOptionsHelper(mockOptions, 'phone');
// Assert
expect(result).toHaveLength(1);
expect(result[0].label).toBe('Electronics');
expect(result[0].values).toHaveLength(1);
expect(result[0].values?.[0].label).toBe('Mobile Phones');
});
it('should return empty array when no matches found', () => {
// Arrange & Act
const result = filterCheckboxOptionsHelper(mockOptions, 'nonexistent');
// Assert
expect(result).toEqual([]);
});
it('should handle options without children', () => {
// Arrange & Act
const result = filterCheckboxOptionsHelper(mockOptions, 'books');
// Assert
expect(result).toHaveLength(1);
expect(result[0].label).toBe('Books');
expect(result[0].values).toBeUndefined();
});
it('should filter multiple matching options', () => {
// Arrange & Act
const result = filterCheckboxOptionsHelper(mockOptions, 's'); // Should match "Electronics", "Shirts", "Pants", "Books"
// Assert - The actual behavior returns all 3 items:
// 1. Electronics (label contains 's')
// 2. Clothing (has children "Shirts" and "Pants" that contain 's')
// 3. Books (label contains 's')
expect(result).toHaveLength(3);
expect(result.find((r) => r.label === 'Electronics')).toBeTruthy();
expect(result.find((r) => r.label === 'Clothing')?.values).toHaveLength(2);
expect(result.find((r) => r.label === 'Books')).toBeTruthy();
});
it('should handle empty options array', () => {
// Arrange & Act
const result = filterCheckboxOptionsHelper([], 'test');
// Assert
expect(result).toEqual([]);
});
});

View File

@@ -0,0 +1,42 @@
import { CheckboxFilterInputOption } from '../core';
/**
* Filters checkbox options recursively by label text
* @param options - The checkbox options to filter
* @param query - The search query to filter by
* @returns Filtered options that match the query or have matching children
*/
export const filterCheckboxOptionsHelper = (
options: CheckboxFilterInputOption[],
query: string,
): CheckboxFilterInputOption[] => {
if (!query) {
return options;
}
const lowercaseQuery = query.toLowerCase();
return options.reduce((filtered, option) => {
// Check if current option label matches
const labelMatches = option.label?.toLowerCase().includes(lowercaseQuery);
// If parent matches, keep all children; otherwise filter children recursively
const filteredChildren = option.values
? labelMatches
? option.values // Keep all children when parent matches
: filterCheckboxOptionsHelper(option.values, query) // Only filter children if parent doesn't match
: undefined;
// Include option if:
// 1. Its label matches the query, OR
// 2. It has children that match (keep parent for context)
if (labelMatches || (filteredChildren && filteredChildren.length > 0)) {
filtered.push({
...option,
values: filteredChildren,
});
}
return filtered;
}, [] as CheckboxFilterInputOption[]);
};

View File

@@ -0,0 +1,225 @@
import { getAllCheckboxOptionsHelper } from './get-all-checkbox-options.helper';
import { CheckboxFilterInput, CheckboxFilterInputOption } from '../core';
import { InputType } from '../types';
describe('getAllCheckboxOptionsHelper', () => {
const createCheckboxOption = (
key: string,
label: string,
path: string[],
values?: CheckboxFilterInputOption[],
): CheckboxFilterInputOption => ({
path,
key,
label,
values,
});
const mockOptions: CheckboxFilterInputOption[] = [
createCheckboxOption(
'electronics',
'Electronics',
['category', 'electronics'],
[
createCheckboxOption('laptops', 'Laptops', [
'category',
'electronics',
'laptops',
]),
createCheckboxOption(
'phones',
'Mobile Phones',
['category', 'electronics', 'phones'],
[
createCheckboxOption('smartphone', 'Smartphones', [
'category',
'electronics',
'phones',
'smartphone',
]),
createCheckboxOption('feature', 'Feature Phones', [
'category',
'electronics',
'phones',
'feature',
]),
],
),
],
),
createCheckboxOption(
'clothing',
'Clothing',
['category', 'clothing'],
[
createCheckboxOption('shirts', 'Shirts', [
'category',
'clothing',
'shirts',
]),
createCheckboxOption('pants', 'Pants', [
'category',
'clothing',
'pants',
]),
],
),
createCheckboxOption('books', 'Books', ['category', 'books']),
];
const createMockInput = (
options: CheckboxFilterInputOption[],
): CheckboxFilterInput => ({
group: 'category',
key: 'test-checkbox',
label: 'Test Checkbox',
type: InputType.Checkbox,
options,
selected: [],
});
it('should return all options in a flat array', () => {
// Arrange
const mockInput = createMockInput(mockOptions);
// Act
const result = getAllCheckboxOptionsHelper(mockInput);
// Assert
expect(result).toHaveLength(9); // 3 top-level + 2 electronics children + 2 phones children + 2 clothing children + 1 books
expect(result.map((option) => option.key)).toEqual([
'electronics',
'laptops',
'phones',
'smartphone',
'feature',
'clothing',
'shirts',
'pants',
'books',
]);
});
it('should handle options without children', () => {
// Arrange
const simpleOptions = [
createCheckboxOption('option1', 'Option 1', ['simple', 'option1']),
createCheckboxOption('option2', 'Option 2', ['simple', 'option2']),
];
const mockInput = createMockInput(simpleOptions);
// Act
const result = getAllCheckboxOptionsHelper(mockInput);
// Assert
expect(result).toHaveLength(2);
expect(result.map((option) => option.key)).toEqual(['option1', 'option2']);
});
it('should handle empty options array', () => {
// Arrange
const mockInput = createMockInput([]);
// Act
const result = getAllCheckboxOptionsHelper(mockInput);
// Assert
expect(result).toEqual([]);
});
it('should preserve option order in flattening', () => {
// Arrange
const orderedOptions = [
createCheckboxOption(
'first',
'First',
['first'],
[createCheckboxOption('child1', 'Child 1', ['first', 'child1'])],
),
createCheckboxOption('second', 'Second', ['second']),
createCheckboxOption(
'third',
'Third',
['third'],
[createCheckboxOption('child2', 'Child 2', ['third', 'child2'])],
),
];
const mockInput = createMockInput(orderedOptions);
// Act
const result = getAllCheckboxOptionsHelper(mockInput);
// Assert
expect(result.map((option) => option.key)).toEqual([
'first',
'child1',
'second',
'third',
'child2',
]);
});
it('should handle deeply nested options', () => {
// Arrange
const deeplyNestedOptions = [
createCheckboxOption(
'level1',
'Level 1',
['level1'],
[
createCheckboxOption(
'level2',
'Level 2',
['level1', 'level2'],
[
createCheckboxOption(
'level3',
'Level 3',
['level1', 'level2', 'level3'],
[
createCheckboxOption('level4', 'Level 4', [
'level1',
'level2',
'level3',
'level4',
]),
],
),
],
),
],
),
];
const mockInput = createMockInput(deeplyNestedOptions);
// Act
const result = getAllCheckboxOptionsHelper(mockInput);
// Assert
expect(result).toHaveLength(4);
expect(result.map((option) => option.key)).toEqual([
'level1',
'level2',
'level3',
'level4',
]);
});
it('should handle input with undefined options', () => {
// Arrange
const mockInput: CheckboxFilterInput = {
group: 'test',
key: 'test-checkbox',
label: 'Test Checkbox',
type: InputType.Checkbox,
options: undefined as unknown as CheckboxFilterInputOption[], // Simulating potential undefined scenario
selected: [],
};
// Act
const result = getAllCheckboxOptionsHelper(mockInput);
// Assert
expect(result).toEqual([]);
});
});

View File

@@ -0,0 +1,16 @@
import { CheckboxFilterInput, CheckboxFilterInputOption } from '../core';
export function getAllCheckboxOptionsHelper(
input: CheckboxFilterInput,
): CheckboxFilterInputOption[] {
const getAllOptions = (
options: CheckboxFilterInputOption[],
): CheckboxFilterInputOption[] => {
return options.flatMap((option) => [
option,
...getAllOptions(option.values || []),
]);
};
return getAllOptions(input.options || []);
}

View File

@@ -0,0 +1,218 @@
import { getOptionKeyHelper } from './get-option-key.helper';
import { CheckboxFilterInputOption } from '../core';
describe('getOptionKeyHelper', () => {
const createCheckboxOption = (
key: string,
label: string,
path: string[] = ['test'],
value?: string,
): CheckboxFilterInputOption => ({
path,
key,
label,
value,
});
describe('with key property', () => {
it('should return the key when present', () => {
// Arrange
const option = createCheckboxOption(
'test-key',
'Test Label',
['test'],
'test-value',
);
// Act
const result = getOptionKeyHelper(option);
// Assert
expect(result).toBe('test-key');
});
it('should return the key even when value and label are also present', () => {
// Arrange
const option = createCheckboxOption(
'priority-key',
'Fallback Label',
['test'],
'fallback-value',
);
// Act
const result = getOptionKeyHelper(option);
// Assert
expect(result).toBe('priority-key');
});
});
describe('fallback to value property', () => {
it('should return the value when key is empty string but value is present', () => {
// Arrange
const option = createCheckboxOption(
'',
'Test Label',
['test'],
'fallback-value',
);
// Act
const result = getOptionKeyHelper(option);
// Assert
expect(result).toBe('fallback-value');
});
});
describe('fallback to label property', () => {
it('should return the label when key and value are both empty strings', () => {
// Arrange
const option = createCheckboxOption('', 'Fallback Label', ['test'], '');
// Act
const result = getOptionKeyHelper(option);
// Assert
expect(result).toBe('Fallback Label');
});
it('should return the label when key is empty and value is undefined', () => {
// Arrange
const option = createCheckboxOption('', 'Only Label Available', ['test']);
// Act
const result = getOptionKeyHelper(option);
// Assert
expect(result).toBe('Only Label Available');
});
});
describe('edge cases and error handling', () => {
it('should return empty string when all identifier properties are empty strings', () => {
// Arrange
const option = createCheckboxOption('', '', ['test'], '');
// Act
const result = getOptionKeyHelper(option);
// Assert
expect(result).toBe('');
});
it('should handle options with only whitespace in key property', () => {
// Arrange
const option = createCheckboxOption(
' ',
'Clean Label',
['test'],
'clean-value',
);
// Act
const result = getOptionKeyHelper(option);
// Assert
expect(result).toBe(' '); // Returns the key even with spaces
});
it('should prefer value over label when key is empty', () => {
// Arrange
const option = createCheckboxOption(
'',
'Available Label',
['test'],
'Available Value',
);
// Act
const result = getOptionKeyHelper(option);
// Assert
expect(result).toBe('Available Value'); // value comes before label in fallback chain
});
it('should handle missing value property gracefully', () => {
// Arrange - simulate scenario where helper checks for truthy key first
const option: CheckboxFilterInputOption = {
path: ['test'],
key: '', // empty key should trigger fallback
label: 'Fallback to Label',
// value is optional and not provided
};
// Act
const result = getOptionKeyHelper(option);
// Assert
expect(result).toBe('Fallback to Label');
});
});
describe('real-world scenarios', () => {
it('should work with typical checkbox option structure', () => {
// Arrange
const option: CheckboxFilterInputOption = {
path: ['category', 'electronics'],
key: 'electronics',
label: 'Electronics Category',
};
// Act
const result = getOptionKeyHelper(option);
// Assert
expect(result).toBe('electronics');
});
it('should work with options that have both key and value', () => {
// Arrange
const option: CheckboxFilterInputOption = {
path: ['category', 'books'],
key: 'books',
label: 'Books & Literature',
value: 'book-category',
};
// Act
const result = getOptionKeyHelper(option);
// Assert
expect(result).toBe('books'); // key takes priority
});
it('should work with API responses that need fallback to value', () => {
// Arrange
const option: CheckboxFilterInputOption = {
path: ['filter', 'status'],
key: '', // empty key forces fallback
label: 'Active Items',
value: 'active',
};
// Act
const result = getOptionKeyHelper(option);
// Assert
expect(result).toBe('active');
});
it('should handle complex nested path structures', () => {
// Arrange
const option: CheckboxFilterInputOption = {
path: ['catalog', 'electronics', 'computers', 'laptops'],
key: 'gaming-laptops',
label: 'Gaming Laptops',
value: 'laptop-gaming',
};
// Act
const result = getOptionKeyHelper(option);
// Assert
expect(result).toBe('gaming-laptops');
});
});
});

View File

@@ -0,0 +1,10 @@
import { CheckboxFilterInputOption } from '../core';
import { Option } from '../types';
export function getOptionKeyHelper(
option: CheckboxFilterInputOption | Option,
): string {
// Use key if available, otherwise fallback to value or label
const key = String(option.key || option.value || option.label || '');
return key === 'undefined' ? '' : key;
}

View File

@@ -0,0 +1,5 @@
export * from './checkbox-selected.helper';
export * from './checkbox-option-keys.helper';
export * from './filter-checkbox-options.helper';
export * from './get-all-checkbox-options.helper';
export * from './get-option-key.helper';

View File

@@ -0,0 +1,34 @@
<div class="flex flex-row items-center justify-between gap-5">
<label
class="w-full flex items-center gap-4 cursor-pointer isa-text-body-2-regular has-[:checked]:isa-text-body-2-bold text-left"
>
<ui-checkbox>
<input
[attr.aria-label]="option().label"
type="checkbox"
[indeterminate]="indeterminate()"
[ngModel]="selected()"
(ngModelChange)="setSelected($event)"
/>
</ui-checkbox>
<span>{{ option().label }}</span>
</label>
@if (isParent()) {
<button
type="button"
(click)="expanded.set(!expanded())"
class="flex items-center justify-center"
>
<ng-icon [name]="expandedIconName()" size="1.5rem"></ng-icon>
</button>
}
</div>
@if (expanded()) {
<div class="pl-6 pt-5 flex flex-col items-start justify-start gap-5">
@for (child of option().values; track child.key) {
<filter-checkbox-input-control
[option]="child"
></filter-checkbox-input-control>
}
</div>
}

View File

@@ -0,0 +1,3 @@
:host {
@apply block flex-grow w-full;
}

View File

@@ -0,0 +1,211 @@
import { Spectator, createComponentFactory } from '@ngneat/spectator/jest';
import { CheckboxInputControlComponent } from './checkbox-input-control.component';
import { FilterService } from '../../core';
import { CheckboxFilterInputOption, CheckboxFilterInput } from '../../core';
import { InputType } from '../../types';
import { signal } from '@angular/core';
import { CheckboxComponent } from '@isa/ui/input-controls';
import { MockComponent } from 'ng-mocks';
import { NgIcon } from '@ng-icons/core';
describe('CheckboxInputControlComponent', () => {
let spectator: Spectator<CheckboxInputControlComponent>;
let filterService: FilterService;
const option: CheckboxFilterInputOption = {
path: ['group', 'key', 'opt'],
key: 'opt',
label: 'Option',
value: 'opt',
};
const parentOption: CheckboxFilterInputOption = {
path: ['group', 'key', 'parent'],
key: 'parent',
label: 'Parent',
values: [
{
path: ['group', 'key', 'parent', 'child1'],
key: 'child1',
label: 'Child1',
value: 'child1',
},
{
path: ['group', 'key', 'parent', 'child2'],
key: 'child2',
label: 'Child2',
value: 'child2',
},
],
};
const mockInputsSignal = signal<CheckboxFilterInput[]>([
{
group: 'group',
key: 'key',
type: InputType.Checkbox,
options: [option, parentOption],
selected: [],
label: 'label',
},
]);
const mockFilterService = {
inputs: mockInputsSignal,
setInputCheckboxOptionSelected: jest.fn(),
};
const createComponent = createComponentFactory({
component: CheckboxInputControlComponent,
declarations: [MockComponent(CheckboxComponent), MockComponent(NgIcon)],
providers: [{ provide: FilterService, useValue: mockFilterService }],
detectChanges: false,
});
beforeEach(() => {
spectator = createComponent({ props: { option } });
filterService = spectator.inject(FilterService);
jest.clearAllMocks();
});
it('should create', () => {
spectator.detectChanges();
expect(spectator.component).toBeTruthy();
});
it('should call setInputCheckboxOptionSelected on setSelected', () => {
spectator.detectChanges();
spectator.component.setSelected(true);
expect(filterService.setInputCheckboxOptionSelected).toHaveBeenCalledWith(
option,
true,
);
spectator.component.setSelected(false);
expect(filterService.setInputCheckboxOptionSelected).toHaveBeenCalledWith(
option,
false,
);
});
describe('checkbox state computation', () => {
it('should compute selected and indeterminate states for unchecked option', () => {
spectator.detectChanges();
expect(spectator.component.selected()).toBe(false);
expect(spectator.component.indeterminate()).toBe(false);
expect(spectator.component.checkboxState()).toBe('none');
});
it('should compute selected state when option is selected', () => {
mockInputsSignal.set([
{
group: 'group',
key: 'key',
type: InputType.Checkbox,
options: [option],
selected: ['opt'],
label: 'label',
},
]);
spectator.detectChanges();
expect(spectator.component.selected()).toBe(true);
expect(spectator.component.indeterminate()).toBe(false);
expect(spectator.component.checkboxState()).toBe('checked');
});
it('should compute indeterminate state for parent with partial selection', () => {
spectator.setInput('option', parentOption);
mockInputsSignal.set([
{
group: 'group',
key: 'key',
type: InputType.Checkbox,
options: [parentOption],
selected: ['parent.child1'],
label: 'label',
},
]);
spectator.detectChanges();
expect(spectator.component.selected()).toBe(false);
expect(spectator.component.indeterminate()).toBe(true);
expect(spectator.component.checkboxState()).toBe('indeterminate');
});
});
describe('expand/collapse functionality', () => {
it('should handle expand/collapse state', () => {
spectator.detectChanges();
expect(spectator.component.expanded()).toBe(false);
spectator.component.expanded.set(true);
expect(spectator.component.expanded()).toBe(true);
spectator.component.expanded.set(false);
expect(spectator.component.expanded()).toBe(false);
});
it('should detect if option is parent', () => {
spectator.detectChanges();
expect(spectator.component.isParent()).toBe(false);
spectator.setInput('option', parentOption);
spectator.detectChanges();
expect(spectator.component.isParent()).toBe(true);
});
it('should update icon name based on expanded state', () => {
spectator.setInput('option', parentOption);
spectator.detectChanges();
expect(spectator.component.expandedIconName()).toBe(
'isaActionChevronDown',
);
spectator.component.expanded.set(true);
expect(spectator.component.expandedIconName()).toBe('isaActionChevronUp');
});
it('should handle parent with empty values array', () => {
const emptyParent: CheckboxFilterInputOption = {
path: ['group', 'key', 'empty'],
key: 'empty',
label: 'Empty Parent',
values: [],
};
spectator.setInput('option', emptyParent);
spectator.detectChanges();
expect(spectator.component.isParent()).toBe(false);
});
it('should handle option without values property', () => {
const optionWithoutValues: CheckboxFilterInputOption = {
path: ['group', 'key', 'simple'],
key: 'simple',
label: 'Simple Option',
value: 'simple',
};
spectator.setInput('option', optionWithoutValues);
spectator.detectChanges();
expect(spectator.component.isParent()).toBe(false);
});
});
describe('data attributes for e2e testing', () => {
it('should have correct data attributes on checkbox input', () => {
spectator.detectChanges();
const checkboxInput = spectator.query('input[type="checkbox"]');
expect(checkboxInput).toHaveAttribute('aria-label', 'Option');
});
it('should have data attributes for expand button when option is parent', () => {
spectator.setInput('option', parentOption);
spectator.detectChanges();
const expandButton = spectator.query('button');
expect(expandButton).toBeTruthy();
});
});
});

View File

@@ -0,0 +1,103 @@
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
input,
signal,
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import { CheckboxComponent } from '@isa/ui/input-controls';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { isaActionChevronDown, isaActionChevronUp } from '@isa/icons';
import { CheckboxFilterInputOption, FilterService } from '../../core';
import { checkboxSelectedHelper } from '../../helpers';
/**
* Component that renders an individual checkbox control within a hierarchical filter structure.
*
* This component handles:
* - Three-state checkbox display (checked, indeterminate, unchecked)
* - Parent-child relationships with expand/collapse functionality
* - Automatic state calculation based on children selection
* - Integration with the FilterService for state management
*
* @example
* ```html
* <filter-checkbox-input-control
* [option]="checkboxOption">
* </filter-checkbox-input-control>
* ```
*/
@Component({
selector: 'filter-checkbox-input-control',
templateUrl: './checkbox-input-control.component.html',
styleUrls: ['./checkbox-input-control.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [FormsModule, CheckboxComponent, NgIcon],
providers: [provideIcons({ isaActionChevronDown, isaActionChevronUp })],
})
export class CheckboxInputControlComponent {
/** Filter service for managing checkbox state */
readonly filterService = inject(FilterService);
/** The checkbox option configuration */
option = input.required<CheckboxFilterInputOption>();
/**
* Computed signal that determines the current state of the checkbox.
* Uses the checkboxSelectedHelper to calculate state based on parent-child relationships.
*/
checkboxState = computed(() => {
return checkboxSelectedHelper(this.filterService.inputs(), this.option());
});
/**
* Whether the checkbox is fully selected (checked state).
* Returns true only when the checkbox is in 'checked' state, not 'indeterminate'.
*/
selected = computed<boolean>(() => {
const state = this.checkboxState();
return state === 'checked';
});
/**
* Whether the checkbox is in an indeterminate state.
* This occurs when a parent checkbox has some but not all children selected.
*/
indeterminate = computed<boolean>(() => {
return this.checkboxState() === 'indeterminate';
});
/**
* Updates the selection state of this checkbox option.
*
* @param value - Whether to select (true) or deselect (false) the option
*/
setSelected(value: boolean) {
const option = this.option();
this.filterService.setInputCheckboxOptionSelected(option, value);
}
/** Signal controlling whether child options are expanded/visible */
expanded = signal(false);
/**
* Determines if this checkbox option has children.
* Parent options can be expanded to show their children.
*/
isParent = computed(() => {
const values = this.option().values;
return Array.isArray(values) && values.length > 0;
});
/**
* Computed icon name for the expand/collapse chevron.
* Shows up arrow when expanded, down arrow when collapsed.
*/
expandedIconName = computed(() => {
return this.expanded() ? 'isaActionChevronUp' : 'isaActionChevronDown';
});
}

View File

@@ -1,27 +1,46 @@
@let inp = input();
@let options = input().options;
@if (inp && options) {
<div [formGroup]="checkboxes" class="flex flex-col items-center justify-start gap-5">
<label class="w-full flex items-center gap-4 cursor-pointer isa-text-body-2-bold">
@if (inp) {
<div class="flex flex-col items-start justify-start gap-5">
<div class="relative flex items-center w-full">
@if (filterControlVisible()) {
<input
type="text"
[placeholder]="filterPlaceholder()"
[(ngModel)]="filterQuery"
class="bg-isa-neutral-200 w-full px-2 py-2 rounded-[0.25rem] text-neutral-500 isa-text-caption-regular focus:outline-none focus:text-isa-neutral-900"
/>
@if (filterQuery()) {
<button
type="button"
(click)="filterQuery.set('')"
class="absolute top-0 right-0 bottom-0 px-2 flex items-center justify-center"
>
<ng-icon
name="isaActionClose"
class="text-neutral-900"
size="1rem"
></ng-icon>
</button>
}
}
</div>
<label
class="w-full flex items-center gap-4 cursor-pointer isa-text-body-2-bold"
>
<ui-checkbox>
<input (click)="toggleSelection()" [checked]="allChecked" type="checkbox" />
<input
(click)="toggleSelection()"
[checked]="allChecked()"
type="checkbox"
/>
</ui-checkbox>
<span> Alle aus/abwählen</span>
</label>
@for (option of options; track option.label; let i = $index) {
<label
class="w-full flex items-center gap-4 cursor-pointer isa-text-body-2-regular has-[:checked]:isa-text-body-2-bold"
>
<ui-checkbox>
<input
[attr.aria-label]="option.label"
[formControlName]="option.value"
type="checkbox"
/>
</ui-checkbox>
<span>{{ option.label }}</span>
</label>
@for (option of options(); track option.key; let i = $index) {
<filter-checkbox-input-control
[option]="option"
></filter-checkbox-input-control>
}
</div>
}

View File

@@ -1,3 +1,3 @@
.filter-checkbox-input {
@apply inline-block p-6 text-isa-neutral-900;
@apply inline-block w-full p-6 text-isa-neutral-900;
}

View File

@@ -1,261 +1,182 @@
import { Spectator, createComponentFactory } from '@ngneat/spectator/jest';
import { CheckboxInputComponent } from './checkbox-input.component';
import { FilterService } from '../../core';
import { MockComponent } from 'ng-mocks';
import {
FilterInput,
FilterService,
CheckboxFilterInputOption,
} from '../../core';
import { CheckboxComponent } from '@isa/ui/input-controls';
import { CheckboxInputControlComponent } from './checkbox-input-control.component';
import { InputType } from '../../types';
import { signal } from '@angular/core';
import { MockComponent } from 'ng-mocks';
import { NgIcon } from '@ng-icons/core';
import { FormsModule } from '@angular/forms';
describe('CheckboxInputComponent', () => {
let spectator: Spectator<CheckboxInputComponent>;
let filterService: FilterService;
// Mock data for filter service
const initialFilterData = [
const option: CheckboxFilterInputOption = {
path: ['group', 'key', 'opt'],
key: 'opt',
label: 'Option',
value: 'opt',
};
const mockInputsSignal = signal<FilterInput[]>([
{
key: 'test-key',
group: 'group',
key: 'key',
type: InputType.Checkbox,
options: [
{ label: 'Option 1', value: 'option1' },
{ label: 'Option 2', value: 'option2' },
],
selected: ['option1'],
options: [option],
selected: [],
label: 'label',
},
];
]);
// Create a proper signal-based mock
const mockInputsSignal = signal(initialFilterData);
// Create a mock filter service with a signal-based inputs property
const mockFilterService = {
inputs: mockInputsSignal,
setInputCheckboxValue: jest.fn(),
setInputCheckboxOptionSelected: jest.fn(),
};
const createComponent = createComponentFactory({
component: CheckboxInputComponent,
declarations: [MockComponent(CheckboxComponent)],
providers: [
{
provide: FilterService,
useValue: mockFilterService,
},
imports: [FormsModule],
declarations: [
MockComponent(CheckboxComponent),
MockComponent(CheckboxInputControlComponent),
MockComponent(NgIcon),
],
providers: [{ provide: FilterService, useValue: mockFilterService }],
detectChanges: false,
});
beforeEach(() => {
spectator = createComponent({
props: {
inputKey: 'test-key',
// Reset the mock signal to its initial state
mockInputsSignal.set([
{
group: 'group',
key: 'key',
type: InputType.Checkbox,
options: [option],
selected: [],
label: 'label',
},
});
]);
spectator = createComponent({ props: { inputKey: 'key' } });
filterService = spectator.inject(FilterService);
jest.clearAllMocks();
});
it('should create', () => {
// Act
spectator.detectChanges();
// Assert
expect(spectator.component).toBeTruthy();
});
it('should initialize form controls based on input options', () => {
// Arrange
const spyOnInitFormControl = jest.spyOn(
spectator.component,
'initFormControl',
);
// Act
it('should compute allChecked as false when nothing selected', () => {
spectator.detectChanges();
// Assert
expect(spyOnInitFormControl).toHaveBeenCalledWith({
option: { label: 'Option 1', value: 'option1' },
isSelected: true,
expect(spectator.component.allChecked()).toBe(false);
});
expect(spyOnInitFormControl).toHaveBeenCalledWith({
option: { label: 'Option 2', value: 'option2' },
isSelected: false,
});
expect(spectator.component.checkboxes.get('option1')?.value).toBe(true);
expect(spectator.component.checkboxes.get('option2')?.value).toBe(false);
});
it('should properly calculate allChecked property', () => {
// Arrange
it('should call setInputCheckboxOptionSelected for each option on toggleSelection', () => {
spectator.detectChanges();
// Assert - initially only one is selected
expect(spectator.component.allChecked).toBe(false);
// Act - check all boxes
spectator.component.checkboxes.setValue({
option1: true,
option2: true,
});
// Assert - all should be checked now
expect(spectator.component.allChecked).toBe(true);
});
it('should call filterService.setInputCheckboxValue when form value changes', () => {
// Arrange
spectator.detectChanges();
// Act - We need to manually trigger the Angular effect by simulating a value change
// First, set up the spy
jest.spyOn(filterService, 'setInputCheckboxValue');
// Then, manually simulate what happens in the effect
spectator.component.checkboxes.setValue({
option1: true,
option2: true,
});
// Manually trigger what the effect would do
spectator.component.valueChanges();
filterService.setInputCheckboxValue('test-key', ['option1', 'option2']);
// Assert
expect(filterService.setInputCheckboxValue).toHaveBeenCalledWith(
'test-key',
['option1', 'option2'],
spectator.component.toggleSelection();
expect(filterService.setInputCheckboxOptionSelected).toHaveBeenCalledWith(
option,
true,
);
});
it('should toggle all checkboxes when toggleSelection is called', () => {
// Arrange
spectator.detectChanges();
// Act - initially one is selected, toggle will check all
spectator.component.toggleSelection();
// Assert
expect(spectator.component.checkboxes.get('option1')?.value).toBe(true);
expect(spectator.component.checkboxes.get('option2')?.value).toBe(true);
// Act - now all are selected, toggle will uncheck all
spectator.component.toggleSelection();
// Assert
expect(spectator.component.checkboxes.get('option1')?.value).toBe(false);
expect(spectator.component.checkboxes.get('option2')?.value).toBe(false);
});
});
// Separate describe blocks for tests that need different component configurations
describe('CheckboxInputComponent with matching values', () => {
let spectator: Spectator<CheckboxInputComponent>;
let filterService: FilterService;
// Create a mock with matching values
const matchingInputsSignal = signal([
it('should compute allChecked as true when all options are checked', () => {
// Simulate all options checked
mockInputsSignal.set([
{
key: 'test-key',
group: 'group',
key: 'key',
type: InputType.Checkbox,
options: [
{ label: 'Option 1', value: 'option1' },
{ label: 'Option 2', value: 'option2' },
],
selected: ['option1'],
options: [option],
selected: ['opt'],
label: 'label',
},
]);
const matchingMockFilterService = {
inputs: matchingInputsSignal,
setInputCheckboxValue: jest.fn(),
};
const createComponent = createComponentFactory({
component: CheckboxInputComponent,
declarations: [MockComponent(CheckboxComponent)],
providers: [
{
provide: FilterService,
useValue: matchingMockFilterService,
},
],
detectChanges: false,
});
beforeEach(() => {
spectator = createComponent({
props: {
inputKey: 'test-key',
},
});
filterService = spectator.inject(FilterService);
jest.clearAllMocks();
});
it('should not call filterService.setInputCheckboxValue when input values match form values', () => {
// Act
spectator.detectChanges();
// Manually trigger the effect by forcing a form value change that matches the input
spectator.component.checkboxes.setValue({
option1: true,
option2: false,
expect(spectator.component.allChecked()).toBe(true);
});
// Force the valueChanges signal to emit
spectator.component.valueChanges();
describe('filter functionality', () => {
it('should show filter control when there are more than 20 options', () => {
const manyOptions = Array.from({ length: 25 }, (_, i) => ({
path: ['group', 'key', `opt${i}`],
key: `opt${i}`,
label: `Option ${i}`,
value: `opt${i}`,
}));
// Assert - since values match, service should not be called
expect(filterService.setInputCheckboxValue).not.toHaveBeenCalled();
});
});
describe('CheckboxInputComponent with non-matching key', () => {
let spectator: Spectator<CheckboxInputComponent>;
// Create a mock with a non-matching key
const noMatchInputsSignal = signal([
mockInputsSignal.set([
{
key: 'other-key', // Different key
group: 'group',
key: 'key',
type: InputType.Checkbox,
options: [],
options: manyOptions,
selected: [],
label: 'label',
},
]);
spectator.detectChanges();
expect(spectator.component.filterControlVisible()).toBe(true);
});
it('should not show filter control when there are 20 or fewer options', () => {
spectator.detectChanges();
expect(spectator.component.filterControlVisible()).toBe(false);
});
it('should generate correct filter placeholder', () => {
spectator.detectChanges();
expect(spectator.component.filterPlaceholder()).toBe('label suchen...');
});
it('should filter options based on search query', () => {
const multipleOptions = [
{
path: ['group', 'key', 'apple'],
key: 'apple',
label: 'Apple',
value: 'apple',
},
{
path: ['group', 'key', 'banana'],
key: 'banana',
label: 'Banana',
value: 'banana',
},
{
path: ['group', 'key', 'cherry'],
key: 'cherry',
label: 'Cherry',
value: 'cherry',
},
];
mockInputsSignal.set([
{
group: 'group',
key: 'key',
type: InputType.Checkbox,
options: multipleOptions,
selected: [],
label: 'label',
},
]);
const noMatchMockFilterService = {
inputs: noMatchInputsSignal,
setInputCheckboxValue: jest.fn(),
};
spectator.component.filterQuery.set('app');
spectator.detectChanges();
const createComponent = createComponentFactory({
component: CheckboxInputComponent,
declarations: [MockComponent(CheckboxComponent)],
providers: [
{
provide: FilterService,
useValue: noMatchMockFilterService,
},
],
detectChanges: false,
});
beforeEach(() => {
spectator = createComponent({
props: {
inputKey: 'test-key', // Key won't match any input
},
const filteredOptions = spectator.component.options();
expect(filteredOptions).toHaveLength(1);
expect(filteredOptions[0].label).toBe('Apple');
});
});
it('should throw error when input is not found', () => {
// Act & Assert
expect(() => spectator.detectChanges()).toThrow(
'Input not found for key: test-key',
);
});
});

View File

@@ -4,19 +4,22 @@ import {
computed,
inject,
input,
effect,
signal,
ViewEncapsulation,
untracked,
} from '@angular/core';
import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
import { provideIcons } from '@ng-icons/core';
import { isaActionCheck } from '@isa/icons';
import { FilterService, CheckboxFilterInput, CheckboxFilterInputOption } from '../../core';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { isaActionCheck, isaActionClose } from '@isa/icons';
import { FilterService, CheckboxFilterInput } from '../../core';
import { InputType } from '../../types';
import { CheckboxComponent } from '@isa/ui/input-controls';
import { OverlayModule } from '@angular/cdk/overlay';
import { toSignal } from '@angular/core/rxjs-interop';
import { sortBy, isEqual } from 'lodash';
import { CheckboxInputControlComponent } from './checkbox-input-control.component';
import {
checkboxSelectedHelper,
getAllCheckboxOptionsHelper,
filterCheckboxOptionsHelper,
} from '../../helpers';
import { FormsModule } from '@angular/forms';
@Component({
selector: 'filter-checkbox-input',
@@ -25,102 +28,67 @@ import { sortBy, isEqual } from 'lodash';
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
standalone: true,
imports: [ReactiveFormsModule, CheckboxComponent, OverlayModule],
imports: [
OverlayModule,
CheckboxInputControlComponent,
CheckboxComponent,
FormsModule,
NgIcon,
],
host: {
'[class]': "['filter-checkbox-input']",
'[formGroup]': 'checkboxes',
},
providers: [provideIcons({ isaActionCheck })],
providers: [provideIcons({ isaActionCheck, isaActionClose })],
})
export class CheckboxInputComponent {
readonly filterService = inject(FilterService);
checkboxes = new FormGroup({});
valueChanges = toSignal(this.checkboxes.valueChanges);
get allChecked() {
return Object.values(this.checkboxes.getRawValue()).every((value) => value);
}
inputKey = input.required<string>();
input = computed<CheckboxFilterInput>(() => {
const inputs = this.filterService.inputs();
const input = inputs.find(
(input) => input.key === this.inputKey() && input.type === InputType.Checkbox,
const inputKey = this.inputKey();
return inputs.find(
(input) => input.key === inputKey && input.type === InputType.Checkbox,
) as CheckboxFilterInput;
if (!input) {
throw new Error(`Input not found for key: ${this.inputKey()}`);
}
const selected = input.selected;
const options = input.options;
for (const option of options) {
const controlExist = this.checkboxes.get(option.value);
const isSelected = selected.includes(option.value);
if (controlExist) {
this.patchFormControl({ option, isSelected });
} else {
this.initFormControl({ option, isSelected });
}
}
this.checkboxes.updateValueAndValidity();
return input;
});
constructor() {
effect(() => {
this.valueChanges();
untracked(() => {
if (Object.keys(this.checkboxes.getRawValue())?.length === 0) {
return;
}
options = computed(() => {
const input = this.input();
const options = input?.options || [];
const query = this.filterQuery();
const selectedKeys = Object.entries(this.checkboxes.getRawValue())
.filter(([, value]) => value === true)
.map(([key]) => key);
const controlEqualsInput = isEqual(sortBy(this.input().selected), sortBy(selectedKeys));
if (!controlEqualsInput) {
this.filterService.setInputCheckboxValue(this.inputKey(), selectedKeys);
}
return filterCheckboxOptionsHelper(options, query);
});
});
}
initFormControl({
option,
isSelected,
}: {
option: CheckboxFilterInputOption;
isSelected: boolean;
}) {
this.checkboxes.addControl(option.value, new FormControl(isSelected));
}
patchFormControl({
option,
isSelected,
}: {
option: CheckboxFilterInputOption;
isSelected: boolean;
}) {
this.checkboxes.patchValue({
[option.value]: isSelected,
filterControlVisible = computed(() => {
const input = this.input();
const allOptions = getAllCheckboxOptionsHelper(input);
// Show control if there are more than 20 options
return allOptions.length > 20;
});
filterPlaceholder = computed(() => {
const input = this.input();
return `${input.label} suchen...`;
});
filterQuery = signal<string>('');
allChecked = computed(() => {
return this.input().options.every(
(option) =>
checkboxSelectedHelper(this.filterService.inputs(), option) ===
'checked',
);
});
}
toggleSelection() {
const newValue = this.allChecked ? false : true;
for (const key of Object.keys(this.checkboxes.getRawValue())) {
this.checkboxes.patchValue({ [key]: newValue });
}
this.checkboxes.updateValueAndValidity();
const options = this.input()?.options || [];
const allChecked = this.allChecked();
options.forEach((option) => {
this.filterService.setInputCheckboxOptionSelected(option, !allChecked);
});
}
}

View File

@@ -25,10 +25,11 @@
[cdkConnectedOverlayScrollStrategy]="scrollStrategy"
[cdkConnectedOverlayOffsetX]="-10"
[cdkConnectedOverlayOffsetY]="18"
cdkConnectedOverlayWidth="18.375rem"
(backdropClick)="toggle()"
>
<filter-filter-menu
class="shadow-[0px,0px,16px,0px,rgba(0, 0, 0, 0.15)]"
class="shadow-[0px,0px,16px,0px,rgba(0, 0, 0, 0.15)] w-full"
(applied)="applied.emit()"
(reseted)="reseted.emit()"
></filter-filter-menu>

View File

@@ -30,9 +30,11 @@
[cdkConnectedOverlayBackdropClass]="'cdk-overlay-transparent-backdrop'"
[cdkConnectedOverlayOffsetX]="-10"
[cdkConnectedOverlayOffsetY]="18"
cdkConnectedOverlayWidth="18.375rem"
(backdropClick)="toggle()"
>
<filter-input-menu
class="w-full"
[filterInput]="input"
(applied)="applied.emit()"
(reseted)="reseted.emit()"

View File

@@ -11,7 +11,7 @@
font-size: 1.5rem;
.ui-checkbox__icon {
@apply invisible min-w-6 size-6 text-isa-white;
@apply hidden min-w-6 size-6 text-isa-white;
}
input[type="checkbox"] {
@@ -30,8 +30,20 @@
&:has(input[type="checkbox"]:checked) {
@apply bg-isa-neutral-900 text-isa-white;
.ui-checkbox__icon {
@apply visible;
.ui-checkbox__icon--checked {
@apply inline-block;
}
}
&:has(input[type="checkbox"]:indeterminate) {
@apply bg-isa-neutral-900 text-isa-white;
.ui-checkbox__icon--checked {
@apply hidden;
}
.ui-checkbox__icon--indeterminate {
@apply inline-block;
}
}
@@ -42,6 +54,10 @@
@apply bg-isa-neutral-400 border-isa-neutral-400;
}
&:has(input[type="checkbox"]:indeterminate) {
@apply bg-isa-neutral-400 border-isa-neutral-400;
}
input[type="checkbox"] {
@apply cursor-default;
}
@@ -59,7 +75,7 @@
@apply rounded-full bg-isa-neutral-300 size-12;
.ui-checkbox__icon {
@apply invisible size-6 text-isa-neutral-100;
@apply hidden size-6 text-isa-neutral-100;
}
input[type="checkbox"] {
@@ -82,8 +98,24 @@
&:has(input[type="checkbox"]:checked) {
@apply bg-isa-neutral-700;
.ui-checkbox__icon {
@apply visible;
.ui-checkbox__icon--checked {
@apply flex;
}
&:hover {
@apply bg-isa-neutral-800;
}
}
&:has(input[type="checkbox"]:indeterminate) {
@apply bg-isa-neutral-700;
.ui-checkbox__icon--checked {
@apply hidden;
}
.ui-checkbox__icon--indeterminate {
@apply flex;
}
&:hover {

View File

@@ -1,2 +1,9 @@
<ng-content select="input[type=checkbox]"></ng-content>
<ng-icon class="ui-checkbox__icon" name="isaActionCheck"></ng-icon>
<ng-icon
class="ui-checkbox__icon ui-checkbox__icon--checked"
name="isaActionCheck"
></ng-icon>
<ng-icon
class="ui-checkbox__icon ui-checkbox__icon--indeterminate"
name="isaActionMinus"
></ng-icon>

View File

@@ -6,7 +6,7 @@ import {
ViewEncapsulation,
} from '@angular/core';
import { NgIconComponent, provideIcons } from '@ng-icons/core';
import { isaActionCheck } from '@isa/icons';
import { isaActionCheck, isaActionMinus } from '@isa/icons';
/**
* Configuration options for CheckboxComponent appearance
@@ -48,7 +48,7 @@ export type CheckboxAppearance =
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
imports: [NgIconComponent],
providers: [provideIcons({ isaActionCheck })],
providers: [provideIcons({ isaActionCheck, isaActionMinus })],
host: {
'[class]': '["ui-checkbox", appearanceClass()]',
},

View File

@@ -132,7 +132,6 @@
"overrides": {
"jest-environment-jsdom": {
"jsdom": "26.0.0"
},
"stylus": "github:stylus/stylus#0.64.0"
}
}
}