This commit is contained in:
Nino
2025-04-11 16:59:20 +02:00
51 changed files with 3578 additions and 395 deletions

View File

@@ -14,7 +14,7 @@ const meta: Meta<UiButtonComponentInputs> = {
argTypes: {
color: {
control: { type: 'select' },
options: ['primary', 'secondary', 'brand', 'tertiary'] as ButtonColor[],
options: Object.values(ButtonColor),
description: 'Determines the button color',
},
size: {

View File

@@ -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<IUser> => {
const getUser = (id: string, includeDetails = false): Promise<User> => {
// ...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 = <T extends IUserProps>(id: string): Promise<T> => {
const getUser = <T extends UserProps>(id: string): Promise<T> => {
// ...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
<!-- Good - Modern control flow syntax -->
<div>
@if (user) {
<h1>Welcome, {{ user.name }}!</h1>
} @else if (isLoading) {
<h1>Loading user data...</h1>
} @else {
<h1>Please log in</h1>
}
<ul>
@for (item of items; track item.id) {
<li>{{ item.name }}</li>
} @empty {
<li>No items available</li>
}
</ul>
@switch (userRole) { @case ('admin') {
<app-admin-dashboard />
} @case ('manager') {
<app-manager-dashboard />
} @default {
<app-user-dashboard />
} }
</div>
<!-- Bad - Old structural directives -->
<div>
<h1 *ngIf="user">Welcome, {{ user.name }}!</h1>
<h1 *ngIf="!user && isLoading">Loading user data...</h1>
<h1 *ngIf="!user && !isLoading">Please log in</h1>
<ul>
<li *ngFor="let item of items; trackBy: trackById">{{ item.name }}</li>
<li *ngIf="!items || items.length === 0">No items available</li>
</ul>
<app-admin-dashboard *ngIf="userRole === 'admin'"></app-admin-dashboard>
<app-manager-dashboard
*ngIf="userRole === 'manager'"
></app-manager-dashboard>
<app-user-dashboard
*ngIf="userRole !== 'admin' && userRole !== 'manager'"
></app-user-dashboard>
</div>
```
- 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
<!-- Good - Using contextual variables -->
@for (item of items; track item.id; let i = $index, isLast = $last) {
<li [class.last-item]="isLast">{{ i + 1 }}. {{ item.name }}</li>
}
```
- Use the `@empty` block with `@for` to handle empty collections gracefully
- Store conditional expression results in variables for clearer templates:
```html
<!-- Good - Storing expression result in variable -->
@if (user.permissions.canEditSettings; as canEdit) {
<button [disabled]="!canEdit">Edit Settings</button>
}
```
## 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

View File

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

View File

@@ -12,20 +12,23 @@
[rollbackOnClose]="true"
></filter-filter-menu-button>
<button uiIconButton *uiBreakpoint="['tablet']" (click)="orderByVisible.set(!orderByVisible())">
<ng-icon name="isaActionSort"></ng-icon>
</button>
<filter-order-by-toolbar
*uiBreakpoint="['desktop', 'dekstop-l', 'dekstop-xl']"
(toggled)="search()"
></filter-order-by-toolbar>
@if (mobileBreakpoint()) {
<button
uiIconButton
type="button"
(click)="showOrderByToolbarMobile.set(!showOrderByToolbarMobile())"
[class.active]="showOrderByToolbarMobile()"
>
<ng-icon name="isaActionSort"></ng-icon>
</button>
} @else {
<filter-order-by-toolbar (toggled)="search()"></filter-order-by-toolbar>
}
</div>
</div>
@if (orderByVisible()) {
@if (mobileBreakpoint() && showOrderByToolbarMobile()) {
<filter-order-by-toolbar
*uiBreakpoint="['tablet']"
class="w-full"
(toggled)="search()"
></filter-order-by-toolbar>
@@ -48,7 +51,10 @@
} @placeholder {
<!-- TODO: Den Spinner durch Skeleton Loader Kacheln ersetzen -->
<div class="h-[7.75rem] w-full flex items-center justify-center">
<ui-icon-button [pending]="true" [color]="'tertiary'"></ui-icon-button>
<ui-icon-button
[pending]="true"
[color]="'tertiary'"
></ui-icon-button>
</div>
}
}

View File

@@ -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<ListResponseArgs<ReceiptListItem>>) => {
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,

View File

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

View File

@@ -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;
}

View File

@@ -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<string>();
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,
});
}

View File

@@ -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();
});
});

