mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-15 13:46:16 -08:00
fix(import): consistent add-source forms with masked passwords and URL errors
- Mask the Xtream password in add and edit (and the Stalker one in edit) behind a shared PasswordVisibilityToggleDirective: one translated "Show password" label, state in aria-pressed, type="button". - Give the Xtream server URL its own mat-error and a neutral hint instead of the EPG file error; give the M3U URL a mat-error. - Use "Playlist title" in every add form, "MAC address" casing, a single ellipsis in "Validating portal…" and one "Add playlist" submit label; translate the method radiogroup's aria-label. - Show Stalker refusals inline under the portal URL (role="status", like the Xtream connection test), translated in the template and cleared by edits; translate the snackbars for outcomes that close the dialog. - Translate new strings into all locales; reuse the identical Stalker URL error translations; fix MAC casing and ellipses; drop unused keys. - Unit specs per form, edit-dialog spec, new add-source-forms web E2E; update E2E locators; UI guidelines Forms section; Stalker contract. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
23a1860119
commit
0ea9ecdb27
56 files changed
+1208
-252
No files matched your search
+7
-7
@@ -12,7 +12,9 @@
|
||||
<div
|
||||
class="apd-method-grid"
|
||||
role="radiogroup"
|
||||
aria-label="Source method"
|
||||
[attr.aria-label]="
|
||||
'HOME.ADD_PLAYLIST.METHOD_GROUP_LABEL' | translate
|
||||
"
|
||||
>
|
||||
@for (opt of methodOptions; track opt.value) {
|
||||
<button
|
||||
@@ -128,7 +130,7 @@
|
||||
)
|
||||
"
|
||||
>
|
||||
{{ 'HOME.TEXT_IMPORT.BUTTON_LABEL' | translate }}
|
||||
{{ 'HOME.URL_UPLOAD.ADD_PLAYLIST' | translate }}
|
||||
</button>
|
||||
}
|
||||
@case ('xtream') {
|
||||
@@ -159,7 +161,7 @@
|
||||
"
|
||||
(click)="xtreamImport()?.addPlaylist()"
|
||||
>
|
||||
{{ 'HOME.XTREAM_PLAYLIST.ADD' | translate }}
|
||||
{{ 'HOME.URL_UPLOAD.ADD_PLAYLIST' | translate }}
|
||||
</button>
|
||||
}
|
||||
@case ('stalker') {
|
||||
@@ -170,9 +172,7 @@
|
||||
type="button"
|
||||
disabled
|
||||
>
|
||||
{{
|
||||
'HOME.STALKER_PORTAL.VALIDATING' | translate
|
||||
}}...
|
||||
{{ 'HOME.STALKER_PORTAL.VALIDATING' | translate }}
|
||||
</button>
|
||||
} @else {
|
||||
<button
|
||||
@@ -182,7 +182,7 @@
|
||||
[disabled]="!stalkerImport()?.form?.valid"
|
||||
(click)="stalkerImport()?.addPlaylist()"
|
||||
>
|
||||
{{ 'HOME.STALKER_PORTAL.ADD' | translate }}
|
||||
{{ 'HOME.URL_UPLOAD.ADD_PLAYLIST' | translate }}
|
||||
</button>
|
||||
}
|
||||
}
|
||||
|
||||
+68
-2
@@ -2,9 +2,10 @@ import { TestBed } from '@angular/core/testing';
|
||||
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { TranslateModule, TranslateService } from '@ngx-translate/core';
|
||||
import { PlaylistActions } from '@iptvnator/m3u-state';
|
||||
import { DataService } from '@iptvnator/services';
|
||||
import { StalkerPortalDiscoveryService } from '@iptvnator/portal/stalker/data-access';
|
||||
import { DataService, RuntimeCapabilitiesService } from '@iptvnator/services';
|
||||
import {
|
||||
PLAYLIST_PARSE_BY_URL,
|
||||
ProviderImportCandidate,
|
||||
@@ -418,3 +419,68 @@ describe('AddPlaylistDialogComponent', () => {
|
||||
component.method.set(type);
|
||||
}
|
||||
});
|
||||
|
||||
describe('AddPlaylistDialogComponent actions', () => {
|
||||
function render(type: PlaylistType) {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [AddPlaylistDialogComponent, TranslateModule.forRoot()],
|
||||
providers: [
|
||||
{ provide: DataService, useValue: { sendIpcEvent: jest.fn() } },
|
||||
{ provide: MatDialogRef, useValue: { close: jest.fn() } },
|
||||
{ provide: Store, useValue: { dispatch: jest.fn() } },
|
||||
{ provide: MatSnackBar, useValue: { open: jest.fn() } },
|
||||
{ provide: MAT_DIALOG_DATA, useValue: { type } },
|
||||
{
|
||||
provide: RuntimeCapabilitiesService,
|
||||
useValue: { isElectron: true },
|
||||
},
|
||||
{
|
||||
provide: StalkerPortalDiscoveryService,
|
||||
useValue: { discover: jest.fn() },
|
||||
},
|
||||
],
|
||||
});
|
||||
const fixture = TestBed.createComponent(AddPlaylistDialogComponent);
|
||||
fixture.detectChanges();
|
||||
return fixture;
|
||||
}
|
||||
|
||||
function primaryLabel(root: HTMLElement): string | undefined {
|
||||
const buttons = root.querySelectorAll<HTMLButtonElement>(
|
||||
'.apd-actions__decision button'
|
||||
);
|
||||
return buttons[buttons.length - 1]?.textContent?.trim();
|
||||
}
|
||||
|
||||
it.each<PlaylistType>(['url', 'text', 'xtream', 'stalker'])(
|
||||
'submits the %s form with the shared "Add playlist" label',
|
||||
(type) => {
|
||||
const fixture = render(type);
|
||||
|
||||
expect(primaryLabel(fixture.nativeElement)).toBe(
|
||||
'HOME.URL_UPLOAD.ADD_PLAYLIST'
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it('shows a single ellipsis while a Stalker portal validates', () => {
|
||||
const fixture = render('stalker');
|
||||
fixture.componentInstance.stalkerImport()?.isLoading.set(true);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(primaryLabel(fixture.nativeElement)).toBe(
|
||||
'HOME.STALKER_PORTAL.VALIDATING'
|
||||
);
|
||||
});
|
||||
|
||||
it('names the method picker with a translated label', () => {
|
||||
const fixture = render('url');
|
||||
const group = (fixture.nativeElement as HTMLElement).querySelector(
|
||||
'[role="radiogroup"]'
|
||||
);
|
||||
|
||||
expect(group?.getAttribute('aria-label')).toBe(
|
||||
'HOME.ADD_PLAYLIST.METHOD_GROUP_LABEL'
|
||||
);
|
||||
});
|
||||
});
|
||||
+33
-1
@@ -1,4 +1,7 @@
|
||||
import type { StalkerPortalErrorKind } from '@iptvnator/portal/stalker/data-access';
|
||||
import {
|
||||
asStalkerPortalError,
|
||||
type StalkerPortalErrorKind,
|
||||
} from '@iptvnator/portal/stalker/data-access';
|
||||
import type { StalkerPortalIdentity } from '@iptvnator/shared/interfaces';
|
||||
|
||||
/** The `stalker*` playlist columns the import form writes. */
|
||||
@@ -49,3 +52,32 @@ export const STALKER_IMPORT_ERROR_KEY_BY_KIND: Readonly<
|
||||
blocked: 'HOME.STALKER_PORTAL.PORTAL_REFUSED',
|
||||
'auth-failed': 'HOME.STALKER_PORTAL.AUTH_FAILED',
|
||||
};
|
||||
|
||||
/**
|
||||
* What the import form shows inline when a portal refuses it: a translation
|
||||
* key, plus the portal's own explanation when it gave one. The template
|
||||
* translates both, so the message follows a language switch.
|
||||
*/
|
||||
export interface StalkerImportFeedback {
|
||||
key: string;
|
||||
portalText?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns an authentication failure into feedback the user can act on. The
|
||||
* portal explains refusals itself (`msg`/`block_msg`, or one of the
|
||||
* documented plain-text bodies); its own words are relayed verbatim.
|
||||
*/
|
||||
export function toStalkerImportFeedback(error: unknown): StalkerImportFeedback {
|
||||
const portalError = asStalkerPortalError(error);
|
||||
if (!portalError) {
|
||||
return { key: 'HOME.STALKER_PORTAL.AUTH_FAILED' };
|
||||
}
|
||||
|
||||
return {
|
||||
key: STALKER_IMPORT_ERROR_KEY_BY_KIND[portalError.kind],
|
||||
...(portalError.portalText
|
||||
? { portalText: portalError.portalText }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
+22
-2
@@ -1,7 +1,7 @@
|
||||
<form [formGroup]="form">
|
||||
<mat-form-field class="w-full">
|
||||
<mat-label for="title">{{
|
||||
'HOME.STALKER_PORTAL.TITLE' | translate
|
||||
'HOME.XTREAM_PLAYLIST.TITLE' | translate
|
||||
}}</mat-label>
|
||||
<input matInput type="text" id="title" formControlName="title" />
|
||||
</mat-form-field>
|
||||
@@ -20,6 +20,17 @@
|
||||
'HOME.STALKER_PORTAL.URL_VALIDATION_ERROR' | translate
|
||||
}}</mat-error>
|
||||
</mat-form-field>
|
||||
@if (feedback(); as message) {
|
||||
<p #feedbackMessage role="status" aria-live="polite">
|
||||
{{ message.key | translate }}
|
||||
@if (message.portalText) {
|
||||
{{
|
||||
'HOME.STALKER_PORTAL.PORTAL_MESSAGE'
|
||||
| translate: { message: message.portalText }
|
||||
}}
|
||||
}
|
||||
</p>
|
||||
}
|
||||
<mat-form-field class="w-full" subscriptSizing="dynamic">
|
||||
<mat-label for="macAddress">{{
|
||||
'HOME.STALKER_PORTAL.MAC_ADDRESS' | translate
|
||||
@@ -142,10 +153,19 @@
|
||||
}}</mat-label>
|
||||
<input
|
||||
matInput
|
||||
type="password"
|
||||
[type]="passwordToggle.inputType()"
|
||||
id="password"
|
||||
formControlName="password"
|
||||
/>
|
||||
<button
|
||||
mat-icon-button
|
||||
matSuffix
|
||||
appPasswordVisibilityToggle
|
||||
#passwordToggle="appPasswordVisibilityToggle"
|
||||
[attr.aria-label]="'HOME.SHOW_PASSWORD' | translate"
|
||||
>
|
||||
<mat-icon>{{ passwordToggle.icon() }}</mat-icon>
|
||||
</button>
|
||||
<mat-hint>{{
|
||||
'HOME.STALKER_PORTAL.CREDENTIALS_HINT' | translate
|
||||
}}</mat-hint>
|
||||
|
||||
+202
-14
@@ -2,7 +2,7 @@ import { webcrypto } from 'node:crypto';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { TranslateModule, TranslateService } from '@ngx-translate/core';
|
||||
import {
|
||||
stalkerSessionFingerprint,
|
||||
StalkerPortalDiscoveryService,
|
||||
@@ -305,11 +305,12 @@ describe('StalkerPortalImportComponent identity handling', () => {
|
||||
|
||||
await component.addPlaylist();
|
||||
|
||||
expect(snackBar.open).toHaveBeenCalledWith(
|
||||
'HOME.STALKER_PORTAL.LOGIN_REQUIRED',
|
||||
undefined,
|
||||
expect.any(Object)
|
||||
);
|
||||
// Inline, like the Xtream connection test: the dialog stays open, so
|
||||
// a snackbar would cover the footer the user acts from next.
|
||||
expect(component.feedback()).toEqual({
|
||||
key: 'HOME.STALKER_PORTAL.LOGIN_REQUIRED',
|
||||
});
|
||||
expect(snackBar.open).not.toHaveBeenCalled();
|
||||
expect(store.dispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -374,13 +375,103 @@ describe('StalkerPortalImportComponent identity handling', () => {
|
||||
|
||||
await component.addPlaylist();
|
||||
|
||||
// The translate mock returns keys, so both the kind headline and the
|
||||
// portal-message wrapper must be present.
|
||||
expect(component.feedback()).toEqual({
|
||||
key: 'HOME.STALKER_PORTAL.PORTAL_REFUSED',
|
||||
portalText: 'device conflict - device_id mismatch',
|
||||
});
|
||||
expect(snackBar.open).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows the generic refusal inline when a canonical portal is unreachable', async () => {
|
||||
portalDiscovery.discover.mockResolvedValue({ status: 'unreachable' });
|
||||
component.form.patchValue({
|
||||
title: 'Offline Portal',
|
||||
macAddress: '00:1A:79:AA:BB:CC',
|
||||
portalUrl:
|
||||
'https://portal.example.com/stalker_portal/server/load.php',
|
||||
});
|
||||
|
||||
await component.addPlaylist();
|
||||
|
||||
expect(component.feedback()).toEqual({
|
||||
key: 'HOME.STALKER_PORTAL.AUTH_FAILED',
|
||||
});
|
||||
expect(snackBar.open).not.toHaveBeenCalled();
|
||||
expect(store.dispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('announces an unvalidated import with a translated snackbar', async () => {
|
||||
portalDiscovery.discover.mockResolvedValue({ status: 'unreachable' });
|
||||
component.form.patchValue({
|
||||
title: 'Offline Panel',
|
||||
macAddress: '00:1A:79:AA:BB:CC',
|
||||
portalUrl: 'https://panel.example.com/c',
|
||||
});
|
||||
|
||||
await component.addPlaylist();
|
||||
|
||||
// The dialog closes on this path, so an inline message would vanish.
|
||||
expect(snackBar.open).toHaveBeenCalledWith(
|
||||
'HOME.STALKER_PORTAL.PORTAL_REFUSED HOME.STALKER_PORTAL.PORTAL_MESSAGE',
|
||||
'HOME.STALKER_PORTAL.ADDED_WITHOUT_VALIDATION',
|
||||
undefined,
|
||||
expect.any(Object)
|
||||
);
|
||||
expect(component.feedback()).toBeNull();
|
||||
expect(store.dispatch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('translates the expiry announced after a validated import', async () => {
|
||||
const translate = TestBed.inject(TranslateService);
|
||||
portalDiscovery.discover.mockResolvedValue({
|
||||
status: 'resolved',
|
||||
portalUrl:
|
||||
'https://portal.example.com/stalker_portal/server/load.php',
|
||||
isFullStalkerPortal: true,
|
||||
token: 'token-1',
|
||||
accountInfo: { login: 'demo', expire_date: 1893456000 },
|
||||
});
|
||||
component.form.patchValue({
|
||||
title: 'Expiring Portal',
|
||||
macAddress: '00:1A:79:AA:BB:CC',
|
||||
portalUrl: 'https://portal.example.com/stalker_portal/c',
|
||||
});
|
||||
|
||||
await component.addPlaylist();
|
||||
|
||||
expect(translate.instant).toHaveBeenCalledWith(
|
||||
'HOME.STALKER_PORTAL.VALIDATED_EXPIRES',
|
||||
{ date: new Date(1893456000 * 1000).toLocaleDateString() }
|
||||
);
|
||||
expect(snackBar.open).toHaveBeenCalledWith(
|
||||
'HOME.STALKER_PORTAL.VALIDATED_EXPIRES',
|
||||
undefined,
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it('drops a refusal once the user edits the form or retries', async () => {
|
||||
portalDiscovery.discover.mockResolvedValue({
|
||||
status: 'auth-rejected',
|
||||
portalUrl:
|
||||
'https://portal.example.com/stalker_portal/server/load.php',
|
||||
error: new StalkerPortalError('login-required'),
|
||||
});
|
||||
component.form.patchValue({
|
||||
title: 'Login Portal',
|
||||
macAddress: '00:1A:79:00:00:08',
|
||||
portalUrl: 'https://portal.example.com/stalker_portal/c',
|
||||
});
|
||||
|
||||
await component.addPlaylist();
|
||||
expect(component.feedback()).not.toBeNull();
|
||||
|
||||
component.form.controls.username.setValue('demo');
|
||||
expect(component.feedback()).toBeNull();
|
||||
|
||||
await component.addPlaylist();
|
||||
expect(component.feedback()).not.toBeNull();
|
||||
component.clearForm();
|
||||
expect(component.feedback()).toBeNull();
|
||||
});
|
||||
|
||||
it('classifies the offline fallback on the normalized URL, not the raw query', async () => {
|
||||
@@ -934,11 +1025,10 @@ describe('StalkerPortalImportComponent identity handling', () => {
|
||||
|
||||
await component.addPlaylist();
|
||||
|
||||
expect(snackBar.open).toHaveBeenCalledWith(
|
||||
'HOME.STALKER_PORTAL.DEVICE_CONFLICT HOME.STALKER_PORTAL.PORTAL_MESSAGE',
|
||||
undefined,
|
||||
expect.any(Object)
|
||||
);
|
||||
expect(component.feedback()).toEqual({
|
||||
key: 'HOME.STALKER_PORTAL.DEVICE_CONFLICT',
|
||||
portalText: 'device conflict - device_id mismatch',
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes a query-carrying /c URL in the unreachable-host fallback', async () => {
|
||||
@@ -986,3 +1076,101 @@ describe('StalkerPortalImportComponent identity handling', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('StalkerPortalImportComponent form', () => {
|
||||
let portalDiscovery: { discover: jest.Mock };
|
||||
|
||||
afterEach(() => {
|
||||
// jsdom has no scrollIntoView; one test installs a spy for it.
|
||||
delete (HTMLElement.prototype as { scrollIntoView?: unknown })
|
||||
.scrollIntoView;
|
||||
});
|
||||
|
||||
function render() {
|
||||
portalDiscovery = { discover: jest.fn() };
|
||||
TestBed.configureTestingModule({
|
||||
imports: [StalkerPortalImportComponent, TranslateModule.forRoot()],
|
||||
providers: [
|
||||
{
|
||||
provide: StalkerPortalDiscoveryService,
|
||||
useValue: portalDiscovery,
|
||||
},
|
||||
{ provide: Store, useValue: { dispatch: jest.fn() } },
|
||||
{ provide: MatSnackBar, useValue: { open: jest.fn() } },
|
||||
],
|
||||
});
|
||||
const translate = TestBed.inject(TranslateService);
|
||||
translate.setTranslation('en', {
|
||||
HOME: {
|
||||
STALKER_PORTAL: {
|
||||
PORTAL_REFUSED: 'Refused.',
|
||||
PORTAL_MESSAGE: 'The portal reported: {{message}}',
|
||||
},
|
||||
},
|
||||
});
|
||||
translate.use('en');
|
||||
const fixture = TestBed.createComponent(StalkerPortalImportComponent);
|
||||
fixture.detectChanges();
|
||||
const root = fixture.nativeElement as HTMLElement;
|
||||
return { fixture, root, component: fixture.componentInstance };
|
||||
}
|
||||
|
||||
it('labels the name field like the other add-source forms', () => {
|
||||
const { root } = render();
|
||||
|
||||
expect(
|
||||
root.querySelector('#title')?.closest('mat-form-field')?.textContent
|
||||
).toContain('HOME.XTREAM_PLAYLIST.TITLE');
|
||||
});
|
||||
|
||||
it('masks the password until the visibility toggle is pressed', () => {
|
||||
const { fixture, root } = render();
|
||||
const password = root.querySelector('#password') as HTMLInputElement;
|
||||
const toggle = password
|
||||
.closest('mat-form-field')
|
||||
?.querySelector('button') as HTMLButtonElement;
|
||||
|
||||
expect(password.type).toBe('password');
|
||||
expect(toggle.getAttribute('aria-label')).toBe('HOME.SHOW_PASSWORD');
|
||||
expect(toggle.getAttribute('aria-pressed')).toBe('false');
|
||||
|
||||
toggle.click();
|
||||
fixture.detectChanges();
|
||||
expect(password.type).toBe('text');
|
||||
expect(toggle.getAttribute('aria-pressed')).toBe('true');
|
||||
});
|
||||
|
||||
it('shows a refusal inline under the portal URL and scrolls it into view', async () => {
|
||||
const scrollIntoView = jest.fn();
|
||||
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {
|
||||
configurable: true,
|
||||
value: scrollIntoView,
|
||||
});
|
||||
const { fixture, root, component } = render();
|
||||
portalDiscovery.discover.mockResolvedValue({
|
||||
status: 'auth-rejected',
|
||||
portalUrl:
|
||||
'https://portal.example.com/stalker_portal/server/load.php',
|
||||
error: new StalkerPortalError('blocked', 'account disabled'),
|
||||
});
|
||||
component.form.patchValue({
|
||||
title: 'Blocked Portal',
|
||||
macAddress: '00:1A:79:AA:BB:CC',
|
||||
portalUrl: 'https://portal.example.com/stalker_portal/c',
|
||||
});
|
||||
|
||||
await component.addPlaylist();
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
const status = root.querySelector('[role="status"]') as HTMLElement;
|
||||
expect(status.textContent?.replace(/\s+/g, ' ').trim()).toBe(
|
||||
'Refused. The portal reported: account disabled'
|
||||
);
|
||||
expect(status.getAttribute('aria-live')).toBe('polite');
|
||||
expect(
|
||||
status.previousElementSibling?.querySelector('#portalUrl')
|
||||
).not.toBeNull();
|
||||
expect(scrollIntoView).toHaveBeenCalledWith({ block: 'nearest' });
|
||||
});
|
||||
});
|
||||
+51
-38
@@ -1,10 +1,14 @@
|
||||
import {
|
||||
afterRenderEffect,
|
||||
Component,
|
||||
ElementRef,
|
||||
inject,
|
||||
output,
|
||||
signal,
|
||||
viewChild,
|
||||
ChangeDetectionStrategy,
|
||||
} from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import {
|
||||
FormControl,
|
||||
FormGroup,
|
||||
@@ -12,15 +16,16 @@ import {
|
||||
ReactiveFormsModule,
|
||||
Validators,
|
||||
} 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 { PlaylistActions } from '@iptvnator/m3u-state';
|
||||
import {
|
||||
asStalkerPortalError,
|
||||
legacyTransformStalkerPortalUrl,
|
||||
normalizeStalkerPortalInputUrl,
|
||||
STALKER_WATCHDOG_DEFAULT_PERIOD_SECONDS,
|
||||
@@ -38,8 +43,10 @@ import {
|
||||
type StalkerDerivedDeviceIds,
|
||||
validateStalkerMacAddressControl,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { PasswordVisibilityToggleDirective } from '@iptvnator/ui/components/password-visibility-toggle';
|
||||
import {
|
||||
STALKER_IMPORT_ERROR_KEY_BY_KIND,
|
||||
type StalkerImportFeedback,
|
||||
toStalkerImportFeedback,
|
||||
toStalkerPlaylistIdentityFields,
|
||||
} from './stalker-import-identity';
|
||||
|
||||
@@ -59,7 +66,10 @@ interface StalkerSettledIdentity {
|
||||
FormsModule,
|
||||
MatCheckboxModule,
|
||||
MatFormFieldModule,
|
||||
MatIcon,
|
||||
MatIconButton,
|
||||
MatInputModule,
|
||||
PasswordVisibilityToggleDirective,
|
||||
ReactiveFormsModule,
|
||||
TranslatePipe,
|
||||
],
|
||||
@@ -130,12 +140,40 @@ export class StalkerPortalImportComponent {
|
||||
|
||||
readonly isLoading = signal(false);
|
||||
|
||||
/**
|
||||
* Why the last import was refused, shown under the portal URL the same
|
||||
* way the Xtream form shows its connection test. Outcomes that close the
|
||||
* dialog (added, added without validation) use a snackbar instead,
|
||||
* because an inline message would vanish with the form.
|
||||
*/
|
||||
readonly feedback = signal<StalkerImportFeedback | null>(null);
|
||||
private readonly feedbackMessage =
|
||||
viewChild<ElementRef<HTMLElement>>('feedbackMessage');
|
||||
|
||||
/** Whether the device IDs are being generated from the MAC. */
|
||||
readonly derivesDeviceIds = signal(false);
|
||||
|
||||
/** Stamps each derivation so a late one cannot overwrite a newer one. */
|
||||
private deriveGeneration = 0;
|
||||
|
||||
constructor() {
|
||||
// Like the Xtream connection test: a message describes the values it
|
||||
// was produced for, so any edit makes it stale.
|
||||
this.form.valueChanges
|
||||
.pipe(takeUntilDestroyed())
|
||||
.subscribe(() => this.feedback.set(null));
|
||||
|
||||
// The form is long and Add sits in the dialog footer, so the user is
|
||||
// usually scrolled to the credentials when a refusal comes back.
|
||||
afterRenderEffect(() => {
|
||||
if (this.feedback()) {
|
||||
this.feedbackMessage()?.nativeElement.scrollIntoView?.({
|
||||
block: 'nearest',
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A MAC outside Infomir's range is imported anyway — plenty of resellers
|
||||
* disable the check — but the stock portal answers a bare `{status: 1}`
|
||||
@@ -308,6 +346,7 @@ export class StalkerPortalImportComponent {
|
||||
// resolve into the freshly cleared form.
|
||||
this.invalidatePendingDerivation();
|
||||
this.derivesDeviceIds.set(false);
|
||||
this.feedback.set(null);
|
||||
this.form.controls.deviceId1.enable();
|
||||
this.form.controls.deviceId2.enable();
|
||||
this.form.reset({
|
||||
@@ -333,6 +372,7 @@ export class StalkerPortalImportComponent {
|
||||
}
|
||||
|
||||
this.isLoading.set(true);
|
||||
this.feedback.set(null);
|
||||
// The identity is frozen for the duration: an edit made now cannot
|
||||
// reach the portal (discovery has the snapshot) and cannot be undone
|
||||
// on it either (`get_profile` pins what it was sent), so the fields
|
||||
@@ -422,7 +462,10 @@ export class StalkerPortalImportComponent {
|
||||
stalkerAccountInfo.expireDate * 1000
|
||||
);
|
||||
this.snackBar.open(
|
||||
`Portal validated. Expires: ${expireDate.toLocaleDateString()}`,
|
||||
this.translate.instant(
|
||||
'HOME.STALKER_PORTAL.VALIDATED_EXPIRES',
|
||||
{ date: expireDate.toLocaleDateString() }
|
||||
),
|
||||
undefined,
|
||||
{ duration: 3000 }
|
||||
);
|
||||
@@ -435,11 +478,7 @@ export class StalkerPortalImportComponent {
|
||||
// The portal explains its own refusals — a demanded login, a
|
||||
// rejected one, a device conflict — so relay those words
|
||||
// instead of the generic "check URL and MAC".
|
||||
this.snackBar.open(
|
||||
this.buildAuthErrorMessage(discovery.error),
|
||||
undefined,
|
||||
{ duration: 8000 }
|
||||
);
|
||||
this.feedback.set(toStalkerImportFeedback(discovery.error));
|
||||
if (discovery.abandonedInFlight) {
|
||||
// The bounded error may arrive while get_profile is still
|
||||
// on the wire. Keep Add and every identity field locked
|
||||
@@ -457,11 +496,7 @@ export class StalkerPortalImportComponent {
|
||||
// Unreachable host on a canonical-portal URL shape: the old
|
||||
// flow aborted here too (its mandatory handshake could not
|
||||
// succeed either).
|
||||
this.snackBar.open(
|
||||
'Failed to authenticate with portal. Please check URL and MAC address.',
|
||||
undefined,
|
||||
{ duration: 5000 }
|
||||
);
|
||||
this.feedback.set({ key: 'HOME.STALKER_PORTAL.AUTH_FAILED' });
|
||||
return;
|
||||
} else {
|
||||
// Unreachable host on a panel-style URL: import with the
|
||||
@@ -475,7 +510,9 @@ export class StalkerPortalImportComponent {
|
||||
);
|
||||
isFullStalkerPortal = false;
|
||||
this.snackBar.open(
|
||||
'Portal did not respond; added without validation.',
|
||||
this.translate.instant(
|
||||
'HOME.STALKER_PORTAL.ADDED_WITHOUT_VALIDATION'
|
||||
),
|
||||
undefined,
|
||||
{ duration: 5000 }
|
||||
);
|
||||
@@ -537,28 +574,4 @@ export class StalkerPortalImportComponent {
|
||||
this.isLoading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns an authentication failure into a message the user can act on.
|
||||
* The portal explains refusals itself (`msg`/`block_msg`, or one of the
|
||||
* documented plain-text bodies); its own words are appended verbatim.
|
||||
*/
|
||||
private buildAuthErrorMessage(error: unknown): string {
|
||||
const portalError = asStalkerPortalError(error);
|
||||
const base = this.translate.instant(
|
||||
portalError
|
||||
? STALKER_IMPORT_ERROR_KEY_BY_KIND[portalError.kind]
|
||||
: 'HOME.STALKER_PORTAL.AUTH_FAILED'
|
||||
);
|
||||
|
||||
if (portalError?.portalText) {
|
||||
const detail = this.translate.instant(
|
||||
'HOME.STALKER_PORTAL.PORTAL_MESSAGE',
|
||||
{ message: portalError.portalText }
|
||||
);
|
||||
return `${base} ${detail}`;
|
||||
}
|
||||
|
||||
return base;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,9 @@
|
||||
<mat-form-field class="w-full">
|
||||
<mat-label>{{ 'HOME.URL_UPLOAD.PLAYLIST_URL' | translate }}</mat-label>
|
||||
<input type="url" matInput formControlName="playlistUrl" />
|
||||
<mat-error>{{
|
||||
'HOME.URL_UPLOAD.URL_VALIDATION_ERROR' | translate
|
||||
}}</mat-error>
|
||||
</mat-form-field>
|
||||
<mat-form-field class="w-full">
|
||||
<mat-label>{{ 'HOME.XTREAM_PLAYLIST.TITLE' | translate }}</mat-label>
|
||||
|
||||
@@ -109,3 +109,62 @@ describe('UrlUploadComponent', () => {
|
||||
expect(component.form.pristine).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('UrlUploadComponent form', () => {
|
||||
let component: UrlUploadComponent;
|
||||
let fixture: ComponentFixture<UrlUploadComponent>;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [
|
||||
UrlUploadComponent,
|
||||
TranslateModule.forRoot(),
|
||||
NoopAnimationsModule,
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
provide: RuntimeCapabilitiesService,
|
||||
useValue: { isElectron: true },
|
||||
},
|
||||
],
|
||||
});
|
||||
fixture = TestBed.createComponent(UrlUploadComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
function fieldText(controlName: string): string {
|
||||
return (
|
||||
(fixture.nativeElement as HTMLElement)
|
||||
.querySelector(`[formcontrolname="${controlName}"]`)
|
||||
?.closest('mat-form-field')
|
||||
?.textContent?.replace(/\s+/g, ' ')
|
||||
.trim() ?? ''
|
||||
);
|
||||
}
|
||||
|
||||
it('labels the name field like the other add-source forms', () => {
|
||||
expect(fieldText('playlistName')).toContain(
|
||||
'HOME.XTREAM_PLAYLIST.TITLE'
|
||||
);
|
||||
});
|
||||
|
||||
it('explains an invalid playlist URL once the field was touched', () => {
|
||||
const root = fixture.nativeElement as HTMLElement;
|
||||
const url = component.form.controls.playlistUrl;
|
||||
|
||||
url.setValue('playlist.m3u');
|
||||
fixture.detectChanges();
|
||||
expect(root.querySelector('mat-error')).toBeNull();
|
||||
|
||||
url.markAsTouched();
|
||||
fixture.detectChanges();
|
||||
expect(root.querySelector('mat-error')?.textContent?.trim()).toBe(
|
||||
'HOME.URL_UPLOAD.URL_VALIDATION_ERROR'
|
||||
);
|
||||
|
||||
url.setValue('https://example.org/playlist.m3u');
|
||||
fixture.detectChanges();
|
||||
expect(root.querySelector('mat-error')).toBeNull();
|
||||
});
|
||||
});
|
||||
+18
-4
@@ -16,10 +16,10 @@
|
||||
formControlName="serverUrl"
|
||||
(input)="extractParams(form.value.serverUrl ?? '')"
|
||||
/>
|
||||
<mat-hint>{{
|
||||
<mat-hint>{{ 'HOME.XTREAM_PLAYLIST.URL_HINT' | translate }}</mat-hint>
|
||||
<mat-error>{{
|
||||
'HOME.XTREAM_PLAYLIST.URL_VALIDATION_ERROR' | translate
|
||||
}}</mat-hint>
|
||||
<mat-error>{{ 'SETTINGS.EPG_URL_ERROR' | translate }}</mat-error>
|
||||
}}</mat-error>
|
||||
</mat-form-field>
|
||||
@if (connectionTest.messageKey()) {
|
||||
<p role="status" aria-live="polite">
|
||||
@@ -39,6 +39,20 @@
|
||||
<mat-label for="password">{{
|
||||
'HOME.XTREAM_PLAYLIST.PASSWORD' | translate
|
||||
}}</mat-label>
|
||||
<input matInput type="text" id="password" formControlName="password" />
|
||||
<input
|
||||
matInput
|
||||
[type]="passwordToggle.inputType()"
|
||||
id="password"
|
||||
formControlName="password"
|
||||
/>
|
||||
<button
|
||||
mat-icon-button
|
||||
matSuffix
|
||||
appPasswordVisibilityToggle
|
||||
#passwordToggle="appPasswordVisibilityToggle"
|
||||
[attr.aria-label]="'HOME.SHOW_PASSWORD' | translate"
|
||||
>
|
||||
<mat-icon>{{ passwordToggle.icon() }}</mat-icon>
|
||||
</button>
|
||||
</mat-form-field>
|
||||
</form>
|
||||
+97
@@ -1,5 +1,6 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { PlaylistActions } from '@iptvnator/m3u-state';
|
||||
import { XtreamConnectionTestService } from '@iptvnator/services';
|
||||
import { XtreamCodeImportComponent } from './xtream-code-import.component';
|
||||
@@ -198,3 +199,99 @@ describe('XtreamCodeImportComponent', () => {
|
||||
expect(component.connectionTest.result()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('XtreamCodeImportComponent form', () => {
|
||||
function render() {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [XtreamCodeImportComponent, TranslateModule.forRoot()],
|
||||
providers: [
|
||||
{ provide: Store, useValue: { dispatch: jest.fn() } },
|
||||
{
|
||||
provide: XtreamConnectionTestService,
|
||||
useValue: { test: jest.fn() },
|
||||
},
|
||||
],
|
||||
});
|
||||
const fixture = TestBed.createComponent(XtreamCodeImportComponent);
|
||||
fixture.detectChanges();
|
||||
const root = fixture.nativeElement as HTMLElement;
|
||||
return { fixture, root, component: fixture.componentInstance };
|
||||
}
|
||||
|
||||
function fieldText(root: HTMLElement, inputId: string): string {
|
||||
return (
|
||||
root
|
||||
.querySelector(`#${inputId}`)
|
||||
?.closest('mat-form-field')
|
||||
?.textContent?.replace(/\s+/g, ' ')
|
||||
.trim() ?? ''
|
||||
);
|
||||
}
|
||||
|
||||
it('labels the name field like the other add-source forms', () => {
|
||||
const { root } = render();
|
||||
|
||||
expect(fieldText(root, 'title')).toContain(
|
||||
'HOME.XTREAM_PLAYLIST.TITLE'
|
||||
);
|
||||
});
|
||||
|
||||
it('masks the password until the visibility toggle is pressed', () => {
|
||||
const { fixture, root } = render();
|
||||
const password = root.querySelector('#password') as HTMLInputElement;
|
||||
const toggle = password
|
||||
.closest('mat-form-field')
|
||||
?.querySelector('button') as HTMLButtonElement;
|
||||
|
||||
expect(password.type).toBe('password');
|
||||
expect(toggle.type).toBe('button');
|
||||
expect(toggle.getAttribute('aria-label')).toBe('HOME.SHOW_PASSWORD');
|
||||
expect(toggle.getAttribute('aria-pressed')).toBe('false');
|
||||
|
||||
toggle.click();
|
||||
fixture.detectChanges();
|
||||
expect(password.type).toBe('text');
|
||||
expect(toggle.getAttribute('aria-pressed')).toBe('true');
|
||||
expect(toggle.getAttribute('aria-label')).toBe('HOME.SHOW_PASSWORD');
|
||||
|
||||
toggle.click();
|
||||
fixture.detectChanges();
|
||||
expect(password.type).toBe('password');
|
||||
expect(toggle.getAttribute('aria-pressed')).toBe('false');
|
||||
});
|
||||
|
||||
it('shows a neutral server hint until the URL is invalid', () => {
|
||||
const { fixture, root, component } = render();
|
||||
|
||||
expect(fieldText(root, 'serverUrl')).toContain(
|
||||
'HOME.XTREAM_PLAYLIST.URL_HINT'
|
||||
);
|
||||
expect(root.querySelector('mat-error')).toBeNull();
|
||||
|
||||
component.form.controls.serverUrl.setValue('ftp://panel.example');
|
||||
component.form.controls.serverUrl.markAsTouched();
|
||||
fixture.detectChanges();
|
||||
|
||||
const error = root.querySelector('mat-error');
|
||||
expect(error?.textContent?.trim()).toBe(
|
||||
'HOME.XTREAM_PLAYLIST.URL_VALIDATION_ERROR'
|
||||
);
|
||||
expect(fieldText(root, 'serverUrl')).not.toContain(
|
||||
'SETTINGS.EPG_URL_ERROR'
|
||||
);
|
||||
});
|
||||
|
||||
it('explains a URL that carries inline credentials as invalid', () => {
|
||||
const { fixture, root, component } = render();
|
||||
|
||||
component.form.controls.serverUrl.setValue(
|
||||
'https://user:pass@panel.example'
|
||||
);
|
||||
component.form.controls.serverUrl.markAsTouched();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(root.querySelector('mat-error')?.textContent?.trim()).toBe(
|
||||
'HOME.XTREAM_PLAYLIST.URL_VALIDATION_ERROR'
|
||||
);
|
||||
});
|
||||
});
|
||||
+6
@@ -14,12 +14,15 @@ import {
|
||||
ValidationErrors,
|
||||
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 { PlaylistActions } from '@iptvnator/m3u-state';
|
||||
import { createXtreamConnectionTestState } from '@iptvnator/services';
|
||||
import { PasswordVisibilityToggleDirective } from '@iptvnator/ui/components/password-visibility-toggle';
|
||||
import {
|
||||
createRandomId,
|
||||
extractXtreamCredentialsFromUrl,
|
||||
@@ -47,7 +50,10 @@ function xtreamServerUrlValidator(
|
||||
imports: [
|
||||
FormsModule,
|
||||
MatFormFieldModule,
|
||||
MatIcon,
|
||||
MatIconButton,
|
||||
MatInputModule,
|
||||
PasswordVisibilityToggleDirective,
|
||||
ReactiveFormsModule,
|
||||
TranslatePipe,
|
||||
],
|
||||
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { of, Subject } from 'rxjs';
|
||||
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
|
||||
import {
|
||||
DatabaseService,
|
||||
PlaylistsService,
|
||||
RuntimeCapabilitiesService,
|
||||
SettingsStore,
|
||||
} from '@iptvnator/services';
|
||||
import { Playlist } from '@iptvnator/shared/interfaces';
|
||||
import { PlaylistInfoComponent } from './playlist-info.component';
|
||||
import { STALKER_PLAYLIST_CONNECTION_EDITOR } from './stalker-playlist-connection-editor.token';
|
||||
|
||||
/** The edit dialog masks a stored password exactly like the add forms. */
|
||||
describe('PlaylistInfoComponent password field', () => {
|
||||
const base = {
|
||||
id: 'playlist-1',
|
||||
_id: 'playlist-1',
|
||||
title: 'Source',
|
||||
count: 0,
|
||||
importDate: '2026-04-01T00:00:00.000Z',
|
||||
autoRefresh: false,
|
||||
username: 'user1',
|
||||
password: 'pass1',
|
||||
};
|
||||
const xtream = {
|
||||
...base,
|
||||
serverUrl: 'http://xtream.example:8080',
|
||||
} as Playlist & { id: string };
|
||||
const stalker = {
|
||||
...base,
|
||||
portalUrl: 'http://portal.example/portal.php',
|
||||
macAddress: '00:1A:79:00:00:01',
|
||||
} as Playlist & { id: string };
|
||||
|
||||
function render(playlist: Playlist & { id: string }) {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [PlaylistInfoComponent, TranslateModule.forRoot()],
|
||||
providers: [
|
||||
{ provide: MAT_DIALOG_DATA, useValue: playlist },
|
||||
{
|
||||
provide: PlaylistsService,
|
||||
useValue: { getPlaylistById: jest.fn(() => of(playlist)) },
|
||||
},
|
||||
{ provide: DatabaseService, useValue: {} },
|
||||
{
|
||||
provide: EpgRuntimeBridgeService,
|
||||
useValue: { supportsDataManagement: false },
|
||||
},
|
||||
{ provide: Store, useValue: { dispatch: jest.fn() } },
|
||||
{
|
||||
provide: SettingsStore,
|
||||
useValue: { getSettings: jest.fn(() => ({ epgUrl: [] })) },
|
||||
},
|
||||
{ provide: MatSnackBar, useValue: { open: jest.fn() } },
|
||||
{
|
||||
provide: MatDialogRef,
|
||||
useValue: { beforeClosed: () => new Subject<void>() },
|
||||
},
|
||||
{
|
||||
provide: RuntimeCapabilitiesService,
|
||||
useValue: { supportsDesktopFileSave: false },
|
||||
},
|
||||
{ provide: STALKER_PLAYLIST_CONNECTION_EDITOR, useValue: {} },
|
||||
],
|
||||
});
|
||||
const fixture = TestBed.createComponent(PlaylistInfoComponent);
|
||||
fixture.detectChanges();
|
||||
return fixture;
|
||||
}
|
||||
|
||||
it.each([
|
||||
['Xtream', xtream],
|
||||
['Stalker', stalker],
|
||||
])(
|
||||
'masks a stored %s password until it is revealed',
|
||||
async (_, playlist) => {
|
||||
const fixture = render(playlist);
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
const root = fixture.nativeElement as HTMLElement;
|
||||
const password = root.querySelector(
|
||||
'input[formcontrolname="password"]'
|
||||
) as HTMLInputElement;
|
||||
const toggle = password
|
||||
.closest('mat-form-field')
|
||||
?.querySelector('button') as HTMLButtonElement;
|
||||
|
||||
expect(password.value).toBe('pass1');
|
||||
expect(password.type).toBe('password');
|
||||
expect(toggle.type).toBe('button');
|
||||
expect(toggle.getAttribute('aria-label')).toBe(
|
||||
'HOME.SHOW_PASSWORD'
|
||||
);
|
||||
expect(toggle.getAttribute('aria-pressed')).toBe('false');
|
||||
|
||||
toggle.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(password.type).toBe('text');
|
||||
expect(toggle.getAttribute('aria-pressed')).toBe('true');
|
||||
// A toggle never submits the edit form it sits in.
|
||||
expect(fixture.componentInstance.isSaving()).toBe(false);
|
||||
}
|
||||
);
|
||||
});
|
||||
+14
-1
@@ -107,7 +107,20 @@
|
||||
<mat-label>{{
|
||||
'HOME.PLAYLISTS.INFO_DIALOG.PASSWORD' | translate
|
||||
}}</mat-label>
|
||||
<input matInput formControlName="password" />
|
||||
<input
|
||||
matInput
|
||||
[type]="passwordToggle.inputType()"
|
||||
formControlName="password"
|
||||
/>
|
||||
<button
|
||||
mat-icon-button
|
||||
matSuffix
|
||||
appPasswordVisibilityToggle
|
||||
#passwordToggle="appPasswordVisibilityToggle"
|
||||
[attr.aria-label]="'HOME.SHOW_PASSWORD' | translate"
|
||||
>
|
||||
<mat-icon>{{ passwordToggle.icon() }}</mat-icon>
|
||||
</button>
|
||||
</mat-form-field>
|
||||
}
|
||||
@if (playlist.serverUrl && !playlist.portalUrl) {
|
||||
|
||||
+2
@@ -52,6 +52,7 @@ import {
|
||||
normalizeEpgUrls,
|
||||
resolvePlaylistEpgSourceState,
|
||||
} from '@iptvnator/shared/m3u-utils';
|
||||
import { PasswordVisibilityToggleDirective } from '@iptvnator/ui/components/password-visibility-toggle';
|
||||
import {
|
||||
hasStalkerConnectionChanged,
|
||||
omitStalkerConnection,
|
||||
@@ -175,6 +176,7 @@ type DesktopFileSaveBridge = Pick<
|
||||
MatIconButton,
|
||||
MatInputModule,
|
||||
MatTooltip,
|
||||
PasswordVisibilityToggleDirective,
|
||||
ReactiveFormsModule,
|
||||
TranslatePipe,
|
||||
],
|
||||
|
||||
@@ -16,6 +16,7 @@ export * from './lib/portal-detail-shell/portal-detail-shell.component';
|
||||
export * from './lib/view-in-portal-action/view-in-portal-action.component';
|
||||
export * from './lib/view-in-portal-action/view-in-portal-handoff.token';
|
||||
export * from './lib/parental-lock-pin-dialog/parental-lock-pin-dialog.component';
|
||||
export * from './lib/password-visibility-toggle/password-visibility-toggle.directive';
|
||||
export * from './lib/progress-capsule/progress-capsule.component';
|
||||
export * from './lib/resizable/resizable.directive';
|
||||
export * from './lib/season-container/season-container.component';
|
||||
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { PasswordVisibilityToggleDirective } from './password-visibility-toggle.directive';
|
||||
|
||||
@Component({
|
||||
imports: [PasswordVisibilityToggleDirective],
|
||||
template: `
|
||||
<input [type]="toggle.inputType()" />
|
||||
<button
|
||||
appPasswordVisibilityToggle
|
||||
#toggle="appPasswordVisibilityToggle"
|
||||
aria-label="Show password"
|
||||
>
|
||||
{{ toggle.icon() }}
|
||||
</button>
|
||||
`,
|
||||
})
|
||||
class HostComponent {}
|
||||
|
||||
describe('PasswordVisibilityToggleDirective', () => {
|
||||
function render() {
|
||||
const fixture = TestBed.createComponent(HostComponent);
|
||||
fixture.detectChanges();
|
||||
const root = fixture.nativeElement as HTMLElement;
|
||||
return {
|
||||
fixture,
|
||||
input: root.querySelector('input') as HTMLInputElement,
|
||||
button: root.querySelector('button') as HTMLButtonElement,
|
||||
};
|
||||
}
|
||||
|
||||
it('starts masked and is a non-submitting toggle button', () => {
|
||||
const { input, button } = render();
|
||||
|
||||
expect(input.type).toBe('password');
|
||||
expect(button.type).toBe('button');
|
||||
expect(button.getAttribute('aria-pressed')).toBe('false');
|
||||
expect(button.textContent?.trim()).toBe('visibility');
|
||||
});
|
||||
|
||||
it('reveals and masks again on each click, keeping its label', () => {
|
||||
const { fixture, input, button } = render();
|
||||
|
||||
button.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(input.type).toBe('text');
|
||||
expect(button.getAttribute('aria-pressed')).toBe('true');
|
||||
expect(button.textContent?.trim()).toBe('visibility_off');
|
||||
expect(button.getAttribute('aria-label')).toBe('Show password');
|
||||
|
||||
button.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(input.type).toBe('password');
|
||||
expect(button.getAttribute('aria-pressed')).toBe('false');
|
||||
});
|
||||
|
||||
it('does not submit the surrounding form', () => {
|
||||
const form = document.createElement('form');
|
||||
const submit = jest.fn((event: Event) => event.preventDefault());
|
||||
form.addEventListener('submit', submit);
|
||||
const { button } = render();
|
||||
form.appendChild(button);
|
||||
document.body.appendChild(form);
|
||||
|
||||
button.click();
|
||||
|
||||
expect(submit).not.toHaveBeenCalled();
|
||||
form.remove();
|
||||
});
|
||||
});
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
import { computed, Directive, signal } from '@angular/core';
|
||||
|
||||
/**
|
||||
* Turns a `mat-icon-button` suffix into the show/hide control of a password
|
||||
* field. The field starts masked; the input binds `[type]="toggle.inputType()"`
|
||||
* and the icon renders `toggle.icon()`.
|
||||
*
|
||||
* It is a toggle button in the ARIA sense: the template gives it one constant
|
||||
* label ("Show password") and `aria-pressed` carries the state, so a screen
|
||||
* reader announces "Show password, toggle button, pressed" instead of a label
|
||||
* that flips under the user's focus.
|
||||
*/
|
||||
@Directive({
|
||||
selector: 'button[appPasswordVisibilityToggle]',
|
||||
exportAs: 'appPasswordVisibilityToggle',
|
||||
host: {
|
||||
type: 'button',
|
||||
'[attr.aria-pressed]': 'visible()',
|
||||
'(click)': 'toggle()',
|
||||
},
|
||||
})
|
||||
export class PasswordVisibilityToggleDirective {
|
||||
readonly visible = signal(false);
|
||||
readonly inputType = computed(() => (this.visible() ? 'text' : 'password'));
|
||||
readonly icon = computed(() =>
|
||||
this.visible() ? 'visibility_off' : 'visibility'
|
||||
);
|
||||
|
||||
toggle(): void {
|
||||
this.visible.update((visible) => !visible);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user