feat: Implement filter input mapping and related schemas

- Added filter input mapping functionality to handle different input types (Text, Checkbox, DateRange).
- Created schemas for various filter inputs including BaseFilterInput, CheckboxFilterInput, DateRangeFilterInput, and TextFilterInput.
- Developed filter mapping logic to aggregate filter groups, inputs, and order by options.
- Implemented unit tests for filter mapping, input mapping, and order by option mapping to ensure correctness.
- Introduced a dropdown component for selecting order by options with appropriate styling and functionality.
This commit is contained in:
Lorenz Hilpert
2025-04-10 19:24:45 +02:00
parent b4caf3a177
commit 3e14426d2e
54 changed files with 1976 additions and 336 deletions

View File

@@ -14,7 +14,7 @@ const meta: Meta<UiButtonComponentInputs> = {
argTypes: {
color: {
control: { type: 'select' },
options: ['primary', 'secondary', 'brand', 'tertiary'] as ButtonColor[],
options: Object.values(ButtonColor),
description: 'Determines the button color',
},
size: {

View File

@@ -15,7 +15,6 @@
- Use `createComponentFactory` for standalone components
- Use `createHostFactory` when testing components with templates
- Mock child components using `ng-mocks`
- Test component inputs, outputs, and lifecycle hooks
- Verify DOM rendering and component behavior separately

View File

@@ -12,25 +12,17 @@
[rollbackOnClose]="true"
></filter-filter-menu-button>
<button uiIconButton *uiBreakpoint="['tablet']" (click)="orderByVisible.set(!orderByVisible())">
<ng-icon name="isaActionSort"></ng-icon>
</button>
<filter-order-by-toolbar
*uiBreakpoint="['desktop', 'dekstop-l', 'dekstop-xl']"
(toggled)="search()"
></filter-order-by-toolbar>
@if (showOrderByToolbar()) {
<filter-order-by-toolbar
*uiBreakpoint="['desktop', 'dekstop-l', 'dekstop-xl']"
(toggled)="search()"
></filter-order-by-toolbar>
} @else {
<filter-order-by-dropdown class="min-w-[9rem]"> </filter-order-by-dropdown>
}
</div>
</div>
@if (orderByVisible()) {
<filter-order-by-toolbar
*uiBreakpoint="['tablet']"
class="w-full"
(toggled)="search()"
></filter-order-by-toolbar>
}
<span class="text-isa-neutral-900 isa-text-body-2-regular self-start">
{{ entityHits() }} Einträge
</span>

View File

@@ -14,17 +14,19 @@ import {
FilterService,
SearchBarInputComponent,
OrderByToolbarComponent,
OrderByDropdownComponent,
} from '@isa/shared/filter';
import { IconButtonComponent } from '@isa/ui/buttons';
import { EmptyStateComponent } from '@isa/ui/empty-state';
import { NgIconComponent, provideIcons } from '@ng-icons/core';
import { provideIcons } from '@ng-icons/core';
import { isaActionSort } from '@isa/icons';
import { ReceiptListItem, ReturnSearchStatus, ReturnSearchStore } from '@isa/oms/data-access';
import { ReturnSearchResultItemComponent } from './return-search-result-item/return-search-result-item.component';
import { BreakpointDirective, InViewportDirective } from '@isa/ui/layout';
import { Breakpoint, BreakpointDirective, InViewportDirective } from '@isa/ui/layout';
import { CallbackResult, ListResponseArgs } from '@isa/common/result';
import { injectRestoreScrollPosition } from '@isa/utils/scroll-position';
import { breakpoint } from '@isa/ui/layout';
@Component({
selector: 'oms-feature-return-search-result',
@@ -35,10 +37,10 @@ import { injectRestoreScrollPosition } from '@isa/utils/scroll-position';
RouterLink,
ReturnSearchResultItemComponent,
OrderByToolbarComponent,
OrderByDropdownComponent,
IconButtonComponent,
SearchBarInputComponent,
EmptyStateComponent,
NgIconComponent,
FilterMenuButtonComponent,
BreakpointDirective,
InViewportDirective,
@@ -46,6 +48,8 @@ import { injectRestoreScrollPosition } from '@isa/utils/scroll-position';
providers: [provideIcons({ isaActionSort })],
})
export class ReturnSearchResultComponent implements AfterViewInit {
showOrderByToolbar = breakpoint([Breakpoint.DekstopL, Breakpoint.DekstopXL]);
#route = inject(ActivatedRoute);
#router = inject(Router);
#filterService = inject(FilterService);
@@ -55,8 +59,6 @@ export class ReturnSearchResultComponent implements AfterViewInit {
processId = injectActivatedProcessId();
returnSearchStore = inject(ReturnSearchStore);
orderByVisible = signal(false);
ReturnSearchStatus = ReturnSearchStatus;
entity = computed(() => {

View File

@@ -1,9 +1,15 @@
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, OrderByDirectionSchema, Query, QuerySchema } from './schemas';
import {
FilterInput,
OrderByDirection,
OrderByDirectionSchema,
Query,
QuerySchema,
} from './schemas';
import { FILTER_ON_COMMIT, FILTER_ON_INIT, QUERY_SETTINGS } from './tokens';
@Injectable()
@@ -13,7 +19,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));
@@ -31,15 +37,12 @@ export class FilterService {
});
}
setOrderBy(
orderBy: { by: string; dir: 'asc' | 'desc' | undefined },
options?: { commit: boolean },
) {
setOrderBy(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 });
@@ -49,29 +52,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, options?: { commit: boolean }): void {
const inputs = this.#state.inputs().map((input) => {
if (input.key !== key) {
@@ -242,12 +222,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 }) {
@@ -276,7 +256,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();
}
@@ -299,7 +279,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) {
@@ -326,13 +306,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();
@@ -358,7 +333,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}`;
@@ -407,26 +382,30 @@ 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,
};
}),
});
});
parseQueryParams(params: Record<string, string>, 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,118 +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,
stop: input.options?.values?.[1].value,
});
}

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,19 @@
import { Input, InputType } from '../../types';
import { CheckboxFilterInput, CheckboxFilterInputSchema } from '../schemas';
import { checkboxOptionMapping } from './checkbox-option.mapping';
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,9 @@
import { CheckboxFilterInputOption, CheckboxFilterInputOptionSchema } from '../schemas';
import { Option } from '../../types';
export function checkboxOptionMapping(option: Option): CheckboxFilterInputOption {
return CheckboxFilterInputOptionSchema.parse({
label: option.label,
value: option.value,
});
}

View File

@@ -0,0 +1,128 @@
import { Input, InputType } from '../../types';
import { dateRangeFilterInputMapping } from './data-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,
});
});
});

View File

@@ -0,0 +1,14 @@
import { Input, InputType } from '../../types';
import { DateRangeFilterInput, DateRangeFilterInputSchema } from '../schemas';
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,
});
}

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,10 @@
import { InputGroup } from '../../types';
import { FilterGroup, FilterGroupSchema } from '../schemas';
export function filterGroupMapping(group: InputGroup): FilterGroup {
return FilterGroupSchema.parse({
group: group.group,
label: group.label,
description: group.description,
});
}

View File

@@ -0,0 +1,132 @@
import { Input, InputType } from '../../types';
import { filterInputMapping } from './filter-input.mapping';
import * as checkboxFilterInputMappingModule from './checkbox-filter-input.mapping';
import * as dateRangeFilterInputMappingModule from './data-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,17 @@
import { Input, InputType } from '../../types';
import { FilterInput } from '../schemas';
import { checkboxFilterInputMapping } from './checkbox-filter-input.mapping';
import { dateRangeFilterInputMapping } from './data-range-filter-input.mapping';
import { textFilterInputMapping } from './text-filter-input.mapping';
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,31 @@
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';
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 './data-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,11 @@
import { OrderBy } from '../../types';
import { OrderByOption, OrderByOptionSchema } from '../schemas';
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,15 @@
import { Input, InputType } from '../../types';
import { TextFilterInput, TextFilterInputSchema } from '../schemas';
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,106 +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(),
stop: 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,14 @@
import { z } from 'zod';
import { InputType } from '../../types';
export 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 type BaseFilterInput = z.infer<typeof BaseFilterInputSchema>;

View File

@@ -0,0 +1,10 @@
import { z } from 'zod';
export const CheckboxFilterInputOptionSchema = z
.object({
label: z.string(),
value: z.string(),
})
.describe('CheckboxFilterInputOption');
export type CheckboxFilterInputOption = z.infer<typeof CheckboxFilterInputOptionSchema>;

View File

@@ -0,0 +1,13 @@
import { z } from 'zod';
import { BaseFilterInputSchema } from './base-filter-input.schema';
import { InputType } from '../../types';
import { CheckboxFilterInputOptionSchema } from './checkbox-filter-input-option.schema';
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 type CheckboxFilterInput = z.infer<typeof CheckboxFilterInputSchema>;

View File

@@ -0,0 +1,11 @@
import { z } from 'zod';
import { BaseFilterInputSchema } from './base-filter-input.schema';
import { InputType } from '../../types';
export const DateRangeFilterInputSchema = BaseFilterInputSchema.extend({
type: z.literal(InputType.DateRange),
start: z.string().optional(),
stop: z.string().optional(),
}).describe('DateRangeFilterInput');
export type DateRangeFilterInput = z.infer<typeof DateRangeFilterInputSchema>;

View File

@@ -0,0 +1,11 @@
import { z } from 'zod';
export const FilterGroupSchema = z
.object({
group: z.string(),
label: z.string().optional(),
description: z.string().optional(),
})
.describe('FilterGroup');
export type FilterGroup = z.infer<typeof FilterGroupSchema>;

View File

@@ -0,0 +1,12 @@
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';
export const FilterInputSchema = z.union([
TextFilterInputSchema,
CheckboxFilterInputSchema,
DateRangeFilterInputSchema,
]);
export type FilterInput = z.infer<typeof FilterInputSchema>;

View File

@@ -0,0 +1,14 @@
import { z } from 'zod';
import { FilterGroupSchema } from './filter-group.schema';
import { FilterInputSchema } from './filter-input.schema';
import { OrderByOptionSchema } from './order-by-option.schema';
export const FilterSchema = z
.object({
groups: z.array(FilterGroupSchema),
inputs: z.array(FilterInputSchema),
orderBy: z.array(OrderByOptionSchema),
})
.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,5 @@
import { z } from 'zod';
export const OrderByDirectionSchema = z.enum(['asc', 'desc']);
export type OrderByDirection = z.infer<typeof OrderByDirectionSchema>;

View File

@@ -0,0 +1,13 @@
import { z } from 'zod';
import { OrderByDirectionSchema } from './order-by-direction.schema';
export const OrderByOptionSchema = z
.object({
by: z.string(),
label: z.string(),
dir: OrderByDirectionSchema,
selected: z.boolean().default(false),
})
.describe('OrderByOption');
export type OrderByOption = z.infer<typeof OrderByOptionSchema>;

View File

@@ -0,0 +1,10 @@
import { z } from 'zod';
export const QueryOrderBySchema = z.object({
by: z.string(),
label: z.string(),
desc: z.boolean(),
selected: z.boolean(),
});
export type QueryOrderBy = z.infer<typeof QueryOrderBySchema>;

View File

@@ -0,0 +1,12 @@
import { z } from 'zod';
import { QueryOrderBySchema } from './query-order.schema';
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 Query = z.infer<typeof QuerySchema>;

View File

@@ -0,0 +1,12 @@
import { z } from 'zod';
import { BaseFilterInputSchema } from './base-filter-input.schema';
import { InputType } from '../../types';
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 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

@@ -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,90 @@
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';
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(),
}),
],
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

@@ -1 +1,2 @@
export * from './order-by-toolbar.component';
export * from './order-by-dropdown.component';

View File

@@ -0,0 +1,18 @@
<ui-dropdown
class="w-full"
label="Sortieren nach"
appearance="grey"
[ngModel]="selectedOrderBy()"
(ngModelChange)="setOrderBy($event)"
[showSelectedValue]="false"
[class.active]="selectedOrderBy()"
>
@for (option of orderByOptions(); track option.by + option.dir) {
<ui-dropdown-option [value]="option">
<div>{{ option.label }}</div>
<div>
<ng-icon [name]="option.dir" size="1.25rem"></ng-icon>
</div>
</ui-dropdown-option>
}
</ui-dropdown>

View File

@@ -0,0 +1,3 @@
:host {
@apply inline-flex;
}

View File

@@ -0,0 +1,26 @@
import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core';
import { DropdownButtonComponent, DropdownOptionComponent } from '@isa/ui/input-controls';
import { FilterService, OrderByOption } from '../core';
import { FormsModule } from '@angular/forms';
import { NgIconComponent, provideIcons } from '@ng-icons/core';
import { isaSortByDownMedium, isaSortByUpMedium } from '@isa/icons';
@Component({
selector: 'filter-order-by-dropdown',
templateUrl: './order-by-dropdown.component.html',
styleUrls: ['./order-by-dropdown.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [DropdownButtonComponent, DropdownOptionComponent, FormsModule, NgIconComponent],
providers: [provideIcons({ desc: isaSortByDownMedium, asc: isaSortByUpMedium })],
})
export class OrderByDropdownComponent {
#filter = inject(FilterService);
orderByOptions = this.#filter.orderBy;
selectedOrderBy = computed(() => this.orderByOptions().find((o) => o.selected));
setOrderBy(option: OrderByOption) {
this.#filter.setOrderBy(option.by, option.dir);
}
}

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';
}
}

View File

@@ -1,4 +1,4 @@
.ui-dropdown.ui-dropdown__button {
.ui-dropdown {
display: inline-flex;
height: 3rem;
padding: 0rem 1.5rem;
@@ -6,13 +6,16 @@
gap: 0.5rem;
flex-shrink: 0;
cursor: pointer;
border-radius: 3.125rem;
@apply text-isa-accent-blue isa-text-body-2-bold border border-solid border-isa-accent-blue;
justify-content: space-between;
ng-icon {
@apply size-6;
}
}
.ui-dropdown__accent-outline {
@apply text-isa-accent-blue isa-text-body-2-bold border border-solid border-isa-accent-blue;
&:hover {
@apply bg-isa-neutral-100 border-isa-secondary-700;
@@ -27,6 +30,22 @@
}
}
.ui-dropdown__grey {
@apply text-isa-neutral-600 isa-text-body-2-bold bg-isa-neutral-400 border border-solid border-isa-neutral-400;
&:hover {
@apply bg-isa-neutral-500;
}
&:active {
@apply border-isa-accent-blue text-isa-accent-blue bg-isa-white;
}
&.open {
@apply border-isa-neutral-900 text-isa-neutral-900;
}
}
.ui-dropdown__options {
// Fixed typo from ui-dorpdown__options
display: inline-flex;
@@ -43,10 +62,11 @@
width: 10rem;
height: 3rem;
padding: 0rem 1.5rem;
flex-direction: column;
justify-content: center;
flex-direction: row;
justify-content: space-between;
align-items: center;
gap: 0.625rem;
border-radius: 0.5rem;
border-radius: 1rem;
word-wrap: none;
white-space: nowrap;
cursor: pointer;

View File

@@ -10,10 +10,9 @@ import {
input,
model,
signal,
ViewEncapsulation,
} from '@angular/core';
import { ControlValueAccessor } from '@angular/forms';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
import { NgIconComponent, provideIcons } from '@ng-icons/core';
import { isaActionChevronUp, isaActionChevronDown } from '@isa/icons';
import { ActiveDescendantKeyManager, Highlightable } from '@angular/cdk/a11y';
@@ -74,7 +73,10 @@ export class DropdownOptionComponent<T> implements Highlightable {
changeDetection: ChangeDetectionStrategy.OnPush,
hostDirectives: [CdkOverlayOrigin],
imports: [NgIconComponent, CdkConnectedOverlay],
providers: [provideIcons({ isaActionChevronUp, isaActionChevronDown })],
providers: [
provideIcons({ isaActionChevronUp, isaActionChevronDown }),
{ provide: NG_VALUE_ACCESSOR, useExisting: DropdownButtonComponent, multi: true },
],
host: {
'[class]': '["ui-dropdown", appearanceClass(), isOpenClass()]',
'role': 'combobox',
@@ -96,7 +98,7 @@ export class DropdownButtonComponent<T> implements ControlValueAccessor, AfterVi
return this.elementRef.nativeElement.offsetWidth;
}
appearance = input<DropdownAppearance>(DropdownAppearance.Button);
appearance = input<DropdownAppearance>(DropdownAppearance.AccentOutline);
appearanceClass = computed(() => `ui-dropdown__${this.appearance()}`);
@@ -110,6 +112,8 @@ export class DropdownButtonComponent<T> implements ControlValueAccessor, AfterVi
disabled = model<boolean>(false);
showSelectedValue = input<boolean>(true);
options = contentChildren(DropdownOptionComponent);
cdkOverlayOrigin = inject(CdkOverlayOrigin, { self: true });
@@ -136,6 +140,10 @@ export class DropdownButtonComponent<T> implements ControlValueAccessor, AfterVi
isOpenIcon = computed(() => (this.isOpen() ? 'isaActionChevronUp' : 'isaActionChevronDown'));
viewLabel = computed(() => {
if (!this.showSelectedValue()) {
return this.label() ?? this.value();
}
const selectedOption = this.selectedOption();
if (!selectedOption) {

View File

@@ -1,5 +1,6 @@
export const DropdownAppearance = {
Button: 'button',
AccentOutline: 'accent-outline',
Grey: 'grey',
} as const;
export type DropdownAppearance = (typeof DropdownAppearance)[keyof typeof DropdownAppearance];