This commit is contained in:
Nino
2025-04-11 16:59:20 +02:00
51 changed files with 3578 additions and 395 deletions

View File

@@ -10,6 +10,21 @@ import {
import { ButtonComponent } from '@isa/ui/buttons';
import { FilterService } from '../core';
/**
* A standalone component that manages filter action buttons (apply/reset)
*
* This component provides UI controls to apply or reset filter values
* within the filtering system. It communicates with the FilterService
* to perform filter operations.
*
* @example
* <filter-actions
* [inputKey]="'myFilterKey'"
* [canApply]="true"
* (applied)="handleFilterApplied()"
* (reseted)="handleFilterReset()">
* </filter-actions>
*/
@Component({
selector: 'filter-actions',
templateUrl: './filter-actions.component.html',
@@ -23,20 +38,44 @@ import { FilterService } from '../core';
},
})
export class FilterActionsComponent {
/** The filter service used to interact with the filter system */
readonly filterService = inject(FilterService);
/**
* Optional key specifying which filter input to apply/reset
* If not provided, all filter inputs will be affected
*/
inputKey = input<string>();
/**
* Controls whether the Apply button should be displayed
* @default true
*/
canApply = input<boolean>(true);
/**
* Computed signal that filters inputs to only include those with 'filter' group
*/
filterInputs = computed(() =>
this.filterService.inputs().filter((input) => input.group === 'filter'),
);
/**
* Event emitted when filters are applied
*/
applied = output<void>();
/**
* Event emitted when filters are reset
*/
reseted = output<void>();
/**
* Applies the current filter values
*
* If inputKey is provided, only that specific filter input is committed.
* Otherwise, all filter inputs are committed.
*/
onApply() {
const inputKey = this.inputKey();
@@ -49,6 +88,13 @@ export class FilterActionsComponent {
this.applied.emit();
}
/**
* Resets filter values to their defaults
*
* If inputKey is provided, only that specific filter input is reset.
* Otherwise, all filter inputs in the 'filter' group are reset.
* After resetting, all changes are committed.
*/
onReset() {
const inputKey = this.inputKey();

View File

@@ -1,10 +1,11 @@
import { computed, inject, Injectable, Input, signal } from '@angular/core';
import { computed, inject, Injectable, signal } from '@angular/core';
import { InputType } from '../types';
import { getState, patchState, signalState } from '@ngrx/signals';
import { mapToFilter } from './mappings';
import { filterMapping } from './mappings';
import { isEqual } from 'lodash';
import {
FilterInput,
OrderByDirection,
OrderByDirectionSchema,
Query,
QuerySchema,
@@ -20,7 +21,7 @@ export class FilterService {
readonly settings = inject(QUERY_SETTINGS);
private readonly defaultState = mapToFilter(this.settings);
private readonly defaultState = filterMapping(this.settings);
#commitdState = signal(structuredClone(this.defaultState));
@@ -39,14 +40,15 @@ export class FilterService {
}
setOrderBy(
orderBy: { by: string; dir: 'asc' | 'desc' | undefined },
by: string,
dir: OrderByDirection | undefined,
options?: { commit: boolean },
) {
const orderByList = this.#state.orderBy().map((o) => {
if (o.by === orderBy.by) {
return { ...o, dir: orderBy.dir };
if (o.by === by && o.dir === dir) {
return { ...o, selected: true };
}
return { ...o, dir: undefined };
return { ...o, selected: false };
});
patchState(this.#state, { orderBy: orderByList });
@@ -56,29 +58,6 @@ export class FilterService {
}
}
toggleOrderBy(by: string, options?: { commit: boolean }): void {
const orderBy = this.#state.orderBy();
const orderByIndex = orderBy.findIndex((o) => o.by === by);
if (orderByIndex === -1) {
console.warn(`No orderBy found with by: ${by}`);
return;
}
let orderByDir = orderBy[orderByIndex].dir;
if (!orderByDir) {
orderByDir = 'asc';
} else if (orderByDir === 'asc') {
orderByDir = 'desc';
} else {
orderByDir = undefined;
}
this.setOrderBy({ by, dir: orderByDir }, options);
}
setInputTextValue(
key: string,
value: string | undefined,
@@ -300,14 +279,12 @@ export class FilterService {
}
commitOrderBy() {
const orderBy = this.#state.orderBy().map((o) => {
const committedOrderBy = this.#commitdState().orderBy.find(
(co) => co.by === o.by,
);
return { ...o, dir: committedOrderBy?.dir };
});
const orderBy = this.#state.orderBy();
patchState(this.#state, { orderBy });
this.#commitdState.set({
...this.#commitdState(),
orderBy,
});
}
clear(options?: { commit: boolean }) {
@@ -340,7 +317,7 @@ export class FilterService {
* @param options.commit - If `true`, the changes will be committed after resetting the state.
*/
reset(options?: { commit: boolean }) {
patchState(this.#state, mapToFilter(this.settings));
patchState(this.#state, structuredClone(this.defaultState));
if (options?.commit) {
this.commit();
}
@@ -363,7 +340,7 @@ export class FilterService {
* ```
*/
resetInput(key: string, options?: { commit: boolean }) {
const defaultFilter = mapToFilter(this.settings);
const defaultFilter = structuredClone(this.defaultState);
const inputToReset = defaultFilter.inputs.find((i) => i.key === key);
if (!inputToReset) {
@@ -390,13 +367,8 @@ export class FilterService {
}
resetOrderBy(options?: { commit: boolean }) {
const defaultOrderBy = mapToFilter(this.settings).orderBy;
const orderBy = this.#state.orderBy().map((o) => {
const defaultOrder = defaultOrderBy.find((do_) => do_.by === o.by);
return { ...o, dir: defaultOrder?.dir };
});
patchState(this.#state, { orderBy });
const defaultOrderBy = structuredClone(this.defaultState.orderBy);
patchState(this.#state, { orderBy: defaultOrderBy });
if (options?.commit) {
this.commit();
@@ -431,7 +403,7 @@ export class FilterService {
}
}
const orderBy = commited.orderBy.find((o) => o.dir);
const orderBy = commited.orderBy.find((o) => o.selected);
if (orderBy) {
result['orderBy'] = `${orderBy.by}:${orderBy.dir}`;
@@ -498,14 +470,16 @@ export class FilterService {
}
return acc;
}, {}),
orderBy: orderBy.map((o) => {
return {
by: o.by,
label: o.label,
desc: o.dir === 'desc',
selected: true,
};
}),
orderBy: orderBy
.filter((o) => o.selected)
.map((o) => {
return {
by: o.by,
label: o.label,
desc: o.dir === 'desc',
selected: true,
};
}),
});
});
@@ -514,13 +488,17 @@ export class FilterService {
options?: { commit: boolean },
): void {
this.reset();
for (const key in params) {
if (key === 'orderBy') {
const [by, dir] = params[key].split(':');
const orderBy = this.orderBy().find((o) => o.by === by);
const orderBy = this.orderBy().some(
(o) => o.by === by && o.dir === dir,
);
if (orderBy) {
this.setOrderBy({ by, dir: OrderByDirectionSchema.parse(dir) });
console.warn(`OrderBy already exists: ${by}:${dir}`);
this.setOrderBy(by, OrderByDirectionSchema.parse(dir));
}
continue;
}

View File

@@ -1,129 +0,0 @@
import { Input, InputGroup, InputType, Option, QuerySettings } from '../types';
import {
CheckboxFilterInput,
CheckboxFilterInputOption,
CheckboxFilterInputOptionSchema,
CheckboxFilterInputSchema,
DateRangeFilterInput,
DateRangeFilterInputSchema,
Filter,
FilterGroup,
FilterGroupSchema,
FilterInput,
OrderBySchema,
TextFilterInput,
TextFilterInputSchema,
} from './schemas';
export function mapToFilter(settings: QuerySettings): Filter {
const filter: Filter = {
groups: [],
inputs: [],
orderBy: [],
};
const groups = [...settings.filter, ...settings.input];
for (const group of groups) {
filter.groups.push(mapToFilterGroup(group));
for (const input of group.input) {
filter.inputs.push(mapToFilterInput(group.group, input));
}
}
if (settings.orderBy) {
const bys = new Set<string>();
for (const orderBy of settings.orderBy) {
if (orderBy.by && bys.has(orderBy.by)) {
continue;
}
if (orderBy.by) {
filter.orderBy.push(OrderBySchema.parse(orderBy));
bys.add(orderBy.by);
}
}
}
return filter;
}
function mapToFilterGroup(group: InputGroup): FilterGroup {
return FilterGroupSchema.parse({
group: group.group,
label: group.label,
description: group.description,
});
}
function mapToFilterInput(group: string, input: Input): FilterInput {
switch (input.type) {
case InputType.Text:
return mapToTextFilterInput(group, input);
case InputType.Checkbox:
return mapToCheckboxFilterInput(group, input);
case InputType.DateRange:
return mapToDateRangeFilterInput(group, input);
}
throw new Error(`Unknown input type: ${input.type}`);
}
function mapToTextFilterInput(group: string, input: Input): TextFilterInput {
return TextFilterInputSchema.parse({
group,
key: input.key,
label: input.label,
description: input.description,
type: InputType.Text,
defaultValue: input.value,
value: input.value,
placeholder: input.placeholder,
});
}
function mapToCheckboxFilterInput(
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(mapToCheckboxOption),
selected:
input.options?.values
?.filter((option) => option.selected)
.map((option) => option.value) || [],
});
}
function mapToCheckboxOption(option: Option): CheckboxFilterInputOption {
return CheckboxFilterInputOptionSchema.parse({
label: option.label,
value: option.value,
});
}
function mapToDateRangeFilterInput(
group: string,
input: Input,
): DateRangeFilterInput {
return DateRangeFilterInputSchema.parse({
group,
key: input.key,
label: input.label,
description: input.description,
type: InputType.DateRange,
start: input.options?.values?.[0].value,
minStart: input.options?.values?.[0].minValue,
maxStart: input.options?.values?.[0].maxValue,
stop: input.options?.values?.[1].value,
minStop: input.options?.values?.[1].minValue,
maxStop: input.options?.values?.[1].maxValue,
});
}

View File

@@ -0,0 +1,220 @@
import { Input, InputType } from '../../types';
import { checkboxFilterInputMapping } from './checkbox-filter-input.mapping';
import * as checkboxOptionMappingModule from './checkbox-option.mapping';
import * as schemaModule from '../schemas/checkbox-filter-input.schema';
describe('checkboxFilterInputMapping', () => {
const mockCheckboxOptionMapping = jest.fn().mockImplementation((option) => ({
label: option.label,
value: option.value,
}));
const mockSchemaParser = jest.fn().mockImplementation((input) => input);
beforeEach(() => {
jest.clearAllMocks();
jest
.spyOn(checkboxOptionMappingModule, 'checkboxOptionMapping')
.mockImplementation(mockCheckboxOptionMapping);
// Mock the schema parse method to avoid validation errors in tests
jest
.spyOn(schemaModule.CheckboxFilterInputSchema, 'parse')
.mockImplementation(mockSchemaParser);
});
it('should map minimal input correctly', () => {
// Arrange
const group = 'testGroup';
const input: Input = {
key: 'testKey',
label: 'Test Label',
type: InputType.Checkbox,
};
// Act
const result = checkboxFilterInputMapping(group, input);
// Assert
expect(mockSchemaParser).toHaveBeenCalledWith({
group: 'testGroup',
key: 'testKey',
label: 'Test Label',
type: InputType.Checkbox,
defaultValue: undefined,
maxOptions: undefined,
options: undefined,
selected: [],
description: undefined,
});
expect(result).toEqual({
group: 'testGroup',
key: 'testKey',
label: 'Test Label',
type: InputType.Checkbox,
defaultValue: undefined,
maxOptions: undefined,
options: undefined,
selected: [],
description: undefined,
});
});
it('should map complete input correctly', () => {
// Arrange
const group = 'testGroup';
const input: Input = {
key: 'testKey',
label: 'Test Label',
description: 'Test Description',
type: InputType.Checkbox,
value: 'defaultValue',
options: {
max: 3,
values: [
{ label: 'Option 1', value: 'value1', selected: false },
{ label: 'Option 2', value: 'value2', selected: false },
],
},
};
// Act
const result = checkboxFilterInputMapping(group, input);
// Assert
expect(mockSchemaParser).toHaveBeenCalledWith({
group: 'testGroup',
key: 'testKey',
label: 'Test Label',
description: 'Test Description',
type: InputType.Checkbox,
defaultValue: 'defaultValue',
maxOptions: 3,
options: [
{ label: 'Option 1', value: 'value1' },
{ label: 'Option 2', value: 'value2' },
],
selected: [],
});
expect(result).toEqual({
group: 'testGroup',
key: 'testKey',
label: 'Test Label',
description: 'Test Description',
type: InputType.Checkbox,
defaultValue: 'defaultValue',
maxOptions: 3,
options: [
{ label: 'Option 1', value: 'value1' },
{ label: 'Option 2', value: 'value2' },
],
selected: [],
});
expect(mockCheckboxOptionMapping).toHaveBeenCalledTimes(2);
});
it('should map selected options correctly', () => {
// Arrange
const group = 'testGroup';
const input: Input = {
key: 'testKey',
label: 'Test Label',
type: InputType.Checkbox,
options: {
values: [
{ label: 'Option 1', value: 'value1', selected: true },
{ label: 'Option 2', value: 'value2', selected: false },
{ label: 'Option 3', value: 'value3', selected: true },
],
},
};
// Act
const result = checkboxFilterInputMapping(group, input);
// Assert
expect(mockSchemaParser).toHaveBeenCalledWith({
group: 'testGroup',
key: 'testKey',
label: 'Test Label',
type: InputType.Checkbox,
defaultValue: undefined,
maxOptions: undefined,
options: [
{ label: 'Option 1', value: 'value1' },
{ label: 'Option 2', value: 'value2' },
{ label: 'Option 3', value: 'value3' },
],
selected: ['value1', 'value3'],
description: undefined,
});
expect(result.selected).toEqual(['value1', 'value3']);
expect(mockCheckboxOptionMapping).toHaveBeenCalledTimes(3);
});
it('should handle empty options array', () => {
// Arrange
const group = 'testGroup';
const input: Input = {
key: 'testKey',
label: 'Test Label',
type: InputType.Checkbox,
options: {
values: [],
},
};
// Act
const result = checkboxFilterInputMapping(group, input);
// Assert
expect(mockSchemaParser).toHaveBeenCalledWith({
group: 'testGroup',
key: 'testKey',
label: 'Test Label',
type: InputType.Checkbox,
defaultValue: undefined,
maxOptions: undefined,
options: [],
selected: [],
description: undefined,
});
expect(result.options).toEqual([]);
expect(result.selected).toEqual([]);
expect(mockCheckboxOptionMapping).not.toHaveBeenCalled();
});
it('should handle undefined options', () => {
// Arrange
const group = 'testGroup';
const input: Input = {
key: 'testKey',
label: 'Test Label',
type: InputType.Checkbox,
};
// Act
const result = checkboxFilterInputMapping(group, input);
// Assert
expect(mockSchemaParser).toHaveBeenCalledWith({
group: 'testGroup',
key: 'testKey',
label: 'Test Label',
type: InputType.Checkbox,
defaultValue: undefined,
maxOptions: undefined,
options: undefined,
selected: [],
description: undefined,
});
expect(result.options).toBeUndefined();
expect(result.selected).toEqual([]);
expect(mockCheckboxOptionMapping).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,34 @@
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) || [],
});
}

View File

@@ -0,0 +1,63 @@
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');
});
});

View File

@@ -0,0 +1,23 @@
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,
});
}

View File

@@ -0,0 +1,177 @@
import { Input, InputType } from '../../types';
import { dateRangeFilterInputMapping } from './date-range-filter-input.mapping';
import * as schemaModule from '../schemas/date-range-filter-input.schema';
describe('dateRangeFilterInputMapping', () => {
const mockSchemaParser = jest.fn().mockImplementation((input) => input);
beforeEach(() => {
jest.clearAllMocks();
// Mock the schema parse method to avoid validation errors in tests
jest
.spyOn(schemaModule.DateRangeFilterInputSchema, 'parse')
.mockImplementation(mockSchemaParser);
});
it('should map minimal input correctly', () => {
// Arrange
const group = 'testGroup';
const input: Input = {
key: 'testKey',
label: 'Test Label',
type: InputType.DateRange,
};
// Act
const result = dateRangeFilterInputMapping(group, input);
// Assert
expect(mockSchemaParser).toHaveBeenCalledWith({
group: 'testGroup',
key: 'testKey',
label: 'Test Label',
description: undefined,
type: InputType.DateRange,
start: undefined,
stop: undefined,
});
expect(result).toEqual({
group: 'testGroup',
key: 'testKey',
label: 'Test Label',
description: undefined,
type: InputType.DateRange,
start: undefined,
stop: undefined,
});
});
it('should map complete input correctly', () => {
// Arrange
const group = 'testGroup';
const input: Input = {
key: 'testKey',
label: 'Test Label',
description: 'Test Description',
type: InputType.DateRange,
options: {
values: [
{ label: 'Start', value: '2023-01-01' },
{ label: 'End', value: '2023-12-31' },
],
},
};
// Act
const result = dateRangeFilterInputMapping(group, input);
// Assert
expect(mockSchemaParser).toHaveBeenCalledWith({
group: 'testGroup',
key: 'testKey',
label: 'Test Label',
description: 'Test Description',
type: InputType.DateRange,
start: '2023-01-01',
stop: '2023-12-31',
});
expect(result).toEqual({
group: 'testGroup',
key: 'testKey',
label: 'Test Label',
description: 'Test Description',
type: InputType.DateRange,
start: '2023-01-01',
stop: '2023-12-31',
});
});
it('should handle missing values in options', () => {
// Arrange
const group = 'testGroup';
const input: Input = {
key: 'testKey',
label: 'Test Label',
type: InputType.DateRange,
options: {
values: [],
},
};
// Act
const result = dateRangeFilterInputMapping(group, input);
// Assert
expect(mockSchemaParser).toHaveBeenCalledWith({
group: 'testGroup',
key: 'testKey',
label: 'Test Label',
description: undefined,
type: InputType.DateRange,
start: undefined,
stop: undefined,
});
expect(result).toEqual({
group: 'testGroup',
key: 'testKey',
label: 'Test Label',
description: undefined,
type: InputType.DateRange,
start: undefined,
stop: undefined,
});
});
it('should map min and max values correctly', () => {
// Arrange
const group = 'testGroup';
const input: Input = {
key: 'testKey',
label: 'Test Label',
type: InputType.DateRange,
options: {
values: [
{
label: 'Start',
value: '2023-01-01',
minValue: '2022-01-01',
maxValue: '2024-12-31',
},
{ label: 'End', value: '2023-12-31' },
],
},
};
// Act
const result = dateRangeFilterInputMapping(group, input);
// Assert
expect(mockSchemaParser).toHaveBeenCalledWith({
group: 'testGroup',
key: 'testKey',
label: 'Test Label',
description: undefined,
type: InputType.DateRange,
start: '2023-01-01',
stop: '2023-12-31',
minStart: '2022-01-01',
maxStop: '2024-12-31',
});
expect(result).toEqual({
group: 'testGroup',
key: 'testKey',
label: 'Test Label',
description: undefined,
type: InputType.DateRange,
start: '2023-01-01',
stop: '2023-12-31',
minStart: '2022-01-01',
maxStop: '2024-12-31',
});
});
});

View File

@@ -0,0 +1,30 @@
import { Input, InputType } from '../../types';
import { DateRangeFilterInput, DateRangeFilterInputSchema } from '../schemas';
/**
* Maps an Input object to a DateRangeFilterInput object
*
* This function takes an input of type DateRange and maps it to a strongly-typed
* DateRangeFilterInput object, validating it against a schema. It extracts the start
* and stop dates from the input's option values.
*
* @param group - The group identifier that this input belongs to
* @param input - The source input object to map
* @returns A validated DateRangeFilterInput object
*/
export function dateRangeFilterInputMapping(
group: string,
input: Input,
): DateRangeFilterInput {
return DateRangeFilterInputSchema.parse({
group,
key: input.key,
label: input.label,
description: input.description,
type: InputType.DateRange,
start: input.options?.values?.[0]?.value,
stop: input.options?.values?.[1]?.value,
minStart: input.options?.values?.[0]?.minValue,
maxStop: input.options?.values?.[0]?.maxValue,
});
}

View File

@@ -0,0 +1,111 @@
import { InputGroup, InputType } from '../../types';
import { filterGroupMapping } from './filter-group.mapping';
import * as schemaModule from '../schemas/filter-group.schema';
describe('filterGroupMapping', () => {
const mockSchemaParser = jest.fn().mockImplementation((input) => input);
beforeEach(() => {
jest.clearAllMocks();
// Mock the schema parse method to avoid validation errors in tests
jest.spyOn(schemaModule.FilterGroupSchema, 'parse').mockImplementation(mockSchemaParser);
});
it('should map minimal input group correctly', () => {
// Arrange
const group: InputGroup = {
group: 'testGroup',
label: 'Test Group',
input: [],
};
// Act
const result = filterGroupMapping(group);
// Assert
expect(mockSchemaParser).toHaveBeenCalledWith({
group: 'testGroup',
label: 'Test Group',
description: undefined,
});
expect(result).toEqual({
group: 'testGroup',
label: 'Test Group',
description: undefined,
});
});
it('should map complete input group correctly', () => {
// Arrange
const group: InputGroup = {
group: 'testGroup',
label: 'Test Group',
description: 'Test Description',
input: [],
};
// Act
const result = filterGroupMapping(group);
// Assert
expect(mockSchemaParser).toHaveBeenCalledWith({
group: 'testGroup',
label: 'Test Group',
description: 'Test Description',
});
expect(result).toEqual({
group: 'testGroup',
label: 'Test Group',
description: 'Test Description',
});
});
it('should ignore input property in the mapping result', () => {
// Arrange
const group: InputGroup = {
group: 'testGroup',
label: 'Test Group',
input: [
{ key: 'input1', label: 'Input 1', type: InputType.Text },
{ key: 'input2', label: 'Input 2', type: InputType.Checkbox },
],
};
// Act
const result = filterGroupMapping(group);
// Assert
expect(mockSchemaParser).toHaveBeenCalledWith({
group: 'testGroup',
label: 'Test Group',
description: undefined,
});
expect(result).toEqual({
group: 'testGroup',
label: 'Test Group',
description: undefined,
});
expect(result).not.toHaveProperty('input');
});
it('should handle schema validation errors', () => {
// Arrange
const schemaError = new Error('Schema validation failed');
jest.spyOn(schemaModule.FilterGroupSchema, 'parse').mockImplementation(() => {
throw schemaError;
});
const group: InputGroup = {
group: 'testGroup',
label: 'Test Group',
input: [],
};
// Act & Assert
expect(() => filterGroupMapping(group)).toThrow(schemaError);
});
});

View File

@@ -0,0 +1,20 @@
import { InputGroup } from '../../types';
import { FilterGroup, FilterGroupSchema } from '../schemas';
/**
* Maps an InputGroup object to a FilterGroup object
*
* This function converts a generic InputGroup to a strongly-typed FilterGroup,
* validating it against a schema. It preserves the group identifier, label,
* and description.
*
* @param group - The source input group to map
* @returns A validated FilterGroup object
*/
export function filterGroupMapping(group: InputGroup): FilterGroup {
return FilterGroupSchema.parse({
group: group.group,
label: group.label,
description: group.description,
});
}

View File

@@ -0,0 +1,140 @@
import { Input, InputType } from '../../types';
import { filterInputMapping } from './filter-input.mapping';
import * as checkboxFilterInputMappingModule from './checkbox-filter-input.mapping';
import * as dateRangeFilterInputMappingModule from './date-range-filter-input.mapping';
import * as textFilterInputMappingModule from './text-filter-input.mapping';
describe('filterInputMapping', () => {
// Mock implementations for each specific mapping function
const mockTextFilterInputMapping = jest
.fn()
.mockImplementation((group, input) => ({
type: InputType.Text,
group,
key: input.key,
mapped: 'text',
}));
const mockCheckboxFilterInputMapping = jest
.fn()
.mockImplementation((group, input) => ({
type: InputType.Checkbox,
group,
key: input.key,
mapped: 'checkbox',
}));
const mockDateRangeFilterInputMapping = jest
.fn()
.mockImplementation((group, input) => ({
type: InputType.DateRange,
group,
key: input.key,
mapped: 'dateRange',
}));
beforeEach(() => {
jest.clearAllMocks();
// Mock all the mapping functions that filterInputMapping delegates to
jest
.spyOn(textFilterInputMappingModule, 'textFilterInputMapping')
.mockImplementation(mockTextFilterInputMapping);
jest
.spyOn(checkboxFilterInputMappingModule, 'checkboxFilterInputMapping')
.mockImplementation(mockCheckboxFilterInputMapping);
jest
.spyOn(dateRangeFilterInputMappingModule, 'dateRangeFilterInputMapping')
.mockImplementation(mockDateRangeFilterInputMapping);
});
it('should delegate to textFilterInputMapping for text inputs', () => {
// Arrange
const group = 'testGroup';
const input: Input = {
key: 'textInput',
label: 'Text Input',
type: InputType.Text,
};
// Act
const result = filterInputMapping(group, input);
// Assert
expect(mockTextFilterInputMapping).toHaveBeenCalledWith(group, input);
expect(mockCheckboxFilterInputMapping).not.toHaveBeenCalled();
expect(mockDateRangeFilterInputMapping).not.toHaveBeenCalled();
expect(result).toEqual({
type: InputType.Text,
group,
key: 'textInput',
mapped: 'text',
});
});
it('should delegate to checkboxFilterInputMapping for checkbox inputs', () => {
// Arrange
const group = 'testGroup';
const input: Input = {
key: 'checkboxInput',
label: 'Checkbox Input',
type: InputType.Checkbox,
};
// Act
const result = filterInputMapping(group, input);
// Assert
expect(mockCheckboxFilterInputMapping).toHaveBeenCalledWith(group, input);
expect(mockTextFilterInputMapping).not.toHaveBeenCalled();
expect(mockDateRangeFilterInputMapping).not.toHaveBeenCalled();
expect(result).toEqual({
type: InputType.Checkbox,
group,
key: 'checkboxInput',
mapped: 'checkbox',
});
});
it('should delegate to dateRangeFilterInputMapping for dateRange inputs', () => {
// Arrange
const group = 'testGroup';
const input: Input = {
key: 'dateRangeInput',
label: 'Date Range Input',
type: InputType.DateRange,
};
// Act
const result = filterInputMapping(group, input);
// Assert
expect(mockDateRangeFilterInputMapping).toHaveBeenCalledWith(group, input);
expect(mockTextFilterInputMapping).not.toHaveBeenCalled();
expect(mockCheckboxFilterInputMapping).not.toHaveBeenCalled();
expect(result).toEqual({
type: InputType.DateRange,
group,
key: 'dateRangeInput',
mapped: 'dateRange',
});
});
it('should throw error for unknown input type', () => {
// Arrange
const group = 'testGroup';
const input: Input = {
key: 'unknownInput',
label: 'Unknown Input',
type: 999 as unknown as InputType, // Invalid input type
};
// Act & Assert
expect(() => filterInputMapping(group, input)).toThrowError(
'Unknown input type: 999',
);
expect(mockTextFilterInputMapping).not.toHaveBeenCalled();
expect(mockCheckboxFilterInputMapping).not.toHaveBeenCalled();
expect(mockDateRangeFilterInputMapping).not.toHaveBeenCalled();
});
});

View File

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

View File

@@ -0,0 +1,245 @@
import { InputGroup, InputType, OrderBy, QuerySettings } from '../../types';
import { filterMapping } from './filter.mapping';
import * as filterGroupMappingModule from './filter-group.mapping';
import * as filterInputMappingModule from './filter-input.mapping';
import * as orderByOptionMappingModule from './order-by-option.mapping';
describe('filterMapping', () => {
// Mock implementations for each specific mapping function
const mockFilterGroupMapping = jest.fn().mockImplementation((group: InputGroup) => ({
group: group.group,
label: group.label,
mapped: 'group',
}));
const mockFilterInputMapping = jest.fn().mockImplementation((group, input) => ({
group,
key: input.key,
mapped: 'input',
}));
const mockOrderByOptionMapping = jest.fn().mockImplementation((orderBy: OrderBy) => ({
by: orderBy.by,
mapped: 'orderBy',
}));
beforeEach(() => {
jest.clearAllMocks();
// Mock all the mapping functions that filterMapping delegates to
jest
.spyOn(filterGroupMappingModule, 'filterGroupMapping')
.mockImplementation(mockFilterGroupMapping);
jest
.spyOn(filterInputMappingModule, 'filterInputMapping')
.mockImplementation(mockFilterInputMapping);
jest
.spyOn(orderByOptionMappingModule, 'orderByOptionMapping')
.mockImplementation(mockOrderByOptionMapping);
});
it('should map empty query settings correctly', () => {
// Arrange
const settings: QuerySettings = {
filter: [],
input: [],
orderBy: [], // Add required property
};
// Act
const result = filterMapping(settings);
// Assert
expect(result).toEqual({
groups: [],
inputs: [],
orderBy: [],
});
expect(mockFilterGroupMapping).not.toHaveBeenCalled();
expect(mockFilterInputMapping).not.toHaveBeenCalled();
expect(mockOrderByOptionMapping).not.toHaveBeenCalled();
});
it('should map filter groups correctly', () => {
// Arrange
const settings: QuerySettings = {
filter: [
{
group: 'group1',
label: 'Group 1',
input: [
{ key: 'input1', label: 'Input 1', type: InputType.Text },
{ key: 'input2', label: 'Input 2', type: InputType.Checkbox },
],
},
],
input: [],
orderBy: [], // Add required property
};
// Act
const result = filterMapping(settings);
// Assert
expect(mockFilterGroupMapping).toHaveBeenCalledTimes(1);
expect(mockFilterGroupMapping).toHaveBeenCalledWith(settings.filter[0]);
expect(mockFilterInputMapping).toHaveBeenCalledTimes(2);
expect(mockFilterInputMapping).toHaveBeenCalledWith('group1', settings.filter[0].input[0]);
expect(mockFilterInputMapping).toHaveBeenCalledWith('group1', settings.filter[0].input[1]);
expect(mockOrderByOptionMapping).not.toHaveBeenCalled();
expect(result).toEqual({
groups: [
{
group: 'group1',
label: 'Group 1',
mapped: 'group',
},
],
inputs: [
{
group: 'group1',
key: 'input1',
mapped: 'input',
},
{
group: 'group1',
key: 'input2',
mapped: 'input',
},
],
orderBy: [],
});
});
it('should map input groups correctly', () => {
// Arrange
const settings: QuerySettings = {
filter: [],
input: [
{
group: 'group2',
label: 'Group 2',
input: [{ key: 'input3', label: 'Input 3', type: InputType.Text }],
},
],
orderBy: [], // Add required property
};
// Act
const result = filterMapping(settings);
// Assert
expect(mockFilterGroupMapping).toHaveBeenCalledTimes(1);
expect(mockFilterGroupMapping).toHaveBeenCalledWith(settings.input[0]);
expect(mockFilterInputMapping).toHaveBeenCalledTimes(1);
expect(mockFilterInputMapping).toHaveBeenCalledWith('group2', settings.input[0].input[0]);
expect(mockOrderByOptionMapping).not.toHaveBeenCalled();
expect(result).toEqual({
groups: [
{
group: 'group2',
label: 'Group 2',
mapped: 'group',
},
],
inputs: [
{
group: 'group2',
key: 'input3',
mapped: 'input',
},
],
orderBy: [],
});
});
it('should map orderBy options correctly', () => {
// Arrange
const settings: QuerySettings = {
filter: [],
input: [],
orderBy: [
{ label: 'Sort by Name', by: 'name', desc: false },
{ label: 'Sort by Date', by: 'date', desc: true },
],
};
// Act
const result = filterMapping(settings);
// Assert
expect(mockFilterGroupMapping).not.toHaveBeenCalled();
expect(mockFilterInputMapping).not.toHaveBeenCalled();
expect(mockOrderByOptionMapping).toHaveBeenCalledTimes(2);
expect(mockOrderByOptionMapping).toHaveBeenNthCalledWith(1, settings.orderBy[0]);
expect(mockOrderByOptionMapping).toHaveBeenNthCalledWith(2, settings.orderBy[1]);
expect(result).toEqual({
groups: [],
inputs: [],
orderBy: [
{ by: 'name', mapped: 'orderBy' },
{ by: 'date', mapped: 'orderBy' },
],
});
});
it('should map a complete query settings object', () => {
// Arrange
const settings: QuerySettings = {
filter: [
{
group: 'filter1',
label: 'Filter 1',
input: [{ key: 'input1', label: 'Input 1', type: InputType.Text }],
},
],
input: [
{
group: 'input1',
label: 'Input 1',
input: [{ key: 'input2', label: 'Input 2', type: InputType.Checkbox }],
},
],
orderBy: [{ label: 'Sort by Name', by: 'name', desc: false }],
};
// Act
const result = filterMapping(settings);
// Assert
expect(mockFilterGroupMapping).toHaveBeenCalledTimes(2);
expect(mockFilterInputMapping).toHaveBeenCalledTimes(2);
expect(mockOrderByOptionMapping).toHaveBeenCalledTimes(1);
expect(result).toEqual({
groups: [
{
group: 'filter1',
label: 'Filter 1',
mapped: 'group',
},
{
group: 'input1',
label: 'Input 1',
mapped: 'group',
},
],
inputs: [
{
group: 'filter1',
key: 'input1',
mapped: 'input',
},
{
group: 'input1',
key: 'input2',
mapped: 'input',
},
],
orderBy: [{ by: 'name', mapped: 'orderBy' }],
});
});
});

View File

@@ -0,0 +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;
}

View File

@@ -0,0 +1,8 @@
export * from './checkbox-filter-input.mapping';
export * from './checkbox-option.mapping';
export * from './date-range-filter-input.mapping';
export * from './filter-group.mapping';
export * from './filter-input.mapping';
export * from './filter.mapping';
export * from './order-by-option.mapping';
export * from './text-filter-input.mapping';

View File

@@ -0,0 +1,68 @@
import { OrderBy } from '../../types';
import { orderByOptionMapping } from './order-by-option.mapping';
import * as schemaModule from '../schemas/order-by-option.schema';
describe('orderByOptionMapping', () => {
const mockSchemaParser = jest.fn().mockImplementation((input) => input);
beforeEach(() => {
jest.clearAllMocks();
// Mock the schema parse method to avoid validation errors in tests
jest.spyOn(schemaModule.OrderByOptionSchema, 'parse').mockImplementation(mockSchemaParser);
});
it('should map ascending order by option correctly', () => {
// Arrange
const orderBy: OrderBy = {
label: 'Sort by Name',
by: 'name',
desc: false,
};
// Act
const result = orderByOptionMapping(orderBy);
// Assert
expect(mockSchemaParser).toHaveBeenCalledWith({
label: 'Sort by Name',
by: 'name',
dir: 'asc',
selected: false,
});
expect(result).toEqual({
label: 'Sort by Name',
by: 'name',
dir: 'asc',
selected: false,
});
});
it('should map descending order by option correctly', () => {
// Arrange
const orderBy: OrderBy = {
label: 'Sort by Date',
by: 'date',
desc: true,
};
// Act
const result = orderByOptionMapping(orderBy);
// Assert
expect(mockSchemaParser).toHaveBeenCalledWith({
label: 'Sort by Date',
by: 'date',
dir: 'desc',
selected: false,
});
expect(result).toEqual({
label: 'Sort by Date',
by: 'date',
dir: 'desc',
selected: false,
});
});
});

View File

@@ -0,0 +1,22 @@
import { OrderBy } from '../../types';
import { OrderByOption, OrderByOptionSchema } from '../schemas';
/**
* Maps an OrderBy object to an OrderByOption object
*
* This function transforms an OrderBy input definition into a strongly-typed
* OrderByOption that can be used by the filter component. It converts the
* desc boolean flag to a direction string ('asc' or 'desc') and initializes
* the selected state to false.
*
* @param orderBy - The source OrderBy object to map
* @returns A validated OrderByOption object
*/
export function orderByOptionMapping(orderBy: OrderBy): OrderByOption {
return OrderByOptionSchema.parse({
label: orderBy.label,
by: orderBy.by,
dir: orderBy.desc ? 'desc' : 'asc',
selected: false,
});
}

View File

@@ -0,0 +1,89 @@
import { Input, InputType } from '../../types';
import { textFilterInputMapping } from './text-filter-input.mapping';
import * as schemaModule from '../schemas/text-filter-input.schema';
describe('textFilterInputMapping', () => {
const mockSchemaParser = jest.fn().mockImplementation((input) => input);
beforeEach(() => {
jest.clearAllMocks();
// Mock the schema parse method to avoid validation errors in tests
jest.spyOn(schemaModule.TextFilterInputSchema, 'parse').mockImplementation(mockSchemaParser);
});
it('should map minimal input correctly', () => {
// Arrange
const group = 'testGroup';
const input: Input = {
key: 'testKey',
label: 'Test Label',
type: InputType.Text,
};
// Act
const result = textFilterInputMapping(group, input);
// Assert
expect(mockSchemaParser).toHaveBeenCalledWith({
group: 'testGroup',
key: 'testKey',
label: 'Test Label',
description: undefined,
type: InputType.Text,
defaultValue: undefined,
value: undefined,
placeholder: undefined,
});
expect(result).toEqual({
group: 'testGroup',
key: 'testKey',
label: 'Test Label',
description: undefined,
type: InputType.Text,
defaultValue: undefined,
value: undefined,
placeholder: undefined,
});
});
it('should map complete input correctly', () => {
// Arrange
const group = 'testGroup';
const input: Input = {
key: 'testKey',
label: 'Test Label',
description: 'Test Description',
type: InputType.Text,
value: 'defaultValue',
placeholder: 'Enter text...',
};
// Act
const result = textFilterInputMapping(group, input);
// Assert
expect(mockSchemaParser).toHaveBeenCalledWith({
group: 'testGroup',
key: 'testKey',
label: 'Test Label',
description: 'Test Description',
type: InputType.Text,
defaultValue: 'defaultValue',
value: 'defaultValue',
placeholder: 'Enter text...',
});
expect(result).toEqual({
group: 'testGroup',
key: 'testKey',
label: 'Test Label',
description: 'Test Description',
type: InputType.Text,
defaultValue: 'defaultValue',
value: 'defaultValue',
placeholder: 'Enter text...',
});
});
});

View File

@@ -0,0 +1,28 @@
import { Input, InputType } from '../../types';
import { TextFilterInput, TextFilterInputSchema } from '../schemas';
/**
* Maps an Input object to a TextFilterInput object
*
* This function takes an input of type Text and maps it to a strongly-typed
* TextFilterInput object, validating it against a schema.
*
* @param group - The group identifier that this input belongs to
* @param input - The source input object to map
* @returns A validated TextFilterInput object
*/
export function textFilterInputMapping(
group: string,
input: Input,
): TextFilterInput {
return TextFilterInputSchema.parse({
group,
key: input.key,
label: input.label,
description: input.description,
type: InputType.Text,
defaultValue: input.value,
value: input.value,
placeholder: input.placeholder,
});
}

View File

@@ -1,112 +0,0 @@
import { z } from 'zod';
import { InputType } from '../types';
export const FilterGroupSchema = z
.object({
group: z.string(),
label: z.string().optional(),
description: z.string().optional(),
})
.describe('FilterGroup');
export const CheckboxFilterInputOptionSchema = z
.object({
label: z.string(),
value: z.string(),
})
.describe('CheckboxFilterInputOption');
const BaseFilterInputSchema = z
.object({
group: z.string(),
key: z.string(),
label: z.string().optional(),
description: z.string().optional(),
type: z.nativeEnum(InputType),
})
.describe('BaseFilterInput');
export const TextFilterInputSchema = BaseFilterInputSchema.extend({
type: z.literal(InputType.Text),
placeholder: z.string().optional(),
defaultValue: z.string().optional(),
value: z.string().optional(),
}).describe('TextFilterInput');
export const CheckboxFilterInputSchema = BaseFilterInputSchema.extend({
type: z.literal(InputType.Checkbox),
maxOptions: z.number().optional(),
options: z.array(CheckboxFilterInputOptionSchema),
selected: z.array(z.string()),
}).describe('CheckboxFilterInput');
export const DateRangeFilterInputSchema = BaseFilterInputSchema.extend({
type: z.literal(InputType.DateRange),
start: z.string().optional(),
minStart: z.string().optional(),
maxStart: z.string().optional(),
stop: z.string().optional(),
minStop: z.string().optional(),
maxStop: z.string().optional(),
}).describe('DateRangeFilterInput');
export const FilterInputSchema = z.union([
TextFilterInputSchema,
CheckboxFilterInputSchema,
DateRangeFilterInputSchema,
]);
export const OrderByDirectionSchema = z.enum(['asc', 'desc']);
export const OrderBySchema = z
.object({
by: z.string(),
label: z.string(),
dir: OrderByDirectionSchema.optional(),
})
.describe('OrderBy');
export const FilterSchema = z
.object({
groups: z.array(FilterGroupSchema),
inputs: z.array(FilterInputSchema),
orderBy: z.array(OrderBySchema),
})
.describe('Filter');
export const QueryOrderBySchema = z.object({
by: z.string(),
label: z.string(),
desc: z.boolean(),
selected: z.boolean(),
});
export const QuerySchema = z.object({
filter: z.record(z.any()).default({}),
input: z.record(z.any()).default({}),
orderBy: z.array(QueryOrderBySchema).default([]),
skip: z.number().default(0),
take: z.number().default(25),
});
export type Filter = z.infer<typeof FilterSchema>;
export type FilterGroup = z.infer<typeof FilterGroupSchema>;
export type TextFilterInput = z.infer<typeof TextFilterInputSchema>;
export type CheckboxFilterInput = z.infer<typeof CheckboxFilterInputSchema>;
export type FilterInput = z.infer<typeof FilterInputSchema>;
export type CheckboxFilterInputOption = z.infer<
typeof CheckboxFilterInputOptionSchema
>;
export type DateRangeFilterInput = z.infer<typeof DateRangeFilterInputSchema>;
export type OrderByDirection = z.infer<typeof OrderByDirectionSchema>;
export type Query = z.infer<typeof QuerySchema>;
export type QueryOrderBy = z.infer<typeof QueryOrderBySchema>;

View File

@@ -0,0 +1,44 @@
import { z } from 'zod';
import { InputType } from '../../types';
/**
* Base schema for all filter input types.
* Contains common properties that all filter inputs must have.
*
* @property group - Group identifier that this input belongs to
* @property key - Unique identifier for the input within its group
* @property label - Optional display name for the input
* @property description - Optional detailed explanation of the input
* @property type - The type of input control (Text, Checkbox, DateRange)
*/
export const BaseFilterInputSchema = z
.object({
group: z
.string()
.describe(
'Identifier for the group this filter input belongs to. Used for organizing related filters.',
),
key: z
.string()
.describe(
'Unique identifier for this input within its group. Used as a key in requests and state management.',
),
label: z
.string()
.optional()
.describe('Human-readable display name shown to users in the UI.'),
description: z
.string()
.optional()
.describe(
'Detailed explanation of what this filter does, displayed as helper text in the UI.',
),
type: z
.nativeEnum(InputType)
.describe(
'Determines the type of input control and its behavior (Text, Checkbox, DateRange, etc.).',
),
})
.describe('BaseFilterInput');
export type BaseFilterInput = z.infer<typeof BaseFilterInputSchema>;

View File

@@ -0,0 +1,24 @@
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
>;

View File

@@ -0,0 +1,37 @@
import { z } from 'zod';
import { BaseFilterInputSchema } from './base-filter-input.schema';
import { InputType } from '../../types';
import { CheckboxFilterInputOptionSchema } from './checkbox-filter-input-option.schema';
/**
* Schema for checkbox-based filter inputs that allow users to select from multiple options.
* Extends the BaseFilterInputSchema with checkbox-specific properties.
*
* @property type - Must be InputType.Checkbox
* @property maxOptions - Optional limit on how many options can be selected
* @property options - Array of selectable checkbox options
* @property selected - Array of string values representing the currently selected options
*/
export const CheckboxFilterInputSchema = BaseFilterInputSchema.extend({
type: z
.literal(InputType.Checkbox)
.describe(
'Specifies this as a checkbox input type. Must be InputType.Checkbox.',
),
maxOptions: z
.number()
.optional()
.describe(
'Optional maximum number of options that can be selected simultaneously. If not provided, all options can be selected.',
),
options: z
.array(CheckboxFilterInputOptionSchema)
.describe('List of available checkbox options that users can select from.'),
selected: z
.array(z.string())
.describe(
'Array of values representing which options are currently selected. Each value corresponds to the value property of an option.',
),
}).describe('CheckboxFilterInput');
export type CheckboxFilterInput = z.infer<typeof CheckboxFilterInputSchema>;

View File

@@ -0,0 +1,45 @@
import { z } from 'zod';
import { BaseFilterInputSchema } from './base-filter-input.schema';
import { InputType } from '../../types';
/**
* Schema for date range inputs that allow filtering by a time period.
* Extends BaseFilterInputSchema with date range specific properties.
*
* @property type - Must be InputType.DateRange
* @property start - Optional ISO string representing the start date of the range
* @property stop - Optional ISO string representing the end date of the range
*/
export const DateRangeFilterInputSchema = BaseFilterInputSchema.extend({
type: z
.literal(InputType.DateRange)
.describe(
'Specifies this as a date range input type. Must be InputType.DateRange.',
),
start: z
.string()
.optional()
.describe(
'ISO date string representing the beginning of the date range. Optional if only an end date is needed.',
),
minStart: z
.string()
.optional()
.describe(
'ISO date string representing the minimum start date of the range. Optional if only an end date is needed.',
),
stop: z
.string()
.optional()
.describe(
'ISO date string representing the end of the date range. Optional if only a start date is needed.',
),
maxStop: z
.string()
.optional()
.describe(
'ISO date string representing the maximum end date of the range. Optional if only a start date is needed.',
),
}).describe('DateRangeFilterInput');
export type DateRangeFilterInput = z.infer<typeof DateRangeFilterInputSchema>;

View File

@@ -0,0 +1,33 @@
import { z } from 'zod';
/**
* Schema for filter groups that organize filter inputs into logical sections.
* Groups provide a way to categorize related filters together for better organization.
*
* @property group - Unique identifier for the filter group
* @property label - Optional display name for the filter group in the UI
* @property description - Optional detailed explanation of what this filter group represents
*/
export const FilterGroupSchema = z
.object({
group: z
.string()
.describe(
'Unique identifier for the filter group, used for referencing in code.',
),
label: z
.string()
.optional()
.describe(
'Human-readable name for the filter group displayed in the UI.',
),
description: z
.string()
.optional()
.describe(
"Detailed explanation of the filter group's purpose or contents, may be shown as helper text.",
),
})
.describe('FilterGroup');
export type FilterGroup = z.infer<typeof FilterGroupSchema>;

View File

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

View File

@@ -0,0 +1,34 @@
import { z } from 'zod';
import { FilterGroupSchema } from './filter-group.schema';
import { FilterInputSchema } from './filter-input.schema';
import { OrderByOptionSchema } from './order-by-option.schema';
/**
* Top-level schema representing the complete filter configuration.
* Combines filter groups, input fields, and ordering options into a unified structure.
*
* @property groups - Collection of filter groups for organizing inputs into categories
* @property inputs - All filter input controls available to the user
* @property orderBy - Available sorting options for the filtered results
*/
export const FilterSchema = z
.object({
groups: z
.array(FilterGroupSchema)
.describe(
'Collection of filter groups that organize inputs into logical categories for better user experience.',
),
inputs: z
.array(FilterInputSchema)
.describe(
'Array of all filter input controls available to the user across all groups.',
),
orderBy: z
.array(OrderByOptionSchema)
.describe(
'Available sorting options that users can apply to the filtered results.',
),
})
.describe('Filter');
export type Filter = z.infer<typeof FilterSchema>;

View File

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

View File

@@ -0,0 +1,16 @@
import { z } from 'zod';
/**
* Enum schema for sort directions in ordering operations.
* Provides type-safe options for ascending or descending sorting.
*
* - 'asc': Ascending order (A-Z, 0-9, oldest to newest)
* - 'desc': Descending order (Z-A, 9-0, newest to oldest)
*/
export const OrderByDirectionSchema = z
.enum(['asc', 'desc'])
.describe(
'Direction for sorting operations, either ascending (asc) or descending (desc).',
);
export type OrderByDirection = z.infer<typeof OrderByDirectionSchema>;

View File

@@ -0,0 +1,35 @@
import { z } from 'zod';
import { OrderByDirectionSchema } from './order-by-direction.schema';
/**
* Schema for defining sort options available to the user.
* Each option represents a different field or property that can be used for ordering results.
*
* @property by - Field identifier to sort by (corresponds to a property in the data)
* @property label - Human-readable name for this sort option to display in the UI
* @property dir - Sort direction ('asc' for ascending or 'desc' for descending)
* @property selected - Whether this ordering option is currently active
*/
export const OrderByOptionSchema = z
.object({
by: z
.string()
.describe(
'Field identifier to sort by, matching a property in the data model.',
),
label: z
.string()
.describe(
'Human-readable name for this sort option to display in the UI.',
),
dir: OrderByDirectionSchema.describe(
'Sort direction, either "asc" for ascending or "desc" for descending.',
),
selected: z
.boolean()
.default(false)
.describe('Indicates whether this ordering option is currently active.'),
})
.describe('OrderByOption');
export type OrderByOption = z.infer<typeof OrderByOptionSchema>;

View File

@@ -0,0 +1,33 @@
import { z } from 'zod';
/**
* Schema representing a sorting criterion in a query.
* This defines how results should be ordered when returned from a data source.
*
* @property by - Field identifier to sort by (corresponds to a property in the data)
* @property label - Human-readable name for this sort option
* @property desc - Whether the sort should be in descending order (true) or ascending (false)
* @property selected - Whether this ordering option is currently active
*/
export const QueryOrderBySchema = z.object({
by: z
.string()
.describe(
'Field identifier to sort by, matching a property name in the data model.',
),
label: z
.string()
.describe('Human-readable name for this sort option to display in the UI.'),
desc: z
.boolean()
.describe(
'Sort direction flag: true for descending order (Z-A, newest first), false for ascending (A-Z, oldest first).',
),
selected: z
.boolean()
.describe(
'Indicates whether this ordering option is currently active in the query.',
),
});
export type QueryOrderBy = z.infer<typeof QueryOrderBySchema>;

View File

@@ -0,0 +1,47 @@
import { z } from 'zod';
import { QueryOrderBySchema } from './query-order.schema';
/**
* Schema representing a complete query for retrieving filtered and sorted data.
* This is the core schema used when making requests to APIs or data sources.
*
* @property filter - Record of filter criteria to apply when querying data
* @property input - Record of user input values from filter controls
* @property orderBy - Array of sort criteria to determine result ordering
* @property skip - Number of items to skip (for pagination)
* @property take - Maximum number of items to return (page size)
*/
export const QuerySchema = z.object({
filter: z
.record(z.any())
.default({})
.describe(
'Key-value pairs of filter criteria to apply when querying data. Keys correspond to data properties, values are the filtering constraints.',
),
input: z
.record(z.any())
.default({})
.describe(
'Key-value pairs representing user input from filter controls. Used to store and restore filter state.',
),
orderBy: z
.array(QueryOrderBySchema)
.default([])
.describe(
'Array of sorting criteria that determine how results should be ordered. Applied in sequence for multi-level sorting.',
),
skip: z
.number()
.default(0)
.describe(
'Number of items to skip from the beginning of the result set. Used for implementing pagination.',
),
take: z
.number()
.default(25)
.describe(
'Maximum number of items to return in a single query. Defines the page size for paginated results.',
),
});
export type Query = z.infer<typeof QuerySchema>;

View File

@@ -0,0 +1,38 @@
import { z } from 'zod';
import { BaseFilterInputSchema } from './base-filter-input.schema';
import { InputType } from '../../types';
/**
* Schema for text-based filter inputs that allow free-form text entry.
* Extends BaseFilterInputSchema with text input specific properties.
*
* @property type - Must be InputType.Text
* @property placeholder - Optional hint text to display when the input is empty
* @property defaultValue - Optional initial value to populate the input with
* @property value - Current value of the text input
*/
export const TextFilterInputSchema = BaseFilterInputSchema.extend({
type: z
.literal(InputType.Text)
.describe('Specifies this as a text input type. Must be InputType.Text.'),
placeholder: z
.string()
.optional()
.describe(
'Hint text displayed when the input is empty to guide users on what to enter.',
),
defaultValue: z
.string()
.optional()
.describe(
'Initial value to populate the text field with when first rendered or reset.',
),
value: z
.string()
.optional()
.describe(
'Current value of the text input field, reflecting what the user has entered.',
),
}).describe('TextFilterInput');
export type TextFilterInput = z.infer<typeof TextFilterInputSchema>;

View File

@@ -0,0 +1,256 @@
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()).toThrowError('Input not found for key: test-key');
});
});

View File

@@ -0,0 +1,93 @@
import {
createComponentFactory,
Spectator,
mockProvider,
} from '@ngneat/spectator/jest';
import { MockComponents } from 'ng-mocks';
import { FilterMenuButtonComponent } from './filter-menu-button.component';
import { FilterService } from '../../core';
import { Overlay } from '@angular/cdk/overlay';
import { FilterMenuComponent } from './filter-menu.component';
import { NgIconComponent } from '@ng-icons/core';
import { IconButtonComponent } from '@isa/ui/buttons';
import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
describe('FilterMenuButtonComponent', () => {
let spectator: Spectator<FilterMenuButtonComponent>;
let filterService: jest.Mocked<FilterService>;
const createComponent = createComponentFactory({
component: FilterMenuButtonComponent,
declarations: [
MockComponents(NgIconComponent, FilterMenuComponent, IconButtonComponent),
],
componentProviders: [
mockProvider(Overlay, { scrollStrategies: { block: jest.fn() } }),
],
providers: [
mockProvider(FilterService, {
isDefaultFilter: jest.fn().mockReturnValue(true),
rollback: jest.fn(),
}),
],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
detectChanges: false,
});
beforeEach(() => {
spectator = createComponent({
props: {
rollbackOnClose: false,
},
});
spectator.detectChanges();
filterService = spectator.inject(FilterService);
});
it('should create the component', () => {
expect(spectator.component).toBeTruthy();
});
it('should toggle open state and emit events', () => {
// Arrange
const closedSpy = jest.spyOn(spectator.component.closed, 'emit');
const openedSpy = jest.spyOn(spectator.component.opened, 'emit');
// Act - Open
spectator.component.toggle();
// Assert - Open
expect(spectator.component.open()).toBe(true);
expect(openedSpy).toHaveBeenCalled();
// Act - Close
spectator.component.toggle();
// Assert - Close
expect(spectator.component.open()).toBe(false);
expect(closedSpy).toHaveBeenCalled();
});
it('should rollback on close when rollbackOnClose is true', () => {
// Arrange
spectator.setInput('rollbackOnClose', true);
// Act
spectator.component.closed.emit();
// Assert
expect(filterService.rollback).toHaveBeenCalled();
});
it('should close menu when applied is emitted', () => {
// Arrange
spectator.component.open.set(true);
// Act
spectator.component.applied.emit();
// Assert
expect(spectator.component.open()).toBe(false);
});
});

View File

@@ -2,6 +2,7 @@
<button
class="filter-input-button__filter-button"
[class.open]="open()"
[class.active]="!isDefaultInputState()"
(click)="toggle()"
type="button"
cdkOverlayOrigin

View File

@@ -20,4 +20,12 @@
@apply text-isa-neutral-900;
}
}
&.active {
@apply border-isa-accent-blue;
.filter-input-button__filter-button-label {
@apply text-isa-accent-blue;
}
}
}

View File

@@ -0,0 +1,102 @@
import {
createComponentFactory,
Spectator,
mockProvider,
} from '@ngneat/spectator/jest';
import { MockComponents, MockDirectives } from 'ng-mocks';
import { FilterInputMenuButtonComponent } from './input-menu-button.component';
import { FilterInput, FilterService } from '../../core';
import {
CdkConnectedOverlay,
CdkOverlayOrigin,
Overlay,
} from '@angular/cdk/overlay';
import { FilterInputMenuComponent } from './input-menu.component';
import { NgIconComponent } from '@ng-icons/core';
import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
describe('FilterInputMenuButtonComponent', () => {
let spectator: Spectator<FilterInputMenuButtonComponent>;
const dummyFilterInput: FilterInput = { label: 'Test Filter' } as FilterInput;
let filterService: jest.Mocked<FilterService>;
const createComponent = createComponentFactory({
component: FilterInputMenuButtonComponent,
declarations: [
MockComponents(NgIconComponent, FilterInputMenuComponent),
MockDirectives(CdkOverlayOrigin, CdkConnectedOverlay),
],
componentProviders: [
mockProvider(Overlay, { scrollStrategies: { block: jest.fn() } }),
],
providers: [
mockProvider(FilterService, {
isDefaultFilterInput: jest.fn().mockReturnValue(true),
commit: jest.fn(),
}),
],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
detectChanges: false,
});
beforeEach(() => {
spectator = createComponent({
props: {
filterInput: dummyFilterInput,
commitOnClose: false,
},
});
spectator.detectChanges();
filterService = spectator.inject(FilterService);
});
it('should create the component', () => {
expect(spectator.component).toBeTruthy();
});
it('should toggle open state and emit events', () => {
// Arrange
const closedSpy = jest.spyOn(spectator.component.closed, 'emit');
const openedSpy = jest.spyOn(spectator.component.opened, 'emit');
// Act - Open
spectator.component.toggle();
// Assert - Open
expect(spectator.component.open()).toBe(true);
expect(openedSpy).toHaveBeenCalled();
// Act - Close
spectator.component.toggle();
// Assert - Close
expect(spectator.component.open()).toBe(false);
expect(closedSpy).toHaveBeenCalled();
});
it('should commit on close when commitOnClose is true', () => {
// Arrange
spectator.setInput('commitOnClose', true);
spectator.component.open.set(true);
// Act
spectator.component.toggle();
// Assert
expect(filterService.commit).toHaveBeenCalled();
});
it('should close menu when applied is emitted', () => {
// Arrange
spectator.component.open.set(true);
// Act
spectator.component.applied.emit();
// Assert
expect(spectator.component.open()).toBe(false);
});
});

View File

@@ -1,6 +1,14 @@
import { ChangeDetectionStrategy, Component, inject, input, model, output } from '@angular/core';
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
input,
model,
output,
} from '@angular/core';
import { FilterInput, FilterService } from '../../core';
import { Overlay, OverlayModule } from '@angular/cdk/overlay';
import { Overlay, CdkOverlayOrigin, CdkConnectedOverlay } from '@angular/cdk/overlay';
import { NgIconComponent, provideIcons } from '@ng-icons/core';
import { isaActionChevronDown, isaActionChevronUp } from '@isa/icons';
import { FilterInputMenuComponent } from './input-menu.component';
@@ -8,22 +16,26 @@ import { FilterInputMenuComponent } from './input-menu.component';
/**
* A button component that toggles the visibility of an input menu for filtering.
* It emits events when the menu is opened, closed, reset, or applied.
* @implements {OnInit}
*/
@Component({
selector: 'filter-input-menu-button',
templateUrl: './input-menu-button.component.html',
styleUrls: ['./input-menu-button.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [OverlayModule, NgIconComponent, FilterInputMenuComponent],
imports: [NgIconComponent, FilterInputMenuComponent, CdkOverlayOrigin, CdkConnectedOverlay],
providers: [provideIcons({ isaActionChevronDown, isaActionChevronUp })],
})
export class FilterInputMenuButtonComponent {
/** Strategy for handling scroll behavior when the overlay is open */
scrollStrategy = inject(Overlay).scrollStrategies.block();
/** Filter service for managing filter state */
#filter = inject(FilterService);
/**
* Tracks the open state of the input menu.
* Controls the visibility state of the input menu
* @default false
*/
open = model<boolean>(false);
@@ -57,6 +69,14 @@ export class FilterInputMenuButtonComponent {
*/
commitOnClose = input<boolean>(false);
/**
* Determines whether the current input state is the default state.
*/
isDefaultInputState = computed(() => {
const input = this.filterInput();
return this.#filter.isDefaultFilterInput(input);
});
/**
* Subscribes to the `applied` event to automatically close the menu.
*/
@@ -67,10 +87,11 @@ export class FilterInputMenuButtonComponent {
}
/**
* Toggles the open state of the input menu.
* Emits `opened` or `closed` events based on the new state.
* Toggles the visibility of the input menu.
* Emits appropriate events based on the new state.
* If commitOnClose is true, commits the filter changes when closing.
*/
toggle() {
toggle(): void {
const open = this.open();
this.open.set(!open);

View File

@@ -0,0 +1,61 @@
import { Spectator, createComponentFactory } from '@ngneat/spectator/jest';
import { FilterInputMenuComponent } from './input-menu.component';
import { MockComponent } from 'ng-mocks';
import { FilterActionsComponent } from '../../actions';
import { InputRendererComponent } from '../../inputs/input-renderer';
import { FilterInput } from '../../core';
import { InputType } from '../../types';
describe('FilterInputMenuComponent', () => {
let spectator: Spectator<FilterInputMenuComponent>;
const createComponent = createComponentFactory({
component: FilterInputMenuComponent,
declarations: [MockComponent(FilterActionsComponent), MockComponent(InputRendererComponent)],
detectChanges: false,
});
beforeEach(() => {
spectator = createComponent();
});
it('should create the component', () => {
expect(spectator.component).toBeTruthy();
});
it('should emit reseted event when reset is triggered', () => {
const resetSpy = jest.spyOn(spectator.component.reseted, 'emit');
// Act
spectator.component.reseted.emit();
// Assert
expect(resetSpy).toHaveBeenCalled();
});
it('should emit applied event when apply is triggered', () => {
const applySpy = jest.spyOn(spectator.component.applied, 'emit');
// Act
spectator.component.applied.emit();
// Assert
expect(applySpy).toHaveBeenCalled();
});
it('should render the filter input', () => {
// Arrange
const filterInput: FilterInput = {
key: 'test-key',
group: 'test-group',
type: InputType.Text,
label: 'Test Label',
};
spectator.setInput('filterInput', filterInput);
// Act
spectator.detectChanges();
// Assert
expect(spectator.query(InputRendererComponent)).toBeTruthy();
});
});

View File

@@ -6,13 +6,13 @@
class="flex flex-1 gap-1 items-center text-nowrap"
uiTextButton
type="button"
(click)="toggleOrderBy(orderBy.by)"
(click)="toggleOrderBy(orderBy)"
>
<div>
{{ orderBy.label }}
</div>
@if (orderBy.dir) {
<ng-icon [name]="orderByIcon(orderBy.dir)" size="1.25rem"></ng-icon>
@if (orderBy.currentDir) {
<ng-icon [name]="orderBy.currentDir" size="1.25rem"></ng-icon>
}
</button>
}

View File

@@ -1,17 +1,24 @@
import { ChangeDetectionStrategy, Component, inject, input, output } from '@angular/core';
import { ChangeDetectionStrategy, Component, computed, inject, input, output } from '@angular/core';
import { TextButtonComponent } from '@isa/ui/buttons';
import { ToolbarComponent } from '@isa/ui/toolbar';
import { FilterService, OrderByDirection } from '../core';
import { FilterService, OrderByDirection, OrderByOption } from '../core';
import { NgIconComponent, provideIcons } from '@ng-icons/core';
import { isaSortByDownMedium, isaSortByUpMedium } from '@isa/icons';
type OrderBy = {
by: string;
label: string;
currentDir: OrderByDirection | undefined;
nextDir: OrderByDirection | undefined;
};
@Component({
selector: 'filter-order-by-toolbar',
templateUrl: './order-by-toolbar.component.html',
styleUrls: ['./order-by-toolbar.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ToolbarComponent, TextButtonComponent, NgIconComponent],
providers: [provideIcons({ isaSortByDownMedium, isaSortByUpMedium })],
providers: [provideIcons({ desc: isaSortByDownMedium, asc: isaSortByUpMedium })],
})
export class OrderByToolbarComponent {
#filter = inject(FilterService);
@@ -20,14 +27,46 @@ export class OrderByToolbarComponent {
toggled = output<void>();
orderByOptions = this.#filter.orderBy;
orderByOptions = computed<OrderBy[]>(() => {
const orderByOptions = this.#filter.orderBy();
const selectedOrderBy = orderByOptions.find((o) => o.selected);
const orderByOptionsWithoutDuplicates = orderByOptions.reduce<OrderByOption[]>((acc, curr) => {
const existing = acc.find((o) => o.by === curr.by);
if (!existing) {
return [...acc, curr];
}
return acc;
}, []);
return orderByOptionsWithoutDuplicates.map((o) => {
if (!selectedOrderBy) {
return { by: o.by, label: o.label, currentDir: undefined, nextDir: 'asc' };
}
if (o.by === selectedOrderBy.by) {
return {
by: o.by,
label: o.label,
currentDir: selectedOrderBy.dir,
nextDir: selectedOrderBy.dir === 'asc' ? 'desc' : undefined,
};
}
return { by: o.by, label: o.label, currentDir: undefined, nextDir: 'asc' };
});
});
selectedOrderBy = computed(() => {
const orderByOptions = this.#filter.orderBy();
return orderByOptions.find((o) => o.selected);
});
toggleOrderBy(orderBy: OrderBy) {
this.#filter.setOrderBy(orderBy.by, orderBy.nextDir, {
commit: this.commitOnToggle(),
});
toggleOrderBy(orderBy: string) {
this.#filter.toggleOrderBy(orderBy, { commit: this.commitOnToggle() });
this.toggled.emit();
}
orderByIcon(dir: OrderByDirection) {
return dir === 'asc' ? 'isaSortByDownMedium' : 'isaSortByUpMedium';
}
}