feat(applications): replace inline add form with modal and add name filter

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-16 02:53:40 +02:00
parent 4d0be98160
commit 1b55b13541

View File

@@ -1,7 +1,8 @@
// client/applications.rs — Applications list page // client/applications.rs — Applications list page
// //
// Displays all applications with: // Displays all applications with:
// - Add form : inline form to create an application by name // - Add button : opens a modal to create an application by name
// - Filter bar : name substring filter (client-side)
// - Table : application name + number of associated hosts // - Table : application name + number of associated hosts
// - Delete : confirmation modal before deletion // - Delete : confirmation modal before deletion
@@ -13,6 +14,61 @@ use crate::api::applications::{
get_applications_with_counts, get_applications_with_counts,
}; };
// ─── Add application modal ────────────────────────────────────────────────────
#[component]
fn AddApplicationModal(
create_action: ServerAction<CreateApplication>,
show_modal: RwSignal<bool>,
) -> impl IntoView {
Effect::new(move |_| {
if let Some(Ok(_)) = create_action.value().get() {
show_modal.set(false);
}
});
view! {
<div class="modal-backdrop" on:click=move |_| show_modal.set(false)>
<div class="modal" on:click=move |e| e.stop_propagation()>
<div class="modal__header">
<h2>"Add an application"</h2>
<button class="modal__close" type="button" aria-label="Close"
on:click=move |_| show_modal.set(false)>
"×"
</button>
</div>
<ActionForm action=create_action>
<div class="add-form__fields">
<label>
"Name"
<input
type="text"
name="name"
placeholder="e.g. Nginx, PostgreSQL, Prometheus"
required
/>
</label>
</div>
<div class="modal__actions">
<button class="btn-secondary" type="button"
on:click=move |_| show_modal.set(false)>
"Cancel"
</button>
<button type="submit">"Add application"</button>
</div>
</ActionForm>
{move || create_action.value().get()
.and_then(|r| r.err())
.map(|e| view! { <p class="error">{e.to_string()}</p> })
}
</div>
</div>
}.into_any()
}
// ─── Delete confirmation modal ──────────────────────────────────────────────── // ─── Delete confirmation modal ────────────────────────────────────────────────
#[component] #[component]
@@ -68,10 +124,15 @@ pub fn ApplicationsPage() -> impl IntoView {
let create_action = ServerAction::<CreateApplication>::new(); let create_action = ServerAction::<CreateApplication>::new();
let delete_action = ServerAction::<DeleteApplication>::new(); let delete_action = ServerAction::<DeleteApplication>::new();
let show_modal = RwSignal::new(false);
// Some(app) = delete modal open for that app; None = closed. // Some(app) = delete modal open for that app; None = closed.
let pending_delete: RwSignal<Option<ApplicationWithCounts>> = RwSignal::new(None); let pending_delete: RwSignal<Option<ApplicationWithCounts>> = RwSignal::new(None);
// Close the modal automatically after a successful deletion. // Name filter (client-side — list is typically small)
let name_filter = RwSignal::new(String::new());
// Close the delete modal automatically after a successful deletion.
Effect::new(move |_| { Effect::new(move |_| {
if let Some(Ok(_)) = delete_action.value().get() { if let Some(Ok(_)) = delete_action.value().get() {
pending_delete.set(None); pending_delete.set(None);
@@ -85,7 +146,22 @@ pub fn ApplicationsPage() -> impl IntoView {
view! { view! {
<div class="applications-page"> <div class="applications-page">
<h1>"Applications"</h1>
// ── Page header ───────────────────────────────────────────────────
<div class="page-header">
<h1>"Applications"</h1>
<button class="btn-primary" on:click=move |_| show_modal.set(true)>
"+ Add application"
</button>
</div>
// ── Add modal ─────────────────────────────────────────────────────
{move || show_modal.get().then(|| view! {
<AddApplicationModal
create_action=create_action
show_modal=show_modal
/>
})}
// ── Delete modal ────────────────────────────────────────────────── // ── Delete modal ──────────────────────────────────────────────────
{move || pending_delete.get().map(|app| view! { {move || pending_delete.get().map(|app| view! {
@@ -96,31 +172,22 @@ pub fn ApplicationsPage() -> impl IntoView {
/> />
})} })}
// ── Add form ────────────────────────────────────────────────────── // ── Filter bar ────────────────────────────────────────────────────
<section class="add-form"> <section class="filter-bar">
<h2>"Add an application"</h2> <div class="filter-bar__fields">
<ActionForm action=create_action> <label class="filter-field">
<label>
"Name" "Name"
<input <input
type="text" type="text"
name="name" placeholder="Search…"
placeholder="e.g. Nginx, PostgreSQL, Prometheus" on:input=move |e| name_filter.set(event_target_value(&e))
required
/> />
</label> </label>
<button type="submit">"Add"</button> </div>
</ActionForm>
{move || create_action.value().get()
.and_then(|r| r.err())
.map(|e| view! { <p class="error">{e.to_string()}</p> })
}
</section> </section>
// ── Application list ────────────────────────────────────────────── // ── Application list ──────────────────────────────────────────────
<section class="list"> <section class="list">
<h2>"All applications"</h2>
{move || delete_action.value().get() {move || delete_action.value().get()
.and_then(|r| r.err()) .and_then(|r| r.err())
.map(|e| view! { <p class="error">"Delete failed: " {e.to_string()}</p> }) .map(|e| view! { <p class="error">"Delete failed: " {e.to_string()}</p> })
@@ -132,41 +199,50 @@ pub fn ApplicationsPage() -> impl IntoView {
<p class="error">"Could not load applications: " {e.to_string()}</p> <p class="error">"Could not load applications: " {e.to_string()}</p>
}.into_any(), }.into_any(),
Ok(list) if list.is_empty() => view! { Ok(list) => {
<p class="empty">"No applications yet. Add one above."</p> let filter = name_filter.get().to_lowercase();
}.into_any(), let filtered: Vec<_> = list.into_iter()
.filter(|app| filter.is_empty() || app.name.to_lowercase().contains(&filter))
.collect();
Ok(list) => view! { if filtered.is_empty() {
<div class="table-container"> view! {
<table> <p class="empty">"No applications match the current filter."</p>
<thead> }.into_any()
<tr> } else {
<th>"Name"</th> view! {
<th class="col-count">"Hosts"</th> <div class="table-container">
<th class="col-actions">"Actions"</th> <table>
</tr> <thead>
</thead>
<tbody>
{list.into_iter().map(|app| {
let app_clone = app.clone();
view! {
<tr> <tr>
<td>{app.name}</td> <th>"Name"</th>
<td class="col-count">{app.host_count}</td> <th class="col-count">"Hosts"</th>
<td class="col-actions"> <th class="col-actions">"Actions"</th>
<button on:click=move |_| {
pending_delete.set(Some(app_clone.clone()));
}>
"Delete"
</button>
</td>
</tr> </tr>
} </thead>
}).collect_view()} <tbody>
</tbody> {filtered.into_iter().map(|app| {
</table> let app_clone = app.clone();
</div> view! {
}.into_any(), <tr>
<td>{app.name}</td>
<td class="col-count">{app.host_count}</td>
<td class="col-actions">
<button on:click=move |_| {
pending_delete.set(Some(app_clone.clone()));
}>
"Delete"
</button>
</td>
</tr>
}
}).collect_view()}
</tbody>
</table>
</div>
}.into_any()
}
}
})} })}
</Suspense> </Suspense>
</section> </section>