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,8 +1,9 @@
export * from './lib/core';
export * from './lib/inputs';
export * from './lib/types';
export * from './lib/actions';
export * from './lib/menus/filter-menu';
export * from './lib/menus/input-menu';
export * from './lib/order-by';
export * from './lib/controls-panel';
export * from './lib/core';
export * from './lib/helpers';
export * from './lib/inputs';
export * from './lib/types';
export * from './lib/actions';
export * from './lib/menus/filter-menu';
export * from './lib/menus/input-menu';
export * from './lib/order-by';
export * from './lib/controls-panel';

View File

File diff suppressed because it is too large Load Diff

View File

@@ -1,34 +1,53 @@
import { Input, InputType } from '../../types';
import { CheckboxFilterInput, CheckboxFilterInputSchema } from '../schemas';
import { checkboxOptionMapping } from './checkbox-option.mapping';
/**
* Maps an Input object to a CheckboxFilterInput object
*
* 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.
*
* @param group - The group identifier that this input belongs to
* @param input - The source input object to map
* @returns A validated CheckboxFilterInput object
*/
export function checkboxFilterInputMapping(
group: string,
input: Input,
): CheckboxFilterInput {
return CheckboxFilterInputSchema.parse({
group,
key: input.key,
label: input.label,
description: input.description,
type: InputType.Checkbox,
defaultValue: input.value,
maxOptions: input.options?.max,
options: input.options?.values?.map(checkboxOptionMapping),
selected:
input.options?.values
?.filter((option) => option.selected)
.map((option) => option.value) || [],
});
}
import { Input, InputType } from '../../types';
import { CheckboxFilterInput, CheckboxFilterInputSchema } from '../schemas';
import { checkboxOptionMapping } from './checkbox-option.mapping';
/**
* Maps an Input object to a CheckboxFilterInput object with support for hierarchical options.
*
* 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 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,
input: Input,
): CheckboxFilterInput {
return CheckboxFilterInputSchema.parse({
group,
key: input.key,
label: input.label,
description: input.description,
type: InputType.Checkbox,
defaultValue: input.value,
maxOptions: input.options?.max,
options: input.options?.values?.map((v) =>
checkboxOptionMapping(v, [group, input.key]),
),
selected:
input.options?.values
?.filter((option) => option.selected)
.map((option) => option.value) || [],
});
}

View File

@@ -1,63 +1,232 @@
import { Option } from '../../types';
import { checkboxOptionMapping } from './checkbox-option.mapping';
import * as schemaModule from '../schemas/checkbox-filter-input-option.schema';
describe('checkboxOptionMapping', () => {
const mockSchemaParser = jest.fn().mockImplementation((input) => input);
beforeEach(() => {
jest.clearAllMocks();
// Mock the schema parse method to avoid validation errors in tests
jest
.spyOn(schemaModule.CheckboxFilterInputOptionSchema, 'parse')
.mockImplementation(mockSchemaParser);
});
it('should map option correctly', () => {
// Arrange
const option: Option = {
label: 'Option Label',
value: 'option-value',
};
// Act
const result = checkboxOptionMapping(option);
// Assert
expect(mockSchemaParser).toHaveBeenCalledWith({
label: 'Option Label',
value: 'option-value',
});
expect(result).toEqual({
label: 'Option Label',
value: 'option-value',
});
});
it('should map option with selected property', () => {
// Arrange
const option: Option = {
label: 'Option Label',
value: 'option-value',
selected: true,
};
// Act
const result = checkboxOptionMapping(option);
// Assert
expect(mockSchemaParser).toHaveBeenCalledWith({
label: 'Option Label',
value: 'option-value',
});
expect(result).toEqual({
label: 'Option Label',
value: 'option-value',
});
// The selected property should not be included in the mapped result
expect(result).not.toHaveProperty('selected');
});
});
import { Option } from '../../types';
import { checkboxOptionMapping } from './checkbox-option.mapping';
import * as schemaModule from '../schemas/checkbox-filter-input-option.schema';
describe('checkboxOptionMapping', () => {
const mockSchemaParser = jest.fn().mockImplementation((input) => input);
beforeEach(() => {
jest.clearAllMocks();
// Mock the schema parse method to avoid validation errors in tests
jest
.spyOn(schemaModule.CheckboxFilterInputOptionSchema, 'parse')
.mockImplementation(mockSchemaParser);
});
it('should map option correctly', () => {
// Arrange
const option: Option = {
key: 'Option Key',
label: 'Option Label',
value: 'option-value',
};
// Act
const result = checkboxOptionMapping(option);
// Assert
expect(result).toEqual({
key: 'Option Key',
label: 'Option Label',
value: 'option-value',
path: ['Option Key'],
});
});
it('should map option with selected property', () => {
// Arrange
const option: Option = {
label: 'Option Label',
value: 'option-value',
selected: true,
};
// Act
const result = checkboxOptionMapping(option);
// Assert
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,23 +1,48 @@
import { Option } from '../../types';
import {
CheckboxFilterInputOption,
CheckboxFilterInputOptionSchema,
} from '../schemas';
/**
* Maps an Option object to a CheckboxFilterInputOption object
*
* This function converts a generic Option to a strongly-typed
* CheckboxFilterInputOption, validating it against a schema.
*
* @param option - The source option object to map
* @returns A validated CheckboxFilterInputOption object
*/
export function checkboxOptionMapping(
option: Option,
): CheckboxFilterInputOption {
return CheckboxFilterInputOptionSchema.parse({
label: option.label,
value: option.value,
});
}
import { getOptionKeyHelper } from '../../helpers';
import { Option } from '../../types';
import {
CheckboxFilterInputOption,
CheckboxFilterInputOptionSchema,
} from '../schemas';
/**
* Recursively maps an Option object to a CheckboxFilterInputOption with hierarchical path tracking.
*
* 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 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,40 +1,311 @@
import { mapFilterInputToRecord } from './filter-input-to-record.mapping';
import { InputType } from '../../types';
import { FilterInput } from '../schemas';
describe('mapFilterInputToRecord', () => {
it('should map text input', () => {
const input: FilterInput[] = [
{ key: 'name', type: InputType.Text, value: 'test' } as any,
];
expect(mapFilterInputToRecord(input)).toEqual({ name: 'test' });
});
it('should map checkbox input', () => {
const input: FilterInput[] = [
{ key: 'tags', type: InputType.Checkbox, selected: ['a', 'b'] } as any,
];
expect(mapFilterInputToRecord(input)).toEqual({ tags: 'a;b' });
});
it('should map date range input', () => {
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,
];
const result = mapFilterInputToRecord(input);
// The stop value is incremented by 1 day and wrapped in quotes
expect(result['range']).toMatch(
/^"2024-06-01T00:00:00.000Z"-"2024-06-06T00:00:00.000Z"$/,
);
});
it('should skip empty values', () => {
const input: FilterInput[] = [
{ key: 'empty', type: InputType.Text, value: '' } as any,
{ key: 'none', type: InputType.Checkbox, selected: [] } as any,
];
expect(mapFilterInputToRecord(input)).toEqual({ empty: '', none: '' });
});
});
import { mapFilterInputToRecord } from './filter-input-to-record.mapping';
import { InputType } from '../../types';
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 TextFilterInput,
];
expect(mapFilterInputToRecord(input)).toEqual({ name: 'test' });
});
it('should map checkbox input', () => {
const input: FilterInput[] = [
{
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' });
});
it('should map date range input', () => {
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 DateRangeFilterInput,
];
const result = mapFilterInputToRecord(input);
// The stop value is incremented by 1 day and wrapped in quotes
expect(result['range']).toMatch(
/^"2024-06-01T00:00:00.000Z"-"2024-06-06T00:00:00.000Z"$/,
);
});
it('should skip empty values', () => {
const input: FilterInput[] = [
{ 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,24 +1,56 @@
import { addDays } from 'date-fns';
import { InputType } from '../../types';
import { FilterInput } from '../schemas';
export function mapFilterInputToRecord(
inputs: FilterInput[],
): Record<string, string> {
return inputs.reduce<Record<string, string>>((acc, input) => {
if (input.type === InputType.Text) {
acc[input.key] = input.value || '';
} else if (input.type === InputType.Checkbox) {
acc[input.key] = input.selected?.join(';') || '';
} else if (input.type === InputType.DateRange) {
const start = input.start ? `"${input.start}"` : '';
const stop = input.stop
? `"${addDays(new Date(input.stop), 1).toISOString()}"`
: '';
if (start || stop) {
acc[input.key] = `${start}-${stop}`;
}
}
return acc;
}, {});
}
import { addDays } from 'date-fns';
import { InputType } from '../../types';
import { FilterInput, CheckboxFilterInputOption } from '../schemas';
export function mapFilterInputToRecord(
inputs: FilterInput[],
): Record<string, string> {
return inputs.reduce<Record<string, string>>((acc, input) => {
if (input.type === InputType.Text) {
acc[input.key] = input.value || '';
} else if (input.type === InputType.Checkbox) {
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
? `"${addDays(new Date(input.stop), 1).toISOString()}"`
: '';
if (start || stop) {
acc[input.key] = `${start}-${stop}`;
}
}
return acc;
}, {});
}

View File

@@ -1,46 +1,46 @@
import { QuerySettings } from '../../types';
import { Filter } from '../schemas';
import { filterGroupMapping } from './filter-group.mapping';
import { filterInputMapping } from './filter-input.mapping';
import { orderByOptionMapping } from './order-by-option.mapping';
/**
* Maps a QuerySettings object to a Filter object
*
* This is the main mapping function that transforms query settings into a
* complete Filter object structure. It:
* 1. Creates filter groups from both filter and input settings
* 2. Maps all inputs from each group to their corresponding filter inputs
* 3. Maps order by options if present
*
* The resulting Filter object can be used by filter components to render
* the appropriate UI and handle user interactions.
*
* @param settings - The source query settings to map
* @returns A fully populated Filter object with groups, inputs, and ordering options
*/
export function filterMapping(settings: QuerySettings): Filter {
const filter: Filter = {
groups: [],
inputs: [],
orderBy: [],
};
const groups = [...settings.filter, ...settings.input];
for (const group of groups) {
filter.groups.push(filterGroupMapping(group));
for (const input of group.input) {
filter.inputs.push(filterInputMapping(group.group, input));
}
}
if (settings.orderBy) {
for (const orderBy of settings.orderBy) {
filter.orderBy.push(orderByOptionMapping(orderBy));
}
}
return filter;
}
import { QuerySettings } from '../../types';
import { Filter } from '../schemas';
import { filterGroupMapping } from './filter-group.mapping';
import { filterInputMapping } from './filter-input.mapping';
import { orderByOptionMapping } from './order-by-option.mapping';
/**
* Maps a QuerySettings object to a Filter object
*
* This is the main mapping function that transforms query settings into a
* complete Filter object structure. It:
* 1. Creates filter groups from both filter and input settings
* 2. Maps all inputs from each group to their corresponding filter inputs
* 3. Maps order by options if present
*
* The resulting Filter object can be used by filter components to render
* the appropriate UI and handle user interactions.
*
* @param settings - The source query settings to map
* @returns A fully populated Filter object with groups, inputs, and ordering options
*/
export function filterMapping(settings: QuerySettings): Filter {
const filter: Filter = {
groups: [],
inputs: [],
orderBy: [],
};
const groups = [...(settings.filter || []), ...(settings.input || [])];
for (const group of groups) {
filter.groups.push(filterGroupMapping(group));
for (const input of group.input) {
filter.inputs.push(filterInputMapping(group.group, input));
}
}
if (settings.orderBy) {
for (const orderBy of settings.orderBy) {
filter.orderBy.push(orderByOptionMapping(orderBy));
}
}
return filter;
}

