252 lines
11 KiB
Rust
252 lines
11 KiB
Rust
// client/applications.rs — Applications list page
|
||
//
|
||
// Displays all applications with:
|
||
// - 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
|
||
// - Delete : confirmation modal before deletion
|
||
|
||
use leptos::prelude::*;
|
||
use leptos::form::ActionForm;
|
||
|
||
use crate::api::applications::{
|
||
ApplicationWithCounts, CreateApplication, DeleteApplication,
|
||
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 ────────────────────────────────────────────────
|
||
|
||
#[component]
|
||
fn DeleteAppModal(
|
||
app: ApplicationWithCounts,
|
||
delete_action: ServerAction<DeleteApplication>,
|
||
pending_delete: RwSignal<Option<ApplicationWithCounts>>,
|
||
) -> impl IntoView {
|
||
let id = app.id;
|
||
let label = app.name.clone();
|
||
let host_count = app.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 application"</h2>
|
||
<button class="modal__close" type="button" aria-label="Close"
|
||
on:click=move |_| pending_delete.set(None)>
|
||
"×"
|
||
</button>
|
||
</div>
|
||
<div class="modal__body">
|
||
<p>"Delete application " <strong>{label}</strong> "?"</p>
|
||
{(host_count > 0).then(|| view! {
|
||
<p class="warning">
|
||
"This application is linked to "
|
||
{host_count}
|
||
{if host_count == 1 { " host" } else { " hosts" }}
|
||
" via shared ports. The port associations will be removed."
|
||
</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(DeleteApplication { id }); }>
|
||
"Delete"
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
}.into_any()
|
||
}
|
||
|
||
// ─── Page ─────────────────────────────────────────────────────────────────────
|
||
|
||
#[component]
|
||
pub fn ApplicationsPage() -> impl IntoView {
|
||
let create_action = ServerAction::<CreateApplication>::new();
|
||
let delete_action = ServerAction::<DeleteApplication>::new();
|
||
|
||
let show_modal = RwSignal::new(false);
|
||
|
||
// Some(app) = delete modal open for that app; None = closed.
|
||
let pending_delete: RwSignal<Option<ApplicationWithCounts>> = RwSignal::new(None);
|
||
|
||
// 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 |_| {
|
||
if let Some(Ok(_)) = delete_action.value().get() {
|
||
pending_delete.set(None);
|
||
}
|
||
});
|
||
|
||
let applications = Resource::new(
|
||
move || (create_action.version().get(), delete_action.version().get()),
|
||
|_| get_applications_with_counts(),
|
||
);
|
||
|
||
view! {
|
||
<div class="applications-page">
|
||
|
||
// ── 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 ──────────────────────────────────────────────────
|
||
{move || pending_delete.get().map(|app| view! {
|
||
<DeleteAppModal
|
||
app=app
|
||
delete_action=delete_action
|
||
pending_delete=pending_delete
|
||
/>
|
||
})}
|
||
|
||
// ── Filter bar ────────────────────────────────────────────────────
|
||
<section class="filter-bar">
|
||
<div class="filter-bar__fields">
|
||
<label class="filter-field">
|
||
"Name"
|
||
<input
|
||
type="text"
|
||
placeholder="Search…"
|
||
on:input=move |e| name_filter.set(event_target_value(&e))
|
||
/>
|
||
</label>
|
||
</div>
|
||
</section>
|
||
|
||
// ── Application list ──────────────────────────────────────────────
|
||
<section class="list">
|
||
{move || delete_action.value().get()
|
||
.and_then(|r| r.err())
|
||
.map(|e| view! { <p class="error">"Delete failed: " {e.to_string()}</p> })
|
||
}
|
||
|
||
<Suspense fallback=|| view! { <p>"Loading applications…"</p> }>
|
||
{move || applications.get().map(|result| match result {
|
||
Err(e) => view! {
|
||
<p class="error">"Could not load applications: " {e.to_string()}</p>
|
||
}.into_any(),
|
||
|
||
Ok(list) => {
|
||
let filter = name_filter.get().to_lowercase();
|
||
let filtered: Vec<_> = list.into_iter()
|
||
.filter(|app| filter.is_empty() || app.name.to_lowercase().contains(&filter))
|
||
.collect();
|
||
|
||
if filtered.is_empty() {
|
||
view! {
|
||
<p class="empty">"No applications match the current filter."</p>
|
||
}.into_any()
|
||
} else {
|
||
view! {
|
||
<div class="table-container">
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>"Name"</th>
|
||
<th class="col-count">"Hosts"</th>
|
||
<th class="col-actions">"Actions"</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{filtered.into_iter().map(|app| {
|
||
let app_clone = app.clone();
|
||
view! {
|
||
<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>
|
||
</section>
|
||
</div>
|
||
}
|
||
}
|