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
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:
co-authored by
Claude Opus 5.5
parent
cae116b50c
commit
809a6bdfe8
+83
-1
@@ -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()) {
|
||||
|
||||
Reference in New Issue
Block a user