View File

@@ -1,24 +1,56 @@
import { z } from 'zod';
/**
* Represents a checkbox option within a CheckboxFilterInput.
*
* @property label - Display text for the checkbox option
* @property value - The value to be used when this option is selected
*/
export const CheckboxFilterInputOptionSchema = z
.object({
label: z
.string()
.describe('Display text shown next to the checkbox in the UI.'),
value: z
.string()
.describe(
'Underlying value that will be sent in requests when this option is selected.',
),
})
.describe('CheckboxFilterInputOption');
export type CheckboxFilterInputOption = z.infer<
typeof CheckboxFilterInputOptionSchema
>;
import { z } from 'zod';
/**
* Base schema for checkbox filter input options.
*
* Represents a single checkbox option within a hierarchical filter structure.
* Each option has a unique path that identifies its position in the hierarchy.
*/
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">
<ui-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>
}
</div>
}
@let inp = input();
@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"
/>
</ui-checkbox>
<span> Alle aus/abwählen</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;
}
.filter-checkbox-input {
@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 { CheckboxComponent } from '@isa/ui/input-controls';
import { InputType } from '../../types';
import { signal } from '@angular/core';
describe('CheckboxInputComponent', () => {
let spectator: Spectator<CheckboxInputComponent>;
let filterService: FilterService;
// Mock data for filter service
const initialFilterData = [
{
key: 'test-key',
type: InputType.Checkbox,
options: [
{ label: 'Option 1', value: 'option1' },
{ label: 'Option 2', value: 'option2' },
],
selected: ['option1'],
},
];
// 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(),
};
const createComponent = createComponentFactory({
component: CheckboxInputComponent,
declarations: [MockComponent(CheckboxComponent)],
providers: [
{
provide: FilterService,
useValue: mockFilterService,
},
],
detectChanges: false,
});
beforeEach(() => {
spectator = createComponent({
props: {
inputKey: 'test-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
spectator.detectChanges();
// Assert
expect(spyOnInitFormControl).toHaveBeenCalledWith({
option: { label: 'Option 1', value: 'option1' },
isSelected: true,
});
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
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'],
);
});
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([
{
key: 'test-key',
type: InputType.Checkbox,
options: [
{ label: 'Option 1', value: 'option1' },
{ label: 'Option 2', value: 'option2' },
],
selected: ['option1'],
},
]);
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,
});
// Force the valueChanges signal to emit
spectator.component.valueChanges();
// 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([
{
key: 'other-key', // Different key
type: InputType.Checkbox,
options: [],
selected: [],
},
]);
const noMatchMockFilterService = {
inputs: noMatchInputsSignal,
setInputCheckboxValue: jest.fn(),
};
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
},
});
});
it('should throw error when input is not found', () => {
// Act & Assert
expect(() => spectator.detectChanges()).toThrow(
'Input not found for key: test-key',
);
});
});
import { Spectator, createComponentFactory } from '@ngneat/spectator/jest';
import { CheckboxInputComponent } from './checkbox-input.component';
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;
const option: CheckboxFilterInputOption = {
path: ['group', 'key', 'opt'],
key: 'opt',
label: 'Option',
value: 'opt',
};
const mockInputsSignal = signal<FilterInput[]>([
{
group: 'group',
key: 'key',
type: InputType.Checkbox,
options: [option],
selected: [],
label: 'label',
},
]);
const mockFilterService = {
inputs: mockInputsSignal,
setInputCheckboxOptionSelected: jest.fn(),
};
const createComponent = createComponentFactory({
component: CheckboxInputComponent,
imports: [FormsModule],
declarations: [
MockComponent(CheckboxComponent),
MockComponent(CheckboxInputControlComponent),
MockComponent(NgIcon),
],
providers: [{ provide: FilterService, useValue: mockFilterService }],
detectChanges: false,
});
beforeEach(() => {
// 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', () => {
spectator.detectChanges();
expect(spectator.component).toBeTruthy();
});
it('should compute allChecked as false when nothing selected', () => {
spectator.detectChanges();
expect(spectator.component.allChecked()).toBe(false);
});
it('should call setInputCheckboxOptionSelected for each option on toggleSelection', () => {
spectator.detectChanges();
spectator.component.toggleSelection();
expect(filterService.setInputCheckboxOptionSelected).toHaveBeenCalledWith(
option,
true,
);
});
it('should compute allChecked as true when all options are checked', () => {
// Simulate all options checked
mockInputsSignal.set([
{
group: 'group',
key: 'key',
type: InputType.Checkbox,
options: [option],
selected: ['opt'],
label: 'label',
},
]);
spectator.detectChanges();
expect(spectator.component.allChecked()).toBe(true);
});
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}`,
}));
mockInputsSignal.set([
{
group: 'group',
key: 'key',
type: InputType.Checkbox,
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',
},
]);
spectator.component.filterQuery.set('app');
spectator.detectChanges();
const filteredOptions = spectator.component.options();
expect(filteredOptions).toHaveLength(1);
expect(filteredOptions[0].label).toBe('Apple');
});
});
});

View File

@@ -1,126 +1,94 @@
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
input,
effect,
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 { 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';
@Component({
selector: 'filter-checkbox-input',
templateUrl: './checkbox-input.component.html',
styleUrls: ['./checkbox-input.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
standalone: true,
imports: [ReactiveFormsModule, CheckboxComponent, OverlayModule],
host: {
'[class]': "['filter-checkbox-input']",
'[formGroup]': 'checkboxes',
},
providers: [provideIcons({ isaActionCheck })],
})
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,
) 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;
}
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);
}
});
});
}
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,
});
}
toggleSelection() {
const newValue = this.allChecked ? false : true;
for (const key of Object.keys(this.checkboxes.getRawValue())) {
this.checkboxes.patchValue({ [key]: newValue });
}
this.checkboxes.updateValueAndValidity();
}
}
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
input,
signal,
ViewEncapsulation,
} from '@angular/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 { CheckboxInputControlComponent } from './checkbox-input-control.component';
import {
checkboxSelectedHelper,
getAllCheckboxOptionsHelper,
filterCheckboxOptionsHelper,
} from '../../helpers';
import { FormsModule } from '@angular/forms';
@Component({
selector: 'filter-checkbox-input',
templateUrl: './checkbox-input.component.html',
styleUrls: ['./checkbox-input.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
standalone: true,
imports: [
OverlayModule,
CheckboxInputControlComponent,
CheckboxComponent,
FormsModule,
NgIcon,
],
host: {
'[class]': "['filter-checkbox-input']",
'[formGroup]': 'checkboxes',
},
providers: [provideIcons({ isaActionCheck, isaActionClose })],
})
export class CheckboxInputComponent {
readonly filterService = inject(FilterService);
inputKey = input.required<string>();
input = computed<CheckboxFilterInput>(() => {
const inputs = this.filterService.inputs();
const inputKey = this.inputKey();
return inputs.find(
(input) => input.key === inputKey && input.type === InputType.Checkbox,
) as CheckboxFilterInput;
});
options = computed(() => {
const input = this.input();
const options = input?.options || [];
const query = this.filterQuery();
return filterCheckboxOptionsHelper(options, query);
});
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 options = this.input()?.options || [];
const allChecked = this.allChecked();
options.forEach((option) => {
this.filterService.setInputCheckboxOptionSelected(option, !allChecked);
});
}
}

View File

@@ -1,35 +1,36 @@
@let selected = selectedFilters();
<button
class="flex flex-row gap-2 items-center justify-center"
[class.has-selected-filter]="selected > 0"
uiIconButton
cdkOverlayOrigin
name="isaActionFilter"
#trigger="cdkOverlayOrigin"
(click)="toggle()"
type="button"
[class.active]="isIconButtonActive()"
data-what="filter-button"
>
@if (selected > 0) {
({{ selected }})
}
</button>
<ng-template
cdkConnectedOverlay
[cdkConnectedOverlayOrigin]="trigger"
[cdkConnectedOverlayOpen]="open()"
[cdkConnectedOverlayHasBackdrop]="true"
cdkConnectedOverlayBackdropClass="cdk-overlay-transparent-backdrop"
[cdkConnectedOverlayScrollStrategy]="scrollStrategy"
[cdkConnectedOverlayOffsetX]="-10"
[cdkConnectedOverlayOffsetY]="18"
(backdropClick)="toggle()"
>
<filter-filter-menu
class="shadow-[0px,0px,16px,0px,rgba(0, 0, 0, 0.15)]"
(applied)="applied.emit()"
(reseted)="reseted.emit()"
></filter-filter-menu>
</ng-template>
@let selected = selectedFilters();
<button
class="flex flex-row gap-2 items-center justify-center"
[class.has-selected-filter]="selected > 0"
uiIconButton
cdkOverlayOrigin
name="isaActionFilter"
#trigger="cdkOverlayOrigin"
(click)="toggle()"
type="button"
[class.active]="isIconButtonActive()"
data-what="filter-button"
>
@if (selected > 0) {
({{ selected }})
}
</button>
<ng-template
cdkConnectedOverlay
[cdkConnectedOverlayOrigin]="trigger"
[cdkConnectedOverlayOpen]="open()"
[cdkConnectedOverlayHasBackdrop]="true"
cdkConnectedOverlayBackdropClass="cdk-overlay-transparent-backdrop"
[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)] w-full"
(applied)="applied.emit()"
(reseted)="reseted.emit()"
></filter-filter-menu>
</ng-template>

View File

@@ -1,40 +1,42 @@
@let input = filterInput();
<button
class="filter-input-button__filter-button"
[class.open]="open()"
[class.active]="!isDefaultInputState()"
(click)="toggle()"
type="button"
cdkOverlayOrigin
data-which="filter-input-button"
[attr.data-what]="input.label"
#trigger="cdkOverlayOrigin"
>
<span class="filter-input-button__filter-button-label">{{
input.label
}}</span>
<ng-icon
class="filter-input-button__filter-button-icon"
[name]="open() ? 'isaActionChevronUp' : 'isaActionChevronDown'"
[attr.data-what]="open() ? 'isaActionChevronUp' : 'isaActionChevronDown'"
size="1.5rem"
>
</ng-icon>
</button>
<ng-template
cdkConnectedOverlay
[cdkConnectedOverlayOrigin]="trigger"
[cdkConnectedOverlayOpen]="open()"
[cdkConnectedOverlayHasBackdrop]="true"
[cdkConnectedOverlayBackdropClass]="'cdk-overlay-transparent-backdrop'"
[cdkConnectedOverlayOffsetX]="-10"
[cdkConnectedOverlayOffsetY]="18"
(backdropClick)="toggle()"
>
<filter-input-menu
[filterInput]="input"
(applied)="applied.emit()"
(reseted)="reseted.emit()"
></filter-input-menu>
</ng-template>
@let input = filterInput();
<button
class="filter-input-button__filter-button"
[class.open]="open()"
[class.active]="!isDefaultInputState()"
(click)="toggle()"
type="button"
cdkOverlayOrigin
data-which="filter-input-button"
[attr.data-what]="input.label"
#trigger="cdkOverlayOrigin"
>
<span class="filter-input-button__filter-button-label">{{
input.label
}}</span>
<ng-icon
class="filter-input-button__filter-button-icon"
[name]="open() ? 'isaActionChevronUp' : 'isaActionChevronDown'"
[attr.data-what]="open() ? 'isaActionChevronUp' : 'isaActionChevronDown'"
size="1.5rem"
>
</ng-icon>
</button>
<ng-template
cdkConnectedOverlay
[cdkConnectedOverlayOrigin]="trigger"
[cdkConnectedOverlayOpen]="open()"
[cdkConnectedOverlayHasBackdrop]="true"
[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()"
></filter-input-menu>
</ng-template>

View File

@@ -1,194 +1,194 @@
export enum InputType {
Text = 1,
Checkbox = 2,
DateRange = 128,
}
export interface QuerySettings {
/**
* Filter
*/
filter: Array<InputGroup>;
/**
* Eingabefelder
*/
input: Array<InputGroup>;
/**
* Sortierung
*/
orderBy: Array<OrderBy>;
}
export interface InputGroup {
/**
* Beschreibung
*/
description?: string;
/**
* Group / ID
*/
group: string;
/**
* Eingabefelder
*/
input: Array<Input>;
/**
* Label
*/
label?: string;
}
/**
* Sortierwert
*/
export interface OrderBy {
/**
* Wert
*/
by?: string;
/**
* Absteigend
*/
desc?: boolean;
/**
* Label
*/
label?: string;
}
/**
* Eingabeelement
*/
export interface Input {
/**
* Regex-Überprüfung
*/
constraint?: string;
/**
* Beschreibung
*/
description?: string;
/**
* Key / ID
*/
key: string;
/**
* Label
*/
label?: string;
/**
* Max-Wert (optional)
*/
maxValue?: string;
/**
* Min-Wert (optional)
*/
minValue?: string;
/**
* Auswahl
*/
options?: InputOptions;
/**
* Wasserzeichen
*/
placeholder?: string;
/**
* Anwendungsziel
*/
target?: string;
/**
* Art des Werts
*/
type: InputType;
/**
* Wert
*/
value?: string;
}
/**
* Auswahl
*/
export interface InputOptions {
/**
* Maximale Anzahl auswählbarer Elemente (null => alle, 1 = single select)
*/
max?: number;
/**
* Werte
*/
values?: Array<Option>;
}
/**
* Auswahlelement
*/
export interface Option {
/**
* Beschreibung
*/
description?: string;
/**
* Aktiv
*/
enabled?: boolean;
/**
* Key / ID
*/
key?: string;
/**
* Label
*/
label?: string;
/**
* Max-Wert (optional)
*/
maxValue?: string;
/**
* Min-Wert (optional)
*/
minValue?: string;
/**
* Wasserzeichen
*/
placeholder?: string;
/**
* Ausgewählt / Default
*/
selected?: boolean;
/**
* Wert
*/
value?: string;
/**
* Unter-Optionen
*/
values?: Array<Option>;
}
export enum InputType {
Text = 1,
Checkbox = 2,
DateRange = 128,
}
export interface QuerySettings {
/**
* Filter
*/
filter: Array<InputGroup>;
/**
* Eingabefelder
*/
input: Array<InputGroup>;
/**
* Sortierung
*/
orderBy: Array<OrderBy>;
}
export interface InputGroup {
/**
* Beschreibung
*/
description?: string;
/**
* Group / ID
*/
group: string;
/**
* Eingabefelder
*/
input: Array<Input>;
/**
* Label
*/
label?: string;
}
/**
* Sortierwert
*/
export interface OrderBy {
/**
* Wert
*/
by?: string;
/**
* Absteigend
*/
desc?: boolean;
/**
* Label
*/
label?: string;
}
/**
* Eingabeelement
*/
export interface Input {
/**
* Regex-Überprüfung
*/
constraint?: string;
/**
* Beschreibung
*/
description?: string;
/**
* Key / ID
*/
key: string;
/**
* Label
*/
label?: string;
/**
* Max-Wert (optional)
*/
maxValue?: string;
/**
* Min-Wert (optional)
*/
minValue?: string;
/**
* Auswahl
*/
options?: InputOptions;
/**
* Wasserzeichen
*/
placeholder?: string;
/**
* Anwendungsziel
*/
target?: string;
/**
* Art des Werts
*/
type: InputType;
/**
* Wert
*/
value?: string;
}
/**
* Auswahl
*/
export interface InputOptions {
/**
* Maximale Anzahl auswählbarer Elemente (null => alle, 1 = single select)
*/
max?: number;
/**
* Werte
*/
values?: Array<Option>;
}
/**
* Auswahlelement
*/
export interface Option {
/**
* Beschreibung
*/
description?: string;
/**
* Aktiv
*/
enabled?: boolean;
/**
* Key / ID
*/
key?: string;
/**
* Label
*/
label?: string;
/**
* Max-Wert (optional)
*/
maxValue?: string;
/**
* Min-Wert (optional)
*/
minValue?: string;
/**
* Wasserzeichen
*/
placeholder?: string;
/**
* Ausgewählt / Default
*/
selected?: boolean;
/**
* Wert
*/
value?: string;
/**
* Unter-Optionen
*/
values?: Array<Option>;
}

View File

@@ -1,105 +1,137 @@
.ui-checkbox-label {
@apply inline-flex items-center gap-4 text-isa-neutral-900 isa-text-body-2-regular;
}
.ui-checkbox-label:has(:checked) {
@apply isa-text-body-2-bold;
}
.ui-checkbox.ui-checkbox__checkbox {
@apply relative inline-flex p-3 items-center justify-center rounded-lg bg-isa-white size-6 border border-solid border-isa-neutral-900;
font-size: 1.5rem;
.ui-checkbox__icon {
@apply invisible min-w-6 size-6 text-isa-white;
}
input[type="checkbox"] {
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
width: 100%;
height: 100%;
box-sizing: border-box;
opacity: 0;
@apply cursor-pointer;
}
&:has(input[type="checkbox"]:checked) {
@apply bg-isa-neutral-900 text-isa-white;
.ui-checkbox__icon {
@apply visible;
}
}
&:has(input[type="checkbox"]:disabled) {
@apply bg-isa-neutral-400 border-isa-neutral-400 cursor-default;
&:has(input[type="checkbox"]:checked) {
@apply bg-isa-neutral-400 border-isa-neutral-400;
}
input[type="checkbox"] {
@apply cursor-default;
}
}
}
.ui-checkbox.ui-checkbox__bullet {
display: inline-flex;
padding: 0.75rem;
justify-content: center;
align-items: center;
font-size: 1.5rem;
position: relative;
@apply rounded-full bg-isa-neutral-300 size-12;
.ui-checkbox__icon {
@apply invisible size-6 text-isa-neutral-100;
}
input[type="checkbox"] {
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
width: 100%;
height: 100%;
box-sizing: border-box;
opacity: 0;
@apply cursor-pointer;
}
&:hover {
@apply bg-isa-neutral-400;
}
&:has(input[type="checkbox"]:checked) {
@apply bg-isa-neutral-700;
.ui-checkbox__icon {
@apply visible;
}
&:hover {
@apply bg-isa-neutral-800;
}
}
&:has(input[type="checkbox"]:disabled) {
@apply bg-isa-neutral-400 cursor-default;
&:has(input[type="checkbox"]:checked) {
@apply bg-isa-neutral-700;
}
input[type="checkbox"] {
@apply cursor-default;
}
}
}
.ui-checkbox-label {
@apply inline-flex items-center gap-4 text-isa-neutral-900 isa-text-body-2-regular;
}
.ui-checkbox-label:has(:checked) {
@apply isa-text-body-2-bold;
}
.ui-checkbox.ui-checkbox__checkbox {
@apply relative inline-flex p-3 items-center justify-center rounded-lg bg-isa-white size-6 border border-solid border-isa-neutral-900;
font-size: 1.5rem;
.ui-checkbox__icon {
@apply hidden min-w-6 size-6 text-isa-white;
}
input[type="checkbox"] {
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
width: 100%;
height: 100%;
box-sizing: border-box;
opacity: 0;
@apply cursor-pointer;
}
&:has(input[type="checkbox"]:checked) {
@apply bg-isa-neutral-900 text-isa-white;
.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;
}
}
&:has(input[type="checkbox"]:disabled) {
@apply bg-isa-neutral-400 border-isa-neutral-400 cursor-default;
&:has(input[type="checkbox"]:checked) {
@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;
}
}
}
.ui-checkbox.ui-checkbox__bullet {
display: inline-flex;
padding: 0.75rem;
justify-content: center;
align-items: center;
font-size: 1.5rem;
position: relative;
@apply rounded-full bg-isa-neutral-300 size-12;
.ui-checkbox__icon {
@apply hidden size-6 text-isa-neutral-100;
}
input[type="checkbox"] {
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
width: 100%;
height: 100%;
box-sizing: border-box;
opacity: 0;
@apply cursor-pointer;
}
&:hover {
@apply bg-isa-neutral-400;
}
&:has(input[type="checkbox"]:checked) {
@apply bg-isa-neutral-700;
.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 {
@apply bg-isa-neutral-800;
}
}
&:has(input[type="checkbox"]:disabled) {
@apply bg-isa-neutral-400 cursor-default;
&:has(input[type="checkbox"]:checked) {
@apply bg-isa-neutral-700;
}
input[type="checkbox"] {
@apply cursor-default;
}
}
}

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-content select="input[type=checkbox]"></ng-content>
<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

@@ -1,73 +1,73 @@
import {
ChangeDetectionStrategy,
Component,
computed,
input,
ViewEncapsulation,
} from '@angular/core';
import { NgIconComponent, provideIcons } from '@ng-icons/core';
import { isaActionCheck } from '@isa/icons';
/**
* Configuration options for CheckboxComponent appearance
* @readonly
*/
export const CheckboxAppearance = {
/** Renders the checkbox as a round bullet style selector */
Bullet: 'bullet',
/** Renders the checkbox as a traditional square checkbox (default) */
Checkbox: 'checkbox',
} as const;
export type CheckboxAppearance =
(typeof CheckboxAppearance)[keyof typeof CheckboxAppearance];
/**
* A customizable checkbox component that supports different visual appearances.
*
* This component provides a styled checkbox input that can be displayed either as
* a traditional checkbox or as a bullet-style (round) selector. It uses Angular signals
* for reactive state management and includes an optional checkmark icon when selected.
*
* @example
* ```html
* <!-- Default checkbox appearance -->
* <ui-checkbox>
* <input type="checkbox" />
* </ui-checkbox>
*
* <!-- Bullet appearance -->
* <ui-checkbox [appearance]="CheckboxAppearance.Bullet">
* <input type="checkbox" />
* </ui-checkbox>
* ```
*/
@Component({
selector: 'ui-checkbox',
templateUrl: './checkbox.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
imports: [NgIconComponent],
providers: [provideIcons({ isaActionCheck })],
host: {
'[class]': '["ui-checkbox", appearanceClass()]',
},
})
export class CheckboxComponent {
/**
* Controls the visual appearance of the checkbox.
* Can be either "checkbox" (default) or "bullet".
*/
appearance = input<CheckboxAppearance>(CheckboxAppearance.Checkbox);
/**
* Computes the CSS class to apply based on the current appearance setting.
*
* @returns A CSS class name string that corresponds to the current appearance
*/
appearanceClass = computed(() => {
return this.appearance() === CheckboxAppearance.Bullet
? 'ui-checkbox__bullet'
: 'ui-checkbox__checkbox';
});
}
import {
ChangeDetectionStrategy,
Component,
computed,
input,
ViewEncapsulation,
} from '@angular/core';
import { NgIconComponent, provideIcons } from '@ng-icons/core';
import { isaActionCheck, isaActionMinus } from '@isa/icons';
/**
* Configuration options for CheckboxComponent appearance
* @readonly
*/
export const CheckboxAppearance = {
/** Renders the checkbox as a round bullet style selector */
Bullet: 'bullet',
/** Renders the checkbox as a traditional square checkbox (default) */
Checkbox: 'checkbox',
} as const;
export type CheckboxAppearance =
(typeof CheckboxAppearance)[keyof typeof CheckboxAppearance];
/**
* A customizable checkbox component that supports different visual appearances.
*
* This component provides a styled checkbox input that can be displayed either as
* a traditional checkbox or as a bullet-style (round) selector. It uses Angular signals
* for reactive state management and includes an optional checkmark icon when selected.
*
* @example
* ```html
* <!-- Default checkbox appearance -->
* <ui-checkbox>
* <input type="checkbox" />
* </ui-checkbox>
*
* <!-- Bullet appearance -->
* <ui-checkbox [appearance]="CheckboxAppearance.Bullet">
* <input type="checkbox" />
* </ui-checkbox>
* ```
*/
@Component({
selector: 'ui-checkbox',
templateUrl: './checkbox.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
imports: [NgIconComponent],
providers: [provideIcons({ isaActionCheck, isaActionMinus })],
host: {
'[class]': '["ui-checkbox", appearanceClass()]',
},
})
export class CheckboxComponent {
/**
* Controls the visual appearance of the checkbox.
* Can be either "checkbox" (default) or "bullet".
*/
appearance = input<CheckboxAppearance>(CheckboxAppearance.Checkbox);
/**
* Computes the CSS class to apply based on the current appearance setting.
*
* @returns A CSS class name string that corresponds to the current appearance
*/
appearanceClass = computed(() => {
return this.appearance() === CheckboxAppearance.Bullet
? 'ui-checkbox__bullet'
: 'ui-checkbox__checkbox';
});
}

View File

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