({
@@ -158,7 +158,7 @@ describe('SystemEvents', () => {
fireEvent.click(refreshButton);
await waitFor(() => {
- expect(API.getSystemEvents).toHaveBeenCalledTimes(3)
+ expect(API.getSystemEvents).toHaveBeenCalledTimes(3);
});
});
@@ -203,7 +203,9 @@ describe('SystemEvents', () => {
});
it('should handle API errors gracefully', async () => {
- const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ const consoleErrorSpy = vi
+ .spyOn(console, 'error')
+ .mockImplementation(() => {});
API.getSystemEvents.mockRejectedValue(new Error('API Error'));
render(
);
diff --git a/frontend/src/components/backups/BackupManager.jsx b/frontend/src/components/backups/BackupManager.jsx
index c58755b2..b1c0ffe3 100644
--- a/frontend/src/components/backups/BackupManager.jsx
+++ b/frontend/src/components/backups/BackupManager.jsx
@@ -14,7 +14,6 @@ import {
Stack,
Switch,
Text,
- TextInput,
Tooltip,
} from '@mantine/core';
import {
@@ -25,16 +24,32 @@ import {
SquarePlus,
UploadCloud,
} from 'lucide-react';
-import { notifications } from '@mantine/notifications';
-import dayjs from 'dayjs';
-
-import API from '../../api';
import ConfirmationDialog from '../ConfirmationDialog';
import useLocalStorage from '../../hooks/useLocalStorage';
import useWarningsStore from '../../store/warnings';
import { CustomTable, useTable } from '../tables/CustomTable';
import { validateCronExpression } from '../../utils/cronUtils';
import ScheduleInput from '../forms/ScheduleInput';
+import { showNotification } from '../../utils/notificationUtils.js';
+import { formatBytes } from '../../utils/networkUtils.js';
+import {
+ format,
+ getDefaultTimeZone,
+ useDateTimeFormat,
+} from '../../utils/dateTimeUtils.js';
+import {
+ createBackup,
+ DAYS_OF_WEEK,
+ deleteBackup,
+ downloadBackup,
+ getBackupSchedule,
+ listBackups,
+ restoreBackup,
+ to12Hour,
+ to24Hour,
+ updateBackupSchedule,
+ uploadBackup,
+} from '../../utils/components/backups/BackupManagerUtils.js';
const RowActions = ({
row,
@@ -81,58 +96,6 @@ const RowActions = ({
);
};
-// Convert 24h time string to 12h format with period
-function to12Hour(time24) {
- if (!time24) return { time: '12:00', period: 'AM' };
- const [hours, minutes] = time24.split(':').map(Number);
- const period = hours >= 12 ? 'PM' : 'AM';
- const hours12 = hours % 12 || 12;
- return {
- time: `${hours12}:${String(minutes).padStart(2, '0')}`,
- period,
- };
-}
-
-// Convert 12h time + period to 24h format
-function to24Hour(time12, period) {
- if (!time12) return '00:00';
- const [hours, minutes] = time12.split(':').map(Number);
- let hours24 = hours;
- if (period === 'PM' && hours !== 12) {
- hours24 = hours + 12;
- } else if (period === 'AM' && hours === 12) {
- hours24 = 0;
- }
- return `${String(hours24).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`;
-}
-
-// Get default timezone (same as Settings page)
-function getDefaultTimeZone() {
- try {
- return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
- } catch {
- return 'UTC';
- }
-}
-
-const DAYS_OF_WEEK = [
- { value: '0', label: 'Sunday' },
- { value: '1', label: 'Monday' },
- { value: '2', label: 'Tuesday' },
- { value: '3', label: 'Wednesday' },
- { value: '4', label: 'Thursday' },
- { value: '5', label: 'Friday' },
- { value: '6', label: 'Saturday' },
-];
-
-function formatBytes(bytes) {
- if (bytes === 0) return '0 B';
- const k = 1024;
- const sizes = ['B', 'KB', 'MB', 'GB'];
- const i = Math.floor(Math.log(bytes) / Math.log(k));
- return `${(bytes / Math.pow(k, i)).toFixed(2)} ${sizes[i]}`;
-}
-
export default function BackupManager() {
const [backups, setBackups] = useState([]);
const [loading, setLoading] = useState(false);
@@ -147,18 +110,9 @@ export default function BackupManager() {
const [deleting, setDeleting] = useState(false);
// Read user's preferences from settings
- const [timeFormat] = useLocalStorage('time-format', '12h');
- const [dateFormatSetting] = useLocalStorage('date-format', 'mdy');
+ const { fullDateTimeFormat, timeFormatSetting } = useDateTimeFormat();
const [userTimezone] = useLocalStorage('time-zone', getDefaultTimeZone());
- const is12Hour = timeFormat === '12h';
-
- // Format date according to user preferences
- const formatDate = (dateString) => {
- const date = dayjs(dateString);
- const datePart = dateFormatSetting === 'mdy' ? 'MM/DD/YYYY' : 'DD/MM/YYYY';
- const timePart = is12Hour ? 'h:mm:ss A' : 'HH:mm:ss';
- return date.format(`${datePart}, ${timePart}`);
- };
+ const is12Hour = timeFormatSetting === '12h';
// Warning suppression for confirmation dialogs
const suppressWarning = useWarningsStore((s) => s.suppressWarning);
@@ -213,7 +167,7 @@ export default function BackupManager() {
minSize: 180,
cell: ({ cell }) => (
- {formatDate(cell.getValue())}
+ {format(cell.getValue(), fullDateTimeFormat)}
),
},
@@ -267,10 +221,10 @@ export default function BackupManager() {
const loadBackups = async () => {
setLoading(true);
try {
- const backupList = await API.listBackups();
+ const backupList = await listBackups();
setBackups(backupList);
} catch (error) {
- notifications.show({
+ showNotification({
title: 'Error',
message: error?.message || 'Failed to load backups',
color: 'red',
@@ -283,7 +237,7 @@ export default function BackupManager() {
const loadSchedule = async () => {
setScheduleLoading(true);
try {
- const settings = await API.getBackupSchedule();
+ const settings = await getBackupSchedule();
setSchedule(settings);
setScheduleType(settings.cron_expression ? 'cron' : 'interval');
@@ -294,7 +248,7 @@ export default function BackupManager() {
setTimePeriod(period);
setScheduleChanged(false);
- } catch (error) {
+ } catch {
// Ignore errors on initial load - settings may not exist yet
} finally {
setScheduleLoading(false);
@@ -340,17 +294,17 @@ export default function BackupManager() {
? schedule
: { ...schedule, cron_expression: '' };
- const updated = await API.updateBackupSchedule(scheduleToSave);
+ const updated = await updateBackupSchedule(scheduleToSave);
setSchedule(updated);
setScheduleChanged(false);
- notifications.show({
+ showNotification({
title: 'Success',
message: 'Backup schedule saved',
color: 'green',
});
} catch (error) {
- notifications.show({
+ showNotification({
title: 'Error',
message: error?.message || 'Failed to save schedule',
color: 'red',
@@ -363,15 +317,15 @@ export default function BackupManager() {
const handleCreateBackup = async () => {
setCreating(true);
try {
- await API.createBackup();
- notifications.show({
+ await createBackup();
+ showNotification({
title: 'Success',
message: 'Backup created successfully',
color: 'green',
});
await loadBackups();
} catch (error) {
- notifications.show({
+ showNotification({
title: 'Error',
message: error?.message || 'Failed to create backup',
color: 'red',
@@ -384,14 +338,14 @@ export default function BackupManager() {
const handleDownload = async (filename) => {
setDownloading(filename);
try {
- await API.downloadBackup(filename);
- notifications.show({
+ await downloadBackup(filename);
+ showNotification({
title: 'Download Started',
message: `Downloading ${filename}...`,
color: 'blue',
});
} catch (error) {
- notifications.show({
+ showNotification({
title: 'Error',
message: error?.message || 'Failed to download backup',
color: 'red',
@@ -409,15 +363,15 @@ export default function BackupManager() {
const handleDeleteConfirm = async () => {
setDeleting(true);
try {
- await API.deleteBackup(selectedBackup.name);
- notifications.show({
+ await deleteBackup(selectedBackup.name);
+ showNotification({
title: 'Success',
message: 'Backup deleted successfully',
color: 'green',
});
await loadBackups();
} catch (error) {
- notifications.show({
+ showNotification({
title: 'Error',
message: error?.message || 'Failed to delete backup',
color: 'red',
@@ -437,8 +391,8 @@ export default function BackupManager() {
const handleRestoreConfirm = async () => {
setRestoring(true);
try {
- await API.restoreBackup(selectedBackup.name);
- notifications.show({
+ await restoreBackup(selectedBackup.name);
+ showNotification({
title: 'Restore Complete',
message:
'Backup restored successfully. A restart is recommended to ensure all services are running against the restored data.',
@@ -446,7 +400,7 @@ export default function BackupManager() {
});
setTimeout(() => window.location.reload(), 4000);
} catch (error) {
- notifications.show({
+ showNotification({
title: 'Error',
message: error?.message || 'Failed to restore backup',
color: 'red',
@@ -462,8 +416,8 @@ export default function BackupManager() {
if (!uploadFile) return;
try {
- await API.uploadBackup(uploadFile);
- notifications.show({
+ await uploadBackup(uploadFile);
+ showNotification({
title: 'Success',
message: 'Backup uploaded successfully',
color: 'green',
@@ -472,7 +426,7 @@ export default function BackupManager() {
setUploadFile(null);
await loadBackups();
} catch (error) {
- notifications.show({
+ showNotification({
title: 'Error',
message: error?.message || 'Failed to upload backup',
color: 'red',
diff --git a/frontend/src/components/backups/__tests__/BackupManager.test.jsx b/frontend/src/components/backups/__tests__/BackupManager.test.jsx
new file mode 100644
index 00000000..4fe1820c
--- /dev/null
+++ b/frontend/src/components/backups/__tests__/BackupManager.test.jsx
@@ -0,0 +1,1117 @@
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import BackupManager from '../BackupManager';
+
+// ── BackupManagerUtils ─────────────────────────────────────────────────────────
+vi.mock('../../../utils/components/backups/BackupManagerUtils.js', () => ({
+ listBackups: vi.fn(),
+ getBackupSchedule: vi.fn(),
+ updateBackupSchedule: vi.fn(),
+ createBackup: vi.fn(),
+ uploadBackup: vi.fn(),
+ downloadBackup: vi.fn(),
+ restoreBackup: vi.fn(),
+ deleteBackup: vi.fn(),
+ to12Hour: vi.fn(),
+ to24Hour: vi.fn(),
+ DAYS_OF_WEEK: [
+ { value: '0', label: 'Sunday' },
+ { value: '1', label: 'Monday' },
+ { value: '2', label: 'Tuesday' },
+ { value: '3', label: 'Wednesday' },
+ { value: '4', label: 'Thursday' },
+ { value: '5', label: 'Friday' },
+ { value: '6', label: 'Saturday' },
+ ],
+}));
+
+// ── hooks ──────────────────────────────────────────────────────────────────────
+vi.mock('../../../hooks/useLocalStorage', () => ({
+ default: vi.fn(() => ['UTC', vi.fn()]),
+}));
+
+// ── store ──────────────────────────────────────────────────────────────────────
+vi.mock('../../../store/warnings', () => ({
+ default: vi.fn((sel) => sel({ suppressWarning: vi.fn() })),
+}));
+
+// ── dateTimeUtils ──────────────────────────────────────────────────────────────
+vi.mock('../../../utils/dateTimeUtils.js', () => ({
+ format: vi.fn(() => '01/01/2024, 10:00:00 AM'),
+ getDefaultTimeZone: vi.fn(() => 'UTC'),
+ useDateTimeFormat: vi.fn(() => ({
+ fullDateTimeFormat: 'MM/DD/YYYY, HH:mm:ss',
+ timeFormatSetting: '24h',
+ })),
+}));
+
+// ── utility functions ──────────────────────────────────────────────────────────
+vi.mock('../../../utils/notificationUtils.js', () => ({
+ showNotification: vi.fn(),
+}));
+vi.mock('../../../utils/networkUtils.js', () => ({
+ formatBytes: vi.fn((bytes) => `${bytes} B`),
+}));
+vi.mock('../../../utils/cronUtils', () => ({
+ validateCronExpression: vi.fn(() => ({ valid: true })),
+}));
+
+// ── CustomTable ────────────────────────────────────────────────────────────────
+vi.mock('../../tables/CustomTable', () => ({
+ CustomTable: ({ table }) => (
+
+ {table.__rows?.map((row, i) => (
+
+ {table.__bodyCellRenderFns?.actions?.({
+ cell: { column: { id: 'actions' } },
+ row,
+ })}
+
+ ))}
+
+ ),
+ useTable: vi.fn(({ data, bodyCellRenderFns }) => ({
+ __rows: (data ?? []).map((item) => ({ original: item })),
+ __bodyCellRenderFns: bodyCellRenderFns,
+ })),
+}));
+
+// ── ScheduleInput ──────────────────────────────────────────────────────────────
+vi.mock('../../forms/ScheduleInput', () => ({
+ default: ({
+ children,
+ scheduleType,
+ onScheduleTypeChange,
+ cronValue,
+ onCronChange,
+ disabled,
+ }) => (
+
+ {scheduleType === 'cron' ? (
+ <>
+
+
+ >
+ ) : (
+ <>
+ {children}
+ {!disabled && (
+
+ )}
+ >
+ )}
+
+ ),
+}));
+
+// ── ConfirmationDialog ─────────────────────────────────────────────────────────
+vi.mock('../../ConfirmationDialog', () => ({
+ default: ({
+ opened,
+ onClose,
+ onConfirm,
+ title,
+ message,
+ confirmLabel,
+ cancelLabel,
+ loading,
+ }) =>
+ opened ? (
+
+
{title}
+
{message}
+
+
+
+ ) : null,
+}));
+
+// ── lucide-react ───────────────────────────────────────────────────────────────
+vi.mock('lucide-react', () => ({
+ Download: () =>
,
+ RefreshCcw: () =>
,
+ RotateCcw: () =>
,
+ SquareMinus: () =>
,
+ SquarePlus: () =>
,
+ UploadCloud: () =>
,
+}));
+
+// ── @mantine/core ──────────────────────────────────────────────────────────────
+vi.mock('@mantine/core', () => ({
+ ActionIcon: ({ children, onClick, color, loading, disabled }) => (
+
+ ),
+ Box: ({ children, style }) =>
{children}
,
+ Button: ({ children, onClick, disabled, loading, color, variant }) => (
+
+ ),
+ FileInput: ({ onChange, label, accept }) => (
+
+ ),
+ Flex: ({ children }) =>
{children}
,
+ Group: ({ children }) =>
{children}
,
+ Loader: ({ size }) =>
,
+ Modal: ({ children, opened, onClose, title }) =>
+ opened ? (
+
+
{title}
+
+ {children}
+
+ ) : null,
+ NumberInput: ({ value, onChange, label, description, min, disabled }) => (
+
+ ),
+ Paper: ({ children }) =>
{children}
,
+ Select: ({ value, onChange, label, data, disabled }) => (
+
+ ),
+ Stack: ({ children }) =>
{children}
,
+ Switch: ({ checked, onChange, label, disabled }) => (
+
+ ),
+ Text: ({ children, size, c, style }) => (
+
+ {children}
+
+ ),
+ Tooltip: ({ children, label }) =>
{children}
,
+}));
+
+// ── imports after mocks ────────────────────────────────────────────────────────
+import {
+ listBackups,
+ getBackupSchedule,
+ updateBackupSchedule,
+ createBackup,
+ uploadBackup,
+ downloadBackup,
+ restoreBackup,
+ deleteBackup,
+ to12Hour,
+ to24Hour,
+} from '../../../utils/components/backups/BackupManagerUtils.js';
+import { showNotification } from '../../../utils/notificationUtils.js';
+import { useDateTimeFormat } from '../../../utils/dateTimeUtils.js';
+
+// ── fixtures ───────────────────────────────────────────────────────────────────
+const defaultSchedule = {
+ enabled: true,
+ frequency: 'daily',
+ time: '03:00',
+ day_of_week: 0,
+ retention_count: 5,
+ cron_expression: '',
+};
+
+const defaultBackups = [
+ {
+ name: 'backup-2024-01-01.zip',
+ size: 1024000,
+ created: '2024-01-01T10:00:00Z',
+ },
+ {
+ name: 'backup-2024-01-02.zip',
+ size: 2048000,
+ created: '2024-01-02T10:00:00Z',
+ },
+];
+
+const setupMocks = ({ schedule = defaultSchedule, backups = [] } = {}) => {
+ vi.mocked(listBackups).mockResolvedValue(backups);
+ vi.mocked(getBackupSchedule).mockResolvedValue(schedule);
+ vi.mocked(updateBackupSchedule).mockResolvedValue({ ...schedule });
+ vi.mocked(createBackup).mockResolvedValue({});
+ vi.mocked(uploadBackup).mockResolvedValue({});
+ vi.mocked(downloadBackup).mockResolvedValue({});
+ vi.mocked(restoreBackup).mockResolvedValue({});
+ vi.mocked(deleteBackup).mockResolvedValue({});
+ vi.mocked(to12Hour).mockReturnValue({ time: '3:00', period: 'AM' });
+ vi.mocked(to24Hour).mockReturnValue('03:00');
+};
+
+/**
+ * Render BackupManager and wait for both initial API calls to settle.
+ * `to12Hour` is called inside `loadSchedule` after `getBackupSchedule` resolves,
+ * so its first invocation is a reliable "initial load complete" indicator.
+ */
+const renderAndLoad = async (opts = {}) => {
+ setupMocks(opts);
+ render(
);
+ await waitFor(() => expect(vi.mocked(to12Hour)).toHaveBeenCalled());
+};
+
+// ─────────────────────────────────────────────────────────────────────────────
+
+describe('BackupManager', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ Object.defineProperty(window, 'location', {
+ configurable: true,
+ writable: true,
+ value: { reload: vi.fn() },
+ });
+ });
+
+ // ── Initial render and data loading ────────────────────────────────────────
+
+ describe('initial data loading', () => {
+ it('renders "Scheduled Backups" heading', async () => {
+ await renderAndLoad();
+ expect(screen.getByText('Scheduled Backups')).toBeInTheDocument();
+ });
+
+ it('calls listBackups on mount', async () => {
+ await renderAndLoad();
+ expect(vi.mocked(listBackups)).toHaveBeenCalledTimes(1);
+ });
+
+ it('calls getBackupSchedule on mount', async () => {
+ await renderAndLoad();
+ expect(vi.mocked(getBackupSchedule)).toHaveBeenCalledTimes(1);
+ });
+
+ it('calls to12Hour with the loaded schedule time', async () => {
+ await renderAndLoad();
+ expect(vi.mocked(to12Hour)).toHaveBeenCalledWith(defaultSchedule.time);
+ });
+
+ it('sets scheduleType to "cron" when loaded schedule has a cron_expression', async () => {
+ await renderAndLoad({
+ schedule: { ...defaultSchedule, cron_expression: '0 3 * * *' },
+ });
+ expect(screen.getByTestId('cron-input')).toBeInTheDocument();
+ });
+
+ it('sets scheduleType to "interval" when loaded schedule has no cron_expression', async () => {
+ await renderAndLoad();
+ expect(screen.getByTestId('switch-to-cron')).toBeInTheDocument();
+ });
+
+ it('handles getBackupSchedule failure silently without showing a notification', async () => {
+ vi.mocked(getBackupSchedule).mockRejectedValue(
+ new Error('Network error')
+ );
+ vi.mocked(listBackups).mockResolvedValue([]);
+ vi.mocked(to12Hour).mockReturnValue({ time: '3:00', period: 'AM' });
+ render(
);
+ await waitFor(() => expect(vi.mocked(listBackups)).toHaveBeenCalled());
+ expect(vi.mocked(showNotification)).not.toHaveBeenCalled();
+ });
+
+ it('shows an error notification when listBackups fails', async () => {
+ vi.mocked(listBackups).mockRejectedValue(new Error('Failed'));
+ vi.mocked(getBackupSchedule).mockResolvedValue(defaultSchedule);
+ vi.mocked(to12Hour).mockReturnValue({ time: '3:00', period: 'AM' });
+ render(
);
+ await waitFor(() =>
+ expect(vi.mocked(showNotification)).toHaveBeenCalledWith(
+ expect.objectContaining({ title: 'Error', color: 'red' })
+ )
+ );
+ });
+ });
+
+ // ── Backup list display ─────────────────────────────────────────────────────
+
+ describe('backup list display', () => {
+ it('shows "No backups found" when list is empty', async () => {
+ await renderAndLoad({ backups: [] });
+ expect(screen.getByText(/No backups found/)).toBeInTheDocument();
+ });
+
+ it('renders CustomTable when backups are present', async () => {
+ await renderAndLoad({ backups: defaultBackups });
+ expect(screen.getByTestId('custom-table')).toBeInTheDocument();
+ });
+
+ it('renders one table row per backup', async () => {
+ await renderAndLoad({ backups: defaultBackups });
+ expect(screen.getAllByTestId('table-row')).toHaveLength(
+ defaultBackups.length
+ );
+ });
+
+ it('does not render CustomTable when list is empty', async () => {
+ await renderAndLoad({ backups: [] });
+ expect(screen.queryByTestId('custom-table')).not.toBeInTheDocument();
+ });
+ });
+
+ // ── Create backup ───────────────────────────────────────────────────────────
+
+ describe('create backup', () => {
+ it('calls createBackup when "Create Backup" button is clicked', async () => {
+ await renderAndLoad();
+ fireEvent.click(screen.getByText('Create Backup'));
+ await waitFor(() =>
+ expect(vi.mocked(createBackup)).toHaveBeenCalledTimes(1)
+ );
+ });
+
+ it('shows success notification after creating backup', async () => {
+ await renderAndLoad();
+ fireEvent.click(screen.getByText('Create Backup'));
+ await waitFor(() =>
+ expect(vi.mocked(showNotification)).toHaveBeenCalledWith(
+ expect.objectContaining({ title: 'Success', color: 'green' })
+ )
+ );
+ });
+
+ it('refreshes backup list after creating backup', async () => {
+ await renderAndLoad();
+ vi.mocked(listBackups).mockClear();
+ fireEvent.click(screen.getByText('Create Backup'));
+ await waitFor(() =>
+ expect(vi.mocked(listBackups)).toHaveBeenCalledTimes(1)
+ );
+ });
+
+ it('shows error notification when createBackup fails', async () => {
+ await renderAndLoad();
+ vi.mocked(createBackup).mockRejectedValue(new Error('Server error'));
+ fireEvent.click(screen.getByText('Create Backup'));
+ await waitFor(() =>
+ expect(vi.mocked(showNotification)).toHaveBeenCalledWith(
+ expect.objectContaining({ title: 'Error', color: 'red' })
+ )
+ );
+ });
+ });
+
+ // ── Refresh ─────────────────────────────────────────────────────────────────
+
+ describe('refresh', () => {
+ it('calls listBackups again when Refresh is clicked', async () => {
+ await renderAndLoad();
+ vi.mocked(listBackups).mockClear();
+ fireEvent.click(screen.getByText('Refresh'));
+ await waitFor(() =>
+ expect(vi.mocked(listBackups)).toHaveBeenCalledTimes(1)
+ );
+ });
+ });
+
+ // ── Download backup ─────────────────────────────────────────────────────────
+
+ describe('download backup', () => {
+ it('calls downloadBackup with the correct filename', async () => {
+ await renderAndLoad({ backups: defaultBackups });
+ const downloadBtn = screen
+ .getAllByTestId('icon-download')[0]
+ .closest('button');
+ fireEvent.click(downloadBtn);
+ await waitFor(() =>
+ expect(vi.mocked(downloadBackup)).toHaveBeenCalledWith(
+ defaultBackups[0].name
+ )
+ );
+ });
+
+ it('shows "Download Started" notification', async () => {
+ await renderAndLoad({ backups: defaultBackups });
+ const downloadBtn = screen
+ .getAllByTestId('icon-download')[0]
+ .closest('button');
+ fireEvent.click(downloadBtn);
+ await waitFor(() =>
+ expect(vi.mocked(showNotification)).toHaveBeenCalledWith(
+ expect.objectContaining({ title: 'Download Started', color: 'blue' })
+ )
+ );
+ });
+
+ it('shows error notification when downloadBackup fails', async () => {
+ await renderAndLoad({ backups: defaultBackups });
+ vi.mocked(downloadBackup).mockRejectedValue(new Error('Network error'));
+ const downloadBtn = screen
+ .getAllByTestId('icon-download')[0]
+ .closest('button');
+ fireEvent.click(downloadBtn);
+ await waitFor(() =>
+ expect(vi.mocked(showNotification)).toHaveBeenCalledWith(
+ expect.objectContaining({ title: 'Error', color: 'red' })
+ )
+ );
+ });
+ });
+
+ // ── Delete backup ───────────────────────────────────────────────────────────
+
+ describe('delete backup', () => {
+ const openDeleteDialog = async () => {
+ await renderAndLoad({ backups: defaultBackups });
+ const deleteBtn = screen
+ .getAllByTestId('icon-delete')[0]
+ .closest('button');
+ fireEvent.click(deleteBtn);
+ };
+
+ it('opens delete ConfirmationDialog when delete action is clicked', async () => {
+ await openDeleteDialog();
+ expect(screen.getByTestId('dialog-title')).toHaveTextContent(
+ 'Delete Backup'
+ );
+ });
+
+ it('shows the backup filename in the delete dialog message', async () => {
+ await openDeleteDialog();
+ expect(screen.getByTestId('dialog-message')).toHaveTextContent(
+ defaultBackups[0].name
+ );
+ });
+
+ it('calls deleteBackup with the filename when confirmed', async () => {
+ await openDeleteDialog();
+ fireEvent.click(screen.getByTestId('dialog-confirm'));
+ await waitFor(() =>
+ expect(vi.mocked(deleteBackup)).toHaveBeenCalledWith(
+ defaultBackups[0].name
+ )
+ );
+ });
+
+ it('refreshes backup list after deletion', async () => {
+ await openDeleteDialog();
+ vi.mocked(listBackups).mockClear();
+ fireEvent.click(screen.getByTestId('dialog-confirm'));
+ await waitFor(() =>
+ expect(vi.mocked(listBackups)).toHaveBeenCalledTimes(1)
+ );
+ });
+
+ it('closes dialog after confirming deletion', async () => {
+ await openDeleteDialog();
+ fireEvent.click(screen.getByTestId('dialog-confirm'));
+ await waitFor(() =>
+ expect(
+ screen.queryByTestId('confirmation-dialog')
+ ).not.toBeInTheDocument()
+ );
+ });
+
+ it('closes dialog when Cancel is clicked', async () => {
+ await openDeleteDialog();
+ fireEvent.click(screen.getByTestId('dialog-cancel'));
+ expect(
+ screen.queryByTestId('confirmation-dialog')
+ ).not.toBeInTheDocument();
+ });
+
+ it('shows error notification when deleteBackup fails', async () => {
+ await openDeleteDialog();
+ vi.mocked(deleteBackup).mockRejectedValue(new Error('Server error'));
+ fireEvent.click(screen.getByTestId('dialog-confirm'));
+ await waitFor(() =>
+ expect(vi.mocked(showNotification)).toHaveBeenCalledWith(
+ expect.objectContaining({ title: 'Error', color: 'red' })
+ )
+ );
+ });
+ });
+
+ // ── Restore backup ──────────────────────────────────────────────────────────
+
+ describe('restore backup', () => {
+ const openRestoreDialog = async () => {
+ await renderAndLoad({ backups: defaultBackups });
+ const restoreBtn = screen
+ .getAllByTestId('icon-restore')[0]
+ .closest('button');
+ fireEvent.click(restoreBtn);
+ };
+
+ it('opens restore ConfirmationDialog when restore action is clicked', async () => {
+ await openRestoreDialog();
+ expect(screen.getByTestId('dialog-title')).toHaveTextContent(
+ 'Restore Backup'
+ );
+ });
+
+ it('shows the backup filename in the restore dialog message', async () => {
+ await openRestoreDialog();
+ expect(screen.getByTestId('dialog-message')).toHaveTextContent(
+ defaultBackups[0].name
+ );
+ });
+
+ it('calls restoreBackup with the filename when confirmed', async () => {
+ await openRestoreDialog();
+ fireEvent.click(screen.getByTestId('dialog-confirm'));
+ await waitFor(() =>
+ expect(vi.mocked(restoreBackup)).toHaveBeenCalledWith(
+ defaultBackups[0].name
+ )
+ );
+ });
+
+ it('shows "Restore Complete" notification after successful restore', async () => {
+ await openRestoreDialog();
+ fireEvent.click(screen.getByTestId('dialog-confirm'));
+ await waitFor(() =>
+ expect(vi.mocked(showNotification)).toHaveBeenCalledWith(
+ expect.objectContaining({ title: 'Restore Complete', color: 'green' })
+ )
+ );
+ });
+
+ it('schedules window.location.reload 4 seconds after restore', async () => {
+ const setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout');
+ await openRestoreDialog();
+ fireEvent.click(screen.getByTestId('dialog-confirm'));
+ await waitFor(() => expect(vi.mocked(restoreBackup)).toHaveBeenCalled());
+ expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 4000);
+ setTimeoutSpy.mockRestore();
+ });
+
+ it('closes dialog when Cancel is clicked', async () => {
+ await openRestoreDialog();
+ fireEvent.click(screen.getByTestId('dialog-cancel'));
+ expect(
+ screen.queryByTestId('confirmation-dialog')
+ ).not.toBeInTheDocument();
+ });
+
+ it('shows error notification when restoreBackup fails', async () => {
+ await openRestoreDialog();
+ vi.mocked(restoreBackup).mockRejectedValue(new Error('Restore failed'));
+ fireEvent.click(screen.getByTestId('dialog-confirm'));
+ await waitFor(() =>
+ expect(vi.mocked(showNotification)).toHaveBeenCalledWith(
+ expect.objectContaining({ title: 'Error', color: 'red' })
+ )
+ );
+ });
+ });
+
+ // ── Upload backup ───────────────────────────────────────────────────────────
+
+ describe('upload backup', () => {
+ it('opens upload modal when Upload button is clicked', async () => {
+ await renderAndLoad();
+ fireEvent.click(screen.getByText('Upload'));
+ expect(screen.getByTestId('modal-title')).toHaveTextContent(
+ 'Upload Backup'
+ );
+ });
+
+ it('closes upload modal when × is clicked', async () => {
+ await renderAndLoad();
+ fireEvent.click(screen.getByText('Upload'));
+ fireEvent.click(screen.getByTestId('modal-close'));
+ expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
+ });
+
+ it('closes upload modal when Cancel is clicked', async () => {
+ await renderAndLoad();
+ fireEvent.click(screen.getByText('Upload'));
+ fireEvent.click(screen.getByText('Cancel'));
+ expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
+ });
+
+ it('submit Upload button is disabled when no file is selected', async () => {
+ await renderAndLoad();
+ fireEvent.click(screen.getByText('Upload'));
+ // First "Upload" opens the modal; second is the submit button inside the modal
+ const submitBtn = screen.getAllByText('Upload')[1];
+ expect(submitBtn).toBeDisabled();
+ });
+
+ it('calls uploadBackup with the selected file when submitted', async () => {
+ await renderAndLoad();
+ fireEvent.click(screen.getByText('Upload'));
+
+ const file = new File(['backup data'], 'backup.zip', {
+ type: 'application/zip',
+ });
+ const fileInput = screen.getByTestId('file-input');
+ Object.defineProperty(fileInput, 'files', {
+ value: [file],
+ configurable: true,
+ });
+ fireEvent.change(fileInput);
+
+ fireEvent.click(screen.getAllByText('Upload')[1]);
+ await waitFor(() =>
+ expect(vi.mocked(uploadBackup)).toHaveBeenCalledWith(file)
+ );
+ });
+
+ it('closes modal and refreshes list after successful upload', async () => {
+ await renderAndLoad();
+ fireEvent.click(screen.getByText('Upload'));
+
+ const file = new File(['data'], 'backup.zip', {
+ type: 'application/zip',
+ });
+ const fileInput = screen.getByTestId('file-input');
+ Object.defineProperty(fileInput, 'files', {
+ value: [file],
+ configurable: true,
+ });
+ fireEvent.change(fileInput);
+
+ vi.mocked(listBackups).mockClear();
+ fireEvent.click(screen.getAllByText('Upload')[1]);
+
+ await waitFor(() => {
+ expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
+ expect(vi.mocked(listBackups)).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ it('shows success notification after upload', async () => {
+ await renderAndLoad();
+ fireEvent.click(screen.getByText('Upload'));
+
+ const file = new File(['data'], 'backup.zip', {
+ type: 'application/zip',
+ });
+ const fileInput = screen.getByTestId('file-input');
+ Object.defineProperty(fileInput, 'files', {
+ value: [file],
+ configurable: true,
+ });
+ fireEvent.change(fileInput);
+ fireEvent.click(screen.getAllByText('Upload')[1]);
+
+ await waitFor(() =>
+ expect(vi.mocked(showNotification)).toHaveBeenCalledWith(
+ expect.objectContaining({ title: 'Success', color: 'green' })
+ )
+ );
+ });
+
+ it('shows error notification when uploadBackup fails', async () => {
+ await renderAndLoad();
+ vi.mocked(uploadBackup).mockRejectedValue(new Error('Upload failed'));
+ fireEvent.click(screen.getByText('Upload'));
+
+ const file = new File(['data'], 'backup.zip', {
+ type: 'application/zip',
+ });
+ const fileInput = screen.getByTestId('file-input');
+ Object.defineProperty(fileInput, 'files', {
+ value: [file],
+ configurable: true,
+ });
+ fireEvent.change(fileInput);
+ fireEvent.click(screen.getAllByText('Upload')[1]);
+
+ await waitFor(() =>
+ expect(vi.mocked(showNotification)).toHaveBeenCalledWith(
+ expect.objectContaining({ title: 'Error', color: 'red' })
+ )
+ );
+ });
+ });
+
+ // ── Schedule saving ─────────────────────────────────────────────────────────
+
+ describe('schedule saving', () => {
+ it('Save button is disabled before any schedule field changes', async () => {
+ await renderAndLoad();
+ expect(screen.getByText('Save')).toBeDisabled();
+ });
+
+ it('Save button becomes enabled after a schedule field changes', async () => {
+ await renderAndLoad();
+ fireEvent.change(screen.getByLabelText('Frequency'), {
+ target: { value: 'weekly' },
+ });
+ expect(screen.getByText('Save')).not.toBeDisabled();
+ });
+
+ it('calls updateBackupSchedule when Save is clicked', async () => {
+ await renderAndLoad();
+ fireEvent.change(screen.getByLabelText('Frequency'), {
+ target: { value: 'weekly' },
+ });
+ fireEvent.click(screen.getByText('Save'));
+ await waitFor(() =>
+ expect(vi.mocked(updateBackupSchedule)).toHaveBeenCalledTimes(1)
+ );
+ });
+
+ it('sends schedule with an empty cron_expression in interval mode', async () => {
+ await renderAndLoad();
+ fireEvent.change(screen.getByLabelText('Frequency'), {
+ target: { value: 'weekly' },
+ });
+ fireEvent.click(screen.getByText('Save'));
+ await waitFor(() =>
+ expect(vi.mocked(updateBackupSchedule)).toHaveBeenCalledWith(
+ expect.objectContaining({ cron_expression: '' })
+ )
+ );
+ });
+
+ it('sends schedule with cron_expression intact in cron mode', async () => {
+ await renderAndLoad({
+ schedule: { ...defaultSchedule, cron_expression: '0 3 * * *' },
+ });
+ fireEvent.change(screen.getByTestId('cron-input'), {
+ target: { value: '0 4 * * *' },
+ });
+ fireEvent.click(screen.getByText('Save'));
+ await waitFor(() =>
+ expect(vi.mocked(updateBackupSchedule)).toHaveBeenCalledWith(
+ expect.objectContaining({ cron_expression: '0 4 * * *' })
+ )
+ );
+ });
+
+ it('shows success notification after saving schedule', async () => {
+ await renderAndLoad();
+ fireEvent.change(screen.getByLabelText('Frequency'), {
+ target: { value: 'weekly' },
+ });
+ fireEvent.click(screen.getByText('Save'));
+ await waitFor(() =>
+ expect(vi.mocked(showNotification)).toHaveBeenCalledWith(
+ expect.objectContaining({
+ title: 'Success',
+ message: 'Backup schedule saved',
+ })
+ )
+ );
+ });
+
+ it('shows error notification when saving schedule fails', async () => {
+ await renderAndLoad();
+ vi.mocked(updateBackupSchedule).mockRejectedValue(
+ new Error('Save failed')
+ );
+ fireEvent.change(screen.getByLabelText('Frequency'), {
+ target: { value: 'weekly' },
+ });
+ fireEvent.click(screen.getByText('Save'));
+ await waitFor(() =>
+ expect(vi.mocked(showNotification)).toHaveBeenCalledWith(
+ expect.objectContaining({ title: 'Error', color: 'red' })
+ )
+ );
+ });
+
+ it('Save button is disabled again after a successful save', async () => {
+ await renderAndLoad();
+ fireEvent.change(screen.getByLabelText('Frequency'), {
+ target: { value: 'weekly' },
+ });
+ fireEvent.click(screen.getByText('Save'));
+ await waitFor(() =>
+ expect(vi.mocked(updateBackupSchedule)).toHaveBeenCalled()
+ );
+ await waitFor(() => expect(screen.getByText('Save')).toBeDisabled());
+ });
+ });
+
+ // ── Schedule enabled switch ─────────────────────────────────────────────────
+
+ describe('schedule enabled switch', () => {
+ it('shows "Enabled" label when schedule.enabled is true', async () => {
+ await renderAndLoad({
+ schedule: { ...defaultSchedule, enabled: true },
+ });
+ expect(screen.getByText('Enabled')).toBeInTheDocument();
+ });
+
+ it('shows "Disabled" label when schedule.enabled is false', async () => {
+ await renderAndLoad({
+ schedule: { ...defaultSchedule, enabled: false },
+ });
+ expect(screen.getByText('Disabled')).toBeInTheDocument();
+ });
+
+ it('toggles label from "Enabled" to "Disabled" when switch is unchecked', async () => {
+ await renderAndLoad({
+ schedule: { ...defaultSchedule, enabled: true },
+ });
+ fireEvent.click(screen.getByTestId('schedule-switch'));
+ await waitFor(() =>
+ expect(screen.getByText('Disabled')).toBeInTheDocument()
+ );
+ });
+
+ it('toggles label from "Disabled" to "Enabled" when switch is checked', async () => {
+ await renderAndLoad({
+ schedule: { ...defaultSchedule, enabled: false },
+ });
+ fireEvent.click(screen.getByTestId('schedule-switch'));
+ await waitFor(() =>
+ expect(screen.getByText('Enabled')).toBeInTheDocument()
+ );
+ });
+ });
+
+ // ── 12-hour vs 24-hour time display ─────────────────────────────────────────
+
+ describe('time format display', () => {
+ it('shows Hour, Minute, and Period selects in 12h mode', async () => {
+ vi.mocked(useDateTimeFormat).mockReturnValue({
+ fullDateTimeFormat: 'MM/DD/YYYY, h:mm:ss A',
+ timeFormatSetting: '12h',
+ });
+ setupMocks();
+ render(
);
+ await waitFor(() => expect(vi.mocked(to12Hour)).toHaveBeenCalled());
+
+ expect(screen.getByLabelText('Hour')).toBeInTheDocument();
+ expect(screen.getByLabelText('Minute')).toBeInTheDocument();
+ expect(screen.getByLabelText('Period')).toBeInTheDocument();
+ });
+
+ it('does not show a Period select in 24h mode', async () => {
+ vi.mocked(useDateTimeFormat).mockReturnValue({
+ fullDateTimeFormat: 'MM/DD/YYYY, HH:mm:ss',
+ timeFormatSetting: '24h',
+ });
+ await renderAndLoad();
+ expect(screen.queryByLabelText('Period')).not.toBeInTheDocument();
+ });
+
+ it('Hour select in 24h mode contains 24 options (00–23)', async () => {
+ vi.mocked(useDateTimeFormat).mockReturnValue({
+ fullDateTimeFormat: 'MM/DD/YYYY, HH:mm:ss',
+ timeFormatSetting: '24h',
+ });
+ await renderAndLoad();
+ const hourSelect = screen.getByLabelText('Hour');
+ expect(hourSelect.querySelectorAll('option')).toHaveLength(24);
+ });
+
+ it('Hour select in 12h mode contains 12 options (1–12)', async () => {
+ vi.mocked(useDateTimeFormat).mockReturnValue({
+ fullDateTimeFormat: 'MM/DD/YYYY, h:mm:ss A',
+ timeFormatSetting: '12h',
+ });
+ setupMocks();
+ render(
);
+ await waitFor(() => expect(vi.mocked(to12Hour)).toHaveBeenCalled());
+
+ const hourSelect = screen.getByLabelText('Hour');
+ expect(hourSelect.querySelectorAll('option')).toHaveLength(12);
+ });
+
+ it('Minute select contains 60 options (00–59)', async () => {
+ await renderAndLoad();
+ const minuteSelect = screen.getByLabelText('Minute');
+ expect(minuteSelect.querySelectorAll('option')).toHaveLength(60);
+ });
+ });
+
+ // ── Weekly frequency / Day selector ─────────────────────────────────────────
+
+ describe('weekly frequency', () => {
+ it('shows Day select when frequency is "weekly"', async () => {
+ await renderAndLoad({
+ schedule: { ...defaultSchedule, frequency: 'weekly' },
+ });
+ expect(screen.getByLabelText('Day')).toBeInTheDocument();
+ });
+
+ it('does not show Day select when frequency is "daily"', async () => {
+ await renderAndLoad({
+ schedule: { ...defaultSchedule, frequency: 'daily' },
+ });
+ expect(screen.queryByLabelText('Day')).not.toBeInTheDocument();
+ });
+
+ it('shows Day select after switching from daily to weekly', async () => {
+ await renderAndLoad({
+ schedule: { ...defaultSchedule, frequency: 'daily' },
+ });
+ fireEvent.change(screen.getByLabelText('Frequency'), {
+ target: { value: 'weekly' },
+ });
+ expect(screen.getByLabelText('Day')).toBeInTheDocument();
+ });
+
+ it('hides Day select after switching from weekly to daily', async () => {
+ await renderAndLoad({
+ schedule: { ...defaultSchedule, frequency: 'weekly' },
+ });
+ fireEvent.change(screen.getByLabelText('Frequency'), {
+ target: { value: 'daily' },
+ });
+ expect(screen.queryByLabelText('Day')).not.toBeInTheDocument();
+ });
+
+ it('Day select contains all 7 days of the week', async () => {
+ await renderAndLoad({
+ schedule: { ...defaultSchedule, frequency: 'weekly' },
+ });
+ expect(
+ screen.getByLabelText('Day').querySelectorAll('option')
+ ).toHaveLength(7);
+ });
+ });
+
+ // ── Timezone info text ───────────────────────────────────────────────────────
+
+ describe('timezone info text', () => {
+ it('shows timezone info when schedule is enabled and in interval mode', async () => {
+ await renderAndLoad({
+ schedule: { ...defaultSchedule, enabled: true, cron_expression: '' },
+ });
+ expect(screen.getByText(/System Timezone/)).toBeInTheDocument();
+ });
+
+ it('does not show timezone info when schedule is disabled', async () => {
+ await renderAndLoad({
+ schedule: { ...defaultSchedule, enabled: false, cron_expression: '' },
+ });
+ expect(screen.queryByText(/System Timezone/)).not.toBeInTheDocument();
+ });
+
+ it('does not show timezone info in cron mode', async () => {
+ await renderAndLoad({
+ schedule: {
+ ...defaultSchedule,
+ enabled: true,
+ cron_expression: '0 3 * * *',
+ },
+ });
+ expect(screen.queryByText(/System Timezone/)).not.toBeInTheDocument();
+ });
+
+ it('includes the user timezone string in the info text', async () => {
+ await renderAndLoad({
+ schedule: { ...defaultSchedule, enabled: true, cron_expression: '' },
+ });
+ // useLocalStorage mock returns 'UTC'
+ expect(screen.getByText(/UTC/)).toBeInTheDocument();
+ });
+ });
+
+ // ── Schedule type switching ──────────────────────────────────────────────────
+
+ describe('schedule type switching', () => {
+ it('switches to cron mode when "Use custom cron schedule" is clicked', async () => {
+ await renderAndLoad();
+ fireEvent.click(screen.getByTestId('switch-to-cron'));
+ expect(screen.getByTestId('cron-input')).toBeInTheDocument();
+ });
+
+ it('switches back to interval mode when "Use simple schedule" is clicked', async () => {
+ await renderAndLoad({
+ schedule: { ...defaultSchedule, cron_expression: '0 3 * * *' },
+ });
+ fireEvent.click(screen.getByTestId('switch-to-interval'));
+ expect(screen.queryByTestId('cron-input')).not.toBeInTheDocument();
+ expect(screen.getByTestId('switch-to-cron')).toBeInTheDocument();
+ });
+
+ it('clears cron_expression when switching back to interval and saving', async () => {
+ await renderAndLoad({
+ schedule: { ...defaultSchedule, cron_expression: '0 3 * * *' },
+ });
+ fireEvent.click(screen.getByTestId('switch-to-interval'));
+ fireEvent.click(screen.getByText('Save'));
+ await waitFor(() =>
+ expect(vi.mocked(updateBackupSchedule)).toHaveBeenCalledWith(
+ expect.objectContaining({ cron_expression: '' })
+ )
+ );
+ });
+ });
+});
diff --git a/frontend/src/components/cards/AvailablePluginCard.jsx b/frontend/src/components/cards/AvailablePluginCard.jsx
index 7e594428..497a5435 100644
--- a/frontend/src/components/cards/AvailablePluginCard.jsx
+++ b/frontend/src/components/cards/AvailablePluginCard.jsx
@@ -1,6 +1,5 @@
import React, { useState } from 'react';
import {
- ActionIcon,
Avatar,
Badge,
Box,
@@ -33,12 +32,20 @@ import {
PluginSecurityWarning,
PluginSupportDisclaimer,
} from '../PluginWarnings.jsx';
-import { useNavigate, useLocation } from 'react-router-dom';
-import API from '../../api';
+import { useLocation, useNavigate } from 'react-router-dom';
import { usePluginStore } from '../../store/plugins';
import PluginDetailPanel from '../PluginDetailPanel.jsx';
-import { compareVersions } from '../pluginUtils.js';
+import {
+ buildCompatibilityTooltip,
+ compareVersions,
+ getInstallInfo,
+} from '../../utils/components/pluginUtils.js';
import SizedInstallButton from '../theme/SizedInstallButton.jsx';
+import {
+ deletePluginByKey,
+ getPluginDetailManifest,
+ setPluginEnabled,
+} from '../../utils/pages/PluginsUtils.js';
const RepoBadge = ({ isOfficial, repoName, signatureVerified }) => {
if (isOfficial) {
@@ -315,7 +322,7 @@ const AvailablePluginCard = ({
if (enableNow && installedKey) {
setEnabling(true);
try {
- await API.setPluginEnabled(installedKey, true);
+ await setPluginEnabled(installedKey, true);
} finally {
setEnabling(false);
}
@@ -329,7 +336,7 @@ const AvailablePluginCard = ({
if (!key) return;
setUninstalling(true);
try {
- const resp = await API.deletePlugin(key);
+ const resp = await deletePluginByKey(key);
if (resp?.success) {
onUninstalled?.(plugin.slug);
usePluginStore.getState().invalidatePlugins();
@@ -375,7 +382,7 @@ const AvailablePluginCard = ({
return;
}
setDetailLoading(true);
- const result = await API.getPluginDetailManifest(
+ const result = await getPluginDetailManifest(
plugin.repo_id,
plugin.manifest_url
);
@@ -530,17 +537,17 @@ const AvailablePluginCard = ({
{!meetsVersion &&
(() => {
- const parts = [];
- if (!meetsMinVersion)
- parts.push(`${plugin.min_dispatcharr_version} or newer`);
- if (!meetsMaxVersion)
- parts.push(`${plugin.max_dispatcharr_version} or older`);
+ const tooltip = buildCompatibilityTooltip(
+ meetsMinVersion,
+ plugin,
+ meetsMaxVersion
+ );
const label = !meetsMinVersion
? `Min ${plugin.min_dispatcharr_version}`
: `Max ${plugin.max_dispatcharr_version}`;
return (
{
- const isDowngrade =
- pendingInstall &&
- plugin.installed_version &&
- compareVersions(pendingInstall.version, plugin.installed_version) < 0;
- const isUpdate =
- pendingInstall &&
- plugin.installed_version &&
- !isDowngrade &&
- compareVersions(pendingInstall.version, plugin.installed_version) > 0;
- const isBadSig = plugin.signature_verified === false;
+ const { isDowngrade, isUpdate, isBadSig } = getInstallInfo(
+ pendingInstall,
+ plugin
+ );
const actionLabel = isDowngrade
? 'Downgrade'
: isUpdate
diff --git a/frontend/src/components/cards/PluginCard.jsx b/frontend/src/components/cards/PluginCard.jsx
index f1b5d5f6..76b9da26 100644
--- a/frontend/src/components/cards/PluginCard.jsx
+++ b/frontend/src/components/cards/PluginCard.jsx
@@ -17,14 +17,24 @@ import {
Text,
Tooltip,
} from '@mantine/core';
-import { Ban, Check, Download, FlaskConical, Info, RefreshCw, Settings, Trash2, Zap } from 'lucide-react';
+import {
+ Ban,
+ Check,
+ Download,
+ FlaskConical,
+ Info,
+ RefreshCw,
+ Settings,
+ Trash2,
+ Zap,
+} from 'lucide-react';
import { getConfirmationDetails } from '../../utils/cards/PluginCardUtils.js';
import { SUBSCRIPTION_EVENTS } from '../../constants.js';
import useSettingsStore from '../../store/settings.jsx';
import { usePluginStore } from '../../store/plugins.jsx';
import API from '../../api';
import PluginDetailPanel from '../PluginDetailPanel.jsx';
-import { compareVersions } from '../pluginUtils.js';
+import { compareVersions } from '../../utils/components/pluginUtils.js';
import {
PluginDowngradeWarning,
PluginSecurityWarning,
@@ -65,7 +75,12 @@ const PluginActionList = ({
Event Triggers
{events.map((event) => (
-
+
{SUBSCRIPTION_EVENTS[event] || event}
))}
@@ -154,19 +169,28 @@ const PluginCard = ({
const fetchDetail = async () => {
if (detailLoading || !isManaged) return;
// Find the available plugin entry for manifest_url
- let avail = usePluginStore.getState().availablePlugins.find(
- (ap) => ap.slug === plugin.slug && ap.repo_id === plugin.source_repo
- );
+ let avail = usePluginStore
+ .getState()
+ .availablePlugins.find(
+ (ap) => ap.slug === plugin.slug && ap.repo_id === plugin.source_repo
+ );
if (!avail) {
setDetailLoading(true);
try {
await usePluginStore.getState().fetchAvailablePlugins();
- avail = usePluginStore.getState().availablePlugins.find(
- (ap) => ap.slug === plugin.slug && ap.repo_id === plugin.source_repo
- );
- } catch { /* ignore */ }
+ avail = usePluginStore
+ .getState()
+ .availablePlugins.find(
+ (ap) => ap.slug === plugin.slug && ap.repo_id === plugin.source_repo
+ );
+ } catch {
+ /* ignore */
+ }
+ }
+ if (!avail) {
+ setDetailLoading(false);
+ return;
}
- if (!avail) { setDetailLoading(false); return; }
if (!avail.manifest_url) {
// Synthesize from top-level entry
setDetail({
@@ -177,16 +201,22 @@ const PluginCard = ({
repo_url: avail.repo_url,
discord_thread: avail.discord_thread,
registry_url: avail.registry_url,
- versions: avail.latest_version ? [{
- version: avail.latest_version,
- url: avail.latest_url,
- checksum_sha256: avail.latest_sha256,
- min_dispatcharr_version: avail.min_dispatcharr_version,
- max_dispatcharr_version: avail.max_dispatcharr_version,
- build_timestamp: avail.last_updated,
- size: avail.latest_size,
- }] : [],
- latest: avail.latest_version ? { version: avail.latest_version } : null,
+ versions: avail.latest_version
+ ? [
+ {
+ version: avail.latest_version,
+ url: avail.latest_url,
+ checksum_sha256: avail.latest_sha256,
+ min_dispatcharr_version: avail.min_dispatcharr_version,
+ max_dispatcharr_version: avail.max_dispatcharr_version,
+ build_timestamp: avail.last_updated,
+ size: avail.latest_size,
+ },
+ ]
+ : [],
+ latest: avail.latest_version
+ ? { version: avail.latest_version }
+ : null,
},
signature_verified: avail.signature_verified ?? null,
_avail: avail,
@@ -197,7 +227,10 @@ const PluginCard = ({
}
setDetailLoading(true);
try {
- const result = await API.getPluginDetailManifest(avail.repo_id, avail.manifest_url);
+ const result = await API.getPluginDetailManifest(
+ avail.repo_id,
+ avail.manifest_url
+ );
if (result) {
setDetail({ ...result, _avail: avail });
if (result.manifest?.versions?.length) {
@@ -326,7 +359,8 @@ const PluginCard = ({
if (!pendingInstallParams) return;
const params = pendingInstallParams;
const selVer = params.version;
- const isDown = plugin.version && compareVersions(selVer, plugin.version) < 0;
+ const isDown =
+ plugin.version && compareVersions(selVer, plugin.version) < 0;
const action = isDown ? 'downgrade' : 'update';
setInstallConfirmOpen(false);
setPendingInstallParams(null);
@@ -367,7 +401,12 @@ const PluginCard = ({
>
{/* Header: avatar, name/author, badges, toggle */}
-
+
openModal('details') : undefined}
- style={{ minWidth: 0, maxWidth: '100%', ...(isManaged ? { cursor: 'pointer' } : {}) }}
+ style={{
+ minWidth: 0,
+ maxWidth: '100%',
+ ...(isManaged ? { cursor: 'pointer' } : {}),
+ }}
>
{plugin.author}
@@ -415,12 +458,26 @@ const PluginCard = ({
{plugin.is_managed && plugin.installed_version_is_prerelease ? (
-
+
: plugin.deprecated ? : }
+ leftSection={
+ detailLoading ? (
+
+ ) : plugin.deprecated ? (
+
+ ) : (
+
+ )
+ }
style={{ cursor: 'pointer' }}
onClick={() => openModal('details')}
>
@@ -428,12 +485,26 @@ const PluginCard = ({
) : plugin.update_available ? (
-
+
: plugin.deprecated ? : }
+ leftSection={
+ detailLoading ? (
+
+ ) : plugin.deprecated ? (
+
+ ) : (
+
+ )
+ }
style={{ cursor: 'pointer' }}
onClick={() => openModal('details')}
>
@@ -441,12 +512,26 @@ const PluginCard = ({
) : plugin.is_managed ? (
-
+
: plugin.deprecated ? : }
+ leftSection={
+ detailLoading ? (
+
+ ) : plugin.deprecated ? (
+
+ ) : (
+
+ )
+ }
style={{ cursor: 'pointer' }}
onClick={() => openModal('details')}
>
@@ -489,8 +574,8 @@ const PluginCard = ({
- VERSION
- v{plugin.version || '1.0.0'}
+ VERSIONv
+ {plugin.version || '1.0.0'}
{plugin.is_managed && plugin.source_repo_name && (
@@ -542,7 +627,13 @@ const PluginCard = ({
onClose={() => setModalOpen(false)}
title={
-
+
{plugin.name?.[0]?.toUpperCase()}
{plugin.name}
@@ -550,11 +641,29 @@ const PluginCard = ({
}
size="lg"
>
- { setModalTab(tab); if (tab === 'details') fetchDetail(); }}>
+ {
+ setModalTab(tab);
+ if (tab === 'details') fetchDetail();
+ }}
+ >
- {isManaged && }>Details}
- {hasFields && }>Settings}
- {hasActions && }>Actions}
+ {isManaged && (
+ }>
+ Details
+
+ )}
+ {hasFields && (
+ }>
+ Settings
+
+ )}
+ {hasActions && (
+ }>
+ Actions
+
+ )}
{isManaged && (
@@ -565,7 +674,9 @@ const PluginCard = ({
selectedVersion={selectedVersion}
onVersionChange={setSelectedVersion}
installedVersion={plugin.version}
- installedVersionIsPrerelease={!!plugin.installed_version_is_prerelease}
+ installedVersionIsPrerelease={
+ !!plugin.installed_version_is_prerelease
+ }
appVersion={appVersion}
installing={installing}
uninstalling={uninstalling}
@@ -631,7 +742,10 @@ const PluginCard = ({
{/* Install confirmation modal */}
{(() => {
const selVer = pendingInstallParams?.version;
- const isDown = plugin.version && selVer && compareVersions(selVer, plugin.version) < 0;
+ const isDown =
+ plugin.version &&
+ selVer &&
+ compareVersions(selVer, plugin.version) < 0;
const actionLabel = isDown ? 'Downgrade' : 'Update';
return (
- {isDown
- ?
- : }
+ {isDown ? (
+
+ ) : (
+
+ )}
Confirm {actionLabel}
}
@@ -653,14 +769,15 @@ const PluginCard = ({
>
- You are about to {actionLabel.toLowerCase()} {plugin.name}{' '}
- from v{plugin.version} to v{selVer}.
+ You are about to {actionLabel.toLowerCase()}{' '}
+ {plugin.name} from v{plugin.version} to{' '}
+ v{selVer}.
- Plugins run server-side code with full access to your Dispatcharr
- instance and its data. Only install plugins from developers you
- trust. Malicious plugins could read or modify data, call internal
- APIs, or perform unwanted actions.
+ Plugins run server-side code with full access to your
+ Dispatcharr instance and its data. Only install plugins from
+ developers you trust. Malicious plugins could read or modify
+ data, call internal APIs, or perform unwanted actions.
{isDown && (
@@ -668,7 +785,9 @@ const PluginCard = ({
Downgrading may cause issues with saved settings or data.
)}
- Are you sure you want to proceed?
+
+ Are you sure you want to proceed?
+