From 2faeda0700c39ee919363d2234d492e6cf21af2e Mon Sep 17 00:00:00 2001 From: Suneeh Date: Thu, 27 Mar 2025 22:02:20 +0100 Subject: [PATCH 01/11] feat: ActionButton and DataSource --- projects/components/action-button/index.ts | 3 + .../components/action-button/ng-package.json | 5 ++ .../components/action-button/public_api.ts | 2 + .../src/action-button.component.html | 19 +++++ .../src/action-button.component.scss | 8 ++ .../src/action-button.component.ts | 28 +++++++ .../src/action-data-source.spec.ts | 46 +++++++++++ .../action-button/src/action-data-source.ts | 51 ++++++++++++ projects/components/package.json | 2 +- .../action-button-demo.component.html | 75 ++++++++++++++++++ .../action-button-demo.component.scss | 5 ++ .../action-button-demo.component.ts | 78 +++++++++++++++++++ .../src/app/app.component.html | 1 + .../src/app/app.config.ts | 4 + .../app/upgrade-notes/upgrade-notes.page.html | 4 + 15 files changed, 330 insertions(+), 1 deletion(-) create mode 100644 projects/components/action-button/index.ts create mode 100644 projects/components/action-button/ng-package.json create mode 100644 projects/components/action-button/public_api.ts create mode 100644 projects/components/action-button/src/action-button.component.html create mode 100644 projects/components/action-button/src/action-button.component.scss create mode 100644 projects/components/action-button/src/action-button.component.ts create mode 100644 projects/components/action-button/src/action-data-source.spec.ts create mode 100644 projects/components/action-button/src/action-data-source.ts create mode 100644 projects/zvoove-components-demo/src/app/action-button-demo/action-button-demo.component.html create mode 100644 projects/zvoove-components-demo/src/app/action-button-demo/action-button-demo.component.scss create mode 100644 projects/zvoove-components-demo/src/app/action-button-demo/action-button-demo.component.ts 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..72ce6c5 --- /dev/null +++ b/projects/components/action-button/public_api.ts @@ -0,0 +1,2 @@ +export { ZvActionButtonComponent, type IZvActionButton } from './src/action-button.component'; +export { ZvActionDataSource, type ZvActionDataSourceOptions } from './src/action-data-source'; 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..04aee9b --- /dev/null +++ b/projects/components/action-button/src/action-button.component.html @@ -0,0 +1,19 @@ + + + @if (actionDs().succeeded()) { + check_circle + } + @if (actionDs().hasError()) { +
{{ actionDs().exception().errorObject | zvErrorMessage }}
+ } +
diff --git a/projects/components/action-button/src/action-button.component.scss b/projects/components/action-button/src/action-button.component.scss new file mode 100644 index 0000000..ff31128 --- /dev/null +++ b/projects/components/action-button/src/action-button.component.scss @@ -0,0 +1,8 @@ +.app-action-button__check { + color: green; + vertical-align: middle; +} + +.app-action-button__error { + color: var(--zv-components-error); +} 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..0efec54 --- /dev/null +++ b/projects/components/action-button/src/action-button.component.ts @@ -0,0 +1,28 @@ +import { ChangeDetectionStrategy, Component, input, Signal } from '@angular/core'; +import { ThemePalette } from '@angular/material/core'; +import { ZvErrorMessagePipe } from '@zvoove/components/core'; +import { MatLabel } from '@angular/material/input'; +import { ZvBlockUi } from '@zvoove/components/block-ui'; +import { MatIcon } from '@angular/material/icon'; +import { MatButtonModule } from '@angular/material/button'; +import { ZvActionDataSource } from './action-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', + styleUrl: './action-button.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [MatLabel, ZvErrorMessagePipe, ZvBlockUi, MatIcon, MatButtonModule], +}) +export class ZvActionButtonComponent { + public actionDs = input.required>(); + public button = input.required(); +} diff --git a/projects/components/action-button/src/action-data-source.spec.ts b/projects/components/action-button/src/action-data-source.spec.ts new file mode 100644 index 0000000..b443cda --- /dev/null +++ b/projects/components/action-button/src/action-data-source.spec.ts @@ -0,0 +1,46 @@ +import { fakeAsync, tick } from '@angular/core/testing'; +import { switchMap, throwError, timer } from 'rxjs'; +import { ZvActionDataSource } from './action-data-source'; + +describe('ActionDataSource', () => { + it('should set properties correctly', fakeAsync(() => { + const dataSource = new ZvActionDataSource({ actionFn: () => timer(1) }); + expect(dataSource.exception()).toBe(null); + expect(dataSource.pending()).toBe(false); + expect(dataSource.hasError()).toBe(false); + expect(dataSource.succeeded()).toBe(false); + + dataSource.execute(); + expect(dataSource.exception()).toBe(null); + expect(dataSource.pending()).toBe(true); + expect(dataSource.hasError()).toBe(false); + expect(dataSource.succeeded()).toBe(false); + + tick(1); + expect(dataSource.exception()).toBe(null); + expect(dataSource.pending()).toBe(false); + expect(dataSource.hasError()).toBe(false); + expect(dataSource.succeeded()).toBe(true); + })); + + it('should set error correctly', fakeAsync(() => { + const error = new Error('action failed'); + const dataSource = new ZvActionDataSource({ actionFn: () => timer(1).pipe(switchMap(() => throwError(() => error))) }); + expect(dataSource.exception()).toBe(null); + expect(dataSource.pending()).toBe(false); + expect(dataSource.hasError()).toBe(false); + expect(dataSource.succeeded()).toBe(false); + + dataSource.execute(); + expect(dataSource.exception()).toBe(null); + expect(dataSource.pending()).toBe(true); + expect(dataSource.hasError()).toBe(false); + expect(dataSource.succeeded()).toBe(false); + + tick(1); + expect(dataSource.exception()?.errorObject).toBe(error); + expect(dataSource.pending()).toBe(false); + expect(dataSource.hasError()).toBe(true); + expect(dataSource.succeeded()).toBe(false); + })); +}); diff --git a/projects/components/action-button/src/action-data-source.ts b/projects/components/action-button/src/action-data-source.ts new file mode 100644 index 0000000..657ddec --- /dev/null +++ b/projects/components/action-button/src/action-data-source.ts @@ -0,0 +1,51 @@ +import { signal } from '@angular/core'; +import { IZvException } from '@zvoove/components/core'; +import { Observable, Subscription } from 'rxjs'; +import { first } from 'rxjs/operators'; + +export interface ZvActionDataSourceOptions { + actionFn: () => Observable; +} + +export class ZvActionDataSource { + private _exception = signal(null); + private _pending = signal(false); + private _hasError = signal(false); + private _succeeded = signal(false); + + private actionSub = Subscription.EMPTY; + + constructor(private options: ZvActionDataSourceOptions) {} + + public readonly exception = this._exception.asReadonly(); + public readonly pending = this._pending.asReadonly(); + public readonly hasError = this._hasError.asReadonly(); + public readonly succeeded = this._succeeded.asReadonly(); + + public execute() { + this.actionSub.unsubscribe(); + this._pending.set(true); + this._hasError.set(false); + this._exception.set(null); + this._succeeded.set(false); + + let load$ = this.options.actionFn(); + load$ = load$.pipe(first()); + + this.actionSub = load$.subscribe({ + next: () => { + this._pending.set(false); + this._succeeded.set(true); + }, + error: (err: unknown) => { + this._pending.set(false); + this._hasError.set(true); + this._exception.set({ + errorObject: err, + alignCenter: true, + icon: 'sentiment_very_dissatisfied', + }); + }, + }); + } +} diff --git a/projects/components/package.json b/projects/components/package.json index e74c325..90eda66 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.2.3", "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..ddd8f47 --- /dev/null +++ b/projects/zvoove-components-demo/src/app/action-button-demo/action-button-demo.component.html @@ -0,0 +1,75 @@ + + + + Color input is currently only compatibil with for m2. + + + + +
Loaded successfully {{ counter() }} times
+
+
+ +
+

Settings:

+ Load Error + Disable Button +
+
+

Try it:

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

+ The zv-action-button uses the + @angular/material button as + well as the + @angular/material icon and the + @angular/material label 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 IZvActionButton:

