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:
@@ -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">
|
||||||
|
|
||||||
|
// ── Page header ───────────────────────────────────────────────────
|
||||||
|
<div class="page-header">
|
||||||
<h1>"Applications"</h1>
|
<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,11 +199,18 @@ 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() {
|
||||||
|
view! {
|
||||||
|
<p class="empty">"No applications match the current filter."</p>
|
||||||
|
}.into_any()
|
||||||
|
} else {
|
||||||
|
view! {
|
||||||
<div class="table-container">
|
<div class="table-container">
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
@@ -147,7 +221,7 @@ pub fn ApplicationsPage() -> impl IntoView {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{list.into_iter().map(|app| {
|
{filtered.into_iter().map(|app| {
|
||||||
let app_clone = app.clone();
|
let app_clone = app.clone();
|
||||||
view! {
|
view! {
|
||||||
<tr>
|
<tr>
|
||||||
@@ -166,7 +240,9 @@ pub fn ApplicationsPage() -> impl IntoView {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
}.into_any(),
|
}.into_any()
|
||||||
|
}
|
||||||
|
}
|
||||||
})}
|
})}
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
Reference in New Issue
Block a user