mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 06:06:18 -08:00
feat(settings): per-section pages with unsaved-changes bar (#1384)
* 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>
This commit is contained in:
1 parent
c95f826739
commit
1a6af75761
89 files changed
+1331
-952
No files matched your search
@@ -8,6 +8,7 @@ import {
|
||||
expect,
|
||||
launchElectronApp,
|
||||
openSettings,
|
||||
openSettingsSection,
|
||||
openSources,
|
||||
openWorkspaceSection,
|
||||
resetMockServers,
|
||||
@@ -69,6 +70,7 @@ test.describe('Electron playlist backup round-trip', () => {
|
||||
}, exportPath);
|
||||
|
||||
await openSettings(app.mainWindow);
|
||||
await openSettingsSection(app.mainWindow, 'backup');
|
||||
const backupSection = app.mainWindow.locator('#backup');
|
||||
await backupSection
|
||||
.getByRole('button', { name: 'Export', exact: true })
|
||||
@@ -113,6 +115,7 @@ test.describe('Electron playlist backup round-trip', () => {
|
||||
// Import the exported file back through the settings flow; the
|
||||
// renderer opens a browser file chooser for it.
|
||||
await openSettings(app.mainWindow);
|
||||
await openSettingsSection(app.mainWindow, 'backup');
|
||||
const fileChooserPromise =
|
||||
app.mainWindow.waitForEvent('filechooser');
|
||||
await backupSection
|
||||
|
||||
@@ -123,6 +123,9 @@ async function selectDownloadCoverSize(
|
||||
): Promise<void> {
|
||||
await openSettings(page);
|
||||
await page.getByTestId(`cover-size-${size}`).click();
|
||||
// Cover size is staged like every other setting now — it reaches the
|
||||
// store (and the document dataset) only once the save lands.
|
||||
await saveSettings(page);
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(
|
||||
@@ -130,7 +133,6 @@ async function selectDownloadCoverSize(
|
||||
)
|
||||
)
|
||||
.toBe(size);
|
||||
await saveSettings(page);
|
||||
await openDownloadsPage(page);
|
||||
}
|
||||
|
||||
|
||||
@@ -983,10 +983,24 @@ export async function openSettings(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId('settings-container')).toBeVisible();
|
||||
}
|
||||
|
||||
/**
|
||||
* Settings render one section page at a time (`/workspace/settings/:section`),
|
||||
* so a control can only be interacted with after its section page is open.
|
||||
*/
|
||||
export async function openSettingsSection(
|
||||
page: Page,
|
||||
sectionId: string
|
||||
): Promise<void> {
|
||||
await page.getByTestId(`settings-section-${sectionId}`).click();
|
||||
await page.waitForURL(new RegExp(`/workspace/settings/${sectionId}$`));
|
||||
}
|
||||
|
||||
export async function enableRemoteControl(
|
||||
page: Page,
|
||||
port: number
|
||||
): Promise<void> {
|
||||
await openSettingsSection(page, 'remote-control');
|
||||
|
||||
const remoteControlCheckbox = page.locator(
|
||||
'mat-checkbox[formcontrolname="remoteControl"] input[type="checkbox"]'
|
||||
);
|
||||
@@ -1011,9 +1025,10 @@ export async function saveSettings(page: Page): Promise<void> {
|
||||
// deterministic post-save state below.
|
||||
await saveButton.click({ noWaitAfter: true });
|
||||
// `onSubmit()` calls `applyChangedSettings()` -> `markAsPristine()` once the
|
||||
// settings write resolves, which disables the button. Awaiting that is a
|
||||
// stronger, race-free confirmation that the save actually committed.
|
||||
await expect(saveButton).toBeDisabled();
|
||||
// settings write resolves, which hides the whole unsaved-changes bar.
|
||||
// Awaiting that is a stronger, race-free confirmation that the save
|
||||
// actually committed.
|
||||
await expect(saveButton).toBeHidden();
|
||||
// Let the fire-and-forget `window.electron.updateSettings(...)` IPC flush to
|
||||
// the main process before callers may relaunch the app to assert persistence.
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
launchElectronApp,
|
||||
openWorkspaceSection,
|
||||
openSettings,
|
||||
openSettingsSection,
|
||||
saveSettings,
|
||||
test,
|
||||
} from './electron-test-fixtures';
|
||||
|
||||
@@ -74,6 +76,7 @@ test.describe('Electron EPG', () => {
|
||||
|
||||
try {
|
||||
await openSettings(app.mainWindow);
|
||||
await openSettingsSection(app.mainWindow, 'epg');
|
||||
await app.mainWindow
|
||||
.getByRole('button', { name: 'Add EPG source' })
|
||||
.click();
|
||||
@@ -254,6 +257,7 @@ test.describe('Electron EPG', () => {
|
||||
);
|
||||
|
||||
await openSettings(app.mainWindow);
|
||||
await openSettingsSection(app.mainWindow, 'epg');
|
||||
await app.mainWindow
|
||||
.getByRole('button', { name: 'Add EPG source' })
|
||||
.click();
|
||||
@@ -272,6 +276,11 @@ test.describe('Electron EPG', () => {
|
||||
})
|
||||
.toBeGreaterThan(0);
|
||||
|
||||
// The added source is still only staged in the form — leaving
|
||||
// settings without saving would surface the unsaved-changes
|
||||
// dialog and block the navigation below.
|
||||
await saveSettings(app.mainWindow);
|
||||
|
||||
await openWorkspaceSection(app.mainWindow, 'All channels');
|
||||
|
||||
const channelItem = channelItemByTitle(
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
importM3uPlaylistFromNativeDialog,
|
||||
launchElectronApp,
|
||||
openSettings,
|
||||
openSettingsSection,
|
||||
saveSettings,
|
||||
test,
|
||||
waitForM3uCatalog,
|
||||
@@ -47,6 +48,7 @@ test.describe('Electron Remote Control', () => {
|
||||
|
||||
try {
|
||||
await openSettings(app.mainWindow);
|
||||
await openSettingsSection(app.mainWindow, 'playback');
|
||||
await selectSettingsOption(
|
||||
app.mainWindow,
|
||||
'select-video-player',
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
m3uFixturePath,
|
||||
openGlobalRecent,
|
||||
openSettings,
|
||||
openSettingsSection,
|
||||
openWorkspaceSection,
|
||||
resetMockServers,
|
||||
restartElectronApp,
|
||||
@@ -49,6 +50,7 @@ test.describe('Electron Settings', () => {
|
||||
|
||||
try {
|
||||
await openSettings(app.mainWindow);
|
||||
await openSettingsSection(app.mainWindow, 'about');
|
||||
|
||||
await expect(app.mainWindow.getByTestId('app-update-status')).toBeVisible();
|
||||
await expect(app.mainWindow.getByTestId('app-update-check')).toBeVisible();
|
||||
@@ -75,6 +77,7 @@ test.describe('Electron Settings', () => {
|
||||
await installExternalPlayerLaunchCapture(app);
|
||||
|
||||
await openSettings(app.mainWindow);
|
||||
await openSettingsSection(app.mainWindow, 'playback');
|
||||
await expect(
|
||||
app.mainWindow.getByTestId(
|
||||
'external-player-double-click-setting'
|
||||
@@ -117,6 +120,7 @@ test.describe('Electron Settings', () => {
|
||||
});
|
||||
|
||||
await openSettings(app.mainWindow);
|
||||
await openSettingsSection(app.mainWindow, 'playback');
|
||||
await expect(doubleClickSetting).toBeVisible();
|
||||
await doubleClickCheckbox.check();
|
||||
await saveSettings(app.mainWindow);
|
||||
@@ -229,6 +233,7 @@ test.describe('Electron Settings', () => {
|
||||
await firstLaunch.mainWindow
|
||||
.locator('[data-test-id="DARK_THEME"]')
|
||||
.click();
|
||||
await openSettingsSection(firstLaunch.mainWindow, 'playback');
|
||||
await selectSettingsOption(
|
||||
firstLaunch.mainWindow,
|
||||
'select-video-player',
|
||||
@@ -250,6 +255,7 @@ test.describe('Electron Settings', () => {
|
||||
)
|
||||
.uncheck();
|
||||
await enableRemoteControl(firstLaunch.mainWindow, 8877);
|
||||
await openSettingsSection(firstLaunch.mainWindow, 'epg');
|
||||
await firstLaunch.mainWindow
|
||||
.getByRole('button', { name: 'Add EPG source' })
|
||||
.click();
|
||||
@@ -273,6 +279,7 @@ test.describe('Electron Settings', () => {
|
||||
await expect(
|
||||
secondLaunch.mainWindow.locator('[data-test-id="DARK_THEME"]')
|
||||
).toHaveAttribute('aria-checked', 'true');
|
||||
await openSettingsSection(secondLaunch.mainWindow, 'playback');
|
||||
await expect(
|
||||
secondLaunch.mainWindow.getByTestId('select-video-player')
|
||||
).toContainText(/HTML5/i);
|
||||
@@ -289,6 +296,7 @@ test.describe('Electron Settings', () => {
|
||||
'mat-checkbox[formcontrolname="showExternalPlaybackBar"] input[type="checkbox"]'
|
||||
)
|
||||
).not.toBeChecked();
|
||||
await openSettingsSection(secondLaunch.mainWindow, 'remote-control');
|
||||
await expect(
|
||||
secondLaunch.mainWindow.locator(
|
||||
'mat-checkbox[formcontrolname="remoteControl"] input[type="checkbox"]'
|
||||
@@ -297,6 +305,7 @@ test.describe('Electron Settings', () => {
|
||||
await expect(
|
||||
secondLaunch.mainWindow.locator('#remoteControlPort')
|
||||
).toHaveValue('8877');
|
||||
await openSettingsSection(secondLaunch.mainWindow, 'epg');
|
||||
await expect(
|
||||
secondLaunch.mainWindow.locator('.epg-source-row input').first()
|
||||
).toHaveValue(epgServer.resourceUrl);
|
||||
@@ -313,6 +322,7 @@ test.describe('Electron Settings', () => {
|
||||
|
||||
try {
|
||||
await openSettings(app.mainWindow);
|
||||
await openSettingsSection(app.mainWindow, 'playback');
|
||||
await selectSettingsOption(
|
||||
app.mainWindow,
|
||||
'select-video-player',
|
||||
@@ -478,6 +488,7 @@ test.describe('Electron Settings', () => {
|
||||
|
||||
try {
|
||||
await openSettings(firstLaunch.mainWindow);
|
||||
await openSettingsSection(firstLaunch.mainWindow, 'epg');
|
||||
const listToggle = firstLaunch.mainWindow.locator(
|
||||
'[data-test-id="epg-view-mode-list"]'
|
||||
);
|
||||
@@ -493,6 +504,7 @@ test.describe('Electron Settings', () => {
|
||||
|
||||
try {
|
||||
await openSettings(secondLaunch.mainWindow);
|
||||
await openSettingsSection(secondLaunch.mainWindow, 'epg');
|
||||
await expect(
|
||||
secondLaunch.mainWindow.locator(
|
||||
'[data-test-id="epg-view-mode-list"]'
|
||||
@@ -508,6 +520,7 @@ test.describe('Electron Settings', () => {
|
||||
|
||||
try {
|
||||
await openSettings(firstLaunch.mainWindow);
|
||||
await openSettingsSection(firstLaunch.mainWindow, 'dashboard');
|
||||
await firstLaunch.mainWindow
|
||||
.locator(
|
||||
'mat-checkbox[formcontrolname="showDashboard"] input[type="checkbox"]'
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
goToDashboard,
|
||||
launchElectronApp,
|
||||
openSettings,
|
||||
openSettingsSection,
|
||||
openWorkspaceSection,
|
||||
resetMockServers,
|
||||
saveSettings,
|
||||
@@ -302,6 +303,7 @@ test('@epg @xtream @electron renders the vertical list view when the setting is
|
||||
// Opt into the list view first (from the fresh workspace) so the portal
|
||||
// → Live TV → channel flow afterwards mirrors the timeline test exactly.
|
||||
await openSettings(app.mainWindow);
|
||||
await openSettingsSection(app.mainWindow, 'epg');
|
||||
await app.mainWindow
|
||||
.locator('[data-test-id="epg-view-mode-list"]')
|
||||
.click();
|
||||
|
||||
Reference in new issue
Block a user