Files
iptvnator/apps/web/src/app/settings/settings-about-section.component.ts
T
ec4b9286a7 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 <mat-form-field>.

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 <noreply@anthropic.com>

* perf(web): cite #1928 as the initial-bytes baseline evidence

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: 4gray <fourgray@proton.me>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:53:08 +02:00

280 lines
10 KiB
TypeScript

import {
Component,
computed,
input,
output,
ViewEncapsulation,
ChangeDetectionStrategy,
} 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 {
AppUpdateChannel,
APP_UPDATE_CHANNELS,
ELECTRON_BRIDGE_APP_UPDATE_STATUSES,
ElectronBridgeAppUpdateStatus,
} 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;
readonly tone: 'accent' | 'success' | 'warn' | 'neutral';
}
@Component({
selector: 'app-settings-about-section',
providers: [provideFormFieldDefaults()],
imports: [
MatButtonModule,
MatFormFieldModule,
MatIconModule,
MatSelectModule,
ReactiveFormsModule,
TranslateModule,
],
templateUrl: './settings-about-section.component.html',
encapsulation: ViewEncapsulation.None,
changeDetection: ChangeDetectionStrategy.OnPush,
styleUrls: ['./settings-about-section.component.scss'],
})
export class SettingsAboutSectionComponent {
readonly isDesktop = input(false);
readonly version = input<string | undefined>();
readonly buildCommit = input<string | undefined>();
readonly updateMessage = input<string | undefined>();
readonly appUpdateStatus = input<ElectronBridgeAppUpdateStatus | null>(
null
);
/**
* The shared settings form; the channel select lives in it so a change
* goes through the same Save and unsaved-changes flow as every other
* setting. Absent in hosts that only render the version block.
*/
readonly form = input<FormGroup | null>(null);
constructor() {
// Parent patches (Discard, backup import) change the form outside
// this OnPush section's events.
markSectionForCheckOnFormEvents(this.form);
}
readonly updateChannelOptions = input<UpdateChannelOption[]>([]);
readonly buildCommitShort = computed(() => {
const commit = this.buildCommit()?.trim();
return commit ? commit.slice(0, 7) : undefined;
});
readonly checkForAppUpdate = output<void>();
/**
* Emitted instead of a plain check while the channel select shows a
* channel the updater has not been told about yet. The host saves the
* form; the main process re-checks on its own once the saved channel
* changes, so one click yields the verdict the user is looking at.
*/
readonly saveAndCheckForAppUpdate = output<void>();
readonly downloadAppUpdate = output<void>();
readonly installAppUpdate = output<void>();
readonly openManualAppUpdate = output<void>();
readonly openAppUpdateReleaseNotes = output<void>();
/** The nightly warning offers the backup page before switching. */
readonly openBackup = output<void>();
readonly canSelectUpdateChannel = computed(
() =>
this.isDesktop() &&
this.form() !== null &&
this.updateChannelOptions().length > 0
);
/** A nightly build on the stable channel waits for the next stable release. */
readonly isNightlyBuildOnStableChannel = computed(() => {
const status = this.appUpdateStatus();
return (
status?.installedChannel === 'nightly' &&
status.channel === 'stable'
);
});
/**
* The channel the select currently shows. Read off the control on every
* check rather than tracked as a signal: the section is eagerly checked
* and the sibling nightly warning already reads the control the same way.
*/
draftUpdateChannel(): AppUpdateChannel | null {
const value: unknown = this.form()?.get('updateChannel')?.value;
return APP_UPDATE_CHANNELS.includes(value as AppUpdateChannel)
? (value as AppUpdateChannel)
: null;
}
/**
* True while the select shows a channel other than the one the last
* verdict describes. The verdict on screen is then about the OTHER
* channel, and a plain "check again" would silently repeat it.
*/
hasPendingChannelChange(): boolean {
const draft = this.draftUpdateChannel();
const applied = this.appUpdateStatus()?.channel;
return draft !== null && applied !== undefined && draft !== applied;
}
/**
* Whether Save would make the updater re-check. A download in flight or
* finished belongs to the previous channel and is kept, so saving there
* changes the channel without a new verdict and the plain check stays.
*/
canSaveAndCheckForAppUpdate(): boolean {
const status = this.appUpdateStatus()?.status;
return (
this.hasPendingChannelChange() &&
status !== ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloading &&
status !== ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloaded
);
}
channelLabelKey(channel: AppUpdateChannel | null | undefined): string {
return channel === 'nightly'
? 'SETTINGS.APP_UPDATE_CHANNEL_NIGHTLY'
: 'SETTINGS.APP_UPDATE_CHANNEL_STABLE';
}
/**
* The channel the verdict on screen belongs to. `verdictChannel` is
* stamped by every check; a channel change saved while a download was
* running or done keeps that download, so the two can differ until
* the new channel is checked.
*/
verdictChannel(): AppUpdateChannel | null {
const status = this.appUpdateStatus();
return status?.verdictChannel ?? status?.channel ?? null;
}
/** The kept download was found on another channel than the saved one. */
hasRetainedOtherChannelVerdict(): boolean {
const status = this.appUpdateStatus();
return (
status?.verdictChannel !== undefined &&
status.verdictChannel !== status.channel
);
}
readonly appliedChannelLabelKey = computed(() => {
const channel = this.verdictChannel();
return channel ? this.channelLabelKey(channel) : null;
});
/** The one-word verdict beside the app name in the hero. */
readonly updateStatusChip = computed<SettingsUpdateStatusChip | null>(
() => {
switch (this.appUpdateStatus()?.status) {
case ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Available:
case ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloading:
case ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloaded:
return {
labelKey: 'SETTINGS.UPDATE_AVAILABLE_CHIP',
tone: 'accent',
};
case ELECTRON_BRIDGE_APP_UPDATE_STATUSES.NotAvailable:
return {
labelKey: 'SETTINGS.UPDATE_UP_TO_DATE',
tone: 'success',
};
case ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Checking:
return {
labelKey: 'SETTINGS.APP_UPDATE_CHECKING',
tone: 'neutral',
};
case ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Error:
return {
labelKey: 'SETTINGS.APP_UPDATE_ERROR',
tone: 'warn',
};
default:
return null;
}
}
);
readonly isAppUpdateBusy = computed(() => {
const status = this.appUpdateStatus()?.status;
return (
status === ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Checking ||
status === ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloading
);
});
readonly canDownloadAppUpdate = computed(() => {
const status = this.appUpdateStatus();
return (
status?.supportedSelfUpdate === true &&
status.status === ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Available
);
});
readonly canInstallAppUpdate = computed(() => {
const status = this.appUpdateStatus();
return (
status?.supportedSelfUpdate === true &&
status.status === ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloaded
);
});
readonly canOpenAppUpdateReleaseNotes = computed(() => {
const status = this.appUpdateStatus();
return Boolean(
status?.currentVersion &&
status.status !== ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Checking
);
});
readonly canOpenManualAppUpdate = computed(() => {
const status = this.appUpdateStatus();
return Boolean(status && !status.supportedSelfUpdate);
});
readonly appUpdateStatusLabelKey = computed(() => {
const status =
this.appUpdateStatus()?.status ??
ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Idle;
switch (status) {
case ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Checking:
return 'SETTINGS.APP_UPDATE_CHECKING';
case ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Available:
return 'SETTINGS.APP_UPDATE_AVAILABLE';
case ELECTRON_BRIDGE_APP_UPDATE_STATUSES.NotAvailable:
return 'SETTINGS.APP_UPDATE_NOT_AVAILABLE';
case ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloading:
return 'SETTINGS.APP_UPDATE_DOWNLOADING';
case ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloaded:
return 'SETTINGS.APP_UPDATE_DOWNLOADED';
case ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Unsupported:
return 'SETTINGS.APP_UPDATE_IDLE';
case ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Error:
return 'SETTINGS.APP_UPDATE_ERROR';
default:
return 'SETTINGS.APP_UPDATE_IDLE';
}
});
}