From 3e14426d2ea481c174dca7ef7f7d46e23787e52f Mon Sep 17 00:00:00 2001 From: Lorenz Hilpert Date: Thu, 10 Apr 2025 19:24:45 +0200 Subject: [PATCH] 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. --- .../stories/ui/buttons/ui-button.stories.ts | 2 +- docs/guidelines/testing.md | 1 - .../return-search-result.component.html | 24 +- .../return-search-result.component.ts | 12 +- .../filter/src/lib/core/filter.service.ts | 95 +++---- libs/shared/filter/src/lib/core/mappings.ts | 118 -------- .../checkbox-filter-input.mapping.spec.ts | 220 +++++++++++++++ .../mappings/checkbox-filter-input.mapping.ts | 19 ++ .../mappings/checkbox-option.mapping.spec.ts | 63 +++++ .../core/mappings/checkbox-option.mapping.ts | 9 + .../data-range-filter-input.mapping.spec.ts | 128 +++++++++ .../data-range-filter-input.mapping.ts | 14 + .../mappings/filter-group.mapping.spec.ts | 111 ++++++++ .../lib/core/mappings/filter-group.mapping.ts | 10 + .../mappings/filter-input.mapping.spec.ts | 132 +++++++++ .../lib/core/mappings/filter-input.mapping.ts | 17 ++ .../lib/core/mappings/filter.mapping.spec.ts | 245 +++++++++++++++++ .../src/lib/core/mappings/filter.mapping.ts | 31 +++ .../filter/src/lib/core/mappings/index.ts | 8 + .../mappings/order-by-option.mapping.spec.ts | 68 +++++ .../core/mappings/order-by-option.mapping.ts | 11 + .../text-filter-input.mapping.spec.ts | 89 ++++++ .../mappings/text-filter-input.mapping.ts | 15 + libs/shared/filter/src/lib/core/schemas.ts | 106 -------- .../core/schemas/base-filter-input.schema.ts | 14 + .../checkbox-filter-input-option.schema.ts | 10 + .../schemas/checkbox-filter-input.schema.ts | 13 + .../schemas/date-range-filter-input.schema.ts | 11 + .../lib/core/schemas/filter-group.schema.ts | 11 + .../lib/core/schemas/filter-input.schema.ts | 12 + .../src/lib/core/schemas/filter.schema.ts | 14 + .../filter/src/lib/core/schemas/index.ts | 12 + .../core/schemas/order-by-direction.schema.ts | 5 + .../core/schemas/order-by-option.schema.ts | 13 + .../lib/core/schemas/query-order.schema.ts | 10 + .../src/lib/core/schemas/query.schema.ts | 12 + .../core/schemas/text-filter-input.schema.ts | 12 + .../checkbox-input.component.spec.ts | 256 ++++++++++++++++++ .../filter-menu-button.component.spec.ts | 0 .../filter-menu/filter-menu.component.spec.ts | 0 .../input-menu-button.component.html | 1 + .../input-menu-button.component.scss | 8 + .../input-menu-button.component.spec.ts | 90 ++++++ .../input-menu/input-menu-button.component.ts | 35 ++- .../input-menu/input-menu.component.spec.ts | 61 +++++ libs/shared/filter/src/lib/order-by/index.ts | 1 + .../order-by/order-by-dropdown.component.html | 18 ++ .../order-by/order-by-dropdown.component.scss | 3 + .../order-by/order-by-dropdown.component.ts | 26 ++ .../order-by/order-by-toolbar.component.html | 6 +- .../order-by/order-by-toolbar.component.ts | 59 +++- .../src/lib/dropdown/_dropdown.scss | 32 ++- .../src/lib/dropdown/dropdown.component.ts | 16 +- .../src/lib/dropdown/dropdown.types.ts | 3 +- 54 files changed, 1976 insertions(+), 336 deletions(-) delete mode 100644 libs/shared/filter/src/lib/core/mappings.ts create mode 100644 libs/shared/filter/src/lib/core/mappings/checkbox-filter-input.mapping.spec.ts create mode 100644 libs/shared/filter/src/lib/core/mappings/checkbox-filter-input.mapping.ts create mode 100644 libs/shared/filter/src/lib/core/mappings/checkbox-option.mapping.spec.ts create mode 100644 libs/shared/filter/src/lib/core/mappings/checkbox-option.mapping.ts create mode 100644 libs/shared/filter/src/lib/core/mappings/data-range-filter-input.mapping.spec.ts create mode 100644 libs/shared/filter/src/lib/core/mappings/data-range-filter-input.mapping.ts create mode 100644 libs/shared/filter/src/lib/core/mappings/filter-group.mapping.spec.ts create mode 100644 libs/shared/filter/src/lib/core/mappings/filter-group.mapping.ts create mode 100644 libs/shared/filter/src/lib/core/mappings/filter-input.mapping.spec.ts create mode 100644 libs/shared/filter/src/lib/core/mappings/filter-input.mapping.ts create mode 100644 libs/shared/filter/src/lib/core/mappings/filter.mapping.spec.ts create mode 100644 libs/shared/filter/src/lib/core/mappings/filter.mapping.ts create mode 100644 libs/shared/filter/src/lib/core/mappings/index.ts create mode 100644 libs/shared/filter/src/lib/core/mappings/order-by-option.mapping.spec.ts create mode 100644 libs/shared/filter/src/lib/core/mappings/order-by-option.mapping.ts create mode 100644 libs/shared/filter/src/lib/core/mappings/text-filter-input.mapping.spec.ts create mode 100644 libs/shared/filter/src/lib/core/mappings/text-filter-input.mapping.ts delete mode 100644 libs/shared/filter/src/lib/core/schemas.ts create mode 100644 libs/shared/filter/src/lib/core/schemas/base-filter-input.schema.ts create mode 100644 libs/shared/filter/src/lib/core/schemas/checkbox-filter-input-option.schema.ts create mode 100644 libs/shared/filter/src/lib/core/schemas/checkbox-filter-input.schema.ts create mode 100644 libs/shared/filter/src/lib/core/schemas/date-range-filter-input.schema.ts create mode 100644 libs/shared/filter/src/lib/core/schemas/filter-group.schema.ts create mode 100644 libs/shared/filter/src/lib/core/schemas/filter-input.schema.ts create mode 100644 libs/shared/filter/src/lib/core/schemas/filter.schema.ts create mode 100644 libs/shared/filter/src/lib/core/schemas/index.ts create mode 100644 libs/shared/filter/src/lib/core/schemas/order-by-direction.schema.ts create mode 100644 libs/shared/filter/src/lib/core/schemas/order-by-option.schema.ts create mode 100644 libs/shared/filter/src/lib/core/schemas/query-order.schema.ts create mode 100644 libs/shared/filter/src/lib/core/schemas/query.schema.ts create mode 100644 libs/shared/filter/src/lib/core/schemas/text-filter-input.schema.ts create mode 100644 libs/shared/filter/src/lib/inputs/checkbox-input/checkbox-input.component.spec.ts create mode 100644 libs/shared/filter/src/lib/menus/filter-menu/filter-menu-button.component.spec.ts create mode 100644 libs/shared/filter/src/lib/menus/filter-menu/filter-menu.component.spec.ts create mode 100644 libs/shared/filter/src/lib/menus/input-menu/input-menu-button.component.spec.ts create mode 100644 libs/shared/filter/src/lib/menus/input-menu/input-menu.component.spec.ts create mode 100644 libs/shared/filter/src/lib/order-by/order-by-dropdown.component.html create mode 100644 libs/shared/filter/src/lib/order-by/order-by-dropdown.component.scss create mode 100644 libs/shared/filter/src/lib/order-by/order-by-dropdown.component.ts diff --git a/apps/isa-app/stories/ui/buttons/ui-button.stories.ts b/apps/isa-app/stories/ui/buttons/ui-button.stories.ts index 679f0e7ab..9269e768c 100644 --- a/apps/isa-app/stories/ui/buttons/ui-button.stories.ts +++ b/apps/isa-app/stories/ui/buttons/ui-button.stories.ts @@ -14,7 +14,7 @@ const meta: Meta = { argTypes: { color: { control: { type: 'select' }, - options: ['primary', 'secondary', 'brand', 'tertiary'] as ButtonColor[], + options: Object.values(ButtonColor), description: 'Determines the button color', }, size: { diff --git a/docs/guidelines/testing.md b/docs/guidelines/testing.md index d31e97eb8..1dd0e665c 100644 --- a/docs/guidelines/testing.md +++ b/docs/guidelines/testing.md @@ -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 diff --git a/libs/oms/feature/return-search/src/lib/return-search-result/return-search-result.component.html b/libs/oms/feature/return-search/src/lib/return-search-result/return-search-result.component.html index f4b3c23c7..1cd2e49d9 100644 --- a/libs/oms/feature/return-search/src/lib/return-search-result/return-search-result.component.html +++ b/libs/oms/feature/return-search/src/lib/return-search-result/return-search-result.component.html @@ -12,25 +12,17 @@ [rollbackOnClose]="true" > - - - + @if (showOrderByToolbar()) { + + } @else { + + } -@if (orderByVisible()) { - -} - {{ entityHits() }} Einträge diff --git a/libs/oms/feature/return-search/src/lib/return-search-result/return-search-result.component.ts b/libs/oms/feature/return-search/src/lib/return-search-result/return-search-result.component.ts index 5b2679ef3..aaf83f2ab 100644 --- a/libs/oms/feature/return-search/src/lib/return-search-result/return-search-result.component.ts +++ b/libs/oms/feature/return-search/src/lib/return-search-result/return-search-result.component.ts @@ -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(() => { diff --git a/libs/shared/filter/src/lib/core/filter.service.ts b/libs/shared/filter/src/lib/core/filter.service.ts index 318da64f2..163852cdf 100644 --- a/libs/shared/filter/src/lib/core/filter.service.ts +++ b/libs/shared/filter/src/lib/core/filter.service.ts @@ -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, 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; } diff --git a/libs/shared/filter/src/lib/core/mappings.ts b/libs/shared/filter/src/lib/core/mappings.ts deleted file mode 100644 index 806bf67bd..000000000 --- a/libs/shared/filter/src/lib/core/mappings.ts +++ /dev/null @@ -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(); - 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, - }); -} diff --git a/libs/shared/filter/src/lib/core/mappings/checkbox-filter-input.mapping.spec.ts b/libs/shared/filter/src/lib/core/mappings/checkbox-filter-input.mapping.spec.ts new file mode 100644 index 000000000..506ce4af4 --- /dev/null +++ b/libs/shared/filter/src/lib/core/mappings/checkbox-filter-input.mapping.spec.ts @@ -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(); + }); +}); diff --git a/libs/shared/filter/src/lib/core/mappings/checkbox-filter-input.mapping.ts b/libs/shared/filter/src/lib/core/mappings/checkbox-filter-input.mapping.ts new file mode 100644 index 000000000..28aa43dd2 --- /dev/null +++ b/libs/shared/filter/src/lib/core/mappings/checkbox-filter-input.mapping.ts @@ -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) || + [], + }); +} diff --git a/libs/shared/filter/src/lib/core/mappings/checkbox-option.mapping.spec.ts b/libs/shared/filter/src/lib/core/mappings/checkbox-option.mapping.spec.ts new file mode 100644 index 000000000..f60723995 --- /dev/null +++ b/libs/shared/filter/src/lib/core/mappings/checkbox-option.mapping.spec.ts @@ -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'); + }); +}); diff --git a/libs/shared/filter/src/lib/core/mappings/checkbox-option.mapping.ts b/libs/shared/filter/src/lib/core/mappings/checkbox-option.mapping.ts new file mode 100644 index 000000000..ab182154f --- /dev/null +++ b/libs/shared/filter/src/lib/core/mappings/checkbox-option.mapping.ts @@ -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, + }); +} diff --git a/libs/shared/filter/src/lib/core/mappings/data-range-filter-input.mapping.spec.ts b/libs/shared/filter/src/lib/core/mappings/data-range-filter-input.mapping.spec.ts new file mode 100644 index 000000000..c7a43187e --- /dev/null +++ b/libs/shared/filter/src/lib/core/mappings/data-range-filter-input.mapping.spec.ts @@ -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, + }); + }); +}); diff --git a/libs/shared/filter/src/lib/core/mappings/data-range-filter-input.mapping.ts b/libs/shared/filter/src/lib/core/mappings/data-range-filter-input.mapping.ts new file mode 100644 index 000000000..a3a44ee99 --- /dev/null +++ b/libs/shared/filter/src/lib/core/mappings/data-range-filter-input.mapping.ts @@ -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, + }); +} diff --git a/libs/shared/filter/src/lib/core/mappings/filter-group.mapping.spec.ts b/libs/shared/filter/src/lib/core/mappings/filter-group.mapping.spec.ts new file mode 100644 index 000000000..610e4b3c9 --- /dev/null +++ b/libs/shared/filter/src/lib/core/mappings/filter-group.mapping.spec.ts @@ -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); + }); +}); diff --git a/libs/shared/filter/src/lib/core/mappings/filter-group.mapping.ts b/libs/shared/filter/src/lib/core/mappings/filter-group.mapping.ts new file mode 100644 index 000000000..2ecf358a8 --- /dev/null +++ b/libs/shared/filter/src/lib/core/mappings/filter-group.mapping.ts @@ -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, + }); +} diff --git a/libs/shared/filter/src/lib/core/mappings/filter-input.mapping.spec.ts b/libs/shared/filter/src/lib/core/mappings/filter-input.mapping.spec.ts new file mode 100644 index 000000000..46768aaf0 --- /dev/null +++ b/libs/shared/filter/src/lib/core/mappings/filter-input.mapping.spec.ts @@ -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(); + }); +}); diff --git a/libs/shared/filter/src/lib/core/mappings/filter-input.mapping.ts b/libs/shared/filter/src/lib/core/mappings/filter-input.mapping.ts new file mode 100644 index 000000000..e8dd789cf --- /dev/null +++ b/libs/shared/filter/src/lib/core/mappings/filter-input.mapping.ts @@ -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}`); +} diff --git a/libs/shared/filter/src/lib/core/mappings/filter.mapping.spec.ts b/libs/shared/filter/src/lib/core/mappings/filter.mapping.spec.ts new file mode 100644 index 000000000..f05dbfd95 --- /dev/null +++ b/libs/shared/filter/src/lib/core/mappings/filter.mapping.spec.ts @@ -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' }], + }); + }); +}); diff --git a/libs/shared/filter/src/lib/core/mappings/filter.mapping.ts b/libs/shared/filter/src/lib/core/mappings/filter.mapping.ts new file mode 100644 index 000000000..cdd9b32d6 --- /dev/null +++ b/libs/shared/filter/src/lib/core/mappings/filter.mapping.ts @@ -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; +} diff --git a/libs/shared/filter/src/lib/core/mappings/index.ts b/libs/shared/filter/src/lib/core/mappings/index.ts new file mode 100644 index 000000000..2ca2d54a5 --- /dev/null +++ b/libs/shared/filter/src/lib/core/mappings/index.ts @@ -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'; diff --git a/libs/shared/filter/src/lib/core/mappings/order-by-option.mapping.spec.ts b/libs/shared/filter/src/lib/core/mappings/order-by-option.mapping.spec.ts new file mode 100644 index 000000000..22b01413f --- /dev/null +++ b/libs/shared/filter/src/lib/core/mappings/order-by-option.mapping.spec.ts @@ -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, + }); + }); +}); diff --git a/libs/shared/filter/src/lib/core/mappings/order-by-option.mapping.ts b/libs/shared/filter/src/lib/core/mappings/order-by-option.mapping.ts new file mode 100644 index 000000000..b44e6e588 --- /dev/null +++ b/libs/shared/filter/src/lib/core/mappings/order-by-option.mapping.ts @@ -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, + }); +} diff --git a/libs/shared/filter/src/lib/core/mappings/text-filter-input.mapping.spec.ts b/libs/shared/filter/src/lib/core/mappings/text-filter-input.mapping.spec.ts new file mode 100644 index 000000000..435598fc4 --- /dev/null +++ b/libs/shared/filter/src/lib/core/mappings/text-filter-input.mapping.spec.ts @@ -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...', + }); + }); +}); diff --git a/libs/shared/filter/src/lib/core/mappings/text-filter-input.mapping.ts b/libs/shared/filter/src/lib/core/mappings/text-filter-input.mapping.ts new file mode 100644 index 000000000..bdbccb87a --- /dev/null +++ b/libs/shared/filter/src/lib/core/mappings/text-filter-input.mapping.ts @@ -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, + }); +} diff --git a/libs/shared/filter/src/lib/core/schemas.ts b/libs/shared/filter/src/lib/core/schemas.ts deleted file mode 100644 index 8e50228d3..000000000 --- a/libs/shared/filter/src/lib/core/schemas.ts +++ /dev/null @@ -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; - -export type FilterGroup = z.infer; - -export type TextFilterInput = z.infer; - -export type CheckboxFilterInput = z.infer; - -export type FilterInput = z.infer; - -export type CheckboxFilterInputOption = z.infer; - -export type DateRangeFilterInput = z.infer; - -export type OrderByDirection = z.infer; - -export type Query = z.infer; - -export type QueryOrderBy = z.infer; diff --git a/libs/shared/filter/src/lib/core/schemas/base-filter-input.schema.ts b/libs/shared/filter/src/lib/core/schemas/base-filter-input.schema.ts new file mode 100644 index 000000000..de8f5645f --- /dev/null +++ b/libs/shared/filter/src/lib/core/schemas/base-filter-input.schema.ts @@ -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; diff --git a/libs/shared/filter/src/lib/core/schemas/checkbox-filter-input-option.schema.ts b/libs/shared/filter/src/lib/core/schemas/checkbox-filter-input-option.schema.ts new file mode 100644 index 000000000..6f3e92089 --- /dev/null +++ b/libs/shared/filter/src/lib/core/schemas/checkbox-filter-input-option.schema.ts @@ -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; diff --git a/libs/shared/filter/src/lib/core/schemas/checkbox-filter-input.schema.ts b/libs/shared/filter/src/lib/core/schemas/checkbox-filter-input.schema.ts new file mode 100644 index 000000000..17bf9f12f --- /dev/null +++ b/libs/shared/filter/src/lib/core/schemas/checkbox-filter-input.schema.ts @@ -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; diff --git a/libs/shared/filter/src/lib/core/schemas/date-range-filter-input.schema.ts b/libs/shared/filter/src/lib/core/schemas/date-range-filter-input.schema.ts new file mode 100644 index 000000000..4f2812cdd --- /dev/null +++ b/libs/shared/filter/src/lib/core/schemas/date-range-filter-input.schema.ts @@ -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; diff --git a/libs/shared/filter/src/lib/core/schemas/filter-group.schema.ts b/libs/shared/filter/src/lib/core/schemas/filter-group.schema.ts new file mode 100644 index 000000000..221c2c856 --- /dev/null +++ b/libs/shared/filter/src/lib/core/schemas/filter-group.schema.ts @@ -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; diff --git a/libs/shared/filter/src/lib/core/schemas/filter-input.schema.ts b/libs/shared/filter/src/lib/core/schemas/filter-input.schema.ts new file mode 100644 index 000000000..e70f05421 --- /dev/null +++ b/libs/shared/filter/src/lib/core/schemas/filter-input.schema.ts @@ -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; diff --git a/libs/shared/filter/src/lib/core/schemas/filter.schema.ts b/libs/shared/filter/src/lib/core/schemas/filter.schema.ts new file mode 100644 index 000000000..ed333fa0d --- /dev/null +++ b/libs/shared/filter/src/lib/core/schemas/filter.schema.ts @@ -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; diff --git a/libs/shared/filter/src/lib/core/schemas/index.ts b/libs/shared/filter/src/lib/core/schemas/index.ts new file mode 100644 index 000000000..e3af7688b --- /dev/null +++ b/libs/shared/filter/src/lib/core/schemas/index.ts @@ -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'; diff --git a/libs/shared/filter/src/lib/core/schemas/order-by-direction.schema.ts b/libs/shared/filter/src/lib/core/schemas/order-by-direction.schema.ts new file mode 100644 index 000000000..63103fe73 --- /dev/null +++ b/libs/shared/filter/src/lib/core/schemas/order-by-direction.schema.ts @@ -0,0 +1,5 @@ +import { z } from 'zod'; + +export const OrderByDirectionSchema = z.enum(['asc', 'desc']); + +export type OrderByDirection = z.infer; diff --git a/libs/shared/filter/src/lib/core/schemas/order-by-option.schema.ts b/libs/shared/filter/src/lib/core/schemas/order-by-option.schema.ts new file mode 100644 index 000000000..3e23bb14a --- /dev/null +++ b/libs/shared/filter/src/lib/core/schemas/order-by-option.schema.ts @@ -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; diff --git a/libs/shared/filter/src/lib/core/schemas/query-order.schema.ts b/libs/shared/filter/src/lib/core/schemas/query-order.schema.ts new file mode 100644 index 000000000..f9694a178 --- /dev/null +++ b/libs/shared/filter/src/lib/core/schemas/query-order.schema.ts @@ -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; diff --git a/libs/shared/filter/src/lib/core/schemas/query.schema.ts b/libs/shared/filter/src/lib/core/schemas/query.schema.ts new file mode 100644 index 000000000..0d205a5ad --- /dev/null +++ b/libs/shared/filter/src/lib/core/schemas/query.schema.ts @@ -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; diff --git a/libs/shared/filter/src/lib/core/schemas/text-filter-input.schema.ts b/libs/shared/filter/src/lib/core/schemas/text-filter-input.schema.ts new file mode 100644 index 000000000..3a610b992 --- /dev/null +++ b/libs/shared/filter/src/lib/core/schemas/text-filter-input.schema.ts @@ -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; diff --git a/libs/shared/filter/src/lib/inputs/checkbox-input/checkbox-input.component.spec.ts b/libs/shared/filter/src/lib/inputs/checkbox-input/checkbox-input.component.spec.ts new file mode 100644 index 000000000..b21a842d2 --- /dev/null +++ b/libs/shared/filter/src/lib/inputs/checkbox-input/checkbox-input.component.spec.ts @@ -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; + 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; + 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; + + // 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'); + }); +}); diff --git a/libs/shared/filter/src/lib/menus/filter-menu/filter-menu-button.component.spec.ts b/libs/shared/filter/src/lib/menus/filter-menu/filter-menu-button.component.spec.ts new file mode 100644 index 000000000..e69de29bb diff --git a/libs/shared/filter/src/lib/menus/filter-menu/filter-menu.component.spec.ts b/libs/shared/filter/src/lib/menus/filter-menu/filter-menu.component.spec.ts new file mode 100644 index 000000000..e69de29bb diff --git a/libs/shared/filter/src/lib/menus/input-menu/input-menu-button.component.html b/libs/shared/filter/src/lib/menus/input-menu/input-menu-button.component.html index 9e34f21a6..ada23b97b 100644 --- a/libs/shared/filter/src/lib/menus/input-menu/input-menu-button.component.html +++ b/libs/shared/filter/src/lib/menus/input-menu/input-menu-button.component.html @@ -2,6 +2,7 @@ } diff --git a/libs/shared/filter/src/lib/order-by/order-by-toolbar.component.ts b/libs/shared/filter/src/lib/order-by/order-by-toolbar.component.ts index 29f80e13a..eb1dd1684 100644 --- a/libs/shared/filter/src/lib/order-by/order-by-toolbar.component.ts +++ b/libs/shared/filter/src/lib/order-by/order-by-toolbar.component.ts @@ -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(); - orderByOptions = this.#filter.orderBy; + orderByOptions = computed(() => { + const orderByOptions = this.#filter.orderBy(); + const selectedOrderBy = orderByOptions.find((o) => o.selected); + + const orderByOptionsWithoutDuplicates = orderByOptions.reduce((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'; - } } diff --git a/libs/ui/input-controls/src/lib/dropdown/_dropdown.scss b/libs/ui/input-controls/src/lib/dropdown/_dropdown.scss index caff79bc7..f67234930 100644 --- a/libs/ui/input-controls/src/lib/dropdown/_dropdown.scss +++ b/libs/ui/input-controls/src/lib/dropdown/_dropdown.scss @@ -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; diff --git a/libs/ui/input-controls/src/lib/dropdown/dropdown.component.ts b/libs/ui/input-controls/src/lib/dropdown/dropdown.component.ts index d08e47a44..3eb717543 100644 --- a/libs/ui/input-controls/src/lib/dropdown/dropdown.component.ts +++ b/libs/ui/input-controls/src/lib/dropdown/dropdown.component.ts @@ -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 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 implements ControlValueAccessor, AfterVi return this.elementRef.nativeElement.offsetWidth; } - appearance = input(DropdownAppearance.Button); + appearance = input(DropdownAppearance.AccentOutline); appearanceClass = computed(() => `ui-dropdown__${this.appearance()}`); @@ -110,6 +112,8 @@ export class DropdownButtonComponent implements ControlValueAccessor, AfterVi disabled = model(false); + showSelectedValue = input(true); + options = contentChildren(DropdownOptionComponent); cdkOverlayOrigin = inject(CdkOverlayOrigin, { self: true }); @@ -136,6 +140,10 @@ export class DropdownButtonComponent 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) { diff --git a/libs/ui/input-controls/src/lib/dropdown/dropdown.types.ts b/libs/ui/input-controls/src/lib/dropdown/dropdown.types.ts index 927381d02..1e8997471 100644 --- a/libs/ui/input-controls/src/lib/dropdown/dropdown.types.ts +++ b/libs/ui/input-controls/src/lib/dropdown/dropdown.types.ts @@ -1,5 +1,6 @@ export const DropdownAppearance = { - Button: 'button', + AccentOutline: 'accent-outline', + Grey: 'grey', } as const; export type DropdownAppearance = (typeof DropdownAppearance)[keyof typeof DropdownAppearance];