feat: enhance filter input mappings with detailed documentation and schema validation

- Added comprehensive JSDoc comments to mapping functions for checkbox and text filter inputs, improving code readability and maintainability.
- Refactored checkboxFilterInputMapping and checkboxOptionMapping functions to enhance clarity and structure.
- Removed unused data-range-filter-input mapping files and tests to streamline the codebase.
- Introduced a new dateRangeFilterInputMapping function with detailed mapping logic for date range inputs.
- Updated filter input schemas to include descriptive comments for better understanding of properties.
- Implemented unit tests for date range filter input mapping to ensure correct functionality.
- Enhanced existing filter mapping functions with improved error handling and validation.
- Updated index exports to reflect the removal and addition of mapping files.
This commit is contained in:
Lorenz Hilpert
2025-04-11 16:13:11 +02:00
parent 8144253a18
commit aff6d18888
29 changed files with 1224 additions and 86 deletions

View File

@@ -9,12 +9,24 @@
- Mock external dependencies to isolate the unit under test
- Mock child components to ensure true unit testing isolation
## Spectator Overview
Spectator is a powerful library that simplifies Angular testing by:
- Reducing boilerplate code in tests
- Providing easy DOM querying utilities
- Offering a clean API for triggering events
- Supporting testing of components, directives, and services
- Including custom matchers for clearer assertions
## Best Practices
### Component Testing
- Use `createComponentFactory` for standalone components
- Use `createHostFactory` when testing components with templates
- Use `createHostFactory` when testing components with templates and inputs/outputs
- Use `createDirectiveFactory` for testing directives
- Use `createServiceFactory` for testing services
- Test component inputs, outputs, and lifecycle hooks
- Verify DOM rendering and component behavior separately
@@ -39,6 +51,143 @@ describe('ParentComponent', () => {
});
```
## Spectator API Reference
### Core Factory Methods
1. **For Components**:
```typescript
const createComponent = createComponentFactory({
component: MyComponent,
imports: [SomeModule],
declarations: [SomeDirective],
providers: [SomeService],
componentProviders: [], // Providers specific to the component
componentViewProviders: [], // ViewProviders for the component
mocks: [ServiceToMock], // Automatically mocks the service
detectChanges: false, // Whether to run change detection initially
});
```
2. **For Components with Host**:
```typescript
const createHost = createHostFactory({
component: MyComponent,
template: `<app-my [prop]="value" (event)="handle()"></app-my>`,
// ...other options similar to createComponentFactory
});
```
3. **For Directives**:
```typescript
const createDirective = createDirectiveFactory({
directive: MyDirective,
template: `<div myDirective [prop]="value"></div>`,
// ...other options
});
```
4. **For Services**:
```typescript
const createService = createServiceFactory({
service: MyService,
providers: [DependencyService],
mocks: [HttpClient],
entryComponents: [],
});
```
5. **For HTTP Services**:
```typescript
const createHttpService = createHttpFactory({
service: MyHttpService,
providers: [SomeService],
mocks: [TokenService],
});
```
### Querying Elements
Spectator offers multiple ways to query the DOM:
```typescript
// Basic CSS selectors
const button = spectator.query('button.submit');
const inputs = spectator.queryAll('input');
// By directive/component type
const childComponent = spectator.query(ChildComponent);
const directives = spectator.queryAll(MyDirective);
// Advanced text-based selectors
spectator.query(byText('Submit'));
spectator.query(byLabel('Username'));
spectator.query(byPlaceholder('Enter your email'));
spectator.query(byValue('Some value'));
spectator.query(byTitle('Click here'));
spectator.query(byAltText('Logo image'));
spectator.query(byRole('button', { pressed: true }));
// Accessing native elements
const { nativeElement } = spectator.query('.class-name');
```
### Working with Inputs and Outputs
```typescript
// Setting component inputs
spectator.setInput('username', 'JohnDoe');
spectator.setInput({
username: 'JohnDoe',
isActive: true,
});
// For host components
spectator.setHostInput('propName', value);
// Working with outputs
const outputSpy = jest.fn();
spectator.output('statusChange').subscribe(outputSpy);
// Trigger and verify outputs
spectator.click('button.submit');
expect(outputSpy).toHaveBeenCalledWith({ status: 'submitted' });
```
### Event Triggering API
Spectator provides a rich API for simulating user interactions:
```typescript
// Mouse events
spectator.click('.button');
spectator.doubleClick('#item');
spectator.hover('.tooltip');
spectator.mouseEnter('.dropdown');
spectator.mouseLeave('.dropdown');
// Keyboard events
spectator.keyboard.pressEscape();
spectator.keyboard.pressEnter();
spectator.keyboard.pressKey('A');
spectator.keyboard.pressKeys('ctrl.a');
// Form interactions
spectator.typeInElement('New value', 'input.username');
spectator.blur('input.username');
spectator.focus('input.password');
spectator.selectOption(selectEl, 'Option 2');
// Custom events
spectator.triggerEventHandler(MyComponent, 'customEvent', eventObj);
spectator.dispatchFakeEvent(element, 'mouseover');
spectator.dispatchTouchEvent(element, 'touchstart');
```
## Example Test Structures
### Basic Component Test
@@ -128,6 +277,190 @@ it('should emit when button is clicked', () => {
});
```
### Testing Services
```typescript
import { createServiceFactory, SpectatorService } from '@ngneat/spectator/jest';
import { UserService } from './user.service';
import { HttpClient } from '@angular/common/http';
describe('UserService', () => {
let spectator: SpectatorService<UserService>;
let httpClient: HttpClient;
const createService = createServiceFactory({
service: UserService,
mocks: [HttpClient],
});
beforeEach(() => {
spectator = createService();
httpClient = spectator.inject(HttpClient);
});
it('should fetch users', () => {
// Arrange
const mockUsers = [{ id: 1, name: 'John' }];
httpClient.get.mockReturnValue(of(mockUsers));
// Act
let result;
spectator.service.getUsers().subscribe((users) => {
result = users;
});
// Assert
expect(httpClient.get).toHaveBeenCalledWith('/api/users');
expect(result).toEqual(mockUsers);
});
});
```
### Testing HTTP Services
```typescript
import {
createHttpFactory,
HttpMethod,
SpectatorHttp,
} from '@ngneat/spectator/jest';
import { UserHttpService } from './user-http.service';
describe('UserHttpService', () => {
let spectator: SpectatorHttp<UserHttpService>;
const createHttp = createHttpFactory({
service: UserHttpService,
});
beforeEach(() => (spectator = createHttp()));
it('should call the correct API endpoint when getting users', () => {
spectator.service.getUsers().subscribe();
spectator.expectOne('/api/users', HttpMethod.GET);
});
it('should include auth token in the headers', () => {
spectator.service.getUsers().subscribe();
const req = spectator.expectOne('/api/users', HttpMethod.GET);
expect(req.request.headers.get('Authorization')).toBeTruthy();
});
});
```
### Testing Directives
```typescript
import {
createDirectiveFactory,
SpectatorDirective,
} from '@ngneat/spectator/jest';
import { HighlightDirective } from './highlight.directive';
describe('HighlightDirective', () => {
let spectator: SpectatorDirective<HighlightDirective>;
const createDirective = createDirectiveFactory({
directive: HighlightDirective,
template: `<div highlight="yellow">Testing</div>`,
});
beforeEach(() => (spectator = createDirective()));
it('should change the background color', () => {
expect(spectator.element).toHaveStyle({
backgroundColor: 'yellow',
});
});
it('should respond to mouse events', () => {
spectator.dispatchMouseEvent(spectator.element, 'mouseover');
expect(spectator.element).toHaveStyle({
backgroundColor: 'yellow',
fontWeight: 'bold',
});
spectator.dispatchMouseEvent(spectator.element, 'mouseout');
expect(spectator.element).toHaveStyle({
backgroundColor: 'yellow',
fontWeight: 'normal',
});
});
});
```
### Testing Angular Standalone Components
```typescript
import { createComponentFactory, Spectator } from '@ngneat/spectator/jest';
import { StandaloneComponent } from './standalone.component';
describe('StandaloneComponent', () => {
let spectator: Spectator<StandaloneComponent>;
const createComponent = createComponentFactory({
component: StandaloneComponent,
// No need for imports as they are part of the component itself
});
beforeEach(() => {
spectator = createComponent();
});
it('should create standalone component', () => {
expect(spectator.component).toBeTruthy();
});
});
```
### Testing Deferrable Views (@defer)
```typescript
import { createComponentFactory, Spectator } from '@ngneat/spectator/jest';
import { ComponentWithDefer } from './component-with-defer.component';
describe('ComponentWithDefer', () => {
let spectator: Spectator<ComponentWithDefer>;
const createComponent = createComponentFactory({
component: ComponentWithDefer,
});
beforeEach(() => {
spectator = createComponent();
});
it('should render defer block content', () => {
// Render the completed state of the first defer block
spectator.deferBlock().renderComplete();
expect(spectator.query('.deferred-content')).toExist();
expect(spectator.query('.placeholder-content')).not.toExist();
});
it('should show loading state', () => {
// Render the loading state of the first defer block
spectator.deferBlock().renderLoading();
expect(spectator.query('.loading-indicator')).toExist();
});
it('should show placeholder content', () => {
// Render the placeholder state of the first defer block
spectator.deferBlock().renderPlaceholder();
expect(spectator.query('.placeholder-content')).toExist();
});
it('should work with multiple defer blocks', () => {
// For the second defer block in the template
spectator.deferBlock(1).renderComplete();
expect(spectator.query('.second-deferred-content')).toExist();
});
});
```
## Common Patterns
### Query Elements
@@ -157,6 +490,32 @@ spectator.typeInElement('value', 'input');
spectator.triggerEventHandler(MyComponent, 'eventName', eventValue);
```
### Custom Matchers
Spectator provides custom matchers to make assertions more readable:
```typescript
// DOM matchers
expect('.button').toExist();
expect('.inactive-element').not.toExist();
expect('.title').toHaveText('Welcome');
expect('.username').toContainText('John');
expect('input').toHaveValue('test');
expect('.error').toHaveClass('visible');
expect('button').toBeDisabled();
expect('div').toHaveAttribute('aria-label', 'Close');
expect('.menu').toHaveData({ testId: 'main-menu' });
expect('img').toHaveProperty('src', 'path/to/image.jpg');
expect('.parent').toHaveDescendant('.child');
expect('.parent').toHaveDescendantWithText({
selector: '.child',
text: 'Child text',
});
// Object matchers
expect(object).toBePartial({ id: 1 });
```
### Test Async Operations
```typescript
@@ -189,6 +548,264 @@ it('should handle async operations', async () => {
- Remember to clean up subscriptions
3. **Performance**
- Mock heavy dependencies
- Keep test setup minimal
- Use `beforeAll` for expensive operations shared across tests
4. **Change Detection**
- Use `spectator.detectChanges()` after modifying component properties
- For OnPush components with a host, use `spectator.detectComponentChanges()`
5. **Injection**
- Use `spectator.inject(Service)` to access injected services
- Use `spectator.inject(Service, true)` to get service from the component injector
## Running Tests
When working in an Nx workspace, there are several ways to run tests:
### Running Tests for a Specific Project
```bash
# Run all tests for a specific project
npx nx test <project-name>
# Example: Run tests for the core/config library
npx nx test core-config
```
### Running Tests with Watch Mode
```bash
# Run tests in watch mode for active development
npx nx test <project-name> --watch
```
### Running Tests with Coverage
```bash
# Run tests with coverage reporting
npx nx test <project-name> --code-coverage
```
### Running a Specific Test File
```bash
# Run a specific test file
npx nx test <project-name> --test-file=path/to/your.spec.ts
```
### Running Affected Tests
```bash
# Run tests only for projects affected by recent changes
npx nx affected:test
```
These commands help you target exactly which tests to run, making your testing workflow more efficient.
## References
- [Spectator Documentation](https://github.com/ngneat/spectator) - Official documentation for the Spectator testing library
- [Jest Documentation](https://jestjs.io/docs/getting-started) - Comprehensive guide to using Jest as a testing framework
- [ng-mocks Documentation](https://ng-mocks.sudo.eu/) - Detailed documentation on mocking Angular dependencies effectively
## ng-mocks Guide
### Overview
ng-mocks is a powerful library that helps with Angular testing by:
- Mocking Components, Directives, Pipes, Modules, Services, and Tokens
- Reducing boilerplate in tests
- Providing a simple interface to access declarations
It's particularly useful for isolating components by mocking their dependencies, which makes tests faster and more reliable.
### Global Configuration
For optimal setup, configure ng-mocks in your test setup file:
```typescript
// src/test-setup.ts or equivalent
import { ngMocks } from 'ng-mocks';
// Auto-spy all methods in mock declarations and providers
ngMocks.autoSpy('jest'); // or 'jasmine'
// Reset customizations after each test automatically
ngMocks.defaultMock(AuthService, () => ({
isLoggedIn$: EMPTY,
currentUser$: EMPTY,
}));
```
### Key APIs
#### MockBuilder
`MockBuilder` provides a fluent API to configure TestBed with mocks:
```typescript
beforeEach(() => {
return MockBuilder(
ComponentUnderTest, // Keep this as real
ParentModule, // Mock everything else
)
.keep(ReactiveFormsModule) // Keep this as real
.mock(SomeOtherDependency, { customConfig: true }); // Custom mock
});
```
#### MockRender
`MockRender` is an enhanced version of `TestBed.createComponent` that:
- Respects all lifecycle hooks
- Handles OnPush change detection
- Creates a wrapper component that binds inputs and outputs
```typescript
// Simple rendering
const fixture = MockRender(ComponentUnderTest);
// With inputs
const fixture = MockRender(ComponentUnderTest, {
name: 'Test User',
id: 123,
});
// Access the component instance
const component = fixture.point.componentInstance;
```
#### MockInstance
`MockInstance` helps configure mocks before they're initialized:
```typescript
// Adding a spy
const saveSpy = MockInstance(StorageService, 'save', jest.fn());
// Verify the spy was called
expect(saveSpy).toHaveBeenCalledWith(expectedData);
```
#### ngMocks Helpers
The library provides several helper functions:
```typescript
// Change form control values
ngMocks.change('[name=email]', 'test@example.com');
// Trigger events
ngMocks.trigger(element, 'click');
ngMocks.trigger(element, 'keyup.control.s'); // Complex events
// Find elements
const emailField = ngMocks.find('[name=email]');
const submitBtn = ngMocks.findAll('button[type="submit"]');
```
### Complete Example
Here's a full example of testing a component with ng-mocks:
```typescript
describe('ProfileComponent', () => {
// Reset customizations after each test
MockInstance.scope();
beforeEach(() => {
return MockBuilder(ProfileComponent, ProfileModule).keep(
ReactiveFormsModule,
);
});
it('saves profile data on ctrl+s hotkey', () => {
// Prepare test data
const profile = {
email: 'test@email.com',
firstName: 'Test',
lastName: 'User',
};
// Mock service method
const saveSpy = MockInstance(StorageService, 'save', jest.fn());
// Render with inputs
const { point } = MockRender(ProfileComponent, { profile });
// Change form value
ngMocks.change('[name=email]', 'updated@email.com');
// Trigger hotkey
ngMocks.trigger(point, 'keyup.control.s');
// Verify behavior
expect(saveSpy).toHaveBeenCalledWith({
email: 'updated@email.com',
firstName: profile.firstName,
lastName: profile.lastName,
});
});
});
```
### Integration with Spectator
While Spectator and ng-mocks have some overlapping functionality, they can be used together effectively:
```typescript
describe('CombinedExample', () => {
let spectator: Spectator<MyComponent>;
const createComponent = createComponentFactory({
component: MyComponent,
declarations: [
// Use ng-mocks to mock child components
MockComponent(ComplexChildComponent),
MockDirective(ComplexDirective),
],
providers: [
// Use ng-mocks to mock a service with default behavior
MockProvider(ComplexService),
],
});
beforeEach(() => {
// Configure a mock instance before the component is created
MockInstance(
ComplexService,
'getData',
jest.fn().mockReturnValue(of(['test'])),
);
// Create the component with Spectator
spectator = createComponent();
});
it('should work with mocked dependencies', () => {
// Use Spectator for interactions
spectator.click('button');
// Use ng-mocks to verify interactions with mocked dependencies
const service = ngMocks.get(ComplexService);
expect(service.getData).toHaveBeenCalled();
});
});
```
### When to Use ng-mocks
ng-mocks is particularly useful when:
1. You need to mock complex Angular artifacts like components, directives, or modules
2. You want to customize mock behavior at the instance level
3. You need to simulate complex user interactions
4. You're testing parent components that depend on multiple child components
For more details and advanced usage, refer to the [official ng-mocks documentation](https://ng-mocks.sudo.eu/).

View File

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

View File

@@ -2,7 +2,21 @@ import { Input, InputType } from '../../types';
import { CheckboxFilterInput, CheckboxFilterInputSchema } from '../schemas';
import { checkboxOptionMapping } from './checkbox-option.mapping';
export function checkboxFilterInputMapping(group: string, input: Input): CheckboxFilterInput {
/**
* Maps an Input object to a CheckboxFilterInput object
*
* This function takes an input of type Checkbox and maps it to a strongly-typed
* CheckboxFilterInput object, validating it against a schema. It also maps all child
* options and tracks which options are selected.
*
* @param group - The group identifier that this input belongs to
* @param input - The source input object to map
* @returns A validated CheckboxFilterInput object
*/
export function checkboxFilterInputMapping(
group: string,
input: Input,
): CheckboxFilterInput {
return CheckboxFilterInputSchema.parse({
group,
key: input.key,
@@ -13,7 +27,8 @@ export function checkboxFilterInputMapping(group: string, input: Input): Checkbo
maxOptions: input.options?.max,
options: input.options?.values?.map(checkboxOptionMapping),
selected:
input.options?.values?.filter((option) => option.selected).map((option) => option.value) ||
[],
input.options?.values
?.filter((option) => option.selected)
.map((option) => option.value) || [],
});
}

View File

@@ -1,7 +1,21 @@
import { CheckboxFilterInputOption, CheckboxFilterInputOptionSchema } from '../schemas';
import { Option } from '../../types';
import {
CheckboxFilterInputOption,
CheckboxFilterInputOptionSchema,
} from '../schemas';
export function checkboxOptionMapping(option: Option): CheckboxFilterInputOption {
/**
* Maps an Option object to a CheckboxFilterInputOption object
*
* This function converts a generic Option to a strongly-typed
* CheckboxFilterInputOption, validating it against a schema.
*
* @param option - The source option object to map
* @returns A validated CheckboxFilterInputOption object
*/
export function checkboxOptionMapping(
option: Option,
): CheckboxFilterInputOption {
return CheckboxFilterInputOptionSchema.parse({
label: option.label,
value: option.value,

View File

@@ -1,14 +0,0 @@
import { Input, InputType } from '../../types';
import { DateRangeFilterInput, DateRangeFilterInputSchema } from '../schemas';
export function dateRangeFilterInputMapping(group: string, input: Input): DateRangeFilterInput {
return DateRangeFilterInputSchema.parse({
group,
key: input.key,
label: input.label,
description: input.description,
type: InputType.DateRange,
start: input.options?.values?.[0].value,
stop: input.options?.values?.[1].value,
});
}

View File

@@ -1,5 +1,5 @@
import { Input, InputType } from '../../types';
import { dateRangeFilterInputMapping } from './data-range-filter-input.mapping';
import { dateRangeFilterInputMapping } from './date-range-filter-input.mapping';
import * as schemaModule from '../schemas/date-range-filter-input.schema';
describe('dateRangeFilterInputMapping', () => {

View File

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

View File

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

View File

@@ -1,31 +1,37 @@
import { Input, InputType } from '../../types';
import { filterInputMapping } from './filter-input.mapping';
import * as checkboxFilterInputMappingModule from './checkbox-filter-input.mapping';
import * as dateRangeFilterInputMappingModule from './data-range-filter-input.mapping';
import * as dateRangeFilterInputMappingModule from './date-range-filter-input.mapping';
import * as textFilterInputMappingModule from './text-filter-input.mapping';
describe('filterInputMapping', () => {
// Mock implementations for each specific mapping function
const mockTextFilterInputMapping = jest.fn().mockImplementation((group, input) => ({
type: InputType.Text,
group,
key: input.key,
mapped: 'text',
}));
const mockTextFilterInputMapping = jest
.fn()
.mockImplementation((group, input) => ({
type: InputType.Text,
group,
key: input.key,
mapped: 'text',
}));
const mockCheckboxFilterInputMapping = jest.fn().mockImplementation((group, input) => ({
type: InputType.Checkbox,
group,
key: input.key,
mapped: 'checkbox',
}));
const mockCheckboxFilterInputMapping = jest
.fn()
.mockImplementation((group, input) => ({
type: InputType.Checkbox,
group,
key: input.key,
mapped: 'checkbox',
}));
const mockDateRangeFilterInputMapping = jest.fn().mockImplementation((group, input) => ({
type: InputType.DateRange,
group,
key: input.key,
mapped: 'dateRange',
}));
const mockDateRangeFilterInputMapping = jest
.fn()
.mockImplementation((group, input) => ({
type: InputType.DateRange,
group,
key: input.key,
mapped: 'dateRange',
}));
beforeEach(() => {
jest.clearAllMocks();
@@ -124,7 +130,9 @@ describe('filterInputMapping', () => {
};
// Act & Assert
expect(() => filterInputMapping(group, input)).toThrowError('Unknown input type: 999');
expect(() => filterInputMapping(group, input)).toThrowError(
'Unknown input type: 999',
);
expect(mockTextFilterInputMapping).not.toHaveBeenCalled();
expect(mockCheckboxFilterInputMapping).not.toHaveBeenCalled();
expect(mockDateRangeFilterInputMapping).not.toHaveBeenCalled();

View File

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

View File

@@ -4,6 +4,21 @@ import { filterGroupMapping } from './filter-group.mapping';
import { filterInputMapping } from './filter-input.mapping';
import { orderByOptionMapping } from './order-by-option.mapping';
/**
* Maps a QuerySettings object to a Filter object
*
* This is the main mapping function that transforms query settings into a
* complete Filter object structure. It:
* 1. Creates filter groups from both filter and input settings
* 2. Maps all inputs from each group to their corresponding filter inputs
* 3. Maps order by options if present
*
* The resulting Filter object can be used by filter components to render
* the appropriate UI and handle user interactions.
*
* @param settings - The source query settings to map
* @returns A fully populated Filter object with groups, inputs, and ordering options
*/
export function filterMapping(settings: QuerySettings): Filter {
const filter: Filter = {
groups: [],

View File

@@ -1,6 +1,6 @@
export * from './checkbox-filter-input.mapping';
export * from './checkbox-option.mapping';
export * from './data-range-filter-input.mapping';
export * from './date-range-filter-input.mapping';
export * from './filter-group.mapping';
export * from './filter-input.mapping';
export * from './filter.mapping';

View File

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

View File

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

View File

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

View File

@@ -1,10 +1,24 @@
import { z } from 'zod';
/**
* Represents a checkbox option within a CheckboxFilterInput.
*
* @property label - Display text for the checkbox option
* @property value - The value to be used when this option is selected
*/
export const CheckboxFilterInputOptionSchema = z
.object({
label: z.string(),
value: z.string(),
label: z
.string()
.describe('Display text shown next to the checkbox in the UI.'),
value: z
.string()
.describe(
'Underlying value that will be sent in requests when this option is selected.',
),
})
.describe('CheckboxFilterInputOption');
export type CheckboxFilterInputOption = z.infer<typeof CheckboxFilterInputOptionSchema>;
export type CheckboxFilterInputOption = z.infer<
typeof CheckboxFilterInputOptionSchema
>;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,10 +1,19 @@
import { createComponentFactory, Spectator, mockProvider } from '@ngneat/spectator/jest';
import {
createComponentFactory,
Spectator,
mockProvider,
} from '@ngneat/spectator/jest';
import { MockComponents, MockDirectives } from 'ng-mocks';
import { FilterInputMenuButtonComponent } from './input-menu-button.component';
import { FilterInput, FilterService } from '../../core';
import { CdkConnectedOverlay, CdkOverlayOrigin, Overlay } from '@angular/cdk/overlay';
import {
CdkConnectedOverlay,
CdkOverlayOrigin,
Overlay,
} from '@angular/cdk/overlay';
import { FilterInputMenuComponent } from './input-menu.component';
import { NgIconComponent } from '@ng-icons/core';
import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
describe('FilterInputMenuButtonComponent', () => {
let spectator: Spectator<FilterInputMenuButtonComponent>;
@@ -19,13 +28,16 @@ describe('FilterInputMenuButtonComponent', () => {
MockComponents(NgIconComponent, FilterInputMenuComponent),
MockDirectives(CdkOverlayOrigin, CdkConnectedOverlay),
],
componentProviders: [mockProvider(Overlay, { scrollStrategies: { block: jest.fn() } })],
componentProviders: [
mockProvider(Overlay, { scrollStrategies: { block: jest.fn() } }),
],
providers: [
mockProvider(FilterService, {
isDefaultFilterInput: jest.fn().mockReturnValue(true),
commit: jest.fn(),
}),
],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
detectChanges: false,
});