diff --git a/projects/components/action-button/index.ts b/projects/components/action-button/index.ts new file mode 100644 index 0000000..c74f953 --- /dev/null +++ b/projects/components/action-button/index.ts @@ -0,0 +1,3 @@ +// export what ./public_api exports so we can import with the lib name like this: +// import { ModuleA } from 'libname' +export * from './public_api'; diff --git a/projects/components/action-button/ng-package.json b/projects/components/action-button/ng-package.json new file mode 100644 index 0000000..1dc0b0b --- /dev/null +++ b/projects/components/action-button/ng-package.json @@ -0,0 +1,5 @@ +{ + "lib": { + "entryFile": "index.ts" + } +} diff --git a/projects/components/action-button/public_api.ts b/projects/components/action-button/public_api.ts new file mode 100644 index 0000000..89990a8 --- /dev/null +++ b/projects/components/action-button/public_api.ts @@ -0,0 +1,6 @@ +export { ZvActionButtonComponent, type IZvActionButton } from './src/action-button.component'; +export { + ZvActionButtonDataSource, + type IZvActionButtonDataSource, + type IZvActionButtonDataSourceOptions, +} from './src/action-button-data-source'; diff --git a/projects/components/action-button/src/action-button-data-source.ts b/projects/components/action-button/src/action-button-data-source.ts new file mode 100644 index 0000000..436766d --- /dev/null +++ b/projects/components/action-button/src/action-button-data-source.ts @@ -0,0 +1,40 @@ +import { computed, DestroyRef, effect, inject, linkedSignal, Signal } from '@angular/core'; +import { ZvActionDataSource, ZvActionDataSourceOptions, ZvExceptionMessageExtractor } from '@zvoove/components/core'; + +export interface IZvActionButtonDataSource { + readonly showSuccess: Signal; + readonly showLoading: Signal; + readonly showError: Signal; + readonly error: Signal; + readonly errorMessage: Signal; + readonly disabled: Signal; + execute(): void; +} + +export declare type IZvActionButtonDataSourceOptions = ZvActionDataSourceOptions; + +export class ZvActionButtonDataSource extends ZvActionDataSource implements IZvActionButtonDataSource { + readonly #destroyRef = inject(DestroyRef); + readonly #errorMessageExtractor = inject(ZvExceptionMessageExtractor); + #timeoutRef: NodeJS.Timeout | undefined; + + public readonly showLoading = this.isLoading; + public readonly showError = this.hasError; + public readonly disabled = this.isLoading; + public readonly showSuccess = linkedSignal(() => this.succeeded()); + public readonly errorMessage = computed(() => (this.hasError() ? this.#errorMessageExtractor.extractErrorMessage(this.error()) : null)); + + constructor(options: IZvActionButtonDataSourceOptions) { + super(options); + + effect(() => { + if (this.showSuccess()) { + clearTimeout(this.#timeoutRef); + this.#timeoutRef = setTimeout(() => { + this.showSuccess.set(false); + }, 2000); + } + }); + this.#destroyRef.onDestroy(() => clearTimeout(this.#timeoutRef)); + } +} diff --git a/projects/components/action-button/src/action-button.component.html b/projects/components/action-button/src/action-button.component.html new file mode 100644 index 0000000..a5279fa --- /dev/null +++ b/projects/components/action-button/src/action-button.component.html @@ -0,0 +1,19 @@ + diff --git a/projects/components/action-button/src/action-button.component.spec.ts b/projects/components/action-button/src/action-button.component.spec.ts new file mode 100644 index 0000000..661e699 --- /dev/null +++ b/projects/components/action-button/src/action-button.component.spec.ts @@ -0,0 +1,122 @@ +import { HarnessLoader } from '@angular/cdk/testing'; +import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; +import { ChangeDetectionStrategy, Component, signal } from '@angular/core'; +import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; +import { tap, timer } from 'rxjs'; +import { ZvActionButtonDataSource } from './action-button-data-source'; +import { ZvActionButtonComponent } from './action-button.component'; +import { ZvActionButtonHarness } from './action-button.harness'; +import { ZvButtonColors } from '@zvoove/components/core'; + +@Component({ + selector: 'zv-test-component', + template: ` + label + `, + // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection + changeDetection: ChangeDetectionStrategy.Default, + imports: [ZvActionButtonComponent], +}) +export class TestComponent { + public readonly isDisabled = signal(false); + public readonly color = signal('primary'); + public actionFnCalled = false; + public throwError: Error | null = null; + dataSource = new ZvActionButtonDataSource({ + actionFn: () => + timer(100).pipe( + tap(() => { + this.actionFnCalled = true; + if (this.throwError) { + throw this.throwError; + } + }) + ), + }); +} +describe('ZvActionButton', () => { + let fixture: ComponentFixture; + let component: TestComponent; + let loader: HarnessLoader; + let harness: ZvActionButtonHarness; + + beforeEach(async () => { + TestBed.configureTestingModule({ + imports: [TestComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(TestComponent); + component = fixture.componentInstance; + loader = TestbedHarnessEnvironment.loader(fixture); + harness = await loader.getHarness(ZvActionButtonHarness); + }); + + it('should create', () => { + expect(component).toBeDefined(); + }); + + it('should call dataSource.execute() on click', async () => { + expect(component.actionFnCalled).toBeFalse(); + await harness.click(); + expect(component.actionFnCalled).toBeTrue(); + }); + + it('should be blocked while loading', fakeAsync(async () => { + await harness.click(); + tick(1); + expect(component.dataSource.showLoading()).toBeTrue(); + expect(await harness.isLoading()).toBeTrue(); + })); + + it('should be disabled while loading', fakeAsync(async () => { + await harness.click(); + tick(1); + expect(component.dataSource.showLoading()).toBeTrue(); + expect(await harness.isDisabled()).toBeTrue(); + })); + + it('should respect disabled property', async () => { + expect(await harness.isDisabled()).toBeFalse(); + component.isDisabled.set(true); + expect(await harness.isDisabled()).toBeTrue(); + component.isDisabled.set(false); + expect(await harness.isDisabled()).toBeFalse(); + }); + + it('should respect color property', async () => { + expect(await harness.hasClass('mat-primary')).toBeTrue(); + component.color.set('accent'); + expect(await harness.hasClass('mat-accent')).toBeTrue(); + component.color.set('warn'); + expect(await harness.hasClass('mat-warn')).toBeTrue(); + }); + + it('should show icon', async () => { + expect(await harness.getIcon()).toBe('home'); + }); + + it('should show label', async () => { + const buttonContent = await harness.getLabel(); + expect(buttonContent).toContain('label'); + }); + + it('should show success icon for 2 seconds', fakeAsync(async () => { + await harness.click(); + tick(100); + expect(component.dataSource.showLoading()).toBeFalse(); + expect(component.dataSource.showSuccess()).toBeTrue(); + expect(await harness.getIcon()).toBe('check_circle'); + + tick(2000); + expect(component.dataSource.showSuccess()).toBeFalse(); + expect(await harness.getIcon()).toBe('home'); + })); + + it('should show error message', fakeAsync(async () => { + component.throwError = new Error('action failed'); + await harness.click(); + tick(100); + expect(await harness.getIcon()).toBe('error'); + expect(await harness.getErrorMessage()).toBe(component.throwError.message); + })); +}); diff --git a/projects/components/action-button/src/action-button.component.ts b/projects/components/action-button/src/action-button.component.ts new file mode 100644 index 0000000..1865e10 --- /dev/null +++ b/projects/components/action-button/src/action-button.component.ts @@ -0,0 +1,39 @@ +import { afterRenderEffect, ChangeDetectionStrategy, Component, input, Signal, viewChild } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { ThemePalette } from '@angular/material/core'; +import { MatIcon } from '@angular/material/icon'; +import { MatProgressSpinner } from '@angular/material/progress-spinner'; +import { MatTooltip } from '@angular/material/tooltip'; +import { ZvButtonColors } from '@zvoove/components/core'; +import { IZvActionButtonDataSource } from './action-button-data-source'; + +export interface IZvActionButton { + label: string; + color: ThemePalette | null; + icon: string; + dataCy: string; + isDisabled?: Signal; +} + +@Component({ + selector: 'zv-action-button', + templateUrl: './action-button.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [MatIcon, MatButtonModule, MatProgressSpinner, MatTooltip], +}) +export class ZvActionButtonComponent { + public readonly dataSource = input.required(); + public readonly icon = input(null); + public readonly color = input(null); + public readonly disabled = input(false); + + private readonly _tooltip = viewChild(MatTooltip); + + constructor() { + afterRenderEffect(() => { + if (this.dataSource().showError()) { + this._tooltip()?.show(0); + } + }); + } +} diff --git a/projects/components/action-button/src/action-button.harness.ts b/projects/components/action-button/src/action-button.harness.ts new file mode 100644 index 0000000..55dc5b9 --- /dev/null +++ b/projects/components/action-button/src/action-button.harness.ts @@ -0,0 +1,38 @@ +import { ComponentHarness } from '@angular/cdk/testing'; + +export class ZvActionButtonHarness extends ComponentHarness { + static hostSelector = 'zv-action-button'; + + private _button = this.locatorForOptional('button'); + private _buttonIcon = this.locatorForOptional('button mat-icon'); + private _loadingSpinner = this.locatorForOptional('button mat-spinner'); + + public async click(): Promise { + const button = await this._button(); + return await button?.click(); + } + + public async getIcon(): Promise { + return await (await this._buttonIcon())?.text(); + } + + public async getLabel(): Promise { + return (await (await this._button())?.text()) ?? null; + } + + public async isLoading(): Promise { + return !!(await this._loadingSpinner()); + } + + public async isDisabled(): Promise { + return await (await this._button())?.getProperty('disabled'); + } + + public async hasClass(className: string): Promise { + return (await (await this._button())?.hasClass(className)) ?? false; + } + + public async getErrorMessage(): Promise { + return (await (await this._button())?.getAttribute('ng-reflect-message')) ?? ''; + } +} diff --git a/projects/components/block-ui/src/block-ui.component.spec.ts b/projects/components/block-ui/src/block-ui.component.spec.ts index c91eb50..53672b9 100644 --- a/projects/components/block-ui/src/block-ui.component.spec.ts +++ b/projects/components/block-ui/src/block-ui.component.spec.ts @@ -19,7 +19,7 @@ import { ZvBlockUiHarness } from './testing/block-ui.harness'; }) export class TestComponent { public blocked = false; - public spinnerText: string = null; + public spinnerText = ''; } describe('ZvBlockUi', () => { diff --git a/projects/components/core/public_api.ts b/projects/components/core/public_api.ts index d545a52..7a62a19 100644 --- a/projects/components/core/public_api.ts +++ b/projects/components/core/public_api.ts @@ -15,3 +15,5 @@ export { provideDateTimeAdapters, provideDateTimeFormats } from './src/date-time export { ZvDateTimeAdapter, type ZvDateTimeParts } from './src/date-time/date-time-adapter'; export { type ZvDateTimeFormats } from './src/date-time/date-time-formats'; export { ZvNativeDateTimeAdapter } from './src/date-time/native-date-time-adapter'; + +export { ZvActionDataSource, type IZvActionDataSource, type ZvActionDataSourceOptions } from './src/action-data-source/action-data-source'; diff --git a/projects/components/core/src/action-data-source/action-data-source.spec.ts b/projects/components/core/src/action-data-source/action-data-source.spec.ts new file mode 100644 index 0000000..6ad6a46 --- /dev/null +++ b/projects/components/core/src/action-data-source/action-data-source.spec.ts @@ -0,0 +1,53 @@ +import { fakeAsync, TestBed, tick } from '@angular/core/testing'; +import { switchMap, throwError, timer } from 'rxjs'; +import { ZvActionDataSource } from './action-data-source'; + +describe('ActionDataSource', () => { + beforeAll(() => { + TestBed.configureTestingModule({}); + }); + it('should set properties correctly', fakeAsync(() => { + TestBed.runInInjectionContext(() => { + const dataSource = new ZvActionDataSource({ actionFn: () => timer(1) }); + expect(dataSource.error()).toBe(null); + expect(dataSource.isLoading()).toBe(false); + expect(dataSource.hasError()).toBe(false); + expect(dataSource.succeeded()).toBe(false); + + dataSource.execute(); + expect(dataSource.error()).toBe(null); + expect(dataSource.isLoading()).toBe(true); + expect(dataSource.hasError()).toBe(false); + expect(dataSource.succeeded()).toBe(false); + + tick(1); + expect(dataSource.error()).toBe(null); + expect(dataSource.isLoading()).toBe(false); + expect(dataSource.hasError()).toBe(false); + expect(dataSource.succeeded()).toBe(true); + }); + })); + + it('should set error correctly', fakeAsync(() => { + TestBed.runInInjectionContext(() => { + const error = new Error('action failed'); + const dataSource = new ZvActionDataSource({ actionFn: () => timer(1).pipe(switchMap(() => throwError(() => error))) }); + expect(dataSource.error()).toBe(null); + expect(dataSource.isLoading()).toBe(false); + expect(dataSource.hasError()).toBe(false); + expect(dataSource.succeeded()).toBe(false); + + dataSource.execute(); + expect(dataSource.error()).toBe(null); + expect(dataSource.isLoading()).toBe(true); + expect(dataSource.hasError()).toBe(false); + expect(dataSource.succeeded()).toBe(false); + + tick(1); + expect(dataSource.error()).toBe(error); + expect(dataSource.isLoading()).toBe(false); + expect(dataSource.hasError()).toBe(true); + expect(dataSource.succeeded()).toBe(false); + }); + })); +}); diff --git a/projects/components/core/src/action-data-source/action-data-source.ts b/projects/components/core/src/action-data-source/action-data-source.ts new file mode 100644 index 0000000..dcac389 --- /dev/null +++ b/projects/components/core/src/action-data-source/action-data-source.ts @@ -0,0 +1,59 @@ +import { computed, DestroyRef, inject, Signal, signal } from '@angular/core'; +import { Observable, Subscription } from 'rxjs'; +import { first } from 'rxjs/operators'; + +export interface IZvActionDataSource { + readonly succeeded: Signal; + readonly isLoading: Signal; + readonly hasError: Signal; + readonly error: Signal; + execute(): void; +} + +export interface ZvActionDataSourceOptions { + actionFn: () => Observable; +} + +export class ZvActionDataSource implements IZvActionDataSource { + readonly #state = signal<{ state: 'idle' | 'loading' | 'success' | 'error'; error: unknown }>({ + state: 'idle', + error: null, + }); + readonly #destroyRef = inject(DestroyRef); + #actionSub = Subscription.EMPTY; + + constructor(private options: ZvActionDataSourceOptions) { + this.#destroyRef.onDestroy(() => this.#actionSub.unsubscribe()); + } + + public readonly error = computed(() => this.#state().error); + public readonly isLoading = computed(() => this.#state().state == 'loading'); + public readonly hasError = computed(() => this.#state().state == 'error'); + public readonly succeeded = computed(() => this.#state().state == 'success'); + + public execute() { + this.#actionSub.unsubscribe(); + this.#state.set({ + error: null, + state: 'loading', + }); + + let load$ = this.options.actionFn(); + load$ = load$.pipe(first()); + + this.#actionSub = load$.subscribe({ + next: () => { + this.#state.set({ + error: null, + state: 'success', + }); + }, + error: (err: unknown) => { + this.#state.set({ + error: err, + state: 'error', + }); + }, + }); + } +} diff --git a/projects/components/package.json b/projects/components/package.json index e74c325..1404087 100644 --- a/projects/components/package.json +++ b/projects/components/package.json @@ -1,7 +1,7 @@ { "name": "@zvoove/components", "description": "A set of angular components compatible with and/or dependent on @angular/material.", - "version": "19.2.2", + "version": "19.3.0", "license": "MIT", "repository": { "type": "git", diff --git a/projects/zvoove-components-demo/src/app/action-button-demo/action-button-demo.component.html b/projects/zvoove-components-demo/src/app/action-button-demo/action-button-demo.component.html new file mode 100644 index 0000000..aef0f02 --- /dev/null +++ b/projects/zvoove-components-demo/src/app/action-button-demo/action-button-demo.component.html @@ -0,0 +1,86 @@ + + + + Color input is currently only compatibil with m2. + + + + +
Loaded successfully {{ counter() }} times
+
+
+ +
+

