fix(playlist): keep the pasted provider message when switching import methods

The auto-detect surface is destroyed by the dialog's method switch, so
inspecting a prefilled form and coming back lost the paste. The text now
lives on the dialog and is fed back into the surface on re-entry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JE1De2dfyRZMYh4H2iGXb4
This commit is contained in:
Claude committed 2026-08-11 19:40:35 +00:00
1 parent 3dee1978f6
commit c26596abb2
4 files changed
+133 -3

No files matched your search

@@ -59,6 +59,8 @@
}
@case ('auto') {
<app-auto-import
[initialText]="autoDetectText()"
(textChanged)="autoDetectText.set($event)"
(candidateSelected)="onCandidateSelected($event)"
/>
}
@@ -101,6 +101,12 @@ export class AddPlaylistDialogComponent {
private readonly pendingPrefill =
signal<ProviderImportCandidate | null>(null);
/**
* Survives the auto-detect surface being destroyed by a method switch, so
* returning to it after inspecting a prefilled form keeps the paste.
*/
readonly autoDetectText = signal('');
// Order matches the v0.22 mockup left-to-right: URL first (Most common),
// then File, Xtream credentials, Stalker portal, raw text paste. Each
// entry stands on its own — no nested subtypes. Labels are short and
@@ -0,0 +1,100 @@
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { provideNoopAnimations } from '@angular/platform-browser/animations';
import { TranslateModule } from '@ngx-translate/core';
import { ProviderImportCandidate } from '@iptvnator/shared/interfaces';
import { AutoImportComponent } from './auto-import.component';
describe('AutoImportComponent', () => {
let component: AutoImportComponent;
let fixture: ComponentFixture<AutoImportComponent>;
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
imports: [AutoImportComponent, TranslateModule.forRoot()],
providers: [provideNoopAnimations()],
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(AutoImportComponent);
component = fixture.componentInstance;
});
it('computes candidates from the pasted text', () => {
fixture.detectChanges();
component.textControl.setValue(
'https://lists.example.com/main.m3u'
);
expect(component.candidates()).toHaveLength(1);
expect(component.candidates()[0].kind).toBe('m3u-url');
});
it('restores the dialog-held text so a method switch does not lose the paste', () => {
fixture.componentRef.setInput(
'initialText',
'MAC: 00:1A:79:12:34:56'
);
fixture.detectChanges();
expect(component.textControl.value).toBe('MAC: 00:1A:79:12:34:56');
expect(component.candidates()[0]?.kind).toBe('stalker');
});
it('reports edits back to the dialog', () => {
fixture.detectChanges();
const emitted: string[] = [];
component.textChanged.subscribe((value) => emitted.push(value));
component.textControl.setValue('some text');
expect(emitted).toEqual(['some text']);
});
it('clears the textarea and the candidates', () => {
fixture.detectChanges();
component.textControl.setValue('https://lists.example.com/main.m3u');
component.clearForm();
expect(component.textControl.value).toBe('');
expect(component.candidates()).toHaveLength(0);
});
it('emits the picked candidate', () => {
fixture.detectChanges();
const emitted: ProviderImportCandidate[] = [];
component.candidateSelected.subscribe((candidate) =>
emitted.push(candidate)
);
const candidate: ProviderImportCandidate = {
kind: 'xtream',
confidence: 'high',
username: 'alice',
};
component.selectCandidate(candidate);
expect(emitted).toEqual([candidate]);
});
it('masks the password in the summary rows', () => {
fixture.detectChanges();
const rows = component.summaryRows({
kind: 'xtream',
confidence: 'high',
serverUrl: 'http://tv.example.com:8080',
username: 'alice',
password: 's3cret',
});
const passwordRow = rows.find(
(row) => row.labelKey === 'HOME.XTREAM_PLAYLIST.PASSWORD'
);
expect(passwordRow?.value).toBe('••••••');
expect(JSON.stringify(rows)).not.toContain('s3cret');
});
});
@@ -1,5 +1,5 @@
import { Component, computed, output } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { Component, computed, input, OnInit, output } from '@angular/core';
import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatFormFieldModule } from '@angular/material/form-field';
@@ -59,15 +59,37 @@ const CONFIDENCE_LABEL_KEYS: Record<ProviderImportConfidence, string> = {
TranslatePipe,
],
})
export class AutoImportComponent {
export class AutoImportComponent implements OnInit {
readonly candidateSelected = output<ProviderImportCandidate>();
/**
* The pasted text lives in the DIALOG, not here: switching to a prefilled
* form destroys this component (`@switch`), and coming back to compare or
* pick another candidate must not cost the user their paste. The dialog
* feeds the last text back in and listens for edits.
*/
readonly initialText = input('');
readonly textChanged = output<string>();
readonly textControl = new FormControl('', { nonNullable: true });
private readonly textValue = toSignal(this.textControl.valueChanges, {
initialValue: '',
});
constructor() {
this.textControl.valueChanges
.pipe(takeUntilDestroyed())
.subscribe((value) => this.textChanged.emit(value));
}
ngOnInit(): void {
const initial = this.initialText();
if (initial) {
this.textControl.setValue(initial);
}
}
readonly candidates = computed(() =>
detectProviderImportCandidates(this.textValue())
);