feat(networks): add delete confirmation modal with host count warning

Show a modal before deleting a network. If the network has hosts,
display a warning with the exact count since they will be cascade-deleted.
Host count comes from the existing NetworkWithCounts data (no extra query).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-16 01:28:12 +02:00
parent 30dd1ad0b0
commit 55d8ed9f72
3 changed files with 92 additions and 5 deletions

View File

@@ -8,7 +8,7 @@ use crate::models::Network;
// Network row augmented with pre-computed counts. // Network row augmented with pre-computed counts.
// Defined here (not in models.rs) because it is a presentation model // Defined here (not in models.rs) because it is a presentation model
// specific to the Networks page, not a pure domain entity. // specific to the Networks page, not a pure domain entity.
#[derive(Clone, Debug, Serialize, Deserialize)] #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct NetworkWithCounts { pub struct NetworkWithCounts {
pub id: i64, pub id: i64,
pub cidr: String, pub cidr: String,

View File

@@ -16,7 +16,59 @@
use leptos::prelude::*; use leptos::prelude::*;
use leptos::form::ActionForm; use leptos::form::ActionForm;
use crate::api::networks::{CreateNetwork, DeleteNetwork, get_networks_with_counts}; use crate::api::networks::{CreateNetwork, DeleteNetwork, NetworkWithCounts, get_networks_with_counts};
// ─── Delete confirmation modal ────────────────────────────────────────────────
#[component]
fn DeleteConfirmModal(
network: NetworkWithCounts,
delete_action: ServerAction<DeleteNetwork>,
pending_delete: RwSignal<Option<NetworkWithCounts>>,
) -> impl IntoView {
let id = network.id;
let cidr = network.cidr.clone();
let host_count = network.host_count;
view! {
<div class="modal-backdrop" on:click=move |_| pending_delete.set(None)>
<div class="modal" on:click=move |e| e.stop_propagation()>
<div class="modal__header">
<h2>"Delete network"</h2>
</div>
<div class="modal__body">
<p>"Delete network " <strong>{cidr}</strong> "?"</p>
{(host_count > 0).then(|| view! {
<p class="warning">
"Warning: "
{host_count}
{if host_count == 1 { " host" } else { " hosts" }}
" belonging to this network will also be deleted."
</p>
})}
</div>
<div class="modal__actions">
<button
class="btn-secondary"
type="button"
on:click=move |_| pending_delete.set(None)
>"Cancel"</button>
<button
class="btn-danger"
type="button"
on:click=move |_| {
delete_action.dispatch(DeleteNetwork { id });
}
>"Delete"</button>
</div>
</div>
</div>
}.into_any()
}
// ─── Page ─────────────────────────────────────────────────────────────────────
#[component] #[component]
pub fn NetworksPage() -> impl IntoView { pub fn NetworksPage() -> impl IntoView {
@@ -28,6 +80,16 @@ pub fn NetworksPage() -> impl IntoView {
let create_action = ServerAction::<CreateNetwork>::new(); let create_action = ServerAction::<CreateNetwork>::new();
let delete_action = ServerAction::<DeleteNetwork>::new(); let delete_action = ServerAction::<DeleteNetwork>::new();
// Stores the network pending deletion; Some = modal open, None = closed.
let pending_delete: RwSignal<Option<NetworkWithCounts>> = RwSignal::new(None);
// Close the modal automatically after a successful deletion.
Effect::new(move |_| {
if let Some(Ok(_)) = delete_action.value().get() {
pending_delete.set(None);
}
});
// ── Data resource ───────────────────────────────────────────────────────── // ── Data resource ─────────────────────────────────────────────────────────
// //
// `Resource::new(source, fetcher)`: // `Resource::new(source, fetcher)`:
@@ -45,6 +107,15 @@ pub fn NetworksPage() -> impl IntoView {
<div class="networks-page"> <div class="networks-page">
<h1>"Networks"</h1> <h1>"Networks"</h1>
// ── Delete confirmation modal ──────────────────────────────────────
{move || pending_delete.get().map(|network| view! {
<DeleteConfirmModal
network=network
delete_action=delete_action
pending_delete=pending_delete
/>
})}
// ── Add form ────────────────────────────────────────────────────── // ── Add form ──────────────────────────────────────────────────────
// //
// `<ActionForm action=create_action>` submits the form to the server // `<ActionForm action=create_action>` submits the form to the server
@@ -123,7 +194,7 @@ pub fn NetworksPage() -> impl IntoView {
{list {list
.into_iter() .into_iter()
.map(|network| { .map(|network| {
let id = network.id; let network_clone = network.clone();
view! { view! {
<tr> <tr>
<td>{network.cidr}</td> <td>{network.cidr}</td>
@@ -131,8 +202,7 @@ pub fn NetworksPage() -> impl IntoView {
<td class="col-count">{network.application_count}</td> <td class="col-count">{network.application_count}</td>
<td class="col-actions"> <td class="col-actions">
<button on:click=move |_| { <button on:click=move |_| {
delete_action pending_delete.set(Some(network_clone.clone()));
.dispatch(DeleteNetwork { id });
}> }>
"Delete" "Delete"
</button> </button>

View File

@@ -924,6 +924,23 @@ td.col-actions {
background: var(--bg-hover); background: var(--bg-hover);
} }
.modal__body {
margin-bottom: var(--size-lg);
}
.modal__body p {
margin: 0 0 var(--size-sm);
}
.warning {
color: var(--color-warning, #b45309);
background: var(--color-warning-bg, #fef3c7);
border: 1px solid var(--color-warning-border, #fcd34d);
border-radius: var(--radius-sm);
padding: var(--size-sm) var(--size-md);
font-size: var(--font-sm);
}
/* Form fields inside modal — single column stack */ /* Form fields inside modal — single column stack */
.modal .add-form__fields { .modal .add-form__fields {
display: flex; display: flex;