View File

@@ -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) || [],
});
}

View File

@@ -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');
});
});

View File

@@ -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,
});
}

View File

@@ -0,0 +1,177 @@
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,
});
});
it('should map min and max values correctly', () => {
// Arrange
const group = 'testGroup';
const input: Input = {
key: 'testKey',
label: 'Test Label',
type: InputType.DateRange,
options: {
values: [
{
label: 'Start',
value: '2023-01-01',
minValue: '2022-01-01',
maxValue: '2024-12-31',
},
{ label: 'End', value: '2023-12-31' },
],
},
};
// Act
const result = dateRangeFilterInputMapping(group, input);
// Assert
expect(mockSchemaParser).toHaveBeenCalledWith({
group: 'testGroup',
key: 'testKey',
label: 'Test Label',
description: undefined,
type: InputType.DateRange,
start: '2023-01-01',
stop: '2023-12-31',
minStart: '2022-01-01',
maxStop: '2024-12-31',
});
expect(result).toEqual({
group: 'testGroup',
key: 'testKey',
label: 'Test Label',
description: undefined,
type: InputType.DateRange,
start: '2023-01-01',
stop: '2023-12-31',
minStart: '2022-01-01',
maxStop: '2024-12-31',
});
});
});

View File

@@ -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,
maxStop: input.options?.values?.[0]?.maxValue,
});
}

View File

@@ -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);
});
});

View File

@@ -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,
});
}

View File

@@ -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();
});
});

View File

