diff --git a/frontend/src/components/settings/SettingsFanoutSection.tsx b/frontend/src/components/settings/SettingsFanoutSection.tsx index 1f11f7a..6e0abc1 100644 --- a/frontend/src/components/settings/SettingsFanoutSection.tsx +++ b/frontend/src/components/settings/SettingsFanoutSection.tsx @@ -324,6 +324,22 @@ function getDetailTypeLabel(detailType: string) { return TYPE_LABELS[detailType] || detailType; } +function fanoutDraftHasUnsavedChanges( + original: FanoutConfig | null, + current: { + name: string; + config: Record; + scope: Record; + } +) { + if (!original) return false; + return ( + current.name !== original.name || + JSON.stringify(current.config) !== JSON.stringify(original.config) || + JSON.stringify(current.scope) !== JSON.stringify(original.scope) + ); +} + function formatBrokerSummary( config: Record, defaults: { host: string; port: number } @@ -1547,7 +1563,17 @@ export function SettingsFanoutSection({ }; const handleBackToList = () => { - if (!confirm('Leave without saving?')) return; + const shouldConfirm = + draftType !== null || + fanoutDraftHasUnsavedChanges( + editingId ? (configs.find((c) => c.id === editingId) ?? null) : null, + { + name: editName, + config: editConfig, + scope: editScope, + } + ); + if (shouldConfirm && !confirm('Leave without saving?')) return; setEditingId(null); setDraftType(null); }; diff --git a/frontend/src/test/fanoutSection.test.tsx b/frontend/src/test/fanoutSection.test.tsx index a6b2fd8..defd708 100644 --- a/frontend/src/test/fanoutSection.test.tsx +++ b/frontend/src/test/fanoutSection.test.tsx @@ -342,11 +342,12 @@ describe('SettingsFanoutSection', () => { fireEvent.click(screen.getByText('← Back to list')); + expect(window.confirm).toHaveBeenCalledWith('Leave without saving?'); await waitFor(() => expect(screen.queryByText('← Back to list')).not.toBeInTheDocument()); expect(mockedApi.createFanoutConfig).not.toHaveBeenCalled(); }); - it('back to list asks for confirmation before leaving', async () => { + it('back to list does not ask for confirmation when an existing integration is unchanged', async () => { mockedApi.getFanoutConfigs.mockResolvedValue([webhookConfig]); renderSection(); await waitFor(() => expect(screen.getByText('Test Hook')).toBeInTheDocument()); @@ -356,11 +357,28 @@ describe('SettingsFanoutSection', () => { fireEvent.click(screen.getByText('← Back to list')); + expect(window.confirm).not.toHaveBeenCalled(); + await waitFor(() => expect(screen.queryByText('← Back to list')).not.toBeInTheDocument()); + }); + + it('back to list asks for confirmation after editing an existing integration', async () => { + mockedApi.getFanoutConfigs.mockResolvedValue([webhookConfig]); + renderSection(); + await waitFor(() => expect(screen.getByText('Test Hook')).toBeInTheDocument()); + + fireEvent.click(screen.getByRole('button', { name: 'Edit' })); + await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument()); + + fireEvent.change(screen.getByLabelText('URL'), { + target: { value: 'https://example.com/new' }, + }); + fireEvent.click(screen.getByText('← Back to list')); + expect(window.confirm).toHaveBeenCalledWith('Leave without saving?'); await waitFor(() => expect(screen.queryByText('← Back to list')).not.toBeInTheDocument()); }); - it('back to list stays on the edit screen when confirmation is cancelled', async () => { + it('back to list stays on the edit screen when confirmation is cancelled after edits', async () => { vi.mocked(window.confirm).mockReturnValue(false); mockedApi.getFanoutConfigs.mockResolvedValue([webhookConfig]); renderSection(); @@ -369,6 +387,9 @@ describe('SettingsFanoutSection', () => { fireEvent.click(screen.getByRole('button', { name: 'Edit' })); await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument()); + fireEvent.change(screen.getByLabelText('URL'), { + target: { value: 'https://example.com/new' }, + }); fireEvent.click(screen.getByText('← Back to list')); expect(window.confirm).toHaveBeenCalledWith('Leave without saving?');