mirror of
https://dev.azure.com/hugendubel/ISA/_git/ISA-Frontend
synced 2025-12-28 22:42:11 +01:00
Merge branch 'develop' of https://dev.azure.com/hugendubel/ISA/_git/ISA-Frontend into develop
This commit is contained in:
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/).
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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) || [],
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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}`);
|
||||
}
|
||||
245
libs/shared/filter/src/lib/core/mappings/filter.mapping.spec.ts
Normal file
245
libs/shared/filter/src/lib/core/mappings/filter.mapping.spec.ts
Normal 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' }],
|
||||
});
|
||||
});
|
||||
});
|
||||
46
libs/shared/filter/src/lib/core/mappings/filter.mapping.ts
Normal file
46
libs/shared/filter/src/lib/core/mappings/filter.mapping.ts
Normal 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;
|
||||
}
|
||||
8
libs/shared/filter/src/lib/core/mappings/index.ts
Normal file
8
libs/shared/filter/src/lib/core/mappings/index.ts
Normal 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';
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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...',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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>;
|
||||
@@ -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
|
||||
>;
|
||||
@@ -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>;
|
||||
@@ -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>;
|
||||
@@ -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>;
|
||||
@@ -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>;
|
||||
34
libs/shared/filter/src/lib/core/schemas/filter.schema.ts
Normal file
34
libs/shared/filter/src/lib/core/schemas/filter.schema.ts
Normal 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>;
|
||||
12
libs/shared/filter/src/lib/core/schemas/index.ts
Normal file
12
libs/shared/filter/src/lib/core/schemas/index.ts
Normal 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';
|
||||
@@ -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>;
|
||||
@@ -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>;
|
||||
@@ -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>;
|
||||
47
libs/shared/filter/src/lib/core/schemas/query.schema.ts
Normal file
47
libs/shared/filter/src/lib/core/schemas/query.schema.ts
Normal 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>;
|
||||
@@ -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>;
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@
|
||||
<button
|
||||
class="filter-input-button__filter-button"
|
||||
[class.open]="open()"
|
||||
[class.active]="!isDefaultInputState()"
|
||||
(click)="toggle()"
|
||||
type="button"
|
||||
cdkOverlayOrigin
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export const DropdownAppearance = {
|
||||
Button: 'button',
|
||||
AccentOutline: 'accent-outline',
|
||||
Grey: 'grey',
|
||||
} as const;
|
||||
|
||||
export type DropdownAppearance = (typeof DropdownAppearance)[keyof typeof DropdownAppearance];
|
||||
|
||||
Reference in New Issue
Block a user