From 3e14426d2ea481c174dca7ef7f7d46e23787e52f Mon Sep 17 00:00:00 2001 From: Lorenz Hilpert Date: Thu, 10 Apr 2025 19:24:45 +0200 Subject: [PATCH 1/3] 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]; From 8144253a18a89667cd56ef44e6e949635bc045ff Mon Sep 17 00:00:00 2001 From: Lorenz Hilpert Date: Fri, 11 Apr 2025 15:24:08 +0200 Subject: [PATCH 2/3] Refactor return search component and remove unused dropdown. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 🛠️ **Refactor**: Updated return search result component for mobile responsiveness - 🗑️ **Chore**: Removed unused order-by dropdown component and related files - 📚 **Docs**: Enhanced component documentation for clarity --- docs/guidelines/code-style.md | 383 ++++++++++++++++-- .../return-search-result.component.html | 28 +- .../return-search-result.component.ts | 119 +++++- 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 -- 7 files changed, 469 insertions(+), 109 deletions(-) delete mode 100644 libs/shared/filter/src/lib/order-by/order-by-dropdown.component.html delete mode 100644 libs/shared/filter/src/lib/order-by/order-by-dropdown.component.scss delete mode 100644 libs/shared/filter/src/lib/order-by/order-by-dropdown.component.ts diff --git a/docs/guidelines/code-style.md b/docs/guidelines/code-style.md index f2751121b..dc6a70456 100644 --- a/docs/guidelines/code-style.md +++ b/docs/guidelines/code-style.md @@ -5,6 +5,186 @@ - **Readability First**: Write code that is easy to read and understand. - **Consistency**: Follow the same patterns and conventions throughout the codebase. - **Clean Code**: Avoid unnecessary complexity and keep functions small and focused. +- **SOLID Principles**: Follow SOLID design principles to create more maintainable, flexible, and scalable code. + +## SOLID Design Principles + +SOLID is an acronym for five design principles that help make software designs more understandable, flexible, and maintainable: + +- **Single Responsibility Principle (SRP)**: A class should have only one reason to change, meaning it should have only one job or responsibility. + + ```typescript + // Good - Each class has a single responsibility + class UserAuthentication { + authenticate(username: string, password: string): boolean { + // Authentication logic + } + } + + class UserRepository { + findById(id: string): User { + // Database access logic + } + } + + // Bad - Class has multiple responsibilities + class UserManager { + authenticate(username: string, password: string): boolean { + // Authentication logic + } + + findById(id: string): User { + // Database access logic + } + + sendEmail(user: User, message: string): void { + // Email sending logic + } + } + ``` + +- **Open/Closed Principle (OCP)**: Software entities should be open for extension but closed for modification. + + ```typescript + // Good - Open for extension + interface PaymentProcessor { + processPayment(amount: number): void; + } + + class CreditCardProcessor implements PaymentProcessor { + processPayment(amount: number): void { + // Credit card processing logic + } + } + + class PayPalProcessor implements PaymentProcessor { + processPayment(amount: number): void { + // PayPal processing logic + } + } + + // New payment methods can be added without modifying existing code + ``` + +- **Liskov Substitution Principle (LSP)**: Objects of a superclass should be replaceable with objects of subclasses without affecting the correctness of the program. + + ```typescript + // Good - Derived classes can substitute base class + class Rectangle { + constructor( + protected width: number, + protected height: number, + ) {} + + setWidth(width: number): void { + this.width = width; + } + + setHeight(height: number): void { + this.height = height; + } + + getArea(): number { + return this.width * this.height; + } + } + + class Square extends Rectangle { + constructor(size: number) { + super(size, size); + } + + // Preserve behavior when overriding methods + setWidth(width: number): void { + super.setWidth(width); + super.setHeight(width); + } + + setHeight(height: number): void { + super.setWidth(height); + super.setHeight(height); + } + } + ``` + +- **Interface Segregation Principle (ISP)**: Clients should not be forced to depend on interfaces they do not use. + + ```typescript + // Good - Segregated interfaces + interface Printable { + print(): void; + } + + interface Scannable { + scan(): void; + } + + class AllInOnePrinter implements Printable, Scannable { + print(): void { + // Printing logic + } + + scan(): void { + // Scanning logic + } + } + + class BasicPrinter implements Printable { + print(): void { + // Printing logic + } + } + + // Bad - Fat interface + interface OfficeMachine { + print(): void; + scan(): void; + fax(): void; + staple(): void; + } + + // Classes must implement methods they don't need + ``` + +- **Dependency Inversion Principle (DIP)**: High-level modules should not depend on low-level modules. Both should depend on abstractions. + + ```typescript + // Good - Depending on abstractions + interface Logger { + log(message: string): void; + } + + class ConsoleLogger implements Logger { + log(message: string): void { + console.log(message); + } + } + + class FileLogger implements Logger { + log(message: string): void { + // File logging logic + } + } + + class UserService { + constructor(private logger: Logger) {} + + createUser(user: User): void { + // Create user logic + this.logger.log(`User created: ${user.name}`); + } + } + + // The UserService depends on the abstraction (Logger interface) + // not on concrete implementations + ``` + +Following these principles improves code quality by: + +- Reducing coupling between components +- Making the system more modular and easier to maintain +- Facilitating testing and extension +- Promoting code reuse ## Extended Guidelines for Angular and TypeScript @@ -13,16 +193,24 @@ This section extends the core code style principles with Angular-specific and ad ### Angular Enhancements - **Change Detection**: Use the OnPush strategy by default for better performance. -- **Lifecycle Hooks**: Explicitly implement Angular lifecycle interfaces. +- **Lifecycle Hooks**: Explicitly implement Angular lifecycle interfaces (OnInit, OnDestroy, etc.). - **Template Management**: Keep templates concise and use the async pipe to handle observables. - **Component Structure**: Follow best practices for component modularization to enhance readability and testability. +- **Naming Conventions**: Follow Angular's official naming conventions for selectors, files, and component classes. +- **File Organization**: Structure files according to features and follow the recommended folder structure. +- **Control Flow**: Use modern control flow syntax (@if, @for) instead of structural directives (*ngIf, *ngFor). +- **Signals**: Prefer signals over RxJS for simpler state management within components. ### TypeScript Enhancements - **Strict Type Checking**: Enable strict mode (`strict: true`) and avoid excessive use of `any`. - **Interfaces vs. Types**: Prefer interfaces for object definitions and use type aliases for unions and intersections. - **Generics**: Use meaningful type parameter names and constrain generics when applicable. -- **Documentation**: Employ JSDoc comments functions and generic parameters to improve code clarity. +- **Documentation**: Employ JSDoc comments for functions and generic parameters to improve code clarity. +- **Non-Nullability**: Use the non-null assertion operator (!) sparingly and only when you're certain a value cannot be null. +- **Type Guards**: Implement custom type guards to handle type narrowing safely. +- **Immutability**: Favor immutable data structures and use readonly modifiers when applicable. +- **Exhaustiveness Checking**: Use exhaustiveness checking for switch statements handling union types. ## TypeScript Guidelines @@ -48,18 +236,18 @@ This section extends the core code style principles with Angular-specific and ad - Prefer `interface` over `type` for object definitions - Use `type` for unions, intersections, and mapped types - - Follow Angular's naming convention: `IComponentProps` for props interfaces + - Follow Angular's naming convention: Don't prefix interfaces with 'I' (use `ComponentProps` not `IComponentProps`) - Extend interfaces instead of repeating properties - Use readonly modifiers where appropriate ```typescript // Good - interface IBaseProps { + interface BaseProps { readonly id: string; name: string; } - interface IUserProps extends IBaseProps { + interface UserProps extends BaseProps { email: string; } @@ -75,9 +263,49 @@ This section extends the core code style principles with Angular-specific and ad - **Enums and Constants**: - - Use `const enum` for better performance - - Only use regular `enum` when runtime access is required - - Prefer union types for simple string literals + - Prefer this order of implementation (from most to least preferred): + + 1. `const enum` for better compile-time performance + 2. Object literals with `as const` for runtime flexibility + 3. Regular `enum` only when necessary for runtime access + + - **When to use each approach**: + - Use `const enum` for internal application enumerations that don't need runtime access + - Use `const object as const` when values need to be inspected at runtime or exported in an API + - Use regular `enum` only when runtime enumeration object access is required + + ```typescript + // Good - const enum (preferred for most cases) + // Advantages: Tree-shakable, type-safe, disappears at compile time + export const enum ConstEnumStates { + NotSet = 'not-set', + Success = 'success', + } + + // Good - const object with 'as const' assertion + // Advantages: Runtime accessible, works well with API boundaries + export const ConstStates = { + NotSet: 'not-set', + Success: 'success', + } as const; + + // Types can be extracted from const objects + type ConstStatesType = (typeof ConstStates)[keyof typeof ConstStates]; + + // Least preferred - regular enum + // Only use when you need the enum object at runtime + export enum States { + NotSet = 'not-set', + Success = 'success', + } + ``` + + - Use union types as an alternative for simple string literals + + ```typescript + // Alternative approach using union types + export type StatusType = 'not-set' | 'success'; + ``` - **Functions and Methods**: @@ -94,7 +322,7 @@ This section extends the core code style principles with Angular-specific and ad * @param id - The user's unique identifier * @param includeDetails - Whether to include additional user details */ - const getUser = (id: string, includeDetails = false): Promise => { + const getUser = (id: string, includeDetails = false): Promise => { // ...implementation }; @@ -113,12 +341,12 @@ Example: ```typescript // Good -interface IUserProps { +interface UserProps { id: string; name: string; } -interface IAdminProps extends IUserProps { +interface AdminProps extends UserProps { permissions: string[]; } @@ -127,7 +355,7 @@ const enum UserRole { User = 'USER', } -const getUser = (id: string): Promise => { +const getUser = (id: string): Promise => { // ...implementation }; @@ -170,20 +398,116 @@ function getUser(id) { subscription: Subscription; ngOnInit() { - this.subscription = this.userService.getUsers().subscribe((users) => (this.users = users)); + this.subscription = this.userService + .getUsers() + .subscribe((users) => (this.users = users)); } } ``` -- **Templates** +- **Templates and Control Flow**: - - Use new control flow syntax - instead if \*ngIf use the @if syntax + - Use modern control flow syntax (`@if`, `@for`, `@switch`) instead of structural directives (`*ngIf`, `*ngFor`, `*ngSwitch`). + + ```html + +
+ @if (user) { +

Welcome, {{ user.name }}!

+ } @else if (isLoading) { +

Loading user data...

+ } @else { +

Please log in

+ } + +
    + @for (item of items; track item.id) { +
  • {{ item.name }}
  • + } @empty { +
  • No items available
  • + } +
