feat(playlist-import): add clear actions to import forms

Entire-Checkpoint: 6719280e397b
This commit is contained in:
4gray committed 2026-05-01 23:42:35 +02:00
1 parent 472ac66185
commit 42bbbe71d8
16 files changed
+434 -94

No files matched your search

@@ -0,0 +1,21 @@
export default {
displayName: 'playlist-import-feature',
preset: '../../../../jest.preset.js',
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
coverageDirectory: '../../../../coverage/libs/playlist/import/feature',
transform: {
'^.+\\.(ts|mjs|js|html)$': [
'jest-preset-angular',
{
tsconfig: '<rootDir>/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',
],
};
@@ -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"
}
@@ -4,7 +4,11 @@
</h2>
<mat-dialog-content>
<div class="apd-segmented" role="tablist" aria-label="Playlist category">
<div
class="apd-segmented"
role="tablist"
aria-label="Playlist category"
>
@for (opt of categoryOptions; track opt.value) {
<button
type="button"
@@ -65,81 +69,112 @@
</div>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-button type="button" (click)="closeDialog()">
{{ 'CANCEL' | translate }}
</button>
<mat-dialog-actions class="apd-actions">
<div class="apd-actions__reset">
<button
mat-button
type="button"
[disabled]="isClearDisabled()"
(click)="clearCurrentForm()"
>
{{ 'CLEAR' | translate }}
</button>
</div>
@switch (playlistType()) {
@case ('url') {
<button
mat-flat-button
color="accent"
type="button"
[disabled]="!urlUpload()?.form?.valid"
(click)="submitUrlPlaylist()"
>
{{ 'HOME.URL_UPLOAD.ADD_PLAYLIST' | translate }}
</button>
}
@case ('file') {
<button
mat-flat-button
color="accent"
type="button"
[disabled]="!fileUpload()?.selectedFile()"
(click)="fileUpload()?.confirm()"
>
{{ 'HOME.URL_UPLOAD.ADD_PLAYLIST' | translate }}
</button>
}
@case ('text') {
<button
mat-flat-button
color="accent"
type="button"
[disabled]="!textImport()?.textForm?.valid"
(click)="textImport()?.textAdded?.emit(textImport()?.textForm?.value?.text)"
>
{{ 'HOME.TEXT_IMPORT.BUTTON_LABEL' | translate }}
</button>
}
@case ('xtream') {
<button
mat-stroked-button
type="button"
[disabled]="!xtreamImport()?.form?.valid || xtreamImport()?.isTestingConnection"
(click)="xtreamImport()?.testConnection()"
>
{{ xtreamImport()?.isTestingConnection ? 'Testing...' : 'Test Connection' }}
</button>
<button
mat-flat-button
color="accent"
type="button"
[disabled]="!xtreamImport()?.form?.valid"
(click)="xtreamImport()?.addPlaylist()"
>
{{ 'HOME.XTREAM_PLAYLIST.ADD' | translate }}
</button>
}
@case ('stalker') {
@if (stalkerImport()?.isLoading()) {
<button mat-flat-button color="accent" type="button" disabled>
{{ 'HOME.STALKER_PORTAL.VALIDATING' | translate }}...
</button>
} @else {
<div class="apd-actions__decision">
<button mat-button type="button" (click)="closeDialog()">
{{ 'CANCEL' | translate }}
</button>
@switch (playlistType()) {
@case ('url') {
<button
mat-flat-button
color="accent"
type="button"
[disabled]="!stalkerImport()?.form?.valid"
(click)="stalkerImport()?.addPlaylist()"
[disabled]="!urlUpload()?.form?.valid"
(click)="submitUrlPlaylist()"
>
{{ 'HOME.STALKER_PORTAL.ADD' | translate }}
{{ 'HOME.URL_UPLOAD.ADD_PLAYLIST' | translate }}
</button>
}
@case ('file') {
<button
mat-flat-button
color="accent"
type="button"
[disabled]="!fileUpload()?.selectedFile()"
(click)="fileUpload()?.confirm()"
>
{{ 'HOME.URL_UPLOAD.ADD_PLAYLIST' | translate }}
</button>
}
@case ('text') {
<button
mat-flat-button
color="accent"
type="button"
[disabled]="!textImport()?.textForm?.valid"
(click)="
textImport()?.textAdded?.emit(
textImport()?.textForm?.value?.text
)
"
>
{{ 'HOME.TEXT_IMPORT.BUTTON_LABEL' | translate }}
</button>
}
@case ('xtream') {
<button
mat-stroked-button
type="button"
[disabled]="
!xtreamImport()?.form?.valid ||
xtreamImport()?.isTestingConnection
"
(click)="xtreamImport()?.testConnection()"
>
{{
xtreamImport()?.isTestingConnection
? 'Testing...'
: 'Test Connection'
}}
</button>
<button
mat-flat-button
color="accent"
type="button"
[disabled]="!xtreamImport()?.form?.valid"
(click)="xtreamImport()?.addPlaylist()"
>
{{ 'HOME.XTREAM_PLAYLIST.ADD' | translate }}
</button>
}
@case ('stalker') {
@if (stalkerImport()?.isLoading()) {
<button
mat-flat-button
color="accent"
type="button"
disabled
>
{{
'HOME.STALKER_PORTAL.VALIDATING' | translate
}}...
</button>
} @else {
<button
mat-flat-button
color="accent"
type="button"
[disabled]="!stalkerImport()?.form?.valid"
(click)="stalkerImport()?.addPlaylist()"
>
{{ 'HOME.STALKER_PORTAL.ADD' | translate }}
</button>
}
}
}
}
</div>
</mat-dialog-actions>
</div>
@@ -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 <body>, 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 {
@@ -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<string, jest.Mock>)[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);
}
});
@@ -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<PlaylistType, 'xtream' | 'stalker'>;
type M3uSubType = Extract<PlaylistType, 'url' | 'file' | 'text'>;
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();
}
@@ -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;
@@ -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();
});
});
@@ -20,4 +20,10 @@ export class TextImportComponent {
textForm = new FormGroup({
text: new FormControl('', Validators.required),
});
clearForm(): void {
this.textForm.reset({
text: '',
});
}
}
@@ -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();
});
});
@@ -39,4 +39,11 @@ export class UrlUploadComponent implements OnInit {
playlistName: [''],
});
}
clearForm(): void {
this.form.reset({
playlistName: '',
playlistUrl: '',
});
}
}
@@ -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: '',
@@ -0,0 +1,6 @@
import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone';
setupZoneTestEnv({
errorOnUnknownElements: true,
errorOnUnknownProperties: true,
});
@@ -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"
}
]
}
@@ -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"]
}
@@ -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"
]
}