diff --git a/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.html b/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.html index 2bbfceb50..ea6305e60 100644 --- a/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.html +++ b/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.html @@ -59,6 +59,8 @@ } @case ('auto') { } diff --git a/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.ts b/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.ts index bdea15172..4e4bd7fd3 100644 --- a/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.ts +++ b/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.ts @@ -101,6 +101,12 @@ export class AddPlaylistDialogComponent { private readonly pendingPrefill = signal(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 diff --git a/libs/playlist/import/feature/src/lib/auto-import/auto-import.component.spec.ts b/libs/playlist/import/feature/src/lib/auto-import/auto-import.component.spec.ts new file mode 100644 index 000000000..d41a7b0c9 --- /dev/null +++ b/libs/playlist/import/feature/src/lib/auto-import/auto-import.component.spec.ts @@ -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; + + 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'); + }); +}); 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 5c86bd4a7..66533af24 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 @@ -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 = { TranslatePipe, ], }) -export class AutoImportComponent { +export class AutoImportComponent implements OnInit { readonly candidateSelected = output(); + /** + * 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(); + 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()) );