From 42bbbe71d8e814ab2684fa640ebddd6b82e67f58 Mon Sep 17 00:00:00 2001 From: 4gray Date: Fri, 1 May 2026 23:42:35 +0200 Subject: [PATCH] feat(playlist-import): add clear actions to import forms Entire-Checkpoint: 6719280e397b --- libs/playlist/import/feature/jest.config.ts | 21 +++ libs/playlist/import/feature/project.json | 8 + .../add-playlist-dialog.component.html | 171 +++++++++++------- .../add-playlist-dialog.component.scss | 61 ++++++- .../add-playlist-dialog.component.spec.ts | 93 ++++++++-- .../add-playlist-dialog.component.ts | 47 ++++- .../stalker-portal-import.component.ts | 18 ++ .../text-import/text-import.component.spec.ts | 14 ++ .../lib/text-import/text-import.component.ts | 6 + .../url-upload/url-upload.component.spec.ts | 16 ++ .../lib/url-upload/url-upload.component.ts | 7 + .../xtream-code-import.component.ts | 3 +- .../playlist/import/feature/src/test-setup.ts | 6 + libs/playlist/import/feature/tsconfig.json | 28 +++ .../playlist/import/feature/tsconfig.lib.json | 12 ++ .../import/feature/tsconfig.spec.json | 17 ++ 16 files changed, 434 insertions(+), 94 deletions(-) create mode 100644 libs/playlist/import/feature/jest.config.ts create mode 100644 libs/playlist/import/feature/src/test-setup.ts create mode 100644 libs/playlist/import/feature/tsconfig.json create mode 100644 libs/playlist/import/feature/tsconfig.lib.json create mode 100644 libs/playlist/import/feature/tsconfig.spec.json diff --git a/libs/playlist/import/feature/jest.config.ts b/libs/playlist/import/feature/jest.config.ts new file mode 100644 index 000000000..32f33ba89 --- /dev/null +++ b/libs/playlist/import/feature/jest.config.ts @@ -0,0 +1,21 @@ +export default { + displayName: 'playlist-import-feature', + preset: '../../../../jest.preset.js', + setupFilesAfterEnv: ['/src/test-setup.ts'], + coverageDirectory: '../../../../coverage/libs/playlist/import/feature', + transform: { + '^.+\\.(ts|mjs|js|html)$': [ + 'jest-preset-angular', + { + tsconfig: '/tsconfig.spec.json', + stringifyContentPathRegex: '\\.(html|svg)$', + }, + ], + }, + transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'], + snapshotSerializers: [ + 'jest-preset-angular/build/serializers/no-ng-attributes', + 'jest-preset-angular/build/serializers/ng-snapshot', + 'jest-preset-angular/build/serializers/html-comment', + ], +}; diff --git a/libs/playlist/import/feature/project.json b/libs/playlist/import/feature/project.json index f2b6b48ed..202bb9525 100644 --- a/libs/playlist/import/feature/project.json +++ b/libs/playlist/import/feature/project.json @@ -6,6 +6,14 @@ "projectType": "library", "tags": ["scope:playlist", "domain:playlist-import", "type:feature"], "targets": { + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "libs/playlist/import/feature/jest.config.ts", + "tsConfig": "libs/playlist/import/feature/tsconfig.spec.json" + } + }, "lint": { "executor": "@nx/eslint:lint" } 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 a6e08f317..25fd20642 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 @@ -4,7 +4,11 @@ -
+
@for (opt of categoryOptions; track opt.value) { + +
+ +
- @switch (playlistType()) { - @case ('url') { - - } - @case ('file') { - - } - @case ('text') { - - } - @case ('xtream') { - - - } - @case ('stalker') { - @if (stalkerImport()?.isLoading()) { - - } @else { +
+ + + @switch (playlistType()) { + @case ('url') { } + @case ('file') { + + } + @case ('text') { + + } + @case ('xtream') { + + + } + @case ('stalker') { + @if (stalkerImport()?.isLoading()) { + + } @else { + + } + } } - } +
diff --git a/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.scss b/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.scss index 52fd704d6..a2ba853f4 100644 --- a/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.scss +++ b/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.scss @@ -5,7 +5,11 @@ --apd-muted: var(--app-muted-color); --apd-surface-raised: var(--app-widget-header-bg); --apd-accent: var(--app-selection-color); - --apd-segment-track: color-mix(in srgb, var(--app-shell-bg) 70%, transparent); + --apd-segment-track: color-mix( + in srgb, + var(--app-shell-bg) 70%, + transparent + ); --apd-segment-active: color-mix( in srgb, var(--app-widget-header-bg) 70%, @@ -25,7 +29,11 @@ margin-bottom: 14px; &--sub { - background: color-mix(in srgb, var(--apd-segment-track) 85%, transparent); + background: color-mix( + in srgb, + var(--apd-segment-track) 85%, + transparent + ); } } @@ -97,17 +105,62 @@ margin: 6px 0 0; padding: 10px 12px; border-radius: 8px; - background: color-mix(in srgb, var(--apd-surface-raised) 50%, transparent); + background: color-mix( + in srgb, + var(--apd-surface-raised) 50%, + transparent + ); color: var(--apd-muted); font-size: 11.5px; line-height: 1.45; } } +.apd-actions { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + gap: 8px 12px; + + &__reset, + &__decision { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + } + + &__decision { + justify-content: flex-end; + margin-left: auto; + } +} + +@media (max-width: 520px) { + .apd-actions { + align-items: stretch; + flex-direction: column; + + &__reset, + &__decision { + width: 100%; + } + + &__decision { + justify-content: flex-end; + margin-left: 0; + } + } +} + // ─── Light-theme segmented control tweak ──────────────────────────────────── // `.dark-theme` is on , so target light mode via `body:not(...)`. body:not(.dark-theme) .apd { - --apd-segment-track: color-mix(in srgb, var(--app-content-bg) 70%, transparent); + --apd-segment-track: color-mix( + in srgb, + var(--app-content-bg) 70%, + transparent + ); --apd-segment-active: #ffffff; .apd-segment.active { diff --git a/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.spec.ts b/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.spec.ts index a194499af..2b356e16b 100644 --- a/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.spec.ts +++ b/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.spec.ts @@ -6,6 +6,7 @@ import { TranslateService } from '@ngx-translate/core'; import { PlaylistActions } from 'm3u-state'; import { DataService } from 'services'; import { PLAYLIST_PARSE_BY_URL } from 'shared-interfaces'; +import { PlaylistType } from '@iptvnator/playlist/shared/ui'; import { AddPlaylistDialogComponent } from './add-playlist-dialog.component'; describe('AddPlaylistDialogComponent', () => { @@ -106,27 +107,89 @@ describe('AddPlaylistDialogComponent', () => { expect(dialogRef.close).toHaveBeenCalled(); }); - it('strips playlist extensions from uploaded file titles', () => { - component.handlePlaylist({ - uploadEvent: { - target: { - result: '#EXTM3U', - }, - } as unknown as Event, - file: { - name: 'Local Source.m3u', - path: '/tmp/Local Source.m3u', - } as File, - }); + it('dispatches imported text and closes the dialog', () => { + component.uploadAsText('#EXTM3U'); expect(store.dispatch).toHaveBeenCalledWith( PlaylistActions.parsePlaylist({ - uploadType: 'FILE', + uploadType: 'TEXT', playlist: '#EXTM3U', - title: 'Local Source', - path: '/tmp/Local Source.m3u', + title: 'HOME.IMPORTED_AS_TEXT', }) ); expect(dialogRef.close).toHaveBeenCalled(); }); + + it('closes the dialog after a successful file import', () => { + component.onFileImported(); + + expect(dialogRef.close).toHaveBeenCalled(); + }); + + it.each([ + { + type: 'url', + childAccessor: 'urlUpload', + clearMethod: 'clearForm', + }, + { + type: 'file', + childAccessor: 'fileUpload', + clearMethod: 'clearSelection', + }, + { + type: 'text', + childAccessor: 'textImport', + clearMethod: 'clearForm', + }, + { + type: 'xtream', + childAccessor: 'xtreamImport', + clearMethod: 'clearForm', + }, + { + type: 'stalker', + childAccessor: 'stalkerImport', + clearMethod: 'clearForm', + }, + ] as const)( + 'clears the current $type import surface', + ({ type, childAccessor, clearMethod }) => { + const clear = jest.fn(); + (component as unknown as Record)[childAccessor] = + jest.fn(() => ({ + [clearMethod]: clear, + })); + selectType(type); + + component.clearCurrentForm(); + + expect(clear).toHaveBeenCalledTimes(1); + } + ); + + it('disables clear when a file upload has no selection', () => { + (component as { fileUpload: jest.Mock }).fileUpload = jest.fn(() => ({ + isImporting: () => false, + selectedFile: () => null, + })); + selectType('file'); + + expect(component.isClearDisabled()).toBeTruthy(); + }); + + function selectType(type: PlaylistType): void { + if (type === 'xtream') { + component.category.set('xtream'); + return; + } + + if (type === 'stalker') { + component.category.set('stalker'); + return; + } + + component.category.set('m3u'); + component.m3uSubType.set(type); + } }); 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 318b9736a..2dcaf188e 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 @@ -16,10 +16,6 @@ import { MatSnackBar } from '@angular/material/snack-bar'; import { Store } from '@ngrx/store'; import { TranslateModule, TranslateService } from '@ngx-translate/core'; import { PlaylistType } from '@iptvnator/playlist/shared/ui'; -import { - M3uSubType, - PlaylistCategory, -} from '@iptvnator/workspace/shell/util'; import { PlaylistActions } from 'm3u-state'; import { DataService } from 'services'; import { PLAYLIST_PARSE_BY_URL } from 'shared-interfaces'; @@ -29,6 +25,9 @@ import { TextImportComponent } from '../text-import/text-import.component'; import { UrlUploadComponent } from '../url-upload/url-upload.component'; import { XtreamCodeImportComponent } from '../xtream-code-import/xtream-code-import.component'; +type PlaylistCategory = 'm3u' | Extract; +type M3uSubType = Extract; + interface CategoryOption { value: PlaylistCategory; label: string; @@ -139,7 +138,9 @@ export class AddPlaylistDialogComponent { return; } - const playlistName = this.normalizeOptionalValue(formValue?.playlistName); + const playlistName = this.normalizeOptionalValue( + formValue?.playlistName + ); this.dataService.sendIpcEvent(PLAYLIST_PARSE_BY_URL, { url: playlistUrl, @@ -163,6 +164,42 @@ export class AddPlaylistDialogComponent { this.closeDialog(); } + clearCurrentForm(): void { + switch (this.playlistType()) { + case 'url': + this.urlUpload()?.clearForm(); + break; + case 'file': + this.fileUpload()?.clearSelection(); + break; + case 'text': + this.textImport()?.clearForm(); + break; + case 'xtream': + this.xtreamImport()?.clearForm(); + break; + case 'stalker': + this.stalkerImport()?.clearForm(); + break; + } + } + + isClearDisabled(): boolean { + switch (this.playlistType()) { + case 'file': + return ( + !this.fileUpload()?.selectedFile() || + !!this.fileUpload()?.isImporting() + ); + case 'xtream': + return !!this.xtreamImport()?.isTestingConnection; + case 'stalker': + return !!this.stalkerImport()?.isLoading(); + default: + return false; + } + } + closeDialog(): void { this.dialogRef.close(); } diff --git a/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts b/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts index 145136450..6342e2979 100644 --- a/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts +++ b/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts @@ -79,6 +79,24 @@ export class StalkerPortalImportComponent { readonly isLoading = signal(false); + clearForm(): void { + this.form.reset({ + _id: uuid(), + title: '', + macAddress: '', + serialNumber: '', + deviceId1: '', + deviceId2: '', + signature1: '', + signature2: '', + password: '', + username: '', + portalUrl: '', + importDate: new Date().toISOString(), + userAgent: '', + }); + } + async addPlaylist() { if (!this.form.valid || this.isLoading()) { return; diff --git a/libs/playlist/import/feature/src/lib/text-import/text-import.component.spec.ts b/libs/playlist/import/feature/src/lib/text-import/text-import.component.spec.ts index 6e17770d4..49a1256f7 100644 --- a/libs/playlist/import/feature/src/lib/text-import/text-import.component.spec.ts +++ b/libs/playlist/import/feature/src/lib/text-import/text-import.component.spec.ts @@ -31,4 +31,18 @@ describe('TextImportComponent', () => { it('should create', () => { expect(component).toBeTruthy(); }); + + it('clears the text import form', () => { + component.textForm.setValue({ + text: '#EXTM3U', + }); + component.textForm.markAsDirty(); + + component.clearForm(); + + expect(component.textForm.value).toEqual({ + text: '', + }); + expect(component.textForm.pristine).toBeTruthy(); + }); }); diff --git a/libs/playlist/import/feature/src/lib/text-import/text-import.component.ts b/libs/playlist/import/feature/src/lib/text-import/text-import.component.ts index 16b92592e..3aaa414a9 100644 --- a/libs/playlist/import/feature/src/lib/text-import/text-import.component.ts +++ b/libs/playlist/import/feature/src/lib/text-import/text-import.component.ts @@ -20,4 +20,10 @@ export class TextImportComponent { textForm = new FormGroup({ text: new FormControl('', Validators.required), }); + + clearForm(): void { + this.textForm.reset({ + text: '', + }); + } } diff --git a/libs/playlist/import/feature/src/lib/url-upload/url-upload.component.spec.ts b/libs/playlist/import/feature/src/lib/url-upload/url-upload.component.spec.ts index f810f3b72..a4c54469b 100644 --- a/libs/playlist/import/feature/src/lib/url-upload/url-upload.component.spec.ts +++ b/libs/playlist/import/feature/src/lib/url-upload/url-upload.component.spec.ts @@ -57,4 +57,20 @@ describe('UrlUploadComponent', () => { fixture.detectChanges(); expect(component.form.valid).toBeTruthy(); }); + + it('clears the url playlist form', () => { + component.form.setValue({ + playlistName: 'News', + playlistUrl: 'http://example.org/playlist.m3u', + }); + component.form.markAsDirty(); + + component.clearForm(); + + expect(component.form.getRawValue()).toEqual({ + playlistName: '', + playlistUrl: '', + }); + expect(component.form.pristine).toBeTruthy(); + }); }); diff --git a/libs/playlist/import/feature/src/lib/url-upload/url-upload.component.ts b/libs/playlist/import/feature/src/lib/url-upload/url-upload.component.ts index a6c1f1690..f9762a4a5 100644 --- a/libs/playlist/import/feature/src/lib/url-upload/url-upload.component.ts +++ b/libs/playlist/import/feature/src/lib/url-upload/url-upload.component.ts @@ -39,4 +39,11 @@ export class UrlUploadComponent implements OnInit { playlistName: [''], }); } + + clearForm(): void { + this.form.reset({ + playlistName: '', + playlistUrl: '', + }); + } } diff --git a/libs/playlist/import/feature/src/lib/xtream-code-import/xtream-code-import.component.ts b/libs/playlist/import/feature/src/lib/xtream-code-import/xtream-code-import.component.ts index 6ffce9a43..fecf0f634 100644 --- a/libs/playlist/import/feature/src/lib/xtream-code-import/xtream-code-import.component.ts +++ b/libs/playlist/import/feature/src/lib/xtream-code-import/xtream-code-import.component.ts @@ -61,7 +61,6 @@ import { v4 as uuid } from 'uuid'; align-items: center; gap: 8px; } - `, ], }) @@ -121,7 +120,7 @@ export class XtreamCodeImportComponent { return this.portalStatusService.getStatusIcon(this.connectionStatus); } - clearForm() { + clearForm(): void { this.form.reset({ _id: uuid(), title: '', diff --git a/libs/playlist/import/feature/src/test-setup.ts b/libs/playlist/import/feature/src/test-setup.ts new file mode 100644 index 000000000..0d88648e3 --- /dev/null +++ b/libs/playlist/import/feature/src/test-setup.ts @@ -0,0 +1,6 @@ +import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone'; + +setupZoneTestEnv({ + errorOnUnknownElements: true, + errorOnUnknownProperties: true, +}); diff --git a/libs/playlist/import/feature/tsconfig.json b/libs/playlist/import/feature/tsconfig.json new file mode 100644 index 000000000..7d33479d8 --- /dev/null +++ b/libs/playlist/import/feature/tsconfig.json @@ -0,0 +1,28 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "isolatedModules": true, + "target": "es2022", + "moduleResolution": "bundler", + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "emitDecoratorMetadata": false, + "module": "preserve" + }, + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "strictTemplates": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + } + ] +} diff --git a/libs/playlist/import/feature/tsconfig.lib.json b/libs/playlist/import/feature/tsconfig.lib.json new file mode 100644 index 000000000..7fba372dc --- /dev/null +++ b/libs/playlist/import/feature/tsconfig.lib.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../../dist/out-tsc", + "declaration": true, + "declarationMap": true, + "inlineSources": true, + "types": [] + }, + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"] +} diff --git a/libs/playlist/import/feature/tsconfig.spec.json b/libs/playlist/import/feature/tsconfig.spec.json new file mode 100644 index 000000000..c0bb248be --- /dev/null +++ b/libs/playlist/import/feature/tsconfig.spec.json @@ -0,0 +1,17 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../../dist/out-tsc", + "module": "commonjs", + "target": "es2016", + "types": ["jest", "node"], + "moduleResolution": "node10" + }, + "files": ["src/test-setup.ts"], + "include": [ + "jest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] +}