diff --git a/libs/shared/filter/src/index.ts b/libs/shared/filter/src/index.ts index f05ee7c44..7db31a961 100644 --- a/libs/shared/filter/src/index.ts +++ b/libs/shared/filter/src/index.ts @@ -1,8 +1,9 @@ -export * from './lib/core'; -export * from './lib/inputs'; -export * from './lib/types'; -export * from './lib/actions'; -export * from './lib/menus/filter-menu'; -export * from './lib/menus/input-menu'; -export * from './lib/order-by'; -export * from './lib/controls-panel'; +export * from './lib/core'; +export * from './lib/helpers'; +export * from './lib/inputs'; +export * from './lib/types'; +export * from './lib/actions'; +export * from './lib/menus/filter-menu'; +export * from './lib/menus/input-menu'; +export * from './lib/order-by'; +export * from './lib/controls-panel'; diff --git a/libs/shared/filter/src/lib/core/filter.service.ts b/libs/shared/filter/src/lib/core/filter.service.ts index a7348e1c4..190207e1c 100644 --- a/libs/shared/filter/src/lib/core/filter.service.ts +++ b/libs/shared/filter/src/lib/core/filter.service.ts @@ -1,756 +1,874 @@ -import { computed, inject, Injectable, signal } from '@angular/core'; -import { InputType } from '../types'; -import { getState, patchState, signalState } from '@ngrx/signals'; -import { filterMapping, mapFilterInputToRecord } from './mappings'; -import { isEqual } from 'lodash'; -import { - DateRangeFilterInput, - FilterInput, - OrderByDirection, - OrderByDirectionSchema, - Query, - QuerySchema, -} from './schemas'; -import { FILTER_ON_COMMIT, FILTER_ON_INIT, QUERY_SETTINGS } from './tokens'; -import { logger } from '@isa/core/logging'; -import { parseISO } from 'date-fns'; - -@Injectable() -export class FilterService { - #logger = logger(() => ({ - library: 'shared/filter', - class: 'FilterService', - })); - - #onInit = inject(FILTER_ON_INIT, { optional: true })?.map((fn) => fn(this)); - #onCommit = inject(FILTER_ON_COMMIT, { optional: true })?.map((fn) => - fn(this), - ); - - readonly settings = inject(QUERY_SETTINGS); - - private readonly defaultState = filterMapping(this.settings); - - // Use a more lightweight approach for creating the initial state - #commitdState = signal({ ...this.defaultState }); - - #state = signalState(this.#commitdState()); - - groups = this.#state.groups; - - inputs = this.#state.inputs; - - orderBy = this.#state.orderBy; - - constructor() { - this.#onInit?.forEach((initFn) => { - initFn(); - }); - } - - /** - * Sets the ordering field and direction for the filter. - * - * @param by - The field to order by - * @param dir - The direction to order by (asc or desc) - * @param options - Optional parameters - * @param options.commit - If true, commits the changes immediately - */ - setOrderBy( - by: string, - dir: OrderByDirection | undefined, - options?: { commit: boolean }, - ): void { - const orderByList = this.#state.orderBy().map((o) => { - if (o.by === by && o.dir === dir) { - return { ...o, selected: true }; - } - return { ...o, selected: false }; - }); - - patchState(this.#state, { orderBy: orderByList }); - - if (options?.commit) { - this.commit(); - } - } - - /** - * Sets the text value for an input with the specified key. - * - * @param key - The key of the input to update - * @param value - The new text value to set - * @param options - Optional parameters - * @param options.commit - If true, commits the changes immediately - */ - setInputTextValue( - key: string, - value: string | undefined, - options?: { commit: boolean }, - ): void { - const inputs = this.#state.inputs().map((input) => { - if (input.key !== key) { - return input; - } - - if (input.type === InputType.Text) { - return { ...input, value }; - } - - this.logUnsupportedInputType(input, 'setInputTextValue'); - return input; - }); - - patchState(this.#state, { inputs }); - - if (options?.commit) { - this.commit(); - } - } - - /** - * Sets the selected values for a checkbox input with the specified key. - * - * @param key - The key of the checkbox input to update - * @param selected - Array of selected values - * @param options - Optional parameters - * @param options.commit - If true, commits the changes immediately - */ - setInputCheckboxValue( - key: string, - selected: string[], - options?: { commit: boolean }, - ): void { - const inputs = this.#state.inputs().map((input) => { - if (input.key !== key) { - return input; - } - - if (input.type === InputType.Checkbox) { - return { ...input, selected }; - } - - this.logUnsupportedInputType(input, 'setInputCheckboxValue'); - return input; - }); - - patchState(this.#state, { inputs }); - - if (options?.commit) { - this.commit(); - } - } - - /** - * Sets the date range values for an input with the specified key. - * - * @param key - The key of the date range input to update - * @param start - The start date as a string - * @param stop - The end date as a string - * @param options - Optional parameters - * @param options.commit - If true, commits the changes immediately - */ - setInputDateRangeValue( - key: string, - start?: string, - stop?: string, - options?: { commit: boolean }, - ): void { - const inputs = this.#state.inputs().map((input) => { - if (input.key !== key) { - return input; - } - - if (input.type === InputType.DateRange) { - return { ...input, start, stop }; - } - - this.logUnsupportedInputType(input, 'setInputDateRangeValue'); - return input; - }); - - patchState(this.#state, { inputs }); - - if (options?.commit) { - this.commit(); - } - } - - /** - * Helper method to consistently log unsupported input type warnings - * @private - * @param input - The input that has an unsupported type - * @param method - The method name where the warning occurred - */ - private logUnsupportedInputType(input: FilterInput, method: string): void { - this.#logger.warn(`Input type not supported`, () => ({ - inputType: input.type, - inputKey: input.key, - method, - })); - } - - /** - * Indicates whether the current state is the default state. - * This computed property checks if the current state is equal to the default state. - */ - isDefaultFilter = computed(() => { - const currentState = getState(this.#state); - return isEqual(currentState.inputs, this.defaultState.inputs); - }); - - /** - * Checks if a specific filter input is in its default state. - * - * @param filterInput - The filter input to check - * @returns True if the input is in its default state, false otherwise - */ - isDefaultFilterInput(filterInput: FilterInput): boolean { - const currentInputState = this.#state - .inputs() - .find((i) => i.key === filterInput.key); - const defaultInputState = this.defaultState.inputs.find( - (i) => i.key === filterInput.key, - ); - - // For DateRange inputs, compare dates by value, not string precision - if (filterInput.type === InputType.DateRange) { - return this._areDateRangeInputsEqual( - currentInputState as DateRangeFilterInput, - defaultInputState as DateRangeFilterInput, - ); - } - - return isEqual(currentInputState, defaultInputState); - } - - /** - * Compares two date range filter inputs for equality, accounting for possible differences - * in date string precision (e.g., "2023-06-05T22:00:00Z" vs. "2023-06-05T22:00:00.000Z"). - * Converts ISO date strings to Date objects before comparison to ensure consistent results. - * - * @param currentInput - The current DateRangeFilterInput state - * @param defaultInput - The default DateRangeFilterInput state - * @returns True if both start and stop dates are equal (ignoring string precision), false otherwise - */ - private _areDateRangeInputsEqual = ( - currentInput: DateRangeFilterInput | undefined, - defaultInput: DateRangeFilterInput | undefined, - ): boolean => { - const currentStart = currentInput?.start; - const defaultStart = defaultInput?.start; - const currentStop = currentInput?.stop; - const defaultStop = defaultInput?.stop; - - return ( - isEqual( - currentStart ? parseISO(currentStart) : currentStart, - defaultStart ? parseISO(defaultStart) : defaultStart, - ) && - isEqual( - currentStop ? parseISO(currentStop) : currentStop, - defaultStop ? parseISO(defaultStop) : defaultStop, - ) - ); - }; - - /** - * Computes the number of filter inputs that are currently selected (i.e., not in their default state). - * - * - For checkbox inputs, increments the count if any values are selected. - * - For date range inputs, increments the count if either start or stop is set. - * - Inputs in their default state are not counted. - * - * @remarks - * This property is a computed signal and updates automatically when the filter state changes. - * - * @returns The number of filter inputs that are currently selected and differ from their default state. - */ - selectedFilterCount = computed(() => { - const currentState = getState(this.#state); - return currentState.inputs.reduce((count, input) => { - if (this.isDefaultFilterInput(input)) { - return count; - } - - if (input.type === InputType.Checkbox && input.selected?.length) { - return count + 1; - } - - if (input.type === InputType.DateRange && (input.start || input.stop)) { - return count + 1; - } - - return count; - }, 0); - }); - - /** - * Indicates whether the current state is empty. - */ - isEmpty = computed(() => { - const currentState = getState(this.#state); - return currentState.inputs.every((input) => { - if (input.type === InputType.Text) { - return !input.value; - } - - if (input.type === InputType.Checkbox) { - return !input.selected?.length; - } - - if (input.type === InputType.DateRange) { - return !input.start && !input.stop; - } - - this.#logger.warn(`Input type not supported`, () => ({ - input, - method: 'isEmptyFilter', - })); - - return true; - }); - }); - - /** - * Checks if a specific filter input has an empty value. - * For text inputs, checks if the value is falsy. - * For checkbox inputs, checks if the selected array is empty. - * For date range inputs, checks if both start and stop are falsy. - * - * @param filterInput - The filter input to check - * @returns True if the filter input is empty, false otherwise - */ - isEmptyFilterInput(filterInput: FilterInput): boolean { - const currentInputState = this.#state - .inputs() - .find((i) => i.key === filterInput.key); - - if (!currentInputState) { - this.#logger.warn(`Input not found`, () => ({ - inputKey: filterInput.key, - method: 'isEmptyFilterInput', - })); - return true; - } - - if (currentInputState.type === InputType.Text) { - return !currentInputState.value; - } - - if (currentInputState.type === InputType.Checkbox) { - return !currentInputState.selected?.length; - } - - if (currentInputState.type === InputType.DateRange) { - return !currentInputState.start && !currentInputState.stop; - } - - this.logUnsupportedInputType(currentInputState, 'isEmptyFilterInput'); - return true; - } - - /** - * Reverts the current state to the last committed state. - * This method restores the state by applying the previously saved committed state. - */ - rollback(): void { - const currentState = getState(this.#state); - const committedState = this.#commitdState(); - if (isEqual(currentState, committedState)) { - this.#logger.debug('No changes to rollback', () => ({ - changes: false, - })); - return; - } - this.#logger.debug('Rolling back filter state', () => ({ - changes: true, - currentState, - committedState, - })); - patchState(this.#state, this.#commitdState); - } - - /** - * Rolls back the input state for specific keys to their last committed state. - * If the input with the given key exists in the committed state, it replaces - * the current input with the committed one. Otherwise, the input remains unchanged. - * - * @param keys - The keys of the inputs to roll back - */ - rollbackInput(keys: string[]): void { - // Find committed inputs for the specified keys - const committedInputs = this.#commitdState().inputs; - - // First check if there's anything to rollback - const hasChangesToRollback = keys.some((key) => { - const currentInput = this.#state.inputs().find((i) => i.key === key); - const committedInput = committedInputs.find((i) => i.key === key); - return committedInput && !isEqual(currentInput, committedInput); - }); - - // Only proceed if there are changes to rollback - if (!hasChangesToRollback) { - this.#logger.debug('No changes to rollback for specified inputs', () => ({ - inputKeys: keys, - })); - return; - } - - // Apply rollback for changed inputs - const inputs = this.#state.inputs().map((input) => { - if (!keys.includes(input.key)) { - return input; - } - - // Get the committed version of this input - const committedInput = committedInputs.find((i) => i.key === input.key); - return committedInput || input; - }); - - this.#logger.debug('Rolling back specified inputs', () => ({ - inputKeys: keys, - })); - - patchState(this.#state, { inputs }); - } - - /** - * Commits the current state by capturing a snapshot of the internal state. - * This method updates the private `#commitdState` property with the current state - * and triggers any registered commit callbacks. - */ - commit(): void { - const currentState = getState(this.#state); - const committedState = this.#commitdState(); - - if (!isEqual(currentState, committedState)) { - this.#commitdState.set(currentState); - this.#logger.debug('Filter state committed', () => ({ - changes: true, - })); - } else { - this.#logger.debug('No changes to commit', () => ({ - changes: false, - })); - } - - this.#onCommit?.forEach((commitFn) => { - commitFn(); - }); - } - - /** - * Clears all filter values without resetting to default values. - * This sets text inputs to undefined, checkbox selections to empty arrays, - * and date ranges to undefined for both start and stop. - * - * @param options - Optional parameters - * @param options.commit - If true, commits the changes immediately after clearing - */ - clear(options?: { commit: boolean }): void { - // First check if there's anything to clear - const hasDataToClear = this.#state.inputs().some((input) => { - if (input.type === InputType.Text) { - return !!input.value; - } - - if (input.type === InputType.Checkbox) { - return input.selected?.length > 0; - } - - if (input.type === InputType.DateRange) { - return !!input.start || !!input.stop; - } - - return false; - }); - - // Only proceed if there's data to clear - if (!hasDataToClear) { - this.#logger.debug('No filter data to clear', () => ({ - changes: false, - })); - return; - } - - const inputs = this.#state.inputs().map((input) => { - if (input.type === InputType.Text) { - return { ...input, value: undefined }; - } - - if (input.type === InputType.Checkbox) { - return { ...input, selected: [] }; - } - - if (input.type === InputType.DateRange) { - return { ...input, start: undefined, stop: undefined }; - } - - return input; - }); - - this.#logger.debug('Clearing filter state', () => ({ - changes: true, - })); - - patchState(this.#state, { inputs }); - if (options?.commit) { - this.commit(); - } - } - - /** - * Resets the filter state to its default values based on the current settings. - * - * @param options - Optional parameters for the reset operation. - * @param options.commit - If `true`, the changes will be committed after resetting the state. - */ - reset(options?: { commit: boolean }): void { - // Use a more lightweight approach than structuredClone - patchState(this.#state, { ...this.defaultState }); - if (options?.commit) { - this.commit(); - } - } - /** - * Resets one or more inputs to their default state. - * - * @param keys - The key or array of keys of the inputs to reset. - * @param options - Optional parameters for the reset operation. - * @param options.commit - If `true`, commits the changes after resetting the input(s). - * - * @remarks - * - If no input is found with the specified key, a warning is logged for that key. - * - The method updates the state by replacing the input(s) with their default configuration. - * - * @example - * ```typescript - * // Reset a single input - * filterService.resetInput('exampleKey', { commit: true }); - * - * // Reset multiple inputs - * filterService.resetInput(['key1', 'key2'], { commit: true }); - * ``` - */ - resetInput(keys: string[], options?: { commit: boolean }): void { - // Use a more lightweight approach than structuredClone - const defaultFilter = { ...this.defaultState }; - - // Find all default inputs that match the provided keys - const inputsToReset = keys - .map((key) => { - const inputToReset = defaultFilter.inputs.find((i) => i.key === key); - - if (!inputToReset) { - this.#logger.warn(`No input found with key`, () => ({ - key, - method: 'resetInput', - })); - } - - return { key, defaultInput: inputToReset }; - }) - .filter((item) => item.defaultInput !== undefined); - - if (inputsToReset.length === 0) { - return; - } - - // Create a set of keys for faster lookups - const keysToReset = new Set(inputsToReset.map((item) => item.key)); - - const inputs = this.#state.inputs().map((input) => { - if (!keysToReset.has(input.key)) { - return input; - } - - const resetItem = inputsToReset.find((item) => item.key === input.key); - return resetItem?.defaultInput ?? input; - }); - - patchState(this.#state, { - inputs, - }); - - if (options?.commit) { - this.commit(); - } - } - - /** - * Resets the orderBy state to its default values. - * - * @param options - Optional parameters for the reset operation. - * @param options.commit - If `true`, the changes will be committed after resetting. - */ - resetOrderBy(options?: { commit: boolean }): void { - // Use a more lightweight approach than structuredClone - patchState(this.#state, { orderBy: [...this.defaultState.orderBy] }); - - if (options?.commit) { - this.commit(); - } - } - - queryParams = computed>(() => { - const commited = this.#commitdState(); - const result: Record = {}; - - for (const input of commited.inputs) { - switch (input.type) { - case InputType.Text: - if (input.value) { - result[input.key] = input.value; - } else { - result[input.key] = ''; - } - break; - case InputType.Checkbox: - if (input.selected) { - result[input.key] = input.selected.join(';'); - } else { - result[input.key] = ''; - } - break; - case InputType.DateRange: - if (input.start && input.stop) { - result[input.key] = `"${input.start}"-"${input.stop}"`; - } else if (input.start) { - result[input.key] = `"${input.start}"-`; - } else if (input.stop) { - result[input.key] = `-"${input.stop}"`; - } else { - result[input.key] = ''; - } - break; - } - } - - const orderBy = commited.orderBy.find((o) => o.selected); - - if (orderBy) { - result['order_by'] = `${orderBy.by}:${orderBy.dir}`; - } else { - // Ensure 'orderBy' is not included if no order is selected - result['order_by'] = ''; - } - - return result; - }); - - /** - * Checks if the current query parameters match the provided parameters. - * - * @param params - The parameters to compare with the current query parameters - * @returns True if the parameters match, false otherwise - */ - isQueryParamsEqual(params: Record): boolean { - const currentParams = this.queryParams(); - return this.queryParamKeys().every( - (key) => params[key] === currentParams[key], - ); - } - - queryParamKeys = computed(() => { - const keys = this.inputs().map((i) => i.key); - const orderBy = this.orderBy().find((o) => o.dir); - - if (orderBy) { - keys.push('order_by'); - } - - return keys; - }); - - query = computed(() => { - const commited = this.#commitdState(); - - const filterGroup = commited.inputs.filter((i) => i.group === 'filter'); - const inputGroup = commited.inputs.filter((i) => i.group === 'main'); - const orderBy = commited.orderBy.filter((o) => o.dir); - - return QuerySchema.parse({ - filter: mapFilterInputToRecord(filterGroup), - input: mapFilterInputToRecord(inputGroup), - orderBy: orderBy - .filter((o) => o.selected) - .map((o) => { - return { - by: o.by, - label: o.label, - desc: o.dir === 'desc', - selected: true, - }; - }), - }); - }); - - /** - * Parses query parameters into filter state. - * - * @param params - Record of query parameters to parse - * @param options - Optional parameters - * @param options.commit - If true, commits the changes immediately after parsing - */ parseQueryParams( - params: Record, - options?: { commit: boolean }, - ): void { - this.reset(); - - for (const key in params) { - if (key === 'order_by') { - const [by, dir] = params[key].split(':'); - const orderBy = this.orderBy().some( - (o) => o.by === by && o.dir === dir, - ); - - if (orderBy) { - this.#logger.warn(`OrderBy already exists`, () => ({ - by, - dir, - method: 'parseQueryParams', - })); - this.setOrderBy(by, OrderByDirectionSchema.parse(dir)); - } - continue; - } - - const input = this.inputs().find((i) => i.key === key); - if (!input) { - this.#logger.warn(`Input not found for key`, () => ({ - key, - method: 'parseQueryParams', - })); - continue; - } - - switch (input.type) { - case InputType.Text: - this.setInputTextValue(key, params[key]); - break; - case InputType.Checkbox: - this.setInputCheckboxValue(key, params[key].split(';')); - break; - case InputType.DateRange: { - const decoded = decodeURIComponent(params[key]); - const [startRaw, stopRaw] = decoded.split('-"'); - const start = - startRaw?.replace(/"-/g, '')?.replace(/"/g, '') || undefined; // Removes " and - from for example: "2023-06-05T22:00:00.000Z"- - const stop = - stopRaw?.replace(/-"/g, '')?.replace(/"/g, '') || undefined; // Removes " and - from for example: -"2023-06-05T22:00:00.000Z" - this.setInputDateRangeValue(key, start, stop); - break; - } - default: - this.logUnsupportedInputType(input, 'parseQueryParams'); - break; - } - } - - if (options?.commit) { - this.commit(); - } - } -} -export { QUERY_SETTINGS }; +import { computed, inject, Injectable, signal } from '@angular/core'; +import { InputType } from '../types'; +import { getState, patchState, signalState } from '@ngrx/signals'; +import { filterMapping, mapFilterInputToRecord } from './mappings'; +import { isEqual } from 'lodash'; +import { + CheckboxFilterInputOption, + DateRangeFilterInput, + FilterInput, + OrderByDirection, + OrderByDirectionSchema, + Query, + QuerySchema, +} from './schemas'; +import { FILTER_ON_COMMIT, FILTER_ON_INIT, QUERY_SETTINGS } from './tokens'; +import { logger } from '@isa/core/logging'; +import { parseISO } from 'date-fns'; +import { checkboxOptionKeysHelper } from '../helpers/checkbox-option-keys.helper'; + +@Injectable() +export class FilterService { + #logger = logger(() => ({ + library: 'shared/filter', + class: 'FilterService', + })); + + #onInit = inject(FILTER_ON_INIT, { optional: true })?.map((fn) => fn(this)); + #onCommit = inject(FILTER_ON_COMMIT, { optional: true })?.map((fn) => + fn(this), + ); + + readonly settings = inject(QUERY_SETTINGS); + + private readonly defaultState = filterMapping(this.settings); + + // Use a more lightweight approach for creating the initial state + #commitdState = signal({ ...this.defaultState }); + + #state = signalState(this.#commitdState()); + + groups = this.#state.groups; + + inputs = this.#state.inputs; + + orderBy = this.#state.orderBy; + + constructor() { + this.#onInit?.forEach((initFn) => { + initFn(); + }); + } + + /** + * Sets the ordering field and direction for the filter. + * + * @param by - The field to order by + * @param dir - The direction to order by (asc or desc) + * @param options - Optional parameters + * @param options.commit - If true, commits the changes immediately + */ + setOrderBy( + by: string, + dir: OrderByDirection | undefined, + options?: { commit: boolean }, + ): void { + const orderByList = this.#state.orderBy().map((o) => { + if (o.by === by && o.dir === dir) { + return { ...o, selected: true }; + } + return { ...o, selected: false }; + }); + + patchState(this.#state, { orderBy: orderByList }); + + if (options?.commit) { + this.commit(); + } + } + + /** + * Sets the text value for an input with the specified key. + * + * @param key - The key of the input to update + * @param value - The new text value to set + * @param options - Optional parameters + * @param options.commit - If true, commits the changes immediately + */ + setInputTextValue( + key: string, + value: string | undefined, + options?: { commit: boolean }, + ): void { + const inputs = this.#state.inputs().map((input) => { + if (input.key !== key) { + return input; + } + + if (input.type === InputType.Text) { + return { ...input, value }; + } + + this.logUnsupportedInputType(input, 'setInputTextValue'); + return input; + }); + + patchState(this.#state, { inputs }); + + if (options?.commit) { + this.commit(); + } + } + + /** + * Sets the selected values for a checkbox input with the specified key. + * + * @param key - The key of the checkbox input to update + * @param selected - Array of selected values + * @param options - Optional parameters + * @param options.commit - If true, commits the changes immediately + */ + setInputCheckboxValue( + key: string, + selected: string[], + options?: { commit: boolean }, + ): void { + const inputs = this.#state.inputs().map((input) => { + if (input.key !== key) { + return input; + } + + if (input.type === InputType.Checkbox) { + return { ...input, selected }; + } + + this.logUnsupportedInputType(input, 'setInputCheckboxValue'); + return input; + }); + + patchState(this.#state, { inputs }); + + if (options?.commit) { + this.commit(); + } + } + + /** + * Sets the selection state of a specific checkbox option within a hierarchical structure. + * + * This method handles the selection/deselection of checkbox options with automatic + * parent-child relationship management: + * - When selecting a parent option, all child options are implicitly selected + * - When deselecting a parent option, all child options are also deselected + * - Child options can be individually selected/deselected + * + * @param path - Array representing the hierarchical path to the option [group, groupKey, ...optionKeys] + * @param selected - Whether to select (true) or deselect (false) the option + * @param options - Optional parameters + * @param options.commit - If true, commits the changes immediately + * + * @example + * ```typescript + * // Select a specific option + * filterService.setInputCheckboxOptionSelected( + * ['category', 'products', 'electronics', 'phones'], + * true + * ); + * + * // Deselect a parent option (also deselects all children) + * filterService.setInputCheckboxOptionSelected( + * ['category', 'products', 'electronics'], + * false, + * { commit: true } + * ); + * ``` + */ + setInputCheckboxOptionSelected( + checkboxOption: CheckboxFilterInputOption, + // [group, groupKey, ...optionKeys]: string[], + selected: boolean, + options?: { commit: boolean }, + ): void { + const [group, groupKey, ...optionKeys] = checkboxOption.path; + + const inputs = this.#state.inputs().map((input) => { + const target = input.group === group && input.key === groupKey; + + if (!target) { + return input; + } + + if (input.type !== InputType.Checkbox) { + this.logUnsupportedInputType(input, 'setInputCheckboxValue'); + return input; + } + + const isParent = + Array.isArray(checkboxOption.values) && + checkboxOption.values.length > 0; + let keys: string[] = []; + + if (isParent) { + // If the option has children, we need to include all child keys + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + keys = checkboxOption.values!.map((v) => + checkboxOptionKeysHelper(v).join('.'), + ); + } else { + // If no children, just use the current option key + keys = [optionKeys.join('.')]; + } + + if (selected) { + const newSelected = [...input.selected, ...keys]; + + return { + ...input, + selected: newSelected, + }; + } else { + const filteredSelected = input.selected.filter((s) => { + // Remove the key itself and any child keys + return keys.includes(s) === false; + }); + + return { + ...input, + selected: filteredSelected, + }; + } + }); + + patchState(this.#state, { inputs }); + + if (options?.commit) { + this.commit(); + } + } + + // getInputCheckboxOptionSelected([group, groupKey, ...optionKeys]: string[]): + // | boolean + // | undefined { + // const input = this.#state.inputs().find((input) => { + // return input.group === group && input.key === groupKey; + // }); + + // if (!input) { + // this.#logger.warn(`Input not found`, () => ({ + // inputGroup: group, + // inputKey: groupKey, + // })); + // return undefined; + // } + + // if (input.type !== InputType.Checkbox) { + // this.logUnsupportedInputType(input, 'getInputCheckboxValue'); + // return undefined; + // } + + // return input.selected.includes(optionKeys.join('.')); + // } + + /** + * Sets the date range values for an input with the specified key. + * + * @param key - The key of the date range input to update + * @param start - The start date as a string + * @param stop - The end date as a string + * @param options - Optional parameters + * @param options.commit - If true, commits the changes immediately + */ + setInputDateRangeValue( + key: string, + start?: string, + stop?: string, + options?: { commit: boolean }, + ): void { + const inputs = this.#state.inputs().map((input) => { + if (input.key !== key) { + return input; + } + + if (input.type === InputType.DateRange) { + return { ...input, start, stop }; + } + + this.logUnsupportedInputType(input, 'setInputDateRangeValue'); + return input; + }); + + patchState(this.#state, { inputs }); + + if (options?.commit) { + this.commit(); + } + } + + /** + * Helper method to consistently log unsupported input type warnings + * @private + * @param input - The input that has an unsupported type + * @param method - The method name where the warning occurred + */ + private logUnsupportedInputType(input: FilterInput, method: string): void { + this.#logger.warn(`Input type not supported`, () => ({ + inputType: input.type, + inputKey: input.key, + method, + })); + } + + /** + * Indicates whether the current state is the default state. + * This computed property checks if the current state is equal to the default state. + */ + isDefaultFilter = computed(() => { + const currentState = getState(this.#state); + return isEqual(currentState.inputs, this.defaultState.inputs); + }); + + /** + * Checks if a specific filter input is in its default state. + * + * @param filterInput - The filter input to check + * @returns True if the input is in its default state, false otherwise + */ + isDefaultFilterInput(filterInput: FilterInput): boolean { + const currentInputState = this.#state + .inputs() + .find((i) => i.key === filterInput.key); + const defaultInputState = this.defaultState.inputs.find( + (i) => i.key === filterInput.key, + ); + + // For DateRange inputs, compare dates by value, not string precision + if (filterInput.type === InputType.DateRange) { + return this._areDateRangeInputsEqual( + currentInputState as DateRangeFilterInput, + defaultInputState as DateRangeFilterInput, + ); + } + + return isEqual(currentInputState, defaultInputState); + } + + /** + * Compares two date range filter inputs for equality, accounting for possible differences + * in date string precision (e.g., "2023-06-05T22:00:00Z" vs. "2023-06-05T22:00:00.000Z"). + * Converts ISO date strings to Date objects before comparison to ensure consistent results. + * + * @param currentInput - The current DateRangeFilterInput state + * @param defaultInput - The default DateRangeFilterInput state + * @returns True if both start and stop dates are equal (ignoring string precision), false otherwise + */ + private _areDateRangeInputsEqual = ( + currentInput: DateRangeFilterInput | undefined, + defaultInput: DateRangeFilterInput | undefined, + ): boolean => { + const currentStart = currentInput?.start; + const defaultStart = defaultInput?.start; + const currentStop = currentInput?.stop; + const defaultStop = defaultInput?.stop; + + return ( + isEqual( + currentStart ? parseISO(currentStart) : currentStart, + defaultStart ? parseISO(defaultStart) : defaultStart, + ) && + isEqual( + currentStop ? parseISO(currentStop) : currentStop, + defaultStop ? parseISO(defaultStop) : defaultStop, + ) + ); + }; + + /** + * Computes the number of filter inputs that are currently selected (i.e., not in their default state). + * + * - For checkbox inputs, increments the count if any values are selected. + * - For date range inputs, increments the count if either start or stop is set. + * - Inputs in their default state are not counted. + * + * @remarks + * This property is a computed signal and updates automatically when the filter state changes. + * + * @returns The number of filter inputs that are currently selected and differ from their default state. + */ + selectedFilterCount = computed(() => { + const currentState = getState(this.#state); + return currentState.inputs.reduce((count, input) => { + if (this.isDefaultFilterInput(input)) { + return count; + } + + if (input.type === InputType.Checkbox && input.selected?.length) { + return count + 1; + } + + if (input.type === InputType.DateRange && (input.start || input.stop)) { + return count + 1; + } + + return count; + }, 0); + }); + + /** + * Indicates whether the current state is empty. + */ + isEmpty = computed(() => { + const currentState = getState(this.#state); + return currentState.inputs.every((input) => { + if (input.type === InputType.Text) { + return !input.value; + } + + if (input.type === InputType.Checkbox) { + return !input.selected?.length; + } + + if (input.type === InputType.DateRange) { + return !input.start && !input.stop; + } + + this.#logger.warn(`Input type not supported`, () => ({ + input, + method: 'isEmptyFilter', + })); + + return true; + }); + }); + + /** + * Checks if a specific filter input has an empty value. + * For text inputs, checks if the value is falsy. + * For checkbox inputs, checks if the selected array is empty. + * For date range inputs, checks if both start and stop are falsy. + * + * @param filterInput - The filter input to check + * @returns True if the filter input is empty, false otherwise + */ + isEmptyFilterInput(filterInput: FilterInput): boolean { + const currentInputState = this.#state + .inputs() + .find((i) => i.key === filterInput.key); + + if (!currentInputState) { + this.#logger.warn(`Input not found`, () => ({ + inputKey: filterInput.key, + method: 'isEmptyFilterInput', + })); + return true; + } + + if (currentInputState.type === InputType.Text) { + return !currentInputState.value; + } + + if (currentInputState.type === InputType.Checkbox) { + return !currentInputState.selected?.length; + } + + if (currentInputState.type === InputType.DateRange) { + return !currentInputState.start && !currentInputState.stop; + } + + this.logUnsupportedInputType(currentInputState, 'isEmptyFilterInput'); + return true; + } + + /** + * Reverts the current state to the last committed state. + * This method restores the state by applying the previously saved committed state. + */ + rollback(): void { + const currentState = getState(this.#state); + const committedState = this.#commitdState(); + if (isEqual(currentState, committedState)) { + this.#logger.debug('No changes to rollback', () => ({ + changes: false, + })); + return; + } + this.#logger.debug('Rolling back filter state', () => ({ + changes: true, + currentState, + committedState, + })); + patchState(this.#state, this.#commitdState); + } + + /** + * Rolls back the input state for specific keys to their last committed state. + * If the input with the given key exists in the committed state, it replaces + * the current input with the committed one. Otherwise, the input remains unchanged. + * + * @param keys - The keys of the inputs to roll back + */ + rollbackInput(keys: string[]): void { + // Find committed inputs for the specified keys + const committedInputs = this.#commitdState().inputs; + + // First check if there's anything to rollback + const hasChangesToRollback = keys.some((key) => { + const currentInput = this.#state.inputs().find((i) => i.key === key); + const committedInput = committedInputs.find((i) => i.key === key); + return committedInput && !isEqual(currentInput, committedInput); + }); + + // Only proceed if there are changes to rollback + if (!hasChangesToRollback) { + this.#logger.debug('No changes to rollback for specified inputs', () => ({ + inputKeys: keys, + })); + return; + } + + // Apply rollback for changed inputs + const inputs = this.#state.inputs().map((input) => { + if (!keys.includes(input.key)) { + return input; + } + + // Get the committed version of this input + const committedInput = committedInputs.find((i) => i.key === input.key); + return committedInput || input; + }); + + this.#logger.debug('Rolling back specified inputs', () => ({ + inputKeys: keys, + })); + + patchState(this.#state, { inputs }); + } + + /** + * Commits the current state by capturing a snapshot of the internal state. + * This method updates the private `#commitdState` property with the current state + * and triggers any registered commit callbacks. + */ + commit(): void { + const currentState = getState(this.#state); + const committedState = this.#commitdState(); + + if (!isEqual(currentState, committedState)) { + this.#commitdState.set(currentState); + this.#logger.debug('Filter state committed', () => ({ + changes: true, + })); + } else { + this.#logger.debug('No changes to commit', () => ({ + changes: false, + })); + } + + this.#onCommit?.forEach((commitFn) => { + commitFn(); + }); + } + + /** + * Clears all filter values without resetting to default values. + * This sets text inputs to undefined, checkbox selections to empty arrays, + * and date ranges to undefined for both start and stop. + * + * @param options - Optional parameters + * @param options.commit - If true, commits the changes immediately after clearing + */ + clear(options?: { commit: boolean }): void { + // First check if there's anything to clear + const hasDataToClear = this.#state.inputs().some((input) => { + if (input.type === InputType.Text) { + return !!input.value; + } + + if (input.type === InputType.Checkbox) { + return input.selected?.length > 0; + } + + if (input.type === InputType.DateRange) { + return !!input.start || !!input.stop; + } + + return false; + }); + + // Only proceed if there's data to clear + if (!hasDataToClear) { + this.#logger.debug('No filter data to clear', () => ({ + changes: false, + })); + return; + } + + const inputs = this.#state.inputs().map((input) => { + if (input.type === InputType.Text) { + return { ...input, value: undefined }; + } + + if (input.type === InputType.Checkbox) { + return { ...input, selected: [] }; + } + + if (input.type === InputType.DateRange) { + return { ...input, start: undefined, stop: undefined }; + } + + return input; + }); + + this.#logger.debug('Clearing filter state', () => ({ + changes: true, + })); + + patchState(this.#state, { inputs }); + if (options?.commit) { + this.commit(); + } + } + + /** + * Resets the filter state to its default values based on the current settings. + * + * @param options - Optional parameters for the reset operation. + * @param options.commit - If `true`, the changes will be committed after resetting the state. + */ + reset(options?: { commit: boolean }): void { + // Use a more lightweight approach than structuredClone + patchState(this.#state, { ...this.defaultState }); + if (options?.commit) { + this.commit(); + } + } + /** + * Resets one or more inputs to their default state. + * + * @param keys - The key or array of keys of the inputs to reset. + * @param options - Optional parameters for the reset operation. + * @param options.commit - If `true`, commits the changes after resetting the input(s). + * + * @remarks + * - If no input is found with the specified key, a warning is logged for that key. + * - The method updates the state by replacing the input(s) with their default configuration. + * + * @example + * ```typescript + * // Reset a single input + * filterService.resetInput('exampleKey', { commit: true }); + * + * // Reset multiple inputs + * filterService.resetInput(['key1', 'key2'], { commit: true }); + * ``` + */ + resetInput(keys: string[], options?: { commit: boolean }): void { + // Use a more lightweight approach than structuredClone + const defaultFilter = { ...this.defaultState }; + + // Find all default inputs that match the provided keys + const inputsToReset = keys + .map((key) => { + const inputToReset = defaultFilter.inputs.find((i) => i.key === key); + + if (!inputToReset) { + this.#logger.warn(`No input found with key`, () => ({ + key, + method: 'resetInput', + })); + } + + return { key, defaultInput: inputToReset }; + }) + .filter((item) => item.defaultInput !== undefined); + + if (inputsToReset.length === 0) { + return; + } + + // Create a set of keys for faster lookups + const keysToReset = new Set(inputsToReset.map((item) => item.key)); + + const inputs = this.#state.inputs().map((input) => { + if (!keysToReset.has(input.key)) { + return input; + } + + const resetItem = inputsToReset.find((item) => item.key === input.key); + return resetItem?.defaultInput ?? input; + }); + + patchState(this.#state, { + inputs, + }); + + if (options?.commit) { + this.commit(); + } + } + + /** + * Resets the orderBy state to its default values. + * + * @param options - Optional parameters for the reset operation. + * @param options.commit - If `true`, the changes will be committed after resetting. + */ + resetOrderBy(options?: { commit: boolean }): void { + // Use a more lightweight approach than structuredClone + patchState(this.#state, { orderBy: [...this.defaultState.orderBy] }); + + if (options?.commit) { + this.commit(); + } + } + + queryParams = computed>(() => { + const commited = this.#commitdState(); + const result: Record = {}; + + for (const input of commited.inputs) { + switch (input.type) { + case InputType.Text: + if (input.value) { + result[input.key] = input.value; + } else { + result[input.key] = ''; + } + break; + case InputType.Checkbox: + if (input.selected) { + result[input.key] = input.selected.join(';'); + } else { + result[input.key] = ''; + } + break; + case InputType.DateRange: + if (input.start && input.stop) { + result[input.key] = `"${input.start}"-"${input.stop}"`; + } else if (input.start) { + result[input.key] = `"${input.start}"-`; + } else if (input.stop) { + result[input.key] = `-"${input.stop}"`; + } else { + result[input.key] = ''; + } + break; + } + } + + const orderBy = commited.orderBy.find((o) => o.selected); + + if (orderBy) { + result['order_by'] = `${orderBy.by}:${orderBy.dir}`; + } else { + // Ensure 'orderBy' is not included if no order is selected + result['order_by'] = ''; + } + + return result; + }); + + /** + * Checks if the current query parameters match the provided parameters. + * + * @param params - The parameters to compare with the current query parameters + * @returns True if the parameters match, false otherwise + */ + isQueryParamsEqual(params: Record): boolean { + const currentParams = this.queryParams(); + return this.queryParamKeys().every( + (key) => params[key] === currentParams[key], + ); + } + + queryParamKeys = computed(() => { + const keys = this.inputs().map((i) => i.key); + const orderBy = this.orderBy().find((o) => o.dir); + + if (orderBy) { + keys.push('order_by'); + } + + return keys; + }); + + query = computed(() => { + const commited = this.#commitdState(); + + const filterGroup = commited.inputs.filter((i) => i.group === 'filter'); + const inputGroup = commited.inputs.filter((i) => i.group === 'main'); + const orderBy = commited.orderBy.filter((o) => o.dir); + + return QuerySchema.parse({ + filter: mapFilterInputToRecord(filterGroup), + input: mapFilterInputToRecord(inputGroup), + orderBy: orderBy + .filter((o) => o.selected) + .map((o) => { + return { + by: o.by, + label: o.label, + desc: o.dir === 'desc', + selected: true, + }; + }), + }); + }); + + /** + * Parses query parameters into filter state. + * + * @param params - Record of query parameters to parse + * @param options - Optional parameters + * @param options.commit - If true, commits the changes immediately after parsing + */ parseQueryParams( + params: Record, + options?: { commit: boolean }, + ): void { + this.reset(); + + for (const key in params) { + if (key === 'order_by') { + const [by, dir] = params[key].split(':'); + const orderBy = this.orderBy().some( + (o) => o.by === by && o.dir === dir, + ); + + if (orderBy) { + this.#logger.warn(`OrderBy already exists`, () => ({ + by, + dir, + method: 'parseQueryParams', + })); + this.setOrderBy(by, OrderByDirectionSchema.parse(dir)); + } + continue; + } + + const input = this.inputs().find((i) => i.key === key); + if (!input) { + this.#logger.warn(`Input not found for key`, () => ({ + key, + method: 'parseQueryParams', + })); + continue; + } + + switch (input.type) { + case InputType.Text: + this.setInputTextValue(key, params[key]); + break; + case InputType.Checkbox: + this.setInputCheckboxValue(key, params[key].split(';')); + break; + case InputType.DateRange: { + const decoded = decodeURIComponent(params[key]); + const [startRaw, stopRaw] = decoded.split('-"'); + const start = + startRaw?.replace(/"-/g, '')?.replace(/"/g, '') || undefined; // Removes " and - from for example: "2023-06-05T22:00:00.000Z"- + const stop = + stopRaw?.replace(/-"/g, '')?.replace(/"/g, '') || undefined; // Removes " and - from for example: -"2023-06-05T22:00:00.000Z" + this.setInputDateRangeValue(key, start, stop); + break; + } + default: + this.logUnsupportedInputType(input, 'parseQueryParams'); + break; + } + } + + if (options?.commit) { + this.commit(); + } + } +} +export { QUERY_SETTINGS }; 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 057aef600..a5ce085bf 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 @@ -1,34 +1,53 @@ -import { Input, InputType } from '../../types'; -import { CheckboxFilterInput, CheckboxFilterInputSchema } from '../schemas'; -import { checkboxOptionMapping } from './checkbox-option.mapping'; - -/** - * Maps an Input object to a CheckboxFilterInput object - * - * This function takes an input of type Checkbox and maps it to a strongly-typed - * CheckboxFilterInput object, validating it against a schema. It also maps all child - * options and tracks which options are selected. - * - * @param group - The group identifier that this input belongs to - * @param input - The source input object to map - * @returns A validated CheckboxFilterInput object - */ -export function checkboxFilterInputMapping( - group: string, - input: Input, -): CheckboxFilterInput { - return CheckboxFilterInputSchema.parse({ - group, - key: input.key, - label: input.label, - description: input.description, - type: InputType.Checkbox, - defaultValue: input.value, - maxOptions: input.options?.max, - options: input.options?.values?.map(checkboxOptionMapping), - selected: - input.options?.values - ?.filter((option) => option.selected) - .map((option) => option.value) || [], - }); -} +import { Input, InputType } from '../../types'; +import { CheckboxFilterInput, CheckboxFilterInputSchema } from '../schemas'; +import { checkboxOptionMapping } from './checkbox-option.mapping'; + +/** + * Maps an Input object to a CheckboxFilterInput object with support for hierarchical options. + * + * This function transforms a generic Input object into a strongly-typed CheckboxFilterInput, + * handling nested checkbox options and tracking selection states. The mapping process: + * - Validates the input against the CheckboxFilterInputSchema + * - Recursively maps nested options with proper path tracking + * - Extracts selected values from the option tree + * + * @param group - The group identifier that this input belongs to + * @param input - The source input object containing checkbox configuration + * @returns A validated CheckboxFilterInput object with hierarchical options + * + * @example + * ```typescript + * const checkboxInput = checkboxFilterInputMapping('filters', { + * key: 'category', + * label: 'Product Category', + * type: 'checkbox', + * options: { + * values: [ + * { value: 'electronics', label: 'Electronics', selected: true }, + * { value: 'clothing', label: 'Clothing' } + * ] + * } + * }); + * ``` + */ +export function checkboxFilterInputMapping( + group: string, + input: Input, +): CheckboxFilterInput { + return CheckboxFilterInputSchema.parse({ + group, + key: input.key, + label: input.label, + description: input.description, + type: InputType.Checkbox, + defaultValue: input.value, + maxOptions: input.options?.max, + options: input.options?.values?.map((v) => + checkboxOptionMapping(v, [group, input.key]), + ), + selected: + input.options?.values + ?.filter((option) => option.selected) + .map((option) => option.value) || [], + }); +} 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 index f60723995..b3f79aa12 100644 --- 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 @@ -1,63 +1,232 @@ -import { Option } from '../../types'; -import { checkboxOptionMapping } from './checkbox-option.mapping'; -import * as schemaModule from '../schemas/checkbox-filter-input-option.schema'; - -describe('checkboxOptionMapping', () => { - const mockSchemaParser = jest.fn().mockImplementation((input) => input); - - beforeEach(() => { - jest.clearAllMocks(); - - // Mock the schema parse method to avoid validation errors in tests - jest - .spyOn(schemaModule.CheckboxFilterInputOptionSchema, 'parse') - .mockImplementation(mockSchemaParser); - }); - - it('should map option correctly', () => { - // Arrange - const option: Option = { - label: 'Option Label', - value: 'option-value', - }; - - // Act - const result = checkboxOptionMapping(option); - - // Assert - expect(mockSchemaParser).toHaveBeenCalledWith({ - label: 'Option Label', - value: 'option-value', - }); - - expect(result).toEqual({ - label: 'Option Label', - value: 'option-value', - }); - }); - - it('should map option with selected property', () => { - // Arrange - const option: Option = { - label: 'Option Label', - value: 'option-value', - selected: true, - }; - - // Act - const result = checkboxOptionMapping(option); - - // Assert - expect(mockSchemaParser).toHaveBeenCalledWith({ - label: 'Option Label', - value: 'option-value', - }); - - expect(result).toEqual({ - label: 'Option Label', - value: 'option-value', - }); - // The selected property should not be included in the mapped result - expect(result).not.toHaveProperty('selected'); - }); -}); +import { Option } from '../../types'; +import { checkboxOptionMapping } from './checkbox-option.mapping'; +import * as schemaModule from '../schemas/checkbox-filter-input-option.schema'; + +describe('checkboxOptionMapping', () => { + const mockSchemaParser = jest.fn().mockImplementation((input) => input); + + beforeEach(() => { + jest.clearAllMocks(); + + // Mock the schema parse method to avoid validation errors in tests + jest + .spyOn(schemaModule.CheckboxFilterInputOptionSchema, 'parse') + .mockImplementation(mockSchemaParser); + }); + + it('should map option correctly', () => { + // Arrange + const option: Option = { + key: 'Option Key', + label: 'Option Label', + value: 'option-value', + }; + + // Act + const result = checkboxOptionMapping(option); + + // Assert + expect(result).toEqual({ + key: 'Option Key', + label: 'Option Label', + value: 'option-value', + path: ['Option Key'], + }); + }); + + it('should map option with selected property', () => { + // Arrange + const option: Option = { + label: 'Option Label', + value: 'option-value', + selected: true, + }; + + // Act + const result = checkboxOptionMapping(option); + + // Assert + expect(result).toEqual({ + key: 'option-value', + label: 'Option Label', + value: 'option-value', + path: ['option-value'], + }); + // The selected property should not be included in the mapped result + expect(result).not.toHaveProperty('selected'); + }); + + it('should use value as key when key is not provided', () => { + // Arrange + const option: Option = { + label: 'Option Label', + value: 'option-value', + }; + + // Act + const result = checkboxOptionMapping(option); + + // Assert + expect(result).toEqual({ + key: 'option-value', + label: 'Option Label', + value: 'option-value', + path: ['option-value'], + }); + }); + + it('should handle custom path parameter', () => { + // Arrange + const option: Option = { + key: 'child', + label: 'Child Option', + value: 'child-value', + }; + const path = ['group', 'parent']; + + // Act + const result = checkboxOptionMapping(option, path); + + // Assert + expect(result).toEqual({ + key: 'child', + label: 'Child Option', + value: 'child-value', + path: ['group', 'parent', 'child'], + }); + }); + + it('should map nested options with one level', () => { + // Arrange + const option: Option = { + key: 'parent', + label: 'Parent Option', + value: 'parent-value', + values: [ + { + key: 'child1', + label: 'Child 1', + value: 'child1-value', + }, + { + key: 'child2', + label: 'Child 2', + value: 'child2-value', + }, + ], + }; + + // Act + const result = checkboxOptionMapping(option); + + // Assert + expect(result).toEqual({ + key: 'parent', + label: 'Parent Option', + value: 'parent-value', + path: ['parent'], + values: [ + { + key: 'child1', + label: 'Child 1', + value: 'child1-value', + path: ['parent', 'child1'], + }, + { + key: 'child2', + label: 'Child 2', + value: 'child2-value', + path: ['parent', 'child2'], + }, + ], + }); + }); + + it('should handle options with undefined values array', () => { + // Arrange + const option: Option = { + key: 'leaf', + label: 'Leaf Option', + value: 'leaf-value', + values: undefined, + }; + + // Act + const result = checkboxOptionMapping(option); + + // Assert + expect(result).toEqual({ + key: 'leaf', + label: 'Leaf Option', + value: 'leaf-value', + path: ['leaf'], + values: undefined, + }); + expect(result.values).toBeUndefined(); + }); + + it('should handle options with empty values array', () => { + // Arrange + const option: Option = { + key: 'empty-parent', + label: 'Empty Parent', + value: 'empty-parent-value', + values: [], + }; + + // Act + const result = checkboxOptionMapping(option); + + // Assert + expect(result).toEqual({ + key: 'empty-parent', + label: 'Empty Parent', + value: 'empty-parent-value', + path: ['empty-parent'], + values: [], + }); + }); + + it('should call schema parse with correct data', () => { + // Arrange + const option: Option = { + key: 'test', + label: 'Test', + value: 'test-value', + }; + + // Act + checkboxOptionMapping(option); + + // Assert + expect(mockSchemaParser).toHaveBeenCalledWith({ + path: ['test'], + key: 'test', + label: 'Test', + value: 'test-value', + }); + }); + + it('should preserve path immutability when mapping nested options', () => { + // Arrange + const option: Option = { + key: 'parent', + label: 'Parent', + value: 'parent-value', + values: [ + { + key: 'child', + label: 'Child', + value: 'child-value', + }, + ], + }; + const initialPath = ['root']; + + // Act + checkboxOptionMapping(option, initialPath); + + // Assert + expect(initialPath).toEqual(['root']); // Original path should not be modified + }); +}); 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 d665e115d..580d0c338 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,23 +1,48 @@ -import { Option } from '../../types'; -import { - CheckboxFilterInputOption, - CheckboxFilterInputOptionSchema, -} from '../schemas'; - -/** - * Maps an Option object to a CheckboxFilterInputOption object - * - * This function converts a generic Option to a strongly-typed - * CheckboxFilterInputOption, validating it against a schema. - * - * @param option - The source option object to map - * @returns A validated CheckboxFilterInputOption object - */ -export function checkboxOptionMapping( - option: Option, -): CheckboxFilterInputOption { - return CheckboxFilterInputOptionSchema.parse({ - label: option.label, - value: option.value, - }); -} +import { getOptionKeyHelper } from '../../helpers'; +import { Option } from '../../types'; +import { + CheckboxFilterInputOption, + CheckboxFilterInputOptionSchema, +} from '../schemas'; + +/** + * Recursively maps an Option object to a CheckboxFilterInputOption with hierarchical path tracking. + * + * This function transforms a generic Option into a strongly-typed CheckboxFilterInputOption, + * building a hierarchical path that uniquely identifies the option's position in the tree. + * The mapping process: + * - Generates a unique path by combining parent paths with the current option's key + * - Recursively processes nested options to build the complete hierarchy + * - Validates the result against the CheckboxFilterInputOptionSchema + * + * @param option - The source option object containing label, value, and optional nested values + * @param path - Array of strings representing the path from root to the parent of this option + * @returns A validated CheckboxFilterInputOption with complete hierarchical path information + * + * @example + * ```typescript + * const mappedOption = checkboxOptionMapping({ + * label: 'Smartphones', + * value: 'smartphones', + * values: [ + * { label: 'iPhone', value: 'iphone' }, + * { label: 'Android', value: 'android' } + * ] + * }, ['category', 'electronics']); + * // Results in path: ['category', 'electronics', 'smartphones'] + * ``` + */ +export function checkboxOptionMapping( + option: Option, + path: string[] = [], +): CheckboxFilterInputOption { + const key = getOptionKeyHelper(option); + + return CheckboxFilterInputOptionSchema.parse({ + path: [...path, key], + key: key, // Use value as key if not provided + label: option.label, + value: option.value, + values: option.values?.map((v) => checkboxOptionMapping(v, [...path, key])), + }); +} diff --git a/libs/shared/filter/src/lib/core/mappings/filter-input-to-record.mapping.spec.ts b/libs/shared/filter/src/lib/core/mappings/filter-input-to-record.mapping.spec.ts index 8f5518b65..bfde29f35 100644 --- a/libs/shared/filter/src/lib/core/mappings/filter-input-to-record.mapping.spec.ts +++ b/libs/shared/filter/src/lib/core/mappings/filter-input-to-record.mapping.spec.ts @@ -1,40 +1,311 @@ -import { mapFilterInputToRecord } from './filter-input-to-record.mapping'; -import { InputType } from '../../types'; -import { FilterInput } from '../schemas'; - -describe('mapFilterInputToRecord', () => { - it('should map text input', () => { - const input: FilterInput[] = [ - { key: 'name', type: InputType.Text, value: 'test' } as any, - ]; - expect(mapFilterInputToRecord(input)).toEqual({ name: 'test' }); - }); - - it('should map checkbox input', () => { - const input: FilterInput[] = [ - { key: 'tags', type: InputType.Checkbox, selected: ['a', 'b'] } as any, - ]; - expect(mapFilterInputToRecord(input)).toEqual({ tags: 'a;b' }); - }); - - it('should map date range input', () => { - const start = '2024-06-01T00:00:00.000Z'; - const stop = '2024-06-05T00:00:00.000Z'; - const input: FilterInput[] = [ - { key: 'range', type: InputType.DateRange, start, stop } as any, - ]; - const result = mapFilterInputToRecord(input); - // The stop value is incremented by 1 day and wrapped in quotes - expect(result['range']).toMatch( - /^"2024-06-01T00:00:00.000Z"-"2024-06-06T00:00:00.000Z"$/, - ); - }); - - it('should skip empty values', () => { - const input: FilterInput[] = [ - { key: 'empty', type: InputType.Text, value: '' } as any, - { key: 'none', type: InputType.Checkbox, selected: [] } as any, - ]; - expect(mapFilterInputToRecord(input)).toEqual({ empty: '', none: '' }); - }); -}); +import { mapFilterInputToRecord } from './filter-input-to-record.mapping'; +import { InputType } from '../../types'; +import { + CheckboxFilterInput, + DateRangeFilterInput, + FilterInput, + TextFilterInput, +} from '../schemas'; + +describe('mapFilterInputToRecord', () => { + it('should map text input', () => { + const input: FilterInput[] = [ + { key: 'name', type: InputType.Text, value: 'test' } as TextFilterInput, + ]; + expect(mapFilterInputToRecord(input)).toEqual({ name: 'test' }); + }); + + it('should map checkbox input', () => { + const input: FilterInput[] = [ + { + key: 'tags', + type: InputType.Checkbox, + selected: ['Key A', 'Key B'], + group: 'g', + options: [ + { + key: 'Key A', + label: 'Option A', + value: 'a', + path: ['g', 'tags', 'Key A'], + }, + { + key: 'Key B', + label: 'Option B', + value: 'b', + path: ['g', 'tags', 'Key B'], + }, + ], + } as CheckboxFilterInput, + ]; + expect(mapFilterInputToRecord(input)).toEqual({ tags: 'a;b' }); + }); + + it('should map date range input', () => { + const start = '2024-06-01T00:00:00.000Z'; + const stop = '2024-06-05T00:00:00.000Z'; + const input: FilterInput[] = [ + { + key: 'range', + type: InputType.DateRange, + start, + stop, + } as DateRangeFilterInput, + ]; + const result = mapFilterInputToRecord(input); + // The stop value is incremented by 1 day and wrapped in quotes + expect(result['range']).toMatch( + /^"2024-06-01T00:00:00.000Z"-"2024-06-06T00:00:00.000Z"$/, + ); + }); + + it('should skip empty values', () => { + const input: FilterInput[] = [ + { key: 'empty', type: InputType.Text, value: '' } as TextFilterInput, + { + key: 'none', + type: InputType.Checkbox, + selected: [], + group: 'g', + options: [], + } as CheckboxFilterInput, + ]; + expect(mapFilterInputToRecord(input)).toEqual({ empty: '', none: '' }); + }); + + describe('nested checkbox selections', () => { + it('should handle 2-level path resolution', () => { + const input: FilterInput[] = [ + { + key: 'categories', + type: InputType.Checkbox, + selected: ['electronics.phones'], + group: 'g', + options: [ + { + key: 'electronics', + label: 'Electronics', + path: ['g', 'categories', 'electronics'], + values: [ + { + key: 'phones', + label: 'Phones', + value: 'phone-value', + path: ['g', 'categories', 'electronics', 'phones'], + }, + ], + }, + ], + } as CheckboxFilterInput, + ]; + expect(mapFilterInputToRecord(input)).toEqual({ categories: 'phone-value' }); + }); + + it('should handle parent selection collecting all child values', () => { + const input: FilterInput[] = [ + { + key: 'categories', + type: InputType.Checkbox, + selected: ['parent'], + group: 'g', + options: [ + { + key: 'parent', + label: 'Parent', + path: ['g', 'categories', 'parent'], + // Parent has no direct value + values: [ + { + key: 'child1', + label: 'Child 1', + value: 'child1-value', + path: ['g', 'categories', 'parent', 'child1'], + }, + { + key: 'child2', + label: 'Child 2', + value: 'child2-value', + path: ['g', 'categories', 'parent', 'child2'], + }, + ], + }, + ], + } as CheckboxFilterInput, + ]; + expect(mapFilterInputToRecord(input)).toEqual({ + categories: 'child1-value;child2-value' + }); + }); + + it('should handle mixed selections of parents and children', () => { + const input: FilterInput[] = [ + { + key: 'filters', + type: InputType.Checkbox, + selected: ['parent1', 'parent2.child'], + group: 'g', + options: [ + { + key: 'parent1', + label: 'Parent 1', + value: 'parent1-value', + path: ['g', 'filters', 'parent1'], + }, + { + key: 'parent2', + label: 'Parent 2', + path: ['g', 'filters', 'parent2'], + values: [ + { + key: 'child', + label: 'Child', + value: 'child-value', + path: ['g', 'filters', 'parent2', 'child'], + }, + ], + }, + ], + } as CheckboxFilterInput, + ]; + expect(mapFilterInputToRecord(input)).toEqual({ + filters: 'parent1-value;child-value' + }); + }); + + it('should handle selections with non-existent paths', () => { + const input: FilterInput[] = [ + { + key: 'categories', + type: InputType.Checkbox, + selected: ['nonexistent.path', 'valid'], + group: 'g', + options: [ + { + key: 'valid', + label: 'Valid', + value: 'valid-value', + path: ['g', 'categories', 'valid'], + }, + ], + } as CheckboxFilterInput, + ]; + expect(mapFilterInputToRecord(input)).toEqual({ categories: 'valid-value' }); + }); + + it('should handle empty or null selected array', () => { + const input: FilterInput[] = [ + { + key: 'categories', + type: InputType.Checkbox, + selected: null as any, + group: 'g', + options: [], + } as CheckboxFilterInput, + ]; + expect(mapFilterInputToRecord(input)).toEqual({ categories: '' }); + }); + + it('should handle options with both value and child values', () => { + const input: FilterInput[] = [ + { + key: 'mixed', + type: InputType.Checkbox, + selected: ['parent'], + group: 'g', + options: [ + { + key: 'parent', + label: 'Parent', + value: 'parent-value', // Has its own value + path: ['g', 'mixed', 'parent'], + values: [ + { + key: 'child', + label: 'Child', + value: 'child-value', + path: ['g', 'mixed', 'parent', 'child'], + }, + ], + }, + ], + } as CheckboxFilterInput, + ]; + expect(mapFilterInputToRecord(input)).toEqual({ + mixed: 'parent-value' // Should use the parent's direct value, not collect children + }); + }); + }); + + describe('date range edge cases', () => { + it('should handle only start date', () => { + const input: FilterInput[] = [ + { + key: 'range', + type: InputType.DateRange, + start: '2024-06-01T00:00:00.000Z', + stop: undefined, + } as DateRangeFilterInput, + ]; + expect(mapFilterInputToRecord(input)).toEqual({ + range: '"2024-06-01T00:00:00.000Z"-' + }); + }); + + it('should handle only stop date', () => { + const input: FilterInput[] = [ + { + key: 'range', + type: InputType.DateRange, + start: undefined, + stop: '2024-06-05T00:00:00.000Z', + } as DateRangeFilterInput, + ]; + const result = mapFilterInputToRecord(input); + expect(result['range']).toMatch(/^-"2024-06-06T00:00:00.000Z"$/); + }); + + it('should skip date range with no dates', () => { + const input: FilterInput[] = [ + { + key: 'range', + type: InputType.DateRange, + start: undefined, + stop: undefined, + } as DateRangeFilterInput, + ]; + expect(mapFilterInputToRecord(input)).toEqual({}); + }); + }); + + it('should handle multiple filter types together', () => { + const input: FilterInput[] = [ + { key: 'name', type: InputType.Text, value: 'test' } as TextFilterInput, + { + key: 'tags', + type: InputType.Checkbox, + selected: ['tag1'], + group: 'g', + options: [ + { + key: 'tag1', + label: 'Tag 1', + value: 'tag1-value', + path: ['g', 'tags', 'tag1'], + }, + ], + } as CheckboxFilterInput, + { + key: 'date', + type: InputType.DateRange, + start: '2024-01-01T00:00:00.000Z', + stop: '2024-01-31T23:59:59.999Z', + } as DateRangeFilterInput, + ]; + const result = mapFilterInputToRecord(input); + expect(result).toMatchObject({ + name: 'test', + tags: 'tag1-value', + }); + expect(result['date']).toMatch(/^"2024-01-01T00:00:00.000Z"-"2024-02-01T/); + }); +}); diff --git a/libs/shared/filter/src/lib/core/mappings/filter-input-to-record.mapping.ts b/libs/shared/filter/src/lib/core/mappings/filter-input-to-record.mapping.ts index 44e8ff79a..b2586c1a9 100644 --- a/libs/shared/filter/src/lib/core/mappings/filter-input-to-record.mapping.ts +++ b/libs/shared/filter/src/lib/core/mappings/filter-input-to-record.mapping.ts @@ -1,24 +1,56 @@ -import { addDays } from 'date-fns'; -import { InputType } from '../../types'; -import { FilterInput } from '../schemas'; - -export function mapFilterInputToRecord( - inputs: FilterInput[], -): Record { - return inputs.reduce>((acc, input) => { - if (input.type === InputType.Text) { - acc[input.key] = input.value || ''; - } else if (input.type === InputType.Checkbox) { - acc[input.key] = input.selected?.join(';') || ''; - } else if (input.type === InputType.DateRange) { - const start = input.start ? `"${input.start}"` : ''; - const stop = input.stop - ? `"${addDays(new Date(input.stop), 1).toISOString()}"` - : ''; - if (start || stop) { - acc[input.key] = `${start}-${stop}`; - } - } - return acc; - }, {}); -} +import { addDays } from 'date-fns'; +import { InputType } from '../../types'; +import { FilterInput, CheckboxFilterInputOption } from '../schemas'; + +export function mapFilterInputToRecord( + inputs: FilterInput[], +): Record { + return inputs.reduce>((acc, input) => { + if (input.type === InputType.Text) { + acc[input.key] = input.value || ''; + } else if (input.type === InputType.Checkbox) { + const values: string[] = []; + input.selected?.forEach((value) => { + const path = value.split('.'); + let current = path.shift(); + let option = input.options?.find((o) => o.key === current); + while (path.length > 0 && option) { + current = path.shift(); + option = option?.values?.find((o) => o.key === current); + } + if (option) { + // If the option has no value but has child options, collect all child values + if (!option.value && option.values && option.values.length > 0) { + const collectChildValues = ( + opt: CheckboxFilterInputOption, + ): string[] => { + const childValues: string[] = []; + if (opt.value) { + childValues.push(opt.value); + } + if (opt.values) { + opt.values.forEach((child) => { + childValues.push(...collectChildValues(child)); + }); + } + return childValues; + }; + values.push(...collectChildValues(option)); + } else if (option.value) { + values.push(option.value); + } + } + }); + acc[input.key] = values.join(';'); + } else if (input.type === InputType.DateRange) { + const start = input.start ? `"${input.start}"` : ''; + const stop = input.stop + ? `"${addDays(new Date(input.stop), 1).toISOString()}"` + : ''; + if (start || stop) { + acc[input.key] = `${start}-${stop}`; + } + } + return acc; + }, {}); +} 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 0322da705..2513c3632 100644 --- a/libs/shared/filter/src/lib/core/mappings/filter.mapping.ts +++ b/libs/shared/filter/src/lib/core/mappings/filter.mapping.ts @@ -1,46 +1,46 @@ -import { QuerySettings } from '../../types'; -import { Filter } from '../schemas'; -import { filterGroupMapping } from './filter-group.mapping'; -import { filterInputMapping } from './filter-input.mapping'; -import { orderByOptionMapping } from './order-by-option.mapping'; - -/** - * Maps a QuerySettings object to a Filter object - * - * This is the main mapping function that transforms query settings into a - * complete Filter object structure. It: - * 1. Creates filter groups from both filter and input settings - * 2. Maps all inputs from each group to their corresponding filter inputs - * 3. Maps order by options if present - * - * The resulting Filter object can be used by filter components to render - * the appropriate UI and handle user interactions. - * - * @param settings - The source query settings to map - * @returns A fully populated Filter object with groups, inputs, and ordering options - */ -export function filterMapping(settings: QuerySettings): Filter { - const filter: Filter = { - groups: [], - inputs: [], - orderBy: [], - }; - - const groups = [...settings.filter, ...settings.input]; - - for (const group of groups) { - filter.groups.push(filterGroupMapping(group)); - - for (const input of group.input) { - filter.inputs.push(filterInputMapping(group.group, input)); - } - } - - if (settings.orderBy) { - for (const orderBy of settings.orderBy) { - filter.orderBy.push(orderByOptionMapping(orderBy)); - } - } - - return filter; -} +import { QuerySettings } from '../../types'; +import { Filter } from '../schemas'; +import { filterGroupMapping } from './filter-group.mapping'; +import { filterInputMapping } from './filter-input.mapping'; +import { orderByOptionMapping } from './order-by-option.mapping'; + +/** + * Maps a QuerySettings object to a Filter object + * + * This is the main mapping function that transforms query settings into a + * complete Filter object structure. It: + * 1. Creates filter groups from both filter and input settings + * 2. Maps all inputs from each group to their corresponding filter inputs + * 3. Maps order by options if present + * + * The resulting Filter object can be used by filter components to render + * the appropriate UI and handle user interactions. + * + * @param settings - The source query settings to map + * @returns A fully populated Filter object with groups, inputs, and ordering options + */ +export function filterMapping(settings: QuerySettings): Filter { + const filter: Filter = { + groups: [], + inputs: [], + orderBy: [], + }; + + const groups = [...(settings.filter || []), ...(settings.input || [])]; + + for (const group of groups) { + filter.groups.push(filterGroupMapping(group)); + + for (const input of group.input) { + filter.inputs.push(filterInputMapping(group.group, input)); + } + } + + if (settings.orderBy) { + for (const orderBy of settings.orderBy) { + filter.orderBy.push(orderByOptionMapping(orderBy)); + } + } + + return filter; +} 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 8b9bc2c14..02be2528e 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,24 +1,56 @@ -import { z } from 'zod'; - -/** - * Represents a checkbox option within a CheckboxFilterInput. - * - * @property label - Display text for the checkbox option - * @property value - The value to be used when this option is selected - */ -export const CheckboxFilterInputOptionSchema = z - .object({ - label: z - .string() - .describe('Display text shown next to the checkbox in the UI.'), - value: z - .string() - .describe( - 'Underlying value that will be sent in requests when this option is selected.', - ), - }) - .describe('CheckboxFilterInputOption'); - -export type CheckboxFilterInputOption = z.infer< - typeof CheckboxFilterInputOptionSchema ->; +import { z } from 'zod'; + +/** + * Base schema for checkbox filter input options. + * + * Represents a single checkbox option within a hierarchical filter structure. + * Each option has a unique path that identifies its position in the hierarchy. + */ +const CheckboxFilterInputOptionBaseSchema = z + .object({ + path: z + .array(z.string()) + .describe( + 'Path to the option, useful for nested structures. Starts with the group key and includes all parent keys.', + ), + key: z.string().describe('Unique identifier for the checkbox option.'), + label: z + .string() + .describe('Display text shown next to the checkbox in the UI.'), + value: z + .string() + .optional() + .describe( + 'Underlying value that will be sent in requests when this option is selected.', + ), + }) + .describe('CheckboxFilterInputOption'); + +/** + * Schema for checkbox filter input options with support for nested hierarchical structures. + * + * Extends the base checkbox filter input option schema to include an optional `values` + * array that can contain nested CheckboxFilterInputOption instances, enabling multi-level + * checkbox filtering capabilities. + * + * @example + * ```typescript + * const filterOption = { + * // base properties from CheckboxFilterInputOptionBaseSchema + * values: [ + * // nested checkbox options + * ] + * }; + * ``` + */ +export const CheckboxFilterInputOptionSchema = + CheckboxFilterInputOptionBaseSchema.extend({ + values: z + .array(CheckboxFilterInputOptionBaseSchema) + .optional() + .describe('Array of CheckboxFilterInputOption for nested options.'), + }); + +export type CheckboxFilterInputOption = z.infer< + typeof CheckboxFilterInputOptionSchema +>; diff --git a/libs/shared/filter/src/lib/helpers/checkbox-option-keys.helper.spec.ts b/libs/shared/filter/src/lib/helpers/checkbox-option-keys.helper.spec.ts new file mode 100644 index 000000000..0534d0a5a --- /dev/null +++ b/libs/shared/filter/src/lib/helpers/checkbox-option-keys.helper.spec.ts @@ -0,0 +1,56 @@ +import { checkboxOptionKeysHelper } from './checkbox-option-keys.helper'; +import { CheckboxFilterInputOption } from '../core'; + +describe('checkboxOptionKeysHelper', () => { + it('should extract option keys from path excluding group and groupKey', () => { + const option: CheckboxFilterInputOption = { + path: ['group', 'key', 'level1', 'level2', 'level3'], + key: 'level3', + label: 'Level 3 Option', + value: 'level3', + }; + + const result = checkboxOptionKeysHelper(option); + + expect(result).toEqual(['level1', 'level2', 'level3']); + }); + + it('should return single key when path has only one option level', () => { + const option: CheckboxFilterInputOption = { + path: ['group', 'key', 'singleOption'], + key: 'singleOption', + label: 'Single Option', + value: 'singleOption', + }; + + const result = checkboxOptionKeysHelper(option); + + expect(result).toEqual(['singleOption']); + }); + + it('should handle deep nested option paths', () => { + const option: CheckboxFilterInputOption = { + path: ['category', 'products', 'electronics', 'phones', 'smartphones', 'android'], + key: 'android', + label: 'Android Phones', + value: 'android', + }; + + const result = checkboxOptionKeysHelper(option); + + expect(result).toEqual(['electronics', 'phones', 'smartphones', 'android']); + }); + + it('should return empty array when path only contains group and groupKey', () => { + const option: CheckboxFilterInputOption = { + path: ['group', 'key'], + key: 'key', + label: 'Root Option', + value: 'key', + }; + + const result = checkboxOptionKeysHelper(option); + + expect(result).toEqual([]); + }); +}); \ No newline at end of file diff --git a/libs/shared/filter/src/lib/helpers/checkbox-option-keys.helper.ts b/libs/shared/filter/src/lib/helpers/checkbox-option-keys.helper.ts new file mode 100644 index 000000000..7e9b89289 --- /dev/null +++ b/libs/shared/filter/src/lib/helpers/checkbox-option-keys.helper.ts @@ -0,0 +1,7 @@ +import { CheckboxFilterInputOption } from '../core'; + +export function checkboxOptionKeysHelper( + option: CheckboxFilterInputOption, +): string[] { + return [...option.path].slice(2); +} diff --git a/libs/shared/filter/src/lib/helpers/checkbox-selected.helper.spec.ts b/libs/shared/filter/src/lib/helpers/checkbox-selected.helper.spec.ts new file mode 100644 index 000000000..3c02480fc --- /dev/null +++ b/libs/shared/filter/src/lib/helpers/checkbox-selected.helper.spec.ts @@ -0,0 +1,321 @@ +import { checkboxSelectedHelper, checkboxSelectedHelperBoolean } from './checkbox-selected.helper'; +import { FilterInput, CheckboxFilterInput, CheckboxFilterInputOption } from '../core'; +import { InputType } from '../types'; + +describe('checkboxSelectedHelper', () => { + const baseInput: CheckboxFilterInput = { + group: 'group', + key: 'key', + type: InputType.Checkbox, + options: [], + selected: [], + label: 'label', + }; + + it('should return "none" if input not found', () => { + const option: CheckboxFilterInputOption = { + path: ['group', 'key', 'opt'], + key: 'opt', + label: 'Option', + value: 'opt', + }; + expect(checkboxSelectedHelper([], option)).toBe('none'); + }); + + it('should return "none" when input type is not checkbox', () => { + const option: CheckboxFilterInputOption = { + path: ['group', 'key', 'opt'], + key: 'opt', + label: 'Option', + value: 'opt', + }; + const input: FilterInput = { + group: 'group', + key: 'key', + type: InputType.Text, + value: 'test', + }; + expect(checkboxSelectedHelper([input], option)).toBe('none'); + }); + + it('should return "checked" for leaf if selected', () => { + const input: CheckboxFilterInput = { + ...baseInput, + selected: ['opt'], + options: [ + { path: ['group', 'key', 'opt'], key: 'opt', label: 'Option', value: 'opt' }, + ], + }; + const option = input.options[0]; + expect(checkboxSelectedHelper([input], option)).toBe('checked'); + }); + + it('should return "none" for leaf if not selected', () => { + const input: CheckboxFilterInput = { + ...baseInput, + selected: [], + options: [ + { path: ['group', 'key', 'opt'], key: 'opt', label: 'Option', value: 'opt' }, + ], + }; + const option = input.options[0]; + expect(checkboxSelectedHelper([input], option)).toBe('none'); + }); + + it('should return "checked" for parent if all children selected', () => { + const input: CheckboxFilterInput = { + ...baseInput, + selected: ['parent.child1', 'parent.child2'], + options: [ + { + path: ['group', 'key', 'parent'], + key: 'parent', + label: 'Parent', + values: [ + { path: ['group', 'key', 'parent', 'child1'], key: 'child1', label: 'Child1', value: 'child1' }, + { path: ['group', 'key', 'parent', 'child2'], key: 'child2', label: 'Child2', value: 'child2' }, + ], + }, + ], + }; + const option = input.options[0]; + expect(checkboxSelectedHelper([input], option)).toBe('checked'); + }); + + it('should return "indeterminate" for parent if some children selected', () => { + const input: CheckboxFilterInput = { + ...baseInput, + selected: ['parent.child1'], + options: [ + { + path: ['group', 'key', 'parent'], + key: 'parent', + label: 'Parent', + values: [ + { path: ['group', 'key', 'parent', 'child1'], key: 'child1', label: 'Child1', value: 'child1' }, + { path: ['group', 'key', 'parent', 'child2'], key: 'child2', label: 'Child2', value: 'child2' }, + ], + }, + ], + }; + const option = input.options[0]; + expect(checkboxSelectedHelper([input], option)).toBe('indeterminate'); + }); + + it('should return "none" for parent if no children selected', () => { + const input: CheckboxFilterInput = { + ...baseInput, + selected: [], + options: [ + { + path: ['group', 'key', 'parent'], + key: 'parent', + label: 'Parent', + values: [ + { path: ['group', 'key', 'parent', 'child1'], key: 'child1', label: 'Child1', value: 'child1' }, + { path: ['group', 'key', 'parent', 'child2'], key: 'child2', label: 'Child2', value: 'child2' }, + ], + }, + ], + }; + const option = input.options[0]; + expect(checkboxSelectedHelper([input], option)).toBe('none'); + }); + + describe('multi-level hierarchies', () => { + it('should handle 2-level deep hierarchies', () => { + const input: CheckboxFilterInput = { + ...baseInput, + selected: ['parent.child1', 'parent.child2'], + options: [ + { + path: ['group', 'key', 'parent'], + key: 'parent', + label: 'Parent', + values: [ + { path: ['group', 'key', 'parent', 'child1'], key: 'child1', label: 'Child1', value: 'gc1' }, + { path: ['group', 'key', 'parent', 'child2'], key: 'child2', label: 'Child2', value: 'gc2' }, + ], + }, + ], + }; + + const parentOption = input.options[0]; + const childOption = parentOption.values![0]; + + expect(checkboxSelectedHelper([input], parentOption)).toBe('checked'); + expect(checkboxSelectedHelper([input], childOption)).toBe('checked'); + }); + + it('should handle indeterminate state in hierarchies', () => { + const input: CheckboxFilterInput = { + ...baseInput, + selected: ['parent.child1'], + options: [ + { + path: ['group', 'key', 'parent'], + key: 'parent', + label: 'Parent', + values: [ + { path: ['group', 'key', 'parent', 'child1'], key: 'child1', label: 'Child1', value: 'gc1' }, + { path: ['group', 'key', 'parent', 'child2'], key: 'child2', label: 'Child2', value: 'gc2' }, + ], + }, + ], + }; + + const parentOption = input.options[0]; + + expect(checkboxSelectedHelper([input], parentOption)).toBe('indeterminate'); + }); + }); + + describe('parent selection inheritance', () => { + it('should return "checked" for children when parent is selected', () => { + const input: CheckboxFilterInput = { + ...baseInput, + selected: ['parent'], + options: [ + { + path: ['group', 'key', 'parent'], + key: 'parent', + label: 'Parent', + values: [ + { path: ['group', 'key', 'parent', 'child1'], key: 'child1', label: 'Child1', value: 'child1' }, + { path: ['group', 'key', 'parent', 'child2'], key: 'child2', label: 'Child2', value: 'child2' }, + ], + }, + ], + }; + + const parentOption = input.options[0]; + const child1 = parentOption.values![0]; + const child2 = parentOption.values![1]; + + expect(checkboxSelectedHelper([input], parentOption)).toBe('checked'); + expect(checkboxSelectedHelper([input], child1)).toBe('checked'); + expect(checkboxSelectedHelper([input], child2)).toBe('checked'); + }); + + it('should inherit selection through parent-child levels', () => { + const input: CheckboxFilterInput = { + ...baseInput, + selected: ['parent'], + options: [ + { + path: ['group', 'key', 'parent'], + key: 'parent', + label: 'Parent', + values: [ + { path: ['group', 'key', 'parent', 'child'], key: 'child', label: 'Child', value: 'gc' }, + ], + }, + ], + }; + + const child = input.options[0].values![0]; + expect(checkboxSelectedHelper([input], child)).toBe('checked'); + }); + }); + + describe('edge cases', () => { + it('should handle option with empty values array', () => { + const input: CheckboxFilterInput = { + ...baseInput, + selected: ['parent'], + options: [ + { + path: ['group', 'key', 'parent'], + key: 'parent', + label: 'Parent', + values: [], + }, + ], + }; + + const option = input.options[0]; + expect(checkboxSelectedHelper([input], option)).toBe('checked'); + }); + + it('should handle null selected array', () => { + const input: CheckboxFilterInput = { + ...baseInput, + selected: null as any, + options: [ + { path: ['group', 'key', 'opt'], key: 'opt', label: 'Option', value: 'opt' }, + ], + }; + + const option = input.options[0]; + expect(checkboxSelectedHelper([input], option)).toBe('none'); + }); + + it('should handle undefined selected array', () => { + const input: CheckboxFilterInput = { + ...baseInput, + selected: undefined as any, + options: [ + { path: ['group', 'key', 'opt'], key: 'opt', label: 'Option', value: 'opt' }, + ], + }; + + const option = input.options[0]; + expect(checkboxSelectedHelper([input], option)).toBe('none'); + }); + }); +}); + +describe('checkboxSelectedHelperBoolean', () => { + const baseInput: CheckboxFilterInput = { + group: 'group', + key: 'key', + type: InputType.Checkbox, + options: [], + selected: [], + label: 'label', + }; + + it('should return true for "checked" state', () => { + const input: CheckboxFilterInput = { + ...baseInput, + selected: ['opt'], + options: [ + { path: ['group', 'key', 'opt'], key: 'opt', label: 'Option', value: 'opt' }, + ], + }; + const option = input.options[0]; + expect(checkboxSelectedHelperBoolean([input], option)).toBe(true); + }); + + it('should return true for "indeterminate" state', () => { + const input: CheckboxFilterInput = { + ...baseInput, + selected: ['parent.child1'], + options: [ + { + path: ['group', 'key', 'parent'], + key: 'parent', + label: 'Parent', + values: [ + { path: ['group', 'key', 'parent', 'child1'], key: 'child1', label: 'Child1', value: 'child1' }, + { path: ['group', 'key', 'parent', 'child2'], key: 'child2', label: 'Child2', value: 'child2' }, + ], + }, + ], + }; + const option = input.options[0]; + expect(checkboxSelectedHelperBoolean([input], option)).toBe(true); + }); + + it('should return false for "none" state', () => { + const input: CheckboxFilterInput = { + ...baseInput, + selected: [], + options: [ + { path: ['group', 'key', 'opt'], key: 'opt', label: 'Option', value: 'opt' }, + ], + }; + const option = input.options[0]; + expect(checkboxSelectedHelperBoolean([input], option)).toBe(false); + }); +}); diff --git a/libs/shared/filter/src/lib/helpers/checkbox-selected.helper.ts b/libs/shared/filter/src/lib/helpers/checkbox-selected.helper.ts new file mode 100644 index 000000000..1bf166ead --- /dev/null +++ b/libs/shared/filter/src/lib/helpers/checkbox-selected.helper.ts @@ -0,0 +1,175 @@ +import { + CheckboxFilterInput, + CheckboxFilterInputOption, + FilterInput, +} from '../core'; +import { InputType } from '../types'; + +/** + * Represents the possible states of a checkbox in a hierarchical structure + */ +export type CheckboxState = 'checked' | 'indeterminate' | 'none'; + +/** + * Determines the selection state of a checkbox option within a hierarchical structure. + * + * This helper function calculates whether a checkbox should be displayed as: + * - 'checked': The option and all its children (if any) are selected + * - 'indeterminate': The option has children and only some are selected + * - 'none': The option is not selected + * + * For parent options, the state is determined by their children's selection status. + * For leaf options, the state is determined by whether the option itself or any of its parent paths are selected. + * + * @param inputs - Array of all filter inputs + * @param option - The checkbox option to check the state for + * @returns The current state of the checkbox + * + * @example + * ```typescript + * const state = checkboxSelectedHelper(filterInputs, { + * path: ['category', 'electronics', 'phones'], + * label: 'Phones', + * value: 'phones', + * values: [] + * }); + * // Returns 'checked' if phones or any parent category is selected + * ``` + */ +export function checkboxSelectedHelper( + inputs: FilterInput[], + option: CheckboxFilterInputOption, +): CheckboxState { + const [group, groupKey, ...optionKeys] = option.path; + // Find the input that matches the group and key + const input = inputs.find( + (input) => + input.group === group && + input.key === groupKey && + input.type === InputType.Checkbox, + ) as CheckboxFilterInput | undefined; + + // If no input found, return 'none' + if (!input) { + return 'none'; + } + + const isParent = Array.isArray(option.values) && option.values.length > 0; + + if (isParent) { + // For parent options, check children selection state + return getParentCheckboxState(input, optionKeys, option); + } else { + // For leaf options, check if this option or any of its parents is selected + return getLeafCheckboxState(input, optionKeys); + } +} + +/** + * Calculates the checkbox state for parent options based on their children's selection status. + * + * @param input - The checkbox filter input containing selected paths + * @param optionKeys - Array of keys representing the path to this option + * @param option - The parent checkbox option + * @returns 'checked' if all children are selected, 'indeterminate' if some are selected, 'none' if none are selected + * @private + */ +function getParentCheckboxState( + input: CheckboxFilterInput, + optionKeys: string[], + option: CheckboxFilterInputOption, +): CheckboxState { + const currentPath = optionKeys.join('.'); + + // If the parent itself is selected, it's checked + if (input.selected?.includes(currentPath)) { + return 'checked'; + } + + // Check how many children are selected (at any level) + if (input.selected) { + const selectedChildren = input.selected.filter((selected) => + selected.startsWith(currentPath + '.'), + ); + + if (selectedChildren.length === 0) { + return 'none'; + } + + // Count all possible children recursively + const totalChildren = countAllChildren(option); + + if (selectedChildren.length === totalChildren) { + return 'checked'; + } else { + return 'indeterminate'; + } + } + + return 'none'; +} + +/** + * Recursively counts all children (including nested children) of a checkbox option. + * + * @param option - The checkbox option to count children for + * @returns Total number of child options at all levels + * @private + */ +function countAllChildren(option: CheckboxFilterInputOption): number { + if (!option.values || option.values.length === 0) { + return 0; + } + + let count = option.values.length; + for (const child of option.values) { + count += countAllChildren(child); + } + + return count; +} + +/** + * Determines the checkbox state for leaf (non-parent) options. + * + * A leaf option is considered 'checked' if either: + * - The option itself is directly selected + * - Any of its parent paths are selected (inherited selection) + * + * @param input - The checkbox filter input containing selected paths + * @param optionKeys - Array of keys representing the path to this option + * @returns 'checked' if the option or any parent is selected, 'none' otherwise + * @private + */ +function getLeafCheckboxState( + input: CheckboxFilterInput, + optionKeys: string[], +): CheckboxState { + if (input.selected) { + // Check if this option or any of its parents is selected + for (let i = 1; i <= optionKeys.length; i++) { + const parentPath = optionKeys.slice(0, i).join('.'); + if (input.selected.includes(parentPath)) { + return 'checked'; + } + } + } + + return 'none'; +} + +/** + * Legacy helper function that returns a boolean representation of checkbox selection state. + * + * @deprecated Use checkboxSelectedHelper() instead for more accurate state representation + * @param inputs - Array of all filter inputs + * @param option - The checkbox option to check + * @returns true if the checkbox is checked or indeterminate, false if none + */ +export function checkboxSelectedHelperBoolean( + inputs: FilterInput[], + option: CheckboxFilterInputOption, +): boolean { + const state = checkboxSelectedHelper(inputs, option); + return state === 'checked' || state === 'indeterminate'; +} diff --git a/libs/shared/filter/src/lib/helpers/filter-checkbox-options.helper.spec.ts b/libs/shared/filter/src/lib/helpers/filter-checkbox-options.helper.spec.ts new file mode 100644 index 000000000..aa47d791e --- /dev/null +++ b/libs/shared/filter/src/lib/helpers/filter-checkbox-options.helper.spec.ts @@ -0,0 +1,143 @@ +import { filterCheckboxOptionsHelper } from './filter-checkbox-options.helper'; +import { CheckboxFilterInputOption } from '../core'; + +describe('filterCheckboxOptionsHelper', () => { + const mockOptions: CheckboxFilterInputOption[] = [ + { + path: ['category', 'electronics'], + key: 'electronics', + label: 'Electronics', + values: [ + { + path: ['category', 'electronics', 'laptops'], + key: 'laptops', + label: 'Laptops', + }, + { + path: ['category', 'electronics', 'phones'], + key: 'phones', + label: 'Mobile Phones', + }, + ], + }, + { + path: ['category', 'clothing'], + key: 'clothing', + label: 'Clothing', + values: [ + { + path: ['category', 'clothing', 'shirts'], + key: 'shirts', + label: 'Shirts', + }, + { + path: ['category', 'clothing', 'pants'], + key: 'pants', + label: 'Pants', + }, + ], + }, + { + path: ['category', 'books'], + key: 'books', + label: 'Books', + }, + ]; + + it('should return all options when query is empty', () => { + // Arrange & Act + const result = filterCheckboxOptionsHelper(mockOptions, ''); + + // Assert + expect(result).toEqual(mockOptions); + }); + + it('should return all options when query is empty string', () => { + // Arrange & Act + const result = filterCheckboxOptionsHelper(mockOptions, ''); + + // Assert + expect(result).toEqual(mockOptions); + }); + + it('should filter options by top-level label match', () => { + // Arrange & Act + const result = filterCheckboxOptionsHelper(mockOptions, 'clothing'); + + // Assert + expect(result).toHaveLength(1); + expect(result[0].label).toBe('Clothing'); + expect(result[0].values).toHaveLength(2); // Should keep all children + }); + + it('should filter options by child label match and keep parent', () => { + // Arrange & Act + const result = filterCheckboxOptionsHelper(mockOptions, 'laptops'); + + // Assert + expect(result).toHaveLength(1); + expect(result[0].label).toBe('Electronics'); + expect(result[0].values).toHaveLength(1); + expect(result[0].values?.[0].label).toBe('Laptops'); + }); + + it('should be case insensitive', () => { + // Arrange & Act + const result = filterCheckboxOptionsHelper(mockOptions, 'ELECTRONICS'); + + // Assert + expect(result).toHaveLength(1); + expect(result[0].label).toBe('Electronics'); + }); + + it('should filter by partial matches', () => { + // Arrange & Act + const result = filterCheckboxOptionsHelper(mockOptions, 'phone'); + + // Assert + expect(result).toHaveLength(1); + expect(result[0].label).toBe('Electronics'); + expect(result[0].values).toHaveLength(1); + expect(result[0].values?.[0].label).toBe('Mobile Phones'); + }); + + it('should return empty array when no matches found', () => { + // Arrange & Act + const result = filterCheckboxOptionsHelper(mockOptions, 'nonexistent'); + + // Assert + expect(result).toEqual([]); + }); + + it('should handle options without children', () => { + // Arrange & Act + const result = filterCheckboxOptionsHelper(mockOptions, 'books'); + + // Assert + expect(result).toHaveLength(1); + expect(result[0].label).toBe('Books'); + expect(result[0].values).toBeUndefined(); + }); + + it('should filter multiple matching options', () => { + // Arrange & Act + const result = filterCheckboxOptionsHelper(mockOptions, 's'); // Should match "Electronics", "Shirts", "Pants", "Books" + + // Assert - The actual behavior returns all 3 items: + // 1. Electronics (label contains 's') + // 2. Clothing (has children "Shirts" and "Pants" that contain 's') + // 3. Books (label contains 's') + expect(result).toHaveLength(3); + expect(result.find((r) => r.label === 'Electronics')).toBeTruthy(); + expect(result.find((r) => r.label === 'Clothing')?.values).toHaveLength(2); + expect(result.find((r) => r.label === 'Books')).toBeTruthy(); + }); + + it('should handle empty options array', () => { + // Arrange & Act + const result = filterCheckboxOptionsHelper([], 'test'); + + // Assert + expect(result).toEqual([]); + }); +}); diff --git a/libs/shared/filter/src/lib/helpers/filter-checkbox-options.helper.ts b/libs/shared/filter/src/lib/helpers/filter-checkbox-options.helper.ts new file mode 100644 index 000000000..871ccdf87 --- /dev/null +++ b/libs/shared/filter/src/lib/helpers/filter-checkbox-options.helper.ts @@ -0,0 +1,42 @@ +import { CheckboxFilterInputOption } from '../core'; + +/** + * Filters checkbox options recursively by label text + * @param options - The checkbox options to filter + * @param query - The search query to filter by + * @returns Filtered options that match the query or have matching children + */ +export const filterCheckboxOptionsHelper = ( + options: CheckboxFilterInputOption[], + query: string, +): CheckboxFilterInputOption[] => { + if (!query) { + return options; + } + + const lowercaseQuery = query.toLowerCase(); + + return options.reduce((filtered, option) => { + // Check if current option label matches + const labelMatches = option.label?.toLowerCase().includes(lowercaseQuery); + + // If parent matches, keep all children; otherwise filter children recursively + const filteredChildren = option.values + ? labelMatches + ? option.values // Keep all children when parent matches + : filterCheckboxOptionsHelper(option.values, query) // Only filter children if parent doesn't match + : undefined; + + // Include option if: + // 1. Its label matches the query, OR + // 2. It has children that match (keep parent for context) + if (labelMatches || (filteredChildren && filteredChildren.length > 0)) { + filtered.push({ + ...option, + values: filteredChildren, + }); + } + + return filtered; + }, [] as CheckboxFilterInputOption[]); +}; diff --git a/libs/shared/filter/src/lib/helpers/get-all-checkbox-options.helper.spec.ts b/libs/shared/filter/src/lib/helpers/get-all-checkbox-options.helper.spec.ts new file mode 100644 index 000000000..4e4458f04 --- /dev/null +++ b/libs/shared/filter/src/lib/helpers/get-all-checkbox-options.helper.spec.ts @@ -0,0 +1,225 @@ +import { getAllCheckboxOptionsHelper } from './get-all-checkbox-options.helper'; +import { CheckboxFilterInput, CheckboxFilterInputOption } from '../core'; +import { InputType } from '../types'; + +describe('getAllCheckboxOptionsHelper', () => { + const createCheckboxOption = ( + key: string, + label: string, + path: string[], + values?: CheckboxFilterInputOption[], + ): CheckboxFilterInputOption => ({ + path, + key, + label, + values, + }); + + const mockOptions: CheckboxFilterInputOption[] = [ + createCheckboxOption( + 'electronics', + 'Electronics', + ['category', 'electronics'], + [ + createCheckboxOption('laptops', 'Laptops', [ + 'category', + 'electronics', + 'laptops', + ]), + createCheckboxOption( + 'phones', + 'Mobile Phones', + ['category', 'electronics', 'phones'], + [ + createCheckboxOption('smartphone', 'Smartphones', [ + 'category', + 'electronics', + 'phones', + 'smartphone', + ]), + createCheckboxOption('feature', 'Feature Phones', [ + 'category', + 'electronics', + 'phones', + 'feature', + ]), + ], + ), + ], + ), + createCheckboxOption( + 'clothing', + 'Clothing', + ['category', 'clothing'], + [ + createCheckboxOption('shirts', 'Shirts', [ + 'category', + 'clothing', + 'shirts', + ]), + createCheckboxOption('pants', 'Pants', [ + 'category', + 'clothing', + 'pants', + ]), + ], + ), + createCheckboxOption('books', 'Books', ['category', 'books']), + ]; + + const createMockInput = ( + options: CheckboxFilterInputOption[], + ): CheckboxFilterInput => ({ + group: 'category', + key: 'test-checkbox', + label: 'Test Checkbox', + type: InputType.Checkbox, + options, + selected: [], + }); + + it('should return all options in a flat array', () => { + // Arrange + const mockInput = createMockInput(mockOptions); + + // Act + const result = getAllCheckboxOptionsHelper(mockInput); + + // Assert + expect(result).toHaveLength(9); // 3 top-level + 2 electronics children + 2 phones children + 2 clothing children + 1 books + expect(result.map((option) => option.key)).toEqual([ + 'electronics', + 'laptops', + 'phones', + 'smartphone', + 'feature', + 'clothing', + 'shirts', + 'pants', + 'books', + ]); + }); + + it('should handle options without children', () => { + // Arrange + const simpleOptions = [ + createCheckboxOption('option1', 'Option 1', ['simple', 'option1']), + createCheckboxOption('option2', 'Option 2', ['simple', 'option2']), + ]; + const mockInput = createMockInput(simpleOptions); + + // Act + const result = getAllCheckboxOptionsHelper(mockInput); + + // Assert + expect(result).toHaveLength(2); + expect(result.map((option) => option.key)).toEqual(['option1', 'option2']); + }); + + it('should handle empty options array', () => { + // Arrange + const mockInput = createMockInput([]); + + // Act + const result = getAllCheckboxOptionsHelper(mockInput); + + // Assert + expect(result).toEqual([]); + }); + + it('should preserve option order in flattening', () => { + // Arrange + const orderedOptions = [ + createCheckboxOption( + 'first', + 'First', + ['first'], + [createCheckboxOption('child1', 'Child 1', ['first', 'child1'])], + ), + createCheckboxOption('second', 'Second', ['second']), + createCheckboxOption( + 'third', + 'Third', + ['third'], + [createCheckboxOption('child2', 'Child 2', ['third', 'child2'])], + ), + ]; + const mockInput = createMockInput(orderedOptions); + + // Act + const result = getAllCheckboxOptionsHelper(mockInput); + + // Assert + expect(result.map((option) => option.key)).toEqual([ + 'first', + 'child1', + 'second', + 'third', + 'child2', + ]); + }); + + it('should handle deeply nested options', () => { + // Arrange + const deeplyNestedOptions = [ + createCheckboxOption( + 'level1', + 'Level 1', + ['level1'], + [ + createCheckboxOption( + 'level2', + 'Level 2', + ['level1', 'level2'], + [ + createCheckboxOption( + 'level3', + 'Level 3', + ['level1', 'level2', 'level3'], + [ + createCheckboxOption('level4', 'Level 4', [ + 'level1', + 'level2', + 'level3', + 'level4', + ]), + ], + ), + ], + ), + ], + ), + ]; + const mockInput = createMockInput(deeplyNestedOptions); + + // Act + const result = getAllCheckboxOptionsHelper(mockInput); + + // Assert + expect(result).toHaveLength(4); + expect(result.map((option) => option.key)).toEqual([ + 'level1', + 'level2', + 'level3', + 'level4', + ]); + }); + + it('should handle input with undefined options', () => { + // Arrange + const mockInput: CheckboxFilterInput = { + group: 'test', + key: 'test-checkbox', + label: 'Test Checkbox', + type: InputType.Checkbox, + options: undefined as unknown as CheckboxFilterInputOption[], // Simulating potential undefined scenario + selected: [], + }; + + // Act + const result = getAllCheckboxOptionsHelper(mockInput); + + // Assert + expect(result).toEqual([]); + }); +}); diff --git a/libs/shared/filter/src/lib/helpers/get-all-checkbox-options.helper.ts b/libs/shared/filter/src/lib/helpers/get-all-checkbox-options.helper.ts new file mode 100644 index 000000000..1976c727b --- /dev/null +++ b/libs/shared/filter/src/lib/helpers/get-all-checkbox-options.helper.ts @@ -0,0 +1,16 @@ +import { CheckboxFilterInput, CheckboxFilterInputOption } from '../core'; + +export function getAllCheckboxOptionsHelper( + input: CheckboxFilterInput, +): CheckboxFilterInputOption[] { + const getAllOptions = ( + options: CheckboxFilterInputOption[], + ): CheckboxFilterInputOption[] => { + return options.flatMap((option) => [ + option, + ...getAllOptions(option.values || []), + ]); + }; + + return getAllOptions(input.options || []); +} diff --git a/libs/shared/filter/src/lib/helpers/get-option-key.helper.spec.ts b/libs/shared/filter/src/lib/helpers/get-option-key.helper.spec.ts new file mode 100644 index 000000000..5c55cac9d --- /dev/null +++ b/libs/shared/filter/src/lib/helpers/get-option-key.helper.spec.ts @@ -0,0 +1,218 @@ +import { getOptionKeyHelper } from './get-option-key.helper'; +import { CheckboxFilterInputOption } from '../core'; + +describe('getOptionKeyHelper', () => { + const createCheckboxOption = ( + key: string, + label: string, + path: string[] = ['test'], + value?: string, + ): CheckboxFilterInputOption => ({ + path, + key, + label, + value, + }); + + describe('with key property', () => { + it('should return the key when present', () => { + // Arrange + const option = createCheckboxOption( + 'test-key', + 'Test Label', + ['test'], + 'test-value', + ); + + // Act + const result = getOptionKeyHelper(option); + + // Assert + expect(result).toBe('test-key'); + }); + + it('should return the key even when value and label are also present', () => { + // Arrange + const option = createCheckboxOption( + 'priority-key', + 'Fallback Label', + ['test'], + 'fallback-value', + ); + + // Act + const result = getOptionKeyHelper(option); + + // Assert + expect(result).toBe('priority-key'); + }); + }); + + describe('fallback to value property', () => { + it('should return the value when key is empty string but value is present', () => { + // Arrange + const option = createCheckboxOption( + '', + 'Test Label', + ['test'], + 'fallback-value', + ); + + // Act + const result = getOptionKeyHelper(option); + + // Assert + expect(result).toBe('fallback-value'); + }); + }); + + describe('fallback to label property', () => { + it('should return the label when key and value are both empty strings', () => { + // Arrange + const option = createCheckboxOption('', 'Fallback Label', ['test'], ''); + + // Act + const result = getOptionKeyHelper(option); + + // Assert + expect(result).toBe('Fallback Label'); + }); + + it('should return the label when key is empty and value is undefined', () => { + // Arrange + const option = createCheckboxOption('', 'Only Label Available', ['test']); + + // Act + const result = getOptionKeyHelper(option); + + // Assert + expect(result).toBe('Only Label Available'); + }); + }); + + describe('edge cases and error handling', () => { + it('should return empty string when all identifier properties are empty strings', () => { + // Arrange + const option = createCheckboxOption('', '', ['test'], ''); + + // Act + const result = getOptionKeyHelper(option); + + // Assert + expect(result).toBe(''); + }); + + it('should handle options with only whitespace in key property', () => { + // Arrange + const option = createCheckboxOption( + ' ', + 'Clean Label', + ['test'], + 'clean-value', + ); + + // Act + const result = getOptionKeyHelper(option); + + // Assert + expect(result).toBe(' '); // Returns the key even with spaces + }); + + it('should prefer value over label when key is empty', () => { + // Arrange + const option = createCheckboxOption( + '', + 'Available Label', + ['test'], + 'Available Value', + ); + + // Act + const result = getOptionKeyHelper(option); + + // Assert + expect(result).toBe('Available Value'); // value comes before label in fallback chain + }); + + it('should handle missing value property gracefully', () => { + // Arrange - simulate scenario where helper checks for truthy key first + const option: CheckboxFilterInputOption = { + path: ['test'], + key: '', // empty key should trigger fallback + label: 'Fallback to Label', + // value is optional and not provided + }; + + // Act + const result = getOptionKeyHelper(option); + + // Assert + expect(result).toBe('Fallback to Label'); + }); + }); + + describe('real-world scenarios', () => { + it('should work with typical checkbox option structure', () => { + // Arrange + const option: CheckboxFilterInputOption = { + path: ['category', 'electronics'], + key: 'electronics', + label: 'Electronics Category', + }; + + // Act + const result = getOptionKeyHelper(option); + + // Assert + expect(result).toBe('electronics'); + }); + + it('should work with options that have both key and value', () => { + // Arrange + const option: CheckboxFilterInputOption = { + path: ['category', 'books'], + key: 'books', + label: 'Books & Literature', + value: 'book-category', + }; + + // Act + const result = getOptionKeyHelper(option); + + // Assert + expect(result).toBe('books'); // key takes priority + }); + + it('should work with API responses that need fallback to value', () => { + // Arrange + const option: CheckboxFilterInputOption = { + path: ['filter', 'status'], + key: '', // empty key forces fallback + label: 'Active Items', + value: 'active', + }; + + // Act + const result = getOptionKeyHelper(option); + + // Assert + expect(result).toBe('active'); + }); + + it('should handle complex nested path structures', () => { + // Arrange + const option: CheckboxFilterInputOption = { + path: ['catalog', 'electronics', 'computers', 'laptops'], + key: 'gaming-laptops', + label: 'Gaming Laptops', + value: 'laptop-gaming', + }; + + // Act + const result = getOptionKeyHelper(option); + + // Assert + expect(result).toBe('gaming-laptops'); + }); + }); +}); diff --git a/libs/shared/filter/src/lib/helpers/get-option-key.helper.ts b/libs/shared/filter/src/lib/helpers/get-option-key.helper.ts new file mode 100644 index 000000000..9527f1d25 --- /dev/null +++ b/libs/shared/filter/src/lib/helpers/get-option-key.helper.ts @@ -0,0 +1,10 @@ +import { CheckboxFilterInputOption } from '../core'; +import { Option } from '../types'; + +export function getOptionKeyHelper( + option: CheckboxFilterInputOption | Option, +): string { + // Use key if available, otherwise fallback to value or label + const key = String(option.key || option.value || option.label || ''); + return key === 'undefined' ? '' : key; +} diff --git a/libs/shared/filter/src/lib/helpers/index.ts b/libs/shared/filter/src/lib/helpers/index.ts new file mode 100644 index 000000000..4aeda4dda --- /dev/null +++ b/libs/shared/filter/src/lib/helpers/index.ts @@ -0,0 +1,5 @@ +export * from './checkbox-selected.helper'; +export * from './checkbox-option-keys.helper'; +export * from './filter-checkbox-options.helper'; +export * from './get-all-checkbox-options.helper'; +export * from './get-option-key.helper'; diff --git a/libs/shared/filter/src/lib/inputs/checkbox-input/checkbox-input-control.component.html b/libs/shared/filter/src/lib/inputs/checkbox-input/checkbox-input-control.component.html new file mode 100644 index 000000000..705aa39d0 --- /dev/null +++ b/libs/shared/filter/src/lib/inputs/checkbox-input/checkbox-input-control.component.html @@ -0,0 +1,34 @@ +
+ + @if (isParent()) { + + } +
+@if (expanded()) { +
+ @for (child of option().values; track child.key) { + + } +
+} diff --git a/libs/shared/filter/src/lib/inputs/checkbox-input/checkbox-input-control.component.scss b/libs/shared/filter/src/lib/inputs/checkbox-input/checkbox-input-control.component.scss new file mode 100644 index 000000000..f92692e2a --- /dev/null +++ b/libs/shared/filter/src/lib/inputs/checkbox-input/checkbox-input-control.component.scss @@ -0,0 +1,3 @@ +:host { + @apply block flex-grow w-full; +} diff --git a/libs/shared/filter/src/lib/inputs/checkbox-input/checkbox-input-control.component.spec.ts b/libs/shared/filter/src/lib/inputs/checkbox-input/checkbox-input-control.component.spec.ts new file mode 100644 index 000000000..dabf29cdc --- /dev/null +++ b/libs/shared/filter/src/lib/inputs/checkbox-input/checkbox-input-control.component.spec.ts @@ -0,0 +1,211 @@ +import { Spectator, createComponentFactory } from '@ngneat/spectator/jest'; +import { CheckboxInputControlComponent } from './checkbox-input-control.component'; +import { FilterService } from '../../core'; +import { CheckboxFilterInputOption, CheckboxFilterInput } from '../../core'; +import { InputType } from '../../types'; +import { signal } from '@angular/core'; +import { CheckboxComponent } from '@isa/ui/input-controls'; +import { MockComponent } from 'ng-mocks'; +import { NgIcon } from '@ng-icons/core'; + +describe('CheckboxInputControlComponent', () => { + let spectator: Spectator; + let filterService: FilterService; + + const option: CheckboxFilterInputOption = { + path: ['group', 'key', 'opt'], + key: 'opt', + label: 'Option', + value: 'opt', + }; + + const parentOption: CheckboxFilterInputOption = { + path: ['group', 'key', 'parent'], + key: 'parent', + label: 'Parent', + values: [ + { + path: ['group', 'key', 'parent', 'child1'], + key: 'child1', + label: 'Child1', + value: 'child1', + }, + { + path: ['group', 'key', 'parent', 'child2'], + key: 'child2', + label: 'Child2', + value: 'child2', + }, + ], + }; + + const mockInputsSignal = signal([ + { + group: 'group', + key: 'key', + type: InputType.Checkbox, + options: [option, parentOption], + selected: [], + label: 'label', + }, + ]); + + const mockFilterService = { + inputs: mockInputsSignal, + setInputCheckboxOptionSelected: jest.fn(), + }; + + const createComponent = createComponentFactory({ + component: CheckboxInputControlComponent, + declarations: [MockComponent(CheckboxComponent), MockComponent(NgIcon)], + providers: [{ provide: FilterService, useValue: mockFilterService }], + detectChanges: false, + }); + + beforeEach(() => { + spectator = createComponent({ props: { option } }); + filterService = spectator.inject(FilterService); + jest.clearAllMocks(); + }); + + it('should create', () => { + spectator.detectChanges(); + expect(spectator.component).toBeTruthy(); + }); + + it('should call setInputCheckboxOptionSelected on setSelected', () => { + spectator.detectChanges(); + spectator.component.setSelected(true); + expect(filterService.setInputCheckboxOptionSelected).toHaveBeenCalledWith( + option, + true, + ); + + spectator.component.setSelected(false); + expect(filterService.setInputCheckboxOptionSelected).toHaveBeenCalledWith( + option, + false, + ); + }); + + describe('checkbox state computation', () => { + it('should compute selected and indeterminate states for unchecked option', () => { + spectator.detectChanges(); + expect(spectator.component.selected()).toBe(false); + expect(spectator.component.indeterminate()).toBe(false); + expect(spectator.component.checkboxState()).toBe('none'); + }); + + it('should compute selected state when option is selected', () => { + mockInputsSignal.set([ + { + group: 'group', + key: 'key', + type: InputType.Checkbox, + options: [option], + selected: ['opt'], + label: 'label', + }, + ]); + spectator.detectChanges(); + + expect(spectator.component.selected()).toBe(true); + expect(spectator.component.indeterminate()).toBe(false); + expect(spectator.component.checkboxState()).toBe('checked'); + }); + + it('should compute indeterminate state for parent with partial selection', () => { + spectator.setInput('option', parentOption); + mockInputsSignal.set([ + { + group: 'group', + key: 'key', + type: InputType.Checkbox, + options: [parentOption], + selected: ['parent.child1'], + label: 'label', + }, + ]); + spectator.detectChanges(); + + expect(spectator.component.selected()).toBe(false); + expect(spectator.component.indeterminate()).toBe(true); + expect(spectator.component.checkboxState()).toBe('indeterminate'); + }); + }); + + describe('expand/collapse functionality', () => { + it('should handle expand/collapse state', () => { + spectator.detectChanges(); + expect(spectator.component.expanded()).toBe(false); + + spectator.component.expanded.set(true); + expect(spectator.component.expanded()).toBe(true); + + spectator.component.expanded.set(false); + expect(spectator.component.expanded()).toBe(false); + }); + + it('should detect if option is parent', () => { + spectator.detectChanges(); + expect(spectator.component.isParent()).toBe(false); + + spectator.setInput('option', parentOption); + spectator.detectChanges(); + expect(spectator.component.isParent()).toBe(true); + }); + + it('should update icon name based on expanded state', () => { + spectator.setInput('option', parentOption); + spectator.detectChanges(); + + expect(spectator.component.expandedIconName()).toBe( + 'isaActionChevronDown', + ); + + spectator.component.expanded.set(true); + expect(spectator.component.expandedIconName()).toBe('isaActionChevronUp'); + }); + + it('should handle parent with empty values array', () => { + const emptyParent: CheckboxFilterInputOption = { + path: ['group', 'key', 'empty'], + key: 'empty', + label: 'Empty Parent', + values: [], + }; + + spectator.setInput('option', emptyParent); + spectator.detectChanges(); + expect(spectator.component.isParent()).toBe(false); + }); + + it('should handle option without values property', () => { + const optionWithoutValues: CheckboxFilterInputOption = { + path: ['group', 'key', 'simple'], + key: 'simple', + label: 'Simple Option', + value: 'simple', + }; + + spectator.setInput('option', optionWithoutValues); + spectator.detectChanges(); + expect(spectator.component.isParent()).toBe(false); + }); + }); + + describe('data attributes for e2e testing', () => { + it('should have correct data attributes on checkbox input', () => { + spectator.detectChanges(); + const checkboxInput = spectator.query('input[type="checkbox"]'); + expect(checkboxInput).toHaveAttribute('aria-label', 'Option'); + }); + + it('should have data attributes for expand button when option is parent', () => { + spectator.setInput('option', parentOption); + spectator.detectChanges(); + const expandButton = spectator.query('button'); + expect(expandButton).toBeTruthy(); + }); + }); +}); diff --git a/libs/shared/filter/src/lib/inputs/checkbox-input/checkbox-input-control.component.ts b/libs/shared/filter/src/lib/inputs/checkbox-input/checkbox-input-control.component.ts new file mode 100644 index 000000000..8c040dd89 --- /dev/null +++ b/libs/shared/filter/src/lib/inputs/checkbox-input/checkbox-input-control.component.ts @@ -0,0 +1,103 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + inject, + input, + signal, +} from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { CheckboxComponent } from '@isa/ui/input-controls'; +import { NgIcon, provideIcons } from '@ng-icons/core'; +import { isaActionChevronDown, isaActionChevronUp } from '@isa/icons'; +import { CheckboxFilterInputOption, FilterService } from '../../core'; +import { checkboxSelectedHelper } from '../../helpers'; + +/** + * Component that renders an individual checkbox control within a hierarchical filter structure. + * + * This component handles: + * - Three-state checkbox display (checked, indeterminate, unchecked) + * - Parent-child relationships with expand/collapse functionality + * - Automatic state calculation based on children selection + * - Integration with the FilterService for state management + * + * @example + * ```html + * + * + * ``` + */ +@Component({ + selector: 'filter-checkbox-input-control', + templateUrl: './checkbox-input-control.component.html', + styleUrls: ['./checkbox-input-control.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: true, + imports: [FormsModule, CheckboxComponent, NgIcon], + providers: [provideIcons({ isaActionChevronDown, isaActionChevronUp })], +}) +export class CheckboxInputControlComponent { + /** Filter service for managing checkbox state */ + readonly filterService = inject(FilterService); + + /** The checkbox option configuration */ + option = input.required(); + + /** + * Computed signal that determines the current state of the checkbox. + * Uses the checkboxSelectedHelper to calculate state based on parent-child relationships. + */ + checkboxState = computed(() => { + return checkboxSelectedHelper(this.filterService.inputs(), this.option()); + }); + + /** + * Whether the checkbox is fully selected (checked state). + * Returns true only when the checkbox is in 'checked' state, not 'indeterminate'. + */ + selected = computed(() => { + const state = this.checkboxState(); + return state === 'checked'; + }); + + /** + * Whether the checkbox is in an indeterminate state. + * This occurs when a parent checkbox has some but not all children selected. + */ + indeterminate = computed(() => { + return this.checkboxState() === 'indeterminate'; + }); + + /** + * Updates the selection state of this checkbox option. + * + * @param value - Whether to select (true) or deselect (false) the option + */ + setSelected(value: boolean) { + const option = this.option(); + this.filterService.setInputCheckboxOptionSelected(option, value); + } + + /** Signal controlling whether child options are expanded/visible */ + expanded = signal(false); + + /** + * Determines if this checkbox option has children. + * Parent options can be expanded to show their children. + */ + isParent = computed(() => { + const values = this.option().values; + + return Array.isArray(values) && values.length > 0; + }); + + /** + * Computed icon name for the expand/collapse chevron. + * Shows up arrow when expanded, down arrow when collapsed. + */ + expandedIconName = computed(() => { + return this.expanded() ? 'isaActionChevronUp' : 'isaActionChevronDown'; + }); +} diff --git a/libs/shared/filter/src/lib/inputs/checkbox-input/checkbox-input.component.html b/libs/shared/filter/src/lib/inputs/checkbox-input/checkbox-input.component.html index 30a5e6753..ce6dc20c2 100644 --- a/libs/shared/filter/src/lib/inputs/checkbox-input/checkbox-input.component.html +++ b/libs/shared/filter/src/lib/inputs/checkbox-input/checkbox-input.component.html @@ -1,27 +1,46 @@ -@let inp = input(); -@let options = input().options; -@if (inp && options) { -
- - - @for (option of options; track option.label; let i = $index) { - - } -
-} +@let inp = input(); +@if (inp) { +
+
+ @if (filterControlVisible()) { + + @if (filterQuery()) { + + } + } +
+ + + @for (option of options(); track option.key; let i = $index) { + + } +
+} diff --git a/libs/shared/filter/src/lib/inputs/checkbox-input/checkbox-input.component.scss b/libs/shared/filter/src/lib/inputs/checkbox-input/checkbox-input.component.scss index ad5661c04..3749a7c0e 100644 --- a/libs/shared/filter/src/lib/inputs/checkbox-input/checkbox-input.component.scss +++ b/libs/shared/filter/src/lib/inputs/checkbox-input/checkbox-input.component.scss @@ -1,3 +1,3 @@ -.filter-checkbox-input { - @apply inline-block p-6 text-isa-neutral-900; -} +.filter-checkbox-input { + @apply inline-block w-full p-6 text-isa-neutral-900; +} 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 index 757ac69e7..0e7d02305 100644 --- 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 @@ -1,261 +1,182 @@ -import { Spectator, createComponentFactory } from '@ngneat/spectator/jest'; -import { CheckboxInputComponent } from './checkbox-input.component'; -import { FilterService } from '../../core'; -import { MockComponent } from 'ng-mocks'; -import { CheckboxComponent } from '@isa/ui/input-controls'; -import { InputType } from '../../types'; -import { signal } from '@angular/core'; - -describe('CheckboxInputComponent', () => { - let spectator: Spectator; - 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()).toThrow( - 'Input not found for key: test-key', - ); - }); -}); +import { Spectator, createComponentFactory } from '@ngneat/spectator/jest'; +import { CheckboxInputComponent } from './checkbox-input.component'; +import { + FilterInput, + FilterService, + CheckboxFilterInputOption, +} from '../../core'; +import { CheckboxComponent } from '@isa/ui/input-controls'; +import { CheckboxInputControlComponent } from './checkbox-input-control.component'; +import { InputType } from '../../types'; +import { signal } from '@angular/core'; +import { MockComponent } from 'ng-mocks'; +import { NgIcon } from '@ng-icons/core'; +import { FormsModule } from '@angular/forms'; + +describe('CheckboxInputComponent', () => { + let spectator: Spectator; + let filterService: FilterService; + + const option: CheckboxFilterInputOption = { + path: ['group', 'key', 'opt'], + key: 'opt', + label: 'Option', + value: 'opt', + }; + + const mockInputsSignal = signal([ + { + group: 'group', + key: 'key', + type: InputType.Checkbox, + options: [option], + selected: [], + label: 'label', + }, + ]); + + const mockFilterService = { + inputs: mockInputsSignal, + setInputCheckboxOptionSelected: jest.fn(), + }; + + const createComponent = createComponentFactory({ + component: CheckboxInputComponent, + imports: [FormsModule], + declarations: [ + MockComponent(CheckboxComponent), + MockComponent(CheckboxInputControlComponent), + MockComponent(NgIcon), + ], + providers: [{ provide: FilterService, useValue: mockFilterService }], + detectChanges: false, + }); + + beforeEach(() => { + // Reset the mock signal to its initial state + mockInputsSignal.set([ + { + group: 'group', + key: 'key', + type: InputType.Checkbox, + options: [option], + selected: [], + label: 'label', + }, + ]); + + spectator = createComponent({ props: { inputKey: 'key' } }); + filterService = spectator.inject(FilterService); + jest.clearAllMocks(); + }); + + it('should create', () => { + spectator.detectChanges(); + expect(spectator.component).toBeTruthy(); + }); + + it('should compute allChecked as false when nothing selected', () => { + spectator.detectChanges(); + expect(spectator.component.allChecked()).toBe(false); + }); + + it('should call setInputCheckboxOptionSelected for each option on toggleSelection', () => { + spectator.detectChanges(); + spectator.component.toggleSelection(); + expect(filterService.setInputCheckboxOptionSelected).toHaveBeenCalledWith( + option, + true, + ); + }); + + it('should compute allChecked as true when all options are checked', () => { + // Simulate all options checked + mockInputsSignal.set([ + { + group: 'group', + key: 'key', + type: InputType.Checkbox, + options: [option], + selected: ['opt'], + label: 'label', + }, + ]); + spectator.detectChanges(); + expect(spectator.component.allChecked()).toBe(true); + }); + + describe('filter functionality', () => { + it('should show filter control when there are more than 20 options', () => { + const manyOptions = Array.from({ length: 25 }, (_, i) => ({ + path: ['group', 'key', `opt${i}`], + key: `opt${i}`, + label: `Option ${i}`, + value: `opt${i}`, + })); + + mockInputsSignal.set([ + { + group: 'group', + key: 'key', + type: InputType.Checkbox, + options: manyOptions, + selected: [], + label: 'label', + }, + ]); + spectator.detectChanges(); + expect(spectator.component.filterControlVisible()).toBe(true); + }); + + it('should not show filter control when there are 20 or fewer options', () => { + spectator.detectChanges(); + expect(spectator.component.filterControlVisible()).toBe(false); + }); + + it('should generate correct filter placeholder', () => { + spectator.detectChanges(); + expect(spectator.component.filterPlaceholder()).toBe('label suchen...'); + }); + + it('should filter options based on search query', () => { + const multipleOptions = [ + { + path: ['group', 'key', 'apple'], + key: 'apple', + label: 'Apple', + value: 'apple', + }, + { + path: ['group', 'key', 'banana'], + key: 'banana', + label: 'Banana', + value: 'banana', + }, + { + path: ['group', 'key', 'cherry'], + key: 'cherry', + label: 'Cherry', + value: 'cherry', + }, + ]; + + mockInputsSignal.set([ + { + group: 'group', + key: 'key', + type: InputType.Checkbox, + options: multipleOptions, + selected: [], + label: 'label', + }, + ]); + + spectator.component.filterQuery.set('app'); + spectator.detectChanges(); + + const filteredOptions = spectator.component.options(); + expect(filteredOptions).toHaveLength(1); + expect(filteredOptions[0].label).toBe('Apple'); + }); + }); +}); diff --git a/libs/shared/filter/src/lib/inputs/checkbox-input/checkbox-input.component.ts b/libs/shared/filter/src/lib/inputs/checkbox-input/checkbox-input.component.ts index bc9d7bbbf..d25ce0365 100644 --- a/libs/shared/filter/src/lib/inputs/checkbox-input/checkbox-input.component.ts +++ b/libs/shared/filter/src/lib/inputs/checkbox-input/checkbox-input.component.ts @@ -1,126 +1,94 @@ -import { - ChangeDetectionStrategy, - Component, - computed, - inject, - input, - effect, - ViewEncapsulation, - untracked, -} from '@angular/core'; -import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; -import { provideIcons } from '@ng-icons/core'; -import { isaActionCheck } from '@isa/icons'; -import { FilterService, CheckboxFilterInput, CheckboxFilterInputOption } from '../../core'; -import { InputType } from '../../types'; -import { CheckboxComponent } from '@isa/ui/input-controls'; -import { OverlayModule } from '@angular/cdk/overlay'; -import { toSignal } from '@angular/core/rxjs-interop'; -import { sortBy, isEqual } from 'lodash'; - -@Component({ - selector: 'filter-checkbox-input', - templateUrl: './checkbox-input.component.html', - styleUrls: ['./checkbox-input.component.scss'], - changeDetection: ChangeDetectionStrategy.OnPush, - encapsulation: ViewEncapsulation.None, - standalone: true, - imports: [ReactiveFormsModule, CheckboxComponent, OverlayModule], - host: { - '[class]': "['filter-checkbox-input']", - '[formGroup]': 'checkboxes', - }, - providers: [provideIcons({ isaActionCheck })], -}) -export class CheckboxInputComponent { - readonly filterService = inject(FilterService); - - checkboxes = new FormGroup({}); - valueChanges = toSignal(this.checkboxes.valueChanges); - - get allChecked() { - return Object.values(this.checkboxes.getRawValue()).every((value) => value); - } - - inputKey = input.required(); - - input = computed(() => { - const inputs = this.filterService.inputs(); - const input = inputs.find( - (input) => input.key === this.inputKey() && input.type === InputType.Checkbox, - ) as CheckboxFilterInput; - - if (!input) { - throw new Error(`Input not found for key: ${this.inputKey()}`); - } - - const selected = input.selected; - const options = input.options; - - for (const option of options) { - const controlExist = this.checkboxes.get(option.value); - const isSelected = selected.includes(option.value); - - if (controlExist) { - this.patchFormControl({ option, isSelected }); - } else { - this.initFormControl({ option, isSelected }); - } - } - - this.checkboxes.updateValueAndValidity(); - - return input; - }); - - constructor() { - effect(() => { - this.valueChanges(); - untracked(() => { - if (Object.keys(this.checkboxes.getRawValue())?.length === 0) { - return; - } - - const selectedKeys = Object.entries(this.checkboxes.getRawValue()) - .filter(([, value]) => value === true) - .map(([key]) => key); - - const controlEqualsInput = isEqual(sortBy(this.input().selected), sortBy(selectedKeys)); - - if (!controlEqualsInput) { - this.filterService.setInputCheckboxValue(this.inputKey(), selectedKeys); - } - }); - }); - } - - initFormControl({ - option, - isSelected, - }: { - option: CheckboxFilterInputOption; - isSelected: boolean; - }) { - this.checkboxes.addControl(option.value, new FormControl(isSelected)); - } - - patchFormControl({ - option, - isSelected, - }: { - option: CheckboxFilterInputOption; - isSelected: boolean; - }) { - this.checkboxes.patchValue({ - [option.value]: isSelected, - }); - } - - toggleSelection() { - const newValue = this.allChecked ? false : true; - for (const key of Object.keys(this.checkboxes.getRawValue())) { - this.checkboxes.patchValue({ [key]: newValue }); - } - this.checkboxes.updateValueAndValidity(); - } -} +import { + ChangeDetectionStrategy, + Component, + computed, + inject, + input, + signal, + ViewEncapsulation, +} from '@angular/core'; +import { NgIcon, provideIcons } from '@ng-icons/core'; +import { isaActionCheck, isaActionClose } from '@isa/icons'; +import { FilterService, CheckboxFilterInput } from '../../core'; +import { InputType } from '../../types'; +import { CheckboxComponent } from '@isa/ui/input-controls'; +import { OverlayModule } from '@angular/cdk/overlay'; +import { CheckboxInputControlComponent } from './checkbox-input-control.component'; +import { + checkboxSelectedHelper, + getAllCheckboxOptionsHelper, + filterCheckboxOptionsHelper, +} from '../../helpers'; +import { FormsModule } from '@angular/forms'; + +@Component({ + selector: 'filter-checkbox-input', + templateUrl: './checkbox-input.component.html', + styleUrls: ['./checkbox-input.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + standalone: true, + imports: [ + OverlayModule, + CheckboxInputControlComponent, + CheckboxComponent, + FormsModule, + NgIcon, + ], + host: { + '[class]': "['filter-checkbox-input']", + '[formGroup]': 'checkboxes', + }, + providers: [provideIcons({ isaActionCheck, isaActionClose })], +}) +export class CheckboxInputComponent { + readonly filterService = inject(FilterService); + + inputKey = input.required(); + + input = computed(() => { + const inputs = this.filterService.inputs(); + const inputKey = this.inputKey(); + return inputs.find( + (input) => input.key === inputKey && input.type === InputType.Checkbox, + ) as CheckboxFilterInput; + }); + + options = computed(() => { + const input = this.input(); + const options = input?.options || []; + const query = this.filterQuery(); + + return filterCheckboxOptionsHelper(options, query); + }); + + filterControlVisible = computed(() => { + const input = this.input(); + const allOptions = getAllCheckboxOptionsHelper(input); + // Show control if there are more than 20 options + return allOptions.length > 20; + }); + + filterPlaceholder = computed(() => { + const input = this.input(); + return `${input.label} suchen...`; + }); + + filterQuery = signal(''); + + allChecked = computed(() => { + return this.input().options.every( + (option) => + checkboxSelectedHelper(this.filterService.inputs(), option) === + 'checked', + ); + }); + + toggleSelection() { + const options = this.input()?.options || []; + const allChecked = this.allChecked(); + options.forEach((option) => { + this.filterService.setInputCheckboxOptionSelected(option, !allChecked); + }); + } +} diff --git a/libs/shared/filter/src/lib/menus/filter-menu/filter-menu-button.component.html b/libs/shared/filter/src/lib/menus/filter-menu/filter-menu-button.component.html index c4982c364..9da4a98b8 100644 --- a/libs/shared/filter/src/lib/menus/filter-menu/filter-menu-button.component.html +++ b/libs/shared/filter/src/lib/menus/filter-menu/filter-menu-button.component.html @@ -1,35 +1,36 @@ -@let selected = selectedFilters(); - - - - - +@let selected = selectedFilters(); + + + + + 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 4cd1ff79f..336d73802 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 @@ -1,40 +1,42 @@ -@let input = filterInput(); - - - - - +@let input = filterInput(); + + + + + diff --git a/libs/shared/filter/src/lib/types.ts b/libs/shared/filter/src/lib/types.ts index 51f717240..2889709a0 100644 --- a/libs/shared/filter/src/lib/types.ts +++ b/libs/shared/filter/src/lib/types.ts @@ -1,194 +1,194 @@ -export enum InputType { - Text = 1, - Checkbox = 2, - DateRange = 128, -} - -export interface QuerySettings { - /** - * Filter - */ - filter: Array; - - /** - * Eingabefelder - */ - input: Array; - - /** - * Sortierung - */ - orderBy: Array; -} - -export interface InputGroup { - /** - * Beschreibung - */ - description?: string; - - /** - * Group / ID - */ - group: string; - - /** - * Eingabefelder - */ - input: Array; - - /** - * Label - */ - label?: string; -} - -/** - * Sortierwert - */ -export interface OrderBy { - /** - * Wert - */ - by?: string; - - /** - * Absteigend - */ - desc?: boolean; - - /** - * Label - */ - label?: string; -} - -/** - * Eingabeelement - */ -export interface Input { - /** - * Regex-Überprüfung - */ - constraint?: string; - - /** - * Beschreibung - */ - description?: string; - - /** - * Key / ID - */ - key: string; - - /** - * Label - */ - label?: string; - - /** - * Max-Wert (optional) - */ - maxValue?: string; - - /** - * Min-Wert (optional) - */ - minValue?: string; - - /** - * Auswahl - */ - options?: InputOptions; - - /** - * Wasserzeichen - */ - placeholder?: string; - - /** - * Anwendungsziel - */ - target?: string; - - /** - * Art des Werts - */ - type: InputType; - - /** - * Wert - */ - value?: string; -} - -/** - * Auswahl - */ -export interface InputOptions { - /** - * Maximale Anzahl auswählbarer Elemente (null => alle, 1 = single select) - */ - max?: number; - - /** - * Werte - */ - values?: Array