graphile_worker_admin_ui_client 0.1.1

Leptos WASM client for the embedded graphile_worker admin UI
Documentation
use leptos::prelude::*;
use serde_json::Value;

use super::api::{post_add_job, post_job_action, post_remove_key};
use super::browser::{copy_to_clipboard, show_toast};
use super::filters::{
    datetime_local_to_utc, job_state, modal_title, optional_csv, optional_i16, optional_string,
    state_label, textarea_value,
};
use super::types::{
    AddJobRequest, AdminClientConfig, JobAction, JobActionRequest, JobKeyModeRequest, ListedJob,
    Modal, OverviewResponse, RemoveJobByKeyRequest,
};

#[component]
pub(super) fn ModalView(
    modal: RwSignal<Option<Modal>>,
    config: AdminClientConfig,
    token: RwSignal<String>,
    overview: RwSignal<OverviewResponse>,
    jobs: RwSignal<Vec<ListedJob>>,
    selected_jobs: RwSignal<Vec<i64>>,
    limit: RwSignal<i64>,
    toast: RwSignal<Option<String>>,
    refreshing: RwSignal<bool>,
    refresh_pending: RwSignal<bool>,
) -> impl IntoView {
    view! {
        <div class="gw-modal" data-open=move || modal.get().is_some().to_string() role="dialog" aria-modal="true">
            <div class="gw-dialog">
                <div class="mb-4 flex items-center justify-between gap-3">
                    <h3 class="text-lg font-semibold">{move || modal_title(&modal.get())}</h3>
                    <button class="gw-btn" type="button" aria-label="Close" on:click=move |_| modal.set(None)>
                        <span class="i-lucide-x h-4 w-4"></span>
                    </button>
                </div>
                {move || match modal.get() {
                    Some(Modal::AddJob) => view! {
                        <AddJobModal
                            config=config.clone()
                            token=token
                            overview=overview
                            jobs=jobs
                            selected_jobs=selected_jobs
                            limit=limit
                            modal=modal
                            toast=toast
                            refreshing=refreshing
                            refresh_pending=refresh_pending
                        />
                    }.into_any(),
                    Some(Modal::FailJobs) => view! {
                        <FailJobsModal
                            config=config.clone()
                            token=token
                            overview=overview
                            jobs=jobs
                            selected_jobs=selected_jobs
                            limit=limit
                            modal=modal
                            toast=toast
                            refreshing=refreshing
                            refresh_pending=refresh_pending
                        />
                    }.into_any(),
                    Some(Modal::Reschedule) => view! {
                        <RescheduleModal
                            config=config.clone()
                            token=token
                            overview=overview
                            jobs=jobs
                            selected_jobs=selected_jobs
                            limit=limit
                            modal=modal
                            toast=toast
                            refreshing=refreshing
                            refresh_pending=refresh_pending
                        />
                    }.into_any(),
                    Some(Modal::RemoveKey) => view! {
                        <RemoveKeyModal
                            config=config.clone()
                            token=token
                            overview=overview
                            jobs=jobs
                            selected_jobs=selected_jobs
                            limit=limit
                            modal=modal
                            toast=toast
                            refreshing=refreshing
                            refresh_pending=refresh_pending
                        />
                    }.into_any(),
                    Some(Modal::JobDetails(job)) => view! { <JobDetailsModal job=job toast=toast /> }.into_any(),
                    None => ().into_any(),
                }}
            </div>
        </div>
    }
}