+

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..c4d0e06 --- /dev/null +++ b/projects/zvoove-components-demo/src/app/action-button-demo/action-button-demo.component.ts @@ -0,0 +1,78 @@ +import { ChangeDetectionStrategy, Component, signal } from '@angular/core'; +import { MatCardModule } from '@angular/material/card'; +import { MatCheckboxModule } from '@angular/material/checkbox'; +import { ThemePalette } from '@angular/material/core'; +import { IZvActionButton, ZvActionButtonComponent, ZvActionDataSource } from '@zvoove/components/action-button'; +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 color: ThemePalette | null = null; + public readonly counter = signal(0); + public readonly isDisabled = signal(false); + + public dataSource = new ZvActionDataSource({ + 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: (ThemePalette | null)[] = [null, 'primary', 'accent', 'warn']; + updateDisabled(isChecked: boolean) { + this.isDisabled.set(isChecked); + } + + getActionButton(color: ThemePalette | null): IZvActionButton { + return { + label: `I am an IZvActionButton (color: ${color})`, + color: color, + icon: 'home', + dataCy: 'test', + isDisabled: this.isDisabled, + }; + } + + importsCode = ` +import { IZvActionButton, ZvActionButtonComponent, ZvActionDataSource } from '@zvoove/components/action-button'; +// ... +imports: [ + ZvActionButtonComponent, +],`; + + usageCodeTs = ` +private http = inject(HttpClient); +actionDataSource = new ZvActionDataSource({ + actionFn: () => this.http.post('https://YOUR.API/POST-ROUTE', {}), +}); + +public actionButton: IZvActionButton = { + label: '', + color: null, + icon: 'home', + dataCy: 'testsWillFindMe', + isDisabled: this.isDisabled, +}; +`; + + usageCodeHtml = ` + +`; +} 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..0685592 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,7 @@ + + action: Add ActionButton + action: Add ActionDatasource + fix: is24HourFormat not returning true for de-DE. From c0c419b9d72092edc3b000dfeec6963f7d3b4759 Mon Sep 17 00:00:00 2001 From: Suneeh Date: Thu, 3 Apr 2025 19:11:21 +0200 Subject: [PATCH 02/11] adds harness + tests for ActionButtonComponent --- .../src/action-button.component.html | 4 +- .../src/action-button.component.scss | 4 +- .../src/action-button.component.spec.ts | 118 ++++++++++++++++++ .../src/action-button.component.ts | 10 +- .../src/action-button.harness.ts | 36 ++++++ .../src/action-data-source.spec.ts | 2 +- .../action-button/src/action-data-source.ts | 15 +-- .../block-ui/src/block-ui.component.spec.ts | 2 +- 8 files changed, 170 insertions(+), 21 deletions(-) create mode 100644 projects/components/action-button/src/action-button.component.spec.ts create mode 100644 projects/components/action-button/src/action-button.harness.ts diff --git a/projects/components/action-button/src/action-button.component.html b/projects/components/action-button/src/action-button.component.html index 04aee9b..0364c10 100644 --- a/projects/components/action-button/src/action-button.component.html +++ b/projects/components/action-button/src/action-button.component.html @@ -11,9 +11,9 @@ {{ button().label }} @if (actionDs().succeeded()) { - check_circle + check_circle } @if (actionDs().hasError()) { -
{{ actionDs().exception().errorObject | zvErrorMessage }}
+
{{ actionDs().exception() | zvErrorMessage }}
} diff --git a/projects/components/action-button/src/action-button.component.scss b/projects/components/action-button/src/action-button.component.scss index ff31128..eccb777 100644 --- a/projects/components/action-button/src/action-button.component.scss +++ b/projects/components/action-button/src/action-button.component.scss @@ -1,8 +1,8 @@ -.app-action-button__check { +.zv-action-button__check { color: green; vertical-align: middle; } -.app-action-button__error { +.zv-action-button__error { color: var(--zv-components-error); } 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..5770fc2 --- /dev/null +++ b/projects/components/action-button/src/action-button.component.spec.ts @@ -0,0 +1,118 @@ +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 { switchMap, throwError, timer } from 'rxjs'; +import { IZvActionButton, ZvActionButtonComponent } from './action-button.component'; +import { ZvActionButtonHarness } from './action-button.harness'; +import { ZvActionDataSource } from './action-data-source'; + +@Component({ + selector: 'zv-test-component', + template: ``, + // 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); + dataSource = new ZvActionDataSource({ + actionFn: () => timer(1), + }); + + actionButton: IZvActionButton = { + label: `label`, + color: 'primary', + icon: 'home', + dataCy: 'dataCyTest', + isDisabled: this.isDisabled, + }; +} +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 be blocked while pending', async () => { + const button = await harness.getButton(); + button?.click(); + expect(component.dataSource.pending()).toBeTrue(); + expect(await harness.isBlocked()).toBeTrue(); + }); + + it('should be disabled while pending', async () => { + const button = await harness.getButton(); + button?.click(); + expect(component.dataSource.pending()).toBeTrue(); + expect(await button!.getProperty('disabled')).toBeTrue(); + }); + + it('should respect disabled property', async () => { + const button = await harness.getButton(); + expect(await button?.getProperty('disabled')).toBeFalse(); + component.isDisabled.set(true); + expect(await button!.getProperty('disabled')).toBeTrue(); + component.isDisabled.set(false); + expect(await button?.getProperty('disabled')).toBeFalse(); + }); + + it('should respect color property', async () => { + const button = await harness.getButton(); + expect(await button?.hasClass('mat-primary')).toBeTrue(); + component.actionButton.color = 'accent'; + component.actionButton = { ...component.actionButton, color: 'accent' }; + expect(await button?.hasClass('mat-accent')).toBeTrue(); + component.actionButton = { ...component.actionButton, color: 'warn' }; + expect(await button?.hasClass('mat-warn')).toBeTrue(); + }); + + it('should have dataCy attribute', async () => { + const button = await harness.getButton(); + expect(await button?.getAttribute('data-cy')).toBe('dataCyTest'); + }); + + it('should show icon', async () => { + const icon = await harness.getButtonIcon(); + expect(await icon?.text()).toBe('home'); + }); + + it('should show label', async () => { + const label = await harness.getButtonLabel(); + expect(await label?.text()).toBe('label'); + }); + + it('should show success icon', fakeAsync(async () => { + const button = await harness.getButton(); + button?.click(); + tick(1); + expect(component.dataSource.pending()).toBeFalse(); + expect(component.dataSource.succeeded()).toBeTrue(); + expect(await harness.showsSuccess()).toBeTrue(); + })); + + it('should show error message', fakeAsync(async () => { + const error = new Error('action failed'); + component.dataSource = new ZvActionDataSource({ actionFn: () => timer(1).pipe(switchMap(() => throwError(() => error))) }); + const button = await harness.getButton(); + button?.click(); + tick(1); + expect(await harness.showsSuccess()).toBeFalse(); + expect(await (await harness.getError())?.text()).toBe(error.message); + })); +}); diff --git a/projects/components/action-button/src/action-button.component.ts b/projects/components/action-button/src/action-button.component.ts index 0efec54..dbc46ea 100644 --- a/projects/components/action-button/src/action-button.component.ts +++ b/projects/components/action-button/src/action-button.component.ts @@ -1,10 +1,10 @@ import { ChangeDetectionStrategy, Component, input, Signal } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; import { ThemePalette } from '@angular/material/core'; -import { ZvErrorMessagePipe } from '@zvoove/components/core'; +import { MatIcon } from '@angular/material/icon'; import { MatLabel } from '@angular/material/input'; import { ZvBlockUi } from '@zvoove/components/block-ui'; -import { MatIcon } from '@angular/material/icon'; -import { MatButtonModule } from '@angular/material/button'; +import { ZvErrorMessagePipe } from '@zvoove/components/core'; import { ZvActionDataSource } from './action-data-source'; export interface IZvActionButton { @@ -23,6 +23,6 @@ export interface IZvActionButton { imports: [MatLabel, ZvErrorMessagePipe, ZvBlockUi, MatIcon, MatButtonModule], }) export class ZvActionButtonComponent { - public actionDs = input.required>(); - public button = input.required(); + public readonly actionDs = input.required>(); + public readonly button = input.required(); } 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..db46fb9 --- /dev/null +++ b/projects/components/action-button/src/action-button.harness.ts @@ -0,0 +1,36 @@ +import { ComponentHarness, TestElement } 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 _buttonLabel = this.locatorForOptional('button mat-label'); + private _blockOverlay = this.locatorForOptional('.zv-block-ui__overlay'); + private _successDiv = this.locatorForOptional('.zv-action-button__check'); + private _errorDiv = this.locatorForOptional('.zv-action-button__error'); + + public async getButton(): Promise { + return await this._button(); + } + + public async getButtonIcon(): Promise { + return await this._buttonIcon(); + } + + public async getButtonLabel(): Promise { + return await this._buttonLabel(); + } + + public async showsSuccess(): Promise { + return !!(await this._successDiv()); + } + + public async getError(): Promise { + return await this._errorDiv(); + } + + public async isBlocked(): Promise { + return !!(await this._blockOverlay()); + } +} diff --git a/projects/components/action-button/src/action-data-source.spec.ts b/projects/components/action-button/src/action-data-source.spec.ts index b443cda..2d8086d 100644 --- a/projects/components/action-button/src/action-data-source.spec.ts +++ b/projects/components/action-button/src/action-data-source.spec.ts @@ -38,7 +38,7 @@ describe('ActionDataSource', () => { expect(dataSource.succeeded()).toBe(false); tick(1); - expect(dataSource.exception()?.errorObject).toBe(error); + expect(dataSource.exception()).toBe(error); expect(dataSource.pending()).toBe(false); expect(dataSource.hasError()).toBe(true); expect(dataSource.succeeded()).toBe(false); diff --git a/projects/components/action-button/src/action-data-source.ts b/projects/components/action-button/src/action-data-source.ts index 657ddec..857862e 100644 --- a/projects/components/action-button/src/action-data-source.ts +++ b/projects/components/action-button/src/action-data-source.ts @@ -1,5 +1,4 @@ import { signal } from '@angular/core'; -import { IZvException } from '@zvoove/components/core'; import { Observable, Subscription } from 'rxjs'; import { first } from 'rxjs/operators'; @@ -8,10 +7,10 @@ export interface ZvActionDataSourceOptions { } export class ZvActionDataSource { - private _exception = signal(null); - private _pending = signal(false); - private _hasError = signal(false); - private _succeeded = signal(false); + private readonly _exception = signal(null); + private readonly _pending = signal(false); + private readonly _hasError = signal(false); + private readonly _succeeded = signal(false); private actionSub = Subscription.EMPTY; @@ -40,11 +39,7 @@ export class ZvActionDataSource { error: (err: unknown) => { this._pending.set(false); this._hasError.set(true); - this._exception.set({ - errorObject: err, - alignCenter: true, - icon: 'sentiment_very_dissatisfied', - }); + this._exception.set(err); }, }); } 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', () => { From cef338017af057cf6f572001aeccfd55504c8073 Mon Sep 17 00:00:00 2001 From: Suneeh Date: Thu, 3 Apr 2025 19:26:06 +0200 Subject: [PATCH 03/11] review comments --- .../action-button/src/action-button.component.html | 4 ++-- .../action-button/src/action-button.component.spec.ts | 4 ++-- .../components/action-button/src/action-button.component.ts | 3 +-- .../components/action-button/src/action-button.harness.ts | 5 ++--- 4 files changed, 7 insertions(+), 9 deletions(-) diff --git a/projects/components/action-button/src/action-button.component.html b/projects/components/action-button/src/action-button.component.html index 0364c10..aaa02f8 100644 --- a/projects/components/action-button/src/action-button.component.html +++ b/projects/components/action-button/src/action-button.component.html @@ -1,14 +1,14 @@ @if (actionDs().succeeded()) { check_circle diff --git a/projects/components/action-button/src/action-button.component.spec.ts b/projects/components/action-button/src/action-button.component.spec.ts index 5770fc2..96e4399 100644 --- a/projects/components/action-button/src/action-button.component.spec.ts +++ b/projects/components/action-button/src/action-button.component.spec.ts @@ -93,8 +93,8 @@ describe('ZvActionButton', () => { }); it('should show label', async () => { - const label = await harness.getButtonLabel(); - expect(await label?.text()).toBe('label'); + const buttonContent = await harness.getButtonContent(); + expect(buttonContent).toContain('label'); }); it('should show success icon', fakeAsync(async () => { diff --git a/projects/components/action-button/src/action-button.component.ts b/projects/components/action-button/src/action-button.component.ts index dbc46ea..0fedf54 100644 --- a/projects/components/action-button/src/action-button.component.ts +++ b/projects/components/action-button/src/action-button.component.ts @@ -2,7 +2,6 @@ import { ChangeDetectionStrategy, Component, input, Signal } from '@angular/core import { MatButtonModule } from '@angular/material/button'; import { ThemePalette } from '@angular/material/core'; import { MatIcon } from '@angular/material/icon'; -import { MatLabel } from '@angular/material/input'; import { ZvBlockUi } from '@zvoove/components/block-ui'; import { ZvErrorMessagePipe } from '@zvoove/components/core'; import { ZvActionDataSource } from './action-data-source'; @@ -20,7 +19,7 @@ export interface IZvActionButton { templateUrl: './action-button.component.html', styleUrl: './action-button.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, - imports: [MatLabel, ZvErrorMessagePipe, ZvBlockUi, MatIcon, MatButtonModule], + imports: [ZvErrorMessagePipe, ZvBlockUi, MatIcon, MatButtonModule], }) export class ZvActionButtonComponent { public readonly actionDs = input.required>(); diff --git a/projects/components/action-button/src/action-button.harness.ts b/projects/components/action-button/src/action-button.harness.ts index db46fb9..229976f 100644 --- a/projects/components/action-button/src/action-button.harness.ts +++ b/projects/components/action-button/src/action-button.harness.ts @@ -5,7 +5,6 @@ export class ZvActionButtonHarness extends ComponentHarness { private _button = this.locatorForOptional('button'); private _buttonIcon = this.locatorForOptional('button mat-icon'); - private _buttonLabel = this.locatorForOptional('button mat-label'); private _blockOverlay = this.locatorForOptional('.zv-block-ui__overlay'); private _successDiv = this.locatorForOptional('.zv-action-button__check'); private _errorDiv = this.locatorForOptional('.zv-action-button__error'); @@ -18,8 +17,8 @@ export class ZvActionButtonHarness extends ComponentHarness { return await this._buttonIcon(); } - public async getButtonLabel(): Promise { - return await this._buttonLabel(); + public async getButtonContent(): Promise { + return (await (await this._button())?.text()) ?? null; } public async showsSuccess(): Promise { From 6e7682008c83b8e7cf4bf53f5544d1eac5aae31a Mon Sep 17 00:00:00 2001 From: Suneeh Date: Thu, 3 Apr 2025 20:17:52 +0200 Subject: [PATCH 04/11] extend harness --- .../components/action-button/public_api.ts | 2 +- .../src/action-button.component.spec.ts | 46 +++++++++---------- .../src/action-button.component.ts | 6 +-- .../src/action-button.harness.ts | 21 +++++++-- .../action-button/src/action-data-source.ts | 17 ++++--- 5 files changed, 54 insertions(+), 38 deletions(-) diff --git a/projects/components/action-button/public_api.ts b/projects/components/action-button/public_api.ts index 72ce6c5..6c243da 100644 --- a/projects/components/action-button/public_api.ts +++ b/projects/components/action-button/public_api.ts @@ -1,2 +1,2 @@ export { ZvActionButtonComponent, type IZvActionButton } from './src/action-button.component'; -export { ZvActionDataSource, type ZvActionDataSourceOptions } from './src/action-data-source'; +export { ZvActionDataSource, type IZvActionDataSource, type ZvActionDataSourceOptions } from './src/action-data-source'; diff --git a/projects/components/action-button/src/action-button.component.spec.ts b/projects/components/action-button/src/action-button.component.spec.ts index 96e4399..a93eb4a 100644 --- a/projects/components/action-button/src/action-button.component.spec.ts +++ b/projects/components/action-button/src/action-button.component.spec.ts @@ -2,7 +2,7 @@ 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 { switchMap, throwError, timer } from 'rxjs'; +import { switchMap, tap, throwError, timer } from 'rxjs'; import { IZvActionButton, ZvActionButtonComponent } from './action-button.component'; import { ZvActionButtonHarness } from './action-button.harness'; import { ZvActionDataSource } from './action-data-source'; @@ -16,8 +16,9 @@ import { ZvActionDataSource } from './action-data-source'; }) export class TestComponent { public readonly isDisabled = signal(false); + public actionFnCalled = false; dataSource = new ZvActionDataSource({ - actionFn: () => timer(1), + actionFn: () => timer(1).pipe(tap(() => (this.actionFnCalled = true))), }); actionButton: IZvActionButton = { @@ -49,47 +50,46 @@ describe('ZvActionButton', () => { 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 pending', async () => { - const button = await harness.getButton(); - button?.click(); + component.dataSource.execute(); expect(component.dataSource.pending()).toBeTrue(); expect(await harness.isBlocked()).toBeTrue(); }); it('should be disabled while pending', async () => { - const button = await harness.getButton(); - button?.click(); + component.dataSource.execute(); expect(component.dataSource.pending()).toBeTrue(); - expect(await button!.getProperty('disabled')).toBeTrue(); + expect(await harness.isDisabled()).toBeTrue(); }); it('should respect disabled property', async () => { - const button = await harness.getButton(); - expect(await button?.getProperty('disabled')).toBeFalse(); + expect(await harness?.isDisabled()).toBeFalse(); component.isDisabled.set(true); - expect(await button!.getProperty('disabled')).toBeTrue(); + expect(await harness?.isDisabled()).toBeTrue(); component.isDisabled.set(false); - expect(await button?.getProperty('disabled')).toBeFalse(); + expect(await harness?.isDisabled()).toBeFalse(); }); it('should respect color property', async () => { - const button = await harness.getButton(); - expect(await button?.hasClass('mat-primary')).toBeTrue(); - component.actionButton.color = 'accent'; + expect(await harness.hasClass('mat-primary')).toBeTrue(); component.actionButton = { ...component.actionButton, color: 'accent' }; - expect(await button?.hasClass('mat-accent')).toBeTrue(); + expect(await harness.hasClass('mat-accent')).toBeTrue(); component.actionButton = { ...component.actionButton, color: 'warn' }; - expect(await button?.hasClass('mat-warn')).toBeTrue(); + expect(await harness.hasClass('mat-warn')).toBeTrue(); }); it('should have dataCy attribute', async () => { - const button = await harness.getButton(); - expect(await button?.getAttribute('data-cy')).toBe('dataCyTest'); + expect(await harness.getDataCy()).toBe('dataCyTest'); }); it('should show icon', async () => { - const icon = await harness.getButtonIcon(); - expect(await icon?.text()).toBe('home'); + expect(await harness.getButtonIcon()).toBe('home'); }); it('should show label', async () => { @@ -98,8 +98,7 @@ describe('ZvActionButton', () => { }); it('should show success icon', fakeAsync(async () => { - const button = await harness.getButton(); - button?.click(); + await harness.click(); tick(1); expect(component.dataSource.pending()).toBeFalse(); expect(component.dataSource.succeeded()).toBeTrue(); @@ -109,8 +108,7 @@ describe('ZvActionButton', () => { it('should show error message', fakeAsync(async () => { const error = new Error('action failed'); component.dataSource = new ZvActionDataSource({ actionFn: () => timer(1).pipe(switchMap(() => throwError(() => error))) }); - const button = await harness.getButton(); - button?.click(); + await harness.click(); tick(1); expect(await harness.showsSuccess()).toBeFalse(); expect(await (await harness.getError())?.text()).toBe(error.message); diff --git a/projects/components/action-button/src/action-button.component.ts b/projects/components/action-button/src/action-button.component.ts index 0fedf54..71efd82 100644 --- a/projects/components/action-button/src/action-button.component.ts +++ b/projects/components/action-button/src/action-button.component.ts @@ -4,7 +4,7 @@ import { ThemePalette } from '@angular/material/core'; import { MatIcon } from '@angular/material/icon'; import { ZvBlockUi } from '@zvoove/components/block-ui'; import { ZvErrorMessagePipe } from '@zvoove/components/core'; -import { ZvActionDataSource } from './action-data-source'; +import { IZvActionDataSource } from './action-data-source'; export interface IZvActionButton { label: string; @@ -21,7 +21,7 @@ export interface IZvActionButton { changeDetection: ChangeDetectionStrategy.OnPush, imports: [ZvErrorMessagePipe, ZvBlockUi, MatIcon, MatButtonModule], }) -export class ZvActionButtonComponent { - public readonly actionDs = input.required>(); +export class ZvActionButtonComponent { + public readonly actionDs = input.required(); public readonly button = input.required(); } diff --git a/projects/components/action-button/src/action-button.harness.ts b/projects/components/action-button/src/action-button.harness.ts index 229976f..54a12e5 100644 --- a/projects/components/action-button/src/action-button.harness.ts +++ b/projects/components/action-button/src/action-button.harness.ts @@ -9,12 +9,13 @@ export class ZvActionButtonHarness extends ComponentHarness { private _successDiv = this.locatorForOptional('.zv-action-button__check'); private _errorDiv = this.locatorForOptional('.zv-action-button__error'); - public async getButton(): Promise { - return await this._button(); + public async click(): Promise { + const button = await this._button(); + return await button?.click(); } - public async getButtonIcon(): Promise { - return await this._buttonIcon(); + public async getButtonIcon(): Promise { + return await (await this._buttonIcon())?.text(); } public async getButtonContent(): Promise { @@ -32,4 +33,16 @@ export class ZvActionButtonHarness extends ComponentHarness { public async isBlocked(): Promise { return !!(await this._blockOverlay()); } + + 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 getDataCy(): Promise { + return (await (await this._button())?.getAttribute('data-cy')) ?? ''; + } } diff --git a/projects/components/action-button/src/action-data-source.ts b/projects/components/action-button/src/action-data-source.ts index 857862e..d963b9d 100644 --- a/projects/components/action-button/src/action-data-source.ts +++ b/projects/components/action-button/src/action-data-source.ts @@ -1,15 +1,22 @@ -import { signal } from '@angular/core'; +import { computed, Signal, signal } from '@angular/core'; import { Observable, Subscription } from 'rxjs'; import { first } from 'rxjs/operators'; +export interface IZvActionDataSource { + readonly succeeded: Signal; + readonly pending: Signal; + readonly hasError: Signal; + readonly exception: Signal; + execute(): void; +} + export interface ZvActionDataSourceOptions { actionFn: () => Observable; } -export class ZvActionDataSource { +export class ZvActionDataSource implements IZvActionDataSource { private readonly _exception = signal(null); private readonly _pending = signal(false); - private readonly _hasError = signal(false); private readonly _succeeded = signal(false); private actionSub = Subscription.EMPTY; @@ -18,13 +25,12 @@ export class ZvActionDataSource { public readonly exception = this._exception.asReadonly(); public readonly pending = this._pending.asReadonly(); - public readonly hasError = this._hasError.asReadonly(); + public readonly hasError = computed(() => this._exception() !== null); public readonly succeeded = this._succeeded.asReadonly(); public execute() { this.actionSub.unsubscribe(); this._pending.set(true); - this._hasError.set(false); this._exception.set(null); this._succeeded.set(false); @@ -38,7 +44,6 @@ export class ZvActionDataSource { }, error: (err: unknown) => { this._pending.set(false); - this._hasError.set(true); this._exception.set(err); }, }); From 016cab7e54241c697b49019bd4c6f1b517f8ee2e Mon Sep 17 00:00:00 2001 From: Suneeh Date: Fri, 4 Apr 2025 15:30:22 +0200 Subject: [PATCH 05/11] integrate icon and error into button --- .../src/action-button.component.html | 38 ++++++++++--------- .../src/action-button.component.scss | 11 ++---- .../src/action-button.component.spec.ts | 6 +-- .../src/action-button.component.ts | 26 +++++++++++-- .../src/action-button.harness.ts | 18 +++------ .../src/action-data-source.spec.ts | 2 +- .../action-button/src/action-data-source.ts | 10 +++-- 7 files changed, 64 insertions(+), 47 deletions(-) diff --git a/projects/components/action-button/src/action-button.component.html b/projects/components/action-button/src/action-button.component.html index aaa02f8..560ee28 100644 --- a/projects/components/action-button/src/action-button.component.html +++ b/projects/components/action-button/src/action-button.component.html @@ -1,19 +1,21 @@ - - - @if (actionDs().succeeded()) { - check_circle - } - @if (actionDs().hasError()) { -
{{ actionDs().exception() | zvErrorMessage }}
- } -
+ + diff --git a/projects/components/action-button/src/action-button.component.scss b/projects/components/action-button/src/action-button.component.scss index eccb777..32dd2e8 100644 --- a/projects/components/action-button/src/action-button.component.scss +++ b/projects/components/action-button/src/action-button.component.scss @@ -1,8 +1,5 @@ -.zv-action-button__check { - color: green; - vertical-align: middle; -} - -.zv-action-button__error { - color: var(--zv-components-error); +.zv-action-button__button-content { + display: flex; + gap: 0.5em; + align-items: center; } diff --git a/projects/components/action-button/src/action-button.component.spec.ts b/projects/components/action-button/src/action-button.component.spec.ts index a93eb4a..43688ef 100644 --- a/projects/components/action-button/src/action-button.component.spec.ts +++ b/projects/components/action-button/src/action-button.component.spec.ts @@ -102,7 +102,7 @@ describe('ZvActionButton', () => { tick(1); expect(component.dataSource.pending()).toBeFalse(); expect(component.dataSource.succeeded()).toBeTrue(); - expect(await harness.showsSuccess()).toBeTrue(); + expect(await harness.getButtonIcon()).toBe('check_circle'); })); it('should show error message', fakeAsync(async () => { @@ -110,7 +110,7 @@ describe('ZvActionButton', () => { component.dataSource = new ZvActionDataSource({ actionFn: () => timer(1).pipe(switchMap(() => throwError(() => error))) }); await harness.click(); tick(1); - expect(await harness.showsSuccess()).toBeFalse(); - expect(await (await harness.getError())?.text()).toBe(error.message); + expect(await harness.getButtonIcon()).toBe('cancel'); + expect(await harness.getExceptionMessage()).toBe(error.message); })); }); diff --git a/projects/components/action-button/src/action-button.component.ts b/projects/components/action-button/src/action-button.component.ts index 71efd82..3ec424c 100644 --- a/projects/components/action-button/src/action-button.component.ts +++ b/projects/components/action-button/src/action-button.component.ts @@ -1,10 +1,11 @@ -import { ChangeDetectionStrategy, Component, input, Signal } from '@angular/core'; +import { afterRenderEffect, ChangeDetectionStrategy, Component, effect, ElementRef, input, signal, Signal, viewChild } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; import { ThemePalette } from '@angular/material/core'; import { MatIcon } from '@angular/material/icon'; -import { ZvBlockUi } from '@zvoove/components/block-ui'; +import { MatProgressSpinner } from '@angular/material/progress-spinner'; import { ZvErrorMessagePipe } from '@zvoove/components/core'; import { IZvActionDataSource } from './action-data-source'; +import { MatTooltip } from '@angular/material/tooltip'; export interface IZvActionButton { label: string; @@ -19,9 +20,28 @@ export interface IZvActionButton { templateUrl: './action-button.component.html', styleUrl: './action-button.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, - imports: [ZvErrorMessagePipe, ZvBlockUi, MatIcon, MatButtonModule], + imports: [ZvErrorMessagePipe, MatIcon, MatButtonModule, MatProgressSpinner, MatTooltip], }) export class ZvActionButtonComponent { public readonly actionDs = input.required(); public readonly button = input.required(); + public readonly contentNode = viewChild.required>('content'); + public readonly spinnerDiameter = signal(20); + public readonly showSuccessIcon = signal(false); + + constructor() { + afterRenderEffect(() => { + if (this.actionDs().pending()) { + this.spinnerDiameter.set(this.contentNode().nativeElement.offsetHeight); + } + }); + effect(() => { + if (this.actionDs().succeeded()) { + this.showSuccessIcon.set(true); + setTimeout(() => { + this.showSuccessIcon.set(false); + }, 2000); + } + }); + } } diff --git a/projects/components/action-button/src/action-button.harness.ts b/projects/components/action-button/src/action-button.harness.ts index 54a12e5..38e8c82 100644 --- a/projects/components/action-button/src/action-button.harness.ts +++ b/projects/components/action-button/src/action-button.harness.ts @@ -1,13 +1,11 @@ -import { ComponentHarness, TestElement } from '@angular/cdk/testing'; +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 _blockOverlay = this.locatorForOptional('.zv-block-ui__overlay'); - private _successDiv = this.locatorForOptional('.zv-action-button__check'); - private _errorDiv = this.locatorForOptional('.zv-action-button__error'); + private _blockOverlay = this.locatorForOptional('button mat-spinner'); public async click(): Promise { const button = await this._button(); @@ -22,14 +20,6 @@ export class ZvActionButtonHarness extends ComponentHarness { return (await (await this._button())?.text()) ?? null; } - public async showsSuccess(): Promise { - return !!(await this._successDiv()); - } - - public async getError(): Promise { - return await this._errorDiv(); - } - public async isBlocked(): Promise { return !!(await this._blockOverlay()); } @@ -45,4 +35,8 @@ export class ZvActionButtonHarness extends ComponentHarness { public async getDataCy(): Promise { return (await (await this._button())?.getAttribute('data-cy')) ?? ''; } + + public async getExceptionMessage(): Promise { + return (await (await this._buttonIcon())?.getAttribute('ng-reflect-message')) ?? ''; + } } diff --git a/projects/components/action-button/src/action-data-source.spec.ts b/projects/components/action-button/src/action-data-source.spec.ts index 2d8086d..b443cda 100644 --- a/projects/components/action-button/src/action-data-source.spec.ts +++ b/projects/components/action-button/src/action-data-source.spec.ts @@ -38,7 +38,7 @@ describe('ActionDataSource', () => { expect(dataSource.succeeded()).toBe(false); tick(1); - expect(dataSource.exception()).toBe(error); + expect(dataSource.exception()?.errorObject).toBe(error); expect(dataSource.pending()).toBe(false); expect(dataSource.hasError()).toBe(true); expect(dataSource.succeeded()).toBe(false); diff --git a/projects/components/action-button/src/action-data-source.ts b/projects/components/action-button/src/action-data-source.ts index d963b9d..9f9f42d 100644 --- a/projects/components/action-button/src/action-data-source.ts +++ b/projects/components/action-button/src/action-data-source.ts @@ -1,4 +1,5 @@ import { computed, Signal, signal } from '@angular/core'; +import { IZvException } from '@zvoove/components/core'; import { Observable, Subscription } from 'rxjs'; import { first } from 'rxjs/operators'; @@ -6,7 +7,7 @@ export interface IZvActionDataSource { readonly succeeded: Signal; readonly pending: Signal; readonly hasError: Signal; - readonly exception: Signal; + readonly exception: Signal; execute(): void; } @@ -15,7 +16,7 @@ export interface ZvActionDataSourceOptions { } export class ZvActionDataSource implements IZvActionDataSource { - private readonly _exception = signal(null); + private readonly _exception = signal(null); private readonly _pending = signal(false); private readonly _succeeded = signal(false); @@ -44,7 +45,10 @@ export class ZvActionDataSource implements IZvActionDataSource { }, error: (err: unknown) => { this._pending.set(false); - this._exception.set(err); + this._exception.set({ + errorObject: err, + icon: 'cancel', + }); }, }); } From aad8ed4db0618ca9c43df9c07f8e4843c366bfe5 Mon Sep 17 00:00:00 2001 From: saithis <1547453+saithis@users.noreply.github.com> Date: Sat, 5 Apr 2025 15:54:27 +0200 Subject: [PATCH 06/11] zv-action-button improvements: * Generic ActionDataSource: property names are now more aligned with angular resources * New ActionButtonDataSource, that adds button specific functionallity to ActionDataSource * Changed IZvActionButton input to separate inputs for icon, color and disabled * Error tooltip auto shows initially * Moved all logic of action button into data source * Improved harness method names * Adjusted demo accordingly and fixed some mistakes --- .../components/action-button/public_api.ts | 6 +- .../src/action-button-data-source.ts | 41 ++++++++ .../src/action-button.component.html | 30 +++--- .../src/action-button.component.spec.ts | 93 ++++++++++--------- .../src/action-button.component.ts | 31 +++---- .../src/action-button.harness.ts | 18 ++-- .../src/action-data-source.spec.ts | 46 --------- .../action-button/src/action-data-source.ts | 55 ----------- projects/components/core/public_api.ts | 2 + .../action-data-source.spec.ts | 53 +++++++++++ .../action-data-source/action-data-source.ts | 59 ++++++++++++ .../action-button-demo.component.html | 23 +++-- .../action-button-demo.component.ts | 30 ++---- 13 files changed, 266 insertions(+), 221 deletions(-) create mode 100644 projects/components/action-button/src/action-button-data-source.ts delete mode 100644 projects/components/action-button/src/action-data-source.spec.ts delete mode 100644 projects/components/action-button/src/action-data-source.ts create mode 100644 projects/components/core/src/action-data-source/action-data-source.spec.ts create mode 100644 projects/components/core/src/action-data-source/action-data-source.ts diff --git a/projects/components/action-button/public_api.ts b/projects/components/action-button/public_api.ts index 6c243da..89990a8 100644 --- a/projects/components/action-button/public_api.ts +++ b/projects/components/action-button/public_api.ts @@ -1,2 +1,6 @@ export { ZvActionButtonComponent, type IZvActionButton } from './src/action-button.component'; -export { ZvActionDataSource, type IZvActionDataSource, type ZvActionDataSourceOptions } from './src/action-data-source'; +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..a6fa23b --- /dev/null +++ b/projects/components/action-button/src/action-button-data-source.ts @@ -0,0 +1,41 @@ +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 interface IZvActionButtonDataSourceOptions extends 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); + + let timeoutRef: NodeJS.Timeout; + effect(() => { + if (this.showSuccess()) { + clearTimeout(timeoutRef); + 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 index 560ee28..1f1ae94 100644 --- a/projects/components/action-button/src/action-button.component.html +++ b/projects/components/action-button/src/action-button.component.html @@ -1,21 +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 index 43688ef..1f98dc1 100644 --- a/projects/components/action-button/src/action-button.component.spec.ts +++ b/projects/components/action-button/src/action-button.component.spec.ts @@ -2,32 +2,36 @@ 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 { switchMap, tap, throwError, timer } from 'rxjs'; -import { IZvActionButton, ZvActionButtonComponent } from './action-button.component'; +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 { ZvActionDataSource } from './action-data-source'; @Component({ selector: 'zv-test-component', - template: ``, + 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; - dataSource = new ZvActionDataSource({ - actionFn: () => timer(1).pipe(tap(() => (this.actionFnCalled = true))), + public throwError: Error | null = null; + dataSource = new ZvActionButtonDataSource({ + actionFn: () => + timer(100).pipe( + tap(() => { + this.actionFnCalled = true; + if (this.throwError) { + throw this.throwError; + } + }) + ), }); - - actionButton: IZvActionButton = { - label: `label`, - color: 'primary', - icon: 'home', - dataCy: 'dataCyTest', - isDisabled: this.isDisabled, - }; } describe('ZvActionButton', () => { let fixture: ComponentFixture; @@ -56,61 +60,62 @@ describe('ZvActionButton', () => { expect(component.actionFnCalled).toBeTrue(); }); - it('should be blocked while pending', async () => { - component.dataSource.execute(); - expect(component.dataSource.pending()).toBeTrue(); - expect(await harness.isBlocked()).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 pending', async () => { - component.dataSource.execute(); - expect(component.dataSource.pending()).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(); + expect(await harness.isDisabled()).toBeFalse(); component.isDisabled.set(true); - expect(await harness?.isDisabled()).toBeTrue(); + expect(await harness.isDisabled()).toBeTrue(); component.isDisabled.set(false); - expect(await harness?.isDisabled()).toBeFalse(); + expect(await harness.isDisabled()).toBeFalse(); }); it('should respect color property', async () => { expect(await harness.hasClass('mat-primary')).toBeTrue(); - component.actionButton = { ...component.actionButton, color: 'accent' }; + component.color.set('accent'); expect(await harness.hasClass('mat-accent')).toBeTrue(); - component.actionButton = { ...component.actionButton, color: 'warn' }; + component.color.set('warn'); expect(await harness.hasClass('mat-warn')).toBeTrue(); }); - it('should have dataCy attribute', async () => { - expect(await harness.getDataCy()).toBe('dataCyTest'); - }); - it('should show icon', async () => { - expect(await harness.getButtonIcon()).toBe('home'); + expect(await harness.getIcon()).toBe('home'); }); it('should show label', async () => { - const buttonContent = await harness.getButtonContent(); + const buttonContent = await harness.getLabel(); expect(buttonContent).toContain('label'); }); - it('should show success icon', fakeAsync(async () => { + it('should show success icon for 2 seconds', fakeAsync(async () => { await harness.click(); - tick(1); - expect(component.dataSource.pending()).toBeFalse(); - expect(component.dataSource.succeeded()).toBeTrue(); - expect(await harness.getButtonIcon()).toBe('check_circle'); + 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 () => { - const error = new Error('action failed'); - component.dataSource = new ZvActionDataSource({ actionFn: () => timer(1).pipe(switchMap(() => throwError(() => error))) }); + component.throwError = new Error('action failed'); await harness.click(); - tick(1); - expect(await harness.getButtonIcon()).toBe('cancel'); - expect(await harness.getExceptionMessage()).toBe(error.message); + 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 index 3ec424c..c40dcf7 100644 --- a/projects/components/action-button/src/action-button.component.ts +++ b/projects/components/action-button/src/action-button.component.ts @@ -1,11 +1,10 @@ -import { afterRenderEffect, ChangeDetectionStrategy, Component, effect, ElementRef, input, signal, Signal, viewChild } from '@angular/core'; +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 { ZvErrorMessagePipe } from '@zvoove/components/core'; -import { IZvActionDataSource } from './action-data-source'; import { MatTooltip } from '@angular/material/tooltip'; +import { IZvActionButtonDataSource } from './action-button-data-source'; export interface IZvActionButton { label: string; @@ -20,27 +19,21 @@ export interface IZvActionButton { templateUrl: './action-button.component.html', styleUrl: './action-button.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, - imports: [ZvErrorMessagePipe, MatIcon, MatButtonModule, MatProgressSpinner, MatTooltip], + imports: [MatIcon, MatButtonModule, MatProgressSpinner, MatTooltip], }) export class ZvActionButtonComponent { - public readonly actionDs = input.required(); - public readonly button = input.required(); - public readonly contentNode = viewChild.required>('content'); - public readonly spinnerDiameter = signal(20); - public readonly showSuccessIcon = signal(false); + public readonly dataSource = input.required(); + public readonly icon = input(null); + public readonly color = input(null); + public readonly disabled = input(false); + + private _tooltip = viewChild(MatTooltip); constructor() { afterRenderEffect(() => { - if (this.actionDs().pending()) { - this.spinnerDiameter.set(this.contentNode().nativeElement.offsetHeight); - } - }); - effect(() => { - if (this.actionDs().succeeded()) { - this.showSuccessIcon.set(true); - setTimeout(() => { - this.showSuccessIcon.set(false); - }, 2000); + if (this.dataSource().showError()) { + console.log(this._tooltip()); + 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 index 38e8c82..55dc5b9 100644 --- a/projects/components/action-button/src/action-button.harness.ts +++ b/projects/components/action-button/src/action-button.harness.ts @@ -5,23 +5,23 @@ export class ZvActionButtonHarness extends ComponentHarness { private _button = this.locatorForOptional('button'); private _buttonIcon = this.locatorForOptional('button mat-icon'); - private _blockOverlay = this.locatorForOptional('button mat-spinner'); + private _loadingSpinner = this.locatorForOptional('button mat-spinner'); public async click(): Promise { const button = await this._button(); return await button?.click(); } - public async getButtonIcon(): Promise { + public async getIcon(): Promise { return await (await this._buttonIcon())?.text(); } - public async getButtonContent(): Promise { + public async getLabel(): Promise { return (await (await this._button())?.text()) ?? null; } - public async isBlocked(): Promise { - return !!(await this._blockOverlay()); + public async isLoading(): Promise { + return !!(await this._loadingSpinner()); } public async isDisabled(): Promise { @@ -32,11 +32,7 @@ export class ZvActionButtonHarness extends ComponentHarness { return (await (await this._button())?.hasClass(className)) ?? false; } - public async getDataCy(): Promise { - return (await (await this._button())?.getAttribute('data-cy')) ?? ''; - } - - public async getExceptionMessage(): Promise { - return (await (await this._buttonIcon())?.getAttribute('ng-reflect-message')) ?? ''; + public async getErrorMessage(): Promise { + return (await (await this._button())?.getAttribute('ng-reflect-message')) ?? ''; } } diff --git a/projects/components/action-button/src/action-data-source.spec.ts b/projects/components/action-button/src/action-data-source.spec.ts deleted file mode 100644 index b443cda..0000000 --- a/projects/components/action-button/src/action-data-source.spec.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { fakeAsync, tick } from '@angular/core/testing'; -import { switchMap, throwError, timer } from 'rxjs'; -import { ZvActionDataSource } from './action-data-source'; - -describe('ActionDataSource', () => { - it('should set properties correctly', fakeAsync(() => { - const dataSource = new ZvActionDataSource({ actionFn: () => timer(1) }); - expect(dataSource.exception()).toBe(null); - expect(dataSource.pending()).toBe(false); - expect(dataSource.hasError()).toBe(false); - expect(dataSource.succeeded()).toBe(false); - - dataSource.execute(); - expect(dataSource.exception()).toBe(null); - expect(dataSource.pending()).toBe(true); - expect(dataSource.hasError()).toBe(false); - expect(dataSource.succeeded()).toBe(false); - - tick(1); - expect(dataSource.exception()).toBe(null); - expect(dataSource.pending()).toBe(false); - expect(dataSource.hasError()).toBe(false); - expect(dataSource.succeeded()).toBe(true); - })); - - it('should set error correctly', fakeAsync(() => { - const error = new Error('action failed'); - const dataSource = new ZvActionDataSource({ actionFn: () => timer(1).pipe(switchMap(() => throwError(() => error))) }); - expect(dataSource.exception()).toBe(null); - expect(dataSource.pending()).toBe(false); - expect(dataSource.hasError()).toBe(false); - expect(dataSource.succeeded()).toBe(false); - - dataSource.execute(); - expect(dataSource.exception()).toBe(null); - expect(dataSource.pending()).toBe(true); - expect(dataSource.hasError()).toBe(false); - expect(dataSource.succeeded()).toBe(false); - - tick(1); - expect(dataSource.exception()?.errorObject).toBe(error); - expect(dataSource.pending()).toBe(false); - expect(dataSource.hasError()).toBe(true); - expect(dataSource.succeeded()).toBe(false); - })); -}); diff --git a/projects/components/action-button/src/action-data-source.ts b/projects/components/action-button/src/action-data-source.ts deleted file mode 100644 index 9f9f42d..0000000 --- a/projects/components/action-button/src/action-data-source.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { computed, Signal, signal } from '@angular/core'; -import { IZvException } from '@zvoove/components/core'; -import { Observable, Subscription } from 'rxjs'; -import { first } from 'rxjs/operators'; - -export interface IZvActionDataSource { - readonly succeeded: Signal; - readonly pending: Signal; - readonly hasError: Signal; - readonly exception: Signal; - execute(): void; -} - -export interface ZvActionDataSourceOptions { - actionFn: () => Observable; -} - -export class ZvActionDataSource implements IZvActionDataSource { - private readonly _exception = signal(null); - private readonly _pending = signal(false); - private readonly _succeeded = signal(false); - - private actionSub = Subscription.EMPTY; - - constructor(private options: ZvActionDataSourceOptions) {} - - public readonly exception = this._exception.asReadonly(); - public readonly pending = this._pending.asReadonly(); - public readonly hasError = computed(() => this._exception() !== null); - public readonly succeeded = this._succeeded.asReadonly(); - - public execute() { - this.actionSub.unsubscribe(); - this._pending.set(true); - this._exception.set(null); - this._succeeded.set(false); - - let load$ = this.options.actionFn(); - load$ = load$.pipe(first()); - - this.actionSub = load$.subscribe({ - next: () => { - this._pending.set(false); - this._succeeded.set(true); - }, - error: (err: unknown) => { - this._pending.set(false); - this._exception.set({ - errorObject: err, - icon: 'cancel', - }); - }, - }); - } -} 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..2a4bd0f --- /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 | null }>({ + 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/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 index 08fefec..d630253 100644 --- 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 @@ -19,7 +19,9 @@

Settings:

Try it:

@for (value of themePalletValues; track value) { - + + {{ value }} example + }
@@ -29,8 +31,15 @@

Try it:

- - + + + + @@ -45,8 +54,10 @@

Try it:

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

@@ -64,7 +75,7 @@

Try it:

Usage -

An example of how to use the IZvActionButton:

+

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.ts b/projects/zvoove-components-demo/src/app/action-button-demo/action-button-demo.component.ts index c4d0e06..3ae0c50 100644 --- 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 @@ -2,7 +2,7 @@ import { ChangeDetectionStrategy, Component, signal } from '@angular/core'; import { MatCardModule } from '@angular/material/card'; import { MatCheckboxModule } from '@angular/material/checkbox'; import { ThemePalette } from '@angular/material/core'; -import { IZvActionButton, ZvActionButtonComponent, ZvActionDataSource } from '@zvoove/components/action-button'; +import { ZvActionButtonComponent, ZvActionButtonDataSource } from '@zvoove/components/action-button'; import { of } from 'rxjs'; import { delay, map } from 'rxjs/operators'; import { allSharedImports } from '../common/shared-imports'; @@ -20,7 +20,7 @@ export class ActionButtonDemoComponent { public readonly counter = signal(0); public readonly isDisabled = signal(false); - public dataSource = new ZvActionDataSource({ + public dataSource = new ZvActionButtonDataSource({ actionFn: () => { return of('foo').pipe( delay(1000), @@ -40,18 +40,8 @@ export class ActionButtonDemoComponent { this.isDisabled.set(isChecked); } - getActionButton(color: ThemePalette | null): IZvActionButton { - return { - label: `I am an IZvActionButton (color: ${color})`, - color: color, - icon: 'home', - dataCy: 'test', - isDisabled: this.isDisabled, - }; - } - importsCode = ` -import { IZvActionButton, ZvActionButtonComponent, ZvActionDataSource } from '@zvoove/components/action-button'; +import { ZvActionButtonComponent, ZvActionButtonDataSource } from '@zvoove/components/action-button'; // ... imports: [ ZvActionButtonComponent, @@ -59,20 +49,14 @@ imports: [ usageCodeTs = ` private http = inject(HttpClient); -actionDataSource = new ZvActionDataSource({ +actionDataSource = new ZvActionButtonDataSource({ actionFn: () => this.http.post('https://YOUR.API/POST-ROUTE', {}), }); - -public actionButton: IZvActionButton = { - label: '', - color: null, - icon: 'home', - dataCy: 'testsWillFindMe', - isDisabled: this.isDisabled, -}; `; usageCodeHtml = ` - + + button label + `; } From 8865cb69eee2455bf6d8b9ad754a45164745f255 Mon Sep 17 00:00:00 2001 From: saithis <1547453+saithis@users.noreply.github.com> Date: Sat, 5 Apr 2025 15:56:10 +0200 Subject: [PATCH 07/11] remove unused css --- .../action-button/src/action-button.component.scss | 5 ----- .../components/action-button/src/action-button.component.ts | 1 - 2 files changed, 6 deletions(-) delete mode 100644 projects/components/action-button/src/action-button.component.scss diff --git a/projects/components/action-button/src/action-button.component.scss b/projects/components/action-button/src/action-button.component.scss deleted file mode 100644 index 32dd2e8..0000000 --- a/projects/components/action-button/src/action-button.component.scss +++ /dev/null @@ -1,5 +0,0 @@ -.zv-action-button__button-content { - display: flex; - gap: 0.5em; - align-items: center; -} diff --git a/projects/components/action-button/src/action-button.component.ts b/projects/components/action-button/src/action-button.component.ts index c40dcf7..5066fb2 100644 --- a/projects/components/action-button/src/action-button.component.ts +++ b/projects/components/action-button/src/action-button.component.ts @@ -17,7 +17,6 @@ export interface IZvActionButton { @Component({ selector: 'zv-action-button', templateUrl: './action-button.component.html', - styleUrl: './action-button.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, imports: [MatIcon, MatButtonModule, MatProgressSpinner, MatTooltip], }) From d3d3b0e574b3df4497b27c9fd140cac31216d0c7 Mon Sep 17 00:00:00 2001 From: saithis <1547453+saithis@users.noreply.github.com> Date: Sat, 5 Apr 2025 17:09:05 +0200 Subject: [PATCH 08/11] Fix review comments --- .../action-button/src/action-button-data-source.ts | 7 +++---- .../action-button/src/action-button.component.html | 2 +- .../action-button/src/action-button.component.ts | 6 +++--- .../action-button-demo/action-button-demo.component.html | 2 +- .../app/action-button-demo/action-button-demo.component.ts | 5 ++--- projects/zvoove-components-demo/src/styles.scss | 1 + 6 files changed, 11 insertions(+), 12 deletions(-) diff --git a/projects/components/action-button/src/action-button-data-source.ts b/projects/components/action-button/src/action-button-data-source.ts index a6fa23b..436766d 100644 --- a/projects/components/action-button/src/action-button-data-source.ts +++ b/projects/components/action-button/src/action-button-data-source.ts @@ -11,7 +11,7 @@ export interface IZvActionButtonDataSource { execute(): void; } -export interface IZvActionButtonDataSourceOptions extends ZvActionDataSourceOptions {} +export declare type IZvActionButtonDataSourceOptions = ZvActionDataSourceOptions; export class ZvActionButtonDataSource extends ZvActionDataSource implements IZvActionButtonDataSource { readonly #destroyRef = inject(DestroyRef); @@ -27,11 +27,10 @@ export class ZvActionButtonDataSource extends ZvActionDataSource implements IZvA constructor(options: IZvActionButtonDataSourceOptions) { super(options); - let timeoutRef: NodeJS.Timeout; effect(() => { if (this.showSuccess()) { - clearTimeout(timeoutRef); - timeoutRef = setTimeout(() => { + clearTimeout(this.#timeoutRef); + this.#timeoutRef = setTimeout(() => { this.showSuccess.set(false); }, 2000); } diff --git a/projects/components/action-button/src/action-button.component.html b/projects/components/action-button/src/action-button.component.html index 1f1ae94..a5279fa 100644 --- a/projects/components/action-button/src/action-button.component.html +++ b/projects/components/action-button/src/action-button.component.html @@ -15,5 +15,5 @@ } @else if (icon()) { {{ icon() }} } - + diff --git a/projects/components/action-button/src/action-button.component.ts b/projects/components/action-button/src/action-button.component.ts index 5066fb2..1865e10 100644 --- a/projects/components/action-button/src/action-button.component.ts +++ b/projects/components/action-button/src/action-button.component.ts @@ -4,6 +4,7 @@ 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 { @@ -23,15 +24,14 @@ export interface IZvActionButton { export class ZvActionButtonComponent { public readonly dataSource = input.required(); public readonly icon = input(null); - public readonly color = input(null); + public readonly color = input(null); public readonly disabled = input(false); - private _tooltip = viewChild(MatTooltip); + private readonly _tooltip = viewChild(MatTooltip); constructor() { afterRenderEffect(() => { if (this.dataSource().showError()) { - console.log(this._tooltip()); this._tooltip()?.show(0); } }); 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 index d630253..aef0f02 100644 --- 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 @@ -19,7 +19,7 @@

Settings:

Try it:

@for (value of themePalletValues; track value) { - + {{ value }} example } 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 index 3ae0c50..1bd16e2 100644 --- 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 @@ -1,8 +1,8 @@ import { ChangeDetectionStrategy, Component, signal } from '@angular/core'; import { MatCardModule } from '@angular/material/card'; import { MatCheckboxModule } from '@angular/material/checkbox'; -import { ThemePalette } from '@angular/material/core'; 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'; @@ -16,7 +16,6 @@ import { allSharedImports } from '../common/shared-imports'; }) export class ActionButtonDemoComponent { public loadError = false; - public color: ThemePalette | null = null; public readonly counter = signal(0); public readonly isDisabled = signal(false); @@ -35,7 +34,7 @@ export class ActionButtonDemoComponent { }, }); - public themePalletValues: (ThemePalette | null)[] = [null, 'primary', 'accent', 'warn']; + public themePalletValues: (ZvButtonColors | null)[] = [null, 'primary', 'accent', 'warn']; updateDisabled(isChecked: boolean) { this.isDisabled.set(isChecked); } 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, From fad2a02a753cbcd64fd74a0c3072e0a8cacbcdf2 Mon Sep 17 00:00:00 2001 From: saithis <1547453+saithis@users.noreply.github.com> Date: Sat, 5 Apr 2025 17:10:19 +0200 Subject: [PATCH 09/11] fix lint error --- .../core/src/action-data-source/action-data-source.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 2a4bd0f..dcac389 100644 --- a/projects/components/core/src/action-data-source/action-data-source.ts +++ b/projects/components/core/src/action-data-source/action-data-source.ts @@ -15,7 +15,7 @@ export interface ZvActionDataSourceOptions { } export class ZvActionDataSource implements IZvActionDataSource { - readonly #state = signal<{ state: 'idle' | 'loading' | 'success' | 'error'; error: unknown | null }>({ + readonly #state = signal<{ state: 'idle' | 'loading' | 'success' | 'error'; error: unknown }>({ state: 'idle', error: null, }); From f8ab8a2de8bdd0814c93cba3a458122375623f35 Mon Sep 17 00:00:00 2001 From: saithis <1547453+saithis@users.noreply.github.com> Date: Sat, 5 Apr 2025 17:12:00 +0200 Subject: [PATCH 10/11] Change version to minor and update release notes --- projects/components/package.json | 2 +- .../src/app/upgrade-notes/upgrade-notes.page.html | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/projects/components/package.json b/projects/components/package.json index 90eda66..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.3", + "version": "19.3.0", "license": "MIT", "repository": { "type": "git", 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 3e73f95..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,5 +1,5 @@ - - action: Add ZvActionButton + + Added new zv-action-button component fix: is24HourFormat not returning true for de-DE. From 16671778108ebdd5be9f76832337f4b21ea4302f Mon Sep 17 00:00:00 2001 From: Suneeh Date: Sat, 5 Apr 2025 17:42:55 +0200 Subject: [PATCH 11/11] fix usage code in demo --- .../action-button/src/action-button.component.spec.ts | 3 ++- .../app/action-button-demo/action-button-demo.component.ts | 6 +++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/projects/components/action-button/src/action-button.component.spec.ts b/projects/components/action-button/src/action-button.component.spec.ts index 1f98dc1..661e699 100644 --- a/projects/components/action-button/src/action-button.component.spec.ts +++ b/projects/components/action-button/src/action-button.component.spec.ts @@ -6,6 +6,7 @@ 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', @@ -18,7 +19,7 @@ import { ZvActionButtonHarness } from './action-button.harness'; }) export class TestComponent { public readonly isDisabled = signal(false); - public readonly color = signal('primary'); + public readonly color = signal('primary'); public actionFnCalled = false; public throwError: Error | null = null; dataSource = new ZvActionButtonDataSource({ 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 index 1bd16e2..88cf188 100644 --- 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 @@ -51,11 +51,11 @@ 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 = ` - - button label - +label `; }