mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-13 20:56:19 -08:00
Merge remote-tracking branch 'origin/master' into claude/great-herschel-e690e4
# Conflicts: # .github/workflows/ci.yml # apps/web/src/app/app.config.ts # apps/web/src/m3-theme.scss # libs/playlist/import/feature/src/lib/text-import/text-import.component.ts # package.json # tools/nx/check-form-field-defaults.mjs # tools/nx/check-form-field-defaults.test.mjs # tools/performance/journey-baselines.json # tsconfig.base.json
This commit is contained in:
commit
a39ca37e07
42 files changed
+644
-551
No files matched your search
@@ -0,0 +1,7 @@
|
||||
---
|
||||
type: perf
|
||||
area: performance
|
||||
---
|
||||
|
||||
The app loads less code at startup: the text field component and its form
|
||||
helpers now load with the screens that show a form instead of with the app.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
type: fix
|
||||
area: stalker
|
||||
---
|
||||
|
||||
Stalker series details now keep their clickable genre and country chips after TMDB fills in the details, so you can jump to Discover from them as you can for movies.
|
||||
@@ -3,7 +3,7 @@ type: perf
|
||||
area: web
|
||||
---
|
||||
|
||||
The app loads about 15% less code before it shows its first screen: form
|
||||
field, dialog, tooltip and progress-bar code, the animation engine and the
|
||||
theme colours of unused components now load only when something needs them,
|
||||
so every launch has less to download and parse.
|
||||
The app loads about 12% less code before it shows its first screen: dialog,
|
||||
tooltip and progress-bar code, the animation engine and the theme colours of
|
||||
unused components now load only when something needs them, so every launch
|
||||
has less to download and parse.
|
||||
@@ -1,10 +1,11 @@
|
||||
# Every Dependabot PR triggers the full pipeline (~15 jobs), so version
|
||||
# updates are batched weekly. Nx minor+patch updates use a dedicated group so
|
||||
# official packages move together; Nx majors are manual coordinated migrations.
|
||||
# Native packaging, parser, and version-locked playback dependencies stay in
|
||||
# standalone PRs; other minor+patch updates are grouped per ecosystem. Security
|
||||
# updates are separate and are not limited by this schedule; CI enforces complete
|
||||
# Nx lockstep.
|
||||
# Electron majors are held while Linux armv7l packages ship: Electron 44
|
||||
# dropped the linux-armv7l binary. Native packaging, parser, and
|
||||
# version-locked playback dependencies stay in standalone PRs; other
|
||||
# minor+patch updates are grouped per ecosystem. Security updates are separate
|
||||
# and are not limited by this schedule; CI enforces complete Nx lockstep.
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: npm
|
||||
@@ -21,6 +22,9 @@ updates:
|
||||
- dependency-name: '@nx/*'
|
||||
update-types:
|
||||
- version-update:semver-major
|
||||
- dependency-name: electron
|
||||
update-types:
|
||||
- version-update:semver-major
|
||||
groups:
|
||||
nx-version-updates:
|
||||
applies-to: version-updates
|
||||
|
||||
@@ -430,11 +430,11 @@ 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.
|
||||
# Form-field defaults are provided per lazy component: the root
|
||||
# token import would load Material form-field and @angular/forms
|
||||
# on startup (tools/nx/check-form-field-defaults.mjs).
|
||||
- name: Validate form-field defaults
|
||||
run: pnpm run styles:form-field-defaults:validate
|
||||
run: pnpm run ui:form-field-defaults:validate
|
||||
|
||||
# m3-theme.scss themes only the Material components the app
|
||||
# imports; a new import without a theme entry renders unthemed.
|
||||
|
||||
@@ -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,
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { UpdateChannelOption } from './settings.models';
|
||||
import { markSectionForCheckOnFormEvents } from './settings-section-form-render';
|
||||
import { provideFormFieldDefaults } from '@iptvnator/ui/components/form-field-defaults';
|
||||
|
||||
interface SettingsUpdateStatusChip {
|
||||
readonly labelKey: string;
|
||||
@@ -28,9 +29,10 @@ interface SettingsUpdateStatusChip {
|
||||
|
||||
@Component({
|
||||
selector: 'app-settings-about-section',
|
||||
providers: [provideFormFieldDefaults()],
|
||||
imports: [
|
||||
MatButtonModule,
|
||||
AppFormFieldModule,
|
||||
MatFormFieldModule,
|
||||
MatIconModule,
|
||||
MatSelectModule,
|
||||
ReactiveFormsModule,
|
||||
|
||||
@@ -12,24 +12,26 @@ 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';
|
||||
import { EpgViewModeOption } from './settings.models';
|
||||
import { markSectionForCheckOnFormEvents } from './settings-section-form-render';
|
||||
import { provideFormFieldDefaults } from '@iptvnator/ui/components/form-field-defaults';
|
||||
|
||||
@Component({
|
||||
selector: 'app-settings-epg-section',
|
||||
providers: [provideFormFieldDefaults()],
|
||||
imports: [
|
||||
EpgSourceStatusComponent,
|
||||
MatButtonModule,
|
||||
AppFormFieldModule,
|
||||
MatFormFieldModule,
|
||||
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,
|
||||
@@ -20,12 +20,14 @@ import {
|
||||
ThemeOption,
|
||||
} from './settings.models';
|
||||
import { markSectionForCheckOnFormEvents } from './settings-section-form-render';
|
||||
import { provideFormFieldDefaults } from '@iptvnator/ui/components/form-field-defaults';
|
||||
|
||||
@Component({
|
||||
selector: 'app-settings-general-section',
|
||||
providers: [provideFormFieldDefaults()],
|
||||
imports: [
|
||||
CommonModule,
|
||||
AppFormFieldModule,
|
||||
MatFormFieldModule,
|
||||
MatIconModule,
|
||||
MatSelectModule,
|
||||
MatSlideToggleModule,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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 {
|
||||
@@ -7,14 +8,15 @@ 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';
|
||||
import { provideFormFieldDefaults } from '@iptvnator/ui/components/form-field-defaults';
|
||||
|
||||
@Component({
|
||||
selector: 'app-settings-parental-lock-section',
|
||||
providers: [provideFormFieldDefaults()],
|
||||
imports: [
|
||||
MatButtonModule,
|
||||
AppFormFieldModule,
|
||||
MatFormFieldModule,
|
||||
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,
|
||||
@@ -22,13 +22,15 @@ import {
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { SettingsPlayerOption } from './settings.models';
|
||||
import { markSectionForCheckOnFormEvents } from './settings-section-form-render';
|
||||
import { provideFormFieldDefaults } from '@iptvnator/ui/components/form-field-defaults';
|
||||
|
||||
@Component({
|
||||
selector: 'app-settings-playback-section',
|
||||
providers: [provideFormFieldDefaults()],
|
||||
imports: [
|
||||
CommonModule,
|
||||
MatButtonModule,
|
||||
AppFormFieldModule,
|
||||
MatFormFieldModule,
|
||||
MatIconModule,
|
||||
MatInputModule,
|
||||
MatSelectModule,
|
||||
|
||||
@@ -7,20 +7,22 @@ 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';
|
||||
import { provideFormFieldDefaults } from '@iptvnator/ui/components/form-field-defaults';
|
||||
|
||||
@Component({
|
||||
selector: 'app-settings-remote-control-section',
|
||||
providers: [provideFormFieldDefaults()],
|
||||
imports: [
|
||||
MatButtonModule,
|
||||
AppFormFieldModule,
|
||||
MatFormFieldModule,
|
||||
MatIconModule,
|
||||
MatInputModule,
|
||||
MatSlideToggleModule,
|
||||
|
||||
@@ -8,23 +8,25 @@ 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';
|
||||
import { provideFormFieldDefaults } from '@iptvnator/ui/components/form-field-defaults';
|
||||
|
||||
type TmdbKeyTestState = 'idle' | 'testing' | 'success' | 'error';
|
||||
|
||||
@Component({
|
||||
selector: 'app-settings-tmdb-section',
|
||||
providers: [provideFormFieldDefaults()],
|
||||
imports: [
|
||||
MatButtonModule,
|
||||
AppFormFieldModule,
|
||||
MatFormFieldModule,
|
||||
MatIconModule,
|
||||
MatInputModule,
|
||||
MatProgressSpinnerModule,
|
||||
|
||||
@@ -329,8 +329,9 @@ html {
|
||||
|
||||
// ─── Global refined outlined inputs ──────────────────────────────────────────
|
||||
// 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.
|
||||
// cascade. Pairs with the outline + dynamic MAT_FORM_FIELD_DEFAULT_OPTIONS
|
||||
// that provideFormFieldDefaults() (@iptvnator/ui/components) sets on each
|
||||
// component rendering a form field.
|
||||
$app-refined-input-density: mat.define-theme((
|
||||
density: (scale: -2),
|
||||
));
|
||||
|
||||
@@ -97,14 +97,13 @@ therefore reaches the initial bundle only through the snack-bar, which root
|
||||
services inject (and with it `button`, the icon button and the CDK overlay).
|
||||
Everything else is lazy by these rules:
|
||||
|
||||
- **Form-field defaults.** Import `AppFormFieldModule`
|
||||
(`@iptvnator/ui/components/form-field`) instead of `MatFormFieldModule`
|
||||
wherever a template renders `<mat-form-field>`: it exports Material's form
|
||||
field with a directive that provides the app defaults (outlined, dynamic
|
||||
subscript) on the element. The defaults are not a root provider, and a
|
||||
route-level one would miss dialogs opened by root services.
|
||||
`pnpm run styles:form-field-defaults:validate` (CI) rejects a component that
|
||||
renders a form field without the module.
|
||||
- **Form-field defaults.** A component that renders `<mat-form-field>`
|
||||
adds `providers: [provideFormFieldDefaults()]`
|
||||
(`@iptvnator/ui/components/form-field-defaults`); the token is never
|
||||
provided at the root, where its import alone put the 54 KB form-field
|
||||
chunk on the initial path. `pnpm run ui:form-field-defaults:validate` (CI)
|
||||
requires the provider on every such component and allows the token
|
||||
nowhere else; see the Dialogs and forms notes below.
|
||||
- **Dialogs from services created with the app.** A root service that opens a
|
||||
dialog resolves `MatDialog` through `LazyMatDialog`
|
||||
(`@iptvnator/ui/components/lazy-dialog`) and imports the dialog component
|
||||
@@ -1083,6 +1082,13 @@ per-theme tokens in `apps/web/src/_settings-theme.scss`.
|
||||
|
||||
## Forms
|
||||
|
||||
Every `<mat-form-field>` is outlined with a dynamic subscript. A component
|
||||
that renders one adds `providers: [provideFormFieldDefaults()]`
|
||||
(`@iptvnator/ui/components/form-field-defaults`); the root injector does not
|
||||
provide the defaults, because the token import there would load Material's
|
||||
form field and `@angular/forms` on startup. `pnpm run
|
||||
ui:form-field-defaults:validate` (in CI) reports a missing provider.
|
||||
|
||||
The add-source forms (M3U URL, Xtream, Stalker) and the edit dialog share one
|
||||
vocabulary, so a field reads the same wherever it appears:
|
||||
|
||||
|
||||
@@ -1163,7 +1163,7 @@ form-field chunk on the initial path, although every form field renders in a
|
||||
lazy route or dialog. Angular Material therefore stays off the startup path by
|
||||
construction, with the rules and guards in the
|
||||
[UI guidelines](iptvnator-ui-guidelines.md#material-off-the-startup-path):
|
||||
form-field defaults travel with `AppFormFieldModule`, services created with
|
||||
form-field defaults are provided per component (#1928), services created with
|
||||
the app resolve `MatDialog` through `LazyMatDialog`, the panels the shell
|
||||
mounts on every launch defer their Material views, the theme emits tokens only
|
||||
for imported components, and the app does not provide the animations module,
|
||||
|
||||
@@ -432,6 +432,12 @@ Detail metadata rule:
|
||||
[Embedded Inline Playback](./embedded-inline-playback.md). It runs before
|
||||
TMDB enrichment, which keeps a non-empty provider rating and release date,
|
||||
so a surviving stand-in would also block the TMDB value.
|
||||
- It also runs AFTER enrichment: the series view re-normalizes the store item
|
||||
on every render, so it carries every `tmdb_*` field over. The key list
|
||||
(`TMDB_ENRICHMENT_KEYS`) is typed as a `Record` over `StalkerVodInfo`'s
|
||||
`tmdb_*` keys, so a new enrichment field fails to compile until it is listed
|
||||
there instead of silently vanishing from the view (as the genre and country
|
||||
Discover chips once did).
|
||||
|
||||
## Stalker Identity Policy
|
||||
|
||||
|
||||
@@ -832,8 +832,10 @@ Contracts worth keeping:
|
||||
credits and no recommendations carries none of them and reads as
|
||||
un-enriched. Since the column is never overwritten, getting this wrong is
|
||||
unfixable after the fact — a real provider date arriving later cannot
|
||||
correct it. The same marker is what `trustedReleaseYear` (the
|
||||
recommendations exclusion index) must consult if it ever reads this column.
|
||||
correct it. Stalker entries never reach this column, but the same marker
|
||||
rides in their stored `info`, and `trustedReleaseYear` (the recommendations
|
||||
exclusion index) reads it first: a date TMDB supplied does not gate the
|
||||
exact tier. `createStalkerInfo()` keeps the marker through re-normalization.
|
||||
- **The id is stored unvetted.** Every consumer reaches TMDB through
|
||||
`TmdbEnrichmentService`, whose `detailsForProviderId` runs
|
||||
`assessProviderId` and lets the title search take over when the years
|
||||
|
||||
@@ -878,6 +878,9 @@ Toolchain notes for the Electron 43 baseline:
|
||||
Those dependencies require standalone PRs so their dedicated package,
|
||||
worker, playback, and diagnostic-contract validation cannot be hidden by an
|
||||
unrelated grouped update.
|
||||
Electron majors are ignored: Electron 44 ships no `linux-armv7l` binary,
|
||||
and the release still packages armv7l AppImage, DEB and Snap builds. Remove
|
||||
the ignore together with those targets when 32-bit ARM support is dropped.
|
||||
4. Electron 42 and newer download their development binary on the first
|
||||
Electron command instead of during package `postinstall`, so `electron`
|
||||
must not remain in pnpm's `onlyBuiltDependencies` allowlist. The first local
|
||||
|
||||
@@ -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,
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
ProviderImportKind,
|
||||
SOURCE_TYPE_ICONS,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { provideFormFieldDefaults } from '@iptvnator/ui/components/form-field-defaults';
|
||||
|
||||
interface CandidateSummaryRow {
|
||||
labelKey: string;
|
||||
@@ -145,12 +146,13 @@ function maskUrlQueryPasswords(url: string): string {
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-auto-import',
|
||||
providers: [provideFormFieldDefaults()],
|
||||
templateUrl: './auto-import.component.html',
|
||||
styleUrl: './auto-import.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
MatButtonModule,
|
||||
AppFormFieldModule,
|
||||
MatFormFieldModule,
|
||||
MatIcon,
|
||||
MatInputModule,
|
||||
ReactiveFormsModule,
|
||||
|
||||
+4
-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,
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
toStalkerImportFeedback,
|
||||
toStalkerPlaylistIdentityFields,
|
||||
} from './stalker-import-identity';
|
||||
import { provideFormFieldDefaults } from '@iptvnator/ui/components/form-field-defaults';
|
||||
|
||||
/**
|
||||
* A MAC and the device IDs that belong to exactly it. Kept together because
|
||||
@@ -66,7 +67,7 @@ interface StalkerSettledIdentity {
|
||||
imports: [
|
||||
FormsModule,
|
||||
MatCheckboxModule,
|
||||
AppFormFieldModule,
|
||||
MatFormFieldModule,
|
||||
MatIcon,
|
||||
MatIconButton,
|
||||
MatInputModule,
|
||||
@@ -75,6 +76,7 @@ interface StalkerSettledIdentity {
|
||||
TranslatePipe,
|
||||
],
|
||||
selector: 'app-stalker-portal-import',
|
||||
providers: [provideFormFieldDefaults()],
|
||||
templateUrl: './stalker-portal-import.component.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
styles: [
|
||||
|
||||
@@ -3,7 +3,6 @@ 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';
|
||||
|
||||
@@ -15,9 +14,6 @@ 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,19 +12,15 @@ import {
|
||||
} from '@angular/forms';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { AppFormFieldModule } from '@iptvnator/ui/components/form-field';
|
||||
import { provideFormFieldDefaults } from '@iptvnator/ui/components/form-field-defaults';
|
||||
|
||||
@Component({
|
||||
selector: 'app-text-import',
|
||||
providers: [provideFormFieldDefaults()],
|
||||
templateUrl: './text-import.component.html',
|
||||
styleUrls: ['./text-import.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
AppFormFieldModule,
|
||||
MatInputModule,
|
||||
ReactiveFormsModule,
|
||||
TranslatePipe,
|
||||
],
|
||||
imports: [MatInputModule, ReactiveFormsModule, TranslatePipe],
|
||||
})
|
||||
export class TextImportComponent {
|
||||
@Output() textAdded = new EventEmitter<string>();
|
||||
|
||||
@@ -11,17 +11,19 @@ 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';
|
||||
import { provideFormFieldDefaults } from '@iptvnator/ui/components/form-field-defaults';
|
||||
|
||||
@Component({
|
||||
selector: 'app-url-upload',
|
||||
providers: [provideFormFieldDefaults()],
|
||||
templateUrl: './url-upload.component.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
AppFormFieldModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
ReactiveFormsModule,
|
||||
TranslatePipe,
|
||||
|
||||
+20
@@ -228,6 +228,26 @@ describe('XtreamCodeImportComponent form', () => {
|
||||
);
|
||||
}
|
||||
|
||||
it('outlines its fields with a dynamic subscript without a root provider', () => {
|
||||
// The root injector no longer provides MAT_FORM_FIELD_DEFAULT_OPTIONS
|
||||
// (it pulled Material's form field and @angular/forms into startup);
|
||||
// the form carries provideFormFieldDefaults() itself.
|
||||
const { root } = render();
|
||||
const fields = Array.from(root.querySelectorAll('mat-form-field'));
|
||||
|
||||
expect(fields.length).toBeGreaterThan(0);
|
||||
for (const field of fields) {
|
||||
expect(field.classList).toContain(
|
||||
'mat-form-field-appearance-outline'
|
||||
);
|
||||
expect(
|
||||
field.querySelector(
|
||||
'.mat-mdc-form-field-subscript-dynamic-size'
|
||||
)
|
||||
).not.toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('labels the name field like the other add-source forms', () => {
|
||||
const { root } = render();
|
||||
|
||||
|
||||
+4
-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';
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
normalizeXtreamServerUrl,
|
||||
Playlist,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { provideFormFieldDefaults } from '@iptvnator/ui/components/form-field-defaults';
|
||||
|
||||
function xtreamServerUrlValidator(
|
||||
control: AbstractControl
|
||||
@@ -50,7 +51,7 @@ function xtreamServerUrlValidator(
|
||||
@Component({
|
||||
imports: [
|
||||
FormsModule,
|
||||
AppFormFieldModule,
|
||||
MatFormFieldModule,
|
||||
MatIcon,
|
||||
MatIconButton,
|
||||
MatInputModule,
|
||||
@@ -59,6 +60,7 @@ function xtreamServerUrlValidator(
|
||||
TranslatePipe,
|
||||
],
|
||||
selector: 'app-xtream-code-import',
|
||||
providers: [provideFormFieldDefaults()],
|
||||
templateUrl: './xtream-code-import.component.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
styles: [
|
||||
|
||||
+2
-3
@@ -28,7 +28,6 @@ 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';
|
||||
@@ -63,6 +62,7 @@ import {
|
||||
STALKER_PLAYLIST_CONNECTION_EDITOR,
|
||||
STALKER_PLAYLIST_CONNECTION_EDITOR_STATUS,
|
||||
} from './stalker-playlist-connection-editor.token';
|
||||
import { provideFormFieldDefaults } from '@iptvnator/ui/components/form-field-defaults';
|
||||
|
||||
type DesktopFileSaveBridge = Pick<
|
||||
typeof window.electron,
|
||||
@@ -166,10 +166,9 @@ type DesktopFileSaveBridge = Pick<
|
||||
}
|
||||
`,
|
||||
],
|
||||
providers: [DatePipe],
|
||||
providers: [provideFormFieldDefaults(), DatePipe],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
AppFormFieldModule,
|
||||
ClipboardModule,
|
||||
MatButton,
|
||||
MatCheckboxModule,
|
||||
|
||||
@@ -253,44 +253,71 @@ describe('stalker-vod.utils regressions', () => {
|
||||
});
|
||||
|
||||
it('preserves TMDB enrichment fields through info re-normalization', () => {
|
||||
const tmdbCast = [{ name: 'Karl Urban', profileUrl: null }];
|
||||
// Detail views re-normalize the selected item on every render, so
|
||||
// every field `mergeStalkerInfoWithTmdb` can set has to survive here
|
||||
const tmdbCast = [{ name: 'Rowan Quill', profileUrl: null }];
|
||||
const tmdbRecommendations = [
|
||||
{ tmdbId: 1, title: 'Invincible', year: 2021, posterUrl: null },
|
||||
{ tmdbId: 1, title: 'Harbor Static', year: 2021, posterUrl: null },
|
||||
];
|
||||
const tmdbGenres = [{ id: 18, name: 'Drama' }];
|
||||
const tmdbCountries = [{ code: 'NO', name: 'Norway' }];
|
||||
|
||||
const info = createStalkerInfo({
|
||||
id: '7',
|
||||
info: {
|
||||
name: 'The Boys s05',
|
||||
name: 'Lantern Coast s05',
|
||||
movie_image: 'http://portal/poster.jpg',
|
||||
description: 'Plot',
|
||||
actors: 'Karl Urban',
|
||||
actors: 'Rowan Quill',
|
||||
director: '',
|
||||
releasedate: '2026',
|
||||
genre: 'Action',
|
||||
releasedate: '2026-03-01',
|
||||
genre: 'Drama',
|
||||
rating_imdb: '',
|
||||
rating_kinopoisk: '8.1',
|
||||
tmdb_id: 4242,
|
||||
tmdb_cast: tmdbCast,
|
||||
tmdb_directors: [
|
||||
{
|
||||
name: 'Eric Kripke',
|
||||
name: 'Mara Tollen',
|
||||
profileUrl: null,
|
||||
tmdbPersonId: 1216630,
|
||||
},
|
||||
],
|
||||
tmdb_backdrop: 'https://image.tmdb.org/t/p/w1280/boys.jpg',
|
||||
tmdb_status: 'returning',
|
||||
tmdb_backdrop: 'https://image.tmdb.org/t/p/w1280/coast.jpg',
|
||||
tmdb_trailer: 'abc123def',
|
||||
tmdb_recommendations: tmdbRecommendations,
|
||||
tmdb_genres: tmdbGenres,
|
||||
tmdb_countries: tmdbCountries,
|
||||
tmdb_media_type: 'tv',
|
||||
tmdb_supplied_release_date: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(info.tmdb_id).toBe(4242);
|
||||
expect(info.tmdb_cast).toEqual(tmdbCast);
|
||||
expect(info.tmdb_directors?.[0]?.name).toBe('Eric Kripke');
|
||||
expect(info.tmdb_directors?.[0]?.name).toBe('Mara Tollen');
|
||||
expect(info.tmdb_status).toBe('returning');
|
||||
expect(info.tmdb_backdrop).toBe(
|
||||
'https://image.tmdb.org/t/p/w1280/boys.jpg'
|
||||
'https://image.tmdb.org/t/p/w1280/coast.jpg'
|
||||
);
|
||||
expect(info.tmdb_trailer).toBe('abc123def');
|
||||
expect(info.tmdb_recommendations).toEqual(tmdbRecommendations);
|
||||
expect(info.tmdb_genres).toEqual(tmdbGenres);
|
||||
expect(info.tmdb_countries).toEqual(tmdbCountries);
|
||||
expect(info.tmdb_media_type).toBe('tv');
|
||||
expect(info.tmdb_supplied_release_date).toBe(true);
|
||||
});
|
||||
|
||||
it('adds no TMDB fields to a row that was never enriched', () => {
|
||||
const info = createStalkerInfo({
|
||||
id: '8',
|
||||
info: { name: 'Unenriched Title', releasedate: '2019' },
|
||||
});
|
||||
|
||||
expect(
|
||||
Object.keys(info).filter((key) => key.startsWith('tmdb_'))
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
describe('temporary-link flags survive normalization', () => {
|
||||
|
||||
@@ -188,20 +188,46 @@ export function createStalkerInfo(item: StalkerVodSource): StalkerVodInfo {
|
||||
genre: text(info.genre, item.genre, item.genres_str),
|
||||
rating_imdb: rating(info.rating_imdb, item.rating_imdb),
|
||||
rating_kinopoisk: rating(info.rating_kinopoisk, item.rating_kinopoisk),
|
||||
// TMDB enrichment fields must survive re-normalization — detail
|
||||
// views normalize the selected item on every render
|
||||
...(info.tmdb_id ? { tmdb_id: info.tmdb_id } : {}),
|
||||
...(info.tmdb_cast ? { tmdb_cast: info.tmdb_cast } : {}),
|
||||
...(info.tmdb_directors ? { tmdb_directors: info.tmdb_directors } : {}),
|
||||
...(info.tmdb_status ? { tmdb_status: info.tmdb_status } : {}),
|
||||
...(info.tmdb_backdrop ? { tmdb_backdrop: info.tmdb_backdrop } : {}),
|
||||
...(info.tmdb_trailer ? { tmdb_trailer: info.tmdb_trailer } : {}),
|
||||
...(info.tmdb_recommendations
|
||||
? { tmdb_recommendations: info.tmdb_recommendations }
|
||||
: {}),
|
||||
...pickTmdbEnrichment(info),
|
||||
};
|
||||
}
|
||||
|
||||
type TmdbEnrichmentKey = Extract<keyof StalkerVodInfo, `tmdb_${string}`>;
|
||||
|
||||
/**
|
||||
* Every TMDB enrichment field of {@link StalkerVodInfo}. A `Record` over the
|
||||
* `tmdb_*` keys, so adding one to the interface fails to compile until it is
|
||||
* listed here: detail views re-normalize the selected item on every render,
|
||||
* and a field this list missed would vanish right after enrichment.
|
||||
*/
|
||||
const TMDB_ENRICHMENT_KEYS: Record<TmdbEnrichmentKey, true> = {
|
||||
tmdb_id: true,
|
||||
tmdb_cast: true,
|
||||
tmdb_directors: true,
|
||||
tmdb_status: true,
|
||||
tmdb_backdrop: true,
|
||||
tmdb_trailer: true,
|
||||
tmdb_recommendations: true,
|
||||
tmdb_genres: true,
|
||||
tmdb_countries: true,
|
||||
tmdb_media_type: true,
|
||||
tmdb_supplied_release_date: true,
|
||||
};
|
||||
|
||||
function pickTmdbEnrichment(
|
||||
info: Partial<StalkerVodInfo>
|
||||
): Partial<Pick<StalkerVodInfo, TmdbEnrichmentKey>> {
|
||||
const picked: Record<string, unknown> = {};
|
||||
for (const key of Object.keys(
|
||||
TMDB_ENRICHMENT_KEYS
|
||||
) as TmdbEnrichmentKey[]) {
|
||||
if (info[key]) {
|
||||
picked[key] = info[key];
|
||||
}
|
||||
}
|
||||
return picked;
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrows a raw portal row to the fields the detail/playback flows need.
|
||||
*
|
||||
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
import { ComponentFixture } from '@angular/core/testing';
|
||||
import { StalkerSeriesViewComponent } from './stalker-series-view.component';
|
||||
import { createStalkerSeriesViewHarness } from './stalker-series-view.test-harness';
|
||||
|
||||
/**
|
||||
* The view re-normalizes the selected item on every render, so the TMDB
|
||||
* genre and country facets the enrichment merged into the store item have
|
||||
* to survive that pass to become clickable Discover chips.
|
||||
*/
|
||||
describe('StalkerSeriesViewComponent TMDB facet chips', () => {
|
||||
let fixture: ComponentFixture<StalkerSeriesViewComponent>;
|
||||
const { selectedItem, createFixture } = createStalkerSeriesViewHarness(() =>
|
||||
jest.fn()
|
||||
);
|
||||
|
||||
async function stabilize(): Promise<void> {
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
}
|
||||
|
||||
function facetChipLabels(): string[] {
|
||||
return Array.from(
|
||||
(fixture.nativeElement as HTMLElement).querySelectorAll(
|
||||
'button.meta-chip__facet'
|
||||
)
|
||||
).map((chip) => chip.textContent?.trim() ?? '');
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
fixture = await createFixture();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fixture?.destroy();
|
||||
});
|
||||
|
||||
it('renders the enriched genres and countries as Discover chips', async () => {
|
||||
await stabilize();
|
||||
|
||||
// Enrichment patches the store item after selection
|
||||
selectedItem.set({
|
||||
id: '30001',
|
||||
cmd: '/media/file_30001.mpg',
|
||||
info: {
|
||||
name: 'Regular Series',
|
||||
description: 'Series description',
|
||||
movie_image: 'poster.jpg',
|
||||
genre: 'Drama',
|
||||
tmdb_id: 777,
|
||||
tmdb_genres: [
|
||||
{ id: 18, name: 'Drama' },
|
||||
{ id: 9648, name: 'Mystery' },
|
||||
],
|
||||
tmdb_countries: [{ code: 'NO', name: 'Norway' }],
|
||||
tmdb_media_type: 'tv',
|
||||
},
|
||||
} as never);
|
||||
await stabilize();
|
||||
|
||||
expect(facetChipLabels()).toEqual(
|
||||
expect.arrayContaining(['Drama', 'Mystery', 'Norway'])
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -64,4 +64,6 @@ export interface StalkerVodInfo {
|
||||
* this to send Discover clicks to the right media type.
|
||||
*/
|
||||
tmdb_media_type?: TmdbMediaType;
|
||||
/** See `XtreamVodInfo.tmdb_supplied_release_date` — same contract */
|
||||
tmdb_supplied_release_date?: boolean;
|
||||
}
|
||||
@@ -9,8 +9,8 @@ export * from './lib/confirm-dialog/confirm-dialog.component';
|
||||
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/form-field-defaults/form-field-defaults';
|
||||
export * from './lib/external-playback-dock/external-playback-dock.component';
|
||||
export * from './lib/form-field/app-form-field.module';
|
||||
export * from './lib/lazy-dialog/lazy-mat-dialog';
|
||||
export * from './lib/portal-detail-shell/content-about.component';
|
||||
export * from './lib/portal-detail-shell/detail-template.directives';
|
||||
|
||||
+4
-2
@@ -14,14 +14,15 @@ 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';
|
||||
import { provideFormFieldDefaults } from '../../form-field-defaults/form-field-defaults';
|
||||
|
||||
export interface EpgMappingDialogData {
|
||||
channelKey: string;
|
||||
@@ -39,6 +40,7 @@ const SEARCH_MIN_CHARS = 2;
|
||||
|
||||
@Component({
|
||||
selector: 'app-epg-mapping-dialog',
|
||||
providers: [provideFormFieldDefaults()],
|
||||
templateUrl: './epg-mapping-dialog.component.html',
|
||||
styleUrl: './epg-mapping-dialog.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
@@ -46,7 +48,7 @@ const SEARCH_MIN_CHARS = 2;
|
||||
FormsModule,
|
||||
MatButtonModule,
|
||||
MatDialogModule,
|
||||
AppFormFieldModule,
|
||||
MatFormFieldModule,
|
||||
MatIconModule,
|
||||
MatInputModule,
|
||||
MatProgressSpinnerModule,
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Provider } from '@angular/core';
|
||||
import {
|
||||
MAT_FORM_FIELD_DEFAULT_OPTIONS,
|
||||
MatFormFieldDefaultOptions,
|
||||
} from '@angular/material/form-field';
|
||||
|
||||
/** The app's outlined, dynamically sized form fields (see m3-theme.scss). */
|
||||
export const APP_FORM_FIELD_DEFAULTS: MatFormFieldDefaultOptions = {
|
||||
appearance: 'outline',
|
||||
subscriptSizing: 'dynamic',
|
||||
};
|
||||
|
||||
/**
|
||||
* Provide the app's form-field defaults on a component that renders
|
||||
* `<mat-form-field>`. They are not provided in the root injector: importing
|
||||
* the token there loads `@angular/material/form-field` (and, since Material
|
||||
* 22.2, `@angular/forms`) on startup, while every form field lives in a lazy
|
||||
* chunk. `tools/nx/check-form-field-defaults.mjs` enforces this.
|
||||
*/
|
||||
export function provideFormFieldDefaults(): Provider {
|
||||
return {
|
||||
provide: MAT_FORM_FIELD_DEFAULT_OPTIONS,
|
||||
useValue: APP_FORM_FIELD_DEFAULTS,
|
||||
};
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,49 +0,0 @@
|
||||
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 {}
|
||||
+4
-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,
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
PARENTAL_LOCK_PIN_MAX_LENGTH,
|
||||
PARENTAL_LOCK_PIN_MIN_LENGTH,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { provideFormFieldDefaults } from '../form-field-defaults/form-field-defaults';
|
||||
|
||||
export type ParentalLockPinDialogMode = 'unlock' | 'set';
|
||||
|
||||
@@ -59,6 +60,7 @@ export interface ParentalLockPinDialogData {
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-parental-lock-pin-dialog',
|
||||
providers: [provideFormFieldDefaults()],
|
||||
templateUrl: './parental-lock-pin-dialog.component.html',
|
||||
styleUrl: './parental-lock-pin-dialog.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
@@ -66,7 +68,7 @@ export interface ParentalLockPinDialogData {
|
||||
FormsModule,
|
||||
MatButtonModule,
|
||||
MatDialogModule,
|
||||
AppFormFieldModule,
|
||||
MatFormFieldModule,
|
||||
MatIconModule,
|
||||
MatInputModule,
|
||||
TranslatePipe,
|
||||
|
||||
+39
@@ -885,6 +885,45 @@ describe('DashboardRecommendationsService', () => {
|
||||
expect(service.items().map((item) => item.title)).toContain('Godzilla');
|
||||
});
|
||||
|
||||
it('ignores a release date the TMDB merge supplied when excluding', async () => {
|
||||
// The portal sent no date, so the stored one is the year of
|
||||
// whatever TMDB matched. It must not gate the exact tier, or a
|
||||
// namesake match would keep the watched title on the rail.
|
||||
enrichMovie.mockResolvedValue({
|
||||
recommendations: {
|
||||
results: [
|
||||
...recTitles
|
||||
.slice(0, 5)
|
||||
.map((title, i) => rec(100 + i, title)),
|
||||
rec(200, 'Quarry Lights', 2014),
|
||||
],
|
||||
},
|
||||
});
|
||||
recentAll = [
|
||||
...recentVod,
|
||||
{
|
||||
title: 'Quarry Lights',
|
||||
type: 'movie',
|
||||
stalker_item: {
|
||||
id: '9',
|
||||
category_id: 'vod',
|
||||
info: {
|
||||
name: 'Quarry Lights',
|
||||
releasedate: '1954-11-03',
|
||||
tmdb_supplied_release_date: true,
|
||||
},
|
||||
},
|
||||
} as ActivityStub,
|
||||
];
|
||||
const service = createService();
|
||||
|
||||
await service.load();
|
||||
|
||||
expect(service.items().map((item) => item.title)).not.toContain(
|
||||
'Quarry Lights'
|
||||
);
|
||||
});
|
||||
|
||||
it('excludes a watched title whose name ends in a year', async () => {
|
||||
// "Blade Runner 2049" is a 2017 film; the 2049 belongs to the
|
||||
// NAME, so it must not be read as a release year and used to
|
||||
|
||||
@@ -52,7 +52,10 @@ export type RecommendationCandidate = Omit<
|
||||
* gating that key on a title-derived 2049 would fail to exclude the very
|
||||
* film the user just watched (TMDB calls it 2017). Only Stalker rows
|
||||
* carry a real date (`info.releasedate`); everything else yields `null`,
|
||||
* which keeps the exact tier excluding unconditionally.
|
||||
* which keeps the exact tier excluding unconditionally. So does a date the
|
||||
* TMDB merge filled in itself (`tmdb_supplied_release_date`): it is the
|
||||
* year of whatever TMDB matched, and a namesake match would otherwise stop
|
||||
* the watched title from excluding itself.
|
||||
*
|
||||
* The base tier is different by construction — its key exists only
|
||||
* because a trailing year was stripped off — so it keeps using that year.
|
||||
@@ -63,6 +66,9 @@ export function trustedReleaseYear(
|
||||
const info = (
|
||||
item.stalker_item as { info?: Record<string, unknown> } | undefined
|
||||
)?.info;
|
||||
if (info?.['tmdb_supplied_release_date'] === true) {
|
||||
return null;
|
||||
}
|
||||
const releaseDate = info?.['releasedate'];
|
||||
return typeof releaseDate === 'string' ? extractYear(releaseDate) : null;
|
||||
}
|
||||
|
||||
+3
-3
@@ -62,9 +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",
|
||||
"ui:form-field-defaults:test": "node --test tools/nx/check-form-field-defaults.test.mjs",
|
||||
"ui:form-field-defaults:check": "node tools/nx/check-form-field-defaults.mjs",
|
||||
"ui:form-field-defaults:validate": "pnpm run ui:form-field-defaults:test && pnpm run ui:form-field-defaults:check",
|
||||
"styles:material-theme-scope:test": "node --test tools/nx/check-material-theme-scope.test.mjs",
|
||||
"styles:material-theme-scope:check": "node tools/nx/check-material-theme-scope.mjs",
|
||||
"styles:material-theme-scope:validate": "pnpm run styles:material-theme-scope:test && pnpm run styles:material-theme-scope:check",
|
||||
|
||||
@@ -2,188 +2,148 @@ 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.
|
||||
* The app's `<mat-form-field>` defaults (outline, dynamic subscript) are
|
||||
* provided per component, not in the root injector: importing
|
||||
* MAT_FORM_FIELD_DEFAULT_OPTIONS in app.config.ts loads
|
||||
* `@angular/material/form-field` and, since Material 22.2, `@angular/forms`
|
||||
* on startup, although every form field lives in a lazy chunk. A component
|
||||
* that renders a form field without the provider silently falls back to the
|
||||
* `fill` appearance, so this guard requires it on each such component, and
|
||||
* allows the provider and the token nowhere else.
|
||||
*/
|
||||
export const DEFAULTS_FILE =
|
||||
'libs/ui/components/src/lib/form-field-defaults/form-field-defaults.ts';
|
||||
const TOKEN = 'MAT_FORM_FIELD_DEFAULT_OPTIONS';
|
||||
const PROVIDER = 'provideFormFieldDefaults';
|
||||
|
||||
/** Angular renderer sources whose components can render a form field. */
|
||||
const SCANNED_PATHSPECS = [
|
||||
'apps/web/*.ts',
|
||||
'apps/remote-control-web/*.ts',
|
||||
'libs/*.ts',
|
||||
];
|
||||
const SCANNED_PATHSPECS = ['apps/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$/;
|
||||
function isScanned(file) {
|
||||
return !/\.(spec|test)\.[cm]?[jt]s$/.test(file);
|
||||
}
|
||||
|
||||
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);
|
||||
/** The object literal of an `@Component({...})` decorator, if `node` is one. */
|
||||
function componentMetadata(node) {
|
||||
if (
|
||||
!ts.isDecorator(node) ||
|
||||
!ts.isCallExpression(node.expression) ||
|
||||
!ts.isIdentifier(node.expression.expression) ||
|
||||
node.expression.expression.text !== 'Component'
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
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
|
||||
const [metadata] = node.expression.arguments;
|
||||
return metadata && ts.isObjectLiteralExpression(metadata)
|
||||
? metadata
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function property(metadata, name) {
|
||||
return metadata.properties.find(
|
||||
(entry) =>
|
||||
ts.isPropertyAssignment(entry) &&
|
||||
(ts.isIdentifier(entry.name) || ts.isStringLiteral(entry.name)) &&
|
||||
entry.name.text === name
|
||||
)?.initializer;
|
||||
}
|
||||
|
||||
/** Literal text of a quoted, backticked or interpolated template. */
|
||||
function literalText(node) {
|
||||
if (ts.isStringLiteralLike(node)) return node.text;
|
||||
if (ts.isTemplateExpression(node)) {
|
||||
return [
|
||||
node.head.text,
|
||||
...node.templateSpans.map((span) => span.literal.text),
|
||||
].join('');
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isProviderCall(node) {
|
||||
return (
|
||||
ts.isCallExpression(node) &&
|
||||
ts.isIdentifier(node.expression) &&
|
||||
node.expression.text === PROVIDER
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Findings for one TypeScript source. `readTemplate` resolves a
|
||||
* `templateUrl` relative to the component and returns its text.
|
||||
*/
|
||||
export function componentDeclarations(source, file) {
|
||||
export async function checkComponentSource(file, source, readTemplate) {
|
||||
const sourceFile = ts.createSourceFile(
|
||||
file,
|
||||
source,
|
||||
ts.ScriptTarget.Latest,
|
||||
true
|
||||
);
|
||||
const lineOf = (node) =>
|
||||
sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile))
|
||||
.line + 1;
|
||||
const findings = [];
|
||||
const components = [];
|
||||
let bindsDefaultsModule = false;
|
||||
const componentProviderCalls = new Set();
|
||||
|
||||
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;
|
||||
const metadata = componentMetadata(node);
|
||||
if (metadata) {
|
||||
const providers = property(metadata, 'providers');
|
||||
const calls =
|
||||
providers && ts.isArrayLiteralExpression(providers)
|
||||
? providers.elements.filter(isProviderCall)
|
||||
: [];
|
||||
calls.forEach((call) => componentProviderCalls.add(call));
|
||||
components.push({ node, metadata, provides: calls.length > 0 });
|
||||
}
|
||||
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)
|
||||
: [],
|
||||
if (ts.isIdentifier(node) && node.text === TOKEN) {
|
||||
if (file !== DEFAULTS_FILE) {
|
||||
findings.push({
|
||||
file,
|
||||
line: lineOf(node),
|
||||
reason: `uses ${TOKEN}; provide the app defaults with ${PROVIDER}() from @iptvnator/ui/components/form-field-defaults`,
|
||||
});
|
||||
}
|
||||
}
|
||||
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;
|
||||
const visitCalls = (node) => {
|
||||
if (isProviderCall(node) && !componentProviderCalls.has(node)) {
|
||||
findings.push({
|
||||
file,
|
||||
line: lineOf(node),
|
||||
reason: `calls ${PROVIDER}() outside a component's providers; a root or route provider loads Material's form field on startup`,
|
||||
});
|
||||
}
|
||||
ts.forEachChild(node, visitCalls);
|
||||
};
|
||||
visitCalls(sourceFile);
|
||||
|
||||
for (const { node, metadata, provides } of components) {
|
||||
const inline = property(metadata, 'template');
|
||||
const url = property(metadata, 'templateUrl');
|
||||
let template = inline ? literalText(inline) : undefined;
|
||||
if (template === undefined && url && ts.isStringLiteralLike(url)) {
|
||||
template = await readTemplate(
|
||||
path.posix.join(path.posix.dirname(file), url.text)
|
||||
);
|
||||
}
|
||||
if (provides || !/<mat-form-field\b/.test(template ?? '')) continue;
|
||||
findings.push({
|
||||
file,
|
||||
line: component.line,
|
||||
className: component.className,
|
||||
line: lineOf(node),
|
||||
reason: `renders <mat-form-field> without providers: [${PROVIDER}()]`,
|
||||
});
|
||||
}
|
||||
return findings;
|
||||
return findings.sort((a, b) => a.line - b.line);
|
||||
}
|
||||
|
||||
/** Tracked renderer sources the guard reads, at any depth. */
|
||||
/** Tracked renderer TypeScript files 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.
|
||||
@@ -206,34 +166,27 @@ const isMain =
|
||||
if (isMain) {
|
||||
const rootDir = process.cwd();
|
||||
const files = listScannedFiles(rootDir);
|
||||
const readTemplate = (file) => readFile(path.join(rootDir, file), 'utf8');
|
||||
|
||||
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')
|
||||
))
|
||||
...(await checkComponentSource(file, source, readTemplate))
|
||||
);
|
||||
}
|
||||
|
||||
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:`
|
||||
'Form-field defaults must be provided per component, not in the root injector:'
|
||||
);
|
||||
for (const { file, line, className } of findings) {
|
||||
console.error(`- ${file}:${line} ${className}`);
|
||||
for (const { file, line, reason } of findings) {
|
||||
console.error(`- ${file}:${line} ${reason}`);
|
||||
}
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
console.log(
|
||||
`Checked ${files.length} renderer sources; every component rendering <mat-form-field> imports ${DEFAULTS_MODULE}.`
|
||||
`Checked ${files.length} renderer files; every <mat-form-field> component provides the app defaults.`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,218 +1,200 @@
|
||||
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 { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { test } from 'node:test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import {
|
||||
componentDeclarations,
|
||||
findFormFieldsWithoutDefaults,
|
||||
DEFAULTS_FILE,
|
||||
checkComponentSource,
|
||||
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');
|
||||
const repoRoot = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
'../..'
|
||||
);
|
||||
const templates = {
|
||||
'libs/a/field.component.html':
|
||||
'<mat-form-field><input matInput /></mat-form-field>',
|
||||
'libs/a/plain.component.html': '<button mat-button>Save</button>',
|
||||
};
|
||||
|
||||
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
|
||||
),
|
||||
[]
|
||||
const readTemplate = async (file) => templates[file];
|
||||
const check = async (file, source) =>
|
||||
(await checkComponentSource(file, source, readTemplate)).map(
|
||||
({ line, reason }) => ({ line, reason })
|
||||
);
|
||||
|
||||
test('accepts a templateUrl form field with the provider', async () => {
|
||||
const source = [
|
||||
'@Component({',
|
||||
" selector: 'app-field',",
|
||||
' providers: [provideFormFieldDefaults()],',
|
||||
" templateUrl: './field.component.html',",
|
||||
'})',
|
||||
].join('\n');
|
||||
|
||||
assert.deepEqual(await check('libs/a/field.component.ts', source), []);
|
||||
});
|
||||
|
||||
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('accepts the provider next to other providers', async () => {
|
||||
const source = [
|
||||
'@Component({',
|
||||
' providers: [DatePipe, provideFormFieldDefaults()],',
|
||||
" templateUrl: './field.component.html',",
|
||||
'})',
|
||||
].join('\n');
|
||||
|
||||
assert.deepEqual(await check('libs/a/field.component.ts', source), []);
|
||||
});
|
||||
|
||||
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('reports a templateUrl form field without the provider', async () => {
|
||||
const source = [
|
||||
'@Component({',
|
||||
" selector: 'app-field',",
|
||||
" templateUrl: './field.component.html',",
|
||||
'})',
|
||||
].join('\n');
|
||||
|
||||
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, [
|
||||
assert.deepEqual(await check('libs/a/field.component.ts', source), [
|
||||
{
|
||||
className: 'XComponent',
|
||||
line: 4,
|
||||
template: undefined,
|
||||
templateUrl: './x.html',
|
||||
imports: ['MatInputModule', 'SHARED', 'AppFormFieldModule'],
|
||||
line: 1,
|
||||
reason: 'renders <mat-form-field> without providers: [provideFormFieldDefaults()]',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
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 });
|
||||
}
|
||||
test('reports an inline template form field without the provider', async () => {
|
||||
const source = [
|
||||
'@Component({',
|
||||
' template: `',
|
||||
' <mat-form-field appearance="outline"></mat-form-field>',
|
||||
' `,',
|
||||
'})',
|
||||
].join('\n');
|
||||
|
||||
assert.equal((await check('libs/a/inline.component.ts', source)).length, 1);
|
||||
});
|
||||
|
||||
test('reads quoted inline templates', async () => {
|
||||
const source = [
|
||||
'@Component({',
|
||||
" template: '<mat-form-field></mat-form-field>',",
|
||||
'})',
|
||||
'class Single {}',
|
||||
'@Component({',
|
||||
' template: "<mat-form-field></mat-form-field>",',
|
||||
'})',
|
||||
'class Double {}',
|
||||
].join('\n');
|
||||
|
||||
assert.deepEqual(
|
||||
(await check('libs/a/quoted.component.ts', source)).map(
|
||||
({ line }) => line
|
||||
),
|
||||
[1, 5]
|
||||
);
|
||||
});
|
||||
|
||||
test('checks each component of a file on its own providers', async () => {
|
||||
const source = [
|
||||
'@Component({',
|
||||
' providers: [provideFormFieldDefaults()],',
|
||||
" templateUrl: './field.component.html',",
|
||||
'})',
|
||||
'class WithDefaults {}',
|
||||
'@Component({',
|
||||
" templateUrl: './field.component.html',",
|
||||
'})',
|
||||
'class WithoutDefaults {}',
|
||||
].join('\n');
|
||||
|
||||
assert.deepEqual(await check('libs/a/field.component.ts', source), [
|
||||
{
|
||||
line: 6,
|
||||
reason: 'renders <mat-form-field> without providers: [provideFormFieldDefaults()]',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('finds the provider after an earlier array-valued provider', async () => {
|
||||
const source = [
|
||||
'@Component({',
|
||||
' providers: [',
|
||||
' { provide: LIST, useValue: [] },',
|
||||
' provideFormFieldDefaults(),',
|
||||
' ],',
|
||||
" templateUrl: './field.component.html',",
|
||||
'})',
|
||||
].join('\n');
|
||||
|
||||
assert.deepEqual(await check('libs/a/field.component.ts', source), []);
|
||||
});
|
||||
|
||||
test('reports the provider outside a component', async () => {
|
||||
const appConfig = [
|
||||
'export const appConfig = {',
|
||||
' providers: [provideRouter(routes), provideFormFieldDefaults()],',
|
||||
'};',
|
||||
].join('\n');
|
||||
const routes = [
|
||||
'export const routes = [',
|
||||
" { path: 'settings', providers: [provideFormFieldDefaults()] },",
|
||||
'];',
|
||||
].join('\n');
|
||||
|
||||
assert.deepEqual(await check('apps/web/src/app/app.config.ts', appConfig), [
|
||||
{
|
||||
line: 2,
|
||||
reason: "calls provideFormFieldDefaults() outside a component's providers; a root or route provider loads Material's form field on startup",
|
||||
},
|
||||
]);
|
||||
assert.equal(
|
||||
(await check('apps/web/src/app/app.routes.ts', routes)).length,
|
||||
1
|
||||
);
|
||||
});
|
||||
|
||||
test('ignores templates without a form field', async () => {
|
||||
const source = [
|
||||
'@Component({',
|
||||
" templateUrl: './plain.component.html',",
|
||||
'})',
|
||||
'@Component({ template: `<mat-icon>add</mat-icon>` })',
|
||||
].join('\n');
|
||||
|
||||
assert.deepEqual(await check('libs/a/plain.component.ts', source), []);
|
||||
});
|
||||
|
||||
test('reports the token outside the shared provider', async () => {
|
||||
const source = [
|
||||
"import { MAT_FORM_FIELD_DEFAULT_OPTIONS } from '@angular/material/form-field';",
|
||||
'export const appConfig = {',
|
||||
' providers: [{ provide: MAT_FORM_FIELD_DEFAULT_OPTIONS, useValue: {} }],',
|
||||
'};',
|
||||
].join('\n');
|
||||
|
||||
assert.deepEqual(
|
||||
(await check('apps/web/src/app/app.config.ts', source)).map(
|
||||
({ line }) => line
|
||||
),
|
||||
[1, 3]
|
||||
);
|
||||
assert.deepEqual(await check(DEFAULTS_FILE, source), []);
|
||||
});
|
||||
|
||||
test('the shared provider supplies the outline, dynamic defaults', async () => {
|
||||
const source = await readFile(path.join(repoRoot, DEFAULTS_FILE), 'utf8');
|
||||
|
||||
assert.match(source, /appearance: 'outline'/);
|
||||
assert.match(source, /subscriptSizing: 'dynamic'/);
|
||||
assert.match(source, /provide: MAT_FORM_FIELD_DEFAULT_OPTIONS/);
|
||||
});
|
||||
|
||||
test('scans renderer TypeScript and skips specs', () => {
|
||||
const files = listScannedFiles(repoRoot);
|
||||
|
||||
assert.ok(files.includes(DEFAULTS_FILE));
|
||||
assert.ok(files.includes('apps/web/src/app/app.config.ts'));
|
||||
assert.ok(files.every((file) => !/\.spec\.ts$/.test(file)));
|
||||
});
|
||||
+2
-2
@@ -105,8 +105,8 @@
|
||||
"@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/form-field-defaults": [
|
||||
"libs/ui/components/src/lib/form-field-defaults/form-field-defaults.ts"
|
||||
],
|
||||
"@iptvnator/ui/components/lazy-dialog": [
|
||||
"libs/ui/components/src/lib/lazy-dialog/lazy-mat-dialog.ts"
|
||||
|
||||
Reference in new issue
Block a user