A card of one's own: one_per_person, prefilled, replaced on send; no double sends; refusals are shown
Test / test (pull_request) Successful in 1m12s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
This commit is contained in:
Bendik Aagaard Lynghaug
2026-09-30 20:39:01 +02:00
co-authored by Claude Opus 5.5
parent cae116b50c
commit 809a6bdfe8
12 changed files with 269 additions and 24 deletions
+83 -1
View File
@@ -1198,9 +1198,11 @@ fn AlternativeCard(
if req.kind == "file" {
file_refs.entry(req.name.clone()).or_default();
} else if req.kind == "select" {
// Preset by a card of one's own (`answers::fill_own`).
let preset: Vec<String> = req.value.clone().filter(|v| !v.is_empty()).into_iter().collect();
select_field_map
.entry(req.name.clone())
.or_insert_with(|| RwSignal::new(Vec::new()));
.or_insert_with(|| RwSignal::new(preset));
select_multi.insert(req.name.clone(), req.multiple);
} else {
// Widget-owned fields hold JSON objects, not strings.
@@ -1238,6 +1240,11 @@ fn AlternativeCard(
let gesture_fields_for_submit = gesture_fields.clone();
let question_id_for_transition = question_id.clone();
let alt_name_for_transition = alternative.name.clone();
// From the moment Send is pressed until the server has answered:
// files upload first, and a second press in that time used to send
// the form twice.
let sending = RwSignal::new(false);
let send_error: RwSignal<Option<String>> = RwSignal::new(None);
let on_submit = move |ev: leptos::ev::SubmitEvent| {
ev.prevent_default();
@@ -1266,6 +1273,8 @@ fn AlternativeCard(
let question_id_for_submit = question_id_for_submit.clone();
let alt_name_for_submit = alt_name_for_submit.clone();
let parent_hash_for_submit = parent_hash_for_submit.clone();
sending.set(true);
send_error.set(None);
leptos::task::spawn_local(async move {
let mut map = serde_json::Map::new();
for (name, sig) in field_map_for_submit.iter() {
@@ -1305,8 +1314,14 @@ fn AlternativeCard(
Ok(key) => {
map.insert(name.clone(), serde_json::Value::String(key));
}
// A form sent without the file it asked for is a
// record that looks complete and is not: stop, and
// say so.
Err(e) => {
leptos::logging::error!("upload of {name} failed: {e:?}");
send_error.set(Some(t(&lang().get_untracked(), "upload_failed").to_string()));
sending.set(false);
return;
}
}
}
@@ -1319,6 +1334,7 @@ fn AlternativeCard(
parent_hash: parent_hash_for_submit,
responses_json,
});
sending.set(false);
});
};
@@ -1512,9 +1528,26 @@ fn AlternativeCard(
if req.kind == "file" {
let file_ref = file_refs.get(&req.name).copied().unwrap_or_else(NodeRef::new);
// On a card of one's own, the file already
// there (see `answers::fill_own`).
let current = req.value.clone().filter(|v| v.starts_with("/attachment/")).map(|href| {
let picture = req.accept.as_deref().is_some_and(|a| a.contains("image/"));
view! {
<span class="field-current">
<span>{move || t(&lang().get(), "current_file")}</span>
{if picture {
view! { <img class="answer-thumb" src=href.clone() alt="" /> }.into_any()
} else {
view! { <a href=href.clone() rel="external">{href.rsplit('/').next().unwrap_or_default().to_string()}</a> }.into_any()
}}
<span class="field-hint">{move || t(&lang().get(), "keep_file")}</span>
</span>
}
});
return view! {
<label class="field" for=label_for>
{label_text}
{current}
<input
id=field_id
type="file"
@@ -1826,12 +1859,24 @@ fn AlternativeCard(
class="alt-submit"
disabled=move || {
(!has_requirements && pending_transitions.get().is_empty())
|| sending.get()
|| submit.pending().get()
|| transition_batch.pending().get()
}
>
{button_label.clone()}
</button>
// Why nothing happened, when something
// was refused: it used to say nothing.
{move || {
send_error
.get()
.or_else(|| match submit.value().get() {
Some(Err(e)) => Some(server_message(&e)),
_ => None,
})
.map(|m| view! { <p class="alt-error" role="alert">{m}</p> })
}}
</form>
}
.into_any()
@@ -2238,6 +2283,13 @@ pub fn uploaded_filename(value: &str) -> Option<&str> {
rest.strip_prefix('-').filter(|name| shaped && !name.is_empty())
}
/// What a refused server call says, without the wrapping a person
/// has no use for.
fn server_message(e: &ServerFnError) -> String {
let text = e.to_string();
text.rsplit_once(": ").map(|(_, m)| m.to_string()).filter(|m| !m.is_empty()).unwrap_or(text)
}
/// Whether an uploaded file is a picture a browser shows as it is.
pub fn is_picture(filename: &str) -> bool {
let lower = filename.to_ascii_lowercase();
@@ -2559,6 +2611,7 @@ pub async fn get_question(
let session: tower_sessions::Session = leptos_axum::extract().await?;
let user = session.get::<User>(SESSION_USER_KEY).await.map_err(|e| ServerFnError::new(e.to_string()))?;
fill_from_user(&mut question, user.as_ref());
crate::answers::fill_own(&state.jetstream, &mut question, user.as_ref()).await;
let mut chain_gate = None;
if let Some(target) = &question.requires_chain {
// The claimed lineage must verifiably end at the required
@@ -2912,6 +2965,26 @@ pub async fn submit_answer(
let mut responses: serde_json::Value = serde_json::from_str(&responses_json)
.map_err(|e| ServerFnError::new(format!("invalid responses: {e}")))?;
// One card each: whose it is comes from the session, and a file the
// person did not choose again is the one already on their card.
let owner = if alt.one_per_person {
let user = user.as_ref().ok_or_else(|| ServerFnError::new("sign in to keep a card of your own"))?;
let bucket = record_as.clone().ok_or_else(|| ServerFnError::new("this alternative keeps nothing"))?;
let previous = crate::answers::own_records(&state.jetstream, &bucket, &user.username).await.into_iter().next();
if let Some(map) = responses.as_object_mut() {
for req in alt.features.iter().flat_map(|f| &f.requirements).filter(|r| r.kind == "file") {
let chosen = map.get(&req.name).and_then(|v| v.as_str()).is_some_and(|v| !v.is_empty());
if let (false, Some(kept)) = (chosen, previous.as_ref().and_then(|p| p.responses.get(&req.name))) {
map.insert(req.name.clone(), kept.clone());
}
}
map.insert(crate::answers::OWNER_FIELD.to_string(), serde_json::Value::String(user.username.clone()));
}
Some((bucket, user.username.clone()))
} else {
None
};
// A record that belongs to another record: check the other one is
// there before this one is written, so a reference in a stored
// record is one that resolved at least once. An optional field
@@ -3027,6 +3100,15 @@ pub async fn submit_answer(
{
tracing::error!("failed to store answer in {bucket}: {e}");
}
if let Some((own_bucket, username)) = &owner {
let initial = state
.aggregates
.load()
.get(own_bucket)
.map(|s| s.initial.clone())
.unwrap_or_else(|| crate::answers::OPEN_STATE.to_string());
crate::answers::keep_newest_own(&state.jetstream, own_bucket, &initial, username).await;
}
// Arrival is a state too: what the schema says the first
// state sends, it sends now.
if let Some(initial) = state.aggregates.load().get(bucket).map(|s| s.initial.clone()) {