feat(confirmation-dialog): add stop stream option to confirmation dialog

This update enhances the ConfirmationDialog component by introducing an option to stop the active stream when confirming actions. The new checkbox allows users to choose whether to stop the stream if it is playing, with the preference being remembered for future confirmations. The implementation includes updates to the state management and effects to handle the new option, as well as corresponding tests to ensure functionality. Additionally, the ChannelsTable component has been updated to utilize this new feature, improving user experience during channel deletions.
This commit is contained in:
SergeantPanda 2026-07-19 18:52:34 +00:00
parent 3011946c56
commit 3749953c2c
9 changed files with 303 additions and 50 deletions

View file

@ -610,11 +610,19 @@ export default class API {
}
}
static async deleteChannel(id) {
static async deleteChannel(id, { stopStream = false } = {}) {
try {
await request(`${host}/api/channels/channels/${id}/`, {
method: 'DELETE',
});
const params = new URLSearchParams();
if (stopStream) {
params.set('stop_stream', 'true');
}
const query = params.toString();
await request(
`${host}/api/channels/channels/${id}/${query ? `?${query}` : ''}`,
{
method: 'DELETE',
}
);
useChannelsStore.getState().removeChannels([id]);
await API.requeryStreams();
@ -624,11 +632,11 @@ export default class API {
}
// @TODO: the bulk delete endpoint is currently broken
static async deleteChannels(channel_ids) {
static async deleteChannels(channel_ids, { stopStream = false } = {}) {
try {
await request(`${host}/api/channels/channels/bulk-delete/`, {
method: 'DELETE',
body: { channel_ids },
body: { channel_ids, stop_stream: Boolean(stopStream) },
});
useChannelsStore.getState().removeChannels(channel_ids);

View file

@ -1,5 +1,5 @@
import { Modal, Group, Button, Checkbox, Box } from '@mantine/core';
import React, { useState } from 'react';
import React, { useEffect, useState } from 'react';
import useWarningsStore from '../store/warnings';
/**
@ -17,6 +17,10 @@ import useWarningsStore from '../store/warnings';
* @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
* @param {boolean} [props.showDeleteFileOption=false] - Show "also delete files" checkbox
* @param {string} [props.deleteFileLabel] - Label for delete-files checkbox
* @param {boolean} [props.showStopStreamOption=false] - Show "also stop stream" checkbox
* @param {string} [props.stopStreamLabel] - Label for stop-stream checkbox
*/
const ConfirmationDialog = ({
opened,
@ -32,14 +36,38 @@ const ConfirmationDialog = ({
zIndex = 1000,
showDeleteFileOption = false,
deleteFileLabel = 'Also delete files from disk',
showStopStreamOption = false,
stopStreamLabel = 'Also stop active stream if playing',
loading = false,
}) => {
const suppressWarning = useWarningsStore((s) => s.suppressWarning);
const isWarningSuppressed = useWarningsStore((s) => s.isWarningSuppressed);
const setActionPreference = useWarningsStore((s) => s.setActionPreference);
const getActionPreference = useWarningsStore((s) => s.getActionPreference);
const [suppressChecked, setSuppressChecked] = useState(
isWarningSuppressed(actionKey)
);
const [deleteFiles, setDeleteFiles] = useState(false);
const [stopStream, setStopStream] = useState(false);
useEffect(() => {
if (!opened) {
return;
}
setSuppressChecked(isWarningSuppressed(actionKey));
setDeleteFiles(false);
if (showStopStreamOption && actionKey) {
setStopStream(getActionPreference(actionKey, 'stopStream', false));
} else {
setStopStream(false);
}
}, [
opened,
actionKey,
showStopStreamOption,
isWarningSuppressed,
getActionPreference,
]);
const handleToggleSuppress = (e) => {
setSuppressChecked(e.currentTarget.checked);
@ -49,19 +77,25 @@ const ConfirmationDialog = ({
};
const handleConfirm = () => {
if (showStopStreamOption && actionKey) {
setActionPreference(actionKey, { stopStream });
}
if (suppressChecked) {
suppressWarning(actionKey);
}
if (showDeleteFileOption) {
if (showStopStreamOption) {
onConfirm(stopStream);
} else if (showDeleteFileOption) {
onConfirm(deleteFiles);
} else {
onConfirm();
}
setDeleteFiles(false); // Reset for next time
setDeleteFiles(false);
};
const handleClose = () => {
setDeleteFiles(false); // Reset for next time
setDeleteFiles(false);
setStopStream(false);
onClose();
};
@ -76,15 +110,6 @@ const ConfirmationDialog = ({
>
<Box mb={20}>{message}</Box>
{actionKey && (
<Checkbox
label="Don't ask me again"
checked={suppressChecked}
onChange={handleToggleSuppress}
mb={20}
/>
)}
{showDeleteFileOption && (
<Checkbox
checked={deleteFiles}
@ -94,6 +119,24 @@ const ConfirmationDialog = ({
/>
)}
{showStopStreamOption && (
<Checkbox
checked={stopStream}
onChange={(event) => setStopStream(event.currentTarget.checked)}
label={stopStreamLabel}
mb="md"
/>
)}
{actionKey && (
<Checkbox
label="Don't ask me again"
checked={suppressChecked}
onChange={handleToggleSuppress}
mb={20}
/>
)}
<Group justify="flex-end">
<Button variant="outline" onClick={handleClose} disabled={loading}>
{cancelLabel}

View file

@ -38,13 +38,18 @@ describe('ConfirmationDialog', () => {
const mockOnSuppressChange = vi.fn();
const mockSuppressWarning = vi.fn();
const mockIsWarningSuppressed = vi.fn();
const mockSetActionPreference = vi.fn();
const mockGetActionPreference = vi.fn(() => false);
beforeEach(() => {
vi.clearAllMocks();
mockGetActionPreference.mockReturnValue(false);
useWarningsStore.mockImplementation((selector) => {
const state = {
suppressWarning: mockSuppressWarning,
isWarningSuppressed: mockIsWarningSuppressed,
setActionPreference: mockSetActionPreference,
getActionPreference: mockGetActionPreference,
};
return selector ? selector(state) : state;
});
@ -211,6 +216,93 @@ describe('ConfirmationDialog', () => {
expect(mockOnConfirm).toHaveBeenCalledWith(true);
});
it('should show stop stream option when enabled', () => {
render(
<ConfirmationDialog
opened={true}
onClose={mockOnClose}
onConfirm={mockOnConfirm}
showStopStreamOption={true}
/>
);
expect(
screen.getByLabelText('Also stop active stream if playing')
).toBeInTheDocument();
});
it('should pass stopStream false by default when stop option enabled', () => {
render(
<ConfirmationDialog
opened={true}
onClose={mockOnClose}
onConfirm={mockOnConfirm}
showStopStreamOption={true}
/>
);
fireEvent.click(screen.getByText('Confirm'));
expect(mockOnConfirm).toHaveBeenCalledWith(false);
});
it('should pass stopStream true when stop option is checked', () => {
render(
<ConfirmationDialog
opened={true}
onClose={mockOnClose}
onConfirm={mockOnConfirm}
showStopStreamOption={true}
actionKey="delete-channel"
/>
);
fireEvent.click(screen.getByLabelText('Also stop active stream if playing'));
fireEvent.click(screen.getByText('Confirm'));
expect(mockOnConfirm).toHaveBeenCalledWith(true);
expect(mockSetActionPreference).toHaveBeenCalledWith('delete-channel', {
stopStream: true,
});
});
it('should render stop stream checkbox before do not ask again', () => {
render(
<ConfirmationDialog
opened={true}
onClose={mockOnClose}
onConfirm={mockOnConfirm}
showStopStreamOption={true}
actionKey="delete-channel"
/>
);
const checkboxes = screen.getAllByRole('checkbox');
expect(checkboxes).toHaveLength(2);
expect(checkboxes[0]).toHaveAccessibleName(
'Also stop active stream if playing'
);
expect(checkboxes[1]).toHaveAccessibleName("Don't ask me again");
});
it('should restore saved stopStream preference when dialog opens', () => {
mockGetActionPreference.mockReturnValue(true);
render(
<ConfirmationDialog
opened={true}
onClose={mockOnClose}
onConfirm={mockOnConfirm}
showStopStreamOption={true}
actionKey="delete-channel"
/>
);
expect(
screen.getByLabelText('Also stop active stream if playing')
).toBeChecked();
});
it('should reset deleteFiles state after confirmation', () => {
const { rerender } = render(
<ConfirmationDialog

View file

@ -332,6 +332,7 @@ const ChannelsTable = ({ onReady }) => {
// store/warnings
const isWarningSuppressed = useWarningsStore((s) => s.isWarningSuppressed);
const suppressWarning = useWarningsStore((s) => s.suppressWarning);
const getActionPreference = useWarningsStore((s) => s.getActionPreference);
/**
* useState
@ -575,8 +576,9 @@ const ChannelsTable = ({ onReady }) => {
setChannelToDelete(null);
if (isWarningSuppressed('delete-channels')) {
// Skip warning if suppressed
return executeDeleteChannels();
return executeDeleteChannels(
getActionPreference('delete-channels', 'stopStream', false)
);
}
setConfirmDeleteOpen(true);
@ -589,17 +591,19 @@ const ChannelsTable = ({ onReady }) => {
setChannelToDelete(knownChannel); // Store the channel object for displaying details
if (isWarningSuppressed('delete-channel')) {
// Skip warning if suppressed
return executeDeleteChannel(id);
return executeDeleteChannel(
id,
getActionPreference('delete-channel', 'stopStream', false)
);
}
setConfirmDeleteOpen(true);
};
const executeDeleteChannel = async (id) => {
const executeDeleteChannel = async (id, stopStream = false) => {
setDeleting(true);
try {
await deleteChannel(id);
await deleteChannel(id, { stopStream });
requeryChannels();
} finally {
setDeleting(false);
@ -609,19 +613,20 @@ const ChannelsTable = ({ onReady }) => {
const handleDeleteChannels = async () => {
if (isWarningSuppressed('delete-channels')) {
// Skip warning if suppressed
return executeDeleteChannels();
return executeDeleteChannels(
getActionPreference('delete-channels', 'stopStream', false)
);
}
setIsBulkDelete(true);
setConfirmDeleteOpen(true);
};
const executeDeleteChannels = async () => {
const executeDeleteChannels = async (stopStream = false) => {
setIsLoading(true);
setDeleting(true);
try {
await deleteChannels(table.selectedTableIds);
await deleteChannels(table.selectedTableIds, { stopStream });
await requeryChannels();
setSelectedChannelIds([]);
table.setSelectedTableIds([]);
@ -1668,11 +1673,12 @@ const ChannelsTable = ({ onReady }) => {
<ConfirmationDialog
opened={confirmDeleteOpen}
onClose={() => setConfirmDeleteOpen(false)}
onConfirm={() =>
isBulkDelete
? executeDeleteChannels()
: executeDeleteChannel(deleteTarget)
}
onConfirm={(stopStream) => {
const shouldStop = stopStream === true;
return isBulkDelete
? executeDeleteChannels(shouldStop)
: executeDeleteChannel(deleteTarget, shouldStop);
}}
loading={deleting}
title={`Confirm ${isBulkDelete ? 'Bulk ' : ''}Channel Deletion`}
message={
@ -1695,6 +1701,8 @@ This action cannot be undone.`}
cancelLabel="Cancel"
actionKey={isBulkDelete ? 'delete-channels' : 'delete-channel'}
onSuppressChange={suppressWarning}
showStopStreamOption
stopStreamLabel="Also stop active stream if playing"
size="md"
/>
</>

View file

@ -200,7 +200,11 @@ vi.mock('../../ConfirmationDialog', () => ({
opened ? (
<div data-testid="confirm-dialog">
<span data-testid="confirm-title">{title}</span>
<button data-testid="confirm-ok" onClick={onConfirm} disabled={loading}>
<button
data-testid="confirm-ok"
onClick={() => onConfirm()}
disabled={loading}
>
{confirmLabel}
</button>
<button data-testid="confirm-cancel" onClick={onClose}>
@ -474,6 +478,7 @@ const setupMocks = ({
authUser = makeAdminUser(),
isWarningSuppressed = vi.fn(() => false),
suppressWarning = vi.fn(),
getActionPreference = vi.fn(() => false),
selectedProfileId = '0',
profiles = { 0: { name: 'Default', channels: new Set() } },
pageCount = 1,
@ -532,7 +537,7 @@ const setupMocks = ({
);
vi.mocked(useWarningsStore).mockImplementation((sel) =>
sel({ isWarningSuppressed, suppressWarning })
sel({ isWarningSuppressed, suppressWarning, getActionPreference })
);
vi.mocked(useLocalStorage).mockReturnValue([{}, vi.fn()]);
@ -820,10 +825,26 @@ describe('ChannelsTable', () => {
render(<ChannelsTable />);
const { getByTestId } = renderActionCell(channel, tableInstance);
fireEvent.click(getByTestId('icon-square-minus').closest('button'));
await waitFor(() => expect(deleteChannel).toHaveBeenCalledWith(7));
await waitFor(() =>
expect(deleteChannel).toHaveBeenCalledWith(7, { stopStream: false })
);
expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument();
});
it('uses saved stopStream preference when warning is suppressed', async () => {
const channel = makeChannel({ id: 8 });
const { tableInstance } = setupMocks({
isWarningSuppressed: vi.fn(() => true),
getActionPreference: vi.fn(() => true),
});
render(<ChannelsTable />);
const { getByTestId } = renderActionCell(channel, tableInstance);
fireEvent.click(getByTestId('icon-square-minus').closest('button'));
await waitFor(() =>
expect(deleteChannel).toHaveBeenCalledWith(8, { stopStream: true })
);
});
it('calls deleteChannel when confirm dialog is confirmed', async () => {
const channel = makeChannel({ id: 5 });
const { tableInstance } = setupMocks({
@ -833,7 +854,9 @@ describe('ChannelsTable', () => {
const { getByTestId } = renderActionCell(channel, tableInstance);
fireEvent.click(getByTestId('icon-square-minus').closest('button'));
fireEvent.click(screen.getByTestId('confirm-ok'));
await waitFor(() => expect(deleteChannel).toHaveBeenCalledWith(5));
await waitFor(() =>
expect(deleteChannel).toHaveBeenCalledWith(5, { stopStream: false })
);
});
it('calls requeryChannels after deleteChannel succeeds', async () => {
@ -913,7 +936,9 @@ describe('ChannelsTable', () => {
render(<ChannelsTable />);
fireEvent.click(screen.getByTestId('header-delete-channels'));
fireEvent.click(screen.getByTestId('confirm-ok'));
await waitFor(() => expect(deleteChannels).toHaveBeenCalledWith([1, 2]));
await waitFor(() =>
expect(deleteChannels).toHaveBeenCalledWith([1, 2], { stopStream: false })
);
});
it('awaits requeryChannels before clearing bulk selection', async () => {
@ -939,7 +964,9 @@ describe('ChannelsTable', () => {
fireEvent.click(screen.getByTestId('header-delete-channels'));
fireEvent.click(screen.getByTestId('confirm-ok'));
await waitFor(() => expect(deleteChannels).toHaveBeenCalledWith([1, 2]));
await waitFor(() =>
expect(deleteChannels).toHaveBeenCalledWith([1, 2], { stopStream: false })
);
expect(requeryChannels).toHaveBeenCalled();
expect(setSelectedTableIds).not.toHaveBeenCalled();
@ -961,7 +988,9 @@ describe('ChannelsTable', () => {
});
render(<ChannelsTable />);
fireEvent.click(screen.getByTestId('header-delete-channels'));
await waitFor(() => expect(deleteChannels).toHaveBeenCalledWith([1, 2]));
await waitFor(() =>
expect(deleteChannels).toHaveBeenCalledWith([1, 2], { stopStream: false })
);
expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument();
});
});

View file

@ -6,6 +6,7 @@ describe('useWarningsStore', () => {
beforeEach(() => {
useWarningsStore.setState({
suppressedWarnings: {},
actionPreferences: {},
});
});
@ -13,6 +14,7 @@ describe('useWarningsStore', () => {
const { result } = renderHook(() => useWarningsStore());
expect(result.current.suppressedWarnings).toEqual({});
expect(result.current.actionPreferences).toEqual({});
});
it('should suppress a warning', () => {
@ -192,4 +194,36 @@ describe('useWarningsStore', () => {
expect(result.current.isWarningSuppressed('action2')).toBe(false);
expect(result.current.isWarningSuppressed('action3')).toBe(false);
});
it('should store and read action preferences', () => {
const { result } = renderHook(() => useWarningsStore());
expect(result.current.getActionPreference('delete-channel', 'stopStream')).toBe(
false
);
act(() => {
result.current.setActionPreference('delete-channel', { stopStream: true });
});
expect(
result.current.getActionPreference('delete-channel', 'stopStream')
).toBe(true);
expect(
result.current.getActionPreference('delete-channels', 'stopStream', false)
).toBe(false);
});
it('should clear action preferences when resetSuppressions is called', () => {
const { result } = renderHook(() => useWarningsStore());
act(() => {
result.current.suppressWarning('delete-channel');
result.current.setActionPreference('delete-channel', { stopStream: true });
result.current.resetSuppressions();
});
expect(result.current.suppressedWarnings).toEqual({});
expect(result.current.actionPreferences).toEqual({});
});
});

View file

@ -1,12 +1,16 @@
import { create } from 'zustand';
const useWarningsStore = create((set) => ({
const useWarningsStore = create((set, get) => ({
// Map of action keys to whether they're suppressed
suppressedWarnings: {},
// Optional per-action preferences remembered with confirmations
// e.g. { 'delete-channel': { stopStream: true } }
actionPreferences: {},
// Function to check if a warning is suppressed
isWarningSuppressed: (actionKey) => {
const state = useWarningsStore.getState();
const state = get();
return state.suppressedWarnings[actionKey] === true;
},
@ -20,9 +24,32 @@ const useWarningsStore = create((set) => ({
}));
},
setActionPreference: (actionKey, preference) => {
if (!actionKey || !preference || typeof preference !== 'object') {
return;
}
set((state) => ({
actionPreferences: {
...state.actionPreferences,
[actionKey]: {
...(state.actionPreferences[actionKey] || {}),
...preference,
},
},
}));
},
getActionPreference: (actionKey, key, defaultValue = false) => {
const prefs = get().actionPreferences[actionKey];
if (!prefs || !(key in prefs)) {
return defaultValue;
}
return prefs[key];
},
// Function to reset all suppressions
resetSuppressions: () => {
set({ suppressedWarnings: {} });
set({ suppressedWarnings: {}, actionPreferences: {} });
},
}));

View file

@ -107,11 +107,11 @@ export const hdhrUrlBase = `${window.location.protocol}//${window.location.host}
export const reorderChannel = (channelId, insertAfterId) => {
return API.reorderChannel(channelId, insertAfterId);
};
export const deleteChannel = (id) => {
return API.deleteChannel(id);
export const deleteChannel = (id, options = {}) => {
return API.deleteChannel(id, options);
};
export const deleteChannels = (channelIds) => {
return API.deleteChannels(channelIds);
export const deleteChannels = (channelIds, options = {}) => {
return API.deleteChannels(channelIds, options);
};
export const queryChannels = (params) => {
return API.queryChannels(params);

View file

@ -576,12 +576,24 @@ describe('ChannelsTableUtils', () => {
it('deleteChannel calls API.deleteChannel', () => {
ChannelsTableUtils.deleteChannel(5);
expect(API.deleteChannel).toHaveBeenCalledWith(5);
expect(API.deleteChannel).toHaveBeenCalledWith(5, {});
});
it('deleteChannel forwards stopStream option', () => {
ChannelsTableUtils.deleteChannel(5, { stopStream: true });
expect(API.deleteChannel).toHaveBeenCalledWith(5, { stopStream: true });
});
it('deleteChannels calls API.deleteChannels', () => {
ChannelsTableUtils.deleteChannels([1, 2, 3]);
expect(API.deleteChannels).toHaveBeenCalledWith([1, 2, 3]);
expect(API.deleteChannels).toHaveBeenCalledWith([1, 2, 3], {});
});
it('deleteChannels forwards stopStream option', () => {
ChannelsTableUtils.deleteChannels([1, 2], { stopStream: true });
expect(API.deleteChannels).toHaveBeenCalledWith([1, 2], {
stopStream: true,
});
});
it('queryChannels calls API.queryChannels', () => {