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.

This commit is contained in:
SergeantPanda 2026-01-12 13:22:24 -06:00
parent 6d5d16d667
commit 43636a84d0
3 changed files with 115 additions and 34 deletions

View file

@ -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)

View file

@ -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 = ({
)}
<Group justify="flex-end">
<Button variant="outline" onClick={handleClose}>
<Button variant="outline" onClick={handleClose} disabled={loading}>
{cancelLabel}
</Button>
<Button color="red" onClick={handleConfirm}>
<Button
color="red"
onClick={handleConfirm}
loading={loading}
disabled={loading}
loaderProps={{ type: 'dots' }}
>
{confirmLabel}
</Button>
</Group>

View file

@ -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 (
<Flex gap={4} wrap="nowrap">
<Tooltip label="Download">
@ -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 */}
<Stack gap="sm">
<Group justify="space-between">
<Text size="sm" fw={500}>Scheduled Backups</Text>
<Text size="sm" fw={500}>
Scheduled Backups
</Text>
<Switch
checked={schedule.enabled}
onChange={(e) => handleScheduleChange('enabled', e.currentTarget.checked)}
onChange={(e) =>
handleScheduleChange('enabled', e.currentTarget.checked)
}
label={schedule.enabled ? 'Enabled' : 'Disabled'}
/>
</Group>
<Group justify="space-between">
<Text size="sm" fw={500}>Advanced (Cron Expression)</Text>
<Text size="sm" fw={500}>
Advanced (Cron Expression)
</Text>
<Switch
checked={advancedMode}
onChange={(e) => setAdvancedMode(e.currentTarget.checked)}
@ -584,18 +624,24 @@ export default function BackupManager() {
<TextInput
label="Cron Expression"
value={schedule.cron_expression}
onChange={(e) => 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}
/>
<Text size="xs" c="dimmed">
Examples: <br />
<code>0 3 * * *</code> - Every day at 3:00 AM<br />
<code>0 2 * * 0</code> - Every Sunday at 2:00 AM<br />
<code>0 */6 * * *</code> - Every 6 hours<br />
<code>30 14 1 * *</code> - 1st of every month at 2:30 PM
Examples: <br /> <code>0 3 * * *</code> - Every day at 3:00
AM
<br /> <code>0 2 * * 0</code> - Every Sunday at 2:00 AM
<br /> <code>0 */6 * * *</code> - Every 6 hours
<br /> <code>30 14 1 * *</code> - 1st of every month at
2:30 PM
</Text>
</Stack>
<Group grow align="flex-end">
@ -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() {
<Select
label="Frequency"
value={schedule.frequency}
onChange={(value) => handleScheduleChange('frequency', value)}
onChange={(value) =>
handleScheduleChange('frequency', value)
}
data={[
{ value: 'daily', label: 'Daily' },
{ value: 'weekly', label: 'Weekly' },
@ -634,7 +684,9 @@ export default function BackupManager() {
<Select
label="Day"
value={String(schedule.day_of_week)}
onChange={(value) => 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() {
<>
<Select
label="Hour"
value={schedule.time ? schedule.time.split(':')[0] : '00'}
value={
schedule.time ? schedule.time.split(':')[0] : '00'
}
onChange={(value) => {
const minute = schedule.time ? schedule.time.split(':')[1] : '00';
const minute = schedule.time
? schedule.time.split(':')[1]
: '00';
handleTimeChange24h(`${value}:${minute}`);
}}
data={Array.from({ length: 24 }, (_, i) => ({
@ -698,9 +758,13 @@ export default function BackupManager() {
/>
<Select
label="Minute"
value={schedule.time ? schedule.time.split(':')[1] : '00'}
value={
schedule.time ? schedule.time.split(':')[1] : '00'
}
onChange={(value) => {
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 && (
<Text size="xs" c="dimmed" mt="xs">
System Timezone: {userTimezone} Backup will run at {schedule.time} {userTimezone}
System Timezone: {userTimezone} Backup will run at{' '}
{schedule.time} {userTimezone}
</Text>
)}
</>
@ -861,7 +928,11 @@ export default function BackupManager() {
>
Cancel
</Button>
<Button onClick={handleUploadSubmit} disabled={!uploadFile} variant="default">
<Button
onClick={handleUploadSubmit}
disabled={!uploadFile}
variant="default"
>
Upload
</Button>
</Group>
@ -881,6 +952,7 @@ export default function BackupManager() {
cancelLabel="Cancel"
actionKey="restore-backup"
onSuppressChange={suppressWarning}
loading={restoring}
/>
<ConfirmationDialog