diff --git a/docs/guidelines/testing.md b/docs/guidelines/testing.md index 1dd0e665c..6a8079a1b 100644 --- a/docs/guidelines/testing.md +++ b/docs/guidelines/testing.md @@ -9,12 +9,24 @@ - Mock external dependencies to isolate the unit under test - Mock child components to ensure true unit testing isolation +## Spectator Overview + +Spectator is a powerful library that simplifies Angular testing by: + +- Reducing boilerplate code in tests +- Providing easy DOM querying utilities +- Offering a clean API for triggering events +- Supporting testing of components, directives, and services +- Including custom matchers for clearer assertions + ## Best Practices ### Component Testing - Use `createComponentFactory` for standalone components -- Use `createHostFactory` when testing components with templates +- Use `createHostFactory` when testing components with templates and inputs/outputs +- Use `createDirectiveFactory` for testing directives +- Use `createServiceFactory` for testing services - Test component inputs, outputs, and lifecycle hooks - Verify DOM rendering and component behavior separately @@ -39,6 +51,143 @@ describe('ParentComponent', () => { }); ``` +## Spectator API Reference + +### Core Factory Methods + +1. **For Components**: + + ```typescript + const createComponent = createComponentFactory({ + component: MyComponent, + imports: [SomeModule], + declarations: [SomeDirective], + providers: [SomeService], + componentProviders: [], // Providers specific to the component + componentViewProviders: [], // ViewProviders for the component + mocks: [ServiceToMock], // Automatically mocks the service + detectChanges: false, // Whether to run change detection initially + }); + ``` + +2. **For Components with Host**: + + ```typescript + const createHost = createHostFactory({ + component: MyComponent, + template: ``, + // ...other options similar to createComponentFactory + }); + ``` + +3. **For Directives**: + + ```typescript + const createDirective = createDirectiveFactory({ + directive: MyDirective, + template: `
`, + // ...other options + }); + ``` + +4. **For Services**: + + ```typescript + const createService = createServiceFactory({ + service: MyService, + providers: [DependencyService], + mocks: [HttpClient], + entryComponents: [], + }); + ``` + +5. **For HTTP Services**: + ```typescript + const createHttpService = createHttpFactory({ + service: MyHttpService, + providers: [SomeService], + mocks: [TokenService], + }); + ``` + +### Querying Elements + +Spectator offers multiple ways to query the DOM: + +```typescript +// Basic CSS selectors +const button = spectator.query('button.submit'); +const inputs = spectator.queryAll('input'); + +// By directive/component type +const childComponent = spectator.query(ChildComponent); +const directives = spectator.queryAll(MyDirective); + +// Advanced text-based selectors +spectator.query(byText('Submit')); +spectator.query(byLabel('Username')); +spectator.query(byPlaceholder('Enter your email')); +spectator.query(byValue('Some value')); +spectator.query(byTitle('Click here')); +spectator.query(byAltText('Logo image')); +spectator.query(byRole('button', { pressed: true })); + +// Accessing native elements +const { nativeElement } = spectator.query('.class-name'); +``` + +### Working with Inputs and Outputs + +```typescript +// Setting component inputs +spectator.setInput('username', 'JohnDoe'); +spectator.setInput({ + username: 'JohnDoe', + isActive: true, +}); + +// For host components +spectator.setHostInput('propName', value); + +// Working with outputs +const outputSpy = jest.fn(); +spectator.output('statusChange').subscribe(outputSpy); + +// Trigger and verify outputs +spectator.click('button.submit'); +expect(outputSpy).toHaveBeenCalledWith({ status: 'submitted' }); +``` + +### Event Triggering API + +Spectator provides a rich API for simulating user interactions: + +```typescript +// Mouse events +spectator.click('.button'); +spectator.doubleClick('#item'); +spectator.hover('.tooltip'); +spectator.mouseEnter('.dropdown'); +spectator.mouseLeave('.dropdown'); + +// Keyboard events +spectator.keyboard.pressEscape(); +spectator.keyboard.pressEnter(); +spectator.keyboard.pressKey('A'); +spectator.keyboard.pressKeys('ctrl.a'); + +// Form interactions +spectator.typeInElement('New value', 'input.username'); +spectator.blur('input.username'); +spectator.focus('input.password'); +spectator.selectOption(selectEl, 'Option 2'); + +// Custom events +spectator.triggerEventHandler(MyComponent, 'customEvent', eventObj); +spectator.dispatchFakeEvent(element, 'mouseover'); +spectator.dispatchTouchEvent(element, 'touchstart'); +``` + ## Example Test Structures ### Basic Component Test @@ -128,6 +277,190 @@ it('should emit when button is clicked', () => { }); ``` +### Testing Services + +```typescript +import { createServiceFactory, SpectatorService } from '@ngneat/spectator/jest'; +import { UserService } from './user.service'; +import { HttpClient } from '@angular/common/http'; + +describe('UserService', () => { + let spectator: SpectatorService; + let httpClient: HttpClient; + + const createService = createServiceFactory({ + service: UserService, + mocks: [HttpClient], + }); + + beforeEach(() => { + spectator = createService(); + httpClient = spectator.inject(HttpClient); + }); + + it('should fetch users', () => { + // Arrange + const mockUsers = [{ id: 1, name: 'John' }]; + httpClient.get.mockReturnValue(of(mockUsers)); + + // Act + let result; + spectator.service.getUsers().subscribe((users) => { + result = users; + }); + + // Assert + expect(httpClient.get).toHaveBeenCalledWith('/api/users'); + expect(result).toEqual(mockUsers); + }); +}); +``` + +### Testing HTTP Services + +```typescript +import { + createHttpFactory, + HttpMethod, + SpectatorHttp, +} from '@ngneat/spectator/jest'; +import { UserHttpService } from './user-http.service'; + +describe('UserHttpService', () => { + let spectator: SpectatorHttp; + + const createHttp = createHttpFactory({ + service: UserHttpService, + }); + + beforeEach(() => (spectator = createHttp())); + + it('should call the correct API endpoint when getting users', () => { + spectator.service.getUsers().subscribe(); + spectator.expectOne('/api/users', HttpMethod.GET); + }); + + it('should include auth token in the headers', () => { + spectator.service.getUsers().subscribe(); + const req = spectator.expectOne('/api/users', HttpMethod.GET); + expect(req.request.headers.get('Authorization')).toBeTruthy(); + }); +}); +``` + +### Testing Directives + +```typescript +import { + createDirectiveFactory, + SpectatorDirective, +} from '@ngneat/spectator/jest'; +import { HighlightDirective } from './highlight.directive'; + +describe('HighlightDirective', () => { + let spectator: SpectatorDirective; + + const createDirective = createDirectiveFactory({ + directive: HighlightDirective, + template: `
Testing
`, + }); + + beforeEach(() => (spectator = createDirective())); + + it('should change the background color', () => { + expect(spectator.element).toHaveStyle({ + backgroundColor: 'yellow', + }); + }); + + it('should respond to mouse events', () => { + spectator.dispatchMouseEvent(spectator.element, 'mouseover'); + expect(spectator.element).toHaveStyle({ + backgroundColor: 'yellow', + fontWeight: 'bold', + }); + + spectator.dispatchMouseEvent(spectator.element, 'mouseout'); + expect(spectator.element).toHaveStyle({ + backgroundColor: 'yellow', + fontWeight: 'normal', + }); + }); +}); +``` + +### Testing Angular Standalone Components + +```typescript +import { createComponentFactory, Spectator } from '@ngneat/spectator/jest'; +import { StandaloneComponent } from './standalone.component'; + +describe('StandaloneComponent', () => { + let spectator: Spectator; + + const createComponent = createComponentFactory({ + component: StandaloneComponent, + // No need for imports as they are part of the component itself + }); + + beforeEach(() => { + spectator = createComponent(); + }); + + it('should create standalone component', () => { + expect(spectator.component).toBeTruthy(); + }); +}); +``` + +### Testing Deferrable Views (@defer) + +```typescript +import { createComponentFactory, Spectator } from '@ngneat/spectator/jest'; +import { ComponentWithDefer } from './component-with-defer.component'; + +describe('ComponentWithDefer', () => { + let spectator: Spectator; + + const createComponent = createComponentFactory({ + component: ComponentWithDefer, + }); + + beforeEach(() => { + spectator = createComponent(); + }); + + it('should render defer block content', () => { + // Render the completed state of the first defer block + spectator.deferBlock().renderComplete(); + + expect(spectator.query('.deferred-content')).toExist(); + expect(spectator.query('.placeholder-content')).not.toExist(); + }); + + it('should show loading state', () => { + // Render the loading state of the first defer block + spectator.deferBlock().renderLoading(); + + expect(spectator.query('.loading-indicator')).toExist(); + }); + + it('should show placeholder content', () => { + // Render the placeholder state of the first defer block + spectator.deferBlock().renderPlaceholder(); + + expect(spectator.query('.placeholder-content')).toExist(); + }); + + it('should work with multiple defer blocks', () => { + // For the second defer block in the template + spectator.deferBlock(1).renderComplete(); + + expect(spectator.query('.second-deferred-content')).toExist(); + }); +}); +``` + ## Common Patterns ### Query Elements @@ -157,6 +490,32 @@ spectator.typeInElement('value', 'input'); spectator.triggerEventHandler(MyComponent, 'eventName', eventValue); ``` +### Custom Matchers + +Spectator provides custom matchers to make assertions more readable: + +```typescript +// DOM matchers +expect('.button').toExist(); +expect('.inactive-element').not.toExist(); +expect('.title').toHaveText('Welcome'); +expect('.username').toContainText('John'); +expect('input').toHaveValue('test'); +expect('.error').toHaveClass('visible'); +expect('button').toBeDisabled(); +expect('div').toHaveAttribute('aria-label', 'Close'); +expect('.menu').toHaveData({ testId: 'main-menu' }); +expect('img').toHaveProperty('src', 'path/to/image.jpg'); +expect('.parent').toHaveDescendant('.child'); +expect('.parent').toHaveDescendantWithText({ + selector: '.child', + text: 'Child text', +}); + +// Object matchers +expect(object).toBePartial({ id: 1 }); +``` + ### Test Async Operations ```typescript @@ -189,6 +548,264 @@ it('should handle async operations', async () => { - Remember to clean up subscriptions 3. **Performance** + - Mock heavy dependencies - Keep test setup minimal - Use `beforeAll` for expensive operations shared across tests + +4. **Change Detection** + + - Use `spectator.detectChanges()` after modifying component properties + - For OnPush components with a host, use `spectator.detectComponentChanges()` + +5. **Injection** + - Use `spectator.inject(Service)` to access injected services + - Use `spectator.inject(Service, true)` to get service from the component injector + +## Running Tests + +When working in an Nx workspace, there are several ways to run tests: + +### Running Tests for a Specific Project + +```bash +# Run all tests for a specific project +npx nx test + +# Example: Run tests for the core/config library +npx nx test core-config +``` + +### Running Tests with Watch Mode + +```bash +# Run tests in watch mode for active development +npx nx test --watch +``` + +### Running Tests with Coverage + +```bash +# Run tests with coverage reporting +npx nx test --code-coverage +``` + +### Running a Specific Test File + +```bash +# Run a specific test file +npx nx test --test-file=path/to/your.spec.ts +``` + +### Running Affected Tests + +```bash +# Run tests only for projects affected by recent changes +npx nx affected:test +``` + +These commands help you target exactly which tests to run, making your testing workflow more efficient. + +## References + +- [Spectator Documentation](https://github.com/ngneat/spectator) - Official documentation for the Spectator testing library +- [Jest Documentation](https://jestjs.io/docs/getting-started) - Comprehensive guide to using Jest as a testing framework +- [ng-mocks Documentation](https://ng-mocks.sudo.eu/) - Detailed documentation on mocking Angular dependencies effectively + +## ng-mocks Guide + +### Overview + +ng-mocks is a powerful library that helps with Angular testing by: + +- Mocking Components, Directives, Pipes, Modules, Services, and Tokens +- Reducing boilerplate in tests +- Providing a simple interface to access declarations + +It's particularly useful for isolating components by mocking their dependencies, which makes tests faster and more reliable. + +### Global Configuration + +For optimal setup, configure ng-mocks in your test setup file: + +```typescript +// src/test-setup.ts or equivalent +import { ngMocks } from 'ng-mocks'; + +// Auto-spy all methods in mock declarations and providers +ngMocks.autoSpy('jest'); // or 'jasmine' + +// Reset customizations after each test automatically +ngMocks.defaultMock(AuthService, () => ({ + isLoggedIn$: EMPTY, + currentUser$: EMPTY, +})); +``` + +### Key APIs + +#### MockBuilder + +`MockBuilder` provides a fluent API to configure TestBed with mocks: + +```typescript +beforeEach(() => { + return MockBuilder( + ComponentUnderTest, // Keep this as real + ParentModule, // Mock everything else + ) + .keep(ReactiveFormsModule) // Keep this as real + .mock(SomeOtherDependency, { customConfig: true }); // Custom mock +}); +``` + +#### MockRender + +`MockRender` is an enhanced version of `TestBed.createComponent` that: + +- Respects all lifecycle hooks +- Handles OnPush change detection +- Creates a wrapper component that binds inputs and outputs + +```typescript +// Simple rendering +const fixture = MockRender(ComponentUnderTest); + +// With inputs +const fixture = MockRender(ComponentUnderTest, { + name: 'Test User', + id: 123, +}); + +// Access the component instance +const component = fixture.point.componentInstance; +``` + +#### MockInstance + +`MockInstance` helps configure mocks before they're initialized: + +```typescript +// Adding a spy +const saveSpy = MockInstance(StorageService, 'save', jest.fn()); + +// Verify the spy was called +expect(saveSpy).toHaveBeenCalledWith(expectedData); +``` + +#### ngMocks Helpers + +The library provides several helper functions: + +```typescript +// Change form control values +ngMocks.change('[name=email]', 'test@example.com'); + +// Trigger events +ngMocks.trigger(element, 'click'); +ngMocks.trigger(element, 'keyup.control.s'); // Complex events + +// Find elements +const emailField = ngMocks.find('[name=email]'); +const submitBtn = ngMocks.findAll('button[type="submit"]'); +``` + +### Complete Example + +Here's a full example of testing a component with ng-mocks: + +```typescript +describe('ProfileComponent', () => { + // Reset customizations after each test + MockInstance.scope(); + + beforeEach(() => { + return MockBuilder(ProfileComponent, ProfileModule).keep( + ReactiveFormsModule, + ); + }); + + it('saves profile data on ctrl+s hotkey', () => { + // Prepare test data + const profile = { + email: 'test@email.com', + firstName: 'Test', + lastName: 'User', + }; + + // Mock service method + const saveSpy = MockInstance(StorageService, 'save', jest.fn()); + + // Render with inputs + const { point } = MockRender(ProfileComponent, { profile }); + + // Change form value + ngMocks.change('[name=email]', 'updated@email.com'); + + // Trigger hotkey + ngMocks.trigger(point, 'keyup.control.s'); + + // Verify behavior + expect(saveSpy).toHaveBeenCalledWith({ + email: 'updated@email.com', + firstName: profile.firstName, + lastName: profile.lastName, + }); + }); +}); +``` + +### Integration with Spectator + +While Spectator and ng-mocks have some overlapping functionality, they can be used together effectively: + +```typescript +describe('CombinedExample', () => { + let spectator: Spectator; + + const createComponent = createComponentFactory({ + component: MyComponent, + declarations: [ + // Use ng-mocks to mock child components + MockComponent(ComplexChildComponent), + MockDirective(ComplexDirective), + ], + providers: [ + // Use ng-mocks to mock a service with default behavior + MockProvider(ComplexService), + ], + }); + + beforeEach(() => { + // Configure a mock instance before the component is created + MockInstance( + ComplexService, + 'getData', + jest.fn().mockReturnValue(of(['test'])), + ); + + // Create the component with Spectator + spectator = createComponent(); + }); + + it('should work with mocked dependencies', () => { + // Use Spectator for interactions + spectator.click('button'); + + // Use ng-mocks to verify interactions with mocked dependencies + const service = ngMocks.get(ComplexService); + expect(service.getData).toHaveBeenCalled(); + }); +}); +``` + +### When to Use ng-mocks + +ng-mocks is particularly useful when: + +1. You need to mock complex Angular artifacts like components, directives, or modules +2. You want to customize mock behavior at the instance level +3. You need to simulate complex user interactions +4. You're testing parent components that depend on multiple child components + +For more details and advanced usage, refer to the [official ng-mocks documentation](https://ng-mocks.sudo.eu/). diff --git a/libs/shared/filter/src/lib/actions/filter-actions.component.ts b/libs/shared/filter/src/lib/actions/filter-actions.component.ts index 9f382491d..9ed89171b 100644 --- a/libs/shared/filter/src/lib/actions/filter-actions.component.ts +++ b/libs/shared/filter/src/lib/actions/filter-actions.component.ts @@ -10,6 +10,21 @@ import { import { ButtonComponent } from '@isa/ui/buttons'; import { FilterService } from '../core'; +/** + * A standalone component that manages filter action buttons (apply/reset) + * + * This component provides UI controls to apply or reset filter values + * within the filtering system. It communicates with the FilterService + * to perform filter operations. + * + * @example + * + * + */ @Component({ selector: 'filter-actions', templateUrl: './filter-actions.component.html', @@ -23,20 +38,44 @@ import { FilterService } from '../core'; }, }) export class FilterActionsComponent { + /** The filter service used to interact with the filter system */ readonly filterService = inject(FilterService); + /** + * Optional key specifying which filter input to apply/reset + * If not provided, all filter inputs will be affected + */ inputKey = input(); + /** + * Controls whether the Apply button should be displayed + * @default true + */ canApply = input(true); + /** + * Computed signal that filters inputs to only include those with 'filter' group + */ filterInputs = computed(() => this.filterService.inputs().filter((input) => input.group === 'filter'), ); + /** + * Event emitted when filters are applied + */ applied = output(); + /** + * Event emitted when filters are reset + */ reseted = output(); + /** + * Applies the current filter values + * + * If inputKey is provided, only that specific filter input is committed. + * Otherwise, all filter inputs are committed. + */ onApply() { const inputKey = this.inputKey(); @@ -49,6 +88,13 @@ export class FilterActionsComponent { this.applied.emit(); } + /** + * Resets filter values to their defaults + * + * If inputKey is provided, only that specific filter input is reset. + * Otherwise, all filter inputs in the 'filter' group are reset. + * After resetting, all changes are committed. + */ onReset() { const inputKey = this.inputKey(); diff --git a/libs/shared/filter/src/lib/core/mappings/checkbox-filter-input.mapping.ts b/libs/shared/filter/src/lib/core/mappings/checkbox-filter-input.mapping.ts index 28aa43dd2..057aef600 100644 --- a/libs/shared/filter/src/lib/core/mappings/checkbox-filter-input.mapping.ts +++ b/libs/shared/filter/src/lib/core/mappings/checkbox-filter-input.mapping.ts @@ -2,7 +2,21 @@ import { Input, InputType } from '../../types'; import { CheckboxFilterInput, CheckboxFilterInputSchema } from '../schemas'; import { checkboxOptionMapping } from './checkbox-option.mapping'; -export function checkboxFilterInputMapping(group: string, input: Input): CheckboxFilterInput { +/** + * Maps an Input object to a CheckboxFilterInput object + * + * This function takes an input of type Checkbox and maps it to a strongly-typed + * CheckboxFilterInput object, validating it against a schema. It also maps all child + * options and tracks which options are selected. + * + * @param group - The group identifier that this input belongs to + * @param input - The source input object to map + * @returns A validated CheckboxFilterInput object + */ +export function checkboxFilterInputMapping( + group: string, + input: Input, +): CheckboxFilterInput { return CheckboxFilterInputSchema.parse({ group, key: input.key, @@ -13,7 +27,8 @@ export function checkboxFilterInputMapping(group: string, input: Input): Checkbo maxOptions: input.options?.max, options: input.options?.values?.map(checkboxOptionMapping), selected: - input.options?.values?.filter((option) => option.selected).map((option) => option.value) || - [], + input.options?.values + ?.filter((option) => option.selected) + .map((option) => option.value) || [], }); } diff --git a/libs/shared/filter/src/lib/core/mappings/checkbox-option.mapping.ts b/libs/shared/filter/src/lib/core/mappings/checkbox-option.mapping.ts index ab182154f..d665e115d 100644 --- a/libs/shared/filter/src/lib/core/mappings/checkbox-option.mapping.ts +++ b/libs/shared/filter/src/lib/core/mappings/checkbox-option.mapping.ts @@ -1,7 +1,21 @@ -import { CheckboxFilterInputOption, CheckboxFilterInputOptionSchema } from '../schemas'; import { Option } from '../../types'; +import { + CheckboxFilterInputOption, + CheckboxFilterInputOptionSchema, +} from '../schemas'; -export function checkboxOptionMapping(option: Option): CheckboxFilterInputOption { +/** + * Maps an Option object to a CheckboxFilterInputOption object + * + * This function converts a generic Option to a strongly-typed + * CheckboxFilterInputOption, validating it against a schema. + * + * @param option - The source option object to map + * @returns A validated CheckboxFilterInputOption object + */ +export function checkboxOptionMapping( + option: Option, +): CheckboxFilterInputOption { return CheckboxFilterInputOptionSchema.parse({ label: option.label, value: option.value, diff --git a/libs/shared/filter/src/lib/core/mappings/data-range-filter-input.mapping.ts b/libs/shared/filter/src/lib/core/mappings/data-range-filter-input.mapping.ts deleted file mode 100644 index a3a44ee99..000000000 --- a/libs/shared/filter/src/lib/core/mappings/data-range-filter-input.mapping.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Input, InputType } from '../../types'; -import { DateRangeFilterInput, DateRangeFilterInputSchema } from '../schemas'; - -export function dateRangeFilterInputMapping(group: string, input: Input): DateRangeFilterInput { - return DateRangeFilterInputSchema.parse({ - group, - key: input.key, - label: input.label, - description: input.description, - type: InputType.DateRange, - start: input.options?.values?.[0].value, - stop: input.options?.values?.[1].value, - }); -} diff --git a/libs/shared/filter/src/lib/core/mappings/data-range-filter-input.mapping.spec.ts b/libs/shared/filter/src/lib/core/mappings/date-range-filter-input.mapping.spec.ts similarity index 97% rename from libs/shared/filter/src/lib/core/mappings/data-range-filter-input.mapping.spec.ts rename to libs/shared/filter/src/lib/core/mappings/date-range-filter-input.mapping.spec.ts index c7a43187e..bf6de7d08 100644 --- a/libs/shared/filter/src/lib/core/mappings/data-range-filter-input.mapping.spec.ts +++ b/libs/shared/filter/src/lib/core/mappings/date-range-filter-input.mapping.spec.ts @@ -1,5 +1,5 @@ import { Input, InputType } from '../../types'; -import { dateRangeFilterInputMapping } from './data-range-filter-input.mapping'; +import { dateRangeFilterInputMapping } from './date-range-filter-input.mapping'; import * as schemaModule from '../schemas/date-range-filter-input.schema'; describe('dateRangeFilterInputMapping', () => { diff --git a/libs/shared/filter/src/lib/core/mappings/date-range-filter-input.mapping.ts b/libs/shared/filter/src/lib/core/mappings/date-range-filter-input.mapping.ts new file mode 100644 index 000000000..25384a6b4 --- /dev/null +++ b/libs/shared/filter/src/lib/core/mappings/date-range-filter-input.mapping.ts @@ -0,0 +1,28 @@ +import { Input, InputType } from '../../types'; +import { DateRangeFilterInput, DateRangeFilterInputSchema } from '../schemas'; + +/** + * Maps an Input object to a DateRangeFilterInput object + * + * This function takes an input of type DateRange and maps it to a strongly-typed + * DateRangeFilterInput object, validating it against a schema. It extracts the start + * and stop dates from the input's option values. + * + * @param group - The group identifier that this input belongs to + * @param input - The source input object to map + * @returns A validated DateRangeFilterInput object + */ +export function dateRangeFilterInputMapping( + group: string, + input: Input, +): DateRangeFilterInput { + return DateRangeFilterInputSchema.parse({ + group, + key: input.key, + label: input.label, + description: input.description, + type: InputType.DateRange, + start: input.options?.values?.[0]?.value, + stop: input.options?.values?.[1]?.value, + }); +} diff --git a/libs/shared/filter/src/lib/core/mappings/filter-group.mapping.ts b/libs/shared/filter/src/lib/core/mappings/filter-group.mapping.ts index 2ecf358a8..621241999 100644 --- a/libs/shared/filter/src/lib/core/mappings/filter-group.mapping.ts +++ b/libs/shared/filter/src/lib/core/mappings/filter-group.mapping.ts @@ -1,6 +1,16 @@ import { InputGroup } from '../../types'; import { FilterGroup, FilterGroupSchema } from '../schemas'; +/** + * Maps an InputGroup object to a FilterGroup object + * + * This function converts a generic InputGroup to a strongly-typed FilterGroup, + * validating it against a schema. It preserves the group identifier, label, + * and description. + * + * @param group - The source input group to map + * @returns A validated FilterGroup object + */ export function filterGroupMapping(group: InputGroup): FilterGroup { return FilterGroupSchema.parse({ group: group.group, diff --git a/libs/shared/filter/src/lib/core/mappings/filter-input.mapping.spec.ts b/libs/shared/filter/src/lib/core/mappings/filter-input.mapping.spec.ts index 46768aaf0..ff4c67e26 100644 --- a/libs/shared/filter/src/lib/core/mappings/filter-input.mapping.spec.ts +++ b/libs/shared/filter/src/lib/core/mappings/filter-input.mapping.spec.ts @@ -1,31 +1,37 @@ import { Input, InputType } from '../../types'; import { filterInputMapping } from './filter-input.mapping'; import * as checkboxFilterInputMappingModule from './checkbox-filter-input.mapping'; -import * as dateRangeFilterInputMappingModule from './data-range-filter-input.mapping'; +import * as dateRangeFilterInputMappingModule from './date-range-filter-input.mapping'; import * as textFilterInputMappingModule from './text-filter-input.mapping'; describe('filterInputMapping', () => { // Mock implementations for each specific mapping function - const mockTextFilterInputMapping = jest.fn().mockImplementation((group, input) => ({ - type: InputType.Text, - group, - key: input.key, - mapped: 'text', - })); + const mockTextFilterInputMapping = jest + .fn() + .mockImplementation((group, input) => ({ + type: InputType.Text, + group, + key: input.key, + mapped: 'text', + })); - const mockCheckboxFilterInputMapping = jest.fn().mockImplementation((group, input) => ({ - type: InputType.Checkbox, - group, - key: input.key, - mapped: 'checkbox', - })); + const mockCheckboxFilterInputMapping = jest + .fn() + .mockImplementation((group, input) => ({ + type: InputType.Checkbox, + group, + key: input.key, + mapped: 'checkbox', + })); - const mockDateRangeFilterInputMapping = jest.fn().mockImplementation((group, input) => ({ - type: InputType.DateRange, - group, - key: input.key, - mapped: 'dateRange', - })); + const mockDateRangeFilterInputMapping = jest + .fn() + .mockImplementation((group, input) => ({ + type: InputType.DateRange, + group, + key: input.key, + mapped: 'dateRange', + })); beforeEach(() => { jest.clearAllMocks(); @@ -124,7 +130,9 @@ describe('filterInputMapping', () => { }; // Act & Assert - expect(() => filterInputMapping(group, input)).toThrowError('Unknown input type: 999'); + expect(() => filterInputMapping(group, input)).toThrowError( + 'Unknown input type: 999', + ); expect(mockTextFilterInputMapping).not.toHaveBeenCalled(); expect(mockCheckboxFilterInputMapping).not.toHaveBeenCalled(); expect(mockDateRangeFilterInputMapping).not.toHaveBeenCalled(); diff --git a/libs/shared/filter/src/lib/core/mappings/filter-input.mapping.ts b/libs/shared/filter/src/lib/core/mappings/filter-input.mapping.ts index e8dd789cf..1889f56d3 100644 --- a/libs/shared/filter/src/lib/core/mappings/filter-input.mapping.ts +++ b/libs/shared/filter/src/lib/core/mappings/filter-input.mapping.ts @@ -1,9 +1,21 @@ import { Input, InputType } from '../../types'; import { FilterInput } from '../schemas'; import { checkboxFilterInputMapping } from './checkbox-filter-input.mapping'; -import { dateRangeFilterInputMapping } from './data-range-filter-input.mapping'; +import { dateRangeFilterInputMapping } from './date-range-filter-input.mapping'; import { textFilterInputMapping } from './text-filter-input.mapping'; +/** + * Maps an Input object to the appropriate FilterInput type based on its input type + * + * This function serves as a router that delegates to the specific mapping function + * based on the input type (Text, Checkbox, DateRange). It ensures that each input + * is converted to its corresponding strongly-typed filter input object. + * + * @param group - The group identifier that this input belongs to + * @param input - The source input object to map + * @returns A validated FilterInput object of the appropriate subtype + * @throws Error if the input type is not supported + */ export function filterInputMapping(group: string, input: Input): FilterInput { switch (input.type) { case InputType.Text: diff --git a/libs/shared/filter/src/lib/core/mappings/filter.mapping.ts b/libs/shared/filter/src/lib/core/mappings/filter.mapping.ts index cdd9b32d6..0322da705 100644 --- a/libs/shared/filter/src/lib/core/mappings/filter.mapping.ts +++ b/libs/shared/filter/src/lib/core/mappings/filter.mapping.ts @@ -4,6 +4,21 @@ import { filterGroupMapping } from './filter-group.mapping'; import { filterInputMapping } from './filter-input.mapping'; import { orderByOptionMapping } from './order-by-option.mapping'; +/** + * Maps a QuerySettings object to a Filter object + * + * This is the main mapping function that transforms query settings into a + * complete Filter object structure. It: + * 1. Creates filter groups from both filter and input settings + * 2. Maps all inputs from each group to their corresponding filter inputs + * 3. Maps order by options if present + * + * The resulting Filter object can be used by filter components to render + * the appropriate UI and handle user interactions. + * + * @param settings - The source query settings to map + * @returns A fully populated Filter object with groups, inputs, and ordering options + */ export function filterMapping(settings: QuerySettings): Filter { const filter: Filter = { groups: [], diff --git a/libs/shared/filter/src/lib/core/mappings/index.ts b/libs/shared/filter/src/lib/core/mappings/index.ts index 2ca2d54a5..50463c081 100644 --- a/libs/shared/filter/src/lib/core/mappings/index.ts +++ b/libs/shared/filter/src/lib/core/mappings/index.ts @@ -1,6 +1,6 @@ export * from './checkbox-filter-input.mapping'; export * from './checkbox-option.mapping'; -export * from './data-range-filter-input.mapping'; +export * from './date-range-filter-input.mapping'; export * from './filter-group.mapping'; export * from './filter-input.mapping'; export * from './filter.mapping'; diff --git a/libs/shared/filter/src/lib/core/mappings/order-by-option.mapping.ts b/libs/shared/filter/src/lib/core/mappings/order-by-option.mapping.ts index b44e6e588..57694529d 100644 --- a/libs/shared/filter/src/lib/core/mappings/order-by-option.mapping.ts +++ b/libs/shared/filter/src/lib/core/mappings/order-by-option.mapping.ts @@ -1,6 +1,17 @@ import { OrderBy } from '../../types'; import { OrderByOption, OrderByOptionSchema } from '../schemas'; +/** + * Maps an OrderBy object to an OrderByOption object + * + * This function transforms an OrderBy input definition into a strongly-typed + * OrderByOption that can be used by the filter component. It converts the + * desc boolean flag to a direction string ('asc' or 'desc') and initializes + * the selected state to false. + * + * @param orderBy - The source OrderBy object to map + * @returns A validated OrderByOption object + */ export function orderByOptionMapping(orderBy: OrderBy): OrderByOption { return OrderByOptionSchema.parse({ label: orderBy.label, diff --git a/libs/shared/filter/src/lib/core/mappings/text-filter-input.mapping.ts b/libs/shared/filter/src/lib/core/mappings/text-filter-input.mapping.ts index bdbccb87a..3fdbbf3e4 100644 --- a/libs/shared/filter/src/lib/core/mappings/text-filter-input.mapping.ts +++ b/libs/shared/filter/src/lib/core/mappings/text-filter-input.mapping.ts @@ -1,7 +1,20 @@ import { Input, InputType } from '../../types'; import { TextFilterInput, TextFilterInputSchema } from '../schemas'; -export function textFilterInputMapping(group: string, input: Input): TextFilterInput { +/** + * Maps an Input object to a TextFilterInput object + * + * This function takes an input of type Text and maps it to a strongly-typed + * TextFilterInput object, validating it against a schema. + * + * @param group - The group identifier that this input belongs to + * @param input - The source input object to map + * @returns A validated TextFilterInput object + */ +export function textFilterInputMapping( + group: string, + input: Input, +): TextFilterInput { return TextFilterInputSchema.parse({ group, key: input.key, diff --git a/libs/shared/filter/src/lib/core/schemas/base-filter-input.schema.ts b/libs/shared/filter/src/lib/core/schemas/base-filter-input.schema.ts index de8f5645f..0dddbac18 100644 --- a/libs/shared/filter/src/lib/core/schemas/base-filter-input.schema.ts +++ b/libs/shared/filter/src/lib/core/schemas/base-filter-input.schema.ts @@ -1,13 +1,43 @@ import { z } from 'zod'; import { InputType } from '../../types'; +/** + * Base schema for all filter input types. + * Contains common properties that all filter inputs must have. + * + * @property group - Group identifier that this input belongs to + * @property key - Unique identifier for the input within its group + * @property label - Optional display name for the input + * @property description - Optional detailed explanation of the input + * @property type - The type of input control (Text, Checkbox, DateRange) + */ export const BaseFilterInputSchema = z .object({ - group: z.string(), - key: z.string(), - label: z.string().optional(), - description: z.string().optional(), - type: z.nativeEnum(InputType), + group: z + .string() + .describe( + 'Identifier for the group this filter input belongs to. Used for organizing related filters.', + ), + key: z + .string() + .describe( + 'Unique identifier for this input within its group. Used as a key in requests and state management.', + ), + label: z + .string() + .optional() + .describe('Human-readable display name shown to users in the UI.'), + description: z + .string() + .optional() + .describe( + 'Detailed explanation of what this filter does, displayed as helper text in the UI.', + ), + type: z + .nativeEnum(InputType) + .describe( + 'Determines the type of input control and its behavior (Text, Checkbox, DateRange, etc.).', + ), }) .describe('BaseFilterInput'); diff --git a/libs/shared/filter/src/lib/core/schemas/checkbox-filter-input-option.schema.ts b/libs/shared/filter/src/lib/core/schemas/checkbox-filter-input-option.schema.ts index 6f3e92089..8b9bc2c14 100644 --- a/libs/shared/filter/src/lib/core/schemas/checkbox-filter-input-option.schema.ts +++ b/libs/shared/filter/src/lib/core/schemas/checkbox-filter-input-option.schema.ts @@ -1,10 +1,24 @@ import { z } from 'zod'; +/** + * Represents a checkbox option within a CheckboxFilterInput. + * + * @property label - Display text for the checkbox option + * @property value - The value to be used when this option is selected + */ export const CheckboxFilterInputOptionSchema = z .object({ - label: z.string(), - value: z.string(), + label: z + .string() + .describe('Display text shown next to the checkbox in the UI.'), + value: z + .string() + .describe( + 'Underlying value that will be sent in requests when this option is selected.', + ), }) .describe('CheckboxFilterInputOption'); -export type CheckboxFilterInputOption = z.infer; +export type CheckboxFilterInputOption = z.infer< + typeof CheckboxFilterInputOptionSchema +>; diff --git a/libs/shared/filter/src/lib/core/schemas/checkbox-filter-input.schema.ts b/libs/shared/filter/src/lib/core/schemas/checkbox-filter-input.schema.ts index 17bf9f12f..7d915f73c 100644 --- a/libs/shared/filter/src/lib/core/schemas/checkbox-filter-input.schema.ts +++ b/libs/shared/filter/src/lib/core/schemas/checkbox-filter-input.schema.ts @@ -3,11 +3,35 @@ import { BaseFilterInputSchema } from './base-filter-input.schema'; import { InputType } from '../../types'; import { CheckboxFilterInputOptionSchema } from './checkbox-filter-input-option.schema'; +/** + * Schema for checkbox-based filter inputs that allow users to select from multiple options. + * Extends the BaseFilterInputSchema with checkbox-specific properties. + * + * @property type - Must be InputType.Checkbox + * @property maxOptions - Optional limit on how many options can be selected + * @property options - Array of selectable checkbox options + * @property selected - Array of string values representing the currently selected options + */ export const CheckboxFilterInputSchema = BaseFilterInputSchema.extend({ - type: z.literal(InputType.Checkbox), - maxOptions: z.number().optional(), - options: z.array(CheckboxFilterInputOptionSchema), - selected: z.array(z.string()), + type: z + .literal(InputType.Checkbox) + .describe( + 'Specifies this as a checkbox input type. Must be InputType.Checkbox.', + ), + maxOptions: z + .number() + .optional() + .describe( + 'Optional maximum number of options that can be selected simultaneously. If not provided, all options can be selected.', + ), + options: z + .array(CheckboxFilterInputOptionSchema) + .describe('List of available checkbox options that users can select from.'), + selected: z + .array(z.string()) + .describe( + 'Array of values representing which options are currently selected. Each value corresponds to the value property of an option.', + ), }).describe('CheckboxFilterInput'); export type CheckboxFilterInput = z.infer; diff --git a/libs/shared/filter/src/lib/core/schemas/date-range-filter-input.schema.ts b/libs/shared/filter/src/lib/core/schemas/date-range-filter-input.schema.ts index 4f2812cdd..ee1761170 100644 --- a/libs/shared/filter/src/lib/core/schemas/date-range-filter-input.schema.ts +++ b/libs/shared/filter/src/lib/core/schemas/date-range-filter-input.schema.ts @@ -2,10 +2,32 @@ import { z } from 'zod'; import { BaseFilterInputSchema } from './base-filter-input.schema'; import { InputType } from '../../types'; +/** + * Schema for date range inputs that allow filtering by a time period. + * Extends BaseFilterInputSchema with date range specific properties. + * + * @property type - Must be InputType.DateRange + * @property start - Optional ISO string representing the start date of the range + * @property stop - Optional ISO string representing the end date of the range + */ export const DateRangeFilterInputSchema = BaseFilterInputSchema.extend({ - type: z.literal(InputType.DateRange), - start: z.string().optional(), - stop: z.string().optional(), + type: z + .literal(InputType.DateRange) + .describe( + 'Specifies this as a date range input type. Must be InputType.DateRange.', + ), + start: z + .string() + .optional() + .describe( + 'ISO date string representing the beginning of the date range. Optional if only an end date is needed.', + ), + stop: z + .string() + .optional() + .describe( + 'ISO date string representing the end of the date range. Optional if only a start date is needed.', + ), }).describe('DateRangeFilterInput'); export type DateRangeFilterInput = z.infer; diff --git a/libs/shared/filter/src/lib/core/schemas/filter-group.schema.ts b/libs/shared/filter/src/lib/core/schemas/filter-group.schema.ts index 221c2c856..ef1e3d779 100644 --- a/libs/shared/filter/src/lib/core/schemas/filter-group.schema.ts +++ b/libs/shared/filter/src/lib/core/schemas/filter-group.schema.ts @@ -1,10 +1,32 @@ import { z } from 'zod'; +/** + * Schema for filter groups that organize filter inputs into logical sections. + * Groups provide a way to categorize related filters together for better organization. + * + * @property group - Unique identifier for the filter group + * @property label - Optional display name for the filter group in the UI + * @property description - Optional detailed explanation of what this filter group represents + */ export const FilterGroupSchema = z .object({ - group: z.string(), - label: z.string().optional(), - description: z.string().optional(), + group: z + .string() + .describe( + 'Unique identifier for the filter group, used for referencing in code.', + ), + label: z + .string() + .optional() + .describe( + 'Human-readable name for the filter group displayed in the UI.', + ), + description: z + .string() + .optional() + .describe( + "Detailed explanation of the filter group's purpose or contents, may be shown as helper text.", + ), }) .describe('FilterGroup'); diff --git a/libs/shared/filter/src/lib/core/schemas/filter-input.schema.ts b/libs/shared/filter/src/lib/core/schemas/filter-input.schema.ts index e70f05421..acb9be8ad 100644 --- a/libs/shared/filter/src/lib/core/schemas/filter-input.schema.ts +++ b/libs/shared/filter/src/lib/core/schemas/filter-input.schema.ts @@ -3,6 +3,15 @@ import { CheckboxFilterInputSchema } from './checkbox-filter-input.schema'; import { DateRangeFilterInputSchema } from './date-range-filter-input.schema'; import { TextFilterInputSchema } from './text-filter-input.schema'; +/** + * A union schema representing all possible filter input types in the system. + * This schema allows for type discrimination based on the `type` property. + * + * Supported filter input types: + * - TextFilterInput: Simple text input fields + * - CheckboxFilterInput: Multiple-choice checkbox selections + * - DateRangeFilterInput: Date range selectors for time-based filtering + */ export const FilterInputSchema = z.union([ TextFilterInputSchema, CheckboxFilterInputSchema, diff --git a/libs/shared/filter/src/lib/core/schemas/filter.schema.ts b/libs/shared/filter/src/lib/core/schemas/filter.schema.ts index ed333fa0d..bf813056e 100644 --- a/libs/shared/filter/src/lib/core/schemas/filter.schema.ts +++ b/libs/shared/filter/src/lib/core/schemas/filter.schema.ts @@ -3,11 +3,31 @@ import { FilterGroupSchema } from './filter-group.schema'; import { FilterInputSchema } from './filter-input.schema'; import { OrderByOptionSchema } from './order-by-option.schema'; +/** + * Top-level schema representing the complete filter configuration. + * Combines filter groups, input fields, and ordering options into a unified structure. + * + * @property groups - Collection of filter groups for organizing inputs into categories + * @property inputs - All filter input controls available to the user + * @property orderBy - Available sorting options for the filtered results + */ export const FilterSchema = z .object({ - groups: z.array(FilterGroupSchema), - inputs: z.array(FilterInputSchema), - orderBy: z.array(OrderByOptionSchema), + groups: z + .array(FilterGroupSchema) + .describe( + 'Collection of filter groups that organize inputs into logical categories for better user experience.', + ), + inputs: z + .array(FilterInputSchema) + .describe( + 'Array of all filter input controls available to the user across all groups.', + ), + orderBy: z + .array(OrderByOptionSchema) + .describe( + 'Available sorting options that users can apply to the filtered results.', + ), }) .describe('Filter'); diff --git a/libs/shared/filter/src/lib/core/schemas/order-by-direction.schema.ts b/libs/shared/filter/src/lib/core/schemas/order-by-direction.schema.ts index 63103fe73..cf1a1419f 100644 --- a/libs/shared/filter/src/lib/core/schemas/order-by-direction.schema.ts +++ b/libs/shared/filter/src/lib/core/schemas/order-by-direction.schema.ts @@ -1,5 +1,16 @@ import { z } from 'zod'; -export const OrderByDirectionSchema = z.enum(['asc', 'desc']); +/** + * Enum schema for sort directions in ordering operations. + * Provides type-safe options for ascending or descending sorting. + * + * - 'asc': Ascending order (A-Z, 0-9, oldest to newest) + * - 'desc': Descending order (Z-A, 9-0, newest to oldest) + */ +export const OrderByDirectionSchema = z + .enum(['asc', 'desc']) + .describe( + 'Direction for sorting operations, either ascending (asc) or descending (desc).', + ); export type OrderByDirection = z.infer; diff --git a/libs/shared/filter/src/lib/core/schemas/order-by-option.schema.ts b/libs/shared/filter/src/lib/core/schemas/order-by-option.schema.ts index 3e23bb14a..15caf815d 100644 --- a/libs/shared/filter/src/lib/core/schemas/order-by-option.schema.ts +++ b/libs/shared/filter/src/lib/core/schemas/order-by-option.schema.ts @@ -1,12 +1,34 @@ import { z } from 'zod'; import { OrderByDirectionSchema } from './order-by-direction.schema'; +/** + * Schema for defining sort options available to the user. + * Each option represents a different field or property that can be used for ordering results. + * + * @property by - Field identifier to sort by (corresponds to a property in the data) + * @property label - Human-readable name for this sort option to display in the UI + * @property dir - Sort direction ('asc' for ascending or 'desc' for descending) + * @property selected - Whether this ordering option is currently active + */ export const OrderByOptionSchema = z .object({ - by: z.string(), - label: z.string(), - dir: OrderByDirectionSchema, - selected: z.boolean().default(false), + by: z + .string() + .describe( + 'Field identifier to sort by, matching a property in the data model.', + ), + label: z + .string() + .describe( + 'Human-readable name for this sort option to display in the UI.', + ), + dir: OrderByDirectionSchema.describe( + 'Sort direction, either "asc" for ascending or "desc" for descending.', + ), + selected: z + .boolean() + .default(false) + .describe('Indicates whether this ordering option is currently active.'), }) .describe('OrderByOption'); diff --git a/libs/shared/filter/src/lib/core/schemas/query-order.schema.ts b/libs/shared/filter/src/lib/core/schemas/query-order.schema.ts index f9694a178..660d51d01 100644 --- a/libs/shared/filter/src/lib/core/schemas/query-order.schema.ts +++ b/libs/shared/filter/src/lib/core/schemas/query-order.schema.ts @@ -1,10 +1,33 @@ import { z } from 'zod'; +/** + * Schema representing a sorting criterion in a query. + * This defines how results should be ordered when returned from a data source. + * + * @property by - Field identifier to sort by (corresponds to a property in the data) + * @property label - Human-readable name for this sort option + * @property desc - Whether the sort should be in descending order (true) or ascending (false) + * @property selected - Whether this ordering option is currently active + */ export const QueryOrderBySchema = z.object({ - by: z.string(), - label: z.string(), - desc: z.boolean(), - selected: z.boolean(), + by: z + .string() + .describe( + 'Field identifier to sort by, matching a property name in the data model.', + ), + label: z + .string() + .describe('Human-readable name for this sort option to display in the UI.'), + desc: z + .boolean() + .describe( + 'Sort direction flag: true for descending order (Z-A, newest first), false for ascending (A-Z, oldest first).', + ), + selected: z + .boolean() + .describe( + 'Indicates whether this ordering option is currently active in the query.', + ), }); export type QueryOrderBy = z.infer; diff --git a/libs/shared/filter/src/lib/core/schemas/query.schema.ts b/libs/shared/filter/src/lib/core/schemas/query.schema.ts index 0d205a5ad..980a92d50 100644 --- a/libs/shared/filter/src/lib/core/schemas/query.schema.ts +++ b/libs/shared/filter/src/lib/core/schemas/query.schema.ts @@ -1,12 +1,47 @@ import { z } from 'zod'; import { QueryOrderBySchema } from './query-order.schema'; +/** + * Schema representing a complete query for retrieving filtered and sorted data. + * This is the core schema used when making requests to APIs or data sources. + * + * @property filter - Record of filter criteria to apply when querying data + * @property input - Record of user input values from filter controls + * @property orderBy - Array of sort criteria to determine result ordering + * @property skip - Number of items to skip (for pagination) + * @property take - Maximum number of items to return (page size) + */ export const QuerySchema = z.object({ - filter: z.record(z.any()).default({}), - input: z.record(z.any()).default({}), - orderBy: z.array(QueryOrderBySchema).default([]), - skip: z.number().default(0), - take: z.number().default(25), + filter: z + .record(z.any()) + .default({}) + .describe( + 'Key-value pairs of filter criteria to apply when querying data. Keys correspond to data properties, values are the filtering constraints.', + ), + input: z + .record(z.any()) + .default({}) + .describe( + 'Key-value pairs representing user input from filter controls. Used to store and restore filter state.', + ), + orderBy: z + .array(QueryOrderBySchema) + .default([]) + .describe( + 'Array of sorting criteria that determine how results should be ordered. Applied in sequence for multi-level sorting.', + ), + skip: z + .number() + .default(0) + .describe( + 'Number of items to skip from the beginning of the result set. Used for implementing pagination.', + ), + take: z + .number() + .default(25) + .describe( + 'Maximum number of items to return in a single query. Defines the page size for paginated results.', + ), }); export type Query = z.infer; diff --git a/libs/shared/filter/src/lib/core/schemas/text-filter-input.schema.ts b/libs/shared/filter/src/lib/core/schemas/text-filter-input.schema.ts index 3a610b992..021a48dbe 100644 --- a/libs/shared/filter/src/lib/core/schemas/text-filter-input.schema.ts +++ b/libs/shared/filter/src/lib/core/schemas/text-filter-input.schema.ts @@ -2,11 +2,37 @@ import { z } from 'zod'; import { BaseFilterInputSchema } from './base-filter-input.schema'; import { InputType } from '../../types'; +/** + * Schema for text-based filter inputs that allow free-form text entry. + * Extends BaseFilterInputSchema with text input specific properties. + * + * @property type - Must be InputType.Text + * @property placeholder - Optional hint text to display when the input is empty + * @property defaultValue - Optional initial value to populate the input with + * @property value - Current value of the text input + */ export const TextFilterInputSchema = BaseFilterInputSchema.extend({ - type: z.literal(InputType.Text), - placeholder: z.string().optional(), - defaultValue: z.string().optional(), - value: z.string().optional(), + type: z + .literal(InputType.Text) + .describe('Specifies this as a text input type. Must be InputType.Text.'), + placeholder: z + .string() + .optional() + .describe( + 'Hint text displayed when the input is empty to guide users on what to enter.', + ), + defaultValue: z + .string() + .optional() + .describe( + 'Initial value to populate the text field with when first rendered or reset.', + ), + value: z + .string() + .optional() + .describe( + 'Current value of the text input field, reflecting what the user has entered.', + ), }).describe('TextFilterInput'); export type TextFilterInput = z.infer; diff --git a/libs/shared/filter/src/lib/menus/filter-menu/filter-menu-button.component.spec.ts b/libs/shared/filter/src/lib/menus/filter-menu/filter-menu-button.component.spec.ts index e69de29bb..f9266787c 100644 --- a/libs/shared/filter/src/lib/menus/filter-menu/filter-menu-button.component.spec.ts +++ b/libs/shared/filter/src/lib/menus/filter-menu/filter-menu-button.component.spec.ts @@ -0,0 +1,93 @@ +import { + createComponentFactory, + Spectator, + mockProvider, +} from '@ngneat/spectator/jest'; +import { MockComponents } from 'ng-mocks'; +import { FilterMenuButtonComponent } from './filter-menu-button.component'; +import { FilterService } from '../../core'; +import { Overlay } from '@angular/cdk/overlay'; +import { FilterMenuComponent } from './filter-menu.component'; +import { NgIconComponent } from '@ng-icons/core'; +import { IconButtonComponent } from '@isa/ui/buttons'; +import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; + +describe('FilterMenuButtonComponent', () => { + let spectator: Spectator; + let filterService: jest.Mocked; + + const createComponent = createComponentFactory({ + component: FilterMenuButtonComponent, + declarations: [ + MockComponents(NgIconComponent, FilterMenuComponent, IconButtonComponent), + ], + componentProviders: [ + mockProvider(Overlay, { scrollStrategies: { block: jest.fn() } }), + ], + providers: [ + mockProvider(FilterService, { + isDefaultFilter: jest.fn().mockReturnValue(true), + rollback: jest.fn(), + }), + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + detectChanges: false, + }); + + beforeEach(() => { + spectator = createComponent({ + props: { + rollbackOnClose: false, + }, + }); + spectator.detectChanges(); + + filterService = spectator.inject(FilterService); + }); + + it('should create the component', () => { + expect(spectator.component).toBeTruthy(); + }); + + it('should toggle open state and emit events', () => { + // Arrange + const closedSpy = jest.spyOn(spectator.component.closed, 'emit'); + const openedSpy = jest.spyOn(spectator.component.opened, 'emit'); + + // Act - Open + spectator.component.toggle(); + + // Assert - Open + expect(spectator.component.open()).toBe(true); + expect(openedSpy).toHaveBeenCalled(); + + // Act - Close + spectator.component.toggle(); + + // Assert - Close + expect(spectator.component.open()).toBe(false); + expect(closedSpy).toHaveBeenCalled(); + }); + + it('should rollback on close when rollbackOnClose is true', () => { + // Arrange + spectator.setInput('rollbackOnClose', true); + + // Act + spectator.component.closed.emit(); + + // Assert + expect(filterService.rollback).toHaveBeenCalled(); + }); + + it('should close menu when applied is emitted', () => { + // Arrange + spectator.component.open.set(true); + + // Act + spectator.component.applied.emit(); + + // Assert + expect(spectator.component.open()).toBe(false); + }); +}); diff --git a/libs/shared/filter/src/lib/menus/filter-menu/filter-menu.component.spec.ts b/libs/shared/filter/src/lib/menus/filter-menu/filter-menu.component.spec.ts deleted file mode 100644 index e69de29bb..000000000 diff --git a/libs/shared/filter/src/lib/menus/input-menu/input-menu-button.component.spec.ts b/libs/shared/filter/src/lib/menus/input-menu/input-menu-button.component.spec.ts index 5f1b4cabd..ce543e887 100644 --- a/libs/shared/filter/src/lib/menus/input-menu/input-menu-button.component.spec.ts +++ b/libs/shared/filter/src/lib/menus/input-menu/input-menu-button.component.spec.ts @@ -1,10 +1,19 @@ -import { createComponentFactory, Spectator, mockProvider } from '@ngneat/spectator/jest'; +import { + createComponentFactory, + Spectator, + mockProvider, +} from '@ngneat/spectator/jest'; import { MockComponents, MockDirectives } from 'ng-mocks'; import { FilterInputMenuButtonComponent } from './input-menu-button.component'; import { FilterInput, FilterService } from '../../core'; -import { CdkConnectedOverlay, CdkOverlayOrigin, Overlay } from '@angular/cdk/overlay'; +import { + CdkConnectedOverlay, + CdkOverlayOrigin, + Overlay, +} from '@angular/cdk/overlay'; import { FilterInputMenuComponent } from './input-menu.component'; import { NgIconComponent } from '@ng-icons/core'; +import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; describe('FilterInputMenuButtonComponent', () => { let spectator: Spectator; @@ -19,13 +28,16 @@ describe('FilterInputMenuButtonComponent', () => { MockComponents(NgIconComponent, FilterInputMenuComponent), MockDirectives(CdkOverlayOrigin, CdkConnectedOverlay), ], - componentProviders: [mockProvider(Overlay, { scrollStrategies: { block: jest.fn() } })], + componentProviders: [ + mockProvider(Overlay, { scrollStrategies: { block: jest.fn() } }), + ], providers: [ mockProvider(FilterService, { isDefaultFilterInput: jest.fn().mockReturnValue(true), commit: jest.fn(), }), ], + schemas: [CUSTOM_ELEMENTS_SCHEMA], detectChanges: false, });