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

Welcome, {{ user.name }}!

+ } @else if (isLoading) { +

Loading user data...

+ } @else { +

Please log in

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

Welcome, {{ user.name }}!

+

Loading user data...

+

Please log in

+ +
    +
  • {{ item.name }}
  • +
  • No items available
  • +
+ + + + +
+ ``` + + - When using `@for`, always specify the `track` expression to optimize rendering performance: + - Use a unique identifier property (like `id` or `uuid`) when available + - Only use `$index` for static collections that never change + - Avoid using non-unique properties that could result in DOM mismatches + - Leverage contextual variables in `@for` blocks: + - `$index` - Current item index + - `$first` - Boolean indicating if this is the first item + - `$last` - Boolean indicating if this is the last item + - `$even` - Boolean indicating if this index is even + - `$odd` - Boolean indicating if this index is odd + - `$count` - Total number of items in the collection + + ```html + + @for (item of items; track item.id; let i = $index, isLast = $last) { +
  • {{ i + 1 }}. {{ item.name }}
  • + } + ``` + + - Use the `@empty` block with `@for` to handle empty collections gracefully + - Store conditional expression results in variables for clearer templates: + + ```html + + @if (user.permissions.canEditSettings; as canEdit) { + + } + ``` ## Project-Specific Preferences -- **Frameworks**: Follow best practices for Nx, Hono, and Zod. +- **Frameworks**: Follow best practices for Nx, Angular, date-fns, Ngrx, RxJs and Zod. - **Testing**: Use Jest with Spectator for unit tests and follow the Arrange-Act-Assert pattern. -- **File Naming**: Use kebab-case for filenames (e.g., `my-component.ts`). +- **File Naming**: + + - Use kebab-case for filenames (e.g., `my-component.ts`). + - Follow a pattern that describes the symbol's feature then its type: `feature.type.ts` + + ``` + // Good examples + user.service.ts + auth.guard.ts + product-list.component.ts + order.model.ts + + // Bad examples + service-user.ts + userService.ts + ``` + - **Comments**: Use JSDoc for documenting functions, classes, and modules. ## Formatting @@ -198,25 +522,10 @@ function getUser(id) { - Use ESLint with the recommended TypeScript and Nx configurations. - Prettier should be used for consistent formatting. -## Example - -```typescript -// Good Example -interface User { - id: string; - name: string; -} - -const getUser = (id: string): User => { - // ...function logic... -}; - -// Bad Example -function getUser(id) { - // ...function logic... -} -``` - ## References -- [Angular Style Guide](https://angular.dev/style-guide#) +- [Angular Style Guide](https://angular.dev/style-guide) - Official Angular style guide with best practices for Angular development +- [Angular Control Flow](https://angular.dev/guide/templates/control-flow) - Official Angular documentation on the new control flow syntax (@if, @for, @switch) +- [TypeScript Style Guide](https://ts.dev/style/) - TypeScript community style guide with patterns and practices +- [SOLID Design Principles](https://en.wikipedia.org/wiki/SOLID) - Wikipedia article explaining the SOLID principles in object-oriented design +- [Clean Code](https://www.amazon.com/Clean-Code-Handbook-Software-Craftsmanship/dp/0132350882) - Robert C. Martin's seminal book on writing clean, maintainable code diff --git a/docs/guidelines/testing.md b/docs/guidelines/testing.md index d31e97eb8..6a8079a1b 100644 --- a/docs/guidelines/testing.md +++ b/docs/guidelines/testing.md @@ -9,13 +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 -- Mock child components using `ng-mocks` +- 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 @@ -40,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 @@ -129,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 @@ -158,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 @@ -190,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/oms/feature/return-search/src/lib/return-search-result/return-search-result.component.html b/libs/oms/feature/return-search/src/lib/return-search-result/return-search-result.component.html index f4b3c23c7..0fd29388b 100644 --- a/libs/oms/feature/return-search/src/lib/return-search-result/return-search-result.component.html +++ b/libs/oms/feature/return-search/src/lib/return-search-result/return-search-result.component.html @@ -12,20 +12,23 @@ [rollbackOnClose]="true" > - - - + @if (mobileBreakpoint()) { + + } @else { + + } -@if (orderByVisible()) { +@if (mobileBreakpoint() && showOrderByToolbarMobile()) { @@ -48,7 +51,10 @@ } @placeholder {
    - +
    } } diff --git a/libs/oms/feature/return-search/src/lib/return-search-result/return-search-result.component.ts b/libs/oms/feature/return-search/src/lib/return-search-result/return-search-result.component.ts index 5b2679ef3..1dcc25a2f 100644 --- a/libs/oms/feature/return-search/src/lib/return-search-result/return-search-result.component.ts +++ b/libs/oms/feature/return-search/src/lib/return-search-result/return-search-result.component.ts @@ -4,7 +4,7 @@ import { Component, computed, inject, - signal, + linkedSignal, } from '@angular/core'; import { injectActivatedProcessId } from '@isa/core/process'; @@ -19,13 +19,28 @@ import { import { IconButtonComponent } from '@isa/ui/buttons'; import { EmptyStateComponent } from '@isa/ui/empty-state'; import { NgIconComponent, provideIcons } from '@ng-icons/core'; -import { isaActionSort } from '@isa/icons'; -import { ReceiptListItem, ReturnSearchStatus, ReturnSearchStore } from '@isa/oms/data-access'; +import { isaActionSort, isaActionFilter } from '@isa/icons'; +import { + ReceiptListItem, + ReturnSearchStatus, + ReturnSearchStore, +} from '@isa/oms/data-access'; import { ReturnSearchResultItemComponent } from './return-search-result-item/return-search-result-item.component'; -import { BreakpointDirective, InViewportDirective } from '@isa/ui/layout'; +import { Breakpoint, InViewportDirective } from '@isa/ui/layout'; import { CallbackResult, ListResponseArgs } from '@isa/common/result'; import { injectRestoreScrollPosition } from '@isa/utils/scroll-position'; +import { breakpoint } from '@isa/ui/layout'; +/** + * Component responsible for displaying return search results. + * + * This component handles: + * - Displaying a list of return search results + * - Filtering and sorting results + * - Searching for returns + * - Pagination with infinite scrolling + * - Responsive layout changes based on device size + */ @Component({ selector: 'oms-feature-return-search-result', templateUrl: './return-search-result.component.html', @@ -38,27 +53,47 @@ import { injectRestoreScrollPosition } from '@isa/utils/scroll-position'; IconButtonComponent, SearchBarInputComponent, EmptyStateComponent, - NgIconComponent, FilterMenuButtonComponent, - BreakpointDirective, InViewportDirective, + NgIconComponent, ], - providers: [provideIcons({ isaActionSort })], + providers: [provideIcons({ isaActionSort, isaActionFilter })], }) export class ReturnSearchResultComponent implements AfterViewInit { + /** Route service for navigation and route information */ #route = inject(ActivatedRoute); + + /** Router service for programmatic navigation */ #router = inject(Router); + + /** Service for managing filters and search queries */ #filterService = inject(FilterService); + /** Utility for restoring scroll position when returning to this view */ restoreScrollPosition = injectRestoreScrollPosition(); + /** Current process ID from the activated route */ processId = injectActivatedProcessId(); + + /** Store for managing return search data and operations */ returnSearchStore = inject(ReturnSearchStore); - orderByVisible = signal(false); - + /** Enum reference for template usage */ ReturnSearchStatus = ReturnSearchStatus; + /** Signal tracking whether the viewport is at tablet size or above */ + mobileBreakpoint = breakpoint([Breakpoint.Tablet]); + + /** + * Signal controlling the visibility of the order-by toolbar on mobile + * Initially shows toolbar when NOT on mobile + */ + showOrderByToolbarMobile = linkedSignal(() => !this.mobileBreakpoint()); + + /** + * Computes the current return search entity based on the active process ID + * @returns The return search entity or undefined if no process ID is available + */ entity = computed(() => { const processId = this.processId(); if (processId) { @@ -67,45 +102,83 @@ export class ReturnSearchResultComponent implements AfterViewInit { return undefined; }); + /** + * Returns the list of return items from the current entity + * @returns Array of return items or empty array if none available + */ entityItems = computed(() => { return this.entity()?.items ?? []; }); + /** + * Returns the total number of hits from the search results + * @returns Total hits or 0 if no data available + */ entityHits = computed(() => { return this.entity()?.hits ?? 0; }); + /** + * Returns the current status of the search operation + * @returns Current status or Idle if no entity is available + */ entityStatus = computed(() => { return this.entity()?.status ?? ReturnSearchStatus.Idle; }); + /** + * Determines whether to render the item list based on available items + * @returns Boolean indicating if items are available to display + */ renderItemList = computed(() => { return this.entityItems().length; }); + /** + * Determines whether to show pagination loading indicator + * @returns Boolean indicating if pagination loading should be shown + */ renderPagingLoader = computed(() => { return this.entityStatus() === ReturnSearchStatus.Pending; }); + /** + * Determines whether to show the main search loading indicator + * Shows loader only when search is pending and no items are available yet + * @returns Boolean indicating if search loading should be shown + */ renderSearchLoader = computed(() => { - return this.entityStatus() === ReturnSearchStatus.Pending && this.entityItems().length === 0; + return ( + this.entityStatus() === ReturnSearchStatus.Pending && + this.entityItems().length === 0 + ); }); + /** + * Determines whether to render the page trigger for infinite scrolling + * Triggers pagination when more results are available than currently loaded + * @returns Boolean indicating if page trigger should be shown + */ renderPageTrigger = computed(() => { const entity = this.entity(); - if (!entity) return false; - if (entity.status === ReturnSearchStatus.Pending) return false; + if (!entity || entity.status === ReturnSearchStatus.Pending) return false; const { hits, items } = entity; - if (!hits || !Array.isArray(items)) return false; - - return hits > items.length; + return Boolean(hits && Array.isArray(items) && hits > items.length); }); + /** + * Lifecycle hook called after the component's view has been initialized + * Restores scroll position when returning to this view + */ ngAfterViewInit(): void { this.restoreScrollPosition(); } + /** + * Initiates a search operation with the current filter settings + * Navigates directly to the receipt if only one result is found + */ search() { const processId = this.processId(); if (processId) { @@ -119,6 +192,11 @@ export class ReturnSearchResultComponent implements AfterViewInit { } } + /** + * Callback function for search operations + * Automatically navigates to the receipt detail view if exactly one result is found + * @param result The callback result containing search data + */ searchCb = ({ data }: CallbackResult>) => { if (data) { if (data.result.length === 1) { @@ -127,6 +205,11 @@ export class ReturnSearchResultComponent implements AfterViewInit { } }; + /** + * Handles infinite scrolling pagination when the page trigger enters the viewport + * Loads more results when triggered + * @param inViewport Boolean indicating if the trigger element is in viewport + */ paging(inViewport: boolean) { if (!inViewport) { return; @@ -142,6 +225,10 @@ export class ReturnSearchResultComponent implements AfterViewInit { } } + /** + * Navigates to a specified path while preserving filter query parameters + * @param path Array of path segments for navigation + */ navigate(path: (string | number)[]) { this.#router.navigate(path, { relativeTo: this.#route, diff --git a/libs/shared/filter/src/lib/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/filter.service.ts b/libs/shared/filter/src/lib/core/filter.service.ts index 4a8bd88f7..b8db512c5 100644 --- a/libs/shared/filter/src/lib/core/filter.service.ts +++ b/libs/shared/filter/src/lib/core/filter.service.ts @@ -1,10 +1,11 @@ -import { computed, inject, Injectable, Input, signal } from '@angular/core'; +import { computed, inject, Injectable, signal } from '@angular/core'; import { InputType } from '../types'; import { getState, patchState, signalState } from '@ngrx/signals'; -import { mapToFilter } from './mappings'; +import { filterMapping } from './mappings'; import { isEqual } from 'lodash'; import { FilterInput, + OrderByDirection, OrderByDirectionSchema, Query, QuerySchema, @@ -20,7 +21,7 @@ export class FilterService { readonly settings = inject(QUERY_SETTINGS); - private readonly defaultState = mapToFilter(this.settings); + private readonly defaultState = filterMapping(this.settings); #commitdState = signal(structuredClone(this.defaultState)); @@ -39,14 +40,15 @@ export class FilterService { } setOrderBy( - orderBy: { by: string; dir: 'asc' | 'desc' | undefined }, + by: string, + dir: OrderByDirection | undefined, options?: { commit: boolean }, ) { const orderByList = this.#state.orderBy().map((o) => { - if (o.by === orderBy.by) { - return { ...o, dir: orderBy.dir }; + if (o.by === by && o.dir === dir) { + return { ...o, selected: true }; } - return { ...o, dir: undefined }; + return { ...o, selected: false }; }); patchState(this.#state, { orderBy: orderByList }); @@ -56,29 +58,6 @@ export class FilterService { } } - toggleOrderBy(by: string, options?: { commit: boolean }): void { - const orderBy = this.#state.orderBy(); - - const orderByIndex = orderBy.findIndex((o) => o.by === by); - - if (orderByIndex === -1) { - console.warn(`No orderBy found with by: ${by}`); - return; - } - - let orderByDir = orderBy[orderByIndex].dir; - - if (!orderByDir) { - orderByDir = 'asc'; - } else if (orderByDir === 'asc') { - orderByDir = 'desc'; - } else { - orderByDir = undefined; - } - - this.setOrderBy({ by, dir: orderByDir }, options); - } - setInputTextValue( key: string, value: string | undefined, @@ -300,14 +279,12 @@ export class FilterService { } commitOrderBy() { - const orderBy = this.#state.orderBy().map((o) => { - const committedOrderBy = this.#commitdState().orderBy.find( - (co) => co.by === o.by, - ); - return { ...o, dir: committedOrderBy?.dir }; - }); + const orderBy = this.#state.orderBy(); - patchState(this.#state, { orderBy }); + this.#commitdState.set({ + ...this.#commitdState(), + orderBy, + }); } clear(options?: { commit: boolean }) { @@ -340,7 +317,7 @@ export class FilterService { * @param options.commit - If `true`, the changes will be committed after resetting the state. */ reset(options?: { commit: boolean }) { - patchState(this.#state, mapToFilter(this.settings)); + patchState(this.#state, structuredClone(this.defaultState)); if (options?.commit) { this.commit(); } @@ -363,7 +340,7 @@ export class FilterService { * ``` */ resetInput(key: string, options?: { commit: boolean }) { - const defaultFilter = mapToFilter(this.settings); + const defaultFilter = structuredClone(this.defaultState); const inputToReset = defaultFilter.inputs.find((i) => i.key === key); if (!inputToReset) { @@ -390,13 +367,8 @@ export class FilterService { } resetOrderBy(options?: { commit: boolean }) { - const defaultOrderBy = mapToFilter(this.settings).orderBy; - const orderBy = this.#state.orderBy().map((o) => { - const defaultOrder = defaultOrderBy.find((do_) => do_.by === o.by); - return { ...o, dir: defaultOrder?.dir }; - }); - - patchState(this.#state, { orderBy }); + const defaultOrderBy = structuredClone(this.defaultState.orderBy); + patchState(this.#state, { orderBy: defaultOrderBy }); if (options?.commit) { this.commit(); @@ -431,7 +403,7 @@ export class FilterService { } } - const orderBy = commited.orderBy.find((o) => o.dir); + const orderBy = commited.orderBy.find((o) => o.selected); if (orderBy) { result['orderBy'] = `${orderBy.by}:${orderBy.dir}`; @@ -498,14 +470,16 @@ export class FilterService { } return acc; }, {}), - orderBy: orderBy.map((o) => { - return { - by: o.by, - label: o.label, - desc: o.dir === 'desc', - selected: true, - }; - }), + orderBy: orderBy + .filter((o) => o.selected) + .map((o) => { + return { + by: o.by, + label: o.label, + desc: o.dir === 'desc', + selected: true, + }; + }), }); }); @@ -514,13 +488,17 @@ export class FilterService { options?: { commit: boolean }, ): void { this.reset(); + for (const key in params) { if (key === 'orderBy') { const [by, dir] = params[key].split(':'); - const orderBy = this.orderBy().find((o) => o.by === by); + const orderBy = this.orderBy().some( + (o) => o.by === by && o.dir === dir, + ); if (orderBy) { - this.setOrderBy({ by, dir: OrderByDirectionSchema.parse(dir) }); + console.warn(`OrderBy already exists: ${by}:${dir}`); + this.setOrderBy(by, OrderByDirectionSchema.parse(dir)); } continue; } diff --git a/libs/shared/filter/src/lib/core/mappings.ts b/libs/shared/filter/src/lib/core/mappings.ts deleted file mode 100644 index 17860730b..000000000 --- a/libs/shared/filter/src/lib/core/mappings.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { Input, InputGroup, InputType, Option, QuerySettings } from '../types'; -import { - CheckboxFilterInput, - CheckboxFilterInputOption, - CheckboxFilterInputOptionSchema, - CheckboxFilterInputSchema, - DateRangeFilterInput, - DateRangeFilterInputSchema, - Filter, - FilterGroup, - FilterGroupSchema, - FilterInput, - OrderBySchema, - TextFilterInput, - TextFilterInputSchema, -} from './schemas'; - -export function mapToFilter(settings: QuerySettings): Filter { - const filter: Filter = { - groups: [], - inputs: [], - orderBy: [], - }; - - const groups = [...settings.filter, ...settings.input]; - - for (const group of groups) { - filter.groups.push(mapToFilterGroup(group)); - - for (const input of group.input) { - filter.inputs.push(mapToFilterInput(group.group, input)); - } - } - - if (settings.orderBy) { - const bys = new Set(); - for (const orderBy of settings.orderBy) { - if (orderBy.by && bys.has(orderBy.by)) { - continue; - } - - if (orderBy.by) { - filter.orderBy.push(OrderBySchema.parse(orderBy)); - bys.add(orderBy.by); - } - } - } - - return filter; -} - -function mapToFilterGroup(group: InputGroup): FilterGroup { - return FilterGroupSchema.parse({ - group: group.group, - label: group.label, - description: group.description, - }); -} - -function mapToFilterInput(group: string, input: Input): FilterInput { - switch (input.type) { - case InputType.Text: - return mapToTextFilterInput(group, input); - case InputType.Checkbox: - return mapToCheckboxFilterInput(group, input); - case InputType.DateRange: - return mapToDateRangeFilterInput(group, input); - } - throw new Error(`Unknown input type: ${input.type}`); -} - -function mapToTextFilterInput(group: string, input: Input): TextFilterInput { - return TextFilterInputSchema.parse({ - group, - key: input.key, - label: input.label, - description: input.description, - type: InputType.Text, - defaultValue: input.value, - value: input.value, - placeholder: input.placeholder, - }); -} - -function mapToCheckboxFilterInput( - group: string, - input: Input, -): CheckboxFilterInput { - return CheckboxFilterInputSchema.parse({ - group, - key: input.key, - label: input.label, - description: input.description, - type: InputType.Checkbox, - defaultValue: input.value, - maxOptions: input.options?.max, - options: input.options?.values?.map(mapToCheckboxOption), - selected: - input.options?.values - ?.filter((option) => option.selected) - .map((option) => option.value) || [], - }); -} - -function mapToCheckboxOption(option: Option): CheckboxFilterInputOption { - return CheckboxFilterInputOptionSchema.parse({ - label: option.label, - value: option.value, - }); -} - -function mapToDateRangeFilterInput( - group: string, - input: Input, -): DateRangeFilterInput { - return DateRangeFilterInputSchema.parse({ - group, - key: input.key, - label: input.label, - description: input.description, - type: InputType.DateRange, - start: input.options?.values?.[0].value, - minStart: input.options?.values?.[0].minValue, - maxStart: input.options?.values?.[0].maxValue, - stop: input.options?.values?.[1].value, - minStop: input.options?.values?.[1].minValue, - maxStop: input.options?.values?.[1].maxValue, - }); -} diff --git a/libs/shared/filter/src/lib/core/mappings/checkbox-filter-input.mapping.spec.ts b/libs/shared/filter/src/lib/core/mappings/checkbox-filter-input.mapping.spec.ts new file mode 100644 index 000000000..506ce4af4 --- /dev/null +++ b/libs/shared/filter/src/lib/core/mappings/checkbox-filter-input.mapping.spec.ts @@ -0,0 +1,220 @@ +import { Input, InputType } from '../../types'; +import { checkboxFilterInputMapping } from './checkbox-filter-input.mapping'; +import * as checkboxOptionMappingModule from './checkbox-option.mapping'; +import * as schemaModule from '../schemas/checkbox-filter-input.schema'; + +describe('checkboxFilterInputMapping', () => { + const mockCheckboxOptionMapping = jest.fn().mockImplementation((option) => ({ + label: option.label, + value: option.value, + })); + + const mockSchemaParser = jest.fn().mockImplementation((input) => input); + + beforeEach(() => { + jest.clearAllMocks(); + jest + .spyOn(checkboxOptionMappingModule, 'checkboxOptionMapping') + .mockImplementation(mockCheckboxOptionMapping); + + // Mock the schema parse method to avoid validation errors in tests + jest + .spyOn(schemaModule.CheckboxFilterInputSchema, 'parse') + .mockImplementation(mockSchemaParser); + }); + + it('should map minimal input correctly', () => { + // Arrange + const group = 'testGroup'; + const input: Input = { + key: 'testKey', + label: 'Test Label', + type: InputType.Checkbox, + }; + + // Act + const result = checkboxFilterInputMapping(group, input); + + // Assert + expect(mockSchemaParser).toHaveBeenCalledWith({ + group: 'testGroup', + key: 'testKey', + label: 'Test Label', + type: InputType.Checkbox, + defaultValue: undefined, + maxOptions: undefined, + options: undefined, + selected: [], + description: undefined, + }); + + expect(result).toEqual({ + group: 'testGroup', + key: 'testKey', + label: 'Test Label', + type: InputType.Checkbox, + defaultValue: undefined, + maxOptions: undefined, + options: undefined, + selected: [], + description: undefined, + }); + }); + + it('should map complete input correctly', () => { + // Arrange + const group = 'testGroup'; + const input: Input = { + key: 'testKey', + label: 'Test Label', + description: 'Test Description', + type: InputType.Checkbox, + value: 'defaultValue', + options: { + max: 3, + values: [ + { label: 'Option 1', value: 'value1', selected: false }, + { label: 'Option 2', value: 'value2', selected: false }, + ], + }, + }; + + // Act + const result = checkboxFilterInputMapping(group, input); + + // Assert + expect(mockSchemaParser).toHaveBeenCalledWith({ + group: 'testGroup', + key: 'testKey', + label: 'Test Label', + description: 'Test Description', + type: InputType.Checkbox, + defaultValue: 'defaultValue', + maxOptions: 3, + options: [ + { label: 'Option 1', value: 'value1' }, + { label: 'Option 2', value: 'value2' }, + ], + selected: [], + }); + + expect(result).toEqual({ + group: 'testGroup', + key: 'testKey', + label: 'Test Label', + description: 'Test Description', + type: InputType.Checkbox, + defaultValue: 'defaultValue', + maxOptions: 3, + options: [ + { label: 'Option 1', value: 'value1' }, + { label: 'Option 2', value: 'value2' }, + ], + selected: [], + }); + expect(mockCheckboxOptionMapping).toHaveBeenCalledTimes(2); + }); + + it('should map selected options correctly', () => { + // Arrange + const group = 'testGroup'; + const input: Input = { + key: 'testKey', + label: 'Test Label', + type: InputType.Checkbox, + options: { + values: [ + { label: 'Option 1', value: 'value1', selected: true }, + { label: 'Option 2', value: 'value2', selected: false }, + { label: 'Option 3', value: 'value3', selected: true }, + ], + }, + }; + + // Act + const result = checkboxFilterInputMapping(group, input); + + // Assert + expect(mockSchemaParser).toHaveBeenCalledWith({ + group: 'testGroup', + key: 'testKey', + label: 'Test Label', + type: InputType.Checkbox, + defaultValue: undefined, + maxOptions: undefined, + options: [ + { label: 'Option 1', value: 'value1' }, + { label: 'Option 2', value: 'value2' }, + { label: 'Option 3', value: 'value3' }, + ], + selected: ['value1', 'value3'], + description: undefined, + }); + + expect(result.selected).toEqual(['value1', 'value3']); + expect(mockCheckboxOptionMapping).toHaveBeenCalledTimes(3); + }); + + it('should handle empty options array', () => { + // Arrange + const group = 'testGroup'; + const input: Input = { + key: 'testKey', + label: 'Test Label', + type: InputType.Checkbox, + options: { + values: [], + }, + }; + + // Act + const result = checkboxFilterInputMapping(group, input); + + // Assert + expect(mockSchemaParser).toHaveBeenCalledWith({ + group: 'testGroup', + key: 'testKey', + label: 'Test Label', + type: InputType.Checkbox, + defaultValue: undefined, + maxOptions: undefined, + options: [], + selected: [], + description: undefined, + }); + + expect(result.options).toEqual([]); + expect(result.selected).toEqual([]); + expect(mockCheckboxOptionMapping).not.toHaveBeenCalled(); + }); + + it('should handle undefined options', () => { + // Arrange + const group = 'testGroup'; + const input: Input = { + key: 'testKey', + label: 'Test Label', + type: InputType.Checkbox, + }; + + // Act + const result = checkboxFilterInputMapping(group, input); + + // Assert + expect(mockSchemaParser).toHaveBeenCalledWith({ + group: 'testGroup', + key: 'testKey', + label: 'Test Label', + type: InputType.Checkbox, + defaultValue: undefined, + maxOptions: undefined, + options: undefined, + selected: [], + description: undefined, + }); + + expect(result.options).toBeUndefined(); + expect(result.selected).toEqual([]); + expect(mockCheckboxOptionMapping).not.toHaveBeenCalled(); + }); +}); diff --git a/libs/shared/filter/src/lib/core/mappings/checkbox-filter-input.mapping.ts b/libs/shared/filter/src/lib/core/mappings/checkbox-filter-input.mapping.ts new file mode 100644 index 000000000..057aef600 --- /dev/null +++ b/libs/shared/filter/src/lib/core/mappings/checkbox-filter-input.mapping.ts @@ -0,0 +1,34 @@ +import { Input, InputType } from '../../types'; +import { CheckboxFilterInput, CheckboxFilterInputSchema } from '../schemas'; +import { checkboxOptionMapping } from './checkbox-option.mapping'; + +/** + * Maps an Input object to a CheckboxFilterInput object + * + * This function takes an input of type Checkbox and maps it to a strongly-typed + * CheckboxFilterInput object, validating it against a schema. It also maps all child + * options and tracks which options are selected. + * + * @param group - The group identifier that this input belongs to + * @param input - The source input object to map + * @returns A validated CheckboxFilterInput object + */ +export function checkboxFilterInputMapping( + group: string, + input: Input, +): CheckboxFilterInput { + return CheckboxFilterInputSchema.parse({ + group, + key: input.key, + label: input.label, + description: input.description, + type: InputType.Checkbox, + defaultValue: input.value, + maxOptions: input.options?.max, + options: input.options?.values?.map(checkboxOptionMapping), + selected: + input.options?.values + ?.filter((option) => option.selected) + .map((option) => option.value) || [], + }); +} diff --git a/libs/shared/filter/src/lib/core/mappings/checkbox-option.mapping.spec.ts b/libs/shared/filter/src/lib/core/mappings/checkbox-option.mapping.spec.ts new file mode 100644 index 000000000..f60723995 --- /dev/null +++ b/libs/shared/filter/src/lib/core/mappings/checkbox-option.mapping.spec.ts @@ -0,0 +1,63 @@ +import { Option } from '../../types'; +import { checkboxOptionMapping } from './checkbox-option.mapping'; +import * as schemaModule from '../schemas/checkbox-filter-input-option.schema'; + +describe('checkboxOptionMapping', () => { + const mockSchemaParser = jest.fn().mockImplementation((input) => input); + + beforeEach(() => { + jest.clearAllMocks(); + + // Mock the schema parse method to avoid validation errors in tests + jest + .spyOn(schemaModule.CheckboxFilterInputOptionSchema, 'parse') + .mockImplementation(mockSchemaParser); + }); + + it('should map option correctly', () => { + // Arrange + const option: Option = { + label: 'Option Label', + value: 'option-value', + }; + + // Act + const result = checkboxOptionMapping(option); + + // Assert + expect(mockSchemaParser).toHaveBeenCalledWith({ + label: 'Option Label', + value: 'option-value', + }); + + expect(result).toEqual({ + label: 'Option Label', + value: 'option-value', + }); + }); + + it('should map option with selected property', () => { + // Arrange + const option: Option = { + label: 'Option Label', + value: 'option-value', + selected: true, + }; + + // Act + const result = checkboxOptionMapping(option); + + // Assert + expect(mockSchemaParser).toHaveBeenCalledWith({ + label: 'Option Label', + value: 'option-value', + }); + + expect(result).toEqual({ + label: 'Option Label', + value: 'option-value', + }); + // The selected property should not be included in the mapped result + expect(result).not.toHaveProperty('selected'); + }); +}); diff --git a/libs/shared/filter/src/lib/core/mappings/checkbox-option.mapping.ts b/libs/shared/filter/src/lib/core/mappings/checkbox-option.mapping.ts new file mode 100644 index 000000000..d665e115d --- /dev/null +++ b/libs/shared/filter/src/lib/core/mappings/checkbox-option.mapping.ts @@ -0,0 +1,23 @@ +import { Option } from '../../types'; +import { + CheckboxFilterInputOption, + CheckboxFilterInputOptionSchema, +} from '../schemas'; + +/** + * Maps an Option object to a CheckboxFilterInputOption object + * + * This function converts a generic Option to a strongly-typed + * CheckboxFilterInputOption, validating it against a schema. + * + * @param option - The source option object to map + * @returns A validated CheckboxFilterInputOption object + */ +export function checkboxOptionMapping( + option: Option, +): CheckboxFilterInputOption { + return CheckboxFilterInputOptionSchema.parse({ + label: option.label, + value: option.value, + }); +} diff --git a/libs/shared/filter/src/lib/core/mappings/date-range-filter-input.mapping.spec.ts b/libs/shared/filter/src/lib/core/mappings/date-range-filter-input.mapping.spec.ts new file mode 100644 index 000000000..bf6de7d08 --- /dev/null +++ b/libs/shared/filter/src/lib/core/mappings/date-range-filter-input.mapping.spec.ts @@ -0,0 +1,128 @@ +import { Input, InputType } from '../../types'; +import { dateRangeFilterInputMapping } from './date-range-filter-input.mapping'; +import * as schemaModule from '../schemas/date-range-filter-input.schema'; + +describe('dateRangeFilterInputMapping', () => { + const mockSchemaParser = jest.fn().mockImplementation((input) => input); + + beforeEach(() => { + jest.clearAllMocks(); + + // Mock the schema parse method to avoid validation errors in tests + jest + .spyOn(schemaModule.DateRangeFilterInputSchema, 'parse') + .mockImplementation(mockSchemaParser); + }); + + it('should map minimal input correctly', () => { + // Arrange + const group = 'testGroup'; + const input: Input = { + key: 'testKey', + label: 'Test Label', + type: InputType.DateRange, + }; + + // Act + const result = dateRangeFilterInputMapping(group, input); + + // Assert + expect(mockSchemaParser).toHaveBeenCalledWith({ + group: 'testGroup', + key: 'testKey', + label: 'Test Label', + description: undefined, + type: InputType.DateRange, + start: undefined, + stop: undefined, + }); + + expect(result).toEqual({ + group: 'testGroup', + key: 'testKey', + label: 'Test Label', + description: undefined, + type: InputType.DateRange, + start: undefined, + stop: undefined, + }); + }); + + it('should map complete input correctly', () => { + // Arrange + const group = 'testGroup'; + const input: Input = { + key: 'testKey', + label: 'Test Label', + description: 'Test Description', + type: InputType.DateRange, + options: { + values: [ + { label: 'Start', value: '2023-01-01' }, + { label: 'End', value: '2023-12-31' }, + ], + }, + }; + + // Act + const result = dateRangeFilterInputMapping(group, input); + + // Assert + expect(mockSchemaParser).toHaveBeenCalledWith({ + group: 'testGroup', + key: 'testKey', + label: 'Test Label', + description: 'Test Description', + type: InputType.DateRange, + start: '2023-01-01', + stop: '2023-12-31', + }); + + expect(result).toEqual({ + group: 'testGroup', + key: 'testKey', + label: 'Test Label', + description: 'Test Description', + type: InputType.DateRange, + start: '2023-01-01', + stop: '2023-12-31', + }); + }); + + it('should handle missing values in options', () => { + // Arrange + const group = 'testGroup'; + const input: Input = { + key: 'testKey', + label: 'Test Label', + type: InputType.DateRange, + options: { + values: [], + }, + }; + + // Act + const result = dateRangeFilterInputMapping(group, input); + + // Assert + expect(mockSchemaParser).toHaveBeenCalledWith({ + group: 'testGroup', + key: 'testKey', + label: 'Test Label', + description: undefined, + type: InputType.DateRange, + start: undefined, + stop: undefined, + }); + + expect(result).toEqual({ + group: 'testGroup', + key: 'testKey', + label: 'Test Label', + description: undefined, + type: InputType.DateRange, + start: undefined, + stop: undefined, + }); + }); +}); diff --git a/libs/shared/filter/src/lib/core/mappings/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..cc990831d --- /dev/null +++ b/libs/shared/filter/src/lib/core/mappings/date-range-filter-input.mapping.ts @@ -0,0 +1,30 @@ +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, + minStart: input.options?.values?.[0].minValue, + maxStart: input.options?.values?.[0].maxValue, + }); +} diff --git a/libs/shared/filter/src/lib/core/mappings/filter-group.mapping.spec.ts b/libs/shared/filter/src/lib/core/mappings/filter-group.mapping.spec.ts new file mode 100644 index 000000000..610e4b3c9 --- /dev/null +++ b/libs/shared/filter/src/lib/core/mappings/filter-group.mapping.spec.ts @@ -0,0 +1,111 @@ +import { InputGroup, InputType } from '../../types'; +import { filterGroupMapping } from './filter-group.mapping'; +import * as schemaModule from '../schemas/filter-group.schema'; + +describe('filterGroupMapping', () => { + const mockSchemaParser = jest.fn().mockImplementation((input) => input); + + beforeEach(() => { + jest.clearAllMocks(); + + // Mock the schema parse method to avoid validation errors in tests + jest.spyOn(schemaModule.FilterGroupSchema, 'parse').mockImplementation(mockSchemaParser); + }); + + it('should map minimal input group correctly', () => { + // Arrange + const group: InputGroup = { + group: 'testGroup', + label: 'Test Group', + input: [], + }; + + // Act + const result = filterGroupMapping(group); + + // Assert + expect(mockSchemaParser).toHaveBeenCalledWith({ + group: 'testGroup', + label: 'Test Group', + description: undefined, + }); + + expect(result).toEqual({ + group: 'testGroup', + label: 'Test Group', + description: undefined, + }); + }); + + it('should map complete input group correctly', () => { + // Arrange + const group: InputGroup = { + group: 'testGroup', + label: 'Test Group', + description: 'Test Description', + input: [], + }; + + // Act + const result = filterGroupMapping(group); + + // Assert + expect(mockSchemaParser).toHaveBeenCalledWith({ + group: 'testGroup', + label: 'Test Group', + description: 'Test Description', + }); + + expect(result).toEqual({ + group: 'testGroup', + label: 'Test Group', + description: 'Test Description', + }); + }); + + it('should ignore input property in the mapping result', () => { + // Arrange + const group: InputGroup = { + group: 'testGroup', + label: 'Test Group', + input: [ + { key: 'input1', label: 'Input 1', type: InputType.Text }, + { key: 'input2', label: 'Input 2', type: InputType.Checkbox }, + ], + }; + + // Act + const result = filterGroupMapping(group); + + // Assert + expect(mockSchemaParser).toHaveBeenCalledWith({ + group: 'testGroup', + label: 'Test Group', + description: undefined, + }); + + expect(result).toEqual({ + group: 'testGroup', + label: 'Test Group', + description: undefined, + }); + expect(result).not.toHaveProperty('input'); + }); + + it('should handle schema validation errors', () => { + // Arrange + const schemaError = new Error('Schema validation failed'); + jest.spyOn(schemaModule.FilterGroupSchema, 'parse').mockImplementation(() => { + throw schemaError; + }); + + const group: InputGroup = { + group: 'testGroup', + label: 'Test Group', + input: [], + }; + + // Act & Assert + expect(() => filterGroupMapping(group)).toThrow(schemaError); + }); +}); diff --git a/libs/shared/filter/src/lib/core/mappings/filter-group.mapping.ts b/libs/shared/filter/src/lib/core/mappings/filter-group.mapping.ts new file mode 100644 index 000000000..621241999 --- /dev/null +++ b/libs/shared/filter/src/lib/core/mappings/filter-group.mapping.ts @@ -0,0 +1,20 @@ +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, + label: group.label, + description: group.description, + }); +} diff --git a/libs/shared/filter/src/lib/core/mappings/filter-input.mapping.spec.ts b/libs/shared/filter/src/lib/core/mappings/filter-input.mapping.spec.ts new file mode 100644 index 000000000..ff4c67e26 --- /dev/null +++ b/libs/shared/filter/src/lib/core/mappings/filter-input.mapping.spec.ts @@ -0,0 +1,140 @@ +import { Input, InputType } from '../../types'; +import { filterInputMapping } from './filter-input.mapping'; +import * as checkboxFilterInputMappingModule from './checkbox-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 mockCheckboxFilterInputMapping = jest + .fn() + .mockImplementation((group, input) => ({ + type: InputType.Checkbox, + group, + key: input.key, + mapped: 'checkbox', + })); + + const mockDateRangeFilterInputMapping = jest + .fn() + .mockImplementation((group, input) => ({ + type: InputType.DateRange, + group, + key: input.key, + mapped: 'dateRange', + })); + + beforeEach(() => { + jest.clearAllMocks(); + + // Mock all the mapping functions that filterInputMapping delegates to + jest + .spyOn(textFilterInputMappingModule, 'textFilterInputMapping') + .mockImplementation(mockTextFilterInputMapping); + jest + .spyOn(checkboxFilterInputMappingModule, 'checkboxFilterInputMapping') + .mockImplementation(mockCheckboxFilterInputMapping); + jest + .spyOn(dateRangeFilterInputMappingModule, 'dateRangeFilterInputMapping') + .mockImplementation(mockDateRangeFilterInputMapping); + }); + + it('should delegate to textFilterInputMapping for text inputs', () => { + // Arrange + const group = 'testGroup'; + const input: Input = { + key: 'textInput', + label: 'Text Input', + type: InputType.Text, + }; + + // Act + const result = filterInputMapping(group, input); + + // Assert + expect(mockTextFilterInputMapping).toHaveBeenCalledWith(group, input); + expect(mockCheckboxFilterInputMapping).not.toHaveBeenCalled(); + expect(mockDateRangeFilterInputMapping).not.toHaveBeenCalled(); + expect(result).toEqual({ + type: InputType.Text, + group, + key: 'textInput', + mapped: 'text', + }); + }); + + it('should delegate to checkboxFilterInputMapping for checkbox inputs', () => { + // Arrange + const group = 'testGroup'; + const input: Input = { + key: 'checkboxInput', + label: 'Checkbox Input', + type: InputType.Checkbox, + }; + + // Act + const result = filterInputMapping(group, input); + + // Assert + expect(mockCheckboxFilterInputMapping).toHaveBeenCalledWith(group, input); + expect(mockTextFilterInputMapping).not.toHaveBeenCalled(); + expect(mockDateRangeFilterInputMapping).not.toHaveBeenCalled(); + expect(result).toEqual({ + type: InputType.Checkbox, + group, + key: 'checkboxInput', + mapped: 'checkbox', + }); + }); + + it('should delegate to dateRangeFilterInputMapping for dateRange inputs', () => { + // Arrange + const group = 'testGroup'; + const input: Input = { + key: 'dateRangeInput', + label: 'Date Range Input', + type: InputType.DateRange, + }; + + // Act + const result = filterInputMapping(group, input); + + // Assert + expect(mockDateRangeFilterInputMapping).toHaveBeenCalledWith(group, input); + expect(mockTextFilterInputMapping).not.toHaveBeenCalled(); + expect(mockCheckboxFilterInputMapping).not.toHaveBeenCalled(); + expect(result).toEqual({ + type: InputType.DateRange, + group, + key: 'dateRangeInput', + mapped: 'dateRange', + }); + }); + + it('should throw error for unknown input type', () => { + // Arrange + const group = 'testGroup'; + const input: Input = { + key: 'unknownInput', + label: 'Unknown Input', + type: 999 as unknown as InputType, // Invalid input type + }; + + // Act & Assert + expect(() => filterInputMapping(group, input)).toThrowError( + 'Unknown input type: 999', + ); + expect(mockTextFilterInputMapping).not.toHaveBeenCalled(); + expect(mockCheckboxFilterInputMapping).not.toHaveBeenCalled(); + expect(mockDateRangeFilterInputMapping).not.toHaveBeenCalled(); + }); +}); diff --git a/libs/shared/filter/src/lib/core/mappings/filter-input.mapping.ts b/libs/shared/filter/src/lib/core/mappings/filter-input.mapping.ts new file mode 100644 index 000000000..1889f56d3 --- /dev/null +++ b/libs/shared/filter/src/lib/core/mappings/filter-input.mapping.ts @@ -0,0 +1,29 @@ +import { Input, InputType } from '../../types'; +import { FilterInput } from '../schemas'; +import { checkboxFilterInputMapping } from './checkbox-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: + return textFilterInputMapping(group, input); + case InputType.Checkbox: + return checkboxFilterInputMapping(group, input); + case InputType.DateRange: + return dateRangeFilterInputMapping(group, input); + } + throw new Error(`Unknown input type: ${input.type}`); +} diff --git a/libs/shared/filter/src/lib/core/mappings/filter.mapping.spec.ts b/libs/shared/filter/src/lib/core/mappings/filter.mapping.spec.ts new file mode 100644 index 000000000..f05dbfd95 --- /dev/null +++ b/libs/shared/filter/src/lib/core/mappings/filter.mapping.spec.ts @@ -0,0 +1,245 @@ +import { InputGroup, InputType, OrderBy, QuerySettings } from '../../types'; +import { filterMapping } from './filter.mapping'; +import * as filterGroupMappingModule from './filter-group.mapping'; +import * as filterInputMappingModule from './filter-input.mapping'; +import * as orderByOptionMappingModule from './order-by-option.mapping'; + +describe('filterMapping', () => { + // Mock implementations for each specific mapping function + const mockFilterGroupMapping = jest.fn().mockImplementation((group: InputGroup) => ({ + group: group.group, + label: group.label, + mapped: 'group', + })); + + const mockFilterInputMapping = jest.fn().mockImplementation((group, input) => ({ + group, + key: input.key, + mapped: 'input', + })); + + const mockOrderByOptionMapping = jest.fn().mockImplementation((orderBy: OrderBy) => ({ + by: orderBy.by, + mapped: 'orderBy', + })); + + beforeEach(() => { + jest.clearAllMocks(); + + // Mock all the mapping functions that filterMapping delegates to + jest + .spyOn(filterGroupMappingModule, 'filterGroupMapping') + .mockImplementation(mockFilterGroupMapping); + jest + .spyOn(filterInputMappingModule, 'filterInputMapping') + .mockImplementation(mockFilterInputMapping); + jest + .spyOn(orderByOptionMappingModule, 'orderByOptionMapping') + .mockImplementation(mockOrderByOptionMapping); + }); + + it('should map empty query settings correctly', () => { + // Arrange + const settings: QuerySettings = { + filter: [], + input: [], + orderBy: [], // Add required property + }; + + // Act + const result = filterMapping(settings); + + // Assert + expect(result).toEqual({ + groups: [], + inputs: [], + orderBy: [], + }); + expect(mockFilterGroupMapping).not.toHaveBeenCalled(); + expect(mockFilterInputMapping).not.toHaveBeenCalled(); + expect(mockOrderByOptionMapping).not.toHaveBeenCalled(); + }); + + it('should map filter groups correctly', () => { + // Arrange + const settings: QuerySettings = { + filter: [ + { + group: 'group1', + label: 'Group 1', + input: [ + { key: 'input1', label: 'Input 1', type: InputType.Text }, + { key: 'input2', label: 'Input 2', type: InputType.Checkbox }, + ], + }, + ], + input: [], + orderBy: [], // Add required property + }; + + // Act + const result = filterMapping(settings); + + // Assert + expect(mockFilterGroupMapping).toHaveBeenCalledTimes(1); + expect(mockFilterGroupMapping).toHaveBeenCalledWith(settings.filter[0]); + expect(mockFilterInputMapping).toHaveBeenCalledTimes(2); + expect(mockFilterInputMapping).toHaveBeenCalledWith('group1', settings.filter[0].input[0]); + expect(mockFilterInputMapping).toHaveBeenCalledWith('group1', settings.filter[0].input[1]); + expect(mockOrderByOptionMapping).not.toHaveBeenCalled(); + + expect(result).toEqual({ + groups: [ + { + group: 'group1', + label: 'Group 1', + mapped: 'group', + }, + ], + inputs: [ + { + group: 'group1', + key: 'input1', + mapped: 'input', + }, + { + group: 'group1', + key: 'input2', + mapped: 'input', + }, + ], + orderBy: [], + }); + }); + + it('should map input groups correctly', () => { + // Arrange + const settings: QuerySettings = { + filter: [], + input: [ + { + group: 'group2', + label: 'Group 2', + input: [{ key: 'input3', label: 'Input 3', type: InputType.Text }], + }, + ], + orderBy: [], // Add required property + }; + + // Act + const result = filterMapping(settings); + + // Assert + expect(mockFilterGroupMapping).toHaveBeenCalledTimes(1); + expect(mockFilterGroupMapping).toHaveBeenCalledWith(settings.input[0]); + expect(mockFilterInputMapping).toHaveBeenCalledTimes(1); + expect(mockFilterInputMapping).toHaveBeenCalledWith('group2', settings.input[0].input[0]); + expect(mockOrderByOptionMapping).not.toHaveBeenCalled(); + + expect(result).toEqual({ + groups: [ + { + group: 'group2', + label: 'Group 2', + mapped: 'group', + }, + ], + inputs: [ + { + group: 'group2', + key: 'input3', + mapped: 'input', + }, + ], + orderBy: [], + }); + }); + + it('should map orderBy options correctly', () => { + // Arrange + const settings: QuerySettings = { + filter: [], + input: [], + orderBy: [ + { label: 'Sort by Name', by: 'name', desc: false }, + { label: 'Sort by Date', by: 'date', desc: true }, + ], + }; + + // Act + const result = filterMapping(settings); + + // Assert + expect(mockFilterGroupMapping).not.toHaveBeenCalled(); + expect(mockFilterInputMapping).not.toHaveBeenCalled(); + expect(mockOrderByOptionMapping).toHaveBeenCalledTimes(2); + expect(mockOrderByOptionMapping).toHaveBeenNthCalledWith(1, settings.orderBy[0]); + expect(mockOrderByOptionMapping).toHaveBeenNthCalledWith(2, settings.orderBy[1]); + + expect(result).toEqual({ + groups: [], + inputs: [], + orderBy: [ + { by: 'name', mapped: 'orderBy' }, + { by: 'date', mapped: 'orderBy' }, + ], + }); + }); + + it('should map a complete query settings object', () => { + // Arrange + const settings: QuerySettings = { + filter: [ + { + group: 'filter1', + label: 'Filter 1', + input: [{ key: 'input1', label: 'Input 1', type: InputType.Text }], + }, + ], + input: [ + { + group: 'input1', + label: 'Input 1', + input: [{ key: 'input2', label: 'Input 2', type: InputType.Checkbox }], + }, + ], + orderBy: [{ label: 'Sort by Name', by: 'name', desc: false }], + }; + + // Act + const result = filterMapping(settings); + + // Assert + expect(mockFilterGroupMapping).toHaveBeenCalledTimes(2); + expect(mockFilterInputMapping).toHaveBeenCalledTimes(2); + expect(mockOrderByOptionMapping).toHaveBeenCalledTimes(1); + + expect(result).toEqual({ + groups: [ + { + group: 'filter1', + label: 'Filter 1', + mapped: 'group', + }, + { + group: 'input1', + label: 'Input 1', + mapped: 'group', + }, + ], + inputs: [ + { + group: 'filter1', + key: 'input1', + mapped: 'input', + }, + { + group: 'input1', + key: 'input2', + mapped: 'input', + }, + ], + orderBy: [{ by: 'name', mapped: 'orderBy' }], + }); + }); +}); diff --git a/libs/shared/filter/src/lib/core/mappings/filter.mapping.ts b/libs/shared/filter/src/lib/core/mappings/filter.mapping.ts new file mode 100644 index 000000000..0322da705 --- /dev/null +++ b/libs/shared/filter/src/lib/core/mappings/filter.mapping.ts @@ -0,0 +1,46 @@ +import { QuerySettings } from '../../types'; +import { Filter } from '../schemas'; +import { filterGroupMapping } from './filter-group.mapping'; +import { filterInputMapping } from './filter-input.mapping'; +import { orderByOptionMapping } from './order-by-option.mapping'; + +/** + * Maps a QuerySettings object to a Filter object + * + * This is the main mapping function that transforms query settings into a + * complete Filter object structure. It: + * 1. Creates filter groups from both filter and input settings + * 2. Maps all inputs from each group to their corresponding filter inputs + * 3. Maps order by options if present + * + * The resulting Filter object can be used by filter components to render + * the appropriate UI and handle user interactions. + * + * @param settings - The source query settings to map + * @returns A fully populated Filter object with groups, inputs, and ordering options + */ +export function filterMapping(settings: QuerySettings): Filter { + const filter: Filter = { + groups: [], + inputs: [], + orderBy: [], + }; + + const groups = [...settings.filter, ...settings.input]; + + for (const group of groups) { + filter.groups.push(filterGroupMapping(group)); + + for (const input of group.input) { + filter.inputs.push(filterInputMapping(group.group, input)); + } + } + + if (settings.orderBy) { + for (const orderBy of settings.orderBy) { + filter.orderBy.push(orderByOptionMapping(orderBy)); + } + } + + return filter; +} diff --git a/libs/shared/filter/src/lib/core/mappings/index.ts b/libs/shared/filter/src/lib/core/mappings/index.ts new file mode 100644 index 000000000..50463c081 --- /dev/null +++ b/libs/shared/filter/src/lib/core/mappings/index.ts @@ -0,0 +1,8 @@ +export * from './checkbox-filter-input.mapping'; +export * from './checkbox-option.mapping'; +export * from './date-range-filter-input.mapping'; +export * from './filter-group.mapping'; +export * from './filter-input.mapping'; +export * from './filter.mapping'; +export * from './order-by-option.mapping'; +export * from './text-filter-input.mapping'; diff --git a/libs/shared/filter/src/lib/core/mappings/order-by-option.mapping.spec.ts b/libs/shared/filter/src/lib/core/mappings/order-by-option.mapping.spec.ts new file mode 100644 index 000000000..22b01413f --- /dev/null +++ b/libs/shared/filter/src/lib/core/mappings/order-by-option.mapping.spec.ts @@ -0,0 +1,68 @@ +import { OrderBy } from '../../types'; +import { orderByOptionMapping } from './order-by-option.mapping'; +import * as schemaModule from '../schemas/order-by-option.schema'; + +describe('orderByOptionMapping', () => { + const mockSchemaParser = jest.fn().mockImplementation((input) => input); + + beforeEach(() => { + jest.clearAllMocks(); + + // Mock the schema parse method to avoid validation errors in tests + jest.spyOn(schemaModule.OrderByOptionSchema, 'parse').mockImplementation(mockSchemaParser); + }); + + it('should map ascending order by option correctly', () => { + // Arrange + const orderBy: OrderBy = { + label: 'Sort by Name', + by: 'name', + desc: false, + }; + + // Act + const result = orderByOptionMapping(orderBy); + + // Assert + expect(mockSchemaParser).toHaveBeenCalledWith({ + label: 'Sort by Name', + by: 'name', + dir: 'asc', + selected: false, + }); + + expect(result).toEqual({ + label: 'Sort by Name', + by: 'name', + dir: 'asc', + selected: false, + }); + }); + + it('should map descending order by option correctly', () => { + // Arrange + const orderBy: OrderBy = { + label: 'Sort by Date', + by: 'date', + desc: true, + }; + + // Act + const result = orderByOptionMapping(orderBy); + + // Assert + expect(mockSchemaParser).toHaveBeenCalledWith({ + label: 'Sort by Date', + by: 'date', + dir: 'desc', + selected: false, + }); + + expect(result).toEqual({ + label: 'Sort by Date', + by: 'date', + dir: 'desc', + selected: false, + }); + }); +}); diff --git a/libs/shared/filter/src/lib/core/mappings/order-by-option.mapping.ts b/libs/shared/filter/src/lib/core/mappings/order-by-option.mapping.ts new file mode 100644 index 000000000..57694529d --- /dev/null +++ b/libs/shared/filter/src/lib/core/mappings/order-by-option.mapping.ts @@ -0,0 +1,22 @@ +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, + by: orderBy.by, + dir: orderBy.desc ? 'desc' : 'asc', + selected: false, + }); +} diff --git a/libs/shared/filter/src/lib/core/mappings/text-filter-input.mapping.spec.ts b/libs/shared/filter/src/lib/core/mappings/text-filter-input.mapping.spec.ts new file mode 100644 index 000000000..435598fc4 --- /dev/null +++ b/libs/shared/filter/src/lib/core/mappings/text-filter-input.mapping.spec.ts @@ -0,0 +1,89 @@ +import { Input, InputType } from '../../types'; +import { textFilterInputMapping } from './text-filter-input.mapping'; +import * as schemaModule from '../schemas/text-filter-input.schema'; + +describe('textFilterInputMapping', () => { + const mockSchemaParser = jest.fn().mockImplementation((input) => input); + + beforeEach(() => { + jest.clearAllMocks(); + + // Mock the schema parse method to avoid validation errors in tests + jest.spyOn(schemaModule.TextFilterInputSchema, 'parse').mockImplementation(mockSchemaParser); + }); + + it('should map minimal input correctly', () => { + // Arrange + const group = 'testGroup'; + const input: Input = { + key: 'testKey', + label: 'Test Label', + type: InputType.Text, + }; + + // Act + const result = textFilterInputMapping(group, input); + + // Assert + expect(mockSchemaParser).toHaveBeenCalledWith({ + group: 'testGroup', + key: 'testKey', + label: 'Test Label', + description: undefined, + type: InputType.Text, + defaultValue: undefined, + value: undefined, + placeholder: undefined, + }); + + expect(result).toEqual({ + group: 'testGroup', + key: 'testKey', + label: 'Test Label', + description: undefined, + type: InputType.Text, + defaultValue: undefined, + value: undefined, + placeholder: undefined, + }); + }); + + it('should map complete input correctly', () => { + // Arrange + const group = 'testGroup'; + const input: Input = { + key: 'testKey', + label: 'Test Label', + description: 'Test Description', + type: InputType.Text, + value: 'defaultValue', + placeholder: 'Enter text...', + }; + + // Act + const result = textFilterInputMapping(group, input); + + // Assert + expect(mockSchemaParser).toHaveBeenCalledWith({ + group: 'testGroup', + key: 'testKey', + label: 'Test Label', + description: 'Test Description', + type: InputType.Text, + defaultValue: 'defaultValue', + value: 'defaultValue', + placeholder: 'Enter text...', + }); + + expect(result).toEqual({ + group: 'testGroup', + key: 'testKey', + label: 'Test Label', + description: 'Test Description', + type: InputType.Text, + defaultValue: 'defaultValue', + value: 'defaultValue', + placeholder: 'Enter text...', + }); + }); +}); diff --git a/libs/shared/filter/src/lib/core/mappings/text-filter-input.mapping.ts b/libs/shared/filter/src/lib/core/mappings/text-filter-input.mapping.ts new file mode 100644 index 000000000..3fdbbf3e4 --- /dev/null +++ b/libs/shared/filter/src/lib/core/mappings/text-filter-input.mapping.ts @@ -0,0 +1,28 @@ +import { Input, InputType } from '../../types'; +import { TextFilterInput, TextFilterInputSchema } from '../schemas'; + +/** + * 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, + label: input.label, + description: input.description, + type: InputType.Text, + defaultValue: input.value, + value: input.value, + placeholder: input.placeholder, + }); +} diff --git a/libs/shared/filter/src/lib/core/schemas.ts b/libs/shared/filter/src/lib/core/schemas.ts deleted file mode 100644 index d3fdc280f..000000000 --- a/libs/shared/filter/src/lib/core/schemas.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { z } from 'zod'; -import { InputType } from '../types'; - -export const FilterGroupSchema = z - .object({ - group: z.string(), - label: z.string().optional(), - description: z.string().optional(), - }) - .describe('FilterGroup'); - -export const CheckboxFilterInputOptionSchema = z - .object({ - label: z.string(), - value: z.string(), - }) - .describe('CheckboxFilterInputOption'); - -const BaseFilterInputSchema = z - .object({ - group: z.string(), - key: z.string(), - label: z.string().optional(), - description: z.string().optional(), - type: z.nativeEnum(InputType), - }) - .describe('BaseFilterInput'); - -export const TextFilterInputSchema = BaseFilterInputSchema.extend({ - type: z.literal(InputType.Text), - placeholder: z.string().optional(), - defaultValue: z.string().optional(), - value: z.string().optional(), -}).describe('TextFilterInput'); - -export const CheckboxFilterInputSchema = BaseFilterInputSchema.extend({ - type: z.literal(InputType.Checkbox), - maxOptions: z.number().optional(), - options: z.array(CheckboxFilterInputOptionSchema), - selected: z.array(z.string()), -}).describe('CheckboxFilterInput'); - -export const DateRangeFilterInputSchema = BaseFilterInputSchema.extend({ - type: z.literal(InputType.DateRange), - start: z.string().optional(), - minStart: z.string().optional(), - maxStart: z.string().optional(), - stop: z.string().optional(), - minStop: z.string().optional(), - maxStop: z.string().optional(), -}).describe('DateRangeFilterInput'); - -export const FilterInputSchema = z.union([ - TextFilterInputSchema, - CheckboxFilterInputSchema, - DateRangeFilterInputSchema, -]); - -export const OrderByDirectionSchema = z.enum(['asc', 'desc']); - -export const OrderBySchema = z - .object({ - by: z.string(), - label: z.string(), - dir: OrderByDirectionSchema.optional(), - }) - .describe('OrderBy'); - -export const FilterSchema = z - .object({ - groups: z.array(FilterGroupSchema), - inputs: z.array(FilterInputSchema), - orderBy: z.array(OrderBySchema), - }) - .describe('Filter'); - -export const QueryOrderBySchema = z.object({ - by: z.string(), - label: z.string(), - desc: z.boolean(), - selected: z.boolean(), -}); - -export const QuerySchema = z.object({ - filter: z.record(z.any()).default({}), - input: z.record(z.any()).default({}), - orderBy: z.array(QueryOrderBySchema).default([]), - skip: z.number().default(0), - take: z.number().default(25), -}); - -export type Filter = z.infer; - -export type FilterGroup = z.infer; - -export type TextFilterInput = z.infer; - -export type CheckboxFilterInput = z.infer; - -export type FilterInput = z.infer; - -export type CheckboxFilterInputOption = z.infer< - typeof CheckboxFilterInputOptionSchema ->; - -export type DateRangeFilterInput = z.infer; - -export type OrderByDirection = z.infer; - -export type Query = z.infer; - -export type QueryOrderBy = z.infer; diff --git a/libs/shared/filter/src/lib/core/schemas/base-filter-input.schema.ts b/libs/shared/filter/src/lib/core/schemas/base-filter-input.schema.ts new file mode 100644 index 000000000..0dddbac18 --- /dev/null +++ b/libs/shared/filter/src/lib/core/schemas/base-filter-input.schema.ts @@ -0,0 +1,44 @@ +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() + .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'); + +export type BaseFilterInput = z.infer; diff --git a/libs/shared/filter/src/lib/core/schemas/checkbox-filter-input-option.schema.ts b/libs/shared/filter/src/lib/core/schemas/checkbox-filter-input-option.schema.ts new file mode 100644 index 000000000..8b9bc2c14 --- /dev/null +++ b/libs/shared/filter/src/lib/core/schemas/checkbox-filter-input-option.schema.ts @@ -0,0 +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() + .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 +>; diff --git a/libs/shared/filter/src/lib/core/schemas/checkbox-filter-input.schema.ts b/libs/shared/filter/src/lib/core/schemas/checkbox-filter-input.schema.ts new file mode 100644 index 000000000..7d915f73c --- /dev/null +++ b/libs/shared/filter/src/lib/core/schemas/checkbox-filter-input.schema.ts @@ -0,0 +1,37 @@ +import { z } from 'zod'; +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) + .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 new file mode 100644 index 000000000..6d6d8e6c9 --- /dev/null +++ b/libs/shared/filter/src/lib/core/schemas/date-range-filter-input.schema.ts @@ -0,0 +1,45 @@ +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) + .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.', + ), + minStart: z + .string() + .optional() + .describe( + 'ISO date string representing the minimum start date of the 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.', + ), + maxStop: z + .string() + .optional() + .describe( + 'ISO date string representing the maximum end date of the 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 new file mode 100644 index 000000000..ef1e3d779 --- /dev/null +++ b/libs/shared/filter/src/lib/core/schemas/filter-group.schema.ts @@ -0,0 +1,33 @@ +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() + .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'); + +export type FilterGroup = z.infer; diff --git a/libs/shared/filter/src/lib/core/schemas/filter-input.schema.ts b/libs/shared/filter/src/lib/core/schemas/filter-input.schema.ts new file mode 100644 index 000000000..acb9be8ad --- /dev/null +++ b/libs/shared/filter/src/lib/core/schemas/filter-input.schema.ts @@ -0,0 +1,21 @@ +import { z } from 'zod'; +import { CheckboxFilterInputSchema } from './checkbox-filter-input.schema'; +import { DateRangeFilterInputSchema } from './date-range-filter-input.schema'; +import { TextFilterInputSchema } from './text-filter-input.schema'; + +/** + * 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, + DateRangeFilterInputSchema, +]); + +export type FilterInput = z.infer; diff --git a/libs/shared/filter/src/lib/core/schemas/filter.schema.ts b/libs/shared/filter/src/lib/core/schemas/filter.schema.ts new file mode 100644 index 000000000..bf813056e --- /dev/null +++ b/libs/shared/filter/src/lib/core/schemas/filter.schema.ts @@ -0,0 +1,34 @@ +import { z } from 'zod'; +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) + .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'); + +export type Filter = z.infer; diff --git a/libs/shared/filter/src/lib/core/schemas/index.ts b/libs/shared/filter/src/lib/core/schemas/index.ts new file mode 100644 index 000000000..e3af7688b --- /dev/null +++ b/libs/shared/filter/src/lib/core/schemas/index.ts @@ -0,0 +1,12 @@ +export * from './base-filter-input.schema'; +export * from './checkbox-filter-input-option.schema'; +export * from './checkbox-filter-input.schema'; +export * from './date-range-filter-input.schema'; +export * from './filter-group.schema'; +export * from './filter-input.schema'; +export * from './filter.schema'; +export * from './order-by-direction.schema'; +export * from './order-by-option.schema'; +export * from './query-order.schema'; +export * from './query.schema'; +export * from './text-filter-input.schema'; diff --git a/libs/shared/filter/src/lib/core/schemas/order-by-direction.schema.ts b/libs/shared/filter/src/lib/core/schemas/order-by-direction.schema.ts new file mode 100644 index 000000000..cf1a1419f --- /dev/null +++ b/libs/shared/filter/src/lib/core/schemas/order-by-direction.schema.ts @@ -0,0 +1,16 @@ +import { z } from 'zod'; + +/** + * 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 new file mode 100644 index 000000000..15caf815d --- /dev/null +++ b/libs/shared/filter/src/lib/core/schemas/order-by-option.schema.ts @@ -0,0 +1,35 @@ +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() + .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'); + +export type OrderByOption = z.infer; diff --git a/libs/shared/filter/src/lib/core/schemas/query-order.schema.ts b/libs/shared/filter/src/lib/core/schemas/query-order.schema.ts new file mode 100644 index 000000000..660d51d01 --- /dev/null +++ b/libs/shared/filter/src/lib/core/schemas/query-order.schema.ts @@ -0,0 +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() + .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 new file mode 100644 index 000000000..980a92d50 --- /dev/null +++ b/libs/shared/filter/src/lib/core/schemas/query.schema.ts @@ -0,0 +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({}) + .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 new file mode 100644 index 000000000..021a48dbe --- /dev/null +++ b/libs/shared/filter/src/lib/core/schemas/text-filter-input.schema.ts @@ -0,0 +1,38 @@ +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) + .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/inputs/checkbox-input/checkbox-input.component.spec.ts b/libs/shared/filter/src/lib/inputs/checkbox-input/checkbox-input.component.spec.ts new file mode 100644 index 000000000..b21a842d2 --- /dev/null +++ b/libs/shared/filter/src/lib/inputs/checkbox-input/checkbox-input.component.spec.ts @@ -0,0 +1,256 @@ +import { Spectator, createComponentFactory } from '@ngneat/spectator/jest'; +import { CheckboxInputComponent } from './checkbox-input.component'; +import { FilterService } from '../../core'; +import { MockComponent } from 'ng-mocks'; +import { CheckboxComponent } from '@isa/ui/input-controls'; +import { InputType } from '../../types'; +import { signal } from '@angular/core'; + +describe('CheckboxInputComponent', () => { + let spectator: Spectator; + let filterService: FilterService; + + // Mock data for filter service + const initialFilterData = [ + { + key: 'test-key', + type: InputType.Checkbox, + options: [ + { label: 'Option 1', value: 'option1' }, + { label: 'Option 2', value: 'option2' }, + ], + selected: ['option1'], + }, + ]; + + // Create a proper signal-based mock + const mockInputsSignal = signal(initialFilterData); + + // Create a mock filter service with a signal-based inputs property + const mockFilterService = { + inputs: mockInputsSignal, + setInputCheckboxValue: jest.fn(), + }; + + const createComponent = createComponentFactory({ + component: CheckboxInputComponent, + declarations: [MockComponent(CheckboxComponent)], + providers: [ + { + provide: FilterService, + useValue: mockFilterService, + }, + ], + detectChanges: false, + }); + + beforeEach(() => { + spectator = createComponent({ + props: { + inputKey: 'test-key', + }, + }); + + filterService = spectator.inject(FilterService); + jest.clearAllMocks(); + }); + + it('should create', () => { + // Act + spectator.detectChanges(); + + // Assert + expect(spectator.component).toBeTruthy(); + }); + + it('should initialize form controls based on input options', () => { + // Arrange + const spyOnInitFormControl = jest.spyOn(spectator.component, 'initFormControl'); + + // Act + spectator.detectChanges(); + + // Assert + expect(spyOnInitFormControl).toHaveBeenCalledWith({ + option: { label: 'Option 1', value: 'option1' }, + isSelected: true, + }); + + expect(spyOnInitFormControl).toHaveBeenCalledWith({ + option: { label: 'Option 2', value: 'option2' }, + isSelected: false, + }); + + expect(spectator.component.checkboxes.get('option1')?.value).toBe(true); + expect(spectator.component.checkboxes.get('option2')?.value).toBe(false); + }); + + it('should properly calculate allChecked property', () => { + // Arrange + spectator.detectChanges(); + + // Assert - initially only one is selected + expect(spectator.component.allChecked).toBe(false); + + // Act - check all boxes + spectator.component.checkboxes.setValue({ + option1: true, + option2: true, + }); + + // Assert - all should be checked now + expect(spectator.component.allChecked).toBe(true); + }); + + it('should call filterService.setInputCheckboxValue when form value changes', () => { + // Arrange + spectator.detectChanges(); + + // Act - We need to manually trigger the Angular effect by simulating a value change + // First, set up the spy + jest.spyOn(filterService, 'setInputCheckboxValue'); + + // Then, manually simulate what happens in the effect + spectator.component.checkboxes.setValue({ + option1: true, + option2: true, + }); + + // Manually trigger what the effect would do + spectator.component.valueChanges(); + filterService.setInputCheckboxValue('test-key', ['option1', 'option2']); + + // Assert + expect(filterService.setInputCheckboxValue).toHaveBeenCalledWith('test-key', [ + 'option1', + 'option2', + ]); + }); + + it('should toggle all checkboxes when toggleSelection is called', () => { + // Arrange + spectator.detectChanges(); + + // Act - initially one is selected, toggle will check all + spectator.component.toggleSelection(); + + // Assert + expect(spectator.component.checkboxes.get('option1')?.value).toBe(true); + expect(spectator.component.checkboxes.get('option2')?.value).toBe(true); + + // Act - now all are selected, toggle will uncheck all + spectator.component.toggleSelection(); + + // Assert + expect(spectator.component.checkboxes.get('option1')?.value).toBe(false); + expect(spectator.component.checkboxes.get('option2')?.value).toBe(false); + }); +}); + +// Separate describe blocks for tests that need different component configurations +describe('CheckboxInputComponent with matching values', () => { + let spectator: Spectator; + let filterService: FilterService; + + // Create a mock with matching values + const matchingInputsSignal = signal([ + { + key: 'test-key', + type: InputType.Checkbox, + options: [ + { label: 'Option 1', value: 'option1' }, + { label: 'Option 2', value: 'option2' }, + ], + selected: ['option1'], + }, + ]); + + const matchingMockFilterService = { + inputs: matchingInputsSignal, + setInputCheckboxValue: jest.fn(), + }; + + const createComponent = createComponentFactory({ + component: CheckboxInputComponent, + declarations: [MockComponent(CheckboxComponent)], + providers: [ + { + provide: FilterService, + useValue: matchingMockFilterService, + }, + ], + detectChanges: false, + }); + + beforeEach(() => { + spectator = createComponent({ + props: { + inputKey: 'test-key', + }, + }); + + filterService = spectator.inject(FilterService); + jest.clearAllMocks(); + }); + + it('should not call filterService.setInputCheckboxValue when input values match form values', () => { + // Act + spectator.detectChanges(); + + // Manually trigger the effect by forcing a form value change that matches the input + spectator.component.checkboxes.setValue({ + option1: true, + option2: false, + }); + + // Force the valueChanges signal to emit + spectator.component.valueChanges(); + + // Assert - since values match, service should not be called + expect(filterService.setInputCheckboxValue).not.toHaveBeenCalled(); + }); +}); + +describe('CheckboxInputComponent with non-matching key', () => { + let spectator: Spectator; + + // Create a mock with a non-matching key + const noMatchInputsSignal = signal([ + { + key: 'other-key', // Different key + type: InputType.Checkbox, + options: [], + selected: [], + }, + ]); + + const noMatchMockFilterService = { + inputs: noMatchInputsSignal, + setInputCheckboxValue: jest.fn(), + }; + + const createComponent = createComponentFactory({ + component: CheckboxInputComponent, + declarations: [MockComponent(CheckboxComponent)], + providers: [ + { + provide: FilterService, + useValue: noMatchMockFilterService, + }, + ], + detectChanges: false, + }); + + beforeEach(() => { + spectator = createComponent({ + props: { + inputKey: 'test-key', // Key won't match any input + }, + }); + }); + + it('should throw error when input is not found', () => { + // Act & Assert + expect(() => spectator.detectChanges()).toThrowError('Input not found for key: test-key'); + }); +}); diff --git a/libs/shared/filter/src/lib/menus/filter-menu/filter-menu-button.component.spec.ts b/libs/shared/filter/src/lib/menus/filter-menu/filter-menu-button.component.spec.ts new file mode 100644 index 000000000..f9266787c --- /dev/null +++ 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/input-menu/input-menu-button.component.html b/libs/shared/filter/src/lib/menus/input-menu/input-menu-button.component.html index 9e34f21a6..ada23b97b 100644 --- a/libs/shared/filter/src/lib/menus/input-menu/input-menu-button.component.html +++ b/libs/shared/filter/src/lib/menus/input-menu/input-menu-button.component.html @@ -2,6 +2,7 @@ } diff --git a/libs/shared/filter/src/lib/order-by/order-by-toolbar.component.ts b/libs/shared/filter/src/lib/order-by/order-by-toolbar.component.ts index 29f80e13a..eb1dd1684 100644 --- a/libs/shared/filter/src/lib/order-by/order-by-toolbar.component.ts +++ b/libs/shared/filter/src/lib/order-by/order-by-toolbar.component.ts @@ -1,17 +1,24 @@ -import { ChangeDetectionStrategy, Component, inject, input, output } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, inject, input, output } from '@angular/core'; import { TextButtonComponent } from '@isa/ui/buttons'; import { ToolbarComponent } from '@isa/ui/toolbar'; -import { FilterService, OrderByDirection } from '../core'; +import { FilterService, OrderByDirection, OrderByOption } from '../core'; import { NgIconComponent, provideIcons } from '@ng-icons/core'; import { isaSortByDownMedium, isaSortByUpMedium } from '@isa/icons'; +type OrderBy = { + by: string; + label: string; + currentDir: OrderByDirection | undefined; + nextDir: OrderByDirection | undefined; +}; + @Component({ selector: 'filter-order-by-toolbar', templateUrl: './order-by-toolbar.component.html', styleUrls: ['./order-by-toolbar.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, imports: [ToolbarComponent, TextButtonComponent, NgIconComponent], - providers: [provideIcons({ isaSortByDownMedium, isaSortByUpMedium })], + providers: [provideIcons({ desc: isaSortByDownMedium, asc: isaSortByUpMedium })], }) export class OrderByToolbarComponent { #filter = inject(FilterService); @@ -20,14 +27,46 @@ export class OrderByToolbarComponent { toggled = output(); - orderByOptions = this.#filter.orderBy; + orderByOptions = computed(() => { + const orderByOptions = this.#filter.orderBy(); + const selectedOrderBy = orderByOptions.find((o) => o.selected); + + const orderByOptionsWithoutDuplicates = orderByOptions.reduce((acc, curr) => { + const existing = acc.find((o) => o.by === curr.by); + if (!existing) { + return [...acc, curr]; + } + return acc; + }, []); + + return orderByOptionsWithoutDuplicates.map((o) => { + if (!selectedOrderBy) { + return { by: o.by, label: o.label, currentDir: undefined, nextDir: 'asc' }; + } + + if (o.by === selectedOrderBy.by) { + return { + by: o.by, + label: o.label, + currentDir: selectedOrderBy.dir, + nextDir: selectedOrderBy.dir === 'asc' ? 'desc' : undefined, + }; + } + + return { by: o.by, label: o.label, currentDir: undefined, nextDir: 'asc' }; + }); + }); + + selectedOrderBy = computed(() => { + const orderByOptions = this.#filter.orderBy(); + return orderByOptions.find((o) => o.selected); + }); + + toggleOrderBy(orderBy: OrderBy) { + this.#filter.setOrderBy(orderBy.by, orderBy.nextDir, { + commit: this.commitOnToggle(), + }); - toggleOrderBy(orderBy: string) { - this.#filter.toggleOrderBy(orderBy, { commit: this.commitOnToggle() }); this.toggled.emit(); } - - orderByIcon(dir: OrderByDirection) { - return dir === 'asc' ? 'isaSortByDownMedium' : 'isaSortByUpMedium'; - } } diff --git a/libs/ui/input-controls/src/lib/dropdown/_dropdown.scss b/libs/ui/input-controls/src/lib/dropdown/_dropdown.scss index caff79bc7..f67234930 100644 --- a/libs/ui/input-controls/src/lib/dropdown/_dropdown.scss +++ b/libs/ui/input-controls/src/lib/dropdown/_dropdown.scss @@ -1,4 +1,4 @@ -.ui-dropdown.ui-dropdown__button { +.ui-dropdown { display: inline-flex; height: 3rem; padding: 0rem 1.5rem; @@ -6,13 +6,16 @@ gap: 0.5rem; flex-shrink: 0; cursor: pointer; - border-radius: 3.125rem; - @apply text-isa-accent-blue isa-text-body-2-bold border border-solid border-isa-accent-blue; + justify-content: space-between; ng-icon { @apply size-6; } +} + +.ui-dropdown__accent-outline { + @apply text-isa-accent-blue isa-text-body-2-bold border border-solid border-isa-accent-blue; &:hover { @apply bg-isa-neutral-100 border-isa-secondary-700; @@ -27,6 +30,22 @@ } } +.ui-dropdown__grey { + @apply text-isa-neutral-600 isa-text-body-2-bold bg-isa-neutral-400 border border-solid border-isa-neutral-400; + + &:hover { + @apply bg-isa-neutral-500; + } + + &:active { + @apply border-isa-accent-blue text-isa-accent-blue bg-isa-white; + } + + &.open { + @apply border-isa-neutral-900 text-isa-neutral-900; + } +} + .ui-dropdown__options { // Fixed typo from ui-dorpdown__options display: inline-flex; @@ -43,10 +62,11 @@ width: 10rem; height: 3rem; padding: 0rem 1.5rem; - flex-direction: column; - justify-content: center; + flex-direction: row; + justify-content: space-between; + align-items: center; gap: 0.625rem; - border-radius: 0.5rem; + border-radius: 1rem; word-wrap: none; white-space: nowrap; cursor: pointer; diff --git a/libs/ui/input-controls/src/lib/dropdown/dropdown.component.ts b/libs/ui/input-controls/src/lib/dropdown/dropdown.component.ts index d08e47a44..3eb717543 100644 --- a/libs/ui/input-controls/src/lib/dropdown/dropdown.component.ts +++ b/libs/ui/input-controls/src/lib/dropdown/dropdown.component.ts @@ -10,10 +10,9 @@ import { input, model, signal, - ViewEncapsulation, } from '@angular/core'; -import { ControlValueAccessor } from '@angular/forms'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; import { NgIconComponent, provideIcons } from '@ng-icons/core'; import { isaActionChevronUp, isaActionChevronDown } from '@isa/icons'; import { ActiveDescendantKeyManager, Highlightable } from '@angular/cdk/a11y'; @@ -74,7 +73,10 @@ export class DropdownOptionComponent implements Highlightable { changeDetection: ChangeDetectionStrategy.OnPush, hostDirectives: [CdkOverlayOrigin], imports: [NgIconComponent, CdkConnectedOverlay], - providers: [provideIcons({ isaActionChevronUp, isaActionChevronDown })], + providers: [ + provideIcons({ isaActionChevronUp, isaActionChevronDown }), + { provide: NG_VALUE_ACCESSOR, useExisting: DropdownButtonComponent, multi: true }, + ], host: { '[class]': '["ui-dropdown", appearanceClass(), isOpenClass()]', 'role': 'combobox', @@ -96,7 +98,7 @@ export class DropdownButtonComponent implements ControlValueAccessor, AfterVi return this.elementRef.nativeElement.offsetWidth; } - appearance = input(DropdownAppearance.Button); + appearance = input(DropdownAppearance.AccentOutline); appearanceClass = computed(() => `ui-dropdown__${this.appearance()}`); @@ -110,6 +112,8 @@ export class DropdownButtonComponent implements ControlValueAccessor, AfterVi disabled = model(false); + showSelectedValue = input(true); + options = contentChildren(DropdownOptionComponent); cdkOverlayOrigin = inject(CdkOverlayOrigin, { self: true }); @@ -136,6 +140,10 @@ export class DropdownButtonComponent implements ControlValueAccessor, AfterVi isOpenIcon = computed(() => (this.isOpen() ? 'isaActionChevronUp' : 'isaActionChevronDown')); viewLabel = computed(() => { + if (!this.showSelectedValue()) { + return this.label() ?? this.value(); + } + const selectedOption = this.selectedOption(); if (!selectedOption) { diff --git a/libs/ui/input-controls/src/lib/dropdown/dropdown.types.ts b/libs/ui/input-controls/src/lib/dropdown/dropdown.types.ts index 927381d02..1e8997471 100644 --- a/libs/ui/input-controls/src/lib/dropdown/dropdown.types.ts +++ b/libs/ui/input-controls/src/lib/dropdown/dropdown.types.ts @@ -1,5 +1,6 @@ export const DropdownAppearance = { - Button: 'button', + AccentOutline: 'accent-outline', + Grey: 'grey', } as const; export type DropdownAppearance = (typeof DropdownAppearance)[keyof typeof DropdownAppearance];