mirror of
https://dev.azure.com/hugendubel/ISA/_git/ISA-Frontend
synced 2025-12-28 22:42:11 +01:00
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:
committed by
Nino Righi
parent
0b4aef5f6c
commit
b339a6d79f
@@ -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';
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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) || [],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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])),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}, {});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
>;
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import { CheckboxFilterInputOption } from '../core';
|
||||
|
||||
export function checkboxOptionKeysHelper(
|
||||
option: CheckboxFilterInputOption,
|
||||
): string[] {
|
||||
return [...option.path].slice(2);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
175
libs/shared/filter/src/lib/helpers/checkbox-selected.helper.ts
Normal file
175
libs/shared/filter/src/lib/helpers/checkbox-selected.helper.ts
Normal 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';
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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[]);
|
||||
};
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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 || []);
|
||||
}
|
||||
218
libs/shared/filter/src/lib/helpers/get-option-key.helper.spec.ts
Normal file
218
libs/shared/filter/src/lib/helpers/get-option-key.helper.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
10
libs/shared/filter/src/lib/helpers/get-option-key.helper.ts
Normal file
10
libs/shared/filter/src/lib/helpers/get-option-key.helper.ts
Normal 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;
|
||||
}
|
||||
5
libs/shared/filter/src/lib/helpers/index.ts
Normal file
5
libs/shared/filter/src/lib/helpers/index.ts
Normal 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';
|
||||
@@ -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>
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
:host {
|
||||
@apply block flex-grow w-full;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user