+ + @switch (userRole) { @case ('admin') { + + } @case ('manager') { + + } @default { + + } } +
+ + +
+

Welcome, {{ user.name }}!

+

Loading user data...

+

Please log in

+ +
    +
  • {{ item.name }}
  • +
  • No items available
  • +
+ + + + +
+ ``` + + - When using `@for`, always specify the `track` expression to optimize rendering performance: + - Use a unique identifier property (like `id` or `uuid`) when available + - Only use `$index` for static collections that never change + - Avoid using non-unique properties that could result in DOM mismatches + - Leverage contextual variables in `@for` blocks: + - `$index` - Current item index + - `$first` - Boolean indicating if this is the first item + - `$last` - Boolean indicating if this is the last item + - `$even` - Boolean indicating if this index is even + - `$odd` - Boolean indicating if this index is odd + - `$count` - Total number of items in the collection + + ```html + + @for (item of items; track item.id; let i = $index, isLast = $last) { +
  • {{ i + 1 }}. {{ item.name }}
  • + } + ``` + + - Use the `@empty` block with `@for` to handle empty collections gracefully + - Store conditional expression results in variables for clearer templates: + + ```html + + @if (user.permissions.canEditSettings; as canEdit) { + + } + ``` ## Project-Specific Preferences -- **Frameworks**: Follow best practices for Nx, Hono, and Zod. +- **Frameworks**: Follow best practices for Nx, Angular, date-fns, Ngrx, RxJs and Zod. - **Testing**: Use Jest with Spectator for unit tests and follow the Arrange-Act-Assert pattern. -- **File Naming**: Use kebab-case for filenames (e.g., `my-component.ts`). +- **File Naming**: + + - Use kebab-case for filenames (e.g., `my-component.ts`). + - Follow a pattern that describes the symbol's feature then its type: `feature.type.ts` + + ``` + // Good examples + user.service.ts + auth.guard.ts + product-list.component.ts + order.model.ts + + // Bad examples + service-user.ts + userService.ts + ``` + - **Comments**: Use JSDoc for documenting functions, classes, and modules. ## Formatting @@ -198,25 +522,10 @@ function getUser(id) { - Use ESLint with the recommended TypeScript and Nx configurations. - Prettier should be used for consistent formatting. -## Example - -```typescript -// Good Example -interface User { - id: string; - name: string; -} - -const getUser = (id: string): User => { - // ...function logic... -}; - -// Bad Example -function getUser(id) { - // ...function logic... -} -``` - ## References -- [Angular Style Guide](https://angular.dev/style-guide#) +- [Angular Style Guide](https://angular.dev/style-guide) - Official Angular style guide with best practices for Angular development +- [Angular Control Flow](https://angular.dev/guide/templates/control-flow) - Official Angular documentation on the new control flow syntax (@if, @for, @switch) +- [TypeScript Style Guide](https://ts.dev/style/) - TypeScript community style guide with patterns and practices +- [SOLID Design Principles](https://en.wikipedia.org/wiki/SOLID) - Wikipedia article explaining the SOLID principles in object-oriented design +- [Clean Code](https://www.amazon.com/Clean-Code-Handbook-Software-Craftsmanship/dp/0132350882) - Robert C. Martin's seminal book on writing clean, maintainable code 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 1cd2e49d9..0fd29388b 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,17 +12,28 @@ [rollbackOnClose]="true" > - @if (showOrderByToolbar()) { - + @if (mobileBreakpoint()) { + } @else { - + } +@if (mobileBreakpoint() && showOrderByToolbarMobile()) { + +} + {{ entityHits() }} Einträge @@ -40,7 +51,10 @@ } @placeholder {
    - +
    } } 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 aaf83f2ab..1dcc25a2f 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 @@ -4,7 +4,7 @@ import { Component, computed, inject, - signal, + linkedSignal, } from '@angular/core'; import { injectActivatedProcessId } from '@isa/core/process'; @@ -14,20 +14,33 @@ import { FilterService, SearchBarInputComponent, OrderByToolbarComponent, - OrderByDropdownComponent, } from '@isa/shared/filter'; import { IconButtonComponent } from '@isa/ui/buttons'; import { EmptyStateComponent } from '@isa/ui/empty-state'; -import { provideIcons } from '@ng-icons/core'; -import { isaActionSort } from '@isa/icons'; -import { ReceiptListItem, ReturnSearchStatus, ReturnSearchStore } from '@isa/oms/data-access'; +import { NgIconComponent, provideIcons } from '@ng-icons/core'; +import { isaActionSort, isaActionFilter } 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 { Breakpoint, BreakpointDirective, InViewportDirective } from '@isa/ui/layout'; +import { Breakpoint, 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 responsible for displaying return search results. + * + * This component handles: + * - Displaying a list of return search results + * - Filtering and sorting results + * - Searching for returns + * - Pagination with infinite scrolling + * - Responsive layout changes based on device size + */ @Component({ selector: 'oms-feature-return-search-result', templateUrl: './return-search-result.component.html', @@ -37,30 +50,50 @@ import { breakpoint } from '@isa/ui/layout'; RouterLink, ReturnSearchResultItemComponent, OrderByToolbarComponent, - OrderByDropdownComponent, IconButtonComponent, SearchBarInputComponent, EmptyStateComponent, FilterMenuButtonComponent, - BreakpointDirective, InViewportDirective, + NgIconComponent, ], - providers: [provideIcons({ isaActionSort })], + providers: [provideIcons({ isaActionSort, isaActionFilter })], }) export class ReturnSearchResultComponent implements AfterViewInit { - showOrderByToolbar = breakpoint([Breakpoint.DekstopL, Breakpoint.DekstopXL]); - + /** Route service for navigation and route information */ #route = inject(ActivatedRoute); + + /** Router service for programmatic navigation */ #router = inject(Router); + + /** Service for managing filters and search queries */ #filterService = inject(FilterService); + /** Utility for restoring scroll position when returning to this view */ restoreScrollPosition = injectRestoreScrollPosition(); + /** Current process ID from the activated route */ processId = injectActivatedProcessId(); + + /** Store for managing return search data and operations */ returnSearchStore = inject(ReturnSearchStore); + /** Enum reference for template usage */ ReturnSearchStatus = ReturnSearchStatus; + /** Signal tracking whether the viewport is at tablet size or above */ + mobileBreakpoint = breakpoint([Breakpoint.Tablet]); + + /** + * Signal controlling the visibility of the order-by toolbar on mobile + * Initially shows toolbar when NOT on mobile + */ + showOrderByToolbarMobile = linkedSignal(() => !this.mobileBreakpoint()); + + /** + * Computes the current return search entity based on the active process ID + * @returns The return search entity or undefined if no process ID is available + */ entity = computed(() => { const processId = this.processId(); if (processId) { @@ -69,45 +102,83 @@ export class ReturnSearchResultComponent implements AfterViewInit { return undefined; }); + /** + * Returns the list of return items from the current entity + * @returns Array of return items or empty array if none available + */ entityItems = computed(() => { return this.entity()?.items ?? []; }); + /** + * Returns the total number of hits from the search results + * @returns Total hits or 0 if no data available + */ entityHits = computed(() => { return this.entity()?.hits ?? 0; }); + /** + * Returns the current status of the search operation + * @returns Current status or Idle if no entity is available + */ entityStatus = computed(() => { return this.entity()?.status ?? ReturnSearchStatus.Idle; }); + /** + * Determines whether to render the item list based on available items + * @returns Boolean indicating if items are available to display + */ renderItemList = computed(() => { return this.entityItems().length; }); + /** + * Determines whether to show pagination loading indicator + * @returns Boolean indicating if pagination loading should be shown + */ renderPagingLoader = computed(() => { return this.entityStatus() === ReturnSearchStatus.Pending; }); + /** + * Determines whether to show the main search loading indicator + * Shows loader only when search is pending and no items are available yet + * @returns Boolean indicating if search loading should be shown + */ renderSearchLoader = computed(() => { - return this.entityStatus() === ReturnSearchStatus.Pending && this.entityItems().length === 0; + return ( + this.entityStatus() === ReturnSearchStatus.Pending && + this.entityItems().length === 0 + ); }); + /** + * Determines whether to render the page trigger for infinite scrolling + * Triggers pagination when more results are available than currently loaded + * @returns Boolean indicating if page trigger should be shown + */ renderPageTrigger = computed(() => { const entity = this.entity(); - if (!entity) return false; - if (entity.status === ReturnSearchStatus.Pending) return false; + if (!entity || entity.status === ReturnSearchStatus.Pending) return false; const { hits, items } = entity; - if (!hits || !Array.isArray(items)) return false; - - return hits > items.length; + return Boolean(hits && Array.isArray(items) && hits > items.length); }); + /** + * Lifecycle hook called after the component's view has been initialized + * Restores scroll position when returning to this view + */ ngAfterViewInit(): void { this.restoreScrollPosition(); } + /** + * Initiates a search operation with the current filter settings + * Navigates directly to the receipt if only one result is found + */ search() { const processId = this.processId(); if (processId) { @@ -121,6 +192,11 @@ export class ReturnSearchResultComponent implements AfterViewInit { } } + /** + * Callback function for search operations + * Automatically navigates to the receipt detail view if exactly one result is found + * @param result The callback result containing search data + */ searchCb = ({ data }: CallbackResult>) => { if (data) { if (data.result.length === 1) { @@ -129,6 +205,11 @@ export class ReturnSearchResultComponent implements AfterViewInit { } }; + /** + * Handles infinite scrolling pagination when the page trigger enters the viewport + * Loads more results when triggered + * @param inViewport Boolean indicating if the trigger element is in viewport + */ paging(inViewport: boolean) { if (!inViewport) { return; @@ -144,6 +225,10 @@ export class ReturnSearchResultComponent implements AfterViewInit { } } + /** + * Navigates to a specified path while preserving filter query parameters + * @param path Array of path segments for navigation + */ navigate(path: (string | number)[]) { this.#router.navigate(path, { relativeTo: this.#route, diff --git a/libs/shared/filter/src/lib/order-by/index.ts b/libs/shared/filter/src/lib/order-by/index.ts index 88b0a81af..bf7d5138a 100644 --- a/libs/shared/filter/src/lib/order-by/index.ts +++ b/libs/shared/filter/src/lib/order-by/index.ts @@ -1,2 +1 @@ export * from './order-by-toolbar.component'; -export * from './order-by-dropdown.component'; diff --git a/libs/shared/filter/src/lib/order-by/order-by-dropdown.component.html b/libs/shared/filter/src/lib/order-by/order-by-dropdown.component.html deleted file mode 100644 index 387ceccfb..000000000 --- a/libs/shared/filter/src/lib/order-by/order-by-dropdown.component.html +++ /dev/null @@ -1,18 +0,0 @@ - - @for (option of orderByOptions(); track option.by + option.dir) { - -
    {{ option.label }}
    -
    - -
    -
    - } -
    diff --git a/libs/shared/filter/src/lib/order-by/order-by-dropdown.component.scss b/libs/shared/filter/src/lib/order-by/order-by-dropdown.component.scss deleted file mode 100644 index a1aee2a49..000000000 --- a/libs/shared/filter/src/lib/order-by/order-by-dropdown.component.scss +++ /dev/null @@ -1,3 +0,0 @@ -:host { - @apply inline-flex; -} diff --git a/libs/shared/filter/src/lib/order-by/order-by-dropdown.component.ts b/libs/shared/filter/src/lib/order-by/order-by-dropdown.component.ts deleted file mode 100644 index 4080535dd..000000000 --- a/libs/shared/filter/src/lib/order-by/order-by-dropdown.component.ts +++ /dev/null @@ -1,26 +0,0 @@ -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); - } -} From aff6d1888809a94a57b36e8524870a6af3bde87a Mon Sep 17 00:00:00 2001 From: Lorenz Hilpert Date: Fri, 11 Apr 2025 16:13:11 +0200 Subject: [PATCH 3/3] feat: enhance filter input mappings with detailed documentation and schema validation - Added comprehensive JSDoc comments to mapping functions for checkbox and text filter inputs, improving code readability and maintainability. - Refactored checkboxFilterInputMapping and checkboxOptionMapping functions to enhance clarity and structure. - Removed unused data-range-filter-input mapping files and tests to streamline the codebase. - Introduced a new dateRangeFilterInputMapping function with detailed mapping logic for date range inputs. - Updated filter input schemas to include descriptive comments for better understanding of properties. - Implemented unit tests for date range filter input mapping to ensure correct functionality. - Enhanced existing filter mapping functions with improved error handling and validation. - Updated index exports to reflect the removal and addition of mapping files. --- docs/guidelines/testing.md | 619 +++++++++++++++++- .../lib/actions/filter-actions.component.ts | 46 ++ .../mappings/checkbox-filter-input.mapping.ts | 21 +- .../core/mappings/checkbox-option.mapping.ts | 18 +- .../data-range-filter-input.mapping.ts | 14 - ...> date-range-filter-input.mapping.spec.ts} | 2 +- .../date-range-filter-input.mapping.ts | 28 + .../lib/core/mappings/filter-group.mapping.ts | 10 + .../mappings/filter-input.mapping.spec.ts | 48 +- .../lib/core/mappings/filter-input.mapping.ts | 14 +- .../src/lib/core/mappings/filter.mapping.ts | 15 + .../filter/src/lib/core/mappings/index.ts | 2 +- .../core/mappings/order-by-option.mapping.ts | 11 + .../mappings/text-filter-input.mapping.ts | 15 +- .../core/schemas/base-filter-input.schema.ts | 40 +- .../checkbox-filter-input-option.schema.ts | 20 +- .../schemas/checkbox-filter-input.schema.ts | 32 +- .../schemas/date-range-filter-input.schema.ts | 28 +- .../lib/core/schemas/filter-group.schema.ts | 28 +- .../lib/core/schemas/filter-input.schema.ts | 9 + .../src/lib/core/schemas/filter.schema.ts | 26 +- .../core/schemas/order-by-direction.schema.ts | 13 +- .../core/schemas/order-by-option.schema.ts | 30 +- .../lib/core/schemas/query-order.schema.ts | 31 +- .../src/lib/core/schemas/query.schema.ts | 45 +- .../core/schemas/text-filter-input.schema.ts | 34 +- .../filter-menu-button.component.spec.ts | 93 +++ .../filter-menu/filter-menu.component.spec.ts | 0 .../input-menu-button.component.spec.ts | 18 +- 29 files changed, 1224 insertions(+), 86 deletions(-) delete mode 100644 libs/shared/filter/src/lib/core/mappings/data-range-filter-input.mapping.ts rename libs/shared/filter/src/lib/core/mappings/{data-range-filter-input.mapping.spec.ts => date-range-filter-input.mapping.spec.ts} (97%) create mode 100644 libs/shared/filter/src/lib/core/mappings/date-range-filter-input.mapping.ts delete mode 100644 libs/shared/filter/src/lib/menus/filter-menu/filter-menu.component.spec.ts diff --git a/docs/guidelines/testing.md b/docs/guidelines/testing.md index 1dd0e665c..6a8079a1b 100644 --- a/docs/guidelines/testing.md +++ b/docs/guidelines/testing.md @@ -9,12 +9,24 @@ - Mock external dependencies to isolate the unit under test - Mock child components to ensure true unit testing isolation +## Spectator Overview + +Spectator is a powerful library that simplifies Angular testing by: + +- Reducing boilerplate code in tests +- Providing easy DOM querying utilities +- Offering a clean API for triggering events +- Supporting testing of components, directives, and services +- Including custom matchers for clearer assertions + ## Best Practices ### Component Testing - Use `createComponentFactory` for standalone components -- Use `createHostFactory` when testing components with templates +- Use `createHostFactory` when testing components with templates and inputs/outputs +- Use `createDirectiveFactory` for testing directives +- Use `createServiceFactory` for testing services - Test component inputs, outputs, and lifecycle hooks - Verify DOM rendering and component behavior separately @@ -39,6 +51,143 @@ describe('ParentComponent', () => { }); ``` +## Spectator API Reference + +### Core Factory Methods + +1. **For Components**: + + ```typescript + const createComponent = createComponentFactory({ + component: MyComponent, + imports: [SomeModule], + declarations: [SomeDirective], + providers: [SomeService], + componentProviders: [], // Providers specific to the component + componentViewProviders: [], // ViewProviders for the component + mocks: [ServiceToMock], // Automatically mocks the service + detectChanges: false, // Whether to run change detection initially + }); + ``` + +2. **For Components with Host**: + + ```typescript + const createHost = createHostFactory({ + component: MyComponent, + template: ``, + // ...other options similar to createComponentFactory + }); + ``` + +3. **For Directives**: + + ```typescript + const createDirective = createDirectiveFactory({ + directive: MyDirective, + template: `
    `, + // ...other options + }); + ``` + +4. **For Services**: + + ```typescript + const createService = createServiceFactory({ + service: MyService, + providers: [DependencyService], + mocks: [HttpClient], + entryComponents: [], + }); + ``` + +5. **For HTTP Services**: + ```typescript + const createHttpService = createHttpFactory({ + service: MyHttpService, + providers: [SomeService], + mocks: [TokenService], + }); + ``` + +### Querying Elements + +Spectator offers multiple ways to query the DOM: + +```typescript +// Basic CSS selectors +const button = spectator.query('button.submit'); +const inputs = spectator.queryAll('input'); + +// By directive/component type +const childComponent = spectator.query(ChildComponent); +const directives = spectator.queryAll(MyDirective); + +// Advanced text-based selectors +spectator.query(byText('Submit')); +spectator.query(byLabel('Username')); +spectator.query(byPlaceholder('Enter your email')); +spectator.query(byValue('Some value')); +spectator.query(byTitle('Click here')); +spectator.query(byAltText('Logo image')); +spectator.query(byRole('button', { pressed: true })); + +// Accessing native elements +const { nativeElement } = spectator.query('.class-name'); +``` + +### Working with Inputs and Outputs + +```typescript +// Setting component inputs +spectator.setInput('username', 'JohnDoe'); +spectator.setInput({ + username: 'JohnDoe', + isActive: true, +}); + +// For host components +spectator.setHostInput('propName', value); + +// Working with outputs +const outputSpy = jest.fn(); +spectator.output('statusChange').subscribe(outputSpy); + +// Trigger and verify outputs +spectator.click('button.submit'); +expect(outputSpy).toHaveBeenCalledWith({ status: 'submitted' }); +``` + +### Event Triggering API + +Spectator provides a rich API for simulating user interactions: + +```typescript +// Mouse events +spectator.click('.button'); +spectator.doubleClick('#item'); +spectator.hover('.tooltip'); +spectator.mouseEnter('.dropdown'); +spectator.mouseLeave('.dropdown'); + +// Keyboard events +spectator.keyboard.pressEscape(); +spectator.keyboard.pressEnter(); +spectator.keyboard.pressKey('A'); +spectator.keyboard.pressKeys('ctrl.a'); + +// Form interactions +spectator.typeInElement('New value', 'input.username'); +spectator.blur('input.username'); +spectator.focus('input.password'); +spectator.selectOption(selectEl, 'Option 2'); + +// Custom events +spectator.triggerEventHandler(MyComponent, 'customEvent', eventObj); +spectator.dispatchFakeEvent(element, 'mouseover'); +spectator.dispatchTouchEvent(element, 'touchstart'); +``` + ## Example Test Structures ### Basic Component Test @@ -128,6 +277,190 @@ it('should emit when button is clicked', () => { }); ``` +### Testing Services + +```typescript +import { createServiceFactory, SpectatorService } from '@ngneat/spectator/jest'; +import { UserService } from './user.service'; +import { HttpClient } from '@angular/common/http'; + +describe('UserService', () => { + let spectator: SpectatorService; + let httpClient: HttpClient; + + const createService = createServiceFactory({ + service: UserService, + mocks: [HttpClient], + }); + + beforeEach(() => { + spectator = createService(); + httpClient = spectator.inject(HttpClient); + }); + + it('should fetch users', () => { + // Arrange + const mockUsers = [{ id: 1, name: 'John' }]; + httpClient.get.mockReturnValue(of(mockUsers)); + + // Act + let result; + spectator.service.getUsers().subscribe((users) => { + result = users; + }); + + // Assert + expect(httpClient.get).toHaveBeenCalledWith('/api/users'); + expect(result).toEqual(mockUsers); + }); +}); +``` + +### Testing HTTP Services + +```typescript +import { + createHttpFactory, + HttpMethod, + SpectatorHttp, +} from '@ngneat/spectator/jest'; +import { UserHttpService } from './user-http.service'; + +describe('UserHttpService', () => { + let spectator: SpectatorHttp; + + const createHttp = createHttpFactory({ + service: UserHttpService, + }); + + beforeEach(() => (spectator = createHttp())); + + it('should call the correct API endpoint when getting users', () => { + spectator.service.getUsers().subscribe(); + spectator.expectOne('/api/users', HttpMethod.GET); + }); + + it('should include auth token in the headers', () => { + spectator.service.getUsers().subscribe(); + const req = spectator.expectOne('/api/users', HttpMethod.GET); + expect(req.request.headers.get('Authorization')).toBeTruthy(); + }); +}); +``` + +### Testing Directives + +```typescript +import { + createDirectiveFactory, + SpectatorDirective, +} from '@ngneat/spectator/jest'; +import { HighlightDirective } from './highlight.directive'; + +describe('HighlightDirective', () => { + let spectator: SpectatorDirective; + + const createDirective = createDirectiveFactory({ + directive: HighlightDirective, + template: `
    Testing
    `, + }); + + beforeEach(() => (spectator = createDirective())); + + it('should change the background color', () => { + expect(spectator.element).toHaveStyle({ + backgroundColor: 'yellow', + }); + }); + + it('should respond to mouse events', () => { + spectator.dispatchMouseEvent(spectator.element, 'mouseover'); + expect(spectator.element).toHaveStyle({ + backgroundColor: 'yellow', + fontWeight: 'bold', + }); + + spectator.dispatchMouseEvent(spectator.element, 'mouseout'); + expect(spectator.element).toHaveStyle({ + backgroundColor: 'yellow', + fontWeight: 'normal', + }); + }); +}); +``` + +### Testing Angular Standalone Components + +```typescript +import { createComponentFactory, Spectator } from '@ngneat/spectator/jest'; +import { StandaloneComponent } from './standalone.component'; + +describe('StandaloneComponent', () => { + let spectator: Spectator; + + const createComponent = createComponentFactory({ + component: StandaloneComponent, + // No need for imports as they are part of the component itself + }); + + beforeEach(() => { + spectator = createComponent(); + }); + + it('should create standalone component', () => { + expect(spectator.component).toBeTruthy(); + }); +}); +``` + +### Testing Deferrable Views (@defer) + +```typescript +import { createComponentFactory, Spectator } from '@ngneat/spectator/jest'; +import { ComponentWithDefer } from './component-with-defer.component'; + +describe('ComponentWithDefer', () => { + let spectator: Spectator; + + const createComponent = createComponentFactory({ + component: ComponentWithDefer, + }); + + beforeEach(() => { + spectator = createComponent(); + }); + + it('should render defer block content', () => { + // Render the completed state of the first defer block + spectator.deferBlock().renderComplete(); + + expect(spectator.query('.deferred-content')).toExist(); + expect(spectator.query('.placeholder-content')).not.toExist(); + }); + + it('should show loading state', () => { + // Render the loading state of the first defer block + spectator.deferBlock().renderLoading(); + + expect(spectator.query('.loading-indicator')).toExist(); + }); + + it('should show placeholder content', () => { + // Render the placeholder state of the first defer block + spectator.deferBlock().renderPlaceholder(); + + expect(spectator.query('.placeholder-content')).toExist(); + }); + + it('should work with multiple defer blocks', () => { + // For the second defer block in the template + spectator.deferBlock(1).renderComplete(); + + expect(spectator.query('.second-deferred-content')).toExist(); + }); +}); +``` + ## Common Patterns ### Query Elements @@ -157,6 +490,32 @@ spectator.typeInElement('value', 'input'); spectator.triggerEventHandler(MyComponent, 'eventName', eventValue); ``` +### Custom Matchers + +Spectator provides custom matchers to make assertions more readable: + +```typescript +// DOM matchers +expect('.button').toExist(); +expect('.inactive-element').not.toExist(); +expect('.title').toHaveText('Welcome'); +expect('.username').toContainText('John'); +expect('input').toHaveValue('test'); +expect('.error').toHaveClass('visible'); +expect('button').toBeDisabled(); +expect('div').toHaveAttribute('aria-label', 'Close'); +expect('.menu').toHaveData({ testId: 'main-menu' }); +expect('img').toHaveProperty('src', 'path/to/image.jpg'); +expect('.parent').toHaveDescendant('.child'); +expect('.parent').toHaveDescendantWithText({ + selector: '.child', + text: 'Child text', +}); + +// Object matchers +expect(object).toBePartial({ id: 1 }); +``` + ### Test Async Operations ```typescript @@ -189,6 +548,264 @@ it('should handle async operations', async () => { - Remember to clean up subscriptions 3. **Performance** + - Mock heavy dependencies - Keep test setup minimal - Use `beforeAll` for expensive operations shared across tests + +4. **Change Detection** + + - Use `spectator.detectChanges()` after modifying component properties + - For OnPush components with a host, use `spectator.detectComponentChanges()` + +5. **Injection** + - Use `spectator.inject(Service)` to access injected services + - Use `spectator.inject(Service, true)` to get service from the component injector + +## Running Tests + +When working in an Nx workspace, there are several ways to run tests: + +### Running Tests for a Specific Project + +```bash +# Run all tests for a specific project +npx nx test + +# Example: Run tests for the core/config library +npx nx test core-config +``` + +### Running Tests with Watch Mode + +```bash +# Run tests in watch mode for active development +npx nx test --watch +``` + +### Running Tests with Coverage + +```bash +# Run tests with coverage reporting +npx nx test --code-coverage +``` + +### Running a Specific Test File + +```bash +# Run a specific test file +npx nx test --test-file=path/to/your.spec.ts +``` + +### Running Affected Tests + +```bash +# Run tests only for projects affected by recent changes +npx nx affected:test +``` + +These commands help you target exactly which tests to run, making your testing workflow more efficient. + +## References + +- [Spectator Documentation](https://github.com/ngneat/spectator) - Official documentation for the Spectator testing library +- [Jest Documentation](https://jestjs.io/docs/getting-started) - Comprehensive guide to using Jest as a testing framework +- [ng-mocks Documentation](https://ng-mocks.sudo.eu/) - Detailed documentation on mocking Angular dependencies effectively + +## ng-mocks Guide + +### Overview + +ng-mocks is a powerful library that helps with Angular testing by: + +- Mocking Components, Directives, Pipes, Modules, Services, and Tokens +- Reducing boilerplate in tests +- Providing a simple interface to access declarations + +It's particularly useful for isolating components by mocking their dependencies, which makes tests faster and more reliable. + +### Global Configuration + +For optimal setup, configure ng-mocks in your test setup file: + +```typescript +// src/test-setup.ts or equivalent +import { ngMocks } from 'ng-mocks'; + +// Auto-spy all methods in mock declarations and providers +ngMocks.autoSpy('jest'); // or 'jasmine' + +// Reset customizations after each test automatically +ngMocks.defaultMock(AuthService, () => ({ + isLoggedIn$: EMPTY, + currentUser$: EMPTY, +})); +``` + +### Key APIs + +#### MockBuilder + +`MockBuilder` provides a fluent API to configure TestBed with mocks: + +```typescript +beforeEach(() => { + return MockBuilder( + ComponentUnderTest, // Keep this as real + ParentModule, // Mock everything else + ) + .keep(ReactiveFormsModule) // Keep this as real + .mock(SomeOtherDependency, { customConfig: true }); // Custom mock +}); +``` + +#### MockRender + +`MockRender` is an enhanced version of `TestBed.createComponent` that: + +- Respects all lifecycle hooks +- Handles OnPush change detection +- Creates a wrapper component that binds inputs and outputs + +```typescript +// Simple rendering +const fixture = MockRender(ComponentUnderTest); + +// With inputs +const fixture = MockRender(ComponentUnderTest, { + name: 'Test User', + id: 123, +}); + +// Access the component instance +const component = fixture.point.componentInstance; +``` + +#### MockInstance + +`MockInstance` helps configure mocks before they're initialized: + +```typescript +// Adding a spy +const saveSpy = MockInstance(StorageService, 'save', jest.fn()); + +// Verify the spy was called +expect(saveSpy).toHaveBeenCalledWith(expectedData); +``` + +#### ngMocks Helpers + +The library provides several helper functions: + +```typescript +// Change form control values +ngMocks.change('[name=email]', 'test@example.com'); + +// Trigger events +ngMocks.trigger(element, 'click'); +ngMocks.trigger(element, 'keyup.control.s'); // Complex events + +// Find elements +const emailField = ngMocks.find('[name=email]'); +const submitBtn = ngMocks.findAll('button[type="submit"]'); +``` + +### Complete Example + +Here's a full example of testing a component with ng-mocks: + +```typescript +describe('ProfileComponent', () => { + // Reset customizations after each test + MockInstance.scope(); + + beforeEach(() => { + return MockBuilder(ProfileComponent, ProfileModule).keep( + ReactiveFormsModule, + ); + }); + + it('saves profile data on ctrl+s hotkey', () => { + // Prepare test data + const profile = { + email: 'test@email.com', + firstName: 'Test', + lastName: 'User', + }; + + // Mock service method + const saveSpy = MockInstance(StorageService, 'save', jest.fn()); + + // Render with inputs + const { point } = MockRender(ProfileComponent, { profile }); + + // Change form value + ngMocks.change('[name=email]', 'updated@email.com'); + + // Trigger hotkey + ngMocks.trigger(point, 'keyup.control.s'); + + // Verify behavior + expect(saveSpy).toHaveBeenCalledWith({ + email: 'updated@email.com', + firstName: profile.firstName, + lastName: profile.lastName, + }); + }); +}); +``` + +### Integration with Spectator + +While Spectator and ng-mocks have some overlapping functionality, they can be used together effectively: + +```typescript +describe('CombinedExample', () => { + let spectator: Spectator; + + const createComponent = createComponentFactory({ + component: MyComponent, + declarations: [ + // Use ng-mocks to mock child components + MockComponent(ComplexChildComponent), + MockDirective(ComplexDirective), + ], + providers: [ + // Use ng-mocks to mock a service with default behavior + MockProvider(ComplexService), + ], + }); + + beforeEach(() => { + // Configure a mock instance before the component is created + MockInstance( + ComplexService, + 'getData', + jest.fn().mockReturnValue(of(['test'])), + ); + + // Create the component with Spectator + spectator = createComponent(); + }); + + it('should work with mocked dependencies', () => { + // Use Spectator for interactions + spectator.click('button'); + + // Use ng-mocks to verify interactions with mocked dependencies + const service = ngMocks.get(ComplexService); + expect(service.getData).toHaveBeenCalled(); + }); +}); +``` + +### When to Use ng-mocks + +ng-mocks is particularly useful when: + +1. You need to mock complex Angular artifacts like components, directives, or modules +2. You want to customize mock behavior at the instance level +3. You need to simulate complex user interactions +4. You're testing parent components that depend on multiple child components + +For more details and advanced usage, refer to the [official ng-mocks documentation](https://ng-mocks.sudo.eu/). diff --git a/libs/shared/filter/src/lib/actions/filter-actions.component.ts b/libs/shared/filter/src/lib/actions/filter-actions.component.ts index 9f382491d..9ed89171b 100644 --- a/libs/shared/filter/src/lib/actions/filter-actions.component.ts +++ b/libs/shared/filter/src/lib/actions/filter-actions.component.ts @@ -10,6 +10,21 @@ import { import { ButtonComponent } from '@isa/ui/buttons'; import { FilterService } from '../core'; +/** + * A standalone component that manages filter action buttons (apply/reset) + * + * This component provides UI controls to apply or reset filter values + * within the filtering system. It communicates with the FilterService + * to perform filter operations. + * + * @example + * + * + */ @Component({ selector: 'filter-actions', templateUrl: './filter-actions.component.html', @@ -23,20 +38,44 @@ import { FilterService } from '../core'; }, }) export class FilterActionsComponent { + /** The filter service used to interact with the filter system */ readonly filterService = inject(FilterService); + /** + * Optional key specifying which filter input to apply/reset + * If not provided, all filter inputs will be affected + */ inputKey = input(); + /** + * Controls whether the Apply button should be displayed + * @default true + */ canApply = input(true); + /** + * Computed signal that filters inputs to only include those with 'filter' group + */ filterInputs = computed(() => this.filterService.inputs().filter((input) => input.group === 'filter'), ); + /** + * Event emitted when filters are applied + */ applied = output(); + /** + * Event emitted when filters are reset + */ reseted = output(); + /** + * Applies the current filter values + * + * If inputKey is provided, only that specific filter input is committed. + * Otherwise, all filter inputs are committed. + */ onApply() { const inputKey = this.inputKey(); @@ -49,6 +88,13 @@ export class FilterActionsComponent { this.applied.emit(); } + /** + * Resets filter values to their defaults + * + * If inputKey is provided, only that specific filter input is reset. + * Otherwise, all filter inputs in the 'filter' group are reset. + * After resetting, all changes are committed. + */ onReset() { const inputKey = this.inputKey(); 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 index 28aa43dd2..057aef600 100644 --- 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 @@ -2,7 +2,21 @@ import { Input, InputType } from '../../types'; import { CheckboxFilterInput, CheckboxFilterInputSchema } from '../schemas'; import { checkboxOptionMapping } from './checkbox-option.mapping'; -export function checkboxFilterInputMapping(group: string, input: Input): CheckboxFilterInput { +/** + * Maps an Input object to a CheckboxFilterInput object + * + * This function takes an input of type Checkbox and maps it to a strongly-typed + * CheckboxFilterInput object, validating it against a schema. It also maps all child + * options and tracks which options are selected. + * + * @param group - The group identifier that this input belongs to + * @param input - The source input object to map + * @returns A validated CheckboxFilterInput object + */ +export function checkboxFilterInputMapping( + group: string, + input: Input, +): CheckboxFilterInput { return CheckboxFilterInputSchema.parse({ group, key: input.key, @@ -13,7 +27,8 @@ export function checkboxFilterInputMapping(group: string, input: Input): Checkbo maxOptions: input.options?.max, options: input.options?.values?.map(checkboxOptionMapping), selected: - input.options?.values?.filter((option) => option.selected).map((option) => option.value) || - [], + input.options?.values + ?.filter((option) => option.selected) + .map((option) => option.value) || [], }); } 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 index ab182154f..d665e115d 100644 --- a/libs/shared/filter/src/lib/core/mappings/checkbox-option.mapping.ts +++ b/libs/shared/filter/src/lib/core/mappings/checkbox-option.mapping.ts @@ -1,7 +1,21 @@ -import { CheckboxFilterInputOption, CheckboxFilterInputOptionSchema } from '../schemas'; import { Option } from '../../types'; +import { + CheckboxFilterInputOption, + CheckboxFilterInputOptionSchema, +} from '../schemas'; -export function checkboxOptionMapping(option: Option): CheckboxFilterInputOption { +/** + * Maps an Option object to a CheckboxFilterInputOption object + * + * This function converts a generic Option to a strongly-typed + * CheckboxFilterInputOption, validating it against a schema. + * + * @param option - The source option object to map + * @returns A validated CheckboxFilterInputOption object + */ +export function checkboxOptionMapping( + option: Option, +): CheckboxFilterInputOption { return CheckboxFilterInputOptionSchema.parse({ label: option.label, value: option.value, 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 deleted file mode 100644 index a3a44ee99..000000000 --- a/libs/shared/filter/src/lib/core/mappings/data-range-filter-input.mapping.ts +++ /dev/null @@ -1,14 +0,0 @@ -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/data-range-filter-input.mapping.spec.ts b/libs/shared/filter/src/lib/core/mappings/date-range-filter-input.mapping.spec.ts similarity index 97% rename from libs/shared/filter/src/lib/core/mappings/data-range-filter-input.mapping.spec.ts rename to libs/shared/filter/src/lib/core/mappings/date-range-filter-input.mapping.spec.ts index c7a43187e..bf6de7d08 100644 --- a/libs/shared/filter/src/lib/core/mappings/data-range-filter-input.mapping.spec.ts +++ b/libs/shared/filter/src/lib/core/mappings/date-range-filter-input.mapping.spec.ts @@ -1,5 +1,5 @@ import { Input, InputType } from '../../types'; -import { dateRangeFilterInputMapping } from './data-range-filter-input.mapping'; +import { dateRangeFilterInputMapping } from './date-range-filter-input.mapping'; import * as schemaModule from '../schemas/date-range-filter-input.schema'; describe('dateRangeFilterInputMapping', () => { diff --git a/libs/shared/filter/src/lib/core/mappings/date-range-filter-input.mapping.ts b/libs/shared/filter/src/lib/core/mappings/date-range-filter-input.mapping.ts new file mode 100644 index 000000000..25384a6b4 --- /dev/null +++ b/libs/shared/filter/src/lib/core/mappings/date-range-filter-input.mapping.ts @@ -0,0 +1,28 @@ +import { Input, InputType } from '../../types'; +import { DateRangeFilterInput, DateRangeFilterInputSchema } from '../schemas'; + +/** + * Maps an Input object to a DateRangeFilterInput object + * + * This function takes an input of type DateRange and maps it to a strongly-typed + * DateRangeFilterInput object, validating it against a schema. It extracts the start + * and stop dates from the input's option values. + * + * @param group - The group identifier that this input belongs to + * @param input - The source input object to map + * @returns A validated DateRangeFilterInput object + */ +export function dateRangeFilterInputMapping( + group: string, + input: Input, +): DateRangeFilterInput { + return DateRangeFilterInputSchema.parse({ + group, + key: input.key, + label: input.label, + description: input.description, + type: InputType.DateRange, + start: input.options?.values?.[0]?.value, + stop: input.options?.values?.[1]?.value, + }); +} 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 index 2ecf358a8..621241999 100644 --- a/libs/shared/filter/src/lib/core/mappings/filter-group.mapping.ts +++ b/libs/shared/filter/src/lib/core/mappings/filter-group.mapping.ts @@ -1,6 +1,16 @@ import { InputGroup } from '../../types'; import { FilterGroup, FilterGroupSchema } from '../schemas'; +/** + * Maps an InputGroup object to a FilterGroup object + * + * This function converts a generic InputGroup to a strongly-typed FilterGroup, + * validating it against a schema. It preserves the group identifier, label, + * and description. + * + * @param group - The source input group to map + * @returns A validated FilterGroup object + */ export function filterGroupMapping(group: InputGroup): FilterGroup { return FilterGroupSchema.parse({ group: group.group, 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 index 46768aaf0..ff4c67e26 100644 --- 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 @@ -1,31 +1,37 @@ 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 dateRangeFilterInputMappingModule from './date-range-filter-input.mapping'; import * as textFilterInputMappingModule from './text-filter-input.mapping'; describe('filterInputMapping', () => { // Mock implementations for each specific mapping function - const mockTextFilterInputMapping = jest.fn().mockImplementation((group, input) => ({ - type: InputType.Text, - group, - key: input.key, - mapped: 'text', - })); + const 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 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', - })); + const mockDateRangeFilterInputMapping = jest + .fn() + .mockImplementation((group, input) => ({ + type: InputType.DateRange, + group, + key: input.key, + mapped: 'dateRange', + })); beforeEach(() => { jest.clearAllMocks(); @@ -124,7 +130,9 @@ describe('filterInputMapping', () => { }; // Act & Assert - expect(() => filterInputMapping(group, input)).toThrowError('Unknown input type: 999'); + 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 index e8dd789cf..1889f56d3 100644 --- a/libs/shared/filter/src/lib/core/mappings/filter-input.mapping.ts +++ b/libs/shared/filter/src/lib/core/mappings/filter-input.mapping.ts @@ -1,9 +1,21 @@ 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 { dateRangeFilterInputMapping } from './date-range-filter-input.mapping'; import { textFilterInputMapping } from './text-filter-input.mapping'; +/** + * Maps an Input object to the appropriate FilterInput type based on its input type + * + * This function serves as a router that delegates to the specific mapping function + * based on the input type (Text, Checkbox, DateRange). It ensures that each input + * is converted to its corresponding strongly-typed filter input object. + * + * @param group - The group identifier that this input belongs to + * @param input - The source input object to map + * @returns A validated FilterInput object of the appropriate subtype + * @throws Error if the input type is not supported + */ export function filterInputMapping(group: string, input: Input): FilterInput { switch (input.type) { case InputType.Text: diff --git a/libs/shared/filter/src/lib/core/mappings/filter.mapping.ts b/libs/shared/filter/src/lib/core/mappings/filter.mapping.ts index cdd9b32d6..0322da705 100644 --- a/libs/shared/filter/src/lib/core/mappings/filter.mapping.ts +++ b/libs/shared/filter/src/lib/core/mappings/filter.mapping.ts @@ -4,6 +4,21 @@ import { filterGroupMapping } from './filter-group.mapping'; import { filterInputMapping } from './filter-input.mapping'; import { orderByOptionMapping } from './order-by-option.mapping'; +/** + * Maps a QuerySettings object to a Filter object + * + * This is the main mapping function that transforms query settings into a + * complete Filter object structure. It: + * 1. Creates filter groups from both filter and input settings + * 2. Maps all inputs from each group to their corresponding filter inputs + * 3. Maps order by options if present + * + * The resulting Filter object can be used by filter components to render + * the appropriate UI and handle user interactions. + * + * @param settings - The source query settings to map + * @returns A fully populated Filter object with groups, inputs, and ordering options + */ export function filterMapping(settings: QuerySettings): Filter { const filter: Filter = { groups: [], diff --git a/libs/shared/filter/src/lib/core/mappings/index.ts b/libs/shared/filter/src/lib/core/mappings/index.ts index 2ca2d54a5..50463c081 100644 --- a/libs/shared/filter/src/lib/core/mappings/index.ts +++ b/libs/shared/filter/src/lib/core/mappings/index.ts @@ -1,6 +1,6 @@ export * from './checkbox-filter-input.mapping'; export * from './checkbox-option.mapping'; -export * from './data-range-filter-input.mapping'; +export * from './date-range-filter-input.mapping'; export * from './filter-group.mapping'; export * from './filter-input.mapping'; export * from './filter.mapping'; 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 index b44e6e588..57694529d 100644 --- 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 @@ -1,6 +1,17 @@ import { OrderBy } from '../../types'; import { OrderByOption, OrderByOptionSchema } from '../schemas'; +/** + * Maps an OrderBy object to an OrderByOption object + * + * This function transforms an OrderBy input definition into a strongly-typed + * OrderByOption that can be used by the filter component. It converts the + * desc boolean flag to a direction string ('asc' or 'desc') and initializes + * the selected state to false. + * + * @param orderBy - The source OrderBy object to map + * @returns A validated OrderByOption object + */ export function orderByOptionMapping(orderBy: OrderBy): OrderByOption { return OrderByOptionSchema.parse({ label: orderBy.label, 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 index bdbccb87a..3fdbbf3e4 100644 --- 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 @@ -1,7 +1,20 @@ import { Input, InputType } from '../../types'; import { TextFilterInput, TextFilterInputSchema } from '../schemas'; -export function textFilterInputMapping(group: string, input: Input): TextFilterInput { +/** + * Maps an Input object to a TextFilterInput object + * + * This function takes an input of type Text and maps it to a strongly-typed + * TextFilterInput object, validating it against a schema. + * + * @param group - The group identifier that this input belongs to + * @param input - The source input object to map + * @returns A validated TextFilterInput object + */ +export function textFilterInputMapping( + group: string, + input: Input, +): TextFilterInput { return TextFilterInputSchema.parse({ group, key: input.key, 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 index de8f5645f..0dddbac18 100644 --- 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 @@ -1,13 +1,43 @@ import { z } from 'zod'; import { InputType } from '../../types'; +/** + * Base schema for all filter input types. + * Contains common properties that all filter inputs must have. + * + * @property group - Group identifier that this input belongs to + * @property key - Unique identifier for the input within its group + * @property label - Optional display name for the input + * @property description - Optional detailed explanation of the input + * @property type - The type of input control (Text, Checkbox, DateRange) + */ export const BaseFilterInputSchema = z .object({ - group: z.string(), - key: z.string(), - label: z.string().optional(), - description: z.string().optional(), - type: z.nativeEnum(InputType), + group: z + .string() + .describe( + 'Identifier for the group this filter input belongs to. Used for organizing related filters.', + ), + key: z + .string() + .describe( + 'Unique identifier for this input within its group. Used as a key in requests and state management.', + ), + label: z + .string() + .optional() + .describe('Human-readable display name shown to users in the UI.'), + description: z + .string() + .optional() + .describe( + 'Detailed explanation of what this filter does, displayed as helper text in the UI.', + ), + type: z + .nativeEnum(InputType) + .describe( + 'Determines the type of input control and its behavior (Text, Checkbox, DateRange, etc.).', + ), }) .describe('BaseFilterInput'); 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 index 6f3e92089..8b9bc2c14 100644 --- 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 @@ -1,10 +1,24 @@ import { z } from 'zod'; +/** + * Represents a checkbox option within a CheckboxFilterInput. + * + * @property label - Display text for the checkbox option + * @property value - The value to be used when this option is selected + */ export const CheckboxFilterInputOptionSchema = z .object({ - label: z.string(), - value: z.string(), + label: z + .string() + .describe('Display text shown next to the checkbox in the UI.'), + value: z + .string() + .describe( + 'Underlying value that will be sent in requests when this option is selected.', + ), }) .describe('CheckboxFilterInputOption'); -export type CheckboxFilterInputOption = z.infer; +export type CheckboxFilterInputOption = z.infer< + typeof CheckboxFilterInputOptionSchema +>; 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 index 17bf9f12f..7d915f73c 100644 --- 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 @@ -3,11 +3,35 @@ import { BaseFilterInputSchema } from './base-filter-input.schema'; import { InputType } from '../../types'; import { CheckboxFilterInputOptionSchema } from './checkbox-filter-input-option.schema'; +/** + * Schema for checkbox-based filter inputs that allow users to select from multiple options. + * Extends the BaseFilterInputSchema with checkbox-specific properties. + * + * @property type - Must be InputType.Checkbox + * @property maxOptions - Optional limit on how many options can be selected + * @property options - Array of selectable checkbox options + * @property selected - Array of string values representing the currently selected options + */ export const CheckboxFilterInputSchema = BaseFilterInputSchema.extend({ - type: z.literal(InputType.Checkbox), - maxOptions: z.number().optional(), - options: z.array(CheckboxFilterInputOptionSchema), - selected: z.array(z.string()), + type: z + .literal(InputType.Checkbox) + .describe( + 'Specifies this as a checkbox input type. Must be InputType.Checkbox.', + ), + maxOptions: z + .number() + .optional() + .describe( + 'Optional maximum number of options that can be selected simultaneously. If not provided, all options can be selected.', + ), + options: z + .array(CheckboxFilterInputOptionSchema) + .describe('List of available checkbox options that users can select from.'), + selected: z + .array(z.string()) + .describe( + 'Array of values representing which options are currently selected. Each value corresponds to the value property of an option.', + ), }).describe('CheckboxFilterInput'); export type CheckboxFilterInput = z.infer; 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 index 4f2812cdd..ee1761170 100644 --- 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 @@ -2,10 +2,32 @@ import { z } from 'zod'; import { BaseFilterInputSchema } from './base-filter-input.schema'; import { InputType } from '../../types'; +/** + * Schema for date range inputs that allow filtering by a time period. + * Extends BaseFilterInputSchema with date range specific properties. + * + * @property type - Must be InputType.DateRange + * @property start - Optional ISO string representing the start date of the range + * @property stop - Optional ISO string representing the end date of the range + */ export const DateRangeFilterInputSchema = BaseFilterInputSchema.extend({ - type: z.literal(InputType.DateRange), - start: z.string().optional(), - stop: z.string().optional(), + type: z + .literal(InputType.DateRange) + .describe( + 'Specifies this as a date range input type. Must be InputType.DateRange.', + ), + start: z + .string() + .optional() + .describe( + 'ISO date string representing the beginning of the date range. Optional if only an end date is needed.', + ), + stop: z + .string() + .optional() + .describe( + 'ISO date string representing the end of the date range. Optional if only a start date is needed.', + ), }).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 index 221c2c856..ef1e3d779 100644 --- a/libs/shared/filter/src/lib/core/schemas/filter-group.schema.ts +++ b/libs/shared/filter/src/lib/core/schemas/filter-group.schema.ts @@ -1,10 +1,32 @@ import { z } from 'zod'; +/** + * Schema for filter groups that organize filter inputs into logical sections. + * Groups provide a way to categorize related filters together for better organization. + * + * @property group - Unique identifier for the filter group + * @property label - Optional display name for the filter group in the UI + * @property description - Optional detailed explanation of what this filter group represents + */ export const FilterGroupSchema = z .object({ - group: z.string(), - label: z.string().optional(), - description: z.string().optional(), + group: z + .string() + .describe( + 'Unique identifier for the filter group, used for referencing in code.', + ), + label: z + .string() + .optional() + .describe( + 'Human-readable name for the filter group displayed in the UI.', + ), + description: z + .string() + .optional() + .describe( + "Detailed explanation of the filter group's purpose or contents, may be shown as helper text.", + ), }) .describe('FilterGroup'); 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 index e70f05421..acb9be8ad 100644 --- a/libs/shared/filter/src/lib/core/schemas/filter-input.schema.ts +++ b/libs/shared/filter/src/lib/core/schemas/filter-input.schema.ts @@ -3,6 +3,15 @@ import { CheckboxFilterInputSchema } from './checkbox-filter-input.schema'; import { DateRangeFilterInputSchema } from './date-range-filter-input.schema'; import { TextFilterInputSchema } from './text-filter-input.schema'; +/** + * A union schema representing all possible filter input types in the system. + * This schema allows for type discrimination based on the `type` property. + * + * Supported filter input types: + * - TextFilterInput: Simple text input fields + * - CheckboxFilterInput: Multiple-choice checkbox selections + * - DateRangeFilterInput: Date range selectors for time-based filtering + */ export const FilterInputSchema = z.union([ TextFilterInputSchema, CheckboxFilterInputSchema, diff --git a/libs/shared/filter/src/lib/core/schemas/filter.schema.ts b/libs/shared/filter/src/lib/core/schemas/filter.schema.ts index ed333fa0d..bf813056e 100644 --- a/libs/shared/filter/src/lib/core/schemas/filter.schema.ts +++ b/libs/shared/filter/src/lib/core/schemas/filter.schema.ts @@ -3,11 +3,31 @@ import { FilterGroupSchema } from './filter-group.schema'; import { FilterInputSchema } from './filter-input.schema'; import { OrderByOptionSchema } from './order-by-option.schema'; +/** + * Top-level schema representing the complete filter configuration. + * Combines filter groups, input fields, and ordering options into a unified structure. + * + * @property groups - Collection of filter groups for organizing inputs into categories + * @property inputs - All filter input controls available to the user + * @property orderBy - Available sorting options for the filtered results + */ export const FilterSchema = z .object({ - groups: z.array(FilterGroupSchema), - inputs: z.array(FilterInputSchema), - orderBy: z.array(OrderByOptionSchema), + groups: z + .array(FilterGroupSchema) + .describe( + 'Collection of filter groups that organize inputs into logical categories for better user experience.', + ), + inputs: z + .array(FilterInputSchema) + .describe( + 'Array of all filter input controls available to the user across all groups.', + ), + orderBy: z + .array(OrderByOptionSchema) + .describe( + 'Available sorting options that users can apply to the filtered results.', + ), }) .describe('Filter'); 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 index 63103fe73..cf1a1419f 100644 --- 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 @@ -1,5 +1,16 @@ import { z } from 'zod'; -export const OrderByDirectionSchema = z.enum(['asc', 'desc']); +/** + * Enum schema for sort directions in ordering operations. + * Provides type-safe options for ascending or descending sorting. + * + * - 'asc': Ascending order (A-Z, 0-9, oldest to newest) + * - 'desc': Descending order (Z-A, 9-0, newest to oldest) + */ +export const OrderByDirectionSchema = z + .enum(['asc', 'desc']) + .describe( + 'Direction for sorting operations, either ascending (asc) or descending (desc).', + ); export type OrderByDirection = z.infer; 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 index 3e23bb14a..15caf815d 100644 --- 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 @@ -1,12 +1,34 @@ import { z } from 'zod'; import { OrderByDirectionSchema } from './order-by-direction.schema'; +/** + * Schema for defining sort options available to the user. + * Each option represents a different field or property that can be used for ordering results. + * + * @property by - Field identifier to sort by (corresponds to a property in the data) + * @property label - Human-readable name for this sort option to display in the UI + * @property dir - Sort direction ('asc' for ascending or 'desc' for descending) + * @property selected - Whether this ordering option is currently active + */ export const OrderByOptionSchema = z .object({ - by: z.string(), - label: z.string(), - dir: OrderByDirectionSchema, - selected: z.boolean().default(false), + by: z + .string() + .describe( + 'Field identifier to sort by, matching a property in the data model.', + ), + label: z + .string() + .describe( + 'Human-readable name for this sort option to display in the UI.', + ), + dir: OrderByDirectionSchema.describe( + 'Sort direction, either "asc" for ascending or "desc" for descending.', + ), + selected: z + .boolean() + .default(false) + .describe('Indicates whether this ordering option is currently active.'), }) .describe('OrderByOption'); 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 index f9694a178..660d51d01 100644 --- a/libs/shared/filter/src/lib/core/schemas/query-order.schema.ts +++ b/libs/shared/filter/src/lib/core/schemas/query-order.schema.ts @@ -1,10 +1,33 @@ import { z } from 'zod'; +/** + * Schema representing a sorting criterion in a query. + * This defines how results should be ordered when returned from a data source. + * + * @property by - Field identifier to sort by (corresponds to a property in the data) + * @property label - Human-readable name for this sort option + * @property desc - Whether the sort should be in descending order (true) or ascending (false) + * @property selected - Whether this ordering option is currently active + */ export const QueryOrderBySchema = z.object({ - by: z.string(), - label: z.string(), - desc: z.boolean(), - selected: z.boolean(), + by: z + .string() + .describe( + 'Field identifier to sort by, matching a property name in the data model.', + ), + label: z + .string() + .describe('Human-readable name for this sort option to display in the UI.'), + desc: z + .boolean() + .describe( + 'Sort direction flag: true for descending order (Z-A, newest first), false for ascending (A-Z, oldest first).', + ), + selected: z + .boolean() + .describe( + 'Indicates whether this ordering option is currently active in the query.', + ), }); export type QueryOrderBy = z.infer; diff --git a/libs/shared/filter/src/lib/core/schemas/query.schema.ts b/libs/shared/filter/src/lib/core/schemas/query.schema.ts index 0d205a5ad..980a92d50 100644 --- a/libs/shared/filter/src/lib/core/schemas/query.schema.ts +++ b/libs/shared/filter/src/lib/core/schemas/query.schema.ts @@ -1,12 +1,47 @@ import { z } from 'zod'; import { QueryOrderBySchema } from './query-order.schema'; +/** + * Schema representing a complete query for retrieving filtered and sorted data. + * This is the core schema used when making requests to APIs or data sources. + * + * @property filter - Record of filter criteria to apply when querying data + * @property input - Record of user input values from filter controls + * @property orderBy - Array of sort criteria to determine result ordering + * @property skip - Number of items to skip (for pagination) + * @property take - Maximum number of items to return (page size) + */ export const QuerySchema = z.object({ - filter: z.record(z.any()).default({}), - input: z.record(z.any()).default({}), - orderBy: z.array(QueryOrderBySchema).default([]), - skip: z.number().default(0), - take: z.number().default(25), + filter: z + .record(z.any()) + .default({}) + .describe( + 'Key-value pairs of filter criteria to apply when querying data. Keys correspond to data properties, values are the filtering constraints.', + ), + input: z + .record(z.any()) + .default({}) + .describe( + 'Key-value pairs representing user input from filter controls. Used to store and restore filter state.', + ), + orderBy: z + .array(QueryOrderBySchema) + .default([]) + .describe( + 'Array of sorting criteria that determine how results should be ordered. Applied in sequence for multi-level sorting.', + ), + skip: z + .number() + .default(0) + .describe( + 'Number of items to skip from the beginning of the result set. Used for implementing pagination.', + ), + take: z + .number() + .default(25) + .describe( + 'Maximum number of items to return in a single query. Defines the page size for paginated results.', + ), }); export type Query = z.infer; 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 index 3a610b992..021a48dbe 100644 --- 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 @@ -2,11 +2,37 @@ import { z } from 'zod'; import { BaseFilterInputSchema } from './base-filter-input.schema'; import { InputType } from '../../types'; +/** + * Schema for text-based filter inputs that allow free-form text entry. + * Extends BaseFilterInputSchema with text input specific properties. + * + * @property type - Must be InputType.Text + * @property placeholder - Optional hint text to display when the input is empty + * @property defaultValue - Optional initial value to populate the input with + * @property value - Current value of the text input + */ export const TextFilterInputSchema = BaseFilterInputSchema.extend({ - type: z.literal(InputType.Text), - placeholder: z.string().optional(), - defaultValue: z.string().optional(), - value: z.string().optional(), + type: z + .literal(InputType.Text) + .describe('Specifies this as a text input type. Must be InputType.Text.'), + placeholder: z + .string() + .optional() + .describe( + 'Hint text displayed when the input is empty to guide users on what to enter.', + ), + defaultValue: z + .string() + .optional() + .describe( + 'Initial value to populate the text field with when first rendered or reset.', + ), + value: z + .string() + .optional() + .describe( + 'Current value of the text input field, reflecting what the user has entered.', + ), }).describe('TextFilterInput'); export type TextFilterInput = z.infer; 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 index e69de29bb..f9266787c 100644 --- 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 @@ -0,0 +1,93 @@ +import { + createComponentFactory, + Spectator, + mockProvider, +} from '@ngneat/spectator/jest'; +import { MockComponents } from 'ng-mocks'; +import { FilterMenuButtonComponent } from './filter-menu-button.component'; +import { FilterService } from '../../core'; +import { Overlay } from '@angular/cdk/overlay'; +import { FilterMenuComponent } from './filter-menu.component'; +import { NgIconComponent } from '@ng-icons/core'; +import { IconButtonComponent } from '@isa/ui/buttons'; +import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; + +describe('FilterMenuButtonComponent', () => { + let spectator: Spectator; + let filterService: jest.Mocked; + + const createComponent = createComponentFactory({ + component: FilterMenuButtonComponent, + declarations: [ + MockComponents(NgIconComponent, FilterMenuComponent, IconButtonComponent), + ], + componentProviders: [ + mockProvider(Overlay, { scrollStrategies: { block: jest.fn() } }), + ], + providers: [ + mockProvider(FilterService, { + isDefaultFilter: jest.fn().mockReturnValue(true), + rollback: jest.fn(), + }), + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + detectChanges: false, + }); + + beforeEach(() => { + spectator = createComponent({ + props: { + rollbackOnClose: false, + }, + }); + spectator.detectChanges(); + + filterService = spectator.inject(FilterService); + }); + + it('should create the component', () => { + expect(spectator.component).toBeTruthy(); + }); + + it('should toggle open state and emit events', () => { + // Arrange + const closedSpy = jest.spyOn(spectator.component.closed, 'emit'); + const openedSpy = jest.spyOn(spectator.component.opened, 'emit'); + + // Act - Open + spectator.component.toggle(); + + // Assert - Open + expect(spectator.component.open()).toBe(true); + expect(openedSpy).toHaveBeenCalled(); + + // Act - Close + spectator.component.toggle(); + + // Assert - Close + expect(spectator.component.open()).toBe(false); + expect(closedSpy).toHaveBeenCalled(); + }); + + it('should rollback on close when rollbackOnClose is true', () => { + // Arrange + spectator.setInput('rollbackOnClose', true); + + // Act + spectator.component.closed.emit(); + + // Assert + expect(filterService.rollback).toHaveBeenCalled(); + }); + + it('should close menu when applied is emitted', () => { + // Arrange + spectator.component.open.set(true); + + // Act + spectator.component.applied.emit(); + + // Assert + expect(spectator.component.open()).toBe(false); + }); +}); 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 deleted file mode 100644 index e69de29bb..000000000 diff --git a/libs/shared/filter/src/lib/menus/input-menu/input-menu-button.component.spec.ts b/libs/shared/filter/src/lib/menus/input-menu/input-menu-button.component.spec.ts index 5f1b4cabd..ce543e887 100644 --- a/libs/shared/filter/src/lib/menus/input-menu/input-menu-button.component.spec.ts +++ b/libs/shared/filter/src/lib/menus/input-menu/input-menu-button.component.spec.ts @@ -1,10 +1,19 @@ -import { createComponentFactory, Spectator, mockProvider } from '@ngneat/spectator/jest'; +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 { + CdkConnectedOverlay, + CdkOverlayOrigin, + Overlay, +} from '@angular/cdk/overlay'; import { FilterInputMenuComponent } from './input-menu.component'; import { NgIconComponent } from '@ng-icons/core'; +import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; describe('FilterInputMenuButtonComponent', () => { let spectator: Spectator; @@ -19,13 +28,16 @@ describe('FilterInputMenuButtonComponent', () => { MockComponents(NgIconComponent, FilterInputMenuComponent), MockDirectives(CdkOverlayOrigin, CdkConnectedOverlay), ], - componentProviders: [mockProvider(Overlay, { scrollStrategies: { block: jest.fn() } })], + componentProviders: [ + mockProvider(Overlay, { scrollStrategies: { block: jest.fn() } }), + ], providers: [ mockProvider(FilterService, { isDefaultFilterInput: jest.fn().mockReturnValue(true), commit: jest.fn(), }), ], + schemas: [CUSTOM_ELEMENTS_SCHEMA], detectChanges: false, });