Merged PR 1881: Stateful Remi Button

#5203

Related work items: #5203
This commit is contained in:
Lorenz Hilpert
2025-07-14 11:57:03 +00:00
committed by Nino Righi
parent 5f74c6ddf8
commit 40c9d51dfc
24 changed files with 1418 additions and 604 deletions

View File

@@ -9,14 +9,12 @@ You are in an nx workspace using Nx 21.2.1 and npm as the package manager.
You have access to the Nx MCP server and the tools it provides. Use them. Follow these guidelines in order to best help the user:
# General Guidelines
- When answering questions, use the nx_workspace tool first to gain an understanding of the workspace architecture
- For questions around nx configuration, best practices or if you're unsure, use the nx_docs tool to get relevant, up-to-date docs!! Always use this instead of assuming things about nx configuration
- If the user needs help with an Nx configuration or project graph error, use the 'nx_workspace' tool to get any errors
- To help answer questions about the workspace structure or simply help with demonstrating how tasks depend on each other, use the 'nx_visualize_graph' tool
# Generation Guidelines
If the user wants to generate something, use the following flow:
- learn about the nx workspace and any specifics the user needs by using the 'nx_workspace' tool and the 'nx_project_details' tool if applicable
@@ -31,11 +29,19 @@ If the user wants to generate something, use the following flow:
- use the information provided in the log file to answer the user's question or continue with what they were doing
# Running Tasks Guidelines
If the user wants help with tasks or commands (which include keywords like "test", "build", "lint", or other similar actions), use the following flow:
- Use the 'nx_current_running_tasks_details' tool to get the list of tasks (this can include tasks that were completed, stopped or failed).
- If there are any tasks, ask the user if they would like help with a specific task then use the 'nx_current_running_task_output' tool to get the terminal output for that task/command
- Use the terminal output from 'nx_current_running_task_output' to see what's wrong and help the user fix their problem. Use the appropriate tools if necessary
- If the user would like to rerun the task or command, always use `nx run <taskId>` to rerun in the terminal. This will ensure that the task will run in the nx context and will be run the same way it originally executed
- If the task was marked as "continuous" do not offer to rerun the task. This task is already running and the user can see the output in the terminal. You can use 'nx_current_running_task_output' to get the output of the task to verify the output.
# CI Error Guidelines
If the user wants help with fixing an error in their CI pipeline, use the following flow:
- Retrieve the list of current CI Pipeline Executions (CIPEs) using the 'nx_cloud_cipe_details' tool
- If there are any errors, use the 'nx_cloud_fix_cipe_failure' tool to retrieve the logs for a specific task
- Use the task logs to see what's wrong and help the user fix their problem. Use the appropriate tools if necessary
- Make sure that the problem is fixed by running the task that you passed into the 'nx_cloud_fix_cipe_failure' tool

View File

