From 43636a84d02db2204d7c277446aa37afafb80ed5 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Mon, 12 Jan 2026 13:22:24 -0600 Subject: [PATCH] Enhancement: Added visual loading indicator to the backup restore confirmation dialog. When clicking "Restore", the button now displays an animated dots loader and becomes disabled, providing clear feedback that the restore operation is in progress. --- CHANGELOG.md | 1 + .../src/components/ConfirmationDialog.jsx | 12 +- .../src/components/backups/BackupManager.jsx | 136 +++++++++++++----- 3 files changed, 115 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 60eb57cb..9d9102b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Backup restore loading feedback: Added visual loading indicator to the backup restore confirmation dialog. When clicking "Restore", the button now displays an animated dots loader and becomes disabled, providing clear feedback that the restore operation is in progress. - Channel profile edit and duplicate functionality: Users can now rename existing channel profiles and create duplicates with automatic channel membership cloning. Each profile action (edit, duplicate, delete) in the profile dropdown for quick access. - ProfileModal component extracted for improved code organization and maintainability of channel profile management operations. - Frontend unit tests for pages and utilities: Added comprehensive unit test coverage for frontend components within pages/ and JS files within utils/, along with a GitHub Actions workflow (`frontend-tests.yml`) to automatically run tests on commits and pull requests - Thanks [@nick4810](https://github.com/nick4810) diff --git a/frontend/src/components/ConfirmationDialog.jsx b/frontend/src/components/ConfirmationDialog.jsx index 73805513..94fb169c 100644 --- a/frontend/src/components/ConfirmationDialog.jsx +++ b/frontend/src/components/ConfirmationDialog.jsx @@ -16,6 +16,7 @@ import useWarningsStore from '../store/warnings'; * @param {string} props.actionKey - Unique key for this type of action (used for suppression) * @param {Function} props.onSuppressChange - Called when "don't show again" option changes * @param {string} [props.size='md'] - Size of the modal + * @param {boolean} [props.loading=false] - Whether the confirm button should show loading state */ const ConfirmationDialog = ({ opened, @@ -31,6 +32,7 @@ const ConfirmationDialog = ({ zIndex = 1000, showDeleteFileOption = false, deleteFileLabel = 'Also delete files from disk', + loading = false, }) => { const suppressWarning = useWarningsStore((s) => s.suppressWarning); const isWarningSuppressed = useWarningsStore((s) => s.isWarningSuppressed); @@ -93,10 +95,16 @@ const ConfirmationDialog = ({ )} - - diff --git a/frontend/src/components/backups/BackupManager.jsx b/frontend/src/components/backups/BackupManager.jsx index fed0dcfa..102c7254 100644 --- a/frontend/src/components/backups/BackupManager.jsx +++ b/frontend/src/components/backups/BackupManager.jsx @@ -34,7 +34,13 @@ import useLocalStorage from '../../hooks/useLocalStorage'; import useWarningsStore from '../../store/warnings'; import { CustomTable, useTable } from '../tables/CustomTable'; -const RowActions = ({ row, handleDownload, handleRestoreClick, handleDeleteClick, downloading }) => { +const RowActions = ({ + row, + handleDownload, + handleRestoreClick, + handleDeleteClick, + downloading, +}) => { return ( @@ -98,7 +104,6 @@ function to24Hour(time12, period) { return `${String(hours24).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`; } - // Get default timezone (same as Settings page) function getDefaultTimeZone() { try { @@ -116,35 +121,60 @@ function validateCronExpression(expression) { const parts = expression.trim().split(/\s+/); if (parts.length !== 5) { - return { valid: false, error: 'Cron expression must have exactly 5 parts: minute hour day month weekday' }; + return { + valid: false, + error: + 'Cron expression must have exactly 5 parts: minute hour day month weekday', + }; } const [minute, hour, dayOfMonth, month, dayOfWeek] = parts; // Validate each part (allowing *, */N steps, ranges, lists, steps) // Supports: *, */2, 5, 1-5, 1-5/2, 1,3,5, etc. - const cronPartRegex = /^(\*\/\d+|\*|\d+(-\d+)?(\/\d+)?(,\d+(-\d+)?(\/\d+)?)*)$/; + const cronPartRegex = + /^(\*\/\d+|\*|\d+(-\d+)?(\/\d+)?(,\d+(-\d+)?(\/\d+)?)*)$/; if (!cronPartRegex.test(minute)) { - return { valid: false, error: 'Invalid minute field (0-59, *, or cron syntax)' }; + return { + valid: false, + error: 'Invalid minute field (0-59, *, or cron syntax)', + }; } if (!cronPartRegex.test(hour)) { - return { valid: false, error: 'Invalid hour field (0-23, *, or cron syntax)' }; + return { + valid: false, + error: 'Invalid hour field (0-23, *, or cron syntax)', + }; } if (!cronPartRegex.test(dayOfMonth)) { - return { valid: false, error: 'Invalid day field (1-31, *, or cron syntax)' }; + return { + valid: false, + error: 'Invalid day field (1-31, *, or cron syntax)', + }; } if (!cronPartRegex.test(month)) { - return { valid: false, error: 'Invalid month field (1-12, *, or cron syntax)' }; + return { + valid: false, + error: 'Invalid month field (1-12, *, or cron syntax)', + }; } if (!cronPartRegex.test(dayOfWeek)) { - return { valid: false, error: 'Invalid weekday field (0-6, *, or cron syntax)' }; + return { + valid: false, + error: 'Invalid weekday field (0-6, *, or cron syntax)', + }; } // Additional range validation for numeric values const validateRange = (value, min, max, name) => { // Skip if it's * or contains special characters - if (value === '*' || value.includes('/') || value.includes('-') || value.includes(',')) { + if ( + value === '*' || + value.includes('/') || + value.includes('-') || + value.includes(',') + ) { return null; } const num = parseInt(value, 10); @@ -200,6 +230,7 @@ export default function BackupManager() { const [restoreConfirmOpen, setRestoreConfirmOpen] = useState(false); const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); const [selectedBackup, setSelectedBackup] = useState(null); + const [restoring, setRestoring] = useState(false); // Read user's preferences from settings const [timeFormat] = useLocalStorage('time-format', '12h'); @@ -508,11 +539,13 @@ export default function BackupManager() { }; const handleRestoreConfirm = async () => { + setRestoring(true); try { await API.restoreBackup(selectedBackup.name); notifications.show({ title: 'Success', - message: 'Backup restored successfully. You may need to refresh the page.', + message: + 'Backup restored successfully. You may need to refresh the page.', color: 'green', }); setTimeout(() => window.location.reload(), 2000); @@ -523,6 +556,7 @@ export default function BackupManager() { color: 'red', }); } finally { + setRestoring(false); setRestoreConfirmOpen(false); setSelectedBackup(null); } @@ -555,16 +589,22 @@ export default function BackupManager() { {/* Schedule Settings */} - Scheduled Backups + + Scheduled Backups + handleScheduleChange('enabled', e.currentTarget.checked)} + onChange={(e) => + handleScheduleChange('enabled', e.currentTarget.checked) + } label={schedule.enabled ? 'Enabled' : 'Disabled'} /> - Advanced (Cron Expression) + + Advanced (Cron Expression) + setAdvancedMode(e.currentTarget.checked)} @@ -584,18 +624,24 @@ export default function BackupManager() { handleScheduleChange('cron_expression', e.currentTarget.value)} + onChange={(e) => + handleScheduleChange( + 'cron_expression', + e.currentTarget.value + ) + } placeholder="0 3 * * *" description="Format: minute hour day month weekday (e.g., '0 3 * * *' = 3:00 AM daily)" disabled={!schedule.enabled} error={cronError} /> - Examples:
- • 0 3 * * * - Every day at 3:00 AM
- • 0 2 * * 0 - Every Sunday at 2:00 AM
- • 0 */6 * * * - Every 6 hours
- • 30 14 1 * * - 1st of every month at 2:30 PM + Examples:
0 3 * * * - Every day at 3:00 + AM +
0 2 * * 0 - Every Sunday at 2:00 AM +
0 */6 * * * - Every 6 hours +
30 14 1 * * - 1st of every month at + 2:30 PM
@@ -603,7 +649,9 @@ export default function BackupManager() { label="Retention" description="0 = keep all" value={schedule.retention_count} - onChange={(value) => handleScheduleChange('retention_count', value || 0)} + onChange={(value) => + handleScheduleChange('retention_count', value || 0) + } min={0} disabled={!schedule.enabled} /> @@ -623,7 +671,9 @@ export default function BackupManager() { handleScheduleChange('day_of_week', parseInt(value, 10))} + onChange={(value) => + handleScheduleChange('day_of_week', parseInt(value, 10)) + } data={DAYS_OF_WEEK} disabled={!schedule.enabled} /> @@ -645,7 +697,9 @@ export default function BackupManager() { label="Hour" value={displayTime ? displayTime.split(':')[0] : '12'} onChange={(value) => { - const minute = displayTime ? displayTime.split(':')[1] : '00'; + const minute = displayTime + ? displayTime.split(':')[1] + : '00'; handleTimeChange12h(`${value}:${minute}`, null); }} data={Array.from({ length: 12 }, (_, i) => ({ @@ -659,7 +713,9 @@ export default function BackupManager() { label="Minute" value={displayTime ? displayTime.split(':')[1] : '00'} onChange={(value) => { - const hour = displayTime ? displayTime.split(':')[0] : '12'; + const hour = displayTime + ? displayTime.split(':')[0] + : '12'; handleTimeChange12h(`${hour}:${value}`, null); }} data={Array.from({ length: 60 }, (_, i) => ({ @@ -684,9 +740,13 @@ export default function BackupManager() { <> { - const hour = schedule.time ? schedule.time.split(':')[0] : '00'; + const hour = schedule.time + ? schedule.time.split(':')[0] + : '00'; handleTimeChange24h(`${hour}:${value}`); }} data={Array.from({ length: 60 }, (_, i) => ({ @@ -718,7 +782,9 @@ export default function BackupManager() { label="Retention" description="0 = keep all" value={schedule.retention_count} - onChange={(value) => handleScheduleChange('retention_count', value || 0)} + onChange={(value) => + handleScheduleChange('retention_count', value || 0) + } min={0} disabled={!schedule.enabled} /> @@ -737,7 +803,8 @@ export default function BackupManager() { {/* Timezone info - only show in simple mode */} {!advancedMode && schedule.enabled && schedule.time && ( - System Timezone: {userTimezone} • Backup will run at {schedule.time} {userTimezone} + System Timezone: {userTimezone} • Backup will run at{' '} + {schedule.time} {userTimezone} )} @@ -861,7 +928,11 @@ export default function BackupManager() { > Cancel - @@ -881,6 +952,7 @@ export default function BackupManager() { cancelLabel="Cancel" actionKey="restore-backup" onSuppressChange={suppressWarning} + loading={restoring} />