Settings:

+ Load Error + Disable Button +
+
+

Try it:

+
+ @for (value of themePalletValues; track value) { + + {{ value }} example + + } +
+
+
+
+
+ + + + + + + + + + + + + + @angular/material card & icon + + +

+ The zv-action-button uses the + @angular/material button as + well as the + @angular/material icon and the + @angular/material progress-spinner + in its view. So @angular/material needs to be installed. +

+
+
+ + + Imports + + +

Add the following to your imports, where you want to use the zv-action-button:

+ +
+
+ + + Usage + + +

An example of how to use the zv-action-button:

+

TypeScript

+ +

HTML

+ +
+
+
+
diff --git a/projects/zvoove-components-demo/src/app/action-button-demo/action-button-demo.component.scss b/projects/zvoove-components-demo/src/app/action-button-demo/action-button-demo.component.scss new file mode 100644 index 0000000..ddbf136 --- /dev/null +++ b/projects/zvoove-components-demo/src/app/action-button-demo/action-button-demo.component.scss @@ -0,0 +1,5 @@ +.app-action-data-source-demo__demo-wrapper { + display: flex; + gap: 1em; + justify-content: space-between; +} diff --git a/projects/zvoove-components-demo/src/app/action-button-demo/action-button-demo.component.ts b/projects/zvoove-components-demo/src/app/action-button-demo/action-button-demo.component.ts new file mode 100644 index 0000000..88cf188 --- /dev/null +++ b/projects/zvoove-components-demo/src/app/action-button-demo/action-button-demo.component.ts @@ -0,0 +1,61 @@ +import { ChangeDetectionStrategy, Component, signal } from '@angular/core'; +import { MatCardModule } from '@angular/material/card'; +import { MatCheckboxModule } from '@angular/material/checkbox'; +import { ZvActionButtonComponent, ZvActionButtonDataSource } from '@zvoove/components/action-button'; +import { ZvButtonColors } from '@zvoove/components/core'; +import { of } from 'rxjs'; +import { delay, map } from 'rxjs/operators'; +import { allSharedImports } from '../common/shared-imports'; + +@Component({ + selector: 'app-action-button-demo', + templateUrl: './action-button-demo.component.html', + styleUrls: ['./action-button-demo.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [allSharedImports, MatCardModule, MatCheckboxModule, ZvActionButtonComponent], +}) +export class ActionButtonDemoComponent { + public loadError = false; + public readonly counter = signal(0); + public readonly isDisabled = signal(false); + + public dataSource = new ZvActionButtonDataSource({ + actionFn: () => { + return of('foo').pipe( + delay(1000), + map((x) => { + if (this.loadError) { + throw new Error('this is the server error (loading)'); + } + this.counter.update((c) => c + 1); + return x; + }) + ); + }, + }); + + public themePalletValues: (ZvButtonColors | null)[] = [null, 'primary', 'accent', 'warn']; + updateDisabled(isChecked: boolean) { + this.isDisabled.set(isChecked); + } + + importsCode = ` +import { ZvActionButtonComponent, ZvActionButtonDataSource } from '@zvoove/components/action-button'; +// ... +imports: [ + ZvActionButtonComponent, +],`; + + usageCodeTs = ` +private http = inject(HttpClient); +actionDataSource = new ZvActionButtonDataSource({ + actionFn: () => this.http.post('https://YOUR.API/POST-ROUTE', {}), +}); +public readonly isDisabled = signal(false); +public readonly color() = signal('primary'); +`; + + usageCodeHtml = ` +label +`; +} diff --git a/projects/zvoove-components-demo/src/app/app.component.html b/projects/zvoove-components-demo/src/app/app.component.html index ff23cfd..383ff50 100644 --- a/projects/zvoove-components-demo/src/app/app.component.html +++ b/projects/zvoove-components-demo/src/app/app.component.html @@ -35,6 +35,7 @@
Components
+ Action Button Block Ui Button Card diff --git a/projects/zvoove-components-demo/src/app/app.config.ts b/projects/zvoove-components-demo/src/app/app.config.ts index 98b9ac1..a524401 100644 --- a/projects/zvoove-components-demo/src/app/app.config.ts +++ b/projects/zvoove-components-demo/src/app/app.config.ts @@ -27,6 +27,10 @@ export const appConfig: ApplicationConfig = { { provide: LOCALE_ID, useValue: getUsersLocale(['en', 'de'], 'en-GB') }, provideHttpClient(withInterceptorsFromDi(), withFetch()), provideRouter([ + { + path: 'action-button', + loadComponent: () => import('./action-button-demo/action-button-demo.component').then((c) => c.ActionButtonDemoComponent), + }, { path: 'button', loadComponent: () => import('./button-demo/button-demo.page').then((m) => m.ButtonDemoPage), diff --git a/projects/zvoove-components-demo/src/app/upgrade-notes/upgrade-notes.page.html b/projects/zvoove-components-demo/src/app/upgrade-notes/upgrade-notes.page.html index 6e7ad0c..363a305 100644 --- a/projects/zvoove-components-demo/src/app/upgrade-notes/upgrade-notes.page.html +++ b/projects/zvoove-components-demo/src/app/upgrade-notes/upgrade-notes.page.html @@ -1,3 +1,6 @@ + + Added new zv-action-button component + fix: is24HourFormat not returning true for de-DE. diff --git a/projects/zvoove-components-demo/src/styles.scss b/projects/zvoove-components-demo/src/styles.scss index f85d081..15d419b 100644 --- a/projects/zvoove-components-demo/src/styles.scss +++ b/projects/zvoove-components-demo/src/styles.scss @@ -73,6 +73,7 @@ $theme: mat.define-theme( // that you are using. @include mat.all-component-themes($theme); @include comp.zvoove-components-theme($theme); + @include mat.color-variants-backwards-compatibility($theme); } html,