diff --git a/package-lock.json b/package-lock.json index 5df26c8..f00d343 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,6 +37,7 @@ "@types/express": "^5.0.6", "@types/node": "^24.10.12", "@vitest/browser-playwright": "^4.1.1", + "@vitest/eslint-plugin": "^1.6.13", "angular-eslint": "^21.3.1", "copyfiles": "^2.4.1", "eslint": "^10.1.0", @@ -44,6 +45,7 @@ "jsdom": "^29.0.1", "lint-staged": "^16.4.0", "ng-packagr": "^21.2.1", + "playwright": "^1.52.0", "prettier": "^3.8.1", "ts-node": "^10.9.2", "typescript": "~5.9.3", @@ -5026,6 +5028,37 @@ } } }, + "node_modules/@vitest/eslint-plugin": { + "version": "1.6.13", + "resolved": "https://registry.npmjs.org/@vitest/eslint-plugin/-/eslint-plugin-1.6.13.tgz", + "integrity": "sha512-ui7JGWBoQpS5NKKW0FDb1eTuFEZ5EupEv2Psemuyfba7DfA5K52SeDLelt6P4pQJJ/4UGkker/BgMk/KrjH3WQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "^8.55.0", + "@typescript-eslint/utils": "^8.55.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@typescript-eslint/eslint-plugin": "*", + "eslint": ">=8.57.0", + "typescript": ">=5.0.0", + "vitest": "*" + }, + "peerDependenciesMeta": { + "@typescript-eslint/eslint-plugin": { + "optional": true + }, + "typescript": { + "optional": true + }, + "vitest": { + "optional": true + } + } + }, "node_modules/@vitest/expect": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.1.tgz", @@ -9290,7 +9323,6 @@ "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "playwright-core": "1.58.2" }, @@ -9310,7 +9342,6 @@ "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "playwright-core": "cli.js" }, @@ -9329,7 +9360,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } diff --git a/package.json b/package.json index 884faed..fa1641d 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "@types/express": "^5.0.6", "@types/node": "^24.10.12", "@vitest/browser-playwright": "^4.1.1", + "@vitest/eslint-plugin": "^1.6.13", "angular-eslint": "^21.3.1", "copyfiles": "^2.4.1", "eslint": "^10.1.0", @@ -60,6 +61,7 @@ "jsdom": "^29.0.1", "lint-staged": "^16.4.0", "ng-packagr": "^21.2.1", + "playwright": "^1.52.0", "prettier": "^3.8.1", "ts-node": "^10.9.2", "typescript": "~5.9.3", @@ -68,5 +70,10 @@ }, "lint-staged": { "*.{ts,json,scss,html}": "prettier --write" + }, + "overrides": { + "eslint-plugin-vitest": { + "eslint": "$eslint" + } } } diff --git a/projects/components/card/src/card.component.spec.ts b/projects/components/card/src/card.component.spec.ts index e240fb2..5db5744 100644 --- a/projects/components/card/src/card.component.spec.ts +++ b/projects/components/card/src/card.component.spec.ts @@ -1,6 +1,6 @@ import { HarnessLoader } from '@angular/cdk/testing'; import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; -import { ChangeDetectionStrategy, Component, signal, ViewChild } from '@angular/core'; +import { ChangeDetectionStrategy, Component, signal, viewChild } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { MatButtonModule } from '@angular/material/button'; import { ZvHeaderHarness } from '@zvoove/components/header/src/testing/header.harness'; @@ -52,7 +52,7 @@ export class TestDataSourceComponent { public readonly addCaptionTemplate = signal(false); public readonly addDescriptionTemplate = signal(false); public readonly addFooterTemplate = signal(false); - @ViewChild(ZvCard) headerComponent: ZvCard; + readonly headerComponent = viewChild(ZvCard); } describe('ZvCard', () => { diff --git a/projects/components/core/src/date/native-date-adapter.spec.ts b/projects/components/core/src/date/native-date-adapter.spec.ts index faae32a..0099e53 100644 --- a/projects/components/core/src/date/native-date-adapter.spec.ts +++ b/projects/components/core/src/date/native-date-adapter.spec.ts @@ -38,8 +38,8 @@ describe('ZvNativeDateAdapter', () => { it('should parse invalid value as invalid', () => { const d = adapter.parse('hello'); expect(d).not.toBeNull(); - expect(adapter.isDateInstance(d), 'Expected string to have been fed through Date.parse').toBe(true); - expect(adapter.isValid(d as Date), 'Expected to parse as "invalid date" object').toBe(false); + expect(adapter.isDateInstance(d)).toBe(true); + expect(adapter.isValid(d as Date)).toBe(false); }); it('should return localized format example', () => { diff --git a/projects/components/core/src/time/native-time-adapter.spec.ts b/projects/components/core/src/time/native-time-adapter.spec.ts index 9f16407..fbfe5da 100644 --- a/projects/components/core/src/time/native-time-adapter.spec.ts +++ b/projects/components/core/src/time/native-time-adapter.spec.ts @@ -25,12 +25,8 @@ describe('ZvNativeTimeAdapter', () => { adapter = timeAdapter; assertValidTime = (t: Time | null, valid: boolean) => { - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions, @typescript-eslint/no-base-to-string - expect(adapter.isTimeInstance(t), `Expected ${t} to be a time instance`).not.toBeNull(); - expect( - adapter.isValid(t!), - `Expected ${JSON.stringify(t)} to be ${valid ? 'valid' : 'invalid'}, but ` + `was ${valid ? 'invalid' : 'valid'}` - ).toBe(valid); + expect(adapter.isTimeInstance(t)).not.toBeNull(); + expect(adapter.isValid(t!)).toBe(valid); }; })); @@ -73,8 +69,8 @@ describe('ZvNativeTimeAdapter', () => { it('should parse invalid value as invalid', () => { const t = adapter.parse('hello'); expect(t).not.toBeNull(); - expect(adapter.isTimeInstance(t), 'Expected string to have been fed through Time.parse').toBe(true); - expect(adapter.isValid(t as Time), 'Expected to parse as "invalid Time" object').toBe(false); + expect(adapter.isTimeInstance(t)).toBe(true); + expect(adapter.isValid(t as Time)).toBe(false); }); it('should format as 24 hour format', () => { @@ -91,21 +87,10 @@ describe('ZvNativeTimeAdapter', () => { ).toEqual('3:30 PM'); }); - // it('should format with a different locale', () => { - // adapter.setLocale('ja-JP'); - // expect(adapter.format(newTime(14, 45), {})).toEqual('2017/1/1'); - // }); - it('should throw when attempting to format invalid Time', () => { expect(() => adapter.format(adapter.invalid(), {})).toThrowError(/ZvNativeTimeAdapter: Cannot format invalid Time\./); }); - // it('should clone', () => { - // let Time = newTime(14, 45); - // expect(adapter.clone(Time)).toEqual(Time); - // expect(adapter.clone(Time)).not.toBe(Time); - // }); - it('should compare Times', () => { expect(adapter.compareTime(newTime(14, 45), newTime(14, 46))).toBeLessThan(0); expect(adapter.compareTime(newTime(14, 45), newTime(15, 45))).toBeLessThan(0); diff --git a/projects/components/date-time-input/src/date-time-input.component.html b/projects/components/date-time-input/src/date-time-input.component.html index 08e372a..d145080 100644 --- a/projects/components/date-time-input/src/date-time-input.component.html +++ b/projects/components/date-time-input/src/date-time-input.component.html @@ -6,7 +6,7 @@ [attr.placeholder]="shouldLabelFloat ? datePlaceholder : ''" [required]="required" formControlName="date" - [matDatepicker]="matDatepicker" + [matDatepicker]="matDatepicker()" (focus)="_onFocus()" (blur)="_onBlur()" (keydown)="_onDateInputKeydown($event)" diff --git a/projects/components/date-time-input/src/date-time-input.component.spec.ts b/projects/components/date-time-input/src/date-time-input.component.spec.ts index e15f495..4940007 100644 --- a/projects/components/date-time-input/src/date-time-input.component.spec.ts +++ b/projects/components/date-time-input/src/date-time-input.component.spec.ts @@ -2,7 +2,7 @@ import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { HarnessLoader, TestKey } from '@angular/cdk/testing'; -import { ChangeDetectionStrategy, Component, LOCALE_ID, signal, ViewChild } from '@angular/core'; +import { ChangeDetectionStrategy, Component, LOCALE_ID, signal, viewChild } from '@angular/core'; import { FormControl, FormsModule, NgModel, ReactiveFormsModule } from '@angular/forms'; import { ErrorStateMatcher } from '@angular/material/core'; import { MatDatepickerInput, MatDatepickerModule } from '@angular/material/datepicker'; @@ -34,13 +34,16 @@ describe('ZvDateTimeInput', () => { }); fixture = TestBed.createComponent(ValueTestComponent); fixture.detectChanges(); - cmp = fixture.componentInstance.dateTimeInputCmp; - expect(cmp).toBeDefined(); + cmp = fixture.componentInstance.dateTimeInputCmp(); loader = TestbedHarnessEnvironment.loader(fixture); harness = await loader.getHarness(ZvDateTimeInputHarness); }); + it('should be defined', () => { + expect(cmp).toBeDefined(); + }); + it('should have size 12 for the date input', async () => { // because chrome doesn't fit all 10 characters of "DD/MM/YYYY" into size 10 const [dateInput] = await harness.getInputs(); @@ -402,13 +405,16 @@ describe('ZvDateTimeInput', () => { }); fixture = TestBed.createComponent(InputsTestComponent); fixture.detectChanges(); - cmp = fixture.componentInstance.dateTimeInputCmp; - expect(cmp).toBeDefined(); + cmp = fixture.componentInstance.dateTimeInputCmp(); loader = TestbedHarnessEnvironment.loader(fixture); harness = await loader.getHarness(ZvDateTimeInputHarness); }); + it('should be defined', () => { + expect(cmp).toBeDefined(); + }); + it('should respect required input', async () => { const host = await harness.host(); const [dateInput, timeInput] = await harness.getInputs(); @@ -458,14 +464,17 @@ describe('ZvDateTimeInput', () => { }); fixture = TestBed.createComponent(FormTestComponent); fixture.detectChanges(); - cmp = fixture.componentInstance.dateTimeInputCmp; + cmp = fixture.componentInstance.dateTimeInputCmp(); formControl = fixture.componentInstance.control; - expect(cmp).toBeDefined(); loader = TestbedHarnessEnvironment.loader(fixture); harness = await loader.getHarness(ZvDateTimeInputHarness); }); + it('should be defined', () => { + expect(cmp).toBeDefined(); + }); + it('should respect disabled form', async () => { const host = await harness.host(); const [dateInput, timeInput] = await harness.getInputs(); @@ -712,8 +721,7 @@ function isValidDate(date: unknown) { ], }) export class ValueTestComponent { - @ViewChild(ZvDateTimeInput) - dateTimeInputCmp!: ZvDateTimeInput; + readonly dateTimeInputCmp = viewChild(ZvDateTimeInput); readonly disabled = signal(false); readonly value = signal(null); } @@ -741,10 +749,8 @@ export class ValueTestComponent { ], }) export class InputsTestComponent { - @ViewChild(ZvDateTimeInput) - dateTimeInputCmp!: ZvDateTimeInput; - @ViewChild('dateInput', { read: NgModel }) - ngModel: NgModel; + readonly dateTimeInputCmp = viewChild(ZvDateTimeInput); + readonly ngModel = viewChild('dateInput', { read: NgModel }); readonly disabled = signal(false); readonly value = signal(null); readonly required = signal(false); @@ -766,7 +772,6 @@ export class InputsTestComponent { ], }) export class FormTestComponent { - @ViewChild(ZvDateTimeInput) - dateTimeInputCmp!: ZvDateTimeInput; + readonly dateTimeInputCmp = viewChild(ZvDateTimeInput); control = new FormControl(null); } diff --git a/projects/components/date-time-input/src/date-time-input.component.ts b/projects/components/date-time-input/src/date-time-input.component.ts index c8ccf78..227a577 100644 --- a/projects/components/date-time-input/src/date-time-input.component.ts +++ b/projects/components/date-time-input/src/date-time-input.component.ts @@ -1,9 +1,4 @@ -/* eslint-disable @angular-eslint/no-conflicting-lifecycle -- - Both DoCheck and OnChanges are required: OnChanges notifies MatFormField - of input changes via stateChanges.next(), while DoCheck runs - updateErrorState() which depends on parent form submission state that - cannot be observed reactively. This follows Angular Material's own - MatInput implementation. */ +/* eslint-disable @angular-eslint/prefer-signals -- MatFormFieldControl/CVA properties must remain as @Input decorators (see design decision D2) */ import { BooleanInput, coerceBooleanProperty } from '@angular/cdk/coercion'; import { @@ -12,16 +7,14 @@ import { Component, DoCheck, ElementRef, - EventEmitter, Input, - OnChanges, OnInit, - Output, - SimpleChanges, - ViewChild, ViewEncapsulation, booleanAttribute, inject, + input, + output, + viewChild, } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { @@ -62,9 +55,7 @@ let nextUniqueId = 0; }, providers: [{ provide: MatFormFieldControl, useExisting: ZvDateTimeInput }], }) -export class ZvDateTimeInput - implements ControlValueAccessor, MatFormFieldControl, OnChanges, OnInit, DoCheck -{ +export class ZvDateTimeInput implements ControlValueAccessor, MatFormFieldControl, OnInit, DoCheck { public _changeDetectorRef = inject(ChangeDetectorRef); _defaultErrorStateMatcher = inject(ErrorStateMatcher); _parentForm = inject(NgForm, { optional: true }); @@ -97,7 +88,7 @@ export class ZvDateTimeInput } private _id = this._uid; - @Input({ required: true }) public matDatepicker!: MatDatepickerPanel, unknown, unknown>; + public readonly matDatepicker = input.required, unknown, unknown>>(); /** Value of the date-time control. */ @Input() @@ -108,7 +99,7 @@ export class ZvDateTimeInput this._assignValue(newValue, { assignForm: true, emitChange: true }); } private _value: TDateTime | null = null; - @Output() public readonly valueChange = new EventEmitter(); + public readonly valueChange = output(); /** Placeholder to be shown if no value has been selected. (not supported for this component!) */ public readonly placeholder = ''; @@ -120,7 +111,14 @@ export class ZvDateTimeInput private _focused = false; @Input({ transform: booleanAttribute }) - public disabled = false; + get disabled(): boolean { + return this._disabled; + } + set disabled(value: boolean) { + this._disabled = value; + this.setDisabledState(this._disabled); + } + private _disabled = false; /** * Implemented as part of MatFormFieldControl. @@ -131,10 +129,12 @@ export class ZvDateTimeInput /** Whether the control is empty. */ get empty(): boolean { - if (!this._dateInputElementRef || !this._timeInputElementRef) { + const dateRef = this._dateInputElementRef(); + const timeRef = this._timeInputElementRef(); + if (!dateRef || !timeRef) { return this.value == null; } - return !this._dateInputElementRef.nativeElement.value && !this._timeInputElementRef.nativeElement.value; + return !dateRef.nativeElement.value && !timeRef.nativeElement.value; } /** Whether the `MatFormField` label should try to float. */ @@ -187,8 +187,10 @@ export class ZvDateTimeInput time: new FormControl(null), }); - @ViewChild('date') _dateInputElementRef!: ElementRef; - @ViewChild('time') _timeInputElementRef!: ElementRef; + public readonly _dateInputElementRef = viewChild>('date'); + public readonly _timeInputElementRef = viewChild>('time'); + public readonly matDateInput = viewChild(MatDatepickerInput); + public readonly zvTimeInput = viewChild(ZvTimeInput); _errorStateTracker: _ErrorStateTracker; constructor() { @@ -230,12 +232,6 @@ export class ZvDateTimeInput } } - ngOnChanges(changes: SimpleChanges): void { - if (changes.disabled) { - this.setDisabledState(this.disabled); - } - } - ngDoCheck() { if (this.ngControl) { // We need to re-evaluate this on every change detection cycle, because there are some @@ -258,9 +254,10 @@ export class ZvDateTimeInput this._ariaDescribedby = ids.join(' '); } - @ViewChild(MatDatepickerInput) public matDateInput!: MatDatepickerInput; - @ViewChild(ZvTimeInput) public zvTimeInput!: ZvTimeInput; - _childValidators: ValidatorFn[] = [(control) => this.matDateInput?.validate(control), (control) => this.zvTimeInput?.validate(control)]; + _childValidators: ValidatorFn[] = [ + (control) => (this._dateInputElementRef()?.nativeElement.value ? this.matDateInput()?.validate(control) : null) ?? null, + (control) => (this._timeInputElementRef()?.nativeElement.value ? this.zvTimeInput()?.validate(control) : null) ?? null, + ]; validate(control: AbstractControl): ValidationErrors | null { const errors = this._childValidators.map((v) => v(control)).filter((error) => error); if (!errors.length) { @@ -318,11 +315,11 @@ export class ZvDateTimeInput * @param isDisabled Sets whether the component is disabled. */ setDisabledState(isDisabled: boolean): void { - this.disabled = isDisabled; + this._disabled = isDisabled; if (isDisabled) { - this._form.disable(); + this._form.disable({ emitEvent: false }); } else { - this._form.enable(); + this._form.enable({ emitEvent: false }); } this._changeDetectorRef.markForCheck(); this.stateChanges.next(); @@ -340,13 +337,13 @@ export class ZvDateTimeInput /** Focuses the date input element. */ private _focus(event: MouseEvent | null, options?: FocusOptions): void { - let target = this._dateInputElementRef.nativeElement; + let target: HTMLInputElement | undefined = this._dateInputElementRef()?.nativeElement; if (this.shouldLabelFloat && event?.target instanceof HTMLInputElement) { target = event.target; } else if (this._form.value.date) { - target = this._timeInputElementRef.nativeElement; + target = this._timeInputElementRef()?.nativeElement; } - target.focus(options); + target?.focus(options); } _onFocus() { @@ -374,7 +371,7 @@ export class ZvDateTimeInput const input = event.target as HTMLInputElement; if (event.key === 'ArrowRight' && input.selectionStart === input.selectionEnd && input.selectionStart === input.value.length) { event.preventDefault(); - this._timeInputElementRef.nativeElement.focus(); + this._timeInputElementRef()?.nativeElement.focus(); } } @@ -382,7 +379,7 @@ export class ZvDateTimeInput const input = event.target as HTMLInputElement; if (event.key === 'ArrowLeft' && input.selectionStart === input.selectionEnd && input.selectionStart === 0) { event.preventDefault(); - this._dateInputElementRef.nativeElement.focus(); + this._dateInputElementRef()?.nativeElement.focus(); } } diff --git a/projects/components/date-time-input/src/time-input.directive.ts b/projects/components/date-time-input/src/time-input.directive.ts index 0227ef8..6ba80aa 100644 --- a/projects/components/date-time-input/src/time-input.directive.ts +++ b/projects/components/date-time-input/src/time-input.directive.ts @@ -1,18 +1,6 @@ +/* eslint-disable @angular-eslint/prefer-signals -- MatFormFieldControl/CVA properties must remain as @Input decorators (see design decision D2) */ import { BooleanInput, coerceBooleanProperty } from '@angular/cdk/coercion'; -import { - AfterViewInit, - Directive, - ElementRef, - EventEmitter, - Input, - OnChanges, - OnDestroy, - Output, - Provider, - SimpleChanges, - forwardRef, - inject, -} from '@angular/core'; +import { AfterViewInit, Directive, ElementRef, Input, OnDestroy, Provider, forwardRef, inject, output } from '@angular/core'; import { AbstractControl, ControlValueAccessor, @@ -73,7 +61,7 @@ export const ZV_TIME_VALIDATORS: Provider = { }, exportAs: 'matTimeInput', }) -export class ZvTimeInput implements ControlValueAccessor, AfterViewInit, OnChanges, OnDestroy, Validator { +export class ZvTimeInput implements ControlValueAccessor, AfterViewInit, OnDestroy, Validator { private readonly _elementRef = inject>(ElementRef); private readonly _timeAdapter = inject>(ZvTimeAdapter, { optional: true }); private readonly _timeFormats = inject(ZV_TIME_FORMATS, { optional: true }); @@ -119,10 +107,10 @@ export class ZvTimeInput implements ControlValueAccessor, AfterViewInit, private _disabled = false; /** Emits when a `change` event is fired on this ``. */ - @Output() readonly timeChange: EventEmitter> = new EventEmitter>(); + public readonly timeChange = output>(); /** Emits when an `input` event is fired on this ``. */ - @Output() readonly timeInput: EventEmitter> = new EventEmitter>(); + public readonly timeInput = output>(); /** Emits when the internal state has changed */ readonly stateChanges = new Subject(); @@ -157,12 +145,6 @@ export class ZvTimeInput implements ControlValueAccessor, AfterViewInit, this._isInitialized = true; } - ngOnChanges(changes: SimpleChanges) { - if (!!this._timeAdapter && timeInputsHaveChanged(changes, this._timeAdapter)) { - this.stateChanges.next(); - } - } - ngOnDestroy() { this._localeSubscription.unsubscribe(); this.stateChanges.complete(); @@ -266,26 +248,3 @@ export class ZvTimeInput implements ControlValueAccessor, AfterViewInit, return !value || !!this._timeAdapter?.isValid(value); } } - -/** - * Checks whether the `SimpleChanges` object from an `ngOnChanges` - * callback has any changes, accounting for date objects. - */ -export function timeInputsHaveChanged(changes: SimpleChanges, adapter: ZvTimeAdapter): boolean { - const keys = Object.keys(changes); - - for (const key of keys) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const { previousValue, currentValue } = changes[key]; - - if (adapter.isTimeInstance(previousValue) && adapter.isTimeInstance(currentValue)) { - if (!adapter.sameTime(previousValue, currentValue)) { - return true; - } - } else { - return true; - } - } - - return false; -} diff --git a/projects/components/dialog-wrapper/src/dialog-wrapper.component.ts b/projects/components/dialog-wrapper/src/dialog-wrapper.component.ts index ae0d01c..30a5fed 100644 --- a/projects/components/dialog-wrapper/src/dialog-wrapper.component.ts +++ b/projects/components/dialog-wrapper/src/dialog-wrapper.component.ts @@ -40,6 +40,7 @@ export class ZvDialogWrapper implements OnDestroy { public get showProgress(): boolean { return this.progress != null && this.progress >= 0; } + // eslint-disable-next-line @angular-eslint/prefer-signals -- synchronous teardown required: old subscription must be unsubscribed before new connect() @Input({ required: true }) public set dataSource(value: IZvDialogWrapperDataSource) { if (this._dataSource) { this._dataSource.disconnect(); diff --git a/projects/components/eslint.config.js b/projects/components/eslint.config.js index 9160fe9..146493d 100644 --- a/projects/components/eslint.config.js +++ b/projects/components/eslint.config.js @@ -1,5 +1,6 @@ // @ts-check const tseslint = require("typescript-eslint"); +const vitest = require("@vitest/eslint-plugin"); const rootConfig = require("../../eslint.config.js"); module.exports = tseslint.config( @@ -20,7 +21,14 @@ module.exports = tseslint.config( }, { files: ["**/*.spec.ts"], + plugins: { + vitest: vitest, + }, rules: { + ...vitest.configs.recommended.rules, + "vitest/expect-expect": ["error", { + assertFunctionNames: ["expect", "assert*", "sortAssert", "validate*"], + }], "@typescript-eslint/no-explicit-any": "off", }, }, diff --git a/projects/components/file-input/src/file-input.component.html b/projects/components/file-input/src/file-input.component.html index ef5ec76..bfdd2f1 100644 --- a/projects/components/file-input/src/file-input.component.html +++ b/projects/components/file-input/src/file-input.component.html @@ -9,7 +9,7 @@ [disabled]="disabled" [readonly]="readonly" [required]="required" - [accept]="accept" + [accept]="accept()" (change)="onFileSelected($event)" #input /> diff --git a/projects/components/file-input/src/file-input.component.spec.ts b/projects/components/file-input/src/file-input.component.spec.ts index e2520ce..84d10f0 100644 --- a/projects/components/file-input/src/file-input.component.spec.ts +++ b/projects/components/file-input/src/file-input.component.spec.ts @@ -2,20 +2,21 @@ import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { HarnessLoader } from '@angular/cdk/testing'; -import { ChangeDetectionStrategy, Component, ViewChild } from '@angular/core'; +import { ChangeDetectionStrategy, Component, signal, viewChild } from '@angular/core'; import { ZvFileInput } from './file-input.component'; import { ZvFileInputHarness } from './testing/file-input.harness'; @Component({ selector: 'zv-test-component', - template: ` `, + template: ` `, // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection changeDetection: ChangeDetectionStrategy.Eager, imports: [ZvFileInput], }) export class TestComponent { - @ViewChild(ZvFileInput) - fileInputCmp!: ZvFileInput; + readonly fileInputCmp = viewChild(ZvFileInput); + + readonly accept = signal([]); } describe('ZvFileInput', () => { @@ -28,8 +29,7 @@ describe('ZvFileInput', () => { beforeEach(async () => { fixture = TestBed.createComponent(TestComponent); fixture.detectChanges(); - cmp = fixture.componentInstance.fileInputCmp; - expect(cmp).toBeDefined(); + cmp = fixture.componentInstance.fileInputCmp(); loader = TestbedHarnessEnvironment.loader(fixture); harness = await loader.getHarness(ZvFileInputHarness); @@ -40,6 +40,10 @@ describe('ZvFileInput', () => { }; }); + it('should be defined', () => { + expect(cmp).toBeDefined(); + }); + it('Should respect disabled', async () => { cmp.disabled = true; detectChanges(); @@ -53,7 +57,7 @@ describe('ZvFileInput', () => { expect(await harness.isRequired()).toEqual(false); expect(await harness.isReadonly()).toEqual(false); - cmp.accept = ['.png', '.jpg']; + fixture.componentInstance.accept.set(['.png', '.jpg']); cmp.placeholder = 'PLACEHOLDER'; cmp.required = true; cmp.readonly = true; diff --git a/projects/components/file-input/src/file-input.component.ts b/projects/components/file-input/src/file-input.component.ts index bbd4439..e473a66 100644 --- a/projects/components/file-input/src/file-input.component.ts +++ b/projects/components/file-input/src/file-input.component.ts @@ -1,9 +1,4 @@ -/* eslint-disable @angular-eslint/no-conflicting-lifecycle -- - Both DoCheck and OnChanges are required: OnChanges notifies MatFormField - of input changes via stateChanges.next(), while DoCheck runs - updateErrorState() which depends on parent form submission state that - cannot be observed reactively. This follows Angular Material's own - MatInput implementation. */ +/* eslint-disable @angular-eslint/prefer-signals -- MatFormFieldControl/CVA properties must remain as @Input decorators (see design decision D2) */ import { coerceBooleanProperty } from '@angular/cdk/coercion'; import { @@ -12,15 +7,14 @@ import { Component, DoCheck, ElementRef, - EventEmitter, Input, - OnChanges, OnDestroy, OnInit, - Output, - ViewChild, ViewEncapsulation, inject, + input, + output, + viewChild, } from '@angular/core'; import { ControlValueAccessor, FormGroupDirective, NgControl, NgForm } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; @@ -52,13 +46,15 @@ let nextUniqueId = 0; changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None, }) -export class ZvFileInput implements ControlValueAccessor, MatFormFieldControl, OnChanges, OnDestroy, OnInit, DoCheck { +export class ZvFileInput implements ControlValueAccessor, MatFormFieldControl, OnDestroy, OnInit, DoCheck { + // Signal inputs (access via .inputName()): accept + // Getter/setter properties (access via .propName): disabled, required, placeholder, value, id, readonly public readonly ngControl = inject(NgControl, { optional: true, self: true }); public readonly _cd = inject(ChangeDetectorRef); fileSelectText = $localize`:@@zvc.chooseFile:Please choose a file.`; - @Input() accept: string[] = []; + public readonly accept = input([]); /** * Implemented as part of MatFormFieldControl. @@ -139,6 +135,7 @@ export class ZvFileInput implements ControlValueAccessor, MatFormFieldControl(); + public readonly valueChange = output(); /** Whether the element is readonly. */ @Input() @@ -211,8 +208,7 @@ export class ZvFileInput implements ControlValueAccessor, MatFormFieldControl; + public readonly _inputfieldViewChild = viewChild>('input'); _errorStateTracker: _ErrorStateTracker; _onModelChange: (val: unknown) => void = () => {}; @@ -235,6 +231,9 @@ export class ZvFileInput implements ControlValueAccessor, MatFormFieldControl
@if (_attachFront || !removeHiddenNodes()) { - + }
@if (_attachBack || !removeHiddenNodes()) { - + }
diff --git a/projects/components/flip-container/src/flip-container.component.spec.ts b/projects/components/flip-container/src/flip-container.component.spec.ts index 56c3b6d..fb23d39 100644 --- a/projects/components/flip-container/src/flip-container.component.spec.ts +++ b/projects/components/flip-container/src/flip-container.component.spec.ts @@ -1,4 +1,4 @@ -import { ChangeDetectionStrategy, Component, ViewChild } from '@angular/core'; +import { ChangeDetectionStrategy, Component, viewChild } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { vi } from 'vitest'; import { By } from '@angular/platform-browser'; @@ -20,7 +20,7 @@ import { ZvFlipContainerModule } from './flip-container.module'; export class TestComponent { public removeHiddenNodes = true; - @ViewChild(ZvFlipContainer, { static: true }) cmp: ZvFlipContainer; + readonly cmp = viewChild.required(ZvFlipContainer); } describe('ZvFlipContainer', () => { @@ -40,70 +40,70 @@ describe('ZvFlipContainer', () => { const component = fixture.componentInstance; fixture.detectChanges(); - expect(component.cmp.active).toEqual('front'); + expect(component.cmp().active).toEqual('front'); - component.cmp.toggleFlip(); - expect(component.cmp.active).toEqual('back'); + component.cmp().toggleFlip(); + expect(component.cmp().active).toEqual('back'); fixture.detectChanges(); await vi.advanceTimersByTimeAsync(300); fixture.detectChanges(); - expect(component.cmp.active).toEqual('back'); + expect(component.cmp().active).toEqual('back'); - component.cmp.toggleFlip(); - expect(component.cmp.active).toEqual('front'); + component.cmp().toggleFlip(); + expect(component.cmp().active).toEqual('front'); fixture.detectChanges(); await vi.advanceTimersByTimeAsync(300); fixture.detectChanges(); - expect(component.cmp.active).toEqual('front'); + expect(component.cmp().active).toEqual('front'); - component.cmp.showFront(); - expect(component.cmp.active).toEqual('front'); + component.cmp().showFront(); + expect(component.cmp().active).toEqual('front'); fixture.detectChanges(); await vi.advanceTimersByTimeAsync(300); fixture.detectChanges(); - expect(component.cmp.active).toEqual('front'); + expect(component.cmp().active).toEqual('front'); - component.cmp.showBack(); - expect(component.cmp.active).toEqual('back'); + component.cmp().showBack(); + expect(component.cmp().active).toEqual('back'); fixture.detectChanges(); await vi.advanceTimersByTimeAsync(300); fixture.detectChanges(); - expect(component.cmp.active).toEqual('back'); + expect(component.cmp().active).toEqual('back'); - component.cmp.showBack(); - expect(component.cmp.active).toEqual('back'); + component.cmp().showBack(); + expect(component.cmp().active).toEqual('back'); fixture.detectChanges(); await vi.advanceTimersByTimeAsync(300); fixture.detectChanges(); - expect(component.cmp.active).toEqual('back'); + expect(component.cmp().active).toEqual('back'); - component.cmp.show('back'); - expect(component.cmp.active).toEqual('back'); + component.cmp().show('back'); + expect(component.cmp().active).toEqual('back'); fixture.detectChanges(); await vi.advanceTimersByTimeAsync(300); fixture.detectChanges(); - expect(component.cmp.active).toEqual('back'); + expect(component.cmp().active).toEqual('back'); - component.cmp.show('front'); - expect(component.cmp.active).toEqual('front'); + component.cmp().show('front'); + expect(component.cmp().active).toEqual('front'); fixture.detectChanges(); await vi.advanceTimersByTimeAsync(300); fixture.detectChanges(); - expect(component.cmp.active).toEqual('front'); + expect(component.cmp().active).toEqual('front'); - component.cmp.show('front'); - expect(component.cmp.active).toEqual('front'); + component.cmp().show('front'); + expect(component.cmp().active).toEqual('front'); fixture.detectChanges(); await vi.advanceTimersByTimeAsync(300); fixture.detectChanges(); - expect(component.cmp.active).toEqual('front'); + expect(component.cmp().active).toEqual('front'); - component.cmp.show('back'); - expect(component.cmp.active).toEqual('back'); + component.cmp().show('back'); + expect(component.cmp().active).toEqual('back'); fixture.detectChanges(); await vi.advanceTimersByTimeAsync(300); fixture.detectChanges(); - expect(component.cmp.active).toEqual('back'); + expect(component.cmp().active).toEqual('back'); }); it('should hide DOM nodes with removeHiddenNodes false', async () => { @@ -120,7 +120,7 @@ describe('ZvFlipContainer', () => { expect(frontEl.hidden).toEqual(false); expect(frontEl.children.length).toEqual(1); - component.cmp.toggleFlip(); + component.cmp().toggleFlip(); fixture.detectChanges(); await vi.advanceTimersByTimeAsync(300); @@ -146,7 +146,7 @@ describe('ZvFlipContainer', () => { expect(frontEl.hidden).toEqual(false); expect(frontEl.children.length).toEqual(1); - component.cmp.toggleFlip(); + component.cmp().toggleFlip(); fixture.detectChanges(); await vi.advanceTimersByTimeAsync(300); @@ -167,7 +167,7 @@ describe('ZvFlipContainer', () => { const flipBoxDbgEl = fixture.debugElement.query(By.css('.zv-flip-container__flip-box')).nativeElement as HTMLElement; expect(flipBoxDbgEl.className).toContain('zv-flip-container__flip-box--active-front'); - component.cmp.toggleFlip(); + component.cmp().toggleFlip(); fixture.detectChanges(); expect(flipBoxDbgEl.className).toContain('zv-flip-container__flip-box--active-back'); @@ -190,7 +190,7 @@ describe('ZvFlipContainer', () => { expect(frontEl.hasAttribute('inert')).toEqual(false); expect(backEl.hasAttribute('inert')).toEqual(true); - component.cmp.toggleFlip(); + component.cmp().toggleFlip(); fixture.detectChanges(); await vi.advanceTimersByTimeAsync(300); diff --git a/projects/components/flip-container/src/flip-container.component.ts b/projects/components/flip-container/src/flip-container.component.ts index fd82f0a..0b10217 100644 --- a/projects/components/flip-container/src/flip-container.component.ts +++ b/projects/components/flip-container/src/flip-container.component.ts @@ -4,9 +4,9 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, - ContentChild, ElementRef, TemplateRef, + contentChild, inject, input, viewChild, @@ -30,11 +30,9 @@ export class ZvFlipContainer implements AfterViewInit { return this._active; } - @ContentChild(FlipContainerFront, { read: TemplateRef }) - public _frontTemplate: TemplateRef | null = null; + public readonly _frontTemplate = contentChild(FlipContainerFront, { read: TemplateRef }); - @ContentChild(FlipContainerBack, { read: TemplateRef }) - public _backTemplate: TemplateRef | null = null; + public readonly _backTemplate = contentChild(FlipContainerBack, { read: TemplateRef }); public readonly _frontside = viewChild.required>('frontside'); public readonly _backside = viewChild.required>('backside'); diff --git a/projects/components/form-field/src/form-field.component.html b/projects/components/form-field/src/form-field.component.html index 291a0a9..67e4382 100644 --- a/projects/components/form-field/src/form-field.component.html +++ b/projects/components/form-field/src/form-field.component.html @@ -3,7 +3,7 @@ style="width: 100%" [class.mat-form-field--emulated]="emulated" [class.mat-form-field--no-underline]="noUnderline" - [floatLabel]="floatLabel" + [floatLabel]="resolvedFloatLabel" [hintLabel]="hintText" > @if (_labelChild) { @@ -16,13 +16,13 @@ {{ calculatedLabel }} } - @if (_prefixChildren.length) { + @if (_prefixChildren().length) { } - @if (_suffixChildren.length) { + @if (_suffixChildren().length) { diff --git a/projects/components/form-field/src/form-field.component.spec.ts b/projects/components/form-field/src/form-field.component.spec.ts index 1593621..b7612b6 100644 --- a/projects/components/form-field/src/form-field.component.spec.ts +++ b/projects/components/form-field/src/form-field.component.spec.ts @@ -1,17 +1,7 @@ import { HarnessLoader } from '@angular/cdk/testing'; import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; import { AsyncPipe } from '@angular/common'; -import { - ChangeDetectionStrategy, - ChangeDetectorRef, - Component, - DebugElement, - Injectable, - ViewChild, - inject, - signal, - viewChild, -} from '@angular/core'; +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, DebugElement, Injectable, inject, signal, viewChild } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { vi } from 'vitest'; import { FormControl, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms'; @@ -135,8 +125,8 @@ export class TestCheckboxComponent { public asyncLabel$ = of('async label'); formControl = new FormControl(''); - @ViewChild('f1', { static: true }) formFieldTemplateLabel: ZvFormField; - @ViewChild('f2', { static: true }) formFieldNoLabel: ZvFormField; + readonly formFieldTemplateLabel = viewChild.required('f1'); + readonly formFieldNoLabel = viewChild.required('f2'); } describe('ZvFormField', () => { @@ -220,8 +210,8 @@ describe('ZvFormField', () => { component.formControl.markAsTouched(); fixture.detectChanges(); - expect(component.formField()._ngControl.invalid).toBe(true); - expect(component.formField()._matFormField._control.errorState).toBe(true); + expect(component.formField()._ngControl()?.invalid).toBe(true); + expect(component.formField()._matFormField()._control.errorState).toBe(true); let errorsChecked = false; component.formField().errors$.subscribe((e) => { @@ -380,7 +370,7 @@ describe('ZvFormField', () => { const component = fixture.componentInstance; expect(component).toBeDefined(); - expect(component.formField().floatLabel).toEqual('auto'); + expect(component.formField().floatLabel()).toEqual('auto'); }); it('should priorize MAT_FORM_FIELD_DEFAULT_OPTIONS over its own settings', async () => { @@ -398,7 +388,7 @@ describe('ZvFormField', () => { const fixture = TestBed.createComponent(TestFormComponent); const component = fixture.componentInstance; expect(component).toBeDefined(); - expect(component.formField().floatLabel).toEqual('always'); + expect(component.formField().floatLabel()).toEqual('always'); }); }); diff --git a/projects/components/form-field/src/form-field.component.ts b/projects/components/form-field/src/form-field.component.ts index b53c4f7..106fa78 100644 --- a/projects/components/form-field/src/form-field.component.ts +++ b/projects/components/form-field/src/form-field.component.ts @@ -1,22 +1,21 @@ import { AsyncPipe, isPlatformServer } from '@angular/common'; -import type { QueryList } from '@angular/core'; import { AfterContentChecked, ChangeDetectionStrategy, Component, - ContentChild, - ContentChildren, ElementRef, - HostBinding, InjectionToken, - Input, - OnChanges, OnDestroy, PLATFORM_ID, - SimpleChanges, - ViewChild, ViewEncapsulation, + computed, + contentChild, + contentChildren, + effect, inject, + input, + untracked, + viewChild, } from '@angular/core'; import { FormControl, NgControl } from '@angular/forms'; import { MatIconButton } from '@angular/material/button'; @@ -64,44 +63,38 @@ function applyConfigDefaults(config: ZvFormFieldConfig | null): { changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None, imports: [MatFormField, MatLabel, MatPrefix, MatSuffix, MatIconButton, MatIcon, MatError, AsyncPipe], + host: { + '[class.zv-form-field--subscript-resize]': 'autoResizeHintError()', + }, }) -export class ZvFormField implements OnChanges, AfterContentChecked, OnDestroy { +export class ZvFormField implements AfterContentChecked, OnDestroy { private _elementRef = inject(ElementRef); private formsService = inject(ZvFormService); private defaults = applyConfigDefaults(inject(ZV_FORM_FIELD_CONFIG, { optional: true })); private matDefaults = inject(MAT_FORM_FIELD_DEFAULT_OPTIONS, { optional: true }); - @Input() public createLabel = true; - @Input() public hint = ''; - @Input() public floatLabel: FloatLabelType = this.matDefaults?.floatLabel || 'auto'; - @Input() public subscriptType: ZvFormFieldSubscriptType = (this.defaults ? this.defaults.subscriptType : null) ?? 'resize'; - @Input() public hintToggle: boolean | null = null; + public readonly createLabel = input(true); + public readonly hint = input(''); + public readonly floatLabel = input(this.matDefaults?.floatLabel || 'auto'); + public readonly subscriptType = input(this.defaults.subscriptType ?? 'resize'); + public readonly hintToggle = input(null); - @ViewChild(MatFormField, { static: true }) public _matFormField!: MatFormField; + public readonly _matFormField = viewChild.required(MatFormField); /** We can get the FromControl from this */ - @ContentChild(NgControl) public _ngControl: NgControl | null = null; + public readonly _ngControl = contentChild(NgControl); /** The MatFormFieldControl or null, if it is no MatFormFieldControl */ - @ContentChild(MatFormFieldControl) public _control: MatFormFieldControl | null = null; + public readonly _control = contentChild(MatFormFieldControl); /** The MatLabel, if it is set or null */ - @ContentChild(MatLabel) public set labelChild(value: MatLabel) { - this._labelChild = value; - this.updateLabel(); - if (this._matFormField) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access -- accessing Angular Material internal _changeDetectorRef - (this._matFormField as any)._changeDetectorRef.markForCheck(); - } - } + public readonly labelChild = contentChild(MatLabel); public _labelChild: MatLabel | null = null; - @ContentChildren(MatPrefix) public _prefixChildren!: QueryList; - @ContentChildren(MatSuffix) public _suffixChildren!: QueryList; + public readonly _prefixChildren = contentChildren(MatPrefix); + public readonly _suffixChildren = contentChildren(MatSuffix); - @HostBinding('class.zv-form-field--subscript-resize') public get autoResizeHintError() { - return this.subscriptType === 'resize'; - } + public readonly autoResizeHintError = computed(() => this.subscriptType() === 'resize'); // mat-form-field childs, that we dont support: // @ContentChild(MatPlaceholder) _placeholderChild: MatPlaceholder; // Deprecated, placeholder attribute of the form field control should be used instead! @@ -109,11 +102,12 @@ export class ZvFormField implements OnChanges, AfterContentChecked, OnDestroy { // @ContentChildren(MatHint) public _hintChildren: QueryList; // No idea how to make this work... public get hintToggleOptionActive(): boolean { - return typeof this.hintToggle === 'boolean' ? this.hintToggle : this.defaults.hintToggle; + const toggle = this.hintToggle(); + return typeof toggle === 'boolean' ? toggle : this.defaults.hintToggle; } public get showHintToggle(): boolean { - return !!this.hint && this.hintToggleOptionActive; + return !!this.hint() && this.hintToggleOptionActive; } public get hintText(): string { @@ -123,14 +117,15 @@ export class ZvFormField implements OnChanges, AfterContentChecked, OnDestroy { return ''; } - const isRequired = this._control?.required; - const isDisabled = this._control?.disabled; + const control = this._control(); + const isRequired = control?.required; + const isDisabled = control?.disabled; if (!isRequired || isDisabled) { - return this.hint; + return this.hint(); } const requiredText = this.defaults?.requiredText; - return [requiredText, this.hint].filter((s) => !!s).join('. '); + return [requiredText, this.hint()].filter((s) => !!s).join('. '); } /** The error messages to show */ @@ -144,6 +139,14 @@ export class ZvFormField implements OnChanges, AfterContentChecked, OnDestroy { public showHint = false; public calculatedLabel: string | null = null; + /** Mutable override for floatLabel when emulated controls need 'always' */ + public _floatLabelOverride: FloatLabelType | null = null; + + /** Resolved float label: override takes precedence over input */ + public get resolvedFloatLabel(): FloatLabelType { + return this._floatLabelOverride ?? this.floatLabel(); + } + private formControl: FormControl | null = null; /** Either the MatFormFieldControl or a DummyMatFormFieldControl */ @@ -161,17 +164,37 @@ export class ZvFormField implements OnChanges, AfterContentChecked, OnDestroy { private isServer = isPlatformServer(inject(PLATFORM_ID)); - public ngOnChanges(changes: SimpleChanges) { - if (changes.hintToggle) { - this.showHint = !this.hintToggleOptionActive; - } + constructor() { + // Replace labelChild setter — track contentChild and run side effects + effect(() => { + const label = this.labelChild(); + this._labelChild = label ?? null; + untracked(() => { + this.updateLabel(); + const matFormField = this._matFormField(); + if (matFormField) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access -- accessing Angular Material internal _changeDetectorRef + (matFormField as any)._changeDetectorRef.markForCheck(); + } + }); + }); + + // Replace ngOnChanges — track hintToggle input + effect(() => { + this.hintToggle(); + untracked(() => { + this.showHint = !this.hintToggleOptionActive; + }); + }); } public ngAfterContentChecked(): void { if (this.initialized) { return; } - this.formControl = this._ngControl && (this._ngControl.control as FormControl); + const ngControl = this._ngControl(); + const control = this._control(); + this.formControl = ngControl ? (ngControl.control as FormControl) : null; // Slider is not initialized the first time we enter this method, therefore we need to check if it got initialized already or not if (this.formControl) { this.initialized = true; @@ -180,8 +203,8 @@ export class ZvFormField implements OnChanges, AfterContentChecked, OnDestroy { if (this.matFormFieldControl instanceof DummyMatFormFieldControl) { this.matFormFieldControl.ngOnDestroy(); } - this.matFormFieldControl = this._control || new DummyMatFormFieldControl(this._ngControl, this.formControl); - this._matFormField._control = this.matFormFieldControl; + this.matFormFieldControl = control || new DummyMatFormFieldControl(ngControl ?? null, this.formControl); + this._matFormField()._control = this.matFormFieldControl; this.emulated = this.matFormFieldControl instanceof DummyMatFormFieldControl; // This tells the mat-input that it is inside a mat-form-field // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access -- accessing Angular Material internal _isInFormField @@ -189,14 +212,14 @@ export class ZvFormField implements OnChanges, AfterContentChecked, OnDestroy { // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access -- accessing Angular Material internal _isInFormField (this.matFormFieldControl as any)._isInFormField = true; } - this.realFormControl = getRealFormControl(this._ngControl, this.matFormFieldControl); + this.realFormControl = getRealFormControl(ngControl, this.matFormFieldControl); this.controlType = this.formsService.getControlType(this.realFormControl) || 'unknown'; // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access this._elementRef.nativeElement.classList.add(`zv-form-field-type-${this.controlType}`); this.noUnderline = this.emulated || !!this.realFormControl.noUnderline; - if (this.floatLabel === 'auto' && (this.emulated || this.realFormControl.shouldLabelFloat === undefined)) { - this.floatLabel = 'always'; + if (this.floatLabel() === 'auto' && (this.emulated || this.realFormControl.shouldLabelFloat === undefined)) { + this._floatLabelOverride = 'always'; } if (this.formControl) { @@ -231,7 +254,7 @@ export class ZvFormField implements OnChanges, AfterContentChecked, OnDestroy { return; } this.calculatedLabel = null; - if (!this.createLabel || this._labelChild || !this.formControl) { + if (!this.createLabel() || this._labelChild || !this.formControl) { return; } @@ -265,13 +288,13 @@ export class ZvFormField implements OnChanges, AfterContentChecked, OnDestroy { // when only our own component is marked for check, then the label will not be shown // when labelText$ didn't run synchronously // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access -- accessing Angular Material internal _changeDetectorRef - (this._matFormField as any)._changeDetectorRef.markForCheck(); + (this._matFormField() as any)._changeDetectorRef.markForCheck(); }); } } function getRealFormControl( - ngControl: NgControl | null, + ngControl: NgControl | null | undefined, matFormFieldControl: MatFormFieldControl ): { noUnderline?: boolean; shouldLabelFloat?: boolean } { if (!(matFormFieldControl instanceof DummyMatFormFieldControl) || !ngControl) { diff --git a/projects/components/form/src/form.component.html b/projects/components/form/src/form.component.html index d48380b..79b381e 100644 --- a/projects/components/form/src/form.component.html +++ b/projects/components/form/src/form.component.html @@ -1,4 +1,4 @@ -@if (dataSource) { +@if (dataSource()) {
@if (contentVisible) { diff --git a/projects/components/form/src/form.component.spec.ts b/projects/components/form/src/form.component.spec.ts index 35483cc..53b073b 100644 --- a/projects/components/form/src/form.component.spec.ts +++ b/projects/components/form/src/form.component.spec.ts @@ -1,5 +1,5 @@ import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; -import { ChangeDetectionStrategy, Component, DebugElement, Injectable, ViewChild, signal } from '@angular/core'; +import { ChangeDetectionStrategy, Component, DebugElement, Injectable, signal, viewChild } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { FormControl, FormGroup } from '@angular/forms'; import { MatButtonHarness } from '@angular/material/button/testing'; @@ -52,8 +52,7 @@ class TestZvFormService extends BaseZvFormService { }) export class TestDataSourceComponent { public readonly dataSource = signal(undefined); - @ViewChild(ZvForm) - formComponent: ZvForm; + readonly formComponent = viewChild(ZvForm); } describe('ZvForm', () => { @@ -304,12 +303,12 @@ describe('ZvForm', () => { threshold: 0, }); expect(getErrorContainer(fixture)).not.toBe(null); - expect(observedEl).toBe(component.formComponent.errorCardWrapper.nativeElement); - component.formComponent.errorCardWrapper.nativeElement.scrollIntoView ??= () => {}; - vi.spyOn(component.formComponent.errorCardWrapper.nativeElement, 'scrollIntoView'); + expect(observedEl).toBe(component.formComponent().errorCardWrapper().nativeElement); + component.formComponent().errorCardWrapper().nativeElement.scrollIntoView ??= () => {}; + vi.spyOn(component.formComponent().errorCardWrapper().nativeElement, 'scrollIntoView'); opts.scrollToError(); - expect(component.formComponent.errorCardWrapper.nativeElement.scrollIntoView).toHaveBeenCalledTimes(1); + expect(component.formComponent().errorCardWrapper().nativeElement.scrollIntoView).toHaveBeenCalledTimes(1); intersectCallback([{ intersectionRatio: 1 }]); intersectCallback([{ intersectionRatio: 1 }]); diff --git a/projects/components/form/src/form.component.ts b/projects/components/form/src/form.component.ts index 94e4066..3963db7 100644 --- a/projects/components/form/src/form.component.ts +++ b/projects/components/form/src/form.component.ts @@ -6,12 +6,15 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, - Input, OnDestroy, PLATFORM_ID, - ViewChild, ViewEncapsulation, + effect, inject, + input, + signal, + untracked, + viewChild, } from '@angular/core'; import { FormGroup, ReactiveFormsModule } from '@angular/forms'; import { MatCard, MatCardContent } from '@angular/material/card'; @@ -40,39 +43,22 @@ export const dependencies = { export class ZvForm implements AfterViewInit, AfterViewChecked, OnDestroy { private readonly cd = inject(ChangeDetectorRef); - @Input({ required: true }) public set dataSource(value: IZvFormDataSource) { - if (this._dataSource) { - this._dataSource.disconnect(); - this._dataSourceSub.unsubscribe(); - } - - this._dataSource = value; - - this.updateErrorCardObserver(); - - if (this._dataSource) { - this.activateDataSource(); - } - } - public get dataSource(): IZvFormDataSource { - return this._dataSource; - } - private _dataSource!: IZvFormDataSource; + public readonly dataSource = input.required(); public get autocomplete() { - return this.dataSource.autocomplete; + return this.dataSource().autocomplete; } public get form(): FormGroup { - return this.dataSource.form; + return this.dataSource().form; } public get buttons(): IZvButton[] { - return this.dataSource.buttons; + return this.dataSource().buttons; } public get progress(): number | null | undefined { - return this.dataSource.progress; + return this.dataSource().progress; } public get showProgress(): boolean { @@ -80,7 +66,7 @@ export class ZvForm implements AfterViewInit, AfterViewChecked, OnDestroy { } public get savebarMode(): string { - return this.dataSource.savebarMode || 'auto'; + return this.dataSource().savebarMode || 'auto'; } public get savebarHidden(): boolean { @@ -95,29 +81,44 @@ export class ZvForm implements AfterViewInit, AfterViewChecked, OnDestroy { } public get contentVisible(): boolean { - return this.dataSource.contentVisible; + return this.dataSource().contentVisible; } public get contentBlocked(): boolean { - return this.dataSource.contentBlocked; + return this.dataSource().contentBlocked; } public get exception(): IZvException | null { - return this.dataSource.exception; + return this.dataSource().exception; } - @ViewChild('errorCardWrapper') public errorCardWrapper: ElementRef | null = null; + public readonly errorCardWrapper = viewChild('errorCardWrapper'); private _dataSourceSub = Subscription.EMPTY; private _errorCardObserver: IntersectionObserver | null = null; - private _viewReady = false; + private readonly _viewReady = signal(false); private _errrorInView$ = new BehaviorSubject(false); private isServer = isPlatformServer(inject(PLATFORM_ID)); + constructor() { + effect((onCleanup) => { + const ds = this.dataSource(); + const ready = this._viewReady(); + untracked(() => { + this.updateErrorCardObserver(); + if (ready) { + this.activateDataSource(); + } + }); + onCleanup(() => { + this._dataSourceSub.unsubscribe(); + ds?.disconnect(); + }); + }); + } + public ngAfterViewInit() { - this._viewReady = true; - this.updateErrorCardObserver(); - this.activateDataSource(); + this._viewReady.set(true); } public ngAfterViewChecked() { @@ -131,15 +132,11 @@ export class ZvForm implements AfterViewInit, AfterViewChecked, OnDestroy { } this._errrorInView$.complete(); - - this._dataSourceSub.unsubscribe(); - if (this._dataSource) { - this._dataSource.disconnect(); - } } private activateDataSource() { - if (!this._viewReady || !this._dataSource) { + const ds = this.dataSource(); + if (!this._viewReady() || !ds) { return; } @@ -147,11 +144,11 @@ export class ZvForm implements AfterViewInit, AfterViewChecked, OnDestroy { errorInView$: this._errrorInView$.pipe(distinctUntilChanged()), scrollToError: () => { // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access - this.errorCardWrapper?.nativeElement.scrollIntoView({ behavior: 'smooth' }); + this.errorCardWrapper()?.nativeElement.scrollIntoView({ behavior: 'smooth' }); }, } as IZvFormDataSourceConnectOptions; - this._dataSourceSub = this._dataSource.connect(options).subscribe(() => { + this._dataSourceSub = ds.connect(options).subscribe(() => { this.cd.markForCheck(); }); } @@ -160,7 +157,9 @@ export class ZvForm implements AfterViewInit, AfterViewChecked, OnDestroy { if (this.isServer) { return; } - if (!this._errorCardObserver && this._dataSource && this._viewReady && this.errorCardWrapper) { + const ds = this.dataSource(); + const errorCardWrapper = this.errorCardWrapper(); + if (!this._errorCardObserver && ds && this._viewReady() && errorCardWrapper) { const options = { root: null, // relative to document viewport rootMargin: '-100px', // margin around root. Values are similar to css property. Unitless values not allowed @@ -175,8 +174,8 @@ export class ZvForm implements AfterViewInit, AfterViewChecked, OnDestroy { }, options); // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - this._errorCardObserver.observe(this.errorCardWrapper.nativeElement); - } else if (this._errorCardObserver && !this._dataSource) { + this._errorCardObserver.observe(errorCardWrapper.nativeElement); + } else if (this._errorCardObserver && !ds) { this._errorCardObserver.disconnect(); this._errorCardObserver = null; } diff --git a/projects/components/header/src/header.component.html b/projects/components/header/src/header.component.html index b98e991..8a6c5c8 100644 --- a/projects/components/header/src/header.component.html +++ b/projects/components/header/src/header.component.html @@ -1,21 +1,21 @@
- @if (caption) { - {{ caption }} + @if (caption()) { + {{ caption() }} } @else { - + }
- @if (description) { - {{ description }} + @if (description()) { + {{ description() }} } @else { - + }
-@if (topButtonSection) { +@if (topButtonSection()) {
- +
} diff --git a/projects/components/header/src/header.component.spec.ts b/projects/components/header/src/header.component.spec.ts index 0649d6c..6e2bde3 100644 --- a/projects/components/header/src/header.component.spec.ts +++ b/projects/components/header/src/header.component.spec.ts @@ -1,6 +1,6 @@ import { HarnessLoader } from '@angular/cdk/testing'; import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; -import { ChangeDetectionStrategy, Component, signal, ViewChild } from '@angular/core'; +import { ChangeDetectionStrategy, Component, signal, viewChild } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { MatButtonModule } from '@angular/material/button'; import { ZvHeader, ZvHeaderModule } from '..'; @@ -37,7 +37,7 @@ export class TestDataSourceComponent { public readonly addButtons = signal(false); public readonly addCaptionTemplate = signal(false); public readonly addDescriptionTemplate = signal(false); - @ViewChild(ZvHeader) headerComponent: ZvHeader; + readonly headerComponent = viewChild(ZvHeader); } describe('ZvHeader', () => { diff --git a/projects/components/header/src/header.component.ts b/projects/components/header/src/header.component.ts index 6ffdef9..8b0858d 100644 --- a/projects/components/header/src/header.component.ts +++ b/projects/components/header/src/header.component.ts @@ -1,4 +1,4 @@ -import { ChangeDetectionStrategy, Component, ContentChild, Input, TemplateRef, ViewEncapsulation } from '@angular/core'; +import { ChangeDetectionStrategy, Component, contentChild, input, TemplateRef, ViewEncapsulation } from '@angular/core'; import { ZvHeaderCaptionSection, ZvHeaderDescriptionSection, ZvHeaderTopButtonSection } from './header.directives'; import { NgTemplateOutlet } from '@angular/common'; @@ -11,15 +11,10 @@ import { NgTemplateOutlet } from '@angular/common'; imports: [NgTemplateOutlet], }) export class ZvHeader { - @Input() public caption: string | null = null; - @Input() public description: string | null = null; + public readonly caption = input(null); + public readonly description = input(null); - @ContentChild(ZvHeaderCaptionSection, { read: TemplateRef }) - public captionSection: TemplateRef | null = null; - - @ContentChild(ZvHeaderDescriptionSection, { read: TemplateRef }) - public descriptionSection: TemplateRef | null = null; - - @ContentChild(ZvHeaderTopButtonSection, { read: TemplateRef }) - public topButtonSection: TemplateRef | null = null; + public readonly captionSection = contentChild(ZvHeaderCaptionSection, { read: TemplateRef }); + public readonly descriptionSection = contentChild(ZvHeaderDescriptionSection, { read: TemplateRef }); + public readonly topButtonSection = contentChild(ZvHeaderTopButtonSection, { read: TemplateRef }); } diff --git a/projects/components/number-input/src/number-input.component.html b/projects/components/number-input/src/number-input.component.html index d7b9ac3..dd2750d 100644 --- a/projects/components/number-input/src/number-input.component.html +++ b/projects/components/number-input/src/number-input.component.html @@ -4,10 +4,10 @@ type="text" inputmode="decimal" [value]="_formattedValue || null" - [attr.aria-valuemin]="min" - [attr.aria-valuemax]="max" + [attr.aria-valuemin]="min()" + [attr.aria-valuemax]="max()" [attr.aria-valuenow]="value" - [attr.tabindex]="tabindex" + [attr.tabindex]="tabindex()" [attr.placeholder]="placeholder" [disabled]="disabled" [readonly]="readonly" diff --git a/projects/components/number-input/src/number-input.component.spec.ts b/projects/components/number-input/src/number-input.component.spec.ts index 68043c5..38f6d53 100644 --- a/projects/components/number-input/src/number-input.component.spec.ts +++ b/projects/components/number-input/src/number-input.component.spec.ts @@ -59,8 +59,8 @@ describe('ZvNumberInput', () => { expect(clearTimerSpy).toHaveBeenCalledTimes(7); }); - it('Should display the spinner value 0.75 ', () => { - spinner.stepSize = 0.25; + it('Should display the spinner value 0.75', () => { + fixture.componentRef.setInput('stepSize', 0.25); fixture.detectChanges(); const spinnerUp = fixture.nativeElement.querySelector('.zv-number-input__button-up'); @@ -74,8 +74,8 @@ describe('ZvNumberInput', () => { it('Should display the formatted value with thousand and decimal separator when input is filled by value 1234.1234', () => { fixture.detectChanges(); - spinner.decimals = 4; - const spinnerInput = spinner._inputfieldViewChild.nativeElement; + fixture.componentRef.setInput('decimals', 4); + const spinnerInput = spinner._inputfieldViewChild()!.nativeElement; spinnerInput.value = '1234.1234'; triggerEvent(spinnerInput, 'input'); @@ -100,7 +100,7 @@ describe('ZvNumberInput', () => { fixture.detectChanges(); spinner.disabled = true; - const spinnerInput = spinner._inputfieldViewChild.nativeElement; + const spinnerInput = spinner._inputfieldViewChild()!.nativeElement; spinnerInput.value = '1'; triggerEvent(spinnerInput, 'keyup'); fixture.detectChanges(); @@ -112,7 +112,7 @@ describe('ZvNumberInput', () => { }); it('should have a max', () => { - spinner.max = 1; + fixture.componentRef.setInput('max', 1); fixture.detectChanges(); const spinnerUp = fixture.nativeElement.querySelector('.zv-number-input__button-up'); triggerEvent(spinnerUp, 'mousedown'); @@ -132,7 +132,7 @@ describe('ZvNumberInput', () => { }); it('should have a min', () => { - spinner.min = -1; + fixture.componentRef.setInput('min', -1); fixture.detectChanges(); const spinnerUp = fixture.nativeElement.querySelector('.zv-number-input__button-down'); triggerEvent(spinnerUp, 'mousedown'); @@ -168,7 +168,7 @@ describe('ZvNumberInput', () => { it('should change placeholder tabindex and required', () => { spinner.placeholder = 'PLACEHOLDER'; - spinner.tabindex = 13; + fixture.componentRef.setInput('tabindex', 13); spinner.required = true; fixture.detectChanges(); @@ -200,7 +200,7 @@ describe('ZvNumberInput', () => { it('should format input', () => { spinner._thousandSeparator = ','; spinner._decimalSeparator = '.'; - spinner.stepSize = 0.25; + fixture.componentRef.setInput('stepSize', 0.25); spinner.value = 10000; fixture.detectChanges(); diff --git a/projects/components/number-input/src/number-input.component.ts b/projects/components/number-input/src/number-input.component.ts index 7fbdc03..434c8af 100644 --- a/projects/components/number-input/src/number-input.component.ts +++ b/projects/components/number-input/src/number-input.component.ts @@ -1,9 +1,4 @@ -/* eslint-disable @angular-eslint/no-conflicting-lifecycle -- - Both DoCheck and OnChanges are required: OnChanges notifies MatFormField - of input changes via stateChanges.next(), while DoCheck runs - updateErrorState() which depends on parent form submission state that - cannot be observed reactively. This follows Angular Material's own - MatInput implementation. */ +/* eslint-disable @angular-eslint/prefer-signals -- MatFormFieldControl/CVA properties must remain as @Input decorators (see design decision D2) */ import { coerceBooleanProperty } from '@angular/cdk/coercion'; import type { ElementRef } from '@angular/core'; import { @@ -11,16 +6,18 @@ import { ChangeDetectorRef, Component, DoCheck, - EventEmitter, Input, LOCALE_ID, - OnChanges, OnDestroy, OnInit, - Output, - ViewChild, ViewEncapsulation, + computed, + effect, inject, + input, + output, + untracked, + viewChild, } from '@angular/core'; import { ControlValueAccessor, FormGroupDirective, NgControl, NgForm } from '@angular/forms'; import { _ErrorStateTracker, ErrorStateMatcher } from '@angular/material/core'; @@ -53,37 +50,37 @@ let nextUniqueId = 0; encapsulation: ViewEncapsulation.None, imports: [MatIcon], }) -export class ZvNumberInput implements ControlValueAccessor, MatFormFieldControl, OnChanges, OnDestroy, OnInit, DoCheck { +export class ZvNumberInput implements ControlValueAccessor, MatFormFieldControl, OnDestroy, OnInit, DoCheck { + // Signal inputs (access via .inputName()): min, max, tabindex, decimals, stepSize + // Getter/setter properties (access via .propName): disabled, required, placeholder, value, id, readonly, errorStateMatcher + public readonly ngControl = inject(NgControl, { optional: true, self: true }); private readonly cd = inject(ChangeDetectorRef); private readonly localeId = inject(LOCALE_ID); /** Mininum boundary value. */ - @Input() min: number | null = null; + public readonly min = input(null); /** Maximum boundary value. */ - @Input() max: number | null = null; + public readonly max = input(null); /** Index of the element in tabbing order. */ - @Input() tabindex: number | null = null; + public readonly tabindex = input(null); /** Number of allowed decimal places. */ - @Input() decimals: number | null = null; + public readonly decimals = input(null); /** Step factor to increment/decrement the value. */ - @Input() - get stepSize(): number { - return this._stepSize; - } - set stepSize(val: number) { - this._stepSize = val; + public readonly stepSize = input(1); - if (this._stepSize != null) { - const tokens = this.stepSize.toString().split(/[,]|[.]/); - this._calculatedDecimals = tokens[1] ? tokens[1].length : null; + public readonly _calculatedDecimals = computed(() => { + const val = this.stepSize(); + if (val != null) { + const tokens = val.toString().split(/[,]|[.]/); + return tokens[1] ? tokens[1].length : null; } - } - _stepSize = 1; + return null; + }); /** * Implemented as part of MatFormFieldControl. @@ -164,6 +161,7 @@ export class ZvNumberInput implements ControlValueAccessor, MatFormFieldControl< } set required(value: boolean) { this._required = coerceBooleanProperty(value); + this.stateChanges.next(); this.cd.markForCheck(); } protected _required = false; @@ -203,7 +201,7 @@ export class ZvNumberInput implements ControlValueAccessor, MatFormFieldControl< } _value: number | null = null; - @Output() public readonly valueChange = new EventEmitter(); + public readonly valueChange = output(); /** Whether the element is readonly. */ @Input() @@ -242,11 +240,9 @@ export class ZvNumberInput implements ControlValueAccessor, MatFormFieldControl< _timer: ReturnType | null = null; _decimalSeparator!: string; _thousandSeparator!: string; - _calculatedDecimals: number | null = null; _errorStateTracker: _ErrorStateTracker; - @ViewChild('inputfield', { static: true }) - _inputfieldViewChild!: ElementRef; + public readonly _inputfieldViewChild = viewChild>('inputfield'); _onModelChange = (_val: number | null) => {}; _onModelTouched = () => {}; @@ -268,6 +264,16 @@ export class ZvNumberInput implements ControlValueAccessor, MatFormFieldControl< _parentForm, this.stateChanges ); + + effect(() => { + const el = this._inputfieldViewChild(); + if (el) { + untracked(() => this._formatValue()); + } + }); + + // No effect needed: min/max/decimals/stepSize/tabindex don't affect MatFormField display. + // Properties that do (required, disabled, value) notify via their setters. } ngOnInit() { @@ -280,10 +286,6 @@ export class ZvNumberInput implements ControlValueAccessor, MatFormFieldControl< this._thousandSeparator = intlParts.find((part) => part.type === 'group')!.value; } - ngOnChanges() { - this.stateChanges.next(); - } - ngOnDestroy() { this._clearTimer(); this.stateChanges.complete(); @@ -325,7 +327,7 @@ export class ZvNumberInput implements ControlValueAccessor, MatFormFieldControl< /** Focuses the input. */ focus(options?: FocusOptions): void { - this._inputfieldViewChild.nativeElement.focus(options); + this._inputfieldViewChild()?.nativeElement.focus(options); } writeValue(value: number | null): void { @@ -362,7 +364,7 @@ export class ZvNumberInput implements ControlValueAccessor, MatFormFieldControl< } _spin(_event: Event, dir: number) { - const step = this.stepSize * dir; + const step = this.stepSize() * dir; const newValue = this._fixNumber((this.value ?? 0) + step); this.value = newValue; this._onModelChange(newValue); @@ -391,13 +393,14 @@ export class ZvNumberInput implements ControlValueAccessor, MatFormFieldControl< this._formattedValue = value.toLocaleString(this.localeId, { maximumFractionDigits: decimals ?? undefined }); } - if (this._inputfieldViewChild && this._inputfieldViewChild.nativeElement) { - this._inputfieldViewChild.nativeElement.value = this._formattedValue; + const viewChild = this._inputfieldViewChild(); + if (viewChild?.nativeElement) { + viewChild.nativeElement.value = this._formattedValue; } } _getDecimals() { - return this.decimals === null ? this._calculatedDecimals : this.decimals; + return this.decimals() === null ? this._calculatedDecimals() : this.decimals(); } _toFixed(value: number, decimals: number) { @@ -417,12 +420,14 @@ export class ZvNumberInput implements ControlValueAccessor, MatFormFieldControl< return null; } - if (this.max !== null && value > this.max) { - value = this.max; + const max = this.max(); + if (max !== null && value > max) { + value = max; } - if (this.min !== null && value < this.min) { - value = this.min; + const min = this.min(); + if (min !== null && value < min) { + value = min; } return value; @@ -430,7 +435,7 @@ export class ZvNumberInput implements ControlValueAccessor, MatFormFieldControl< _onUpButtonMousedown(event: Event) { if (!this.disabled) { - this._inputfieldViewChild.nativeElement.focus(); + this._inputfieldViewChild()?.nativeElement.focus(); this._repeat(event, null, 1); event.preventDefault(); } @@ -450,7 +455,7 @@ export class ZvNumberInput implements ControlValueAccessor, MatFormFieldControl< _onDownButtonMousedown(event: Event) { if (!this.disabled) { - this._inputfieldViewChild.nativeElement.focus(); + this._inputfieldViewChild()?.nativeElement.focus(); this._repeat(event, null, -1); event.preventDefault(); } diff --git a/projects/components/select/src/select.component.html b/projects/components/select/src/select.component.html index 4f0fbdd..fdfda2c 100644 --- a/projects/components/select/src/select.component.html +++ b/projects/components/select/src/select.component.html @@ -1,19 +1,19 @@ -
+
- @if (!multiple && triggerTemplate && !empty) { + @if (!multiple() && triggerTemplate && !empty) { @if (triggerTemplate) { @@ -21,7 +21,7 @@ } - @if (multiple && !empty) { + @if (multiple() && !empty) {
@if (!triggerTemplate) { @@ -31,7 +31,7 @@ }
- @if ($customTriggerDataArray().length > 1 && selectedLabel) { + @if ($customTriggerDataArray().length > 1 && selectedLabel()) {
({{ $customTriggerDataArray().length }} selected)
@@ -43,7 +43,7 @@ - @if (!optionTemplate) { + @if (!optionTemplate()) { {{ item.label }} } - @if (optionTemplate) { - + @if (optionTemplate()) { + } } diff --git a/projects/components/select/src/select.component.spec.ts b/projects/components/select/src/select.component.spec.ts index aeb8ad0..4c35efb 100644 --- a/projects/components/select/src/select.component.spec.ts +++ b/projects/components/select/src/select.component.spec.ts @@ -8,8 +8,8 @@ import { OnDestroy, QueryList, Type, - ViewChild, inject, + viewChild, signal, } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; @@ -121,9 +121,16 @@ function createZvSelect(options?: { dataSource?: ZvSelectDataSource; service?: Z const fixture = TestBed.createComponent(ZvSelect); const component = fixture.componentInstance; - component.setMatSelect = matSelect; + (component as any)._matSelect = matSelect; component.dataSource = dataSource; - return { component: component, matSelect: matSelect, service: service, dataSource: dataSource, focused: component.focused }; + return { + fixture: fixture, + component: component, + matSelect: matSelect, + service: service, + dataSource: dataSource, + focused: component.focused, + }; } @Component({ @@ -154,8 +161,7 @@ export class TestComponent implements OnDestroy { readonly panelClass = signal>({}); readonly clearable = signal(true); - @ViewChild(ZvSelect, { static: true }) - select: ZvSelect; + readonly select = viewChild.required(ZvSelect); private valuesSubscription: Subscription; constructor() { @@ -209,8 +215,7 @@ export class TestMultipleComponent { readonly selectedLabel = signal(true); readonly customTemplate = signal(false); - @ViewChild(ZvSelect, { static: true }) - select: ZvSelect; + readonly select = viewChild.required(ZvSelect); } @Component({ @@ -314,11 +319,11 @@ describe('ZvSelect', () => { it('should use right default values', () => { const { component } = createZvSelect(); - expect(component.clearable).toBe(true); - expect(component.showToggleAll).toBe(true); - expect(component.multiple).toBe(false); + expect(component.clearable()).toBe(true); + expect(component.showToggleAll()).toBe(true); + expect(component.multiple()).toBe(false); expect(component.errorStateMatcher).toBe(undefined); - expect(component.panelClass).toBe(''); + expect(component.panelClass()).toBe(''); expect(component.placeholder).toBe(''); expect(component.required).toBe(false); expect(component.disabled).toBe(false); @@ -326,8 +331,8 @@ describe('ZvSelect', () => { }); it('should determine empty value correctly', () => { - const { component } = createZvSelect(); - component.multiple = true; + const { fixture, component } = createZvSelect(); + fixture.componentRef.setInput('multiple', true); const items = [ { value: 1, label: 'i1', hidden: false }, @@ -347,7 +352,7 @@ describe('ZvSelect', () => { component.value = items; expect(component.empty).toBe(false); - component.multiple = false; + fixture.componentRef.setInput('multiple', false); component.value = null; expect(component.empty).toBe(true); @@ -394,8 +399,12 @@ describe('ZvSelect', () => { it('should fix MatSelect.close() not emitting stateChanges', () => { const matSelect = createFakeMatSelect(); - const { component } = createZvSelect(); - component.setMatSelect = matSelect; + // Manually apply the same monkey-patching that afterNextRender does + const originalClose = matSelect.close; + matSelect.close = () => { + originalClose.call(matSelect); + matSelect.stateChanges.next(); + }; vi.spyOn(matSelect.stateChanges, 'next'); @@ -541,11 +550,11 @@ describe('ZvSelect', () => { fixture.detectChanges(); await flushMicrotasks(); fixture.detectChanges(); - expect(component.select.focused).toBe(false); + expect(component.select().focused).toBe(false); await zvSelect.open(); - expect(component.select.focused).toBe(true); + expect(component.select().focused).toBe(true); await zvSelect.close(); - expect(component.select.focused).toBe(false); + expect(component.select().focused).toBe(false); }); it('should set the right css classes', async () => { @@ -580,12 +589,12 @@ describe('ZvSelect', () => { errorState = false; // Required - component.select.required = true; - (component.select as any).cd.markForCheck(); + component.select().required = true; + (component.select() as any).cd.markForCheck(); fixture.detectChanges(); fixture.detectChanges(); assertZvSelectCssClasses(fixture, ['zv-select', 'zv-select-required']); - component.select.required = false; + component.select().required = false; // mat-option component.panelClass.set({ 'custom-mat-option-class': true }); diff --git a/projects/components/select/src/select.component.ts b/projects/components/select/src/select.component.ts index dba4912..905e7b3 100644 --- a/projects/components/select/src/select.component.ts +++ b/projects/components/select/src/select.component.ts @@ -1,23 +1,24 @@ +/* eslint-disable @angular-eslint/prefer-signals -- MatFormFieldControl/CVA properties must remain as @Input decorators (see design decision D2) */ import { NgTemplateOutlet } from '@angular/common'; import { ChangeDetectionStrategy, ChangeDetectorRef, Component, - ContentChild, DoCheck, - EventEmitter, - HostBinding, Input, OnDestroy, OnInit, - Output, TemplateRef, - ViewChild, ViewEncapsulation, + afterNextRender, booleanAttribute, computed, + contentChild, + input, + output, signal, inject, + viewChild, } from '@angular/core'; import { ControlValueAccessor, FormControl, FormGroupDirective, FormsModule, NgControl, NgForm, ReactiveFormsModule } from '@angular/forms'; import { MatIconButton } from '@angular/material/button'; @@ -52,7 +53,8 @@ const enum ValueChangeSource { encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush, host: { - '[class.zv-select-multiple]': 'multiple', + '[id]': 'id', + '[class.zv-select-multiple]': 'multiple()', '[class.zv-select-disabled]': 'disabled', '[class.zv-select-invalid]': 'errorState', '[class.zv-select-required]': 'required', @@ -74,35 +76,24 @@ const enum ValueChangeSource { ZvErrorMessagePipe, ], }) +// D11: CVA+MatFormFieldControl — dataSource, value, disabled, required, placeholder, errorStateMatcher +// kept as getter/setter @Input; simple inputs migrated to signal input(); outputs to output(). export class ZvSelect implements ControlValueAccessor, MatFormFieldControl, DoCheck, OnInit, OnDestroy { private readonly cd = inject(ChangeDetectorRef); private readonly selectService = inject(ZvSelectService, { optional: true }); public readonly ngControl = inject(NgControl, { optional: true, self: true }); public static nextId = 0; - @HostBinding() public id = `zv-select-${ZvSelect.nextId++}`; + public id = `zv-select-${ZvSelect.nextId++}`; - @ContentChild(ZvSelectOptionTemplate, { read: TemplateRef }) - public optionTemplate: TemplateRef | null = null; - - @ContentChild(ZvSelectTriggerTemplate) - public customTrigger: ZvSelectTriggerTemplate | null = null; + public readonly optionTemplate = contentChild(ZvSelectOptionTemplate, { read: TemplateRef }); + public readonly customTrigger = contentChild(ZvSelectTriggerTemplate); public get triggerTemplate(): TemplateRef | null { - return this.customTrigger?.templateRef ?? null; + return this.customTrigger()?.templateRef ?? null; } - @ViewChild(MatSelect, { static: true }) public set setMatSelect(select: MatSelect) { - this._matSelect = select; - - // MatSelect doesn't trigger stateChanges on close which causes problems, so we patch it here. - // eslint-disable-next-line @typescript-eslint/unbound-method - const close = select.close; - select.close = () => { - close.call(select); - select.stateChanges.next(); - }; - } + public readonly _matSelectQuery = viewChild.required(MatSelect); /** * Stream containing the latest information on what rows are being displayed on screen. @@ -137,14 +128,14 @@ export class ZvSelect implements ControlValueAccessor, MatFormField private _value: T | null = null; /** If true, then there will be a empty option available to deselect any values (only single select mode) */ - @Input() public clearable = true; + public readonly clearable = input(true); /** If true, then there will be a toggle all checkbox available (only multiple select mode) */ - @Input() public showToggleAll = true; - @Input() public multiple = false; - @Input() public panelClass: string | string[] | Set | Record = ''; + public readonly showToggleAll = input(true); + public readonly multiple = input(false); + public readonly panelClass = input | Record>(''); @Input() public placeholder = ''; @Input() public required = false; - @Input() public selectedLabel = true; + public readonly selectedLabel = input(true); /** * Event that emits whenever the raw value of the select changes. This is here primarily @@ -152,9 +143,9 @@ export class ZvSelect implements ControlValueAccessor, MatFormField * * @docs-private */ - @Output() readonly valueChange: EventEmitter = new EventEmitter(); - @Output() public readonly openedChange = new EventEmitter(); - @Output() public readonly selectionChange = new EventEmitter(); + public readonly valueChange = output(); + public readonly openedChange = output(); + public readonly selectionChange = output(); public empty = true; @@ -229,7 +220,7 @@ export class ZvSelect implements ControlValueAccessor, MatFormField /** If true, then the empty option should be shown. */ public get showEmptyInput() { - if (this.multiple || !this.clearable || !this.items?.length) { + if (this.multiple() || !this.clearable() || !this.items?.length) { return false; } const searchText = (this.filterCtrl.value || '').toLowerCase(); @@ -238,7 +229,7 @@ export class ZvSelect implements ControlValueAccessor, MatFormField public get tooltip(): string { // MatSelect is not fully initialized in the beginning, so we need to skip this here until it is ready - if (this.multiple && this._matSelect?._selectionModel && this._matSelect.selected) { + if (this.multiple() && this._matSelect?._selectionModel && this._matSelect.selected) { return (this._matSelect.selected as MatOption[]).map((x) => x.viewValue).join(', '); } return ''; @@ -256,7 +247,7 @@ export class ZvSelect implements ControlValueAccessor, MatFormField }); /** The value displayed in the trigger. */ readonly $customTriggerData = computed(() => { - if (this.multiple) { + if (this.multiple()) { return this.$customTriggerDataArray(); } return this.$customTriggerDataArray()[0]; @@ -298,6 +289,18 @@ export class ZvSelect implements ControlValueAccessor, MatFormField } this._errorStateTracker = new _ErrorStateTracker(defaultErrorStateMatcher, ngControl, parentFormGroup, parentForm, this.stateChanges); + + afterNextRender(() => { + const select = this._matSelectQuery(); + this._matSelect = select; + // MatSelect doesn't trigger stateChanges on close which causes problems, so we patch it here. + // eslint-disable-next-line @typescript-eslint/unbound-method + const close = select.close; + select.close = () => { + close.call(select); + select.stateChanges.next(); + }; + }); } public ngDoCheck() { @@ -314,6 +317,9 @@ export class ZvSelect implements ControlValueAccessor, MatFormField public ngOnInit() { this._onInitCalled = true; + const matSelect = this._matSelect ?? this._matSelectQuery(); + this._matSelect = matSelect; + // before oninit ngControl.control isn't set, but it is needed for datasource creation this._switchDataSource(this._dataSourceInput); @@ -321,17 +327,16 @@ export class ZvSelect implements ControlValueAccessor, MatFormField this.filterCtrl.valueChanges .pipe(takeUntil(this._ngUnsubscribe$)) .subscribe((searchText) => this.dataSource.searchTextChanged(searchText)); - let selectionSignalInitialized = false; - this._matSelect.stateChanges + matSelect.stateChanges .pipe( tap(() => { - if (!selectionSignalInitialized && this._matSelect._selectionModel) { + if (!selectionSignalInitialized && matSelect._selectionModel) { // eslint-disable-next-line @typescript-eslint/no-unsafe-argument -- MatSelect._selectionModel.selected is MatOption[] - this.$currentSelection.set(this._matSelect._selectionModel.selected); - this._matSelect._selectionModel.changed.pipe(takeUntil(this._ngUnsubscribe$)).subscribe(() => { + this.$currentSelection.set(matSelect._selectionModel.selected); + matSelect._selectionModel.changed.pipe(takeUntil(this._ngUnsubscribe$)).subscribe(() => { // eslint-disable-next-line @typescript-eslint/no-unsafe-argument -- MatSelect._selectionModel.selected is MatOption[] - this.$currentSelection.set(this._matSelect._selectionModel.selected); + this.$currentSelection.set(matSelect._selectionModel.selected); }); selectionSignalInitialized = true; } @@ -403,7 +408,7 @@ export class ZvSelect implements ControlValueAccessor, MatFormField private _propagateValueChange(value: unknown, source: ValueChangeSource) { this._value = value as T | null; - this.empty = this.multiple ? !Array.isArray(value) || value.length === 0 : value == null || value === ''; + this.empty = this.multiple() ? !Array.isArray(value) || value.length === 0 : value == null || value === ''; this._updateToggleAllCheckbox(); this._pushSelectedValuesToDataSource(this._value); if (source !== ValueChangeSource.valueInput) { @@ -420,7 +425,7 @@ export class ZvSelect implements ControlValueAccessor, MatFormField return; } let values: T[]; - if (this.multiple) { + if (this.multiple()) { values = Array.isArray(value) ? value : []; } else { values = value ? [value] : []; @@ -446,7 +451,7 @@ export class ZvSelect implements ControlValueAccessor, MatFormField } this._dataSourceInstance.searchTextChanged(this.filterCtrl.value); - this._dataSourceInstance.panelOpenChanged(this._matSelect.panelOpen); + this._dataSourceInstance.panelOpenChanged(this._matSelect?.panelOpen ?? false); this._pushSelectedValuesToDataSource(this._value); this._renderChangeSubscription = this._dataSourceInstance.connect().subscribe((items) => { @@ -457,7 +462,7 @@ export class ZvSelect implements ControlValueAccessor, MatFormField } private _updateToggleAllCheckbox() { - if (this.multiple && this.items && Array.isArray(this._value)) { + if (this.multiple() && this.items && Array.isArray(this._value)) { const selectedValueCount = this._value.length; this.toggleAllCheckboxChecked = this.items.length === selectedValueCount; this.toggleAllCheckboxIndeterminate = selectedValueCount > 0 && selectedValueCount < this.items.length; diff --git a/projects/components/table/src/directives/table.directives.spec.ts b/projects/components/table/src/directives/table.directives.spec.ts index 4411565..8a08825 100644 --- a/projects/components/table/src/directives/table.directives.spec.ts +++ b/projects/components/table/src/directives/table.directives.spec.ts @@ -1,10 +1,32 @@ +import { ChangeDetectionStrategy, Component, signal, viewChild } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; import { ZvTableRowDetail } from './table.directives'; +@Component({ + selector: 'zv-test-row-detail', + template: ``, + // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection + changeDetection: ChangeDetectionStrategy.Eager, + imports: [ZvTableRowDetail], +}) +class TestRowDetailComponent { + readonly expanded = signal(false); + + readonly dir = viewChild.required(ZvTableRowDetail); +} + describe('ZvTableRowDetailDirective', () => { let dir: ZvTableRowDetail; let item: any; + let fixture: ReturnType>; + beforeEach(() => { - dir = new ZvTableRowDetail(); + TestBed.configureTestingModule({ + imports: [TestRowDetailComponent], + }); + fixture = TestBed.createComponent(TestRowDetailComponent); + fixture.detectChanges(); + dir = fixture.componentInstance.dir()!; item = {}; }); @@ -33,7 +55,8 @@ describe('ZvTableRowDetailDirective', () => { }); it('should set initial expandable status for an item to true if expanded is true', () => { - dir.expanded = true; + fixture.componentInstance.expanded.set(true); + fixture.detectChanges(); expect(dir.isExpanded(item)).toBeTruthy(); dir.toggle(item); diff --git a/projects/components/table/src/directives/table.directives.ts b/projects/components/table/src/directives/table.directives.ts index 87f10ea..9ec5b27 100644 --- a/projects/components/table/src/directives/table.directives.ts +++ b/projects/components/table/src/directives/table.directives.ts @@ -1,4 +1,4 @@ -import { ContentChild, Directive, Input, TemplateRef } from '@angular/core'; +import { Directive, TemplateRef, contentChild, input } from '@angular/core'; @Directive({ selector: '[zvTableColumnTemplate]', @@ -18,17 +18,15 @@ export class ZvTableColumnHeaderTemplate {} standalone: true, }) export class ZvTableColumn { - @Input() public header = ''; - @Input({ required: true }) public property = ''; - @Input() public sortable = true; - @Input() public mandatory = false; - @Input() public width = 'auto'; - @Input() public headerStyles: Record = {}; - @Input() public columnStyles: Record = {}; - @ContentChild(ZvTableColumnTemplate, { read: TemplateRef }) - public columnTemplate: TemplateRef | null = null; - @ContentChild(ZvTableColumnHeaderTemplate, { read: TemplateRef }) - public headerTemplate: TemplateRef | null = null; + public readonly header = input(''); + public readonly property = input.required(); + public readonly sortable = input(true); + public readonly mandatory = input(false); + public readonly width = input('auto'); + public readonly headerStyles = input>({}); + public readonly columnStyles = input>({}); + public readonly columnTemplate = contentChild(ZvTableColumnTemplate, { read: TemplateRef }); + public readonly headerTemplate = contentChild(ZvTableColumnHeaderTemplate, { read: TemplateRef }); } @Directive({ @@ -62,11 +60,10 @@ export class ZvTableRowDetailTemplate {} }) export class ZvTableRowDetail { /** Gibt an, ob die Row Details initial expanded sein sollen */ - @Input() public expanded = false; - @Input() public showToggleColumn: boolean | ((row: object) => boolean) = true; + public readonly expanded = input(false); + public readonly showToggleColumn = input boolean)>(true); - @ContentChild(ZvTableRowDetailTemplate, { read: TemplateRef }) - public template: TemplateRef | null = null; + public readonly template = contentChild(ZvTableRowDetailTemplate, { read: TemplateRef }); private expandedItems = new WeakSet(); private seenItems = new WeakSet(); @@ -91,7 +88,7 @@ export class ZvTableRowDetail { /** @public This is a public api */ public isExpanded(item: object) { // Beim ersten Aufruf für ein Item expanden, wenn expanded === true - if (this.expanded && !this.seenItems.has(item)) { + if (this.expanded() && !this.seenItems.has(item)) { this.expandedItems.add(item); this.seenItems.add(item); } diff --git a/projects/components/table/src/subcomponents/table-actions.component.spec.ts b/projects/components/table/src/subcomponents/table-actions.component.spec.ts index 14fad21..56b3140 100644 --- a/projects/components/table/src/subcomponents/table-actions.component.spec.ts +++ b/projects/components/table/src/subcomponents/table-actions.component.spec.ts @@ -1,5 +1,5 @@ import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; -import { ChangeDetectionStrategy, Component, ViewChild } from '@angular/core'; +import { ChangeDetectionStrategy, Component, viewChild } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { MatIconButton } from '@angular/material/button'; import { MatButtonHarness } from '@angular/material/button/testing'; @@ -13,7 +13,7 @@ import { ZvTableActionsComponent } from './table-actions.component'; @Component({ selector: 'zv-test-component', template: ` - @@ -26,7 +26,7 @@ export class TestComponent { public actions: IZvTableAction[] = []; public items: any = []; - @ViewChild(ZvTableActionsComponent, { static: true }) comp: ZvTableActionsComponent; + readonly comp = viewChild.required(ZvTableActionsComponent); } describe('ZvTableActionsComponent', () => { @@ -82,9 +82,36 @@ describe('ZvTableActionsComponent', () => { const items = await menu.getItems(); expect(items[0]).toBeDefined(); expect(await items[0].isDisabled()).toBe(testCase.expected); - if (testCase.routerLink && testCase.expected) { - expect(await (await items[0].host()).getCssValue('pointer-events')).toBe('none'); - } }); }); + + disabledFnTestCases + .filter((tc) => tc.routerLink && tc.expected) + .forEach((testCase) => { + it('should set pointer-events none on disabled router link action', async () => { + const fixture = TestBed.createComponent(TestComponent); + const component = fixture.componentInstance; + + component.actions = [ + { + icon: 'remove', + label: 'Remove', + scope: ZvTableActionScope.row, + actionFn: testCase.actionFn, + isDisabledFn: testCase.isDisabledFn, + routerLink: testCase.routerLink, + }, + ]; + + const loader = TestbedHarnessEnvironment.loader(fixture); + fixture.detectChanges(); + + const menuTrigger = await loader.getHarness(MatButtonHarness); + menuTrigger.click(); + + const menu = await loader.getHarness(MatMenuHarness); + const items = await menu.getItems(); + expect(await (await items[0].host()).getCssValue('pointer-events')).toBe('none'); + }); + }); }); diff --git a/projects/components/table/src/subcomponents/table-data.component.html b/projects/components/table/src/subcomponents/table-data.component.html index 16c9214..5c4afb2 100644 --- a/projects/components/table/src/subcomponents/table-data.component.html +++ b/projects/components/table/src/subcomponents/table-data.component.html @@ -3,12 +3,12 @@ matSort multiTemplateDataRows class="zv-table-data__table" - [dataSource]="dataSource" - [trackBy]="dataSource.trackBy" - [matSortActive]="sortColumn!" - [matSortDirection]="sortDirection" + [dataSource]="dataSource()" + [trackBy]="dataSource().trackBy" + [matSortActive]="sortColumn()!" + [matSortDirection]="sortDirection()" [matSortDisableClear]="true" - [matSortDisabled]="!showSorting" + [matSortDisabled]="!showSorting()" (matSortChange)="onSortChanged($event)" > @@ -27,36 +27,36 @@ @if (showRowDetails(row)) { } - @for (columnDef of columnDefs; track columnDef.property) { - + @for (columnDef of columnDefs(); track columnDef.property()) { + - @if (!columnDef.headerTemplate) { - {{ columnDef.header }} + @if (!columnDef.headerTemplate()) { + {{ columnDef.header() }} } @else { - + } - - @if (!columnDef.columnTemplate) { - {{ element[columnDef.property] }} + + @if (!columnDef.columnTemplate()) { + {{ element[columnDef.property()] }} } @else { } @@ -66,7 +66,7 @@ - @if (showListActions) { + @if (showListActions()) { @@ -75,33 +75,33 @@ - @if (rowDetail) { + @if (rowDetail()) { - - + + } - - - @if (rowDetail) { + + + @if (rowDetail()) { } diff --git a/projects/components/table/src/subcomponents/table-data.component.spec.ts b/projects/components/table/src/subcomponents/table-data.component.spec.ts index 58f7d64..12bb01f 100644 --- a/projects/components/table/src/subcomponents/table-data.component.spec.ts +++ b/projects/components/table/src/subcomponents/table-data.component.spec.ts @@ -26,36 +26,38 @@ describe('ZvTableDataComponent', () => { }); it('isMasterToggleChecked should only return true when there are visible rows and they are all selected', () => { - const component = TestBed.createComponent(ZvTableDataComponent).componentInstance; - component.dataSource = createDataSourceMock(); + const fixture = TestBed.createComponent(ZvTableDataComponent); + const component = fixture.componentInstance; + fixture.componentRef.setInput('dataSource', createDataSourceMock()); - (component.dataSource as any).allVisibleRowsSelected = false; - component.dataSource.selectionModel.select({}); + (component.dataSource() as any).allVisibleRowsSelected = false; + component.dataSource().selectionModel.select({}); expect(component.isMasterToggleChecked()).toBe(false); - (component.dataSource as any).allVisibleRowsSelected = true; - component.dataSource.selectionModel.select({}); + (component.dataSource() as any).allVisibleRowsSelected = true; + component.dataSource().selectionModel.select({}); expect(component.isMasterToggleChecked()).toBe(true); - (component.dataSource as any).allVisibleRowsSelected = true; - component.dataSource.selectionModel.clear(); + (component.dataSource() as any).allVisibleRowsSelected = true; + component.dataSource().selectionModel.clear(); expect(component.isMasterToggleChecked()).toBe(false); }); it('isMasterToggleIndeterminate should only return true when some but not all rows are selected', () => { - const component = TestBed.createComponent(ZvTableDataComponent).componentInstance; - component.dataSource = createDataSourceMock(); + const fixture = TestBed.createComponent(ZvTableDataComponent); + const component = fixture.componentInstance; + fixture.componentRef.setInput('dataSource', createDataSourceMock()); - (component.dataSource as any).allVisibleRowsSelected = false; - component.dataSource.selectionModel.select({}); + (component.dataSource() as any).allVisibleRowsSelected = false; + component.dataSource().selectionModel.select({}); expect(component.isMasterToggleIndeterminate()).toBe(true); - (component.dataSource as any).allVisibleRowsSelected = true; - component.dataSource.selectionModel.select({}); + (component.dataSource() as any).allVisibleRowsSelected = true; + component.dataSource().selectionModel.select({}); expect(component.isMasterToggleIndeterminate()).toBe(false); - (component.dataSource as any).allVisibleRowsSelected = true; - component.dataSource.selectionModel.clear(); + (component.dataSource() as any).allVisibleRowsSelected = true; + component.dataSource().selectionModel.clear(); expect(component.isMasterToggleIndeterminate()).toBe(false); }); @@ -68,11 +70,11 @@ describe('ZvTableDataComponent', () => { (component as any).cd = cdSpy; let toggledRow: any = null; - component.rowDetail = { + fixture.componentRef.setInput('rowDetail', { toggle: (x: any) => { toggledRow = x; }, - } as any; + } as any); const row = { a: 'b' }; component.toggleRowDetail(row); @@ -82,43 +84,47 @@ describe('ZvTableDataComponent', () => { }); it('onMasterToggleChange should call toggleVisibleRowSelection on data source', () => { - const component = TestBed.createComponent(ZvTableDataComponent).componentInstance; - component.dataSource = createDataSourceMock(); + const fixture = TestBed.createComponent(ZvTableDataComponent); + const component = fixture.componentInstance; + fixture.componentRef.setInput('dataSource', createDataSourceMock()); - vi.spyOn(component.dataSource, 'toggleVisibleRowSelection'); + vi.spyOn(component.dataSource(), 'toggleVisibleRowSelection'); component.onMasterToggleChange(); - expect(component.dataSource.toggleVisibleRowSelection).toHaveBeenCalled(); + expect(component.dataSource().toggleVisibleRowSelection).toHaveBeenCalled(); }); it('onRowToggleChange should toggle row', () => { - const component = TestBed.createComponent(ZvTableDataComponent).componentInstance; - component.dataSource = createDataSourceMock(); + const fixture = TestBed.createComponent(ZvTableDataComponent); + const component = fixture.componentInstance; + fixture.componentRef.setInput('dataSource', createDataSourceMock()); const row = { a: 'b' }; component.onRowToggleChange(row); - expect(component.dataSource.selectionModel.isSelected(row)).toBe(true); - expect(component.dataSource.selectionModel.selected.length).toBe(1); + expect(component.dataSource().selectionModel.isSelected(row)).toBe(true); + expect(component.dataSource().selectionModel.selected.length).toBe(1); }); it('isRowSelected should only return true if row is selected', () => { - const component = TestBed.createComponent(ZvTableDataComponent).componentInstance; - component.dataSource = createDataSourceMock(); + const fixture = TestBed.createComponent(ZvTableDataComponent); + const component = fixture.componentInstance; + fixture.componentRef.setInput('dataSource', createDataSourceMock()); const row = { a: 'b' }; - component.dataSource.selectionModel.select(row); + component.dataSource().selectionModel.select(row); expect(component.isRowSelected(row)).toBe(true); expect(component.isRowSelected({})).toBe(false); }); it('getSelectedRows should return selected rows', () => { - const component = TestBed.createComponent(ZvTableDataComponent).componentInstance; - component.dataSource = createDataSourceMock(); + const fixture = TestBed.createComponent(ZvTableDataComponent); + const component = fixture.componentInstance; + fixture.componentRef.setInput('dataSource', createDataSourceMock()); const row1 = { a: 'b' }; const row2 = { b: 'c' }; - component.dataSource.selectionModel.select(row1, row2); + component.dataSource().selectionModel.select(row1, row2); expect(component.getSelectedRows()).toEqual([row1, row2]); }); diff --git a/projects/components/table/src/subcomponents/table-data.component.ts b/projects/components/table/src/subcomponents/table-data.component.ts index e5a8160..c1738b0 100644 --- a/projects/components/table/src/subcomponents/table-data.component.ts +++ b/projects/components/table/src/subcomponents/table-data.component.ts @@ -1,16 +1,5 @@ import { NgStyle, NgTemplateOutlet } from '@angular/common'; -import { - ChangeDetectionStrategy, - ChangeDetectorRef, - Component, - EventEmitter, - Input, - OnChanges, - Output, - SimpleChanges, - ViewEncapsulation, - inject, -} from '@angular/core'; +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ViewEncapsulation, effect, inject, input, output } from '@angular/core'; import { MatIconButton } from '@angular/material/button'; import { MatCheckbox } from '@angular/material/checkbox'; import { MatIcon } from '@angular/material/icon'; @@ -28,7 +17,6 @@ import { MatRowDef, MatTable, } from '@angular/material/table'; -import { Subscription } from 'rxjs'; import { ZvTableColumn, ZvTableRowDetail } from '../directives/table.directives'; import { ITableDataSource, IZvTableAction, IZvTableSort, ZvTableActionScope } from '../models'; import { ZvTableActionsComponent } from './table-actions.component'; @@ -66,23 +54,23 @@ import { TableRowDetailComponent } from './table-row-detail.component'; MatSortHeader, ], }) -export class ZvTableDataComponent implements OnChanges { +export class ZvTableDataComponent { private readonly cd = inject(ChangeDetectorRef); - @Input() public dataSource!: ITableDataSource; - @Input() public tableId!: string; - @Input() public rowDetail!: ZvTableRowDetail | null; - @Input() public columnDefs!: ZvTableColumn[]; - @Input() public showListActions!: boolean; - @Input() public refreshable!: boolean; - @Input() public settingsEnabled!: boolean; - @Input() public displayedColumns!: string[]; - @Input() public showSorting!: boolean; - @Input() public sortColumn!: string | null; - @Input() public sortDirection!: 'asc' | 'desc'; - @Output() public readonly showSettingsClicked = new EventEmitter(); - @Output() public readonly refreshDataClicked = new EventEmitter(); - @Output() public readonly sortChanged = new EventEmitter(); + public readonly dataSource = input.required>(); + public readonly tableId = input.required(); + public readonly rowDetail = input(null); + public readonly columnDefs = input.required(); + public readonly showListActions = input.required(); + public readonly refreshable = input.required(); + public readonly settingsEnabled = input.required(); + public readonly displayedColumns = input.required(); + public readonly showSorting = input.required(); + public readonly sortColumn = input.required(); + public readonly sortDirection = input.required<'asc' | 'desc'>(); + public readonly showSettingsClicked = output(); + public readonly refreshDataClicked = output(); + public readonly sortChanged = output(); private refreshAction: IZvTableAction = { icon: 'refresh', @@ -97,27 +85,24 @@ export class ZvTableDataComponent implements OnChanges { scope: ZvTableActionScope.list, }; public get listActions() { - const actions = [...this.dataSource.listActions]; - if (this.refreshable) { + const actions = [...this.dataSource().listActions]; + if (this.refreshable()) { actions.push(this.refreshAction); } - if (this.settingsEnabled) { + if (this.settingsEnabled()) { actions.push(this.settingsAction); } return actions; } - private _dataSourceChangesSub = Subscription.EMPTY; - - public ngOnChanges(changes: SimpleChanges) { - if (changes.dataSource) { - this._dataSourceChangesSub.unsubscribe(); - if (this.dataSource._internalDetectChanges) { - this._dataSourceChangesSub = this.dataSource._internalDetectChanges.subscribe(() => { - this.cd.markForCheck(); - }); + constructor() { + effect((onCleanup) => { + const ds = this.dataSource(); + if (ds._internalDetectChanges) { + const sub = ds._internalDetectChanges.subscribe(() => this.cd.markForCheck()); + onCleanup(() => sub.unsubscribe()); } - } + }); } public onSortChanged(sort: Sort) { @@ -125,37 +110,38 @@ export class ZvTableDataComponent implements OnChanges { } public toggleRowDetail(item: object) { - this.rowDetail!.toggle(item); + this.rowDetail()!.toggle(item); this.cd.markForCheck(); } public onMasterToggleChange() { - this.dataSource.toggleVisibleRowSelection(); + this.dataSource().toggleVisibleRowSelection(); } public onRowToggleChange(row: TData) { - this.dataSource.selectionModel.toggle(row); + this.dataSource().selectionModel.toggle(row); } public isMasterToggleChecked() { - return this.dataSource.selectionModel.hasValue() && this.dataSource.allVisibleRowsSelected; + return this.dataSource().selectionModel.hasValue() && this.dataSource().allVisibleRowsSelected; } public isMasterToggleIndeterminate() { - return this.dataSource.selectionModel.hasValue() && !this.dataSource.allVisibleRowsSelected; + return this.dataSource().selectionModel.hasValue() && !this.dataSource().allVisibleRowsSelected; } public isRowSelected(row: TData) { - return this.dataSource.selectionModel.isSelected(row); + return this.dataSource().selectionModel.isSelected(row); } public getSelectedRows() { - return this.dataSource.selectionModel.selected; + return this.dataSource().selectionModel.selected; } public showRowDetails(row: object) { - if (typeof this.rowDetail!.showToggleColumn === 'function') { - return this.rowDetail!.showToggleColumn(row); + const showToggle = this.rowDetail()!.showToggleColumn(); + if (typeof showToggle === 'function') { + return showToggle(row); } return true; diff --git a/projects/components/table/src/subcomponents/table-header.component.html b/projects/components/table/src/subcomponents/table-header.component.html index 4b895cf..028c5bd 100644 --- a/projects/components/table/src/subcomponents/table-header.component.html +++ b/projects/components/table/src/subcomponents/table-header.component.html @@ -1,25 +1,25 @@ -@if (caption) { -

{{ caption }}

+@if (caption()) { +

{{ caption() }}

} -@if (customHeader) { +@if (customHeader()) {
- +
} -@if (showSorting) { +@if (showSorting()) { } -@if (filterable) { - +@if (filterable()) { + } -@if (topButtonSection) { +@if (topButtonSection()) {
- +
} diff --git a/projects/components/table/src/subcomponents/table-header.component.spec.ts b/projects/components/table/src/subcomponents/table-header.component.spec.ts index c928247..a2346d6 100644 --- a/projects/components/table/src/subcomponents/table-header.component.spec.ts +++ b/projects/components/table/src/subcomponents/table-header.component.spec.ts @@ -1,4 +1,4 @@ -import { ChangeDetectionStrategy, Component, signal, TemplateRef, ViewChild } from '@angular/core'; +import { ChangeDetectionStrategy, Component, signal, TemplateRef, viewChild } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { ZvTableHeaderComponent } from './table-header.component'; @@ -9,6 +9,10 @@ import { ZvTableHeaderComponent } from './table-header.component'; [caption]="caption()" [showSorting]="showSorting()" [filterable]="filterable()" + [selectedRows]="selectedRows()" + [sortColumn]="sortColumn()" + [sortDirection]="sortDirection()" + [searchText]="searchText()" [topButtonSection]="topButtonSection()" /> @@ -21,12 +25,15 @@ export class TestComponent { public readonly caption = signal('caption'); public readonly showSorting = signal(true); public readonly filterable = signal(true); + public readonly selectedRows = signal([]); + public readonly sortColumn = signal(null); + public readonly sortDirection = signal<'asc' | 'desc'>('asc'); + public readonly searchText = signal(''); public readonly topButtonSection = signal | null>(null); - @ViewChild(ZvTableHeaderComponent, { static: true }) cmp: ZvTableHeaderComponent; + readonly cmp = viewChild.required(ZvTableHeaderComponent); - @ViewChild('tpl', { read: TemplateRef, static: true }) - public dummyTpl: TemplateRef | null = null; + readonly dummyTpl = viewChild('tpl', { read: TemplateRef }); } describe('ZvTableHeaderComponent', () => { @@ -47,41 +54,41 @@ describe('ZvTableHeaderComponent', () => { component.filterable.set(false); component.topButtonSection.set(null); await fixture.whenStable(); - expect(component.cmp.paddingTop).toBe('0'); + expect(component.cmp().paddingTop()).toBe('0'); component.caption.set('test'); component.showSorting.set(false); component.filterable.set(false); component.topButtonSection.set(null); await fixture.whenStable(); - expect(component.cmp.paddingTop).toBe('0'); + expect(component.cmp().paddingTop()).toBe('0'); component.caption.set('test'); component.showSorting.set(true); component.filterable.set(true); - component.topButtonSection.set(component.dummyTpl); + component.topButtonSection.set(component.dummyTpl()); await fixture.whenStable(); - expect(component.cmp.paddingTop).toBe('0'); + expect(component.cmp().paddingTop()).toBe('0'); component.caption.set(''); component.showSorting.set(true); component.filterable.set(false); component.topButtonSection.set(null); await fixture.whenStable(); - expect(component.cmp.paddingTop).toBe('1em'); + expect(component.cmp().paddingTop()).toBe('1em'); component.caption.set(''); component.showSorting.set(false); component.filterable.set(true); component.topButtonSection.set(null); await fixture.whenStable(); - expect(component.cmp.paddingTop).toBe('1em'); + expect(component.cmp().paddingTop()).toBe('1em'); component.caption.set(''); component.showSorting.set(false); component.filterable.set(false); - component.topButtonSection.set(component.dummyTpl); + component.topButtonSection.set(component.dummyTpl()); await fixture.whenStable(); - expect(component.cmp.paddingTop).toBe('1em'); + expect(component.cmp().paddingTop()).toBe('1em'); }); }); diff --git a/projects/components/table/src/subcomponents/table-header.component.ts b/projects/components/table/src/subcomponents/table-header.component.ts index a08a1c0..e61b119 100644 --- a/projects/components/table/src/subcomponents/table-header.component.ts +++ b/projects/components/table/src/subcomponents/table-header.component.ts @@ -1,14 +1,5 @@ import { NgTemplateOutlet } from '@angular/common'; -import { - ChangeDetectionStrategy, - Component, - EventEmitter, - HostBinding, - Input, - Output, - TemplateRef, - ViewEncapsulation, -} from '@angular/core'; +import { ChangeDetectionStrategy, Component, TemplateRef, ViewEncapsulation, computed, input, output } from '@angular/core'; import { IZvTableSort, IZvTableSortDefinition } from '../models'; import { ZvTableSearchComponent } from './table-search.component'; import { ZvTableSortComponent } from './table-sort.component'; @@ -20,23 +11,24 @@ import { ZvTableSortComponent } from './table-sort.component'; changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None, imports: [NgTemplateOutlet, ZvTableSortComponent, ZvTableSearchComponent], + host: { '[style.padding-top]': 'paddingTop()' }, }) export class ZvTableHeaderComponent { - @Input() public caption!: string; - @Input() public topButtonSection!: TemplateRef | null; - @Input() public customHeader!: TemplateRef | null; - @Input() public selectedRows!: unknown[]; - @Input() public showSorting!: boolean; - @Input() public sortColumn!: string | null; - @Input() public sortDirection!: 'asc' | 'desc'; - @Input() public sortDefinitions: IZvTableSortDefinition[] = []; - @Input() public filterable!: boolean; - @Input() public searchText!: string; + public readonly caption = input.required(); + public readonly topButtonSection = input | null>(null); + public readonly customHeader = input | null>(null); + public readonly selectedRows = input.required(); + public readonly showSorting = input.required(); + public readonly sortColumn = input.required(); + public readonly sortDirection = input.required<'asc' | 'desc'>(); + public readonly sortDefinitions = input([]); + public readonly filterable = input.required(); + public readonly searchText = input.required(); - @Output() public readonly sortChanged = new EventEmitter(); - @Output() public readonly searchChanged = new EventEmitter(); + public readonly sortChanged = output(); + public readonly searchChanged = output(); - @HostBinding('style.padding-top') public get paddingTop() { - return !this.caption && (this.showSorting || this.filterable || this.topButtonSection) ? '1em' : '0'; - } + public readonly paddingTop = computed(() => + !this.caption() && (this.showSorting() || this.filterable() || this.topButtonSection()) ? '1em' : '0' + ); } diff --git a/projects/components/table/src/subcomponents/table-pagination.component.spec.ts b/projects/components/table/src/subcomponents/table-pagination.component.spec.ts index 41ef290..a3fcc4a 100644 --- a/projects/components/table/src/subcomponents/table-pagination.component.spec.ts +++ b/projects/components/table/src/subcomponents/table-pagination.component.spec.ts @@ -1,4 +1,4 @@ -import { ChangeDetectionStrategy, Component, DebugElement, signal, ViewChild } from '@angular/core'; +import { ChangeDetectionStrategy, Component, DebugElement, signal, viewChild } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { vi } from 'vitest'; import { FormsModule } from '@angular/forms'; @@ -30,8 +30,7 @@ class PaginationTestComponent { public pageSizeOptions: number[] = [5, 10, 25]; public readonly pageDebounce = signal(undefined); - @ViewChild(ZvTablePaginationComponent) - public pagination: ZvTablePaginationComponent; + readonly pagination = viewChild(ZvTablePaginationComponent); public onPage = (_: PageEvent) => {}; } @@ -97,7 +96,7 @@ describe('ZvTablePaginationComponent', () => { component.pageIndex.set(0); await fixture.whenStable(); - expect(component.pagination.pages().length).toEqual(data[2]); + expect(component.pagination().pages().length).toEqual(data[2]); } }); @@ -105,6 +104,6 @@ describe('ZvTablePaginationComponent', () => { component.dataLength.set(15); component.pageSize.set(5); await fixture.whenStable(); - expect(component.pagination.pages().length).toEqual(3); + expect(component.pagination().pages().length).toEqual(3); }); }); diff --git a/projects/components/table/src/subcomponents/table-row-actions.component.spec.ts b/projects/components/table/src/subcomponents/table-row-actions.component.spec.ts index 95eb404..48567fd 100644 --- a/projects/components/table/src/subcomponents/table-row-actions.component.spec.ts +++ b/projects/components/table/src/subcomponents/table-row-actions.component.spec.ts @@ -1,5 +1,5 @@ import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; -import { ChangeDetectionStrategy, Component, signal, ViewChild } from '@angular/core'; +import { ChangeDetectionStrategy, Component, signal, viewChild } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { MatIconHarness } from '@angular/material/icon/testing'; import { Observable } from 'rxjs'; @@ -31,8 +31,7 @@ export class TestComponent { public readonly item = signal({}); public readonly moreMenuThreshold = signal(2); - @ViewChild(ZvTableRowActionsComponent, { static: true }) - comp: ZvTableRowActionsComponent; + readonly comp = viewChild.required(ZvTableRowActionsComponent); } describe('ZvTableRowActionsComponent', () => { @@ -102,9 +101,31 @@ describe('ZvTableRowActionsComponent', () => { const button = await loader.getHarness(MatButtonHarness); expect(button).toBeDefined(); expect(await button.isDisabled()).toBe(testCase.expected); - if (testCase.routerLink && testCase.expected) { - expect(await (await button.host()).getCssValue('pointer-events')).toBe('none'); - } }); }); + + disabledFnTestCases + .filter((tc) => tc.routerLink && tc.expected) + .forEach((testCase) => { + it('should set pointer-events none on disabled router link action', async () => { + const fixture = TestBed.createComponent(TestComponent); + const component = fixture.componentInstance; + + component.actions.set([ + { + icon: 'remove', + label: 'Remove', + scope: ZvTableActionScope.row, + actionFn: testCase.actionFn, + isDisabledFn: testCase.isDisabledFn, + routerLink: testCase.routerLink, + }, + ]); + const loader = TestbedHarnessEnvironment.loader(fixture); + fixture.autoDetectChanges(); + + const button = await loader.getHarness(MatButtonHarness); + expect(await (await button.host()).getCssValue('pointer-events')).toBe('none'); + }); + }); }); diff --git a/projects/components/table/src/subcomponents/table-row-detail.component.html b/projects/components/table/src/subcomponents/table-row-detail.component.html index 877fd6a..e34969d 100644 --- a/projects/components/table/src/subcomponents/table-row-detail.component.html +++ b/projects/components/table/src/subcomponents/table-row-detail.component.html @@ -3,5 +3,5 @@ Deshalb sorgen wir hier mit ngIf dafür, das es erst beim Aufklappen initialisiert wird. --> @if (renderContent()) { - + } diff --git a/projects/components/table/src/subcomponents/table-search.component.spec.ts b/projects/components/table/src/subcomponents/table-search.component.spec.ts index 125bade..be8eebd 100644 --- a/projects/components/table/src/subcomponents/table-search.component.spec.ts +++ b/projects/components/table/src/subcomponents/table-search.component.spec.ts @@ -1,4 +1,4 @@ -import { ChangeDetectionStrategy, Component, signal, ViewChild } from '@angular/core'; +import { ChangeDetectionStrategy, Component, signal, viewChild } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { vi } from 'vitest'; import { MatIconButton } from '@angular/material/button'; @@ -16,8 +16,7 @@ import { ZvTableSearchComponent } from './table-search.component'; export class TestComponent { public readonly searchText = signal('search text'); - @ViewChild(ZvTableSearchComponent, { static: true }) - tableSearch: ZvTableSearchComponent; + readonly tableSearch = viewChild.required(ZvTableSearchComponent); public onSearchChanged(_event: string) {} } @@ -49,7 +48,7 @@ describe('ZvTableSearchComponent', () => { input.triggerEventHandler('keyup', new KeyboardEvent('keyup', { key: 'a' } as any)); await vi.advanceTimersByTimeAsync(50); - component.tableSearch.ngOnDestroy(); + component.tableSearch().ngOnDestroy(); await vi.advanceTimersByTimeAsync(999); expect(component.onSearchChanged).not.toHaveBeenCalled(); @@ -129,23 +128,23 @@ describe('ZvTableSearchComponent', () => { vi.spyOn(component, 'onSearchChanged'); component.searchText.set('text'); - component.tableSearch.currentSearchText.set(''); + component.tableSearch().currentSearchText.set(''); fixture.autoDetectChanges(); expect(fixture.debugElement.query(By.directive(MatIconButton))).not.toBe(null); component.searchText.set(''); fixture.detectChanges(); - component.tableSearch.currentSearchText.set('text'); + component.tableSearch().currentSearchText.set('text'); fixture.detectChanges(); expect(fixture.debugElement.query(By.directive(MatIconButton))).not.toBe(null); component.searchText.set('text'); - component.tableSearch.currentSearchText.set('text'); + component.tableSearch().currentSearchText.set('text'); fixture.detectChanges(); expect(fixture.debugElement.query(By.directive(MatIconButton))).not.toBe(null); component.searchText.set(''); - component.tableSearch.currentSearchText.set(''); + component.tableSearch().currentSearchText.set(''); fixture.detectChanges(); expect(fixture.debugElement.query(By.directive(MatIconButton))).toBe(null); }); diff --git a/projects/components/table/src/subcomponents/table-settings.component.html b/projects/components/table/src/subcomponents/table-settings.component.html index 5907f47..70997ea 100644 --- a/projects/components/table/src/subcomponents/table-settings.component.html +++ b/projects/components/table/src/subcomponents/table-settings.component.html @@ -7,36 +7,36 @@
Displayed Columns - @for (columnDef of columnDefinitions; track columnDef.property) { - @if (columnDef.header) { + @for (columnDef of columnDefinitions(); track columnDef.property()) { + @if (columnDef.header()) { - {{ columnDef.header }} + {{ columnDef.header() }} } }
- @if (sortDefinitions.length) { + @if (sortDefinitions().length) { } Items per page - @for (pageSizeOption of pageSizeOptions; track pageSizeOption) { + @for (pageSizeOption of pageSizeOptions(); track pageSizeOption) { {{ pageSizeOption }} }
- + diff --git a/projects/components/table/src/subcomponents/table-settings.component.spec.ts b/projects/components/table/src/subcomponents/table-settings.component.spec.ts index 291f192..3a68a0a 100644 --- a/projects/components/table/src/subcomponents/table-settings.component.spec.ts +++ b/projects/components/table/src/subcomponents/table-settings.component.spec.ts @@ -1,4 +1,4 @@ -import { ChangeDetectionStrategy, Component, ViewChild } from '@angular/core'; +import { ChangeDetectionStrategy, Component, viewChild } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { vi } from 'vitest'; import { MatButton } from '@angular/material/button'; @@ -34,8 +34,7 @@ export class TestComponent { public sortDefinitions: IZvTableSortDefinition[] = []; public pageSizeOptions: number[] = [1, 3, 7]; - @ViewChild(ZvTableSettingsComponent, { static: true }) - tableSearch: ZvTableSettingsComponent; + readonly tableSearch = viewChild.required(ZvTableSettingsComponent); public onSettingsSaved() {} public onSettingsAborted() {} @@ -75,17 +74,17 @@ describe('ZvTableSettingsComponent', () => { sortDirection: 'desc', }; component.tableId = 'tableA'; - component.tableSearch.settingsService.getStream = () => of(settings); + component.tableSearch().settingsService.getStream = () => of(settings); fixture.detectChanges(); vi.spyOn(component, 'onSettingsSaved'); - vi.spyOn(component.tableSearch.settingsService, 'save').mockReturnValue(of(null).pipe(delay(10))); + vi.spyOn(component.tableSearch().settingsService, 'save').mockReturnValue(of(null).pipe(delay(10))); const [saveButton] = fixture.debugElement.query(By.directive(MatCardActions)).queryAll(By.directive(MatButton)); saveButton.triggerEventHandler('click', null); - expect(component.tableSearch.settingsService.save).toHaveBeenCalledWith('tableA', settings); + expect(component.tableSearch().settingsService.save).toHaveBeenCalledWith('tableA', settings); await vi.advanceTimersByTimeAsync(10); @@ -107,15 +106,15 @@ describe('ZvTableSettingsComponent', () => { customProperty: 'custom value', }; component.tableId = 'tableA'; - component.tableSearch.settingsService.getStream = () => of(settings); + component.tableSearch().settingsService.getStream = () => of(settings); fixture.detectChanges(); - vi.spyOn(component.tableSearch.settingsService, 'save').mockReturnValue(of(null)); + vi.spyOn(component.tableSearch().settingsService, 'save').mockReturnValue(of(null)); const [saveButton] = fixture.debugElement.query(By.directive(MatCardActions)).queryAll(By.directive(MatButton)); saveButton.triggerEventHandler('click', null); - expect(component.tableSearch.settingsService.save).toHaveBeenCalledWith('tableA', settings); + expect(component.tableSearch().settingsService.save).toHaveBeenCalledWith('tableA', settings); }); }); @@ -141,7 +140,8 @@ describe('ZvTableSettingsComponent', () => { const fixture = TestBed.createComponent(ZvTableSettingsComponent); const component = fixture.componentInstance; - component.tableId = 'table.1'; + fixture.componentRef.setInput('tableId', 'table.1'); + fixture.componentRef.setInput('pageSizeOptions', []); fixture.detectChanges(); @@ -159,7 +159,8 @@ describe('ZvTableSettingsComponent', () => { const fixture = TestBed.createComponent(ZvTableSettingsComponent); const component = fixture.componentInstance; - component.tableId = 'table.1'; + fixture.componentRef.setInput('tableId', 'table.1'); + fixture.componentRef.setInput('pageSizeOptions', []); fixture.detectChanges(); let asyncSettings: IZvTableSetting; @@ -177,6 +178,8 @@ describe('ZvTableSettingsComponent', () => { it('columnVisible should return false for blacklisted columns', () => { const fixture = TestBed.createComponent(ZvTableSettingsComponent); + fixture.componentRef.setInput('tableId', 'test'); + fixture.componentRef.setInput('pageSizeOptions', []); const component = fixture.componentInstance; function createSettings(blacklist: string[]): IZvTableSetting { return { @@ -185,8 +188,8 @@ describe('ZvTableSettingsComponent', () => { } function createColumnDef(propName: string): ZvTableColumn { return { - property: propName, - } as Partial as any; + property: () => propName, + } as any; } expect(component.columnVisible(createSettings(['prop']), createColumnDef('prop'))).toEqual(false); expect(component.columnVisible(createSettings(['a']), createColumnDef('prop'))).toEqual(true); @@ -198,6 +201,8 @@ describe('ZvTableSettingsComponent', () => { beforeEach(() => { const fixture = TestBed.createComponent(ZvTableSettingsComponent); + fixture.componentRef.setInput('tableId', 'test'); + fixture.componentRef.setInput('pageSizeOptions', []); component = fixture.componentInstance; }); @@ -235,34 +240,32 @@ describe('ZvTableSettingsComponent', () => { it('onColumnVisibilityChange should toggle column in blacklist', () => { const fixture = TestBed.createComponent(ZvTableSettingsComponent); + fixture.componentRef.setInput('tableId', 'test'); + fixture.componentRef.setInput('pageSizeOptions', []); const component = fixture.componentInstance; const settings: IZvTableSetting = { columnBlacklist: [], } as any; - const columnDef: ZvTableColumn = { - property: '', - } as any; + function createColumnDef(propName: string): ZvTableColumn { + return { property: () => propName } as any; + } const event = new MatCheckboxChange(); - columnDef.property = 'prop'; event.checked = false; - component.onColumnVisibilityChange(event, settings, columnDef); + component.onColumnVisibilityChange(event, settings, createColumnDef('prop')); expect(settings.columnBlacklist).toEqual(['prop']); - columnDef.property = 'prop2'; event.checked = false; - component.onColumnVisibilityChange(event, settings, columnDef); + component.onColumnVisibilityChange(event, settings, createColumnDef('prop2')); expect(settings.columnBlacklist).toEqual(['prop', 'prop2']); - columnDef.property = 'prop'; event.checked = true; - component.onColumnVisibilityChange(event, settings, columnDef); + component.onColumnVisibilityChange(event, settings, createColumnDef('prop')); expect(settings.columnBlacklist).toEqual(['prop2']); - columnDef.property = 'prop'; event.checked = true; - component.onColumnVisibilityChange(event, settings, columnDef); + component.onColumnVisibilityChange(event, settings, createColumnDef('prop')); expect(settings.columnBlacklist).toEqual(['prop2']); }); }); diff --git a/projects/components/table/src/subcomponents/table-settings.component.ts b/projects/components/table/src/subcomponents/table-settings.component.ts index b00c7f6..e3e0bf1 100644 --- a/projects/components/table/src/subcomponents/table-settings.component.ts +++ b/projects/components/table/src/subcomponents/table-settings.component.ts @@ -1,4 +1,4 @@ -import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnInit, Output, TemplateRef, inject } from '@angular/core'; +import { ChangeDetectionStrategy, Component, OnInit, TemplateRef, inject, input, output } from '@angular/core'; import { MatCheckbox, MatCheckboxChange } from '@angular/material/checkbox'; import { Observable, Subscription } from 'rxjs'; import { first, map } from 'rxjs/operators'; @@ -57,21 +57,21 @@ import { ZvTableSortComponent } from './table-sort.component'; export class ZvTableSettingsComponent implements OnInit { public readonly settingsService = inject(ZvTableSettingsService); - @Input() public tableId!: string; - @Input() public columnDefinitions: ZvTableColumn[] = []; - @Input() public sortDefinitions: IZvTableSortDefinition[] = []; - @Input() public pageSizeOptions!: number[]; - @Input() public customSettings: TemplateRef | null = null; + public readonly tableId = input.required(); + public readonly columnDefinitions = input([]); + public readonly sortDefinitions = input([]); + public readonly pageSizeOptions = input.required(); + public readonly customSettings = input | null>(null); - @Output() public readonly settingsSaved = new EventEmitter(); - @Output() public readonly settingsAborted = new EventEmitter(); + public readonly settingsSaved = output(); + public readonly settingsAborted = output(); public settings$!: Observable; private _settingSaveSub!: Subscription; public ngOnInit(): void { - this.settings$ = this.settingsService.getStream(this.tableId, true).pipe( + this.settings$ = this.settingsService.getStream(this.tableId(), true).pipe( map((settings) => { settings = settings || ({} as IZvTableSetting); return { @@ -86,7 +86,7 @@ export class ZvTableSettingsComponent implements OnInit { } public columnVisible(settings: IZvTableSetting, columnDef: ZvTableColumn) { - return !settings.columnBlacklist.some((x) => x === columnDef.property); + return !settings.columnBlacklist.some((x) => x === columnDef.property()); } public onSortChanged(event: IZvTableSort, settings: IZvTableSetting) { @@ -99,9 +99,9 @@ export class ZvTableSettingsComponent implements OnInit { public onColumnVisibilityChange(event: MatCheckboxChange, settings: IZvTableSetting, columnDef: ZvTableColumn) { if (event.checked) { - settings.columnBlacklist = settings.columnBlacklist.filter((x) => x !== columnDef.property); - } else if (!settings.columnBlacklist.find((x) => x === columnDef.property)) { - settings.columnBlacklist.push(columnDef.property); + settings.columnBlacklist = settings.columnBlacklist.filter((x) => x !== columnDef.property()); + } else if (!settings.columnBlacklist.find((x) => x === columnDef.property())) { + settings.columnBlacklist.push(columnDef.property()); } } @@ -110,7 +110,7 @@ export class ZvTableSettingsComponent implements OnInit { this._settingSaveSub.unsubscribe(); } this._settingSaveSub = this.settingsService - .save(this.tableId, setting) + .save(this.tableId(), setting) .pipe(first()) .subscribe({ complete: () => this.settingsSaved.emit(), diff --git a/projects/components/table/src/subcomponents/table-sort.component.spec.ts b/projects/components/table/src/subcomponents/table-sort.component.spec.ts index e910a77..dcbf631 100644 --- a/projects/components/table/src/subcomponents/table-sort.component.spec.ts +++ b/projects/components/table/src/subcomponents/table-sort.component.spec.ts @@ -1,5 +1,5 @@ import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; -import { ChangeDetectionStrategy, Component, ViewChild } from '@angular/core'; +import { ChangeDetectionStrategy, Component, viewChild } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { MatMiniFabButton } from '@angular/material/button'; import { MatSelectHarness } from '@angular/material/select/testing'; @@ -29,8 +29,7 @@ export class TestComponent { { prop: 'prop2', displayName: 'Sort Prop' }, ]; - @ViewChild(ZvTableSortComponent, { static: true }) - tableSort: ZvTableSortComponent; + readonly tableSort = viewChild.required(ZvTableSortComponent); public onSortChanged(_event: IZvTableSort) {} } diff --git a/projects/components/table/src/table.component.html b/projects/components/table/src/table.component.html index 9da44da..70fb170 100644 --- a/projects/components/table/src/table.component.html +++ b/projects/components/table/src/table.component.html @@ -1,15 +1,15 @@
@@ -44,7 +44,7 @@ [dataLength]="dataLength" [pageIndex]="pageIndex" [pageSizeOptions]="pageSizeOptions" - [pageDebounce]="pageDebounce" + [pageDebounce]="pageDebounce()" (page)="onPage($event)" /> @@ -52,13 +52,13 @@
@if (settingsEnabled) { }
diff --git a/projects/components/table/src/table.component.spec.ts b/projects/components/table/src/table.component.spec.ts index 6f4a97c..52361c2 100644 --- a/projects/components/table/src/table.component.spec.ts +++ b/projects/components/table/src/table.component.spec.ts @@ -1,7 +1,17 @@ import { HarnessLoader } from '@angular/cdk/testing'; import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; import { CommonModule } from '@angular/common'; -import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Injectable, LOCALE_ID, QueryList, ViewChild, signal } from '@angular/core'; +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + EnvironmentInjector, + Injectable, + LOCALE_ID, + runInInjectionContext, + signal, + viewChild, +} from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { vi } from 'vitest'; import { IconType, MatIconHarness, MatIconTestingModule } from '@angular/material/icon/testing'; @@ -80,12 +90,16 @@ const route: ActivatedRoute = { queryParamMap: queryParams$, } as any; -function createColDef(data: { property?: string; header?: string; sortable?: boolean }) { - const colDef = new ZvTableColumn(); - colDef.sortable = data.sortable || false; - colDef.property = data.property || null; - colDef.header = data.header || null; - return colDef; +function createColDef(data: { property?: string; header?: string; sortable?: boolean }): ZvTableColumn { + const injector = TestBed.inject(EnvironmentInjector); + return runInInjectionContext(injector, () => { + const colDef = new ZvTableColumn(); + // Signal inputs cannot be assigned directly; replace them with writable signals for testing + (colDef as any).sortable = signal(data.sortable ?? true); + (colDef as any).property = signal(data.property || ''); + (colDef as any).header = signal(data.header || ''); + return colDef; + }); } @Component({ @@ -160,10 +174,8 @@ export class TestComponent { public readonly expanded = signal(false); public readonly showToggleColumn = signal(true); - @ViewChild(ZvTable, { static: true }) - table: ZvTable; - @ViewChild(ZvTablePaginationComponent, { static: true }) - paginator: ZvTablePaginationComponent; + readonly table = viewChild.required(ZvTable); + readonly paginator = viewChild.required(ZvTablePaginationComponent); public onPage(_event: unknown) {} public onListActionExecute(_selection: unknown[]) {} @@ -188,14 +200,19 @@ describe('ZvTable', () => { ], }); const table = TestBed.inject(ZvTable); - // Override default URL state manager to avoid router mock issues in Angular 21 - table.stateManager = new ZvTableMemoryStateManager(); - table.tableId = 'tableid'; - table.dataSource = new ZvTableDataSource(() => of([{ a: 'asdfg' }, { a: 'gasdf' }, { a: 'asdas' }, { a: '32424rw' }])); + // Signal inputs cannot be assigned directly; replace them with writable signals for testing + (table as any).stateManager = signal(new ZvTableMemoryStateManager()); + (table as any).tableId = signal('tableid'); + (table as any).dataSource = signal( + new ZvTableDataSource(() => of([{ a: 'asdfg' }, { a: 'gasdf' }, { a: 'asdas' }, { a: '32424rw' }])) + ); + (table as any).refreshable = signal(true); + (table as any).showSettings = signal(true); + (table as any).filterable = signal(true); + (table as any).preferSortDropdown = signal(false); + (table as any).sortDefinitions = signal([]); if (hooks) { - table.ngOnChanges({}); table.ngOnInit(); - table.ngAfterContentInit(); } return table; } @@ -211,16 +228,17 @@ describe('ZvTable', () => { it('should update table state from the settings service and the query params', async () => { const table = createTableInstance(); - table.stateManager = new ZvTableUrlStateManager(router, route); + (table as any).stateManager = signal(new ZvTableUrlStateManager(router, route)); settingsService.settings$.next({}); vi.spyOn(settingsService, 'getStream'); - table.columnDefs = [createColDef({ property: 'prop1' }), createColDef({ property: 'prop2' })]; - table.rowDetail = { showToggleColumn: true } as any; - table.dataSource.listActions.push({ icon: 'add', label: 'Add', scope: ZvTableActionScope.list }); - table.dataSource.rowActions.push({ icon: 'add', label: 'Add', scope: ZvTableActionScope.row }); + const colDefs = [createColDef({ property: 'prop1' }), createColDef({ property: 'prop2' })]; + // Override content query signals so the consolidated effect populates columnDefs and _rowDetail correctly + (table as any).columnDefsQuery = signal(colDefs); + (table as any).rowDetailQuery = signal({ showToggleColumn: () => true }); + table.dataSource().listActions.push({ icon: 'add', label: 'Add', scope: ZvTableActionScope.list }); + table.dataSource().rowActions.push({ icon: 'add', label: 'Add', scope: ZvTableActionScope.row }); table.ngOnInit(); - table.ngAfterContentInit(); await vi.advanceTimersByTimeAsync(1); expect(table.pageSize).toEqual(15); @@ -229,7 +247,7 @@ describe('ZvTable', () => { expect(table.sortColumn).toEqual(null); expect(table.sortDirection).toEqual('asc'); expect(table.displayedColumns).toEqual(['select', 'rowDetailExpander', 'prop1', 'prop2', 'options']); - expect(settingsService.getStream).toHaveBeenCalledWith(table.tableId, false); + expect(settingsService.getStream).toHaveBeenCalledWith(table.tableId(), false); settingsService.settings$.next({ tableid: { @@ -262,24 +280,24 @@ describe('ZvTable', () => { expect(table.sortDirection).toEqual('asc'); expect(table.displayedColumns).toEqual(['select', 'rowDetailExpander', 'prop1', 'options']); - table.rowDetail = { showToggleColumn: false } as any; + (table as any)._rowDetail = { showToggleColumn: () => false } as any; queryParams$.next(convertToParamMap({ tableid: '1◬1◬asdf◬Column1◬desc' } as Params)); await vi.advanceTimersByTimeAsync(1); expect(table.displayedColumns).toEqual(['select', 'prop1', 'options']); - table.rowDetail = null; + (table as any)._rowDetail = null; queryParams$.next(convertToParamMap({ tableid: '1◬2◬asdf◬Column1◬desc' } as Params)); await vi.advanceTimersByTimeAsync(1); expect(table.displayedColumns).toEqual(['select', 'prop1', 'options']); - table.dataSource.listActions.length = 0; + table.dataSource().listActions.length = 0; queryParams$.next(convertToParamMap({ tableid: '1◬3◬asdf◬Column1◬desc' } as Params)); await vi.advanceTimersByTimeAsync(1); expect(table.displayedColumns).toEqual(['prop1', 'options']); - table.dataSource.rowActions.length = 0; - table.showSettings = false; - table.refreshable = false; + table.dataSource().rowActions.length = 0; + (table as any).showSettings.set(false); + (table as any).refreshable.set(false); queryParams$.next(convertToParamMap({ tableid: '1◬4◬asdf◬Column1◬desc' } as Params)); await vi.advanceTimersByTimeAsync(1); expect(table.displayedColumns).toEqual(['prop1']); @@ -295,17 +313,17 @@ describe('ZvTable', () => { it('should show right content depending on the datatable state', () => { const table = createTableInstance(); - table.dataSource = { loading: false, error: null, visibleRows: [] } as any; + (table as any).dataSource.set({ loading: false, error: null, visibleRows: [] } as any); expect(table.showNoEntriesText).toBe(true); expect(table.showError).toBe(false); expect(table.showLoading).toBe(false); - table.dataSource = { loading: true, error: null, visibleRows: [] } as any; + (table as any).dataSource.set({ loading: true, error: null, visibleRows: [] } as any); expect(table.showNoEntriesText).toBe(false); expect(table.showError).toBe(false); expect(table.showLoading).toBe(true); - table.dataSource = { loading: false, error: new Error('error'), visibleRows: [] } as any; + (table as any).dataSource.set({ loading: false, error: new Error('error'), visibleRows: [] } as any); expect(table.showNoEntriesText).toBe(false); expect(table.showError).toBe(true); expect(table.showLoading).toBe(false); @@ -314,8 +332,8 @@ describe('ZvTable', () => { it('should only enable settings if all prerequisites are met', () => { const table = createTableInstance(); - table.tableId = 'test'; - table.showSettings = true; + (table as any).tableId.set('test'); + (table as any).showSettings.set(true); settingsService.settingsEnabled = true; expect(table.settingsEnabled).toBe(true); @@ -323,126 +341,121 @@ describe('ZvTable', () => { expect(table.settingsEnabled).toBe(false); settingsService.settingsEnabled = true; - table.showSettings = false; + (table as any).showSettings.set(false); expect(table.settingsEnabled).toBe(false); - table.showSettings = true; + (table as any).showSettings.set(true); - table.tableId = null; + (table as any).tableId.set(null); expect(table.settingsEnabled).toBe(false); }); it('should only show list actions if there are any menu items', () => { const table = createTableInstance(); - table.tableId = 'test'; - table.showSettings = true; + (table as any).tableId.set('test'); + (table as any).showSettings.set(true); settingsService.settingsEnabled = true; - table.refreshable = false; + (table as any).refreshable.set(false); expect(table.showListActions).toBe(true); - table.showSettings = false; + (table as any).showSettings.set(false); expect(table.showListActions).toBe(false); - table.refreshable = true; + (table as any).refreshable.set(true); expect(table.showListActions).toBe(true); - table.refreshable = false; + (table as any).refreshable.set(false); }); it('should merge sort definitions and only show sort dropdown when there are custom definitions when preferSortDropdown input is false', () => { const table = createTableInstance(); - table.preferSortDropdown = false; + (table as any).preferSortDropdown.set(false); const customSortDef = { prop: 'custom', displayName: 'Custom' }; - const notSortableColDef = new ZvTableColumn(); - notSortableColDef.sortable = false; - notSortableColDef.property = 'no_sort'; - notSortableColDef.header = 'NoSort'; - const sortableColDef = new ZvTableColumn(); - sortableColDef.sortable = true; - sortableColDef.property = 'sort'; - sortableColDef.header = 'Sort'; - const colDefs = new QueryList(); + const notSortableColDef = createColDef({ sortable: false, property: 'no_sort', header: 'NoSort' }); + const sortableColDef = createColDef({ sortable: true, property: 'sort', header: 'Sort' }); // nothing to sort - colDefs.reset([notSortableColDef]); - table.columnDefsSetter = colDefs; - table.sortDefinitions = []; + table.columnDefs = [notSortableColDef]; + (table as any).sortDefinitions.set([]); + (table as any).mergeSortDefinitions(); expect(table.useSortDropdown).toBe(false); expect(table.showDropdownSorting).toBe(false); - expect(table.sortDefinitions).toEqual([]); + expect(table.mergedSortDefinitions).toEqual([]); // only things sortable in the header - colDefs.reset([notSortableColDef, sortableColDef]); - table.columnDefsSetter = colDefs; + table.columnDefs = [notSortableColDef, sortableColDef]; + (table as any).mergeSortDefinitions(); expect(table.useSortDropdown).toBe(false); expect(table.showDropdownSorting).toBe(false); - expect(table.sortDefinitions).toEqual([{ prop: 'sort', displayName: 'Sort' }]); + expect(table.mergedSortDefinitions).toEqual([{ prop: 'sort', displayName: 'Sort' }]); // sortable column defs and custom sorting rules - table.sortDefinitions = [{ prop: 'custom', displayName: 'Custom' }]; + (table as any).sortDefinitions.set([{ prop: 'custom', displayName: 'Custom' }]); + (table as any)._sortDefinitions = [{ prop: 'custom', displayName: 'Custom' }]; + (table as any).mergeSortDefinitions(); expect(table.useSortDropdown).toBe(true); expect(table.showDropdownSorting).toBe(true); - expect(table.sortDefinitions).toEqual([customSortDef, { prop: 'sort', displayName: 'Sort' }]); + expect(table.mergedSortDefinitions).toEqual([customSortDef, { prop: 'sort', displayName: 'Sort' }]); // no column defs, but custom sorting rules - colDefs.reset([]); - table.columnDefsSetter = colDefs; + table.columnDefs = []; + (table as any).mergeSortDefinitions(); expect(table.useSortDropdown).toBe(true); expect(table.showDropdownSorting).toBe(true); - expect(table.sortDefinitions).toEqual([customSortDef]); + expect(table.mergedSortDefinitions).toEqual([customSortDef]); // no column defs, no custom sorting rules - table.sortDefinitions = null; + (table as any).sortDefinitions.set([]); + (table as any)._sortDefinitions = []; + (table as any).mergeSortDefinitions(); expect(table.useSortDropdown).toBe(false); expect(table.showDropdownSorting).toBe(false); - expect(table.sortDefinitions).toEqual([]); + expect(table.mergedSortDefinitions).toEqual([]); }); it('should always show sort dropdown when preferSortDropdown input is true and there are things to sort', () => { const table = createTableInstance(); - table.preferSortDropdown = true; + (table as any).preferSortDropdown.set(true); const customSortDef = { prop: 'custom', displayName: 'Custom' }; - const notSortableColDef = new ZvTableColumn(); - notSortableColDef.sortable = false; - notSortableColDef.property = 'no_sort'; - notSortableColDef.header = 'NoSort'; - const sortableColDef = new ZvTableColumn(); - sortableColDef.sortable = true; - sortableColDef.property = 'sort'; - sortableColDef.header = 'Sort'; - const colDefs = new QueryList(); + const notSortableColDef = createColDef({ sortable: false, property: 'no_sort', header: 'NoSort' }); + const sortableColDef = createColDef({ sortable: true, property: 'sort', header: 'Sort' }); // nothing to sort - colDefs.reset([notSortableColDef]); - table.columnDefsSetter = colDefs; - table.sortDefinitions = []; + table.columnDefs = [notSortableColDef]; + (table as any).sortDefinitions.set([]); + (table as any)._sortDefinitions = []; + (table as any).mergeSortDefinitions(); expect(table.useSortDropdown).toBe(true); expect(table.showDropdownSorting).toBe(false); // only things sortable in the header - colDefs.reset([notSortableColDef, sortableColDef]); - table.columnDefsSetter = colDefs; + table.columnDefs = [notSortableColDef, sortableColDef]; + (table as any).mergeSortDefinitions(); expect(table.useSortDropdown).toBe(true); expect(table.showDropdownSorting).toBe(true); - expect(table.sortDefinitions).toEqual([{ prop: 'sort', displayName: 'Sort' }]); + expect(table.mergedSortDefinitions).toEqual([{ prop: 'sort', displayName: 'Sort' }]); // sortable column defs and custom sorting rules - table.sortDefinitions = [{ prop: 'custom', displayName: 'Custom' }]; + (table as any).sortDefinitions.set([{ prop: 'custom', displayName: 'Custom' }]); + (table as any)._sortDefinitions = [{ prop: 'custom', displayName: 'Custom' }]; + (table as any).mergeSortDefinitions(); expect(table.useSortDropdown).toBe(true); expect(table.showDropdownSorting).toBe(true); - expect(table.sortDefinitions).toEqual([customSortDef, { prop: 'sort', displayName: 'Sort' }]); + expect(table.mergedSortDefinitions).toEqual([customSortDef, { prop: 'sort', displayName: 'Sort' }]); // no column defs, but custom sorting rules - colDefs.reset([]); - table.columnDefsSetter = colDefs; + table.columnDefs = []; + (table as any).mergeSortDefinitions(); expect(table.useSortDropdown).toBe(true); expect(table.showDropdownSorting).toBe(true); - expect(table.sortDefinitions).toEqual([customSortDef]); + expect(table.mergedSortDefinitions).toEqual([customSortDef]); // no column defs, no custom sorting rules - table.sortDefinitions = null; + (table as any).sortDefinitions.set([]); + (table as any)._sortDefinitions = []; + (table as any).mergeSortDefinitions(); expect(table.useSortDropdown).toBe(true); expect(table.showDropdownSorting).toBe(false); - expect(table.sortDefinitions).toEqual([]); + expect(table.mergedSortDefinitions).toEqual([]); }); it('requestUpdate should update query params without overriding deleting other query params', () => { @@ -451,13 +464,13 @@ describe('ZvTable', () => { const stateManager = new ZvTableUrlStateManager(localRouter as any, route); const table = createTableInstance(); - table.stateManager = stateManager; + (table as any).stateManager.set(stateManager); table.pageIndex = 3; table.pageSize = 12; table.filterText = 'Blubb'; table.sortColumn = 'col'; table.sortDirection = 'desc'; - table.tableId = 'requestUpdate'; + (table as any).tableId.set('requestUpdate'); (table as any).requestUpdate(); @@ -476,9 +489,8 @@ describe('ZvTable', () => { vi.spyOn(newDataSource, 'updateData'); const table = createTableInstance(); - table.dataSource = initialDataSource; + (table as any).dataSource.set(initialDataSource); table.ngOnInit(); - table.ngAfterContentInit(); await vi.advanceTimersByTimeAsync(1); @@ -535,14 +547,14 @@ describe('ZvTable', () => { const stateManager = new ZvTableUrlStateManager(localRouter as any, route); const table = createTableInstance(); - table.stateManager = stateManager; - table.tableId = 'tableId'; - table.flipContainer = { showFront: () => {} } as any; - vi.spyOn(table.flipContainer, 'showFront'); + (table as any).stateManager.set(stateManager); + (table as any).tableId.set('tableId'); + const mockFlipContainer = { showFront: vi.fn() }; + (table as any).flipContainer = signal(mockFlipContainer); table.onSettingsSaved(); - expect(table.flipContainer.showFront).toHaveBeenCalledTimes(1); + expect(mockFlipContainer.showFront).toHaveBeenCalledTimes(1); const expectedQueryParams = { existingParam: '0815', }; @@ -550,27 +562,22 @@ describe('ZvTable', () => { await vi.advanceTimersByTimeAsync(1); }); - it('should update view when view/content children change', () => { + it('should call updateTableState and markForCheck when columnDefs or rowDetail changes', () => { vi.spyOn(cd, 'markForCheck'); const table = createTableInstance(); vi.spyOn(table as any, 'updateTableState'); - table.customHeader = null; - expect(cd.markForCheck).toHaveBeenCalledTimes(1); - - table.customSettings = null; - expect(cd.markForCheck).toHaveBeenCalledTimes(2); - - table.topButtonSection = null; - expect(cd.markForCheck).toHaveBeenCalledTimes(3); - - table.columnDefsSetter = new QueryList(); + // Simulate what the effect does: update columnDefs and call updateTableState + table.columnDefs = []; + (table as any).updateTableState(); expect((table as any).updateTableState).toHaveBeenCalledTimes(1); - expect(cd.markForCheck).toHaveBeenCalledTimes(4); + expect(cd.markForCheck).toHaveBeenCalledTimes(1); - table.rowDetail = null; + // Simulate rowDetail change triggering updateTableState + (table as any)._rowDetail = null; + (table as any).updateTableState(); expect((table as any).updateTableState).toHaveBeenCalledTimes(2); - expect(cd.markForCheck).toHaveBeenCalledTimes(5); + expect(cd.markForCheck).toHaveBeenCalledTimes(2); }); }); @@ -587,7 +594,7 @@ describe('ZvTable', () => { fixture = TestBed.createComponent(TestComponent); component = fixture.componentInstance; expect(component).toBeDefined(); - modifySettings?.(component.table._settingsService as TestSettingsService); + modifySettings?.(component.table()._settingsService as TestSettingsService); component.dataSource.set(tableDataSource); fixture.detectChanges(); // Allow async data source subscriptions to settle @@ -750,32 +757,38 @@ describe('ZvTable', () => { expect(await (await strDataCell[0].host()).getCssValue('color')).toEqual('rgb(0, 0, 255)'); const detail = await filterAsync(dataRows, async (row) => await (await row.host()).hasClass('zv-table-data__detail-row')); + expect(detail.length).toEqual(2); - expect(detail.every(async (d) => (await (await d.host()).getCssValue('height')) === '0')).toEqual(true); + expect(await (await detail[0].host()).hasClass('zv-table-data__detail-row--collapsed')).toBe(true); + expect(await (await detail[1].host()).hasClass('zv-table-data__detail-row--collapsed')).toBe(true); const expanderCell = await data[0].getCells({ columnName: 'rowDetailExpander' }); expect(expanderCell.length).toEqual(1); - await (await expanderCell[0].host()).click(); - expect(await (await detail[0].host()).getCssValue('height')).not.toEqual('0'); - - const customExpanderCell = await data[0].getCells({ columnName: '__custom' }); - expect(customExpanderCell.length).toEqual(1); + // Click the button inside the cell (not the ) to trigger the (click) handler + const expanderButtons = fixture.debugElement.queryAll(By.css('.mat-column-rowDetailExpander button')); + expanderButtons[0].nativeElement.click(); + fixture.detectChanges(); + expect(await (await detail[0].host()).hasClass('zv-table-data__detail-row--expanded')).toBe(true); + expect(await (await detail[1].host()).hasClass('zv-table-data__detail-row--collapsed')).toBe(true); - await (await customExpanderCell[0].host()).click(); - expect(detail.every(async (d) => (await (await d.host()).getCssValue('height')) === '0')).toEqual(true); + // Click the custom toggle button to collapse row 0 again + const customButtons = fixture.debugElement.queryAll(By.css('.mat-column-__custom button')); + customButtons[0].nativeElement.click(); + fixture.detectChanges(); + expect(await (await detail[0].host()).hasClass('zv-table-data__detail-row--collapsed')).toBe(true); }); it('should filter', async () => { const searchInput = await table.getSearchInput(); expect(await searchInput.getValue()).toEqual(''); - vi.spyOn(component.table, 'onSearchChanged'); + vi.spyOn(component.table(), 'onSearchChanged'); await searchInput.setValue('asdf'); // Wait for search debounce (300ms) await new Promise((resolve) => setTimeout(resolve, 350)); - expect(component.table.onSearchChanged).toHaveBeenCalledTimes(1); - expect(component.table.onSearchChanged).toHaveBeenCalledWith('asdf'); + expect(component.table().onSearchChanged).toHaveBeenCalledTimes(1); + expect(component.table().onSearchChanged).toHaveBeenCalledWith('asdf'); }); it('should reset page to 0 when filtering', async () => { @@ -798,11 +811,11 @@ describe('ZvTable', () => { ); // Set the page to 2 manually to simulate navigation - component.table.pageIndex = 1; + component.table().pageIndex = 1; fixture.detectChanges(); // Verify we're on page 2 - expect(component.table.pageIndex).toEqual(1); + expect(component.table().pageIndex).toEqual(1); // Apply a filter const searchInput = await table.getSearchInput(); @@ -811,7 +824,7 @@ describe('ZvTable', () => { await new Promise((resolve) => setTimeout(resolve, 350)); // Verify that the page index is reset to 0 - expect(component.table.pageIndex).toEqual(0); + expect(component.table().pageIndex).toEqual(0); }); it('should sort via dropdown', async () => { @@ -825,9 +838,9 @@ describe('ZvTable', () => { const optionTexts = await Promise.all((await sortSelect.getOptions()).map(async (o) => await o.getText())); expect(optionTexts).toEqual(['', 'Custom Sort', 'id']); - vi.spyOn(component.table, 'onSortChanged'); + vi.spyOn(component.table(), 'onSortChanged'); await sortSelect.clickOptions({ text: 'id' }); - expect(component.table.onSortChanged).toHaveBeenCalledWith({ + expect(component.table().onSortChanged).toHaveBeenCalledWith({ sortColumn: 'id', sortDirection: 'asc', }); @@ -835,7 +848,7 @@ describe('ZvTable', () => { const sortDirectionButtons = await table.getSortDirectionButtons(); expect(sortDirectionButtons.length).toEqual(2); await sortDirectionButtons[0].click(); - expect(component.table.onSortChanged).toHaveBeenCalledWith({ + expect(component.table().onSortChanged).toHaveBeenCalledWith({ sortColumn: 'id', sortDirection: 'desc', }); @@ -854,12 +867,12 @@ describe('ZvTable', () => { const idSortHeader = (await sort.getSortHeaders({ label: 'id' }))[0]; - vi.spyOn(component.table, 'onSortChanged'); + vi.spyOn(component.table(), 'onSortChanged'); await idSortHeader.click(); let activeHeader = await sort.getActiveHeader(); expect(await activeHeader.getLabel()).toEqual('id'); expect(await activeHeader.getSortDirection()).toEqual('asc'); - expect(component.table.onSortChanged).toHaveBeenCalledWith({ + expect(component.table().onSortChanged).toHaveBeenCalledWith({ sortColumn: 'id', sortDirection: 'asc', }); @@ -868,7 +881,7 @@ describe('ZvTable', () => { activeHeader = await sort.getActiveHeader(); expect(await activeHeader.getLabel()).toEqual('id'); expect(await activeHeader.getSortDirection()).toEqual('desc'); - expect(component.table.onSortChanged).toHaveBeenCalledWith({ + expect(component.table().onSortChanged).toHaveBeenCalledWith({ sortColumn: 'id', sortDirection: 'desc', }); @@ -877,7 +890,7 @@ describe('ZvTable', () => { activeHeader = await sort.getActiveHeader(); expect(await activeHeader?.getLabel()).toEqual('id'); expect(await activeHeader?.getSortDirection()).toEqual('asc'); - expect(component.table.onSortChanged).toHaveBeenCalledWith({ + expect(component.table().onSortChanged).toHaveBeenCalledWith({ sortColumn: 'id', sortDirection: 'asc', }); @@ -891,9 +904,9 @@ describe('ZvTable', () => { const listActions = await listActionsMenu.getItems(); expect(listActions.length).toEqual(3); - vi.spyOn(component.table.dataSource, 'updateData'); + vi.spyOn(component.table().dataSource(), 'updateData'); await listActionsMenu.clickItem({ text: 'refresh Refresh list' }); - expect(component.table.dataSource.updateData).toHaveBeenCalled(); + expect(component.table().dataSource().updateData).toHaveBeenCalled(); }); it('should hide refresh button', async () => { @@ -984,7 +997,7 @@ describe('ZvTable', () => { }) ); - component.table.pageDebounce = 0; + (component.table() as any).pageDebounce = signal(0); const gotoPageSelect = await table.getGotoPageSelect(); await gotoPageSelect.open(); diff --git a/projects/components/table/src/table.component.ts b/projects/components/table/src/table.component.ts index f13d0f1..0c12c66 100644 --- a/projects/components/table/src/table.component.ts +++ b/projects/components/table/src/table.component.ts @@ -1,23 +1,19 @@ -import type { QueryList } from '@angular/core'; import { - AfterContentInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, - ContentChild, - ContentChildren, - EventEmitter, - HostBinding, + contentChild, + contentChildren, + effect, inject, - Input, + input, LOCALE_ID, - OnChanges, OnDestroy, OnInit, - Output, - SimpleChanges, + output, TemplateRef, - ViewChild, + untracked, + viewChild, ViewEncapsulation, } from '@angular/core'; import { PageEvent } from '@angular/material/paginator'; @@ -47,9 +43,11 @@ import { ZvTableSettingsComponent } from './subcomponents/table-settings.compone templateUrl: './table.component.html', styleUrls: ['./table.component.scss'], host: { - '[class.mat-elevation-z1]': `layout === 'card'`, - '[class.zv-table--card]': `layout === 'card'`, - '[class.zv-table--border]': `layout === 'border'`, + '[class.mat-elevation-z1]': "layout() === 'card'", + '[class.zv-table--card]': "layout() === 'card'", + '[class.zv-table--border]': "layout() === 'border'", + '[class.zv-table--striped]': 'striped()', + '[class.zv-table--row-detail]': '!!rowDetailQuery()', }, changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None, @@ -62,7 +60,7 @@ import { ZvTableSettingsComponent } from './subcomponents/table-settings.compone ZvTableSettingsComponent, ], }) -export class ZvTable implements OnInit, OnChanges, AfterContentInit, OnDestroy { +export class ZvTable implements OnInit, OnDestroy { public _settingsService = inject(ZvTableSettingsService); public _exceptionMessageExtractor = inject(ZvExceptionMessageExtractor); public _cd = inject(ChangeDetectorRef); @@ -70,197 +68,155 @@ export class ZvTable implements OnInit, OnChanges, AfterContent public _router = inject(Router); public _locale = inject(LOCALE_ID); - @Input() public caption = ''; - @Input({ required: true }) public dataSource!: ITableDataSource; - @Input({ required: true }) public tableId!: string; - @Input() - public set sortDefinitions(value: IZvTableSortDefinition[]) { - this._sortDefinitions = value ? [...value] : []; - this.mergeSortDefinitions(); - } - - public get sortDefinitions(): IZvTableSortDefinition[] { - return this._mergedSortDefinitions; - } - - @Input() public refreshable = true; - @Input() public filterable = true; - @Input() public showSettings = true; - @Input() public pageDebounce: number | null = null; - @Input() public preferSortDropdown = this._settingsService.preferSortDropdown; - - @Input() - public layout: 'card' | 'border' | 'flat' = 'card'; - - @Input() - @HostBinding('class.zv-table--striped') - public striped = false; - - @Input() stateManager: ZvTableStateManager = new ZvTableUrlStateManager(this._router, this._route); + public readonly caption = input(''); + public readonly dataSource = input.required>(); + public readonly tableId = input.required(); + public readonly refreshable = input(true); + public readonly filterable = input(true); + public readonly showSettings = input(true); + public readonly pageDebounce = input(null); + public readonly preferSortDropdown = input(this._settingsService.preferSortDropdown); + public readonly layout = input<'card' | 'border' | 'flat'>('card'); + public readonly stateManager = input(new ZvTableUrlStateManager(this._router, this._route)); + public readonly sortDefinitions = input([]); + public readonly striped = input(false); /** @deprecated use the datasource to detect paginations */ - @Output() public readonly page = new EventEmitter(); + public readonly page = output(); - @ViewChild(ZvFlipContainer, { static: true }) public flipContainer!: ZvFlipContainer; + public readonly flipContainer = viewChild.required(ZvFlipContainer); - @ContentChild(ZvTableCustomHeaderTemplate, { read: TemplateRef }) - public set customHeader(value: TemplateRef | null) { - this._customHeader = value; - this._cd.markForCheck(); - } + public readonly customHeader = contentChild(ZvTableCustomHeaderTemplate, { read: TemplateRef }); + public readonly customSettings = contentChild(ZvTableCustomSettingsTemplate, { read: TemplateRef }); + public readonly topButtonSection = contentChild(ZvTableTopButtonSectionTemplate, { read: TemplateRef }); - public get customHeader() { - return this._customHeader; - } + public readonly columnDefsQuery = contentChildren(ZvTableColumn); - private _customHeader: TemplateRef | null = null; - - @ContentChild(ZvTableCustomSettingsTemplate, { read: TemplateRef }) - public set customSettings(value: TemplateRef | null) { - this._customSettings = value; - this._cd.markForCheck(); - } - - public get customSettings() { - return this._customSettings; - } - - private _customSettings: TemplateRef | null = null; - - @ContentChild(ZvTableTopButtonSectionTemplate, { read: TemplateRef }) - public set topButtonSection(value: TemplateRef | null) { - this._topButtonSection = value; - this._cd.markForCheck(); - } - - public get topButtonSection() { - return this._topButtonSection; - } - - private _topButtonSection: TemplateRef | null = null; - - @ContentChildren(ZvTableColumn) - public set columnDefsSetter(queryList: QueryList) { - this.columnDefs = [...queryList.toArray()]; - this.mergeSortDefinitions(); - this.updateTableState(); - } - - @HostBinding('class.zv-table--row-detail') - @ContentChild(ZvTableRowDetail) - public set rowDetail(value: ZvTableRowDetail | null) { - this._rowDetail = value; - this.updateTableState(); - } + public readonly rowDetailQuery = contentChild(ZvTableRowDetail); public get rowDetail() { return this._rowDetail; } - private _rowDetail: ZvTableRowDetail | null = null; - public pageSizeOptions!: number[]; public columnDefs: ZvTableColumn[] = []; public displayedColumns: string[] = []; public get sortDirection(): 'asc' | 'desc' { - return this.dataSource.sortDirection; + return this.dataSource().sortDirection; } public set sortDirection(value: 'asc' | 'desc') { - this.dataSource.sortDirection = value; + this.dataSource().sortDirection = value; } public get sortColumn(): string | null { - return this.dataSource.sortColumn; + return this.dataSource().sortColumn; } public set sortColumn(value: string | null) { - this.dataSource.sortColumn = value; + this.dataSource().sortColumn = value; } public get pageIndex(): number { - return this.dataSource.pageIndex; + return this.dataSource().pageIndex; } public set pageIndex(value: number) { - this.dataSource.pageIndex = value; + this.dataSource().pageIndex = value; } public get pageSize(): number { - return this.dataSource.pageSize; + return this.dataSource().pageSize; } public set pageSize(value: number) { - this.dataSource.pageSize = value; + this.dataSource().pageSize = value; } public get dataLength(): number { - return this.dataSource.dataLength; + return this.dataSource().dataLength; } public get filterText(): string { - return this.dataSource.filter; + return this.dataSource().filter; } public set filterText(value: string) { - this.dataSource.filter = value; + this.dataSource().filter = value; } public get showNoEntriesText(): boolean { - return !this.dataSource.loading && !this.dataSource.error && !this.dataSource.visibleRows.length; + return !this.dataSource().loading && !this.dataSource().error && !this.dataSource().visibleRows.length; } public get errorMessage(): string | null { - return this._exceptionMessageExtractor.extractErrorMessage(this.dataSource.error); + return this._exceptionMessageExtractor.extractErrorMessage(this.dataSource().error); } public get showError(): boolean { - return !!this.dataSource.error; + return !!this.dataSource().error; } public get showLoading(): boolean { - return this.dataSource.loading; + return this.dataSource().loading; } public get settingsEnabled(): boolean { - return !!(this.tableId && this._settingsService.settingsEnabled && this.showSettings); + return !!(this.tableId() && this._settingsService.settingsEnabled && this.showSettings()); } public get showListActions(): boolean { - return !!this.dataSource.listActions.length || this.settingsEnabled || this.refreshable; + return !!this.dataSource().listActions.length || this.settingsEnabled || this.refreshable(); } public get useSortDropdown(): boolean | null { - return this.preferSortDropdown || this._sortDefinitions.length !== 0; + return this.preferSortDropdown() || this._sortDefinitions.length !== 0; } public get showDropdownSorting(): boolean { return (this.useSortDropdown && !!this._mergedSortDefinitions.length) ?? false; } + public get mergedSortDefinitions(): IZvTableSortDefinition[] { + return this._mergedSortDefinitions; + } + private subscriptions: Subscription[] = []; private _sortDefinitions: IZvTableSortDefinition[] = []; private _mergedSortDefinitions: IZvTableSortDefinition[] = []; private _tableSettings: Partial = {}; private _urlSettings: Partial = {}; + private _rowDetail: ZvTableRowDetail | null = null; + private _previousDataSource: ITableDataSource | null = null; + + constructor() { + effect(() => { + const cols = this.columnDefsQuery(); + const sortDefs = this.sortDefinitions(); + const detail = this.rowDetailQuery(); + const ds = this.dataSource(); + + untracked(() => { + this.columnDefs = [...cols]; + this._sortDefinitions = sortDefs ? [...sortDefs] : []; + this._rowDetail = detail ?? null; + + if (this._previousDataSource && this._previousDataSource !== ds) { + ds.tableReady = this._previousDataSource.tableReady; + } + this._previousDataSource = ds; + + this.mergeSortDefinitions(); + this.updateTableState(); + }); + }); + } public ngOnInit() { - this.dataSource.locale = this._locale; + this.dataSource().locale = this._locale; this.pageSizeOptions = this._settingsService.pageSizeOptions; - } - - public ngOnChanges(changes: SimpleChanges) { - if (changes.dataSource && !changes.dataSource.firstChange) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - this.dataSource.tableReady = changes.dataSource.previousValue.tableReady as unknown as boolean; - } - - this.updateTableState(); - } - - public ngAfterContentInit(): void { // This can't happen earlier, otherwise the ContentChilds would not be resolved yet this.initListSettingsSubscription(); } @@ -295,12 +251,12 @@ export class ZvTable implements OnInit, OnChanges, AfterContent } public onRefreshDataClicked() { - this.dataSource.updateData(true); + this.dataSource().updateData(true); } public onSettingsSaved() { - this.stateManager.remove(this.tableId); - this.flipContainer.showFront(); + this.stateManager().remove(this.tableId()); + this.flipContainer().showFront(); } /** @public This is a public api */ @@ -314,15 +270,15 @@ export class ZvTable implements OnInit, OnChanges, AfterContent private requestUpdate(data: Partial) { const updateInfo = { - ...this.dataSource.getUpdateDataInfo(), + ...this.dataSource().getUpdateDataInfo(), ...data, }; - this.stateManager.requestUpdate(this.tableId, updateInfo); + this.stateManager().requestUpdate(this.tableId(), updateInfo); } private initListSettingsSubscription() { - const tableSettings$ = this._settingsService.getStream(this.tableId, false); - const stateSettings$ = this.stateManager.createStateSource(this.tableId); + const tableSettings$ = this._settingsService.getStream(this.tableId(), false); + const stateSettings$ = this.stateManager().createStateSource(this.tableId()); this.subscriptions.push( combineLatest([stateSettings$, tableSettings$]) .pipe( @@ -337,12 +293,12 @@ export class ZvTable implements OnInit, OnChanges, AfterContent // Paging, Sorting, Filter und Display Columns updaten this.updateTableState(); - this.dataSource.tableReady = true; - this.dataSource.updateData(false); + this.dataSource().tableReady = true; + this.dataSource().updateData(false); }, // eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents error: (err: Error | unknown) => { - this.dataSource.error = err; + this.dataSource().error = err; }, }) ); @@ -358,23 +314,23 @@ export class ZvTable implements OnInit, OnChanges, AfterContent this.sortDirection = urlSettings.sortDirection || tableSettings.sortDirection || 'asc'; this.filterText = urlSettings.searchText || ''; - this.displayedColumns = this.columnDefs.map((x) => x.property); + this.displayedColumns = this.columnDefs.map((x) => x.property()); if (tableSettings.columnBlacklist && tableSettings.columnBlacklist.length) { this.displayedColumns = this.displayedColumns.filter((x) => !tableSettings.columnBlacklist!.includes(x)); } // Row Detail Expander aktivieren - if (this.rowDetail && this.rowDetail.showToggleColumn) { + if (this.rowDetail && this.rowDetail.showToggleColumn()) { this.displayedColumns.splice(0, 0, 'rowDetailExpander'); } // Selektierung der Rows aktivieren - if (this.dataSource.listActions.length) { + if (this.dataSource().listActions.length) { this.displayedColumns.splice(0, 0, 'select'); } // Selektierungs- und Row-Aktionen aktivieren - if (this.showListActions || this.dataSource.rowActions.length) { + if (this.showListActions || this.dataSource().rowActions.length) { this.displayedColumns.push('options'); } @@ -383,8 +339,8 @@ export class ZvTable implements OnInit, OnChanges, AfterContent private mergeSortDefinitions() { const sortableColumns = this.columnDefs - .filter((def) => def.sortable) - .map((def) => ({ prop: def.property, displayName: def.header }) as IZvTableSortDefinition); + .filter((def) => def.sortable()) + .map((def) => ({ prop: def.property(), displayName: def.header() }) as IZvTableSortDefinition); this._mergedSortDefinitions = sortableColumns .concat(this._sortDefinitions) diff --git a/projects/components/utils/src/inject-destroy.spec.ts b/projects/components/utils/src/inject-destroy.spec.ts index 3127f82..192ebb6 100644 --- a/projects/components/utils/src/inject-destroy.spec.ts +++ b/projects/components/utils/src/inject-destroy.spec.ts @@ -4,7 +4,7 @@ import { vi } from 'vitest'; import { interval, takeUntil } from 'rxjs'; import { injectDestroy } from './inject-destroy'; -describe(injectDestroy.name, () => { +describe('injectDestroy', () => { describe('emits when the component is destroyed using takeUntil', () => { // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ standalone: true, template: '' }) diff --git a/projects/components/view/src/view-data-source.ts b/projects/components/view/src/view-data-source.ts index 3abf2e8..dfd599e 100644 --- a/projects/components/view/src/view-data-source.ts +++ b/projects/components/view/src/view-data-source.ts @@ -36,8 +36,8 @@ export class ZvViewDataSource implements IZvViewDataSource { if (this.connected) { throw new Error('ViewDataSource is already connected.'); } + this.connected = true; this.connectSub = this.options.loadTrigger$.subscribe((params) => { - this.connected = true; this.params = params; this.loadData(params); }); @@ -51,6 +51,7 @@ export class ZvViewDataSource implements IZvViewDataSource { } public disconnect(): void { + this.connected = false; this.connectSub.unsubscribe(); this.loadingSub.unsubscribe(); } diff --git a/projects/components/view/src/view.component.html b/projects/components/view/src/view.component.html index bdeb276..c273652 100644 --- a/projects/components/view/src/view.component.html +++ b/projects/components/view/src/view.component.html @@ -1,17 +1,17 @@ -@if (dataSource) { +@if (dataSource()) {
- @if (dataSource.contentVisible()) { - + @if (dataSource().contentVisible()) { + } - @if (dataSource.exception()) { - - @if (dataSource.exception()?.icon) { - {{ dataSource.exception()?.icon }} + @if (dataSource().exception()) { + + @if (dataSource().exception()?.icon) { + {{ dataSource().exception()?.icon }} } - {{ dataSource.exception()?.errorObject | zvErrorMessage }} + {{ dataSource().exception()?.errorObject | zvErrorMessage }} }
diff --git a/projects/components/view/src/view.component.spec.ts b/projects/components/view/src/view.component.spec.ts index 75aee57..281a831 100644 --- a/projects/components/view/src/view.component.spec.ts +++ b/projects/components/view/src/view.component.spec.ts @@ -1,7 +1,7 @@ import { HarnessLoader } from '@angular/cdk/testing'; import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; -import { ChangeDetectionStrategy, Component, signal, ViewChild } from '@angular/core'; +import { ChangeDetectionStrategy, Component, signal, viewChild } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { IZvException } from '@zvoove/components/core'; import { ZvViewHarness } from './testing/view.harness'; @@ -30,8 +30,7 @@ class TestViewDataSource implements IZvViewDataSource { }) export class TestDataSourceComponent { public readonly dataSource = signal(undefined); - @ViewChild(ZvView) - formComponent: ZvView; + readonly formComponent = viewChild(ZvView); } describe('ZvView', () => { diff --git a/projects/components/view/src/view.component.ts b/projects/components/view/src/view.component.ts index 5eb28c9..4bbb01d 100644 --- a/projects/components/view/src/view.component.ts +++ b/projects/components/view/src/view.component.ts @@ -1,4 +1,4 @@ -import { ChangeDetectionStrategy, Component, Input, OnDestroy, ViewEncapsulation } from '@angular/core'; +import { ChangeDetectionStrategy, Component, effect, input, ViewEncapsulation } from '@angular/core'; import { MatCard } from '@angular/material/card'; import { MatIcon } from '@angular/material/icon'; import { ZvBlockUi } from '@zvoove/components/block-ui'; @@ -13,33 +13,15 @@ import { IZvViewDataSource } from './view-data-source'; encapsulation: ViewEncapsulation.None, imports: [ZvBlockUi, MatCard, MatIcon, ZvErrorMessagePipe], }) -export class ZvView implements OnDestroy { - @Input({ required: true }) public set dataSource(value: IZvViewDataSource) { - if (this._dataSource) { - this._dataSource.disconnect(); - } +export class ZvView { + public readonly dataSource = input.required(); - this._dataSource = value; - - if (this._dataSource) { - this.activateDataSource(); - } - } - public get dataSource(): IZvViewDataSource { - return this._dataSource; - } - private _dataSource!: IZvViewDataSource; - - public ngOnDestroy() { - if (this._dataSource) { - this._dataSource.disconnect(); - } - } - - private activateDataSource() { - if (!this._dataSource) { - return; - } - this._dataSource.connect(); + constructor() { + effect((onCleanup) => { + const ds = this.dataSource(); + if (!ds) return; + ds.connect(); + onCleanup(() => ds.disconnect()); + }); } } diff --git a/projects/zvoove-components-demo/src/app/file-input-demo/file-input-demo.component.html b/projects/zvoove-components-demo/src/app/file-input-demo/file-input-demo.component.html index edfd226..d0fe61d 100644 --- a/projects/zvoove-components-demo/src/app/file-input-demo/file-input-demo.component.html +++ b/projects/zvoove-components-demo/src/app/file-input-demo/file-input-demo.component.html @@ -14,7 +14,7 @@

zv-file-input

accept (comma separeted) - + id diff --git a/projects/zvoove-components-demo/src/app/file-input-demo/file-input-demo.component.ts b/projects/zvoove-components-demo/src/app/file-input-demo/file-input-demo.component.ts index 2e22a13..e84e534 100644 --- a/projects/zvoove-components-demo/src/app/file-input-demo/file-input-demo.component.ts +++ b/projects/zvoove-components-demo/src/app/file-input-demo/file-input-demo.component.ts @@ -39,9 +39,7 @@ export class FileInputDemoComponent { public id = ''; public acceptStr = '*.*'; - public get accept() { - return this.acceptStr.split(',').map((s) => s.trim()); - } + public accept = ['*.*']; public placeholder = ''; public required = false; public disabled = false; @@ -51,6 +49,10 @@ export class FileInputDemoComponent { public validatorRequired = false; public useErrorStateMatcher = false; + public onAcceptChange() { + this.accept = this.acceptStr.split(',').map((s) => s.trim()); + } + public onValidatorChange() { const validators = []; if (this.validatorRequired) { diff --git a/projects/zvoove-components-demo/src/app/form-demo/form-data-source.ts b/projects/zvoove-components-demo/src/app/form-demo/form-data-source.ts index 344dacf..94bcddd 100644 --- a/projects/zvoove-components-demo/src/app/form-demo/form-data-source.ts +++ b/projects/zvoove-components-demo/src/app/form-demo/form-data-source.ts @@ -49,7 +49,7 @@ export class FormDataSource< private hasLoadError = false; private saving = false; private blockView = false; - private stateChanges$!: Subject; + private stateChanges$ = new Subject(); private loadParams: TTriggerData | null = null; private loadingSub = Subscription.EMPTY; private connectSub = Subscription.EMPTY;