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:
4grayandClaude Opus 5.5 committed 2026-10-03 09:55:16 +02:00
1 parent 23a1860119
commit 0ea9ecdb27
56 files changed
+1208 -252

No files matched your search

+1
View File
@@ -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';
@@ -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();
});
});
@@ -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);
}
}