From 294d630156d6840c3ba07cef4d577276e2f66830 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Tue, 13 Oct 2026 19:51:46 +0200 Subject: [PATCH 1/3] fix(stalker): keep TMDB genre and country chips on Stalker series (#1930) The series view re-normalizes the selected item through createStalkerInfo() on every render, and that mapping carried only seven hand-picked tmdb_* fields. tmdb_genres, tmdb_countries, tmdb_media_type and tmdb_supplied_release_date were dropped right after enrichment, so the clickable Discover chips never appeared on Stalker series. The carried keys now come from a Record over StalkerVodInfo's tmdb_* keys, so a new enrichment field fails to compile until it is listed. Saved Stalker entries now also keep tmdb_supplied_release_date, and the recommendations exclusion index (trustedReleaseYear) skips a date TMDB supplied, as the flag's contract requires. Co-authored-by: Claude Opus 5.5 --- .changes/stalker-series-discover-chips.md | 6 ++ docs/architecture/stalker-portal.md | 6 ++ docs/architecture/tmdb-metadata-enrichment.md | 6 +- .../src/lib/stalker-vod.utils.spec.ts | 47 +++++++++++--- .../data-access/src/lib/stalker-vod.utils.ts | 48 ++++++++++---- .../stalker-series-view.tmdb-facets.spec.ts | 64 +++++++++++++++++++ .../src/lib/stalker-vod-details.interface.ts | 2 + .../dashboard-recommendations.service.spec.ts | 39 +++++++++++ .../src/lib/dashboard-recommendations.util.ts | 8 ++- 9 files changed, 202 insertions(+), 24 deletions(-) create mode 100644 .changes/stalker-series-discover-chips.md create mode 100644 libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.tmdb-facets.spec.ts diff --git a/.changes/stalker-series-discover-chips.md b/.changes/stalker-series-discover-chips.md new file mode 100644 index 000000000..db6945f23 --- /dev/null +++ b/.changes/stalker-series-discover-chips.md @@ -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. diff --git a/docs/architecture/stalker-portal.md b/docs/architecture/stalker-portal.md index c2aee3afc..90997a8e2 100644 --- a/docs/architecture/stalker-portal.md +++ b/docs/architecture/stalker-portal.md @@ -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 diff --git a/docs/architecture/tmdb-metadata-enrichment.md b/docs/architecture/tmdb-metadata-enrichment.md index 606eb72fa..3b2af24bd 100644 --- a/docs/architecture/tmdb-metadata-enrichment.md +++ b/docs/architecture/tmdb-metadata-enrichment.md @@ -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 diff --git a/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.spec.ts b/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.spec.ts index 23dfe09cb..b0bc7563b 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.spec.ts @@ -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', () => { diff --git a/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.ts b/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.ts index f650aa371..7ec8dc9a8 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.ts @@ -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; + +/** + * 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 = { + 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 +): Partial> { + const picked: Record = {}; + 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. * diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.tmdb-facets.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.tmdb-facets.spec.ts new file mode 100644 index 000000000..f0a343a66 --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.tmdb-facets.spec.ts @@ -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; + const { selectedItem, createFixture } = createStalkerSeriesViewHarness(() => + jest.fn() + ); + + async function stabilize(): Promise { + 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']) + ); + }); +}); diff --git a/libs/shared/interfaces/src/lib/stalker-vod-details.interface.ts b/libs/shared/interfaces/src/lib/stalker-vod-details.interface.ts index 32c7d5aef..8a87d4258 100644 --- a/libs/shared/interfaces/src/lib/stalker-vod-details.interface.ts +++ b/libs/shared/interfaces/src/lib/stalker-vod-details.interface.ts @@ -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; } diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-recommendations.service.spec.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-recommendations.service.spec.ts index 10602e866..9dd0d806a 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-recommendations.service.spec.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-recommendations.service.spec.ts @@ -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 diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-recommendations.util.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-recommendations.util.ts index a232249de..3aa628293 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-recommendations.util.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-recommendations.util.ts @@ -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 } | undefined )?.info; + if (info?.['tmdb_supplied_release_date'] === true) { + return null; + } const releaseDate = info?.['releasedate']; return typeof releaseDate === 'string' ? extractYear(releaseDate) : null; } From c51ef83b565fbe1f2802f736db8089eeda368d34 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Tue, 13 Oct 2026 21:53:04 +0200 Subject: [PATCH 2/3] ci(deps): hold Electron at 43 while armv7l Linux packages ship (#1925) Electron 44 publishes no linux-armv7l binary, so the 44.x update fails the Linux packaging jobs on the armv7l AppImage, DEB and Snap targets. Ignore Electron majors in Dependabot until 32-bit ARM support is dropped deliberately; 43.x minor and patch updates still arrive. Co-authored-by: 4gray Co-authored-by: Claude Opus 5.5 --- .github/dependabot.yml | 12 ++++++++---- docs/architecture/workspace-shell.md | 3 +++ 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 965668988..6b4853bf0 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -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 diff --git a/docs/architecture/workspace-shell.md b/docs/architecture/workspace-shell.md index 58b09ed0c..8d7f837c0 100644 --- a/docs/architecture/workspace-shell.md +++ b/docs/architecture/workspace-shell.md @@ -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 From ec4b9286a7ff62b1751970e8d12e6e9e6bc83aa6 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Tue, 13 Oct 2026 21:53:08 +0200 Subject: [PATCH 3/3] perf(web): provide form-field defaults per component, not at the root (#1928) * perf(web): provide form-field defaults per component, not at the root app.config.ts provided MAT_FORM_FIELD_DEFAULT_OPTIONS in the root injector. Importing the token kept @angular/material/form-field in main.js, and Material 22.2's form field imports @angular/forms/signals, which would add @angular/forms to startup as well (the reason the Angular 22.2 dependency update exceeds the initial-bytes ratchet). Every form field renders in a lazy chunk, so the outline + dynamic defaults now come from provideFormFieldDefaults() (@iptvnator/ui/components/form-field-defaults) on each component that renders . tools/nx/check-form-field-defaults.mjs (ui:form-field-defaults:validate, run in CI) reports a component that renders a form field without the provider, and any use of the token outside the shared provider. The Xtream import form test asserts outlined, dynamically sized fields with no root provider; without the component provider they render as fill. renderer.initialBytes: 1,596,201 -> 1,534,604 bytes (-61,597) on a local production build; the baseline is lowered to the measured value. Co-Authored-By: Claude Opus 5.5 * perf(web): cite #1928 as the initial-bytes baseline evidence Co-Authored-By: Claude Opus 5.5 * test(ui): check form-field providers per component on the TypeScript AST The guard matched templates and providers with regular expressions over the whole file: a quoted inline template went unread, one component's provider covered another in the same file, an earlier array-valued provider hid the call, and provideFormFieldDefaults() in app.config or a route passed although it loads Material's form field on startup again. It now reads each @Component decorator's own template and providers array, and reports any provideFormFieldDefaults() call outside one. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: 4gray Co-authored-by: Claude Opus 5.5 --- .../performance-lazy-form-field-defaults.md | 7 + .github/workflows/ci.yml | 6 + apps/web/src/app/app.config.ts | 5 - .../settings-about-section.component.ts | 2 + .../settings-epg-section.component.ts | 2 + .../settings-general-section.component.ts | 2 + .../settings-parental-section.component.ts | 2 + .../settings-playback-section.component.ts | 2 + ...ttings-remote-control-section.component.ts | 2 + .../settings-tmdb-section.component.ts | 2 + apps/web/src/m3-theme.scss | 5 +- docs/architecture/iptvnator-ui-guidelines.md | 7 + .../lib/auto-import/auto-import.component.ts | 2 + .../stalker-portal-import.component.ts | 2 + .../lib/text-import/text-import.component.ts | 2 + .../lib/url-upload/url-upload.component.ts | 2 + .../xtream-code-import.component.spec.ts | 20 ++ .../xtream-code-import.component.ts | 2 + .../playlist-info/playlist-info.component.ts | 3 +- libs/ui/components/src/index.ts | 1 + .../epg-mapping-dialog.component.ts | 2 + .../form-field-defaults.ts | 25 +++ .../parental-lock-pin-dialog.component.ts | 2 + package.json | 3 + tools/nx/check-form-field-defaults.mjs | 192 +++++++++++++++++ tools/nx/check-form-field-defaults.test.mjs | 200 ++++++++++++++++++ tools/performance/journey-baselines.json | 6 +- tsconfig.base.json | 3 + 28 files changed, 500 insertions(+), 11 deletions(-) create mode 100644 .changes/performance-lazy-form-field-defaults.md create mode 100644 libs/ui/components/src/lib/form-field-defaults/form-field-defaults.ts create mode 100644 tools/nx/check-form-field-defaults.mjs create mode 100644 tools/nx/check-form-field-defaults.test.mjs diff --git a/.changes/performance-lazy-form-field-defaults.md b/.changes/performance-lazy-form-field-defaults.md new file mode 100644 index 000000000..d1ae58790 --- /dev/null +++ b/.changes/performance-lazy-form-field-defaults.md @@ -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. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b5a581c80..d25580c26 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -430,6 +430,12 @@ jobs: - name: Validate icon-only button names run: pnpm run a11y:icon-buttons:validate + # 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 ui: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. diff --git a/apps/web/src/app/app.config.ts b/apps/web/src/app/app.config.ts index 6a5b072bb..27e447a27 100644 --- a/apps/web/src/app/app.config.ts +++ b/apps/web/src/app/app.config.ts @@ -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' }, - }, ], }; diff --git a/apps/web/src/app/settings/settings-about-section.component.ts b/apps/web/src/app/settings/settings-about-section.component.ts index 511206c62..d48af2add 100644 --- a/apps/web/src/app/settings/settings-about-section.component.ts +++ b/apps/web/src/app/settings/settings-about-section.component.ts @@ -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,6 +29,7 @@ interface SettingsUpdateStatusChip { @Component({ selector: 'app-settings-about-section', + providers: [provideFormFieldDefaults()], imports: [ MatButtonModule, MatFormFieldModule, diff --git a/apps/web/src/app/settings/settings-epg-section.component.ts b/apps/web/src/app/settings/settings-epg-section.component.ts index d4ec29556..431a94796 100644 --- a/apps/web/src/app/settings/settings-epg-section.component.ts +++ b/apps/web/src/app/settings/settings-epg-section.component.ts @@ -23,9 +23,11 @@ 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, diff --git a/apps/web/src/app/settings/settings-general-section.component.ts b/apps/web/src/app/settings/settings-general-section.component.ts index 7bb7b4f33..92582b33c 100644 --- a/apps/web/src/app/settings/settings-general-section.component.ts +++ b/apps/web/src/app/settings/settings-general-section.component.ts @@ -20,9 +20,11 @@ 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, MatFormFieldModule, diff --git a/apps/web/src/app/settings/settings-parental-section.component.ts b/apps/web/src/app/settings/settings-parental-section.component.ts index 7e874c010..50ced0a82 100644 --- a/apps/web/src/app/settings/settings-parental-section.component.ts +++ b/apps/web/src/app/settings/settings-parental-section.component.ts @@ -9,9 +9,11 @@ import { } from '@angular/material/slide-toggle'; import { TranslateModule } from '@ngx-translate/core'; 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, MatFormFieldModule, diff --git a/apps/web/src/app/settings/settings-playback-section.component.ts b/apps/web/src/app/settings/settings-playback-section.component.ts index 5fd306029..b806917b2 100644 --- a/apps/web/src/app/settings/settings-playback-section.component.ts +++ b/apps/web/src/app/settings/settings-playback-section.component.ts @@ -22,9 +22,11 @@ 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, diff --git a/apps/web/src/app/settings/settings-remote-control-section.component.ts b/apps/web/src/app/settings/settings-remote-control-section.component.ts index 3b69c0636..9e99ac088 100644 --- a/apps/web/src/app/settings/settings-remote-control-section.component.ts +++ b/apps/web/src/app/settings/settings-remote-control-section.component.ts @@ -15,9 +15,11 @@ import { MatTooltipModule } from '@angular/material/tooltip'; import { TranslateModule } from '@ngx-translate/core'; import { QRCodeComponent } from 'angularx-qrcode'; 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, MatFormFieldModule, diff --git a/apps/web/src/app/settings/settings-tmdb-section.component.ts b/apps/web/src/app/settings/settings-tmdb-section.component.ts index 428893053..5a751e376 100644 --- a/apps/web/src/app/settings/settings-tmdb-section.component.ts +++ b/apps/web/src/app/settings/settings-tmdb-section.component.ts @@ -17,11 +17,13 @@ import { TranslateModule } from '@ngx-translate/core'; 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, MatFormFieldModule, diff --git a/apps/web/src/m3-theme.scss b/apps/web/src/m3-theme.scss index acd62acd0..ccf773f13 100644 --- a/apps/web/src/m3-theme.scss +++ b/apps/web/src/m3-theme.scss @@ -279,8 +279,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. +// 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), )); diff --git a/docs/architecture/iptvnator-ui-guidelines.md b/docs/architecture/iptvnator-ui-guidelines.md index 5886a24ae..da7192356 100644 --- a/docs/architecture/iptvnator-ui-guidelines.md +++ b/docs/architecture/iptvnator-ui-guidelines.md @@ -1033,6 +1033,13 @@ per-theme tokens in `apps/web/src/_settings-theme.scss`. ## Forms +Every `` 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: diff --git a/libs/playlist/import/feature/src/lib/auto-import/auto-import.component.ts b/libs/playlist/import/feature/src/lib/auto-import/auto-import.component.ts index 0b86cb5af..4283d80fd 100644 --- a/libs/playlist/import/feature/src/lib/auto-import/auto-import.component.ts +++ b/libs/playlist/import/feature/src/lib/auto-import/auto-import.component.ts @@ -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,6 +146,7 @@ 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, diff --git a/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts b/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts index 1a19d0403..f4e93c8c2 100644 --- a/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts +++ b/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts @@ -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 @@ -75,6 +76,7 @@ interface StalkerSettledIdentity { TranslatePipe, ], selector: 'app-stalker-portal-import', + providers: [provideFormFieldDefaults()], templateUrl: './stalker-portal-import.component.html', changeDetection: ChangeDetectionStrategy.OnPush, styles: [ diff --git a/libs/playlist/import/feature/src/lib/text-import/text-import.component.ts b/libs/playlist/import/feature/src/lib/text-import/text-import.component.ts index f767cffb7..f5751e4f6 100644 --- a/libs/playlist/import/feature/src/lib/text-import/text-import.component.ts +++ b/libs/playlist/import/feature/src/lib/text-import/text-import.component.ts @@ -12,9 +12,11 @@ import { } from '@angular/forms'; import { MatInputModule } from '@angular/material/input'; import { TranslatePipe } from '@ngx-translate/core'; +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, diff --git a/libs/playlist/import/feature/src/lib/url-upload/url-upload.component.ts b/libs/playlist/import/feature/src/lib/url-upload/url-upload.component.ts index 59338b8bf..0df1ae15e 100644 --- a/libs/playlist/import/feature/src/lib/url-upload/url-upload.component.ts +++ b/libs/playlist/import/feature/src/lib/url-upload/url-upload.component.ts @@ -15,9 +15,11 @@ import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { TranslatePipe } from '@ngx-translate/core'; 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: [ diff --git a/libs/playlist/import/feature/src/lib/xtream-code-import/xtream-code-import.component.spec.ts b/libs/playlist/import/feature/src/lib/xtream-code-import/xtream-code-import.component.spec.ts index 72f4e8099..1c72b0334 100644 --- a/libs/playlist/import/feature/src/lib/xtream-code-import/xtream-code-import.component.spec.ts +++ b/libs/playlist/import/feature/src/lib/xtream-code-import/xtream-code-import.component.spec.ts @@ -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(); diff --git a/libs/playlist/import/feature/src/lib/xtream-code-import/xtream-code-import.component.ts b/libs/playlist/import/feature/src/lib/xtream-code-import/xtream-code-import.component.ts index ca7c148f9..ac9d137a4 100644 --- a/libs/playlist/import/feature/src/lib/xtream-code-import/xtream-code-import.component.ts +++ b/libs/playlist/import/feature/src/lib/xtream-code-import/xtream-code-import.component.ts @@ -30,6 +30,7 @@ import { normalizeXtreamServerUrl, Playlist, } from '@iptvnator/shared/interfaces'; +import { provideFormFieldDefaults } from '@iptvnator/ui/components/form-field-defaults'; function xtreamServerUrlValidator( control: AbstractControl @@ -59,6 +60,7 @@ function xtreamServerUrlValidator( TranslatePipe, ], selector: 'app-xtream-code-import', + providers: [provideFormFieldDefaults()], templateUrl: './xtream-code-import.component.html', changeDetection: ChangeDetectionStrategy.OnPush, styles: [ diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.ts b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.ts index 0f68e88ba..12e0399fe 100644 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.ts +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.ts @@ -62,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, @@ -165,7 +166,7 @@ type DesktopFileSaveBridge = Pick< } `, ], - providers: [DatePipe], + providers: [provideFormFieldDefaults(), DatePipe], changeDetection: ChangeDetectionStrategy.OnPush, imports: [ ClipboardModule, diff --git a/libs/ui/components/src/index.ts b/libs/ui/components/src/index.ts index df0b14ba3..cd83984b6 100644 --- a/libs/ui/components/src/index.ts +++ b/libs/ui/components/src/index.ts @@ -9,6 +9,7 @@ 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/portal-detail-shell/content-about.component'; export * from './lib/portal-detail-shell/detail-template.directives'; diff --git a/libs/ui/components/src/lib/channel-list-container/epg-mapping-dialog/epg-mapping-dialog.component.ts b/libs/ui/components/src/lib/channel-list-container/epg-mapping-dialog/epg-mapping-dialog.component.ts index bc0e423c8..51d88ed55 100644 --- a/libs/ui/components/src/lib/channel-list-container/epg-mapping-dialog/epg-mapping-dialog.component.ts +++ b/libs/ui/components/src/lib/channel-list-container/epg-mapping-dialog/epg-mapping-dialog.component.ts @@ -22,6 +22,7 @@ import { MatSnackBar } from '@angular/material/snack-bar'; 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, diff --git a/libs/ui/components/src/lib/form-field-defaults/form-field-defaults.ts b/libs/ui/components/src/lib/form-field-defaults/form-field-defaults.ts new file mode 100644 index 000000000..c29c8996e --- /dev/null +++ b/libs/ui/components/src/lib/form-field-defaults/form-field-defaults.ts @@ -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 + * ``. 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, + }; +} diff --git a/libs/ui/components/src/lib/parental-lock-pin-dialog/parental-lock-pin-dialog.component.ts b/libs/ui/components/src/lib/parental-lock-pin-dialog/parental-lock-pin-dialog.component.ts index 42fb878d9..486e1d8e8 100644 --- a/libs/ui/components/src/lib/parental-lock-pin-dialog/parental-lock-pin-dialog.component.ts +++ b/libs/ui/components/src/lib/parental-lock-pin-dialog/parental-lock-pin-dialog.component.ts @@ -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, diff --git a/package.json b/package.json index 023cb8546..8bd8d804f 100644 --- a/package.json +++ b/package.json @@ -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", + "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", "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", diff --git a/tools/nx/check-form-field-defaults.mjs b/tools/nx/check-form-field-defaults.mjs new file mode 100644 index 000000000..624d66be9 --- /dev/null +++ b/tools/nx/check-form-field-defaults.mjs @@ -0,0 +1,192 @@ +import { execFileSync } from 'node:child_process'; +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import ts from 'typescript'; + +/** + * The app's `` 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'; + +const SCANNED_PATHSPECS = ['apps/web/*.ts', 'libs/*.ts']; + +function isScanned(file) { + return !/\.(spec|test)\.[cm]?[jt]s$/.test(file); +} + +/** 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; + } + 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 + ); +} + +/** + * Findings for one TypeScript source. `readTemplate` resolves a + * `templateUrl` relative to the component and returns its text. + */ +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 = []; + const componentProviderCalls = new Set(); + + const visit = (node) => { + 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.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); + + 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 || !/ without providers: [${PROVIDER}()]`, + }); + } + return findings.sort((a, b) => a.line - b.line); +} + +/** 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. + 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 readTemplate = (file) => readFile(path.join(rootDir, file), 'utf8'); + + const findings = []; + for (const file of files) { + const source = await readFile(path.join(rootDir, file), 'utf8'); + findings.push( + ...(await checkComponentSource(file, source, readTemplate)) + ); + } + + if (findings.length > 0) { + console.error( + 'Form-field defaults must be provided per component, not in the root injector:' + ); + for (const { file, line, reason } of findings) { + console.error(`- ${file}:${line} ${reason}`); + } + process.exitCode = 1; + } else { + console.log( + `Checked ${files.length} renderer files; every component provides the app defaults.` + ); + } +} diff --git a/tools/nx/check-form-field-defaults.test.mjs b/tools/nx/check-form-field-defaults.test.mjs new file mode 100644 index 000000000..b3301622a --- /dev/null +++ b/tools/nx/check-form-field-defaults.test.mjs @@ -0,0 +1,200 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { test } from 'node:test'; +import { fileURLToPath } from 'node:url'; + +import { + DEFAULTS_FILE, + checkComponentSource, + listScannedFiles, +} from './check-form-field-defaults.mjs'; + +const repoRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '../..' +); +const templates = { + 'libs/a/field.component.html': + '', + 'libs/a/plain.component.html': '', +}; +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('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('reports a templateUrl form field without the provider', async () => { + const source = [ + '@Component({', + " selector: 'app-field',", + " templateUrl: './field.component.html',", + '})', + ].join('\n'); + + assert.deepEqual(await check('libs/a/field.component.ts', source), [ + { + line: 1, + reason: 'renders without providers: [provideFormFieldDefaults()]', + }, + ]); +}); + +test('reports an inline template form field without the provider', async () => { + const source = [ + '@Component({', + ' template: `', + ' ', + ' `,', + '})', + ].join('\n'); + + assert.equal((await check('libs/a/inline.component.ts', source)).length, 1); +}); + +test('reads quoted inline templates', async () => { + const source = [ + '@Component({', + " template: '',", + '})', + 'class Single {}', + '@Component({', + ' template: "",', + '})', + '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 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: `add` })', + ].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))); +}); diff --git a/tools/performance/journey-baselines.json b/tools/performance/journey-baselines.json index ee8841485..d3814bfb3 100644 --- a/tools/performance/journey-baselines.json +++ b/tools/performance/journey-baselines.json @@ -3,11 +3,11 @@ "journeys": { "launch": { "renderer.initialBytes": { - "value": 1595777, + "value": 1534604, "unit": "bytes", "slack": 4096, - "updatedAt": "2026-10-07", - "evidencePr": 1810, + "updatedAt": "2026-10-13", + "evidencePr": 1928, "measuredWith": "pnpm nx build web && pnpm run perf:initial-bytes" }, "renderer.ipcCallsToFirstCard": { diff --git a/tsconfig.base.json b/tsconfig.base.json index 01a5e5b29..de5cbeead 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -105,6 +105,9 @@ "@iptvnator/ui/components/confirm-dialog": [ "libs/ui/components/src/lib/confirm-dialog/dialog.service.ts" ], + "@iptvnator/ui/components/form-field-defaults": [ + "libs/ui/components/src/lib/form-field-defaults/form-field-defaults.ts" + ], "@iptvnator/ui/components/parental-lock-pin-dialog": [ "libs/ui/components/src/lib/parental-lock-pin-dialog/parental-lock-pin-dialog.component.ts" ],