From 2a3d0db670cc9a19ccd54bd6b70f1f86fe68af3b Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Mon, 12 Jan 2026 13:53:44 -0600 Subject: [PATCH] Enhancement: Loading feedback for all confirmation dialogs: Extended visual loading indicators across all confirmation dialogs throughout the application. Delete, cleanup, and bulk operation dialogs now show an animated dots loader and disabled state during async operations, providing consistent user feedback for backups (restore/delete), channels, EPGs, logos, VOD logos, M3U accounts, streams, users, groups, filters, profiles, batch operations, and network access changes. --- CHANGELOG.md | 2 +- .../src/components/backups/BackupManager.jsx | 4 +++ .../src/components/forms/ChannelBatch.jsx | 19 ++++++++-- .../src/components/forms/GroupManager.jsx | 7 +++- frontend/src/components/forms/M3UFilters.jsx | 11 +++--- frontend/src/components/forms/M3UProfiles.jsx | 6 +++- .../forms/settings/NetworkAccessForm.jsx | 11 ++++-- .../src/components/tables/ChannelsTable.jsx | 30 +++++++++++----- .../ChannelsTable/ChannelTableHeader.jsx | 11 ++++-- frontend/src/components/tables/EPGsTable.jsx | 13 ++++--- frontend/src/components/tables/LogosTable.jsx | 6 ++-- frontend/src/components/tables/M3UsTable.jsx | 13 +++++-- .../src/components/tables/StreamsTable.jsx | 36 ++++++++++++------- frontend/src/components/tables/UsersTable.jsx | 13 +++++-- .../src/components/tables/VODLogosTable.jsx | 5 +++ 15 files changed, 139 insertions(+), 48 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d9102b4..92e42aa1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +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. +- Loading feedback for all confirmation dialogs: Extended visual loading indicators across all confirmation dialogs throughout the application. Delete, cleanup, and bulk operation dialogs now show an animated dots loader and disabled state during async operations, providing consistent user feedback for backups (restore/delete), channels, EPGs, logos, VOD logos, M3U accounts, streams, users, groups, filters, profiles, batch operations, and network access changes. - 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/backups/BackupManager.jsx b/frontend/src/components/backups/BackupManager.jsx index 102c7254..dc130254 100644 --- a/frontend/src/components/backups/BackupManager.jsx +++ b/frontend/src/components/backups/BackupManager.jsx @@ -231,6 +231,7 @@ export default function BackupManager() { const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); const [selectedBackup, setSelectedBackup] = useState(null); const [restoring, setRestoring] = useState(false); + const [deleting, setDeleting] = useState(false); // Read user's preferences from settings const [timeFormat] = useLocalStorage('time-format', '12h'); @@ -513,6 +514,7 @@ export default function BackupManager() { }; const handleDeleteConfirm = async () => { + setDeleting(true); try { await API.deleteBackup(selectedBackup.name); notifications.show({ @@ -528,6 +530,7 @@ export default function BackupManager() { color: 'red', }); } finally { + setDeleting(false); setDeleteConfirmOpen(false); setSelectedBackup(null); } @@ -968,6 +971,7 @@ export default function BackupManager() { cancelLabel="Cancel" actionKey="delete-backup" onSuppressChange={suppressWarning} + loading={deleting} /> ); diff --git a/frontend/src/components/forms/ChannelBatch.jsx b/frontend/src/components/forms/ChannelBatch.jsx index a1cebe54..14dd22f1 100644 --- a/frontend/src/components/forms/ChannelBatch.jsx +++ b/frontend/src/components/forms/ChannelBatch.jsx @@ -77,6 +77,9 @@ const ChannelBatchForm = ({ channelIds, isOpen, onClose }) => { const [confirmSetLogosOpen, setConfirmSetLogosOpen] = useState(false); const [confirmSetTvgIdsOpen, setConfirmSetTvgIdsOpen] = useState(false); const [confirmBatchUpdateOpen, setConfirmBatchUpdateOpen] = useState(false); + const [settingNames, setSettingNames] = useState(false); + const [settingLogos, setSettingLogos] = useState(false); + const [settingTvgIds, setSettingTvgIds] = useState(false); const isWarningSuppressed = useWarningsStore((s) => s.isWarningSuppressed); const suppressWarning = useWarningsStore((s) => s.suppressWarning); @@ -328,6 +331,7 @@ const ChannelBatchForm = ({ channelIds, isOpen, onClose }) => { }; const executeSetNamesFromEpg = async () => { + setSettingNames(true); try { // Start the backend task await API.setChannelNamesFromEpg(channelIds); @@ -341,7 +345,6 @@ const ChannelBatchForm = ({ channelIds, isOpen, onClose }) => { }); // Close the modal since the task is now running in background - setConfirmSetNamesOpen(false); onClose(); } catch (error) { console.error('Failed to start EPG name setting task:', error); @@ -350,6 +353,8 @@ const ChannelBatchForm = ({ channelIds, isOpen, onClose }) => { message: 'Failed to start EPG name setting task.', color: 'red', }); + } finally { + setSettingNames(false); setConfirmSetNamesOpen(false); } }; @@ -373,6 +378,7 @@ const ChannelBatchForm = ({ channelIds, isOpen, onClose }) => { }; const executeSetLogosFromEpg = async () => { + setSettingLogos(true); try { // Start the backend task await API.setChannelLogosFromEpg(channelIds); @@ -386,7 +392,6 @@ const ChannelBatchForm = ({ channelIds, isOpen, onClose }) => { }); // Close the modal since the task is now running in background - setConfirmSetLogosOpen(false); onClose(); } catch (error) { console.error('Failed to start EPG logo setting task:', error); @@ -395,6 +400,8 @@ const ChannelBatchForm = ({ channelIds, isOpen, onClose }) => { message: 'Failed to start EPG logo setting task.', color: 'red', }); + } finally { + setSettingLogos(false); setConfirmSetLogosOpen(false); } }; @@ -418,6 +425,7 @@ const ChannelBatchForm = ({ channelIds, isOpen, onClose }) => { }; const executeSetTvgIdsFromEpg = async () => { + setSettingTvgIds(true); try { // Start the backend task await API.setChannelTvgIdsFromEpg(channelIds); @@ -431,7 +439,6 @@ const ChannelBatchForm = ({ channelIds, isOpen, onClose }) => { }); // Close the modal since the task is now running in background - setConfirmSetTvgIdsOpen(false); onClose(); } catch (error) { console.error('Failed to start EPG TVG-ID setting task:', error); @@ -440,6 +447,8 @@ const ChannelBatchForm = ({ channelIds, isOpen, onClose }) => { message: 'Failed to start EPG TVG-ID setting task.', color: 'red', }); + } finally { + setSettingTvgIds(false); setConfirmSetTvgIdsOpen(false); } }; @@ -947,6 +956,7 @@ const ChannelBatchForm = ({ channelIds, isOpen, onClose }) => { opened={confirmSetNamesOpen} onClose={() => setConfirmSetNamesOpen(false)} onConfirm={executeSetNamesFromEpg} + loading={settingNames} title="Confirm Set Names from EPG" message={
@@ -968,6 +978,7 @@ This action cannot be undone.`} opened={confirmSetLogosOpen} onClose={() => setConfirmSetLogosOpen(false)} onConfirm={executeSetLogosFromEpg} + loading={settingLogos} title="Confirm Set Logos from EPG" message={
@@ -989,6 +1000,7 @@ This action cannot be undone.`} opened={confirmSetTvgIdsOpen} onClose={() => setConfirmSetTvgIdsOpen(false)} onConfirm={executeSetTvgIdsFromEpg} + loading={settingTvgIds} title="Confirm Set TVG-IDs from EPG" message={
@@ -1010,6 +1022,7 @@ This action cannot be undone.`} opened={confirmBatchUpdateOpen} onClose={() => setConfirmBatchUpdateOpen(false)} onConfirm={onSubmit} + loading={isSubmitting} title="Confirm Batch Update" message={
diff --git a/frontend/src/components/forms/GroupManager.jsx b/frontend/src/components/forms/GroupManager.jsx index adcd55d3..453d9494 100644 --- a/frontend/src/components/forms/GroupManager.jsx +++ b/frontend/src/components/forms/GroupManager.jsx @@ -183,6 +183,7 @@ const GroupManager = React.memo(({ isOpen, onClose }) => { const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false); const [groupToDelete, setGroupToDelete] = useState(null); const [confirmCleanupOpen, setConfirmCleanupOpen] = useState(false); + const [deletingGroup, setDeletingGroup] = useState(false); // Memoize the channel groups array to prevent unnecessary re-renders const channelGroupsArray = useMemo( @@ -382,6 +383,7 @@ const GroupManager = React.memo(({ isOpen, onClose }) => { const executeDeleteGroup = useCallback( async (group) => { + setDeletingGroup(true); try { await API.deleteChannelGroup(group.id); @@ -392,13 +394,14 @@ const GroupManager = React.memo(({ isOpen, onClose }) => { }); await fetchGroupUsage(); // Refresh usage data - setConfirmDeleteOpen(false); } catch (error) { notifications.show({ title: 'Error', message: 'Failed to delete group', color: 'red', }); + } finally { + setDeletingGroup(false); setConfirmDeleteOpen(false); } }, @@ -680,6 +683,7 @@ const GroupManager = React.memo(({ isOpen, onClose }) => { opened={confirmDeleteOpen} onClose={() => setConfirmDeleteOpen(false)} onConfirm={() => groupToDelete && executeDeleteGroup(groupToDelete)} + loading={deletingGroup} title="Confirm Group Deletion" message={ groupToDelete ? ( @@ -706,6 +710,7 @@ This action cannot be undone.`} opened={confirmCleanupOpen} onClose={() => setConfirmCleanupOpen(false)} onConfirm={executeCleanup} + loading={isCleaningUp} title="Confirm Group Cleanup" message={
diff --git a/frontend/src/components/forms/M3UFilters.jsx b/frontend/src/components/forms/M3UFilters.jsx index f8bf9975..acd0c446 100644 --- a/frontend/src/components/forms/M3UFilters.jsx +++ b/frontend/src/components/forms/M3UFilters.jsx @@ -151,6 +151,7 @@ const M3UFilters = ({ playlist, isOpen, onClose }) => { const [deleteTarget, setDeleteTarget] = useState(null); const [filterToDelete, setFilterToDelete] = useState(null); const [filters, setFilters] = useState([]); + const [deleting, setDeleting] = useState(false); const isWarningSuppressed = useWarningsStore((s) => s.isWarningSuppressed); const suppressWarning = useWarningsStore((s) => s.suppressWarning); @@ -192,16 +193,17 @@ const M3UFilters = ({ playlist, isOpen, onClose }) => { const deleteFilter = async (id) => { if (!playlist || !playlist.id) return; + setDeleting(true); try { await API.deleteM3UFilter(playlist.id, id); - setConfirmDeleteOpen(false); + fetchPlaylist(playlist.id); + setFilters(filters.filter((f) => f.id !== id)); } catch (error) { console.error('Error deleting profile:', error); + } finally { + setDeleting(false); setConfirmDeleteOpen(false); } - - fetchPlaylist(playlist.id); - setFilters(filters.filter((f) => f.id !== id)); }; const closeEditor = (updatedPlaylist = null) => { @@ -321,6 +323,7 @@ const M3UFilters = ({ playlist, isOpen, onClose }) => { opened={confirmDeleteOpen} onClose={() => setConfirmDeleteOpen(false)} onConfirm={() => deleteFilter(deleteTarget)} + loading={deleting} title="Confirm Filter Deletion" message={ filterToDelete ? ( diff --git a/frontend/src/components/forms/M3UProfiles.jsx b/frontend/src/components/forms/M3UProfiles.jsx index 88caf49f..efbc1ed8 100644 --- a/frontend/src/components/forms/M3UProfiles.jsx +++ b/frontend/src/components/forms/M3UProfiles.jsx @@ -38,6 +38,7 @@ const M3UProfiles = ({ playlist = null, isOpen, onClose }) => { const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false); const [deleteTarget, setDeleteTarget] = useState(null); const [profileToDelete, setProfileToDelete] = useState(null); + const [deletingProfile, setDeletingProfile] = useState(false); const [accountInfoOpen, setAccountInfoOpen] = useState(false); const [selectedProfileForInfo, setSelectedProfileForInfo] = useState(null); @@ -88,11 +89,13 @@ const M3UProfiles = ({ playlist = null, isOpen, onClose }) => { const executeDeleteProfile = async (id) => { if (!playlist || !playlist.id) return; + setDeletingProfile(true); try { await API.deleteM3UProfile(playlist.id, id); - setConfirmDeleteOpen(false); } catch (error) { console.error('Error deleting profile:', error); + } finally { + setDeletingProfile(false); setConfirmDeleteOpen(false); } }; @@ -359,6 +362,7 @@ const M3UProfiles = ({ playlist = null, isOpen, onClose }) => { opened={confirmDeleteOpen} onClose={() => setConfirmDeleteOpen(false)} onConfirm={() => executeDeleteProfile(deleteTarget)} + loading={deletingProfile} title="Confirm Profile Deletion" message={ profileToDelete ? ( diff --git a/frontend/src/components/forms/settings/NetworkAccessForm.jsx b/frontend/src/components/forms/settings/NetworkAccessForm.jsx index 1d2c42e7..8200d635 100644 --- a/frontend/src/components/forms/settings/NetworkAccessForm.jsx +++ b/frontend/src/components/forms/settings/NetworkAccessForm.jsx @@ -20,6 +20,7 @@ const NetworkAccessForm = React.memo(({ active }) => { const [saved, setSaved] = useState(false); const [networkAccessConfirmOpen, setNetworkAccessConfirmOpen] = useState(false); + const [saving, setSaving] = useState(false); const [netNetworkAccessConfirmCIDRs, setNetNetworkAccessConfirmCIDRs] = useState([]); const [clientIpAddress, setClientIpAddress] = useState(null); @@ -31,7 +32,7 @@ const NetworkAccessForm = React.memo(({ active }) => { }); useEffect(() => { - if(!active) setSaved(false); + if (!active) setSaved(false); }, [active]); useEffect(() => { @@ -74,19 +75,22 @@ const NetworkAccessForm = React.memo(({ active }) => { const saveNetworkAccess = async () => { setSaved(false); + setSaving(true); try { await updateSetting({ ...settings['network-access'], value: JSON.stringify(networkAccessForm.getValues()), }); setSaved(true); - setNetworkAccessConfirmOpen(false); } catch (e) { const errors = {}; for (const key in e.body.value) { errors[key] = `Invalid CIDR(s): ${e.body.value[key]}`; } networkAccessForm.setErrors(errors); + } finally { + setSaving(false); + setNetworkAccessConfirmOpen(false); } }; @@ -135,6 +139,7 @@ const NetworkAccessForm = React.memo(({ active }) => { onClose={() => setNetworkAccessConfirmOpen(false)} onConfirm={saveNetworkAccess} title={`Confirm Network Access Blocks`} + loading={saving} message={ <> @@ -158,4 +163,4 @@ const NetworkAccessForm = React.memo(({ active }) => { ); }); -export default NetworkAccessForm; \ No newline at end of file +export default NetworkAccessForm; diff --git a/frontend/src/components/tables/ChannelsTable.jsx b/frontend/src/components/tables/ChannelsTable.jsx index cbfb510c..dc82c131 100644 --- a/frontend/src/components/tables/ChannelsTable.jsx +++ b/frontend/src/components/tables/ChannelsTable.jsx @@ -316,6 +316,7 @@ const ChannelsTable = ({ onReady }) => { const [deleteTarget, setDeleteTarget] = useState(null); const [isBulkDelete, setIsBulkDelete] = useState(false); const [channelToDelete, setChannelToDelete] = useState(null); + const [deleting, setDeleting] = useState(false); const hasFetchedData = useRef(false); @@ -545,9 +546,14 @@ const ChannelsTable = ({ onReady }) => { }; const executeDeleteChannel = async (id) => { - await API.deleteChannel(id); - API.requeryChannels(); - setConfirmDeleteOpen(false); + setDeleting(true); + try { + await API.deleteChannel(id); + API.requeryChannels(); + } finally { + setDeleting(false); + setConfirmDeleteOpen(false); + } }; const deleteChannels = async () => { @@ -562,12 +568,17 @@ const ChannelsTable = ({ onReady }) => { const executeDeleteChannels = async () => { setIsLoading(true); - await API.deleteChannels(table.selectedTableIds); - await API.requeryChannels(); - setSelectedChannelIds([]); - table.setSelectedTableIds([]); - setIsLoading(false); - setConfirmDeleteOpen(false); + setDeleting(true); + try { + await API.deleteChannels(table.selectedTableIds); + await API.requeryChannels(); + setSelectedChannelIds([]); + table.setSelectedTableIds([]); + } finally { + setDeleting(false); + setIsLoading(false); + setConfirmDeleteOpen(false); + } }; const createRecording = (channel) => { @@ -1498,6 +1509,7 @@ const ChannelsTable = ({ onReady }) => { ? executeDeleteChannels() : executeDeleteChannel(deleteTarget) } + loading={deleting} title={`Confirm ${isBulkDelete ? 'Bulk ' : ''}Channel Deletion`} message={ isBulkDelete ? ( diff --git a/frontend/src/components/tables/ChannelsTable/ChannelTableHeader.jsx b/frontend/src/components/tables/ChannelsTable/ChannelTableHeader.jsx index de389ccb..2263806f 100644 --- a/frontend/src/components/tables/ChannelsTable/ChannelTableHeader.jsx +++ b/frontend/src/components/tables/ChannelsTable/ChannelTableHeader.jsx @@ -118,6 +118,7 @@ const ChannelTableHeader = ({ const [confirmDeleteProfileOpen, setConfirmDeleteProfileOpen] = useState(false); const [profileToDelete, setProfileToDelete] = useState(null); + const [deletingProfile, setDeletingProfile] = useState(false); const [profileModalState, setProfileModalState] = useState({ opened: false, mode: null, @@ -157,8 +158,13 @@ const ChannelTableHeader = ({ }; const executeDeleteProfile = async (id) => { - await API.deleteChannelProfile(id); - setConfirmDeleteProfileOpen(false); + setDeletingProfile(true); + try { + await API.deleteChannelProfile(id); + } finally { + setDeletingProfile(false); + setConfirmDeleteProfileOpen(false); + } }; const matchEpg = async () => { @@ -402,6 +408,7 @@ const ChannelTableHeader = ({ opened={confirmDeleteProfileOpen} onClose={() => setConfirmDeleteProfileOpen(false)} onConfirm={() => executeDeleteProfile(profileToDelete?.id)} + loading={deletingProfile} title="Confirm Profile Deletion" message={ profileToDelete ? ( diff --git a/frontend/src/components/tables/EPGsTable.jsx b/frontend/src/components/tables/EPGsTable.jsx index b8dfeb6d..f6952542 100644 --- a/frontend/src/components/tables/EPGsTable.jsx +++ b/frontend/src/components/tables/EPGsTable.jsx @@ -110,6 +110,7 @@ const EPGsTable = () => { const [deleteTarget, setDeleteTarget] = useState(null); const [epgToDelete, setEpgToDelete] = useState(null); const [data, setData] = useState([]); + const [deleting, setDeleting] = useState(false); const epgs = useEPGsStore((s) => s.epgs); const refreshProgress = useEPGsStore((s) => s.refreshProgress); @@ -431,10 +432,13 @@ const EPGsTable = () => { }; const executeDeleteEPG = async (id) => { - setIsLoading(true); - await API.deleteEPG(id); - setIsLoading(false); - setConfirmDeleteOpen(false); + setDeleting(true); + try { + await API.deleteEPG(id); + } finally { + setDeleting(false); + setConfirmDeleteOpen(false); + } }; const refreshEPG = async (id) => { @@ -688,6 +692,7 @@ const EPGsTable = () => { opened={confirmDeleteOpen} onClose={() => setConfirmDeleteOpen(false)} onConfirm={() => executeDeleteEPG(deleteTarget)} + loading={deleting} title="Confirm EPG Source Deletion" message={ epgToDelete ? ( diff --git a/frontend/src/components/tables/LogosTable.jsx b/frontend/src/components/tables/LogosTable.jsx index 3e278ebd..7c718590 100644 --- a/frontend/src/components/tables/LogosTable.jsx +++ b/frontend/src/components/tables/LogosTable.jsx @@ -189,12 +189,12 @@ const LogosTable = () => { color: 'red', }); } finally { - setIsLoading(false); setConfirmDeleteOpen(false); setDeleteTarget(null); setLogoToDelete(null); setIsBulkDelete(false); clearSelections(); // Clear selections + setIsLoading(false); } }, [fetchAllLogos, clearSelections] @@ -221,10 +221,10 @@ const LogosTable = () => { color: 'red', }); } finally { - setIsLoading(false); setConfirmDeleteOpen(false); setIsBulkDelete(false); clearSelections(); // Clear selections + setIsLoading(false); } }, [selectedRows, fetchAllLogos, clearSelections] @@ -805,6 +805,7 @@ const LogosTable = () => { setConfirmDeleteOpen(false)} + loading={isLoading} onConfirm={(deleteFiles) => { if (isBulkDelete) { executeBulkDelete(deleteFiles); @@ -867,6 +868,7 @@ const LogosTable = () => { setConfirmCleanupOpen(false)} + loading={isCleaningUp} onConfirm={executeCleanupUnused} title="Cleanup Unused Logos" message={ diff --git a/frontend/src/components/tables/M3UsTable.jsx b/frontend/src/components/tables/M3UsTable.jsx index 64b401d8..6e88af56 100644 --- a/frontend/src/components/tables/M3UsTable.jsx +++ b/frontend/src/components/tables/M3UsTable.jsx @@ -140,6 +140,7 @@ const M3UTable = () => { const [playlistToDelete, setPlaylistToDelete] = useState(null); const [data, setData] = useState([]); const [sorting, setSorting] = useState([{ id: 'name', desc: '' }]); + const [deleting, setDeleting] = useState(false); const playlists = usePlaylistsStore((s) => s.playlists); const refreshProgress = usePlaylistsStore((s) => s.refreshProgress); @@ -400,9 +401,14 @@ const M3UTable = () => { const executeDeletePlaylist = async (id) => { setIsLoading(true); - await API.deletePlaylist(id); - setIsLoading(false); - setConfirmDeleteOpen(false); + setDeleting(true); + try { + await API.deletePlaylist(id); + } finally { + setDeleting(false); + setIsLoading(false); + setConfirmDeleteOpen(false); + } }; const toggleActive = async (playlist) => { @@ -893,6 +899,7 @@ const M3UTable = () => { opened={confirmDeleteOpen} onClose={() => setConfirmDeleteOpen(false)} onConfirm={() => executeDeletePlaylist(deleteTarget)} + loading={deleting} title="Confirm M3U Account Deletion" message={ playlistToDelete ? ( diff --git a/frontend/src/components/tables/StreamsTable.jsx b/frontend/src/components/tables/StreamsTable.jsx index c39e63ab..21b13baf 100644 --- a/frontend/src/components/tables/StreamsTable.jsx +++ b/frontend/src/components/tables/StreamsTable.jsx @@ -215,6 +215,7 @@ const StreamsTable = ({ onReady }) => { const [deleteTarget, setDeleteTarget] = useState(null); const [streamToDelete, setStreamToDelete] = useState(null); const [isBulkDelete, setIsBulkDelete] = useState(false); + const [deleting, setDeleting] = useState(false); // const [allRowsSelected, setAllRowsSelected] = useState(false); @@ -590,12 +591,17 @@ const StreamsTable = ({ onReady }) => { }; const executeDeleteStream = async (id) => { - await API.deleteStream(id); - fetchData(); - // Clear the selection for the deleted stream - setSelectedStreamIds([]); - table.setSelectedTableIds([]); - setConfirmDeleteOpen(false); + setDeleting(true); + try { + await API.deleteStream(id); + fetchData(); + // Clear the selection for the deleted stream + setSelectedStreamIds([]); + table.setSelectedTableIds([]); + } finally { + setDeleting(false); + setConfirmDeleteOpen(false); + } }; const deleteStreams = async () => { @@ -612,12 +618,17 @@ const StreamsTable = ({ onReady }) => { const executeDeleteStreams = async () => { setIsLoading(true); - await API.deleteStreams(selectedStreamIds); - setIsLoading(false); - fetchData(); - setSelectedStreamIds([]); - table.setSelectedTableIds([]); - setConfirmDeleteOpen(false); + setDeleting(true); + try { + await API.deleteStreams(selectedStreamIds); + fetchData(); + setSelectedStreamIds([]); + table.setSelectedTableIds([]); + } finally { + setDeleting(false); + setIsLoading(false); + setConfirmDeleteOpen(false); + } }; const closeStreamForm = () => { @@ -1258,6 +1269,7 @@ This action cannot be undone.`} cancelLabel="Cancel" actionKey={isBulkDelete ? 'delete-streams' : 'delete-stream'} onSuppressChange={suppressWarning} + loading={deleting} size="md" /> diff --git a/frontend/src/components/tables/UsersTable.jsx b/frontend/src/components/tables/UsersTable.jsx index 3e9e4971..467423dc 100644 --- a/frontend/src/components/tables/UsersTable.jsx +++ b/frontend/src/components/tables/UsersTable.jsx @@ -96,6 +96,7 @@ const UsersTable = () => { const [deleteTarget, setDeleteTarget] = useState(null); const [userToDelete, setUserToDelete] = useState(null); const [isLoading, setIsLoading] = useState(false); + const [deleting, setDeleting] = useState(false); const [visiblePasswords, setVisiblePasswords] = useState({}); /** @@ -110,9 +111,14 @@ const UsersTable = () => { const executeDeleteUser = useCallback(async (id) => { setIsLoading(true); - await API.deleteUser(id); - setIsLoading(false); - setConfirmDeleteOpen(false); + setDeleting(true); + try { + await API.deleteUser(id); + } finally { + setDeleting(false); + setIsLoading(false); + setConfirmDeleteOpen(false); + } }, []); const editUser = useCallback(async (user = null) => { @@ -406,6 +412,7 @@ const UsersTable = () => { opened={confirmDeleteOpen} onClose={() => setConfirmDeleteOpen(false)} onConfirm={() => executeDeleteUser(deleteTarget)} + loading={deleting} title="Confirm User Deletion" message={ userToDelete ? ( diff --git a/frontend/src/components/tables/VODLogosTable.jsx b/frontend/src/components/tables/VODLogosTable.jsx index ed166b65..75b322f5 100644 --- a/frontend/src/components/tables/VODLogosTable.jsx +++ b/frontend/src/components/tables/VODLogosTable.jsx @@ -74,6 +74,7 @@ export default function VODLogosTable() { const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false); const [deleteTarget, setDeleteTarget] = useState(null); const [confirmCleanupOpen, setConfirmCleanupOpen] = useState(false); + const [deleting, setDeleting] = useState(false); const [paginationString, setPaginationString] = useState(''); const [isCleaningUp, setIsCleaningUp] = useState(false); const tableRef = React.useRef(null); @@ -139,6 +140,7 @@ export default function VODLogosTable() { }, []); const handleConfirmDelete = async () => { + setDeleting(true); try { if (deleteTarget.length === 1) { await deleteVODLogo(deleteTarget[0]); @@ -162,6 +164,7 @@ export default function VODLogosTable() { color: 'red', }); } finally { + setDeleting(false); // Always clear selections and close dialog, even on error clearSelections(); setConfirmDeleteOpen(false); @@ -571,6 +574,7 @@ export default function VODLogosTable() { // pass deleteFiles option through handleConfirmDelete(deleteFiles); }} + loading={deleting} title={ deleteTarget && deleteTarget.length > 1 ? 'Delete Multiple Logos' @@ -633,6 +637,7 @@ export default function VODLogosTable() { setConfirmCleanupOpen(false)} + loading={isCleaningUp} onConfirm={handleConfirmCleanup} title="Cleanup Unused Logos" message={