mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 13:16:16 -08:00
perf(web): take the form-field defaults off the initial path
app.config.ts provided MAT_FORM_FIELD_DEFAULT_OPTIONS at the root, and that one import put the whole 54 KB chunk of @angular/material/form-field into the initial bundle, although every form field renders in a lazy route or a dialog. The bundler places a module in one chunk, so one eager import of any export carries the module's entire code. The defaults now travel with the element: AppFormFieldModule (@iptvnator/ui/components/form-field) exports MatFormFieldModule together with a directive that matches mat-form-field and provides the options in its element injector. That also reaches the dialogs that root services open, which a route-level provider would miss. Every component that renders a form field imports it, and tools/nx/check-form-field-defaults.mjs (CI) rejects one that does not. renderer.initialBytes: 1,596,201 -> 1,534,606 bytes (-61,595) on a local production build. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
8bf92f9d65
commit
73575ff092
26 files changed
+615
-35
No files matched your search
@@ -430,6 +430,12 @@ jobs:
|
||||
- name: Validate icon-only button names
|
||||
run: pnpm run a11y:icon-buttons:validate
|
||||
|
||||
# The form-field defaults travel with AppFormFieldModule, not a
|
||||
# root provider: a component that renders <mat-form-field>
|
||||
# without it silently gets Material's filled look.
|
||||
- name: Validate form-field defaults
|
||||
run: pnpm run styles:form-field-defaults:validate
|
||||
|
||||
# Nx rejects a non-parallel task with continuous dependencies, and
|
||||
# the Playwright plugin infers serve dependencies only when CI is
|
||||
# unset, so this checks both the local and the CI inference.
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
OverlayContainer,
|
||||
} from '@angular/cdk/overlay';
|
||||
import { ApplicationConfig, inject, importProvidersFrom } from '@angular/core';
|
||||
import { MAT_FORM_FIELD_DEFAULT_OPTIONS } from '@angular/material/form-field';
|
||||
import { provideAnimations } from '@angular/platform-browser/animations';
|
||||
import { provideRouter, withComponentInputBinding } from '@angular/router';
|
||||
import { provideServiceWorker } from '@angular/service-worker';
|
||||
@@ -162,9 +161,5 @@ export const appConfig: ApplicationConfig = {
|
||||
...provideParentalLockPrompt(),
|
||||
provideParentalLockPlaylistCleanup(),
|
||||
...provideXtreamDataSource(),
|
||||
{
|
||||
provide: MAT_FORM_FIELD_DEFAULT_OPTIONS,
|
||||
useValue: { appearance: 'outline', subscriptSizing: 'dynamic' },
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -8,10 +8,10 @@ import {
|
||||
} from '@angular/core';
|
||||
import { FormGroup, ReactiveFormsModule } from '@angular/forms';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { AppFormFieldModule } from '@iptvnator/ui/components/form-field';
|
||||
import {
|
||||
AppUpdateChannel,
|
||||
APP_UPDATE_CHANNELS,
|
||||
@@ -30,7 +30,7 @@ interface SettingsUpdateStatusChip {
|
||||
selector: 'app-settings-about-section',
|
||||
imports: [
|
||||
MatButtonModule,
|
||||
MatFormFieldModule,
|
||||
AppFormFieldModule,
|
||||
MatIconModule,
|
||||
MatSelectModule,
|
||||
ReactiveFormsModule,
|
||||
|
||||
@@ -12,12 +12,12 @@ import {
|
||||
ReactiveFormsModule,
|
||||
} from '@angular/forms';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { AppFormFieldModule } from '@iptvnator/ui/components/form-field';
|
||||
import { EpgViewMode } from '@iptvnator/shared/interfaces';
|
||||
import { EpgSourceStatusComponent } from '@iptvnator/ui/epg';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
@@ -29,7 +29,7 @@ import { markSectionForCheckOnFormEvents } from './settings-section-form-render'
|
||||
imports: [
|
||||
EpgSourceStatusComponent,
|
||||
MatButtonModule,
|
||||
MatFormFieldModule,
|
||||
AppFormFieldModule,
|
||||
MatIconModule,
|
||||
MatInputModule,
|
||||
MatProgressSpinnerModule,
|
||||
|
||||
@@ -7,11 +7,11 @@ import {
|
||||
ChangeDetectionStrategy,
|
||||
} from '@angular/core';
|
||||
import { FormGroup, ReactiveFormsModule } from '@angular/forms';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { AppFormFieldModule } from '@iptvnator/ui/components/form-field';
|
||||
import { CoverSize, Language, Theme } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
CoverSizeOption,
|
||||
@@ -25,7 +25,7 @@ import { markSectionForCheckOnFormEvents } from './settings-section-form-render'
|
||||
selector: 'app-settings-general-section',
|
||||
imports: [
|
||||
CommonModule,
|
||||
MatFormFieldModule,
|
||||
AppFormFieldModule,
|
||||
MatIconModule,
|
||||
MatSelectModule,
|
||||
MatSlideToggleModule,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Component, input, output, ViewEncapsulation } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import {
|
||||
@@ -8,13 +7,14 @@ import {
|
||||
MatSlideToggleModule,
|
||||
} from '@angular/material/slide-toggle';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { AppFormFieldModule } from '@iptvnator/ui/components/form-field';
|
||||
import { ParentalLockRelockMinutes } from '@iptvnator/shared/interfaces';
|
||||
|
||||
@Component({
|
||||
selector: 'app-settings-parental-lock-section',
|
||||
imports: [
|
||||
MatButtonModule,
|
||||
MatFormFieldModule,
|
||||
AppFormFieldModule,
|
||||
MatIconModule,
|
||||
MatSelectModule,
|
||||
MatSlideToggleModule,
|
||||
|
||||
@@ -8,13 +8,13 @@ import {
|
||||
} from '@angular/core';
|
||||
import { FormGroup, ReactiveFormsModule } from '@angular/forms';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { AppFormFieldModule } from '@iptvnator/ui/components/form-field';
|
||||
import {
|
||||
StreamFormat,
|
||||
VideoPlayer,
|
||||
@@ -28,7 +28,7 @@ import { markSectionForCheckOnFormEvents } from './settings-section-form-render'
|
||||
imports: [
|
||||
CommonModule,
|
||||
MatButtonModule,
|
||||
MatFormFieldModule,
|
||||
AppFormFieldModule,
|
||||
MatIconModule,
|
||||
MatInputModule,
|
||||
MatSelectModule,
|
||||
|
||||
@@ -7,20 +7,20 @@ import {
|
||||
} from '@angular/core';
|
||||
import { FormGroup, ReactiveFormsModule } from '@angular/forms';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { QRCodeComponent } from 'angularx-qrcode';
|
||||
import { AppFormFieldModule } from '@iptvnator/ui/components/form-field';
|
||||
import { markSectionForCheckOnFormEvents } from './settings-section-form-render';
|
||||
|
||||
@Component({
|
||||
selector: 'app-settings-remote-control-section',
|
||||
imports: [
|
||||
MatButtonModule,
|
||||
MatFormFieldModule,
|
||||
AppFormFieldModule,
|
||||
MatIconModule,
|
||||
MatInputModule,
|
||||
MatSlideToggleModule,
|
||||
|
||||
@@ -8,12 +8,12 @@ import {
|
||||
} from '@angular/core';
|
||||
import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { AppFormFieldModule } from '@iptvnator/ui/components/form-field';
|
||||
import { TmdbApiService, TmdbCacheService } from '@iptvnator/services';
|
||||
import type { TmdbCacheStats } from '@iptvnator/shared/interfaces';
|
||||
import { markSectionForCheckOnFormEvents } from './settings-section-form-render';
|
||||
@@ -24,7 +24,7 @@ type TmdbKeyTestState = 'idle' | 'testing' | 'success' | 'error';
|
||||
selector: 'app-settings-tmdb-section',
|
||||
imports: [
|
||||
MatButtonModule,
|
||||
MatFormFieldModule,
|
||||
AppFormFieldModule,
|
||||
MatIconModule,
|
||||
MatInputModule,
|
||||
MatProgressSpinnerModule,
|
||||
|
||||
@@ -278,9 +278,9 @@ html {
|
||||
}
|
||||
|
||||
// ─── Global refined outlined inputs ──────────────────────────────────────────
|
||||
// Applied app-wide AFTER mat.all-component-themes so these overrides win the
|
||||
// cascade. Pairs with MAT_FORM_FIELD_DEFAULT_OPTIONS (outline + dynamic) set
|
||||
// in app.config.ts.
|
||||
// Applied app-wide AFTER the component themes so these overrides win the
|
||||
// cascade. Pairs with the outline + dynamic defaults that AppFormFieldModule
|
||||
// (libs/ui/components, form-field) gives every form field.
|
||||
$app-refined-input-density: mat.define-theme((
|
||||
density: (scale: -2),
|
||||
));
|
||||
|
||||
@@ -9,10 +9,10 @@ import {
|
||||
import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
|
||||
import { FormControl, ReactiveFormsModule } from '@angular/forms';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { AppFormFieldModule } from '@iptvnator/ui/components/form-field';
|
||||
import {
|
||||
detectProviderImportCandidates,
|
||||
ProviderImportCandidate,
|
||||
@@ -150,7 +150,7 @@ function maskUrlQueryPasswords(url: string): string {
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
MatButtonModule,
|
||||
MatFormFieldModule,
|
||||
AppFormFieldModule,
|
||||
MatIcon,
|
||||
MatInputModule,
|
||||
ReactiveFormsModule,
|
||||
|
||||
+2
-2
@@ -18,12 +18,12 @@ import {
|
||||
} from '@angular/forms';
|
||||
import { MatIconButton } from '@angular/material/button';
|
||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { AppFormFieldModule } from '@iptvnator/ui/components/form-field';
|
||||
import { PlaylistActions } from '@iptvnator/m3u-state';
|
||||
import {
|
||||
legacyTransformStalkerPortalUrl,
|
||||
@@ -66,7 +66,7 @@ interface StalkerSettledIdentity {
|
||||
imports: [
|
||||
FormsModule,
|
||||
MatCheckboxModule,
|
||||
MatFormFieldModule,
|
||||
AppFormFieldModule,
|
||||
MatIcon,
|
||||
MatIconButton,
|
||||
MatInputModule,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
|
||||
import { AppFormFieldModule } from '@iptvnator/ui/components/form-field';
|
||||
import { MockModule } from 'ng-mocks';
|
||||
import { TextImportComponent } from './text-import.component';
|
||||
|
||||
@@ -14,6 +15,9 @@ describe('TextImportComponent', () => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [
|
||||
TextImportComponent,
|
||||
// The form field and its input are mocked together: a real
|
||||
// MatFormField would look for the mocked input's state.
|
||||
MockModule(AppFormFieldModule),
|
||||
MockModule(MatInputModule),
|
||||
MockModule(FormsModule),
|
||||
MockModule(ReactiveFormsModule),
|
||||
|
||||
@@ -12,13 +12,19 @@ import {
|
||||
} from '@angular/forms';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { AppFormFieldModule } from '@iptvnator/ui/components/form-field';
|
||||
|
||||
@Component({
|
||||
selector: 'app-text-import',
|
||||
templateUrl: './text-import.component.html',
|
||||
styleUrls: ['./text-import.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [MatInputModule, ReactiveFormsModule, TranslatePipe],
|
||||
imports: [
|
||||
AppFormFieldModule,
|
||||
MatInputModule,
|
||||
ReactiveFormsModule,
|
||||
TranslatePipe,
|
||||
],
|
||||
})
|
||||
export class TextImportComponent {
|
||||
@Output() textAdded = new EventEmitter<string>();
|
||||
|
||||
@@ -11,9 +11,9 @@ import {
|
||||
ReactiveFormsModule,
|
||||
Validators,
|
||||
} from '@angular/forms';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { AppFormFieldModule } from '@iptvnator/ui/components/form-field';
|
||||
import { RuntimeCapabilitiesService } from '@iptvnator/services';
|
||||
|
||||
@Component({
|
||||
@@ -21,7 +21,7 @@ import { RuntimeCapabilitiesService } from '@iptvnator/services';
|
||||
templateUrl: './url-upload.component.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
MatFormFieldModule,
|
||||
AppFormFieldModule,
|
||||
MatInputModule,
|
||||
ReactiveFormsModule,
|
||||
TranslatePipe,
|
||||
|
||||
+2
-2
@@ -16,11 +16,11 @@ import {
|
||||
Validators,
|
||||
} from '@angular/forms';
|
||||
import { MatIconButton } from '@angular/material/button';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { AppFormFieldModule } from '@iptvnator/ui/components/form-field';
|
||||
import { PlaylistActions } from '@iptvnator/m3u-state';
|
||||
import { createXtreamConnectionTestState } from '@iptvnator/services';
|
||||
import { PasswordVisibilityToggleDirective } from '@iptvnator/ui/components/password-visibility-toggle';
|
||||
@@ -50,7 +50,7 @@ function xtreamServerUrlValidator(
|
||||
@Component({
|
||||
imports: [
|
||||
FormsModule,
|
||||
MatFormFieldModule,
|
||||
AppFormFieldModule,
|
||||
MatIcon,
|
||||
MatIconButton,
|
||||
MatInputModule,
|
||||
|
||||
+2
@@ -28,6 +28,7 @@ import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { AppFormFieldModule } from '@iptvnator/ui/components/form-field';
|
||||
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
|
||||
import { PlaylistActions } from '@iptvnator/m3u-state';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
@@ -168,6 +169,7 @@ type DesktopFileSaveBridge = Pick<
|
||||
providers: [DatePipe],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
AppFormFieldModule,
|
||||
ClipboardModule,
|
||||
MatButton,
|
||||
MatCheckboxModule,
|
||||
|
||||
@@ -10,6 +10,7 @@ export * from './lib/confirm-dialog/dialog.service';
|
||||
export * from './lib/content-hero/content-hero.component';
|
||||
export * from './lib/expandable-text/expandable-text.component';
|
||||
export * from './lib/external-playback-dock/external-playback-dock.component';
|
||||
export * from './lib/form-field/app-form-field.module';
|
||||
export * from './lib/portal-detail-shell/content-about.component';
|
||||
export * from './lib/portal-detail-shell/detail-template.directives';
|
||||
export * from './lib/portal-detail-shell/portal-detail-shell.component';
|
||||
|
||||
+2
-2
@@ -14,11 +14,11 @@ import {
|
||||
MatDialogModule,
|
||||
MatDialogRef,
|
||||
} from '@angular/material/dialog';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { AppFormFieldModule } from '../../form-field/app-form-field.module';
|
||||
import { EpgRuntimeBridgeService, EpgService } from '@iptvnator/epg/data-access';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { debounceTime, from, Subject, switchMap } from 'rxjs';
|
||||
@@ -46,7 +46,7 @@ const SEARCH_MIN_CHARS = 2;
|
||||
FormsModule,
|
||||
MatButtonModule,
|
||||
MatDialogModule,
|
||||
MatFormFieldModule,
|
||||
AppFormFieldModule,
|
||||
MatIconModule,
|
||||
MatInputModule,
|
||||
MatProgressSpinnerModule,
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { AppFormFieldModule } from './app-form-field.module';
|
||||
|
||||
@Component({
|
||||
imports: [AppFormFieldModule, MatInputModule],
|
||||
template: `
|
||||
<mat-form-field>
|
||||
<mat-label>Name</mat-label>
|
||||
<input matInput />
|
||||
<mat-hint>Short</mat-hint>
|
||||
</mat-form-field>
|
||||
`,
|
||||
})
|
||||
class WithDefaultsComponent {}
|
||||
|
||||
@Component({
|
||||
imports: [MatFormFieldModule, MatInputModule],
|
||||
template: `
|
||||
<mat-form-field>
|
||||
<mat-label>Name</mat-label>
|
||||
<input matInput />
|
||||
</mat-form-field>
|
||||
`,
|
||||
})
|
||||
class WithoutDefaultsComponent {}
|
||||
|
||||
function formField(component: typeof WithDefaultsComponent): HTMLElement {
|
||||
const fixture = TestBed.createComponent(component);
|
||||
fixture.detectChanges();
|
||||
return fixture.nativeElement.querySelector('mat-form-field');
|
||||
}
|
||||
|
||||
describe('AppFormFieldModule', () => {
|
||||
it('gives a form field the outlined appearance and dynamic subscript', () => {
|
||||
const field = formField(WithDefaultsComponent);
|
||||
|
||||
expect(field.classList).toContain('mat-form-field-appearance-outline');
|
||||
expect(field.querySelector('.mdc-notched-outline')).not.toBeNull();
|
||||
// Dynamic sizing reserves no empty subscript row below the field.
|
||||
expect(
|
||||
field.querySelector('.mat-mdc-form-field-subscript-dynamic-size')
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it('is the only source of those defaults: Material alone renders filled', () => {
|
||||
const field = formField(WithoutDefaultsComponent);
|
||||
|
||||
expect(field.classList).toContain('mat-form-field-appearance-fill');
|
||||
expect(field.querySelector('.mdc-notched-outline')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Directive, NgModule } from '@angular/core';
|
||||
import {
|
||||
MAT_FORM_FIELD_DEFAULT_OPTIONS,
|
||||
MatFormFieldDefaultOptions,
|
||||
MatFormFieldModule,
|
||||
} from '@angular/material/form-field';
|
||||
|
||||
/** The app-wide form field: outlined, with hints and errors sized as needed. */
|
||||
export const APP_FORM_FIELD_DEFAULTS: MatFormFieldDefaultOptions = {
|
||||
appearance: 'outline',
|
||||
subscriptSizing: 'dynamic',
|
||||
};
|
||||
|
||||
/**
|
||||
* Gives every `<mat-form-field>` the app defaults through its own element
|
||||
* injector, which `MatFormField` reads before any environment injector.
|
||||
*
|
||||
* Provided at the root, the defaults were a static import of
|
||||
* `@angular/material/form-field` in `app.config.ts`, and one eager import of
|
||||
* any export puts a module's whole bundler chunk on the initial path: 54 KB
|
||||
* of form-field code for an app whose every form field renders in a lazy
|
||||
* route or dialog. A route-level provider would miss the dialogs that root
|
||||
* services open, so the defaults travel with the element instead.
|
||||
*/
|
||||
@Directive({
|
||||
// The directive must match the Material element itself: an attribute
|
||||
// would have to be added to every form field by hand.
|
||||
// eslint-disable-next-line @angular-eslint/directive-selector
|
||||
selector: 'mat-form-field',
|
||||
providers: [
|
||||
{
|
||||
provide: MAT_FORM_FIELD_DEFAULT_OPTIONS,
|
||||
useValue: APP_FORM_FIELD_DEFAULTS,
|
||||
},
|
||||
],
|
||||
})
|
||||
export class AppFormFieldDefaultsDirective {}
|
||||
|
||||
/**
|
||||
* Import this, not `MatFormFieldModule`, wherever a template renders
|
||||
* `<mat-form-field>`; it exports Material's form field together with the
|
||||
* defaults directive. `pnpm run styles:form-field-defaults:validate` (CI)
|
||||
* rejects a component that renders a form field without it.
|
||||
*/
|
||||
@NgModule({
|
||||
imports: [MatFormFieldModule, AppFormFieldDefaultsDirective],
|
||||
exports: [MatFormFieldModule, AppFormFieldDefaultsDirective],
|
||||
})
|
||||
export class AppFormFieldModule {}
|
||||
+2
-2
@@ -16,10 +16,10 @@ import {
|
||||
MatDialogModule,
|
||||
MatDialogRef,
|
||||
} from '@angular/material/dialog';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { AppFormFieldModule } from '../form-field/app-form-field.module';
|
||||
import {
|
||||
createParentalLockPinThrottle,
|
||||
isValidParentalLockPin,
|
||||
@@ -66,7 +66,7 @@ export interface ParentalLockPinDialogData {
|
||||
FormsModule,
|
||||
MatButtonModule,
|
||||
MatDialogModule,
|
||||
MatFormFieldModule,
|
||||
AppFormFieldModule,
|
||||
MatIconModule,
|
||||
MatInputModule,
|
||||
TranslatePipe,
|
||||
|
||||
@@ -62,6 +62,9 @@
|
||||
"a11y:icon-buttons:test": "node --test tools/nx/check-icon-button-names.test.mjs",
|
||||
"a11y:icon-buttons:check": "node tools/nx/check-icon-button-names.mjs",
|
||||
"a11y:icon-buttons:validate": "pnpm run a11y:icon-buttons:test && pnpm run a11y:icon-buttons:check",
|
||||
"styles:form-field-defaults:test": "node --test tools/nx/check-form-field-defaults.test.mjs",
|
||||
"styles:form-field-defaults:check": "node tools/nx/check-form-field-defaults.mjs",
|
||||
"styles:form-field-defaults:validate": "pnpm run styles:form-field-defaults:test && pnpm run styles:form-field-defaults:check",
|
||||
"e2e:task-graphs:test": "node --test tools/nx/check-e2e-task-graphs.test.mjs",
|
||||
"e2e:task-graphs:check": "node tools/nx/check-e2e-task-graphs.mjs",
|
||||
"e2e:task-graphs:validate": "pnpm run e2e:task-graphs:test && pnpm run e2e:task-graphs:check",
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import {
|
||||
parseTemplate,
|
||||
TmplAstRecursiveVisitor,
|
||||
tmplAstVisitAll,
|
||||
} from '@angular/compiler';
|
||||
import ts from 'typescript';
|
||||
|
||||
import { isScanned } from './check-theme-references.mjs';
|
||||
|
||||
/**
|
||||
* The app-wide form-field defaults (outlined, dynamic subscript) are not a
|
||||
* root provider: that import put the whole form-field chunk on the initial
|
||||
* path. `AppFormFieldModule` carries them on the element instead, so a
|
||||
* component that renders `<mat-form-field>` without importing it silently
|
||||
* gets Material's filled look. This guard requires the import.
|
||||
*/
|
||||
|
||||
/** Angular renderer sources whose components can render a form field. */
|
||||
const SCANNED_PATHSPECS = [
|
||||
'apps/web/*.ts',
|
||||
'apps/remote-control-web/*.ts',
|
||||
'libs/*.ts',
|
||||
];
|
||||
|
||||
export const DEFAULTS_MODULE = 'AppFormFieldModule';
|
||||
export const DEFAULTS_ALIAS = '@iptvnator/ui/components/form-field';
|
||||
const DEFAULTS_SOURCE = /(^|\/)app-form-field\.module$/;
|
||||
|
||||
const FORM_FIELD_ELEMENT = 'mat-form-field';
|
||||
|
||||
/** Identifiers listed in an `imports: [...]` array, nested arrays included. */
|
||||
function importIdentifiers(expression) {
|
||||
if (ts.isArrayLiteralExpression(expression)) {
|
||||
return expression.elements.flatMap(importIdentifiers);
|
||||
}
|
||||
return ts.isIdentifier(expression) ? [expression.text] : [];
|
||||
}
|
||||
|
||||
function propertyNamed(object, name) {
|
||||
return object.properties.find(
|
||||
(property) =>
|
||||
ts.isPropertyAssignment(property) &&
|
||||
(ts.isIdentifier(property.name) ||
|
||||
ts.isStringLiteral(property.name)) &&
|
||||
property.name.text === name
|
||||
);
|
||||
}
|
||||
|
||||
function stringValue(property) {
|
||||
const value = property?.initializer;
|
||||
return value &&
|
||||
(ts.isStringLiteral(value) || ts.isNoSubstitutionTemplateLiteral(value))
|
||||
? value.text
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `@Component({...})` declarations of a TypeScript source: class name,
|
||||
* the line its decorator starts on, inline `template` or `templateUrl`, and
|
||||
* the identifiers of its `imports` array. Whether the source binds `AppFormFieldModule` from its
|
||||
* module is reported alongside, since a same-named local class would not
|
||||
* carry the defaults.
|
||||
*/
|
||||
export function componentDeclarations(source, file) {
|
||||
const sourceFile = ts.createSourceFile(
|
||||
file,
|
||||
source,
|
||||
ts.ScriptTarget.Latest,
|
||||
true
|
||||
);
|
||||
const components = [];
|
||||
let bindsDefaultsModule = false;
|
||||
|
||||
const visit = (node) => {
|
||||
if (
|
||||
ts.isImportDeclaration(node) &&
|
||||
ts.isStringLiteral(node.moduleSpecifier) &&
|
||||
(node.moduleSpecifier.text === DEFAULTS_ALIAS ||
|
||||
DEFAULTS_SOURCE.test(node.moduleSpecifier.text)) &&
|
||||
node.importClause?.namedBindings &&
|
||||
ts.isNamedImports(node.importClause.namedBindings) &&
|
||||
node.importClause.namedBindings.elements.some(
|
||||
(element) => element.name.text === DEFAULTS_MODULE
|
||||
)
|
||||
) {
|
||||
bindsDefaultsModule = true;
|
||||
}
|
||||
if (
|
||||
ts.isDecorator(node) &&
|
||||
ts.isCallExpression(node.expression) &&
|
||||
ts.isIdentifier(node.expression.expression) &&
|
||||
node.expression.expression.text === 'Component' &&
|
||||
ts.isClassDeclaration(node.parent)
|
||||
) {
|
||||
const [metadata] = node.expression.arguments;
|
||||
if (metadata && ts.isObjectLiteralExpression(metadata)) {
|
||||
const importsProperty = propertyNamed(metadata, 'imports');
|
||||
components.push({
|
||||
className: node.parent.name?.text ?? '(anonymous)',
|
||||
line:
|
||||
sourceFile.getLineAndCharacterOfPosition(
|
||||
node.parent.getStart(sourceFile)
|
||||
).line + 1,
|
||||
template: stringValue(propertyNamed(metadata, 'template')),
|
||||
templateUrl: stringValue(
|
||||
propertyNamed(metadata, 'templateUrl')
|
||||
),
|
||||
imports: importsProperty
|
||||
? importIdentifiers(importsProperty.initializer)
|
||||
: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
ts.forEachChild(node, visit);
|
||||
};
|
||||
visit(sourceFile);
|
||||
return { components, bindsDefaultsModule };
|
||||
}
|
||||
|
||||
class FormFieldVisitor extends TmplAstRecursiveVisitor {
|
||||
found = false;
|
||||
|
||||
visitElement(element) {
|
||||
if (element.name === FORM_FIELD_ELEMENT) this.found = true;
|
||||
super.visitElement(element);
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether a template renders `<mat-form-field>` anywhere, control flow included. */
|
||||
export function templateRendersFormField(template, file) {
|
||||
const parsed = parseTemplate(template, file, {
|
||||
preserveWhitespaces: true,
|
||||
});
|
||||
if (parsed.errors?.length) {
|
||||
throw new Error(
|
||||
`${file}: cannot parse template: ${parsed.errors[0].msg}`
|
||||
);
|
||||
}
|
||||
const visitor = new FormFieldVisitor();
|
||||
tmplAstVisitAll(visitor, parsed.nodes);
|
||||
return visitor.found;
|
||||
}
|
||||
|
||||
/**
|
||||
* Components of one source that render a form field without importing the
|
||||
* defaults module. `readTemplate` resolves a `templateUrl` to its text.
|
||||
*/
|
||||
export async function findFormFieldsWithoutDefaults(
|
||||
file,
|
||||
source,
|
||||
readTemplate
|
||||
) {
|
||||
const { components, bindsDefaultsModule } = componentDeclarations(
|
||||
source,
|
||||
file
|
||||
);
|
||||
const findings = [];
|
||||
for (const component of components) {
|
||||
const template =
|
||||
component.template ??
|
||||
(component.templateUrl
|
||||
? await readTemplate(component.templateUrl)
|
||||
: undefined);
|
||||
if (template === undefined) continue;
|
||||
if (!templateRendersFormField(template, file)) continue;
|
||||
if (
|
||||
bindsDefaultsModule &&
|
||||
component.imports.includes(DEFAULTS_MODULE)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
findings.push({
|
||||
file,
|
||||
line: component.line,
|
||||
className: component.className,
|
||||
});
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
/** Tracked renderer sources the guard reads, at any depth. */
|
||||
export function listScannedFiles(rootDir) {
|
||||
// No shell: `cmd.exe` treats single quotes as literal characters, so a
|
||||
// POSIX-quoted pathspec reaches git intact on Windows and matches nothing.
|
||||
return execFileSync('git', ['ls-files', ...SCANNED_PATHSPECS], {
|
||||
cwd: rootDir,
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 32 * 1024 * 1024,
|
||||
})
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter(Boolean)
|
||||
.filter(isScanned);
|
||||
}
|
||||
|
||||
const isMain =
|
||||
process.argv[1] &&
|
||||
path.resolve(process.argv[1]) ===
|
||||
path.resolve(fileURLToPath(import.meta.url));
|
||||
|
||||
if (isMain) {
|
||||
const rootDir = process.cwd();
|
||||
const files = listScannedFiles(rootDir);
|
||||
|
||||
const findings = [];
|
||||
for (const file of files) {
|
||||
const source = await readFile(path.join(rootDir, file), 'utf8');
|
||||
if (
|
||||
!source.includes(FORM_FIELD_ELEMENT) &&
|
||||
!source.includes('templateUrl')
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
findings.push(
|
||||
...(await findFormFieldsWithoutDefaults(file, source, (url) =>
|
||||
readFile(path.resolve(rootDir, path.dirname(file), url), 'utf8')
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
if (findings.length > 0) {
|
||||
console.error(
|
||||
`Components that render <mat-form-field> without ${DEFAULTS_MODULE} (from ${DEFAULTS_ALIAS}), so their fields lose the app defaults (outlined, dynamic subscript). Import it in place of MatFormFieldModule:`
|
||||
);
|
||||
for (const { file, line, className } of findings) {
|
||||
console.error(`- ${file}:${line} ${className}`);
|
||||
}
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
console.log(
|
||||
`Checked ${files.length} renderer sources; every component rendering <mat-form-field> imports ${DEFAULTS_MODULE}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { test } from 'node:test';
|
||||
|
||||
import {
|
||||
componentDeclarations,
|
||||
findFormFieldsWithoutDefaults,
|
||||
listScannedFiles,
|
||||
templateRendersFormField,
|
||||
} from './check-form-field-defaults.mjs';
|
||||
|
||||
const lines = (...rows) => rows.join('\n');
|
||||
|
||||
const component = ({
|
||||
name = 'FieldComponent',
|
||||
imports = ['AppFormFieldModule'],
|
||||
template,
|
||||
templateUrl,
|
||||
importLine = "import { AppFormFieldModule } from '@iptvnator/ui/components/form-field';",
|
||||
}) =>
|
||||
lines(
|
||||
"import { Component } from '@angular/core';",
|
||||
importLine,
|
||||
'',
|
||||
'@Component({',
|
||||
` selector: 'app-field',`,
|
||||
` imports: [${imports.join(', ')}],`,
|
||||
template !== undefined
|
||||
? ` template: \`${template}\`,`
|
||||
: ` templateUrl: '${templateUrl}',`,
|
||||
'})',
|
||||
`export class ${name} {}`
|
||||
);
|
||||
|
||||
const noTemplates = () => {
|
||||
throw new Error('no templateUrl expected');
|
||||
};
|
||||
|
||||
test('accepts a form field in a component that imports the defaults module', async () => {
|
||||
const source = component({
|
||||
template: '<mat-form-field><input matInput /></mat-form-field>',
|
||||
});
|
||||
assert.deepEqual(
|
||||
await findFormFieldsWithoutDefaults(
|
||||
'a.component.ts',
|
||||
source,
|
||||
noTemplates
|
||||
),
|
||||
[]
|
||||
);
|
||||
});
|
||||
|
||||
test('reports the line of the @Component decorator when the defaults module is missing', async () => {
|
||||
const source = component({
|
||||
imports: ['MatFormFieldModule'],
|
||||
importLine:
|
||||
"import { MatFormFieldModule } from '@angular/material/form-field';",
|
||||
template: '<mat-form-field><input matInput /></mat-form-field>',
|
||||
});
|
||||
assert.deepEqual(
|
||||
await findFormFieldsWithoutDefaults(
|
||||
'a.component.ts',
|
||||
source,
|
||||
noTemplates
|
||||
),
|
||||
[{ file: 'a.component.ts', line: 4, className: 'FieldComponent' }]
|
||||
);
|
||||
});
|
||||
|
||||
test('a same-named class from elsewhere does not count as the defaults module', async () => {
|
||||
const source = component({
|
||||
importLine: "import { AppFormFieldModule } from './local-module';",
|
||||
template: '<mat-form-field></mat-form-field>',
|
||||
});
|
||||
assert.equal(
|
||||
(
|
||||
await findFormFieldsWithoutDefaults(
|
||||
'a.component.ts',
|
||||
source,
|
||||
noTemplates
|
||||
)
|
||||
).length,
|
||||
1
|
||||
);
|
||||
});
|
||||
|
||||
test('a relative import of the module source counts inside its own library', async () => {
|
||||
const source = component({
|
||||
importLine:
|
||||
"import { AppFormFieldModule } from '../../form-field/app-form-field.module';",
|
||||
template: '<mat-form-field></mat-form-field>',
|
||||
});
|
||||
assert.deepEqual(
|
||||
await findFormFieldsWithoutDefaults(
|
||||
'a.component.ts',
|
||||
source,
|
||||
noTemplates
|
||||
),
|
||||
[]
|
||||
);
|
||||
});
|
||||
|
||||
test('reads templateUrl templates and sees form fields inside control flow', async () => {
|
||||
const source = component({
|
||||
imports: ['MatInputModule'],
|
||||
importLine: "import { MatInputModule } from '@angular/material/input';",
|
||||
templateUrl: './a.component.html',
|
||||
});
|
||||
const urls = [];
|
||||
const findings = await findFormFieldsWithoutDefaults(
|
||||
'dir/a.component.ts',
|
||||
source,
|
||||
async (url) => {
|
||||
urls.push(url);
|
||||
return lines(
|
||||
'@if (editing()) {',
|
||||
' <mat-form-field appearance="fill">',
|
||||
' <input matInput />',
|
||||
' </mat-form-field>',
|
||||
'}'
|
||||
);
|
||||
}
|
||||
);
|
||||
assert.deepEqual(urls, ['./a.component.html']);
|
||||
assert.equal(findings.length, 1);
|
||||
});
|
||||
|
||||
test('ignores components without a form field and classes without a template', async () => {
|
||||
const source = lines(
|
||||
component({
|
||||
imports: ['MatButtonModule'],
|
||||
importLine:
|
||||
"import { MatButtonModule } from '@angular/material/button';",
|
||||
template: '<button mat-button>Save</button>',
|
||||
}),
|
||||
'@Injectable({ providedIn: "root" })',
|
||||
'export class PlainService {}'
|
||||
);
|
||||
assert.deepEqual(
|
||||
await findFormFieldsWithoutDefaults('a.ts', source, noTemplates),
|
||||
[]
|
||||
);
|
||||
});
|
||||
|
||||
test('templateRendersFormField does not match a form field inside a comment', () => {
|
||||
assert.equal(
|
||||
templateRendersFormField(
|
||||
'<!-- <mat-form-field></mat-form-field> -->',
|
||||
'a.html'
|
||||
),
|
||||
false
|
||||
);
|
||||
assert.equal(
|
||||
templateRendersFormField(
|
||||
'<div><ng-template><mat-form-field /></ng-template></div>',
|
||||
'a.html'
|
||||
),
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
test('componentDeclarations flattens nested import arrays and keeps the template url', () => {
|
||||
const { components, bindsDefaultsModule } = componentDeclarations(
|
||||
lines(
|
||||
"import { Component } from '@angular/core';",
|
||||
"import { AppFormFieldModule } from '@iptvnator/ui/components/form-field';",
|
||||
'const SHARED = [AppFormFieldModule];',
|
||||
'@Component({',
|
||||
' imports: [[MatInputModule, SHARED], AppFormFieldModule],',
|
||||
" templateUrl: './x.html',",
|
||||
'})',
|
||||
'export class XComponent {}'
|
||||
),
|
||||
'x.ts'
|
||||
);
|
||||
assert.equal(bindsDefaultsModule, true);
|
||||
assert.deepEqual(components, [
|
||||
{
|
||||
className: 'XComponent',
|
||||
line: 4,
|
||||
template: undefined,
|
||||
templateUrl: './x.html',
|
||||
imports: ['MatInputModule', 'SHARED', 'AppFormFieldModule'],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('listScannedFiles returns tracked renderer sources and skips specs', async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), 'form-field-guard-'));
|
||||
try {
|
||||
await mkdir(path.join(root, 'libs/ui/src'), { recursive: true });
|
||||
await mkdir(path.join(root, 'apps/web/src'), { recursive: true });
|
||||
await writeFile(path.join(root, 'libs/ui/src/a.component.ts'), '');
|
||||
await writeFile(path.join(root, 'libs/ui/src/a.component.spec.ts'), '');
|
||||
await writeFile(path.join(root, 'apps/web/src/b.component.ts'), '');
|
||||
await writeFile(path.join(root, 'apps/web/src/untracked.ts'), '');
|
||||
execFileSync('git', ['init', '-q'], { cwd: root });
|
||||
execFileSync(
|
||||
'git',
|
||||
[
|
||||
'add',
|
||||
'libs/ui/src/a.component.ts',
|
||||
'libs/ui/src/a.component.spec.ts',
|
||||
'apps/web/src/b.component.ts',
|
||||
],
|
||||
{ cwd: root }
|
||||
);
|
||||
assert.deepEqual(listScannedFiles(root).sort(), [
|
||||
'apps/web/src/b.component.ts',
|
||||
'libs/ui/src/a.component.ts',
|
||||
]);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -105,6 +105,9 @@
|
||||
"@iptvnator/ui/components/confirm-dialog": [
|
||||
"libs/ui/components/src/lib/confirm-dialog/dialog.service.ts"
|
||||
],
|
||||
"@iptvnator/ui/components/form-field": [
|
||||
"libs/ui/components/src/lib/form-field/app-form-field.module.ts"
|
||||
],
|
||||
"@iptvnator/ui/components/parental-lock-pin-dialog": [
|
||||
"libs/ui/components/src/lib/parental-lock-pin-dialog/parental-lock-pin-dialog.component.ts"
|
||||
],
|
||||
|
||||
Reference in new issue
Block a user