@@ -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}`);
}

View File

@@ -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' }],
});
});
});

View File

@@ -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;
}

View File

@@ -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';

View File

@@ -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,
});
});
});

View File

@@ -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,
});
}

View File

@@ -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...',
});
});
});

View File

@@ -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,
});
}

View File

@@ -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<typeof FilterSchema>;
export type FilterGroup = z.infer<typeof FilterGroupSchema>;
export type TextFilterInput = z.infer<typeof TextFilterInputSchema>;
export type CheckboxFilterInput = z.infer<typeof CheckboxFilterInputSchema>;
export type FilterInput = z.infer<typeof FilterInputSchema>;
export type CheckboxFilterInputOption = z.infer<
typeof CheckboxFilterInputOptionSchema
>;
export type DateRangeFilterInput = z.infer<typeof DateRangeFilterInputSchema>;
export type OrderByDirection = z.infer<typeof OrderByDirectionSchema>;
export type Query = z.infer<typeof QuerySchema>;
export type QueryOrderBy = z.infer<typeof QueryOrderBySchema>;

View File

@@ -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<typeof BaseFilterInputSchema>;

View File

@@ -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
>;

View File

@@ -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<typeof CheckboxFilterInputSchema>;

View File

@@ -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<typeof DateRangeFilterInputSchema>;

View File

@@ -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<typeof FilterGroupSchema>;

View File

@@ -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<typeof FilterInputSchema>;

View File

@@ -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<typeof FilterSchema>;

View File

@@ -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';

View File

@@ -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<typeof OrderByDirectionSchema>;

View File

@@ -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<typeof OrderByOptionSchema>;

View File

@@ -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<typeof QueryOrderBySchema>;

View File

@@ -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<typeof QuerySchema>;

View File

@@ -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<typeof TextFilterInputSchema>;

View File

@@ -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<CheckboxInputComponent>;
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<CheckboxInputComponent>;
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<CheckboxInputComponent>;
// 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');
});
});

View File

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

View File

@@ -2,6 +2,7 @@
<button
class="filter-input-button__filter-button"
[class.open]="open()"
[class.active]="!isDefaultInputState()"
(click)="toggle()"
type="button"
cdkOverlayOrigin

View File

@@ -20,4 +20,12 @@
@apply text-isa-neutral-900;
}
}
&.active {
@apply border-isa-accent-blue;
.filter-input-button__filter-button-label {
@apply text-isa-accent-blue;
}
}
}

View File

@@ -0,0 +1,102 @@
import {
createComponentFactory,
Spectator,
mockProvider,
} from '@ngneat/spectator/jest';
import { MockComponents, MockDirectives } from 'ng-mocks';
import { FilterInputMenuButtonComponent } from './input-menu-button.component';
import { FilterInput, FilterService } from '../../core';
import {
CdkConnectedOverlay,
CdkOverlayOrigin,
Overlay,
} from '@angular/cdk/overlay';
import { FilterInputMenuComponent } from './input-menu.component';
import { NgIconComponent } from '@ng-icons/core';
import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
describe('FilterInputMenuButtonComponent', () => {
let spectator: Spectator<FilterInputMenuButtonComponent>;
const dummyFilterInput: FilterInput = { label: 'Test Filter' } as FilterInput;
let filterService: jest.Mocked<FilterService>;
const createComponent = createComponentFactory({
component: FilterInputMenuButtonComponent,
declarations: [
MockComponents(NgIconComponent, FilterInputMenuComponent),
MockDirectives(CdkOverlayOrigin, CdkConnectedOverlay),
],
componentProviders: [
mockProvider(Overlay, { scrollStrategies: { block: jest.fn() } }),
],
providers: [
mockProvider(FilterService, {
isDefaultFilterInput: jest.fn().mockReturnValue(true),
commit: jest.fn(),
}),
],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
detectChanges: false,
});
beforeEach(() => {
spectator = createComponent({
props: {
filterInput: dummyFilterInput,
commitOnClose: 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 commit on close when commitOnClose is true', () => {
// Arrange
spectator.setInput('commitOnClose', true);
spectator.component.open.set(true);
// Act
spectator.component.toggle();
// Assert
expect(filterService.commit).toHaveBeenCalled();
});
it('should close menu when applied is emitted', () => {
// Arrange
spectator.component.open.set(true);
// Act
spectator.component.applied.emit();
// Assert
expect(spectator.component.open()).toBe(false);
});
});

View File

@@ -1,6 +1,14 @@
import { ChangeDetectionStrategy, Component, inject, input, model, output } from '@angular/core';
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
input,
model,
output,
} from '@angular/core';
import { FilterInput, FilterService } from '../../core';
import { Overlay, OverlayModule } from '@angular/cdk/overlay';
import { Overlay, CdkOverlayOrigin, CdkConnectedOverlay } from '@angular/cdk/overlay';
import { NgIconComponent, provideIcons } from '@ng-icons/core';
import { isaActionChevronDown, isaActionChevronUp } from '@isa/icons';
import { FilterInputMenuComponent } from './input-menu.component';
@@ -8,22 +16,26 @@ import { FilterInputMenuComponent } from './input-menu.component';
/**
* A button component that toggles the visibility of an input menu for filtering.
* It emits events when the menu is opened, closed, reset, or applied.
* @implements {OnInit}
*/
@Component({
selector: 'filter-input-menu-button',
templateUrl: './input-menu-button.component.html',
styleUrls: ['./input-menu-button.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [OverlayModule, NgIconComponent, FilterInputMenuComponent],
imports: [NgIconComponent, FilterInputMenuComponent, CdkOverlayOrigin, CdkConnectedOverlay],
providers: [provideIcons({ isaActionChevronDown, isaActionChevronUp })],
})
export class FilterInputMenuButtonComponent {
/** Strategy for handling scroll behavior when the overlay is open */
scrollStrategy = inject(Overlay).scrollStrategies.block();
/** Filter service for managing filter state */
#filter = inject(FilterService);
/**
* Tracks the open state of the input menu.
* Controls the visibility state of the input menu
* @default false
*/
open = model<boolean>(false);
@@ -57,6 +69,14 @@ export class FilterInputMenuButtonComponent {
*/
commitOnClose = input<boolean>(false);
/**
* Determines whether the current input state is the default state.
*/
isDefaultInputState = computed(() => {
const input = this.filterInput();
return this.#filter.isDefaultFilterInput(input);
});
/**
* Subscribes to the `applied` event to automatically close the menu.
*/
@@ -67,10 +87,11 @@ export class FilterInputMenuButtonComponent {
}
/**
* Toggles the open state of the input menu.
* Emits `opened` or `closed` events based on the new state.
* Toggles the visibility of the input menu.
* Emits appropriate events based on the new state.
* If commitOnClose is true, commits the filter changes when closing.
*/
toggle() {
toggle(): void {
const open = this.open();
this.open.set(!open);

View File

@@ -0,0 +1,61 @@
import { Spectator, createComponentFactory } from '@ngneat/spectator/jest';
import { FilterInputMenuComponent } from './input-menu.component';
import { MockComponent } from 'ng-mocks';
import { FilterActionsComponent } from '../../actions';
import { InputRendererComponent } from '../../inputs/input-renderer';
import { FilterInput } from '../../core';
import { InputType } from '../../types';
describe('FilterInputMenuComponent', () => {
let spectator: Spectator<FilterInputMenuComponent>;
const createComponent = createComponentFactory({
component: FilterInputMenuComponent,
declarations: [MockComponent(FilterActionsComponent), MockComponent(InputRendererComponent)],
detectChanges: false,
});
beforeEach(() => {
spectator = createComponent();
});
it('should create the component', () => {
expect(spectator.component).toBeTruthy();
});
it('should emit reseted event when reset is triggered', () => {
const resetSpy = jest.spyOn(spectator.component.reseted, 'emit');
// Act
spectator.component.reseted.emit();
// Assert
expect(resetSpy).toHaveBeenCalled();
});
it('should emit applied event when apply is triggered', () => {
const applySpy = jest.spyOn(spectator.component.applied, 'emit');
// Act
spectator.component.applied.emit();
// Assert
expect(applySpy).toHaveBeenCalled();
});
it('should render the filter input', () => {
// Arrange
const filterInput: FilterInput = {
key: 'test-key',
group: 'test-group',
type: InputType.Text,
label: 'Test Label',
};
spectator.setInput('filterInput', filterInput);
// Act
spectator.detectChanges();
// Assert
expect(spectator.query(InputRendererComponent)).toBeTruthy();
});
});

View File

@@ -6,13 +6,13 @@
class="flex flex-1 gap-1 items-center text-nowrap"
uiTextButton
type="button"
(click)="toggleOrderBy(orderBy.by)"
(click)="toggleOrderBy(orderBy)"
>
<div>
{{ orderBy.label }}
</div>
@if (orderBy.dir) {
<ng-icon [name]="orderByIcon(orderBy.dir)" size="1.25rem"></ng-icon>
@if (orderBy.currentDir) {
<ng-icon [name]="orderBy.currentDir" size="1.25rem"></ng-icon>
}
</button>
}

View File

@@ -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<void>();
orderByOptions = this.#filter.orderBy;
orderByOptions = computed<OrderBy[]>(() => {
const orderByOptions = this.#filter.orderBy();
const selectedOrderBy = orderByOptions.find((o) => o.selected);
const orderByOptionsWithoutDuplicates = orderByOptions.reduce<OrderByOption[]>((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';
}
}

View File

@@ -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;

View File

@@ -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<T> 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<T> implements ControlValueAccessor, AfterVi
return this.elementRef.nativeElement.offsetWidth;
}
appearance = input<DropdownAppearance>(DropdownAppearance.Button);
appearance = input<DropdownAppearance>(DropdownAppearance.AccentOutline);
appearanceClass = computed(() => `ui-dropdown__${this.appearance()}`);
@@ -110,6 +112,8 @@ export class DropdownButtonComponent<T> implements ControlValueAccessor, AfterVi
disabled = model<boolean>(false);
showSelectedValue = input<boolean>(true);
options = contentChildren(DropdownOptionComponent);
cdkOverlayOrigin = inject(CdkOverlayOrigin, { self: true });
@@ -136,6 +140,10 @@ export class DropdownButtonComponent<T> 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) {

View File

@@ -1,5 +1,6 @@
export const DropdownAppearance = {
Button: 'button',
AccentOutline: 'accent-outline',
Grey: 'grey',
} as const;
export type DropdownAppearance = (typeof DropdownAppearance)[keyof typeof DropdownAppearance];