@@ -9,6 +9,7 @@ describe('InFlight Decorators', () => {
afterEach(() => {
vi.restoreAllMocks();
vi.clearAllTimers();
vi.useRealTimers();
});
describe('InFlight', () => {
@@ -75,17 +76,27 @@ describe('InFlight Decorators', () => {
const promise1 = service.fetchWithError();
const promise2 = service.fetchWithError();
// Handle the promises immediately to avoid unhandled rejections
const resultsPromise = Promise.allSettled([promise1, promise2]);
await vi.runAllTimersAsync();
// Both should reject with the same error
await expect(promise1).rejects.toThrow('Test error');
await expect(promise2).rejects.toThrow('Test error');
const results = await resultsPromise;
expect(results[0].status).toBe('rejected');
expect(results[1].status).toBe('rejected');
expect((results[0] as PromiseRejectedResult).reason.message).toBe('Test error');
expect((results[1] as PromiseRejectedResult).reason.message).toBe('Test error');
expect(service.callCount).toBe(1);
// Should allow new call after error
const promise3 = service.fetchWithError();
const promise3Result = Promise.allSettled([promise3]);
await vi.runAllTimersAsync();
await expect(promise3).rejects.toThrow('Test error');
const [result3] = await promise3Result;
expect(result3.status).toBe('rejected');
expect((result3 as PromiseRejectedResult).reason.message).toBe('Test error');
expect(service.callCount).toBe(2);
});
@@ -307,14 +318,20 @@ describe('InFlight Decorators', () => {
// First call that errors
const promise1 = service.fetchWithError();
const promise1Result = Promise.allSettled([promise1]);
await vi.runAllTimersAsync();
await expect(promise1).rejects.toThrow('API Error');
const result1 = await promise1Result;
expect(result1[0].status).toBe('rejected');
expect((result1[0] as PromiseRejectedResult).reason.message).toBe('API Error');
expect(service.callCount).toBe(1);
// Second call should not use cache (errors aren't cached)
const promise2 = service.fetchWithError();
const promise2Result = Promise.allSettled([promise2]);
await vi.runAllTimersAsync();
await expect(promise2).rejects.toThrow('API Error');
const result2 = await promise2Result;
expect(result2[0].status).toBe('rejected');
expect((result2[0] as PromiseRejectedResult).reason.message).toBe('API Error');
expect(service.callCount).toBe(2);
});
});

View File

@@ -20,8 +20,8 @@ export function InFlight<
const inFlightMap = new WeakMap<object, Promise<any>>();
return function (
target: any,
propertyKey: string | symbol,
_target: any,
_propertyKey: string | symbol,
descriptor: PropertyDescriptor,
): PropertyDescriptor {
const originalMethod = descriptor.value;
@@ -39,15 +39,9 @@ export function InFlight<
// Create new request and store it
const promise = originalMethod
.apply(this, args)
.then((result: any) => {
// Clean up after successful completion
.finally(() => {
// Always clean up in-flight request
inFlightMap.delete(this);
return result;
})
.catch((error: any) => {
// Clean up after error
inFlightMap.delete(this);
throw error;
});
inFlightMap.set(this, promise);
@@ -92,8 +86,8 @@ export function InFlightWithKey<T extends (...args: any[]) => Promise<any>>(
const inFlightMap = new WeakMap<object, Map<string, Promise<any>>>();
return function (
target: any,
propertyKey: string | symbol,
_target: any,
_propertyKey: string | symbol,
descriptor: PropertyDescriptor,
): PropertyDescriptor {
const originalMethod = descriptor.value;
@@ -106,7 +100,7 @@ export function InFlightWithKey<T extends (...args: any[]) => Promise<any>>(
if (!inFlightMap.has(this)) {
inFlightMap.set(this, new Map());
}
const instanceMap = inFlightMap.get(this)!;
const instanceMap = inFlightMap.get(this) as Map<string, Promise<any>>;
// Generate cache key
const key = options.keyGenerator
@@ -122,15 +116,9 @@ export function InFlightWithKey<T extends (...args: any[]) => Promise<any>>(
// Create new request and store it
const promise = originalMethod
.apply(this, args)
.then((result: any) => {
// Clean up after successful completion
.finally(() => {
// Always clean up in-flight request
instanceMap.delete(key);
return result;
})
.catch((error: any) => {
// Clean up after error
instanceMap.delete(key);
throw error;
});
instanceMap.set(key, promise);
@@ -183,8 +171,8 @@ export function InFlightWithCache<T extends (...args: any[]) => Promise<any>>(
>();
return function (
target: any,
propertyKey: string | symbol,
_target: any,
_propertyKey: string | symbol,
descriptor: PropertyDescriptor,
): PropertyDescriptor {
const originalMethod = descriptor.value;
@@ -198,8 +186,8 @@ export function InFlightWithCache<T extends (...args: any[]) => Promise<any>>(
inFlightMap.set(this, new Map());
cacheMap.set(this, new Map());
}
const instanceInFlight = inFlightMap.get(this)!;
const instanceCache = cacheMap.get(this)!;
const instanceInFlight = inFlightMap.get(this) as Map<string, Promise<any>>;
const instanceCache = cacheMap.get(this) as Map<string, { result: any; expiry: number }>;
// Generate cache key
const key = options.keyGenerator

View File

@@ -1,4 +1,4 @@
import { describe, it, expect } from 'vitest';
// Using Jest (default for existing libraries)
import { calculateAvailableStock } from './calc-available-stock.helper';
describe('calculateAvailableStock', () => {

View File

@@ -1,4 +1,4 @@
import { describe, it, expect } from 'vitest';
// Using Jest (default for existing libraries)
import { calculateStockToRemit } from './calc-stock-to-remit.helper';
describe('calculateStockToRemit', () => {

View File

@@ -1,4 +1,4 @@
import { describe, it, expect } from 'vitest';
// Using Jest (default for existing libraries)
import { calculateTargetStock } from './calc-target-stock.helper';
describe('calculateTargetStock', () => {

View File

@@ -1,7 +1,7 @@
# remission-feature-remission-list
# remi-remission-list
This library was generated with [Nx](https://nx.dev).
## Running unit tests
Run `nx test remission-feature-remission-list` to execute the unit tests.
Run `nx test remi-remission-list` to execute the unit tests.

View File

@@ -1,5 +1,5 @@
export default {
displayName: 'remission-feature-remission-list',
displayName: 'remi-remission-list',
preset: '../../../../jest.preset.js',
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
coverageDirectory:

View File

@@ -1,5 +1,5 @@
{
"name": "remission-feature-remission-list",
"name": "remi-remission-list",
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/remission/feature/remission-list/src",
"prefix": "remi",

View File

@@ -0,0 +1,16 @@
<ui-stateful-button
[(state)]="state"
defaultContent="Remittieren"
successContent="Hinzugefügt"
errorContent="Konnte nicht hinzugefügt werden."
errorAction="Noch mal versuchen"
defaultWidth="10rem"
successWidth="20.375rem"
errorWidth="32rem"
[pending]="isLoading()"
color="brand"
size="large"
class="remit-button"
(clicked)="clickHandler()"
(action)="retryHandler()"
/>

View File

@@ -0,0 +1 @@
// Component now uses ui-stateful-button which handles all styling

View File

@@ -0,0 +1,58 @@
import {
ChangeDetectionStrategy,
Component,
signal,
OnDestroy,
} from '@angular/core';
import { StatefulButtonComponent, StatefulButtonState } from '@isa/ui/buttons';
@Component({
selector: 'remi-remit-button',
templateUrl: './remit-button.component.html',
styleUrls: ['./remit-button.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [StatefulButtonComponent],
})
export class RemitButtonComponent implements OnDestroy {
state = signal<StatefulButtonState>('default');
isLoading = signal<boolean>(false);
private timer: ReturnType<typeof setTimeout> | null = null;
ngOnDestroy(): void {
this.clearTimer();
}
clickHandler() {
// Clear any existing timer to prevent multiple clicks from stacking
this.clearTimer();
this.isLoading.set(true);
this.timer = setTimeout(() => {
this.isLoading.set(false);
// Simulate an async operation, e.g., API call
const success = Math.random() > 0.5; // Randomly succeed or fail
if (success) {
this.state.set('success');
} else {
this.state.set('error');
}
}, 100); // Simulate async operation
}
retryHandler() {
this.isLoading.set(true);
this.timer = setTimeout(() => {
this.isLoading.set(false);
this.state.set('success');
}, 100);
}
private clearTimer(): void {
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
}
}

View File

@@ -0,0 +1,162 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Component } from '@angular/core';
import { By } from '@angular/platform-browser';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { BulletListItemComponent } from './bullet-list-item.component';
import { BulletListComponent } from './bullet-list.component';
import { isaActionChevronRight } from '@isa/icons';
// Mock icons for testing
const testIcons = {
isaActionChevronRight,
customIcon: '<svg></svg>',
ownIcon: '<svg></svg>',
parentIcon: '<svg></svg>',
childIcon: '<svg></svg>',
newParentIcon: '<svg></svg>',
};
// Test host component without parent BulletListComponent
@Component({
template: `
<ui-bullet-list-item [icon]="testIcon">
<span data-what="item-content">Test item content</span>
</ui-bullet-list-item>
`,
standalone: true,
imports: [BulletListItemComponent],
providers: [provideIcons(testIcons)],
})
class TestHostComponent {
testIcon: string | undefined = undefined;
}
// Test host component with parent BulletListComponent
@Component({
template: `
<ui-bullet-list [icon]="parentIcon">
<ui-bullet-list-item [icon]="childIcon">
<span data-what="item-content">Test item content</span>
</ui-bullet-list-item>
</ui-bullet-list>
`,
standalone: true,
imports: [BulletListComponent, BulletListItemComponent],
})
class TestHostWithParentComponent {
parentIcon = 'parentIcon';
childIcon: string | undefined = undefined;
}
describe('BulletListItemComponent', () => {
let component: BulletListItemComponent;
let fixture: ComponentFixture<BulletListItemComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [BulletListItemComponent],
providers: [provideIcons(testIcons)],
}).compileComponents();
fixture = TestBed.createComponent(BulletListItemComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should have undefined icon by default', () => {
expect(component.icon()).toBeUndefined();
});
it('should accept custom icon input', () => {
fixture.componentRef.setInput('icon', 'customIcon');
fixture.detectChanges();
expect(component.icon()).toBe('customIcon');
});
it('should render with correct CSS class', () => {
const hostElement = fixture.nativeElement;
expect(hostElement.classList.contains('ui-bullet-list-item')).toBe(true);
});
it('should render ng-icon with correct size', () => {
const ngIcon = fixture.debugElement.query(By.directive(NgIcon));
expect(ngIcon).toBeTruthy();
expect(ngIcon.nativeElement.getAttribute('size')).toBe('1.5rem');
});
it('should use undefined iconName when no icon is provided and no parent exists', () => {
expect(component.iconName()).toBeUndefined();
});
it('should use own icon when provided', () => {
fixture.componentRef.setInput('icon', 'ownIcon');
fixture.detectChanges();
expect(component.iconName()).toBe('ownIcon');
});
it('should project content correctly', async () => {
const hostFixture = TestBed.createComponent(TestHostComponent);
hostFixture.detectChanges();
const projectedContent = hostFixture.nativeElement.querySelector('[data-what="item-content"]');
expect(projectedContent).toBeTruthy();
expect(projectedContent.textContent.trim()).toBe('Test item content');
});
it('should inherit parent icon when no own icon is provided', async () => {
const hostFixture = TestBed.createComponent(TestHostWithParentComponent);
hostFixture.detectChanges();
const bulletListItem = hostFixture.debugElement.query(
sel => sel.componentInstance instanceof BulletListItemComponent
).componentInstance;
expect(bulletListItem.iconName()).toBe('parentIcon');
});
it('should use own icon instead of parent icon when both are provided', async () => {
const hostFixture = TestBed.createComponent(TestHostWithParentComponent);
const hostComponent = hostFixture.componentInstance;
hostComponent.childIcon = 'childIcon';
hostFixture.detectChanges();
const bulletListItem = hostFixture.debugElement.query(
sel => sel.componentInstance instanceof BulletListItemComponent
).componentInstance;
expect(bulletListItem.iconName()).toBe('childIcon');
});
it('should render ng-icon with computed iconName', async () => {
const hostFixture = TestBed.createComponent(TestHostWithParentComponent);
hostFixture.detectChanges();
const ngIcon = hostFixture.debugElement.query(By.directive(NgIcon));
expect(ngIcon.componentInstance.name()).toBe('parentIcon');
});
it('should update icon when parent icon changes', async () => {
const hostFixture = TestBed.createComponent(TestHostWithParentComponent);
const hostComponent = hostFixture.componentInstance;
hostFixture.detectChanges();
const bulletListItem = hostFixture.debugElement.query(
sel => sel.componentInstance instanceof BulletListItemComponent
).componentInstance;
expect(bulletListItem.iconName()).toBe('parentIcon');
// Change parent icon
hostComponent.parentIcon = 'newParentIcon';
hostFixture.detectChanges();
expect(bulletListItem.iconName()).toBe('newParentIcon');
});
});

View File

@@ -0,0 +1,85 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Component } from '@angular/core';
import { provideIcons } from '@ng-icons/core';
import { BulletListComponent } from './bullet-list.component';
import { isaActionChevronRight } from '@isa/icons';
// Mock icons for testing
const testIcons = {
isaActionChevronRight,
customIcon: '<svg></svg>',
customTestIcon: '<svg></svg>',
};
// Test host component to verify content projection
@Component({
template: `
<ui-bullet-list [icon]="testIcon">
<div data-what="test-content">Test content</div>
</ui-bullet-list>
`,
standalone: true,
imports: [BulletListComponent],
})
class TestHostComponent {
testIcon = 'testIcon';
}
describe('BulletListComponent', () => {
let component: BulletListComponent;
let fixture: ComponentFixture<BulletListComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [BulletListComponent],
providers: [provideIcons(testIcons)],
}).compileComponents();
fixture = TestBed.createComponent(BulletListComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should have default icon value', () => {
expect(component.icon()).toBe('isaActionChevronRight');
});
it('should accept custom icon input', () => {
fixture.componentRef.setInput('icon', 'customIcon');
fixture.detectChanges();
expect(component.icon()).toBe('customIcon');
});
it('should render with correct CSS class', () => {
const hostElement = fixture.nativeElement;
expect(hostElement.classList.contains('ui-bullet-list')).toBe(true);
});
it('should project content correctly', async () => {
const hostFixture = TestBed.createComponent(TestHostComponent);
hostFixture.detectChanges();
const projectedContent = hostFixture.nativeElement.querySelector('[data-what="test-content"]');
expect(projectedContent).toBeTruthy();
expect(projectedContent.textContent.trim()).toBe('Test content');
});
it('should pass icon to child components via dependency injection', async () => {
const hostFixture = TestBed.createComponent(TestHostComponent);
const hostComponent = hostFixture.componentInstance;
hostComponent.testIcon = 'customTestIcon';
hostFixture.detectChanges();
const bulletListComponent = hostFixture.debugElement.query(
sel => sel.componentInstance instanceof BulletListComponent
).componentInstance;
expect(bulletListComponent.icon()).toBe('customTestIcon');
});
});

View File

@@ -2,3 +2,4 @@
@use "lib/icon-button";
@use "lib/info-button";
@use "lib/text-button";
@use "lib/stateful-button/stateful-button";

View File

@@ -2,4 +2,5 @@ export * from './lib/button.component';
export * from './lib/icon-button.component';
export * from './lib/info-button.component';
export * from './lib/text-button.component';
export * from './lib/stateful-button/stateful-button.component';
export * from './lib/types';

View File

@@ -0,0 +1,24 @@
.stateful-button {
@apply flex items-center justify-center select-none;
overflow: hidden;
width: 100%;
.stateful-button-content {
@apply flex items-center gap-1 w-full;
white-space: nowrap;
&--default {
@apply justify-center;
}
&--error {
@apply justify-between;
}
&--success {
@apply justify-start;
}
}
}
.stateful-button-action {
@apply cursor-pointer font-bold;
}

View File

@@ -0,0 +1,52 @@
<ui-button
[class]="['stateful-button', stateClass()]"
[color]="color()"
[size]="size()"
[pending]="pending()"
(click)="handleButtonClick()"
data-what="stateful-button"
[attr.data-which]="state()"
>
@switch (state()) {
@case ('default') {
<div
class="stateful-button-content stateful-button-content--default"
[@fade]
>
<span>{{ defaultContent() }}</span>
</div>
}
@case ('success') {
<div
class="stateful-button-content stateful-button-content--success"
[@fade]
>
<ng-icon name="isaActionCheck" size="1.5rem"></ng-icon>
<span>{{ successContent() }}</span>
</div>
}
@case ('error') {
<div
class="stateful-button-content stateful-button-content--error"
[@fade]
>
<span class="font-normal">{{ errorContent() }}</span>
@if (errorAction()) {
<span
class="stateful-button-action"
(click)="handleActionClick($event)"
(keydown.enter)="handleActionClick($event)"
(keydown.space)="handleActionClick($event)"
tabindex="0"
role="button"
data-what="stateful-button-action"
>
{{ errorAction() }}
</span>
} @else {
<span></span>
}
</div>
}
}
</ui-button>

View File

@@ -0,0 +1,227 @@
import { createComponentFactory, Spectator } from '@ngneat/spectator/jest';
import { StatefulButtonComponent } from './stateful-button.component';
import { fakeAsync, tick } from '@angular/core/testing';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
describe('StatefulButtonComponent', () => {
let spectator: Spectator<StatefulButtonComponent>;
const createComponent = createComponentFactory({
component: StatefulButtonComponent,
imports: [NoopAnimationsModule],
});
beforeEach(() => {
spectator = createComponent({
props: {
defaultContent: 'Submit',
successContent: 'Submitted',
errorContent: 'Failed to submit',
},
});
});
it('should create', () => {
expect(spectator.component).toBeTruthy();
});
it('should display default content initially', () => {
expect(spectator.query('.stateful-button-content--default')).toHaveText('Submit');
expect(spectator.component.state()).toBe('default');
});
it('should have correct e2e attributes', () => {
const button = spectator.query('ui-button');
expect(button).toHaveAttribute('data-what', 'stateful-button');
expect(button).toHaveAttribute('data-which', 'default');
});
it('should apply correct CSS classes based on state', () => {
expect(spectator.query('.stateful-button-content--default')).toExist();
spectator.component.state.set('success');
spectator.detectChanges();
expect(spectator.query('.stateful-button-content--success')).toExist();
spectator.component.state.set('error');
spectator.detectChanges();
expect(spectator.query('.stateful-button-content--error')).toExist();
});
it('should emit clicked event when clicked in default state', () => {
const clickedSpy = jest.fn();
spectator.output('clicked').subscribe(clickedSpy);
spectator.click('ui-button');
expect(clickedSpy).toHaveBeenCalledTimes(1);
});
it('should transition from success to default when clicked', () => {
spectator.component.state.set('success');
spectator.detectChanges();
expect(spectator.component.state()).toBe('success');
expect(spectator.query('.stateful-button-content--success')).toContainText('Submitted');
spectator.click('ui-button');
expect(spectator.component.state()).toBe('default');
});
it('should transition from error to default when clicked', () => {
spectator.component.state.set('error');
spectator.detectChanges();
expect(spectator.component.state()).toBe('error');
spectator.click('ui-button');
expect(spectator.component.state()).toBe('default');
});
it('should display success icon and content', () => {
spectator.component.state.set('success');
spectator.detectChanges();
expect(spectator.query('ng-icon')).toExist();
expect(spectator.query('ng-icon')).toHaveAttribute('name', 'isaActionCheck');
expect(spectator.query('.stateful-button-content--success')).toContainText('Submitted');
});
it('should display error content with action button', () => {
spectator.setInput('errorAction', 'Try again');
spectator.component.state.set('error');
spectator.detectChanges();
expect(spectator.query('.stateful-button-content--error')).toContainText('Failed to submit');
expect(spectator.query('.stateful-button-action')).toHaveText('Try again');
});
it('should emit action event when error action is clicked', () => {
spectator.setInput('errorAction', 'Try again');
spectator.component.state.set('error');
spectator.detectChanges();
const actionSpy = jest.fn();
spectator.output('action').subscribe(actionSpy);
spectator.click('.stateful-button-action');
expect(actionSpy).toHaveBeenCalledTimes(1);
});
it('should display error content without action when errorAction is not provided', () => {
spectator.component.state.set('error');
spectator.detectChanges();
expect(spectator.query('.stateful-button-content--error')).toContainText('Failed to submit');
expect(spectator.query('.stateful-button-action')).not.toExist();
});
it('should pass through button properties correctly', () => {
spectator.setInput('color', 'brand');
spectator.setInput('size', 'large');
spectator.setInput('pending', true);
spectator.detectChanges();
const button = spectator.query('ui-button');
expect(button).toHaveClass('ui-button__brand');
expect(button).toHaveClass('ui-button__large');
expect(button).toHaveClass('ui-button__pending');
});
it('should auto-dismiss success state after timeout', fakeAsync(() => {
spectator.setInput('dismiss', 1000);
spectator.component.state.set('success');
spectator.detectChanges();
expect(spectator.component.state()).toBe('success');
tick(1000);
expect(spectator.component.state()).toBe('default');
}));
it('should use correct widths for each state', () => {
// Setup the expected widths
spectator.setInput('defaultWidth', '10rem');
spectator.setInput('successWidth', '20.375rem');
spectator.setInput('errorWidth', '32rem');
expect(spectator.component.widthStyle()).toBe('10rem');
spectator.component.state.set('success');
expect(spectator.component.widthStyle()).toBe('20.375rem');
spectator.component.state.set('error');
expect(spectator.component.widthStyle()).toBe('32rem');
});
it('should use custom widths when provided', () => {
spectator.setInput('defaultWidth', '8rem');
spectator.setInput('successWidth', '16rem');
spectator.setInput('errorWidth', '24rem');
expect(spectator.component.widthStyle()).toBe('8rem');
spectator.component.state.set('success');
expect(spectator.component.widthStyle()).toBe('16rem');
spectator.component.state.set('error');
expect(spectator.component.widthStyle()).toBe('24rem');
});
it('should update e2e data-which attribute based on state', () => {
const button = spectator.query('ui-button');
expect(button).toHaveAttribute('data-which', 'default');
spectator.component.state.set('success');
spectator.detectChanges();
expect(button).toHaveAttribute('data-which', 'success');
spectator.component.state.set('error');
spectator.detectChanges();
expect(button).toHaveAttribute('data-which', 'error');
});
it('should handle keyboard accessibility on action button', () => {
spectator.setInput('errorAction', 'Try again');
spectator.component.state.set('error');
spectator.detectChanges();
const action = spectator.query('.stateful-button-action');
expect(action).toHaveAttribute('role', 'button');
expect(action).toHaveAttribute('tabindex', '0');
expect(action).toHaveAttribute('data-what', 'stateful-button-action');
});
it('should have fade animation applied to content divs', () => {
// Animation triggers don't add DOM attributes, so we test for the elements to exist
// The animation setup is tested through the NoopAnimationsModule in the component setup
expect(spectator.query('.stateful-button-content--default')).toExist();
spectator.component.state.set('success');
spectator.detectChanges();
expect(spectator.query('.stateful-button-content--success')).toExist();
spectator.component.state.set('error');
spectator.detectChanges();
expect(spectator.query('.stateful-button-content--error')).toExist();
});
it('should properly align content based on state', () => {
const defaultContent = spectator.query('.stateful-button-content--default');
expect(defaultContent).toHaveClass('stateful-button-content--default');
spectator.component.state.set('success');
spectator.detectChanges();
const successContent = spectator.query('.stateful-button-content--success');
expect(successContent).toHaveClass('stateful-button-content--success');
spectator.component.state.set('error');
spectator.detectChanges();
const errorContent = spectator.query('.stateful-button-content--error');
expect(errorContent).toHaveClass('stateful-button-content--error');
});
});

View File

@@ -0,0 +1,176 @@
import {
ChangeDetectionStrategy,
Component,
computed,
input,
model,
output,
effect,
OnDestroy,
inject,
viewChild,
ElementRef,
untracked,
} from '@angular/core';
import { ButtonComponent } from '../button.component';
import { ButtonColor, ButtonSize } from '../types';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { isaActionCheck } from '@isa/icons';
import {
animate,
AnimationBuilder,
style,
transition,
trigger,
} from '@angular/animations';
export type StatefulButtonState = 'default' | 'success' | 'error';
@Component({
selector: 'ui-stateful-button',
templateUrl: './stateful-button.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [ButtonComponent, NgIcon],
providers: [provideIcons({ isaActionCheck })],
animations: [
trigger('fade', [
transition(':enter', [
style({ opacity: 0.5 }),
animate('125ms 125ms ease-in', style({ opacity: 1 })),
]),
transition(':leave', [
style({ opacity: '*' }),
animate('124ms ease-out', style({ opacity: 0.5 })),
]),
]),
],
})
export class StatefulButtonComponent implements OnDestroy {
#animationBuilder = inject(AnimationBuilder);
private buttonElement = viewChild.required(ButtonComponent, {
read: ElementRef,
});
// State management
state = model<StatefulButtonState>('default');
stateEffect = effect(() => {
this.state();
untracked(() => {
this.#makeWidthAnimation(this.widthStyle());
});
});
// Content inputs for each state
defaultContent = input.required<string>();
successContent = input.required<string>();
errorContent = input.required<string>();
errorAction = input<string>();
// Width configuration for each state
defaultWidth = input<string>('100%');
successWidth = input<string>('100%');
errorWidth = input<string>('100%');
// Optional dismiss timeout in milliseconds
dismiss = input<number>();
// Button properties
color = input<ButtonColor>('primary');
size = input<ButtonSize>('medium');
pending = input<boolean>(false);
// Output events
clicked = output<void>();
action = output<void>();
// Internal state
private dismissTimer: ReturnType<typeof setTimeout> | null = null;
// Computed properties
stateClass = computed(() => `stateful-button--${this.state()}`);
widthStyle = computed(() => {
switch (this.state()) {
case 'success':
return this.successWidth();
case 'error':
return this.errorWidth();
default:
return this.defaultWidth();
}
});
constructor() {
// Watch for state changes to handle auto-dismiss
this.setupStateEffect();
}
ngOnDestroy(): void {
this.clearDismissTimer();
}
private setupStateEffect(): void {
// Use effect to watch state changes
effect(() => {
const currentState = this.state();
const dismissTimeout = this.dismiss();
// Clear any existing timer
this.clearDismissTimer();
// Set up auto-dismiss if configured
if (
dismissTimeout &&
(currentState === 'success' || currentState === 'error')
) {
this.dismissTimer = setTimeout(() => {
this.changeState('default');
}, dismissTimeout);
}
});
}
handleButtonClick(): void {
const currentState = this.state();
if (currentState === 'default') {
// Only emit click event in default state
this.clicked.emit();
} else if (currentState === 'success' || currentState === 'error') {
// In success/error states, clicking the button returns to default
this.changeState('default');
}
}
handleActionClick(event: Event): void {
// Prevent button click from firing
event.stopPropagation();
// Emit action event
this.action.emit();
}
private changeState(newState: StatefulButtonState): void {
this.clearDismissTimer();
this.state.set(newState);
}
private clearDismissTimer(): void {
if (this.dismissTimer) {
clearTimeout(this.dismissTimer);
this.dismissTimer = null;
}
}
#makeWidthAnimation(width: string): void {
const animation = this.#animationBuilder.build([
style({ width: '*' }),
animate('250ms', style({ width })),
]);
const player = animation.create(this.buttonElement().nativeElement);
player.play();
}
}