diff --git a/CHANGELOG.md b/CHANGELOG.md index e70560e..f78ba62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ One line per change, grouped by the release that shipped it. Newest first. A release is a Cargo.toml bump plus a `v` tag; the content repos pin the tag they run (see the end of this file). +## Unreleased + +- `one_per_person: true` on an alternative: each signed-in person keeps one record there, their own. The form opens filled in with it, a file left unchosen keeps the one already there (shown beside the picker), and sending again replaces the card instead of adding one. Two sends at once settle on the newest. Trainer: `one_per_person` on a need entered from inside; Klingenberg Bygg's people each edit their own card on the front page. +- Send is disabled from the moment it is pressed. Files upload before the form is sent, and a second press in that time used to send it twice. +- A failed upload stops the send and says so. The form used to go without the file, which made a record that looked complete and was not. +- A refused send says why, under its button. It used to say nothing. + ## 0.6.5 (2026-09-30) - Trainer: `paused: true` on a need. Its card stays on the front door with what the business does and a sentence saying it is not taking new ones right now, with no form and a button that does not press; nothing is recorded. The lint reports a paused need as a warning ("paused, not open now") and counts it on its own in the summary, as `planned` already did for needs not open yet. Klingenberg Bygg's building applications are paused for capacity. diff --git a/plans/klingenbergbygg.yaml b/plans/klingenbergbygg.yaml index 875051c..024aa9d 100644 --- a/plans/klingenbergbygg.yaml +++ b/plans/klingenbergbygg.yaml @@ -145,12 +145,13 @@ needs: - { name: hva } - { name: dokument, type: file } - # Menneskene: kontoret legger inn hver ansatt med navn, rolle, - # erfaring og portrett, og forsiden viser dem alle. + # Menneskene: hver ansatt har sitt eget kort på forsiden, med rolle, + # erfaring og portrett, og retter det selv. Kontoret kan ta et kort ned. - id: ansatt - who: kontoret, når noen begynner hos oss eller skal ha nytt portrett + who: en av oss på kontoret, som vil vise hvem hen er og hva hen kan wants: at den som vurderer oss ser hvem som gjør jobben enters_as: kontor + one_per_person: true lands_in: ansatte handled_by: kontor watched_by: [public] diff --git a/plans/words/klingenbergbygg.json b/plans/words/klingenbergbygg.json index 9392890..95dbcd0 100644 --- a/plans/words/klingenbergbygg.json +++ b/plans/words/klingenbergbygg.json @@ -258,7 +258,7 @@ }, "ansatte": { "heading": "Ansatte på forsiden", - "description": "Menneskene slik besøkende ser dem.", + "description": "Menneskene slik besøkende ser dem. Hver retter sitt eget kort; herfra kan et kort tas ned.", "moves": { "open->sluttet": "Tatt av forsiden" }, @@ -273,8 +273,8 @@ "button": "Ny leveranse" }, "ansatt": { - "heading": "Legg inn en ansatt", - "description": "Navn, rolle, erfaring og portrett, rett ut på forsiden.", + "heading": "Ditt kort på forsiden", + "description": "Rolle, erfaring og portrett, slik besøkende ser deg.", "button": "Åpne" } }, @@ -471,10 +471,10 @@ } }, "questions/kontor/ansatt-sent.yaml": { - "question": "Lagt ut", - "description": "Den ansatte vises nå blant menneskene på forsiden.", - "next_heading": "Skal noen ned igjen?", - "next_description": "Trykk «Tatt av forsiden» på raden deres i listen over ansatte på kontoret. Nytt portrett: legg personen inn på nytt, og ta den gamle raden av.", + "question": "Kortet ditt er lagret", + "description": "Slik står du nå på forsiden.", + "next_heading": "Vil du endre noe?", + "next_description": "Åpne «Ditt kort på forsiden» igjen: det er fylt ut med det du har, og det du lagrer erstatter det.", "see_all": { "heading": "Se alle ansatte", "description": "", @@ -482,21 +482,21 @@ } }, "questions/kontor/ansatt.yaml": { - "heading": "Legg inn en ansatt", - "description": "Navn, rolle, erfaring og portrett. Den ansatte vises på forsiden så snart du sender.", - "button": "Legg ut", + "heading": "Ditt kort på forsiden", + "description": "Slik står du blant menneskene på forsiden. Rett det du vil og lagre; du har ett kort, og det nye erstatter det gamle.", + "button": "Lagre kortet", "encouragement": "", - "icon": "user-plus", + "icon": "id-card", "requires_title": "", "requires_description": "", "requires_icon": "", - "road_title": "Hva skjer så", - "road_description": "Personen vises på forsiden til du tar dem av igjen fra kontoret.", + "road_title": "Når du lagrer", + "road_description": "Kortet ditt på forsiden endres med en gang.", "road_icon": "route", "who_title": "Hvem ser det", - "who_description": "Alle som besøker siden. Portrettet vises som det er, så velg et bilde du er fornøyd med.", + "who_description": "Alle som besøker siden. Navnet ditt er det du er logget inn med.", "who_icon": "users", - "form_title": "Den ansatte", + "form_title": "Kortet ditt", "form_description": "", "labels": { "name": "Navn", @@ -508,10 +508,10 @@ "name": "", "rolle": "For eksempel daglig leder, rådgivende ingeniør", "erfaring": "Utdanning, fagområder, år i faget, bygg du er stolt av", - "portrett": "Et stående bilde, gjerne tatt i dagslys" + "portrett": "Et stående bilde, gjerne tatt i dagslys. Velger du ikke noe nytt, beholder du det du har." }, - "question": "Hvem skal vises på forsiden?", - "page_description": "" + "question": "Ditt kort på forsiden", + "page_description": "Det du skriver her, står under portrettet ditt på forsiden." }, "questions/status/ansatte/index.yaml": { "question": "Hvem består Klingenberg Bygg av?", diff --git a/src/answers.rs b/src/answers.rs index c570f9b..73f8f18 100644 --- a/src/answers.rs +++ b/src/answers.rs @@ -114,6 +114,73 @@ pub async fn store_answer( Ok(()) } +/// Whose a record is, on an alternative that keeps one per person +/// (`one_per_person`): the signed-in person's username, written by the +/// server into the record's fields, never taken from the form. +pub const OWNER_FIELD: &str = "by"; + +/// Every record in `bucket` that belongs to `username`, newest first. +#[cfg(feature = "ssr")] +pub async fn own_records(js: &async_nats::jetstream::Context, bucket: &str, username: &str) -> Vec { + use futures::TryStreamExt; + let Ok(store) = js.get_key_value(bucket).await else { return Vec::new() }; + let keys: Vec = match store.keys().await { + Ok(keys) => keys.try_collect().await.unwrap_or_default(), + Err(_) => return Vec::new(), + }; + let mut own = Vec::new(); + for key in keys { + let Ok(Some(bytes)) = store.get(&key).await else { continue }; + let Ok(answer) = serde_json::from_slice::(&bytes) else { continue }; + if answer.responses.get(OWNER_FIELD).and_then(|v| v.as_str()) == Some(username) { + own.push(answer); + } + } + own.sort_by(|a, b| (b.submitted_ms, &b.id).cmp(&(a.submitted_ms, &a.id))); + own +} + +/// A person keeps one card: of their records still in the bucket's +/// first state, only the newest stays in the list, and the rest leave +/// it (the event log keeps them). Run after every store, so two sends +/// racing each other - a double click - both settle on the same one. +#[cfg(feature = "ssr")] +pub async fn keep_newest_own(js: &async_nats::jetstream::Context, bucket: &str, initial: &str, username: &str) { + let own = own_records(js, bucket, username).await; + let Ok(store) = js.get_key_value(bucket).await else { return }; + for older in own.iter().filter(|a| a.state == initial).skip(1) { + if let Err(e) = store.delete(&older.id).await { + tracing::error!(bucket, id = %older.id, error = %e, "could not retire an older own record"); + } + } +} + +/// Fills the form of every `one_per_person` alternative with what the +/// signed-in person's own newest record says, so sending it again is +/// editing their card. A file field shows the file already there, at +/// the address a list would open it from, and may be left empty to +/// keep it. +#[cfg(feature = "ssr")] +pub async fn fill_own(js: &async_nats::jetstream::Context, question: &mut crate::content::Question, user: Option<&crate::auth::User>) { + let Some(user) = user else { return }; + for alt in &mut question.alternatives { + let (true, Some(bucket)) = (alt.one_per_person, alt.record_as.clone()) else { continue }; + let Some(mine) = own_records(js, &bucket, &user.username).await.into_iter().next() else { continue }; + for req in alt.features.iter_mut().flat_map(|f| f.requirements.iter_mut()) { + if req.kind == "hidden" { + continue; + } + let Some(value) = mine.responses.get(&req.name).and_then(|v| v.as_str()).filter(|v| !v.trim().is_empty()) else { continue }; + if req.kind == "file" { + req.value = Some(format!("/attachment/{bucket}/{}/{}", mine.id, req.name)); + req.optional = true; + } else { + req.value = Some(value.to_string()); + } + } + } +} + /// One selected row's pending transition. `feature_name` is per-item /// so a batch can span more than one resource feature on the same /// alternative. diff --git a/src/app.rs b/src/app.rs index d541122..6d848f5 100644 --- a/src/app.rs +++ b/src/app.rs @@ -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 = 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> = 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! { + + {move || t(&lang().get(), "current_file")} + {if picture { + view! { }.into_any() + } else { + view! { {href.rsplit('/').next().unwrap_or_default().to_string()} }.into_any() + }} + {move || t(&lang().get(), "keep_file")} + + } + }); return view! {