mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-09 01:16:15 -08:00
* feat(settings): split settings into per-section pages with an unsaved-changes bar Replace the single scrolling settings page with routed section pages (/workspace/settings/:section): the context-panel rail links each section, only the active section renders, and unknown or capability-gated sections redirect to General. The shared form lives on the parent component, so staged edits survive section switches; a floating unsaved-changes bar (Save/Discard) replaces the always-visible footer Save button. Rail links navigate with replaceUrl so Back still leaves settings in one step. Along the way: - delete the unreachable settings dialog mode and the dead AppPortalNavigationActionsService with both of its never-injected DI tokens (PORTAL_NAVIGATION_ACTIONS, PLAYLIST_PLAYER_ACTIONS) - delete the scroll-spy directive and pendingScrollTarget plumbing - revive the EPG panel's "Open EPG settings" empty-state button as a deep link to /workspace/settings/epg; the M3U player now reports m3u-needs-setup only when the channel has no programmes and no EPG source exists in settings or on the playlist itself - load TMDB cache stats when the Metadata page opens (the section component now only exists while its page is open) - add SETTINGS.UNSAVED_CHANGES / SETTINGS.DISCARD_CHANGES to all 19 locales Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(settings): confirm before leaving with unsaved changes Add settingsUnsavedChangesGuard (canDeactivate on the :section route) with a three-action dialog: save and leave, leave without saving, keep editing. The guard only intercepts leaving the settings AREA — section switches share the one settings form and pass unconditionally, so the dialog can never nag while moving between pages. A failed save cancels the navigation instead of silently dropping the edits it promised to keep; leaving without saving also reverts the live theme preview. Save-and-leave is disabled while the form is invalid, with a hint explaining why. New SETTINGS.UNSAVED_DIALOG_* keys in all 19 locales. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(settings): stage cover size and EPG view mode; adapt e2e to section pages Cover size and EPG view mode were the only two controls that persisted eagerly on click, which made Discard (and leave-without-saving) unable to revert them: hydrateFromStore() faithfully reloaded the just-persisted edit. They now stage in the form like every other setting and reach the store on Save. Review finding by Greptile (P1) and Codex. E2E suites that walk through settings are updated for one-section-page rendering (epg, backup-roundtrip, xtream-epg, remote-control) and for the staged cover size (downloads asserts the dataset after Save); the EPG icon fallback test saves before leaving settings so the new unsaved-changes dialog does not block its navigation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
266 lines
9.2 KiB
TypeScript
266 lines
9.2 KiB
TypeScript
import type { Page } from '@playwright/test';
|
|
import { join } from 'path';
|
|
import { expect, test } from './fixtures';
|
|
|
|
async function openSettings(page: Page) {
|
|
await page.locator('a[href$="/workspace/settings"]').click();
|
|
// The bare settings URL redirects to the default section page.
|
|
await page.waitForURL(/\/workspace\/settings\/general$/);
|
|
await expect(page.locator('.settings-container')).toBeVisible();
|
|
await expect(page.locator('.settings-back-button')).toBeVisible();
|
|
}
|
|
|
|
/** Settings render one section page at a time — open it via the rail. */
|
|
async function openSettingsSection(page: Page, sectionId: string) {
|
|
await page.locator(`[data-test-id="settings-section-${sectionId}"]`).click();
|
|
await page.waitForURL(new RegExp(`/workspace/settings/${sectionId}$`));
|
|
}
|
|
|
|
async function saveSettings(page: Page) {
|
|
const saveButton = page.locator('[data-test-id="save-settings"]');
|
|
|
|
await saveButton.click();
|
|
// A successful save marks the form pristine, which removes the whole
|
|
// unsaved-changes bar together with the save button.
|
|
await expect(saveButton).toBeHidden();
|
|
}
|
|
|
|
test.describe('Settings', () => {
|
|
test.beforeEach(async ({ page }) => {
|
|
// Playwright creates a fresh browser context per test, so extra
|
|
// IndexedDB cleanup here only risks racing with app-managed DB handles.
|
|
await page.goto('/');
|
|
});
|
|
|
|
test('@settings @web Check settings page', async ({ page }) => {
|
|
await openSettings(page);
|
|
await page.locator('.settings-back-button').click();
|
|
});
|
|
|
|
test('@settings @web Change video player', async ({ page }) => {
|
|
await openSettings(page);
|
|
await openSettingsSection(page, 'playback');
|
|
|
|
const playerSelect = page.locator('[data-test-id="select-video-player"]');
|
|
|
|
await expect(playerSelect).toContainText(
|
|
/Video\.js/i
|
|
);
|
|
await playerSelect.click();
|
|
await page.locator('mat-option[data-test-id="html5"]').click();
|
|
|
|
await saveSettings(page);
|
|
await page.reload();
|
|
await openSettings(page);
|
|
await openSettingsSection(page, 'playback');
|
|
|
|
await expect(playerSelect).toContainText(
|
|
/HTML5/i
|
|
);
|
|
});
|
|
|
|
test('@settings @web Enable shared web player controls', async ({
|
|
page,
|
|
}) => {
|
|
await openSettings(page);
|
|
await openSettingsSection(page, 'playback');
|
|
|
|
const setting = page.locator(
|
|
'[data-test-id="web-player-shared-controls-setting"]'
|
|
);
|
|
const checkbox = setting.locator('input[type="checkbox"]');
|
|
|
|
await expect(setting).toBeVisible();
|
|
await expect(checkbox).not.toBeChecked();
|
|
await checkbox.check();
|
|
await saveSettings(page);
|
|
await page.reload();
|
|
await openSettings(page);
|
|
await openSettingsSection(page, 'playback');
|
|
|
|
await expect(checkbox).toBeChecked();
|
|
});
|
|
|
|
test('@settings @web Change app theme', async ({ page }) => {
|
|
await openSettings(page);
|
|
// v0.22 compact theme picker exposes the segmented control as a
|
|
// radiogroup with options labelled just "Light"/"Dark"/"System".
|
|
// Scope to the theme radiogroup so we don't collide with the
|
|
// identically-labelled cover-size options below.
|
|
const themeGroup = page.locator(
|
|
'[data-test-id="select-theme"][role="radiogroup"]'
|
|
);
|
|
await expect(
|
|
themeGroup.getByRole('radio', { name: 'System', exact: true })
|
|
).toHaveAttribute('aria-checked', 'true');
|
|
await themeGroup
|
|
.getByRole('radio', { name: 'Dark', exact: true })
|
|
.click();
|
|
|
|
await saveSettings(page);
|
|
await page.reload();
|
|
await openSettings(page);
|
|
|
|
await expect(
|
|
themeGroup.getByRole('radio', { name: 'Dark', exact: true })
|
|
).toHaveAttribute('aria-checked', 'true');
|
|
});
|
|
|
|
test('@settings @web Deep links open one section page and unknown sections redirect', async ({
|
|
page,
|
|
}) => {
|
|
await page.goto('/workspace/settings/playback');
|
|
|
|
// Only the routed section renders — the playback controls are
|
|
// there, the general ones are not.
|
|
await expect(
|
|
page.locator('[data-test-id="select-video-player"]')
|
|
).toBeVisible();
|
|
await expect(
|
|
page.locator('[data-test-id="select-language"]')
|
|
).toHaveCount(0);
|
|
|
|
// A stale or mistyped section URL is rewritten to the default page.
|
|
await page.goto('/workspace/settings/nonsense');
|
|
await page.waitForURL(/\/workspace\/settings\/general$/);
|
|
await expect(
|
|
page.locator('[data-test-id="select-language"]')
|
|
).toBeVisible();
|
|
});
|
|
|
|
test('@settings @web Unsaved bar survives section switches and discard reverts', async ({
|
|
page,
|
|
}) => {
|
|
await openSettings(page);
|
|
|
|
const unsavedBar = page.locator(
|
|
'[data-test-id="settings-unsaved-bar"]'
|
|
);
|
|
await expect(unsavedBar).toBeHidden();
|
|
|
|
const themeGroup = page.locator(
|
|
'[data-test-id="select-theme"][role="radiogroup"]'
|
|
);
|
|
await themeGroup
|
|
.getByRole('radio', { name: 'Dark', exact: true })
|
|
.click();
|
|
await expect(unsavedBar).toBeVisible();
|
|
|
|
// The staged edit belongs to the page, not the section — moving to
|
|
// another section page must keep the bar (and the pending change).
|
|
await openSettingsSection(page, 'playback');
|
|
await expect(unsavedBar).toBeVisible();
|
|
|
|
await page.locator('[data-test-id="discard-settings"]').click();
|
|
await expect(unsavedBar).toBeHidden();
|
|
|
|
await openSettingsSection(page, 'general');
|
|
await expect(
|
|
themeGroup.getByRole('radio', { name: 'System', exact: true })
|
|
).toHaveAttribute('aria-checked', 'true');
|
|
});
|
|
|
|
test('@settings @web Leaving with unsaved edits asks for confirmation', async ({
|
|
page,
|
|
}) => {
|
|
await openSettings(page);
|
|
|
|
const themeGroup = page.locator(
|
|
'[data-test-id="select-theme"][role="radiogroup"]'
|
|
);
|
|
await themeGroup
|
|
.getByRole('radio', { name: 'Dark', exact: true })
|
|
.click();
|
|
|
|
// Trying to leave the settings area surfaces the dialog.
|
|
await page
|
|
.getByRole('navigation')
|
|
.getByRole('link', { name: 'Dashboard', exact: true })
|
|
.click();
|
|
const dialog = page.getByRole('dialog');
|
|
await expect(dialog).toBeVisible();
|
|
|
|
// Keep editing: navigation is cancelled, the edit survives.
|
|
await dialog.locator('[data-test-id="unsaved-dialog-stay"]').click();
|
|
await expect(dialog).toBeHidden();
|
|
await expect(page).toHaveURL(/\/workspace\/settings\/general$/);
|
|
await expect(
|
|
page.locator('[data-test-id="settings-unsaved-bar"]')
|
|
).toBeVisible();
|
|
|
|
// Leave without saving: the staged edit is discarded.
|
|
await page
|
|
.getByRole('navigation')
|
|
.getByRole('link', { name: 'Dashboard', exact: true })
|
|
.click();
|
|
await page
|
|
.getByRole('dialog')
|
|
.locator('[data-test-id="unsaved-dialog-discard"]')
|
|
.click();
|
|
await page.waitForURL(/\/workspace\/dashboard$/);
|
|
|
|
await openSettings(page);
|
|
await expect(
|
|
themeGroup.getByRole('radio', { name: 'System', exact: true })
|
|
).toHaveAttribute('aria-checked', 'true');
|
|
});
|
|
|
|
test('@settings @web Save-and-leave persists the staged edit', async ({
|
|
page,
|
|
}) => {
|
|
await openSettings(page);
|
|
|
|
const themeGroup = page.locator(
|
|
'[data-test-id="select-theme"][role="radiogroup"]'
|
|
);
|
|
await themeGroup
|
|
.getByRole('radio', { name: 'Dark', exact: true })
|
|
.click();
|
|
|
|
await page
|
|
.getByRole('navigation')
|
|
.getByRole('link', { name: 'Dashboard', exact: true })
|
|
.click();
|
|
await page
|
|
.getByRole('dialog')
|
|
.locator('[data-test-id="unsaved-dialog-save"]')
|
|
.click();
|
|
await page.waitForURL(/\/workspace\/dashboard$/);
|
|
|
|
await page.reload();
|
|
await openSettings(page);
|
|
await expect(
|
|
themeGroup.getByRole('radio', { name: 'Dark', exact: true })
|
|
).toHaveAttribute('aria-checked', 'true');
|
|
});
|
|
|
|
test('@settings @web Change app language', async ({ page }) => {
|
|
await openSettings(page);
|
|
const languageSelect = page.locator('[data-test-id="select-language"]');
|
|
|
|
await expect(languageSelect).toContainText(
|
|
'English'
|
|
);
|
|
await languageSelect.click();
|
|
await page.locator('mat-option[data-test-id="de"]').click();
|
|
|
|
await saveSettings(page);
|
|
await page.reload();
|
|
await openSettings(page);
|
|
|
|
await expect(languageSelect).toContainText(
|
|
'Deutsch'
|
|
);
|
|
});
|
|
|
|
test.afterEach(async ({ page }, testInfo) => {
|
|
await page.screenshot({
|
|
path: join(
|
|
process.cwd(),
|
|
'dist/.playwright/apps/web-e2e/screenshots/settings',
|
|
`${testInfo.title}.png`
|
|
),
|
|
});
|
|
});
|
|
});
|