#[component]
pub(super) fn AddJobModal(
    config: AdminClientConfig,
    token: RwSignal<String>,
    overview: RwSignal<OverviewResponse>,
    jobs: RwSignal<Vec<ListedJob>>,
    selected_jobs: RwSignal<Vec<i64>>,
    limit: RwSignal<i64>,
    modal: RwSignal<Option<Modal>>,
    toast: RwSignal<Option<String>>,
    refreshing: RwSignal<bool>,
    refresh_pending: RwSignal<bool>,
) -> impl IntoView {
    let identifier = RwSignal::new(String::new());
    let queue = RwSignal::new(String::new());
    let key = RwSignal::new(String::new());
    let key_mode = RwSignal::new(String::new());
    let priority = RwSignal::new(String::new());
    let max_attempts = RwSignal::new(String::new());
    let run_at = RwSignal::new(String::new());
    let payload = RwSignal::new("{\"hello\":\"world\"}".to_string());
    let flags = RwSignal::new(String::new());

    view! {
        <form class="grid gap-3" on:submit=move |event| {
            event.prevent_default();
            let payload_value = match serde_json::from_str::<Value>(&payload.get_untracked()) {
                Ok(payload) => payload,
                Err(error) => {
                    show_toast(toast, format!("Invalid JSON: {error}"));
                    return;
                }
            };
            let request = AddJobRequest {
                identifier: identifier.get_untracked(),
                payload: payload_value,
                queue: optional_string(queue.get_untracked()),
                run_at: datetime_local_to_utc(&run_at.get_untracked()),
                max_attempts: optional_i16(max_attempts.get_untracked()),
                key: optional_string(key.get_untracked()),
                job_key_mode: match key_mode.get_untracked().as_str() {
                    "replace" => Some(JobKeyModeRequest::Replace),
                    "preserve-run-at" => Some(JobKeyModeRequest::PreserveRunAt),
                    "unsafe-dedupe" => Some(JobKeyModeRequest::UnsafeDedupe),
                    _ => None,
                },
                priority: optional_i16(priority.get_untracked()),
                flags: optional_csv(flags.get_untracked()),
            };
            post_add_job(
                config.clone(),
                token,
                request,
                overview,
                jobs,
                selected_jobs,
                limit,
                modal,
                toast,
                refreshing,
                refresh_pending,
            );
        }>
            <div class="grid gap-3 md:grid-cols-2">
                <TextInput label="Task identifier" value=identifier required=true />
                <TextInput label="Queue" value=queue required=false />
                <TextInput label="Key" value=key required=false />
                <label class="grid gap-1 text-sm" for="key-mode">"Key mode"
                    <select id="key-mode" name="job_key_mode" class="gw-input" prop:value=move || key_mode.get() on:change=move |event| key_mode.set(event_target_value(&event))>
                        <option value="">"Default"</option>
                        <option value="replace">"Replace"</option>
                        <option value="preserve-run-at">"Preserve run_at"</option>
                        <option value="unsafe-dedupe">"Unsafe dedupe"</option>
                    </select>
                </label>
                <TextInput label="Priority" value=priority input_type="number" required=false />
                <TextInput label="Max attempts" value=max_attempts input_type="number" required=false />
                <TextInput label="Run at" value=run_at input_type="datetime-local" required=false class="grid gap-1 text-sm md:col-span-2" />
            </div>
            <label class="grid gap-1 text-sm" for="job-payload">"Payload JSON"
                <textarea id="job-payload" name="payload" class="gw-textarea font-mono" prop:value=move || payload.get() on:input=move |event| payload.set(textarea_value(&event))></textarea>
            </label>
            <TextInput label="Flags, comma-separated" value=flags required=false />
            <ModalButtons modal=modal danger=false submit_label="Add job" submit_icon="i-lucide-plus" />
        </form>
    }
}

#[component]
pub(super) fn FailJobsModal(
    config: AdminClientConfig,
    token: RwSignal<String>,
    overview: RwSignal<OverviewResponse>,
    jobs: RwSignal<Vec<ListedJob>>,
    selected_jobs: RwSignal<Vec<i64>>,
    limit: RwSignal<i64>,
    modal: RwSignal<Option<Modal>>,
    toast: RwSignal<Option<String>>,
    refreshing: RwSignal<bool>,
    refresh_pending: RwSignal<bool>,
) -> impl IntoView {
    let reason = RwSignal::new("Marked failed from Graphile Worker admin UI".to_string());
    view! {
        <form class="grid gap-3" on:submit=move |event| {
            event.prevent_default();
            post_job_action(
                config.clone(),
                token,
                JobActionRequest {
                    action: JobAction::Fail,
                    ids: selected_jobs.get_untracked(),
                    reason: optional_string(reason.get_untracked()),
                    run_at: None,
                    priority: None,
                    attempts: None,
                    max_attempts: None,
                },
                overview,
                jobs,
                selected_jobs,
                limit,
                Some(modal),
                toast,
                refreshing,
                refresh_pending,
            );
        }>
            <p class="gw-muted text-sm">{move || format!("This permanently fails {} selected job(s).", selected_jobs.get().len())}</p>
            <label class="grid gap-1 text-sm" for="fail-reason">"Reason"
                <textarea id="fail-reason" name="reason" class="gw-textarea" prop:value=move || reason.get() on:input=move |event| reason.set(textarea_value(&event))></textarea>
            </label>
            <ModalButtons modal=modal danger=true submit_label="Fail jobs" submit_icon="i-lucide-ban" />
        </form>
    }
}

#[component]
pub(super) fn RescheduleModal(
    config: AdminClientConfig,
    token: RwSignal<String>,
    overview: RwSignal<OverviewResponse>,
    jobs: RwSignal<Vec<ListedJob>>,
    selected_jobs: RwSignal<Vec<i64>>,
    limit: RwSignal<i64>,
    modal: RwSignal<Option<Modal>>,
    toast: RwSignal<Option<String>>,
    refreshing: RwSignal<bool>,
    refresh_pending: RwSignal<bool>,
) -> impl IntoView {
    let run_at = RwSignal::new(String::new());
    let priority = RwSignal::new(String::new());
    let attempts = RwSignal::new(String::new());
    let max_attempts = RwSignal::new(String::new());
    view! {
        <form class="grid gap-3" on:submit=move |event| {
            event.prevent_default();
            post_job_action(
                config.clone(),
                token,
                JobActionRequest {
                    action: JobAction::Reschedule,
                    ids: selected_jobs.get_untracked(),
                    reason: None,
                    run_at: datetime_local_to_utc(&run_at.get_untracked()),
                    priority: optional_i16(priority.get_untracked()),
                    attempts: optional_i16(attempts.get_untracked()),
                    max_attempts: optional_i16(max_attempts.get_untracked()),
                },
                overview,
                jobs,
                selected_jobs,
                limit,
                Some(modal),
                toast,
                refreshing,
                refresh_pending,
            );
        }>
            <div class="grid gap-3 md:grid-cols-2">
                <TextInput label="Run at" value=run_at input_type="datetime-local" required=false />
                <TextInput label="Priority" value=priority input_type="number" required=false />
                <TextInput label="Attempts" value=attempts input_type="number" required=false />
                <TextInput label="Max attempts" value=max_attempts input_type="number" required=false />
            </div>
            <ModalButtons modal=modal danger=false submit_label="Reschedule" submit_icon="i-lucide-calendar-clock" />
        </form>
    }
}

#[component]
pub(super) fn RemoveKeyModal(
    config: AdminClientConfig,
    token: RwSignal<String>,
    overview: RwSignal<OverviewResponse>,
    jobs: RwSignal<Vec<ListedJob>>,
    selected_jobs: RwSignal<Vec<i64>>,
    limit: RwSignal<i64>,
    modal: RwSignal<Option<Modal>>,
    toast: RwSignal<Option<String>>,
    refreshing: RwSignal<bool>,
    refresh_pending: RwSignal<bool>,
) -> impl IntoView {
    let key = RwSignal::new(String::new());
    view! {
        <form class="grid gap-3" on:submit=move |event| {
            event.prevent_default();
            post_remove_key(
                config.clone(),
                token,
                RemoveJobByKeyRequest { key: key.get_untracked() },
                overview,
                jobs,
                selected_jobs,
                limit,
                modal,
                toast,
                refreshing,
                refresh_pending,
            );
        }>
            <TextInput label="Job key" value=key required=true />
            <ModalButtons modal=modal danger=true submit_label="Remove" submit_icon="i-tabler-key-off" />
        </form>
    }
}

#[component]
pub(super) fn JobDetailsModal(job: ListedJob, toast: RwSignal<Option<String>>) -> impl IntoView {
    let row_state = state_label(job_state(&job));
    let job_json = serde_json::to_string_pretty(&job).unwrap_or_default();
    view! {
        <div class="grid gap-3">
            <div class="grid gap-3 md:grid-cols-3">
                <div class="gw-panel p-3"><span class="gw-muted text-xs">"Task"</span><strong class="block">{job.task_identifier.clone()}</strong></div>
                <div class="gw-panel p-3"><span class="gw-muted text-xs">"Queue"</span><strong class="block">{job.queue_name.clone().unwrap_or_else(|| "default".to_string())}</strong></div>
                <div class="gw-panel p-3"><span class="gw-muted text-xs">"State"</span><strong class="block">{row_state}</strong></div>
            </div>
            <label class="grid gap-1 text-sm" for="job-detail-payload">"Payload"
                <textarea id="job-detail-payload" name="job_detail_payload" class="gw-textarea font-mono" readonly>{serde_json::to_string_pretty(&job.payload).unwrap_or_default()}</textarea>
            </label>
            <label class="grid gap-1 text-sm" for="job-detail-last-error">"Last error"
                <textarea id="job-detail-last-error" name="job_detail_last_error" class="gw-textarea font-mono" readonly>{job.last_error.clone().unwrap_or_default()}</textarea>
            </label>
            <div class="flex justify-end gap-2">
                <button class="gw-btn" type="button" on:click=move |_| copy_to_clipboard(job_json.clone(), "Copied job JSON", toast)>
                    <span class="i-lucide-copy h-4 w-4"></span>"Copy JSON"
                </button>
            </div>
        </div>
    }
}

#[component]
pub(super) fn TextInput(
    label: &'static str,
    value: RwSignal<String>,
    #[prop(default = "text")] input_type: &'static str,
    #[prop(default = false)] required: bool,
    #[prop(default = "grid gap-1 text-sm")] class: &'static str,
) -> impl IntoView {
    let id = format!(
        "admin-{}",
        label
            .to_ascii_lowercase()
            .replace(|character: char| !character.is_ascii_alphanumeric(), "-")
    );
    let name = id.trim_start_matches("admin-").replace('-', "_");
    let input_id = id.clone();
    view! {
        <label class=class for=id.clone()>{label}
            <input
                id=input_id
                name=name
                class="gw-input"
                type=input_type
                required=required
                prop:value=move || value.get()
                on:input=move |event| value.set(event_target_value(&event))
            />
        </label>
    }
}

#[component]
pub(super) fn ModalButtons(
    modal: RwSignal<Option<Modal>>,
    danger: bool,
    submit_label: &'static str,
    submit_icon: &'static str,
) -> impl IntoView {
    view! {
        <div class="flex justify-end gap-2">
            <button class="gw-btn" type="button" on:click=move |_| modal.set(None)>"Cancel"</button>
            <button class=if danger { "gw-btn gw-btn-danger" } else { "gw-btn gw-btn-primary" } type="submit">
                <span class=format!("{submit_icon} h-4 w-4")></span>
                {submit_label}
            </button>
        </div>
    }
}