diff --git a/src/answers.rs b/src/answers.rs index 8e00197..3c9e315 100644 --- a/src/answers.rs +++ b/src/answers.rs @@ -88,43 +88,83 @@ pub async fn store_answer( Ok(()) } -/// Moves a stored answer from `open` to one of the target states its -/// own resource spec declares (`ResourceSpec.transitions`) - `to` is -/// checked against that content-declared allow-list, never accepted as -/// an arbitrary string. Requires a signed-in member of the resource's -/// `requires_group`, regardless of whether the resource is otherwise -/// `public` - reads can be public, mutations always need identity. +/// One selected row's pending transition - `feature_name` is carried +/// per-item (not once per call) so a batch can span more than one +/// resource feature on the same alternative, even though today's +/// content never actually declares more than one transitionable +/// resource per alternative. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct TransitionItem { + pub feature_name: String, + pub item_id: String, + pub to: String, +} + +/// Moves every selected row to its chosen target state in one call - +/// the batched counterpart to what used to be a `transition_answer` +/// fired separately per row, one "Confirm" button each. Now there's +/// one shared button per alternative (see `AlternativeCard`), and a +/// click here applies whatever was selected across every row at once. +/// Each item is independent - there's no cross-item transaction to +/// have, only per-item CAS (see `aggregates::transition`) - so one +/// item's failure doesn't roll back or block the others. Failures are +/// collected and reported together; whatever succeeded stays applied. #[server] -pub async fn transition_answer( +pub async fn transition_answers( question_id: String, alternative: String, - feature_name: String, - item_id: String, - to: String, + items: Vec, ) -> Result<(), ServerFnError> { use crate::auth::{User, SESSION_USER_KEY}; - use crate::chain::hash_node; - use crate::events::{emit_answer_submitted, AnswerSubmitted}; use crate::server::AppState; let state = expect_context::(); - let question = state + let alt = state .questions .load() .get(&question_id) - .cloned() - .ok_or_else(|| ServerFnError::new("unknown question"))?; - // Same alternative-scoped lookup as get_resource, and for the same - // reason - a feature name isn't unique across a whole question, - // only within its own alternative. - let feature = question - .alternatives - .iter() - .find(|a| a.name == alternative) - .ok_or_else(|| ServerFnError::new("unknown alternative"))? + .and_then(|q| q.alternatives.iter().find(|a| a.name == alternative).cloned()) + .ok_or_else(|| ServerFnError::new("unknown question or alternative"))?; + + let session: tower_sessions::Session = leptos_axum::extract().await?; + let user = session + .get::(SESSION_USER_KEY) + .await + .map_err(|e| ServerFnError::new(e.to_string()))? + .ok_or_else(|| ServerFnError::new("not signed in"))?; + + let mut errors = Vec::new(); + for item in items { + let item_id = item.item_id.clone(); + if let Err(e) = apply_transition(&state, &question_id, &alt, &user, item).await { + errors.push(format!("{item_id}: {e}")); + } + } + if errors.is_empty() { + Ok(()) + } else { + Err(ServerFnError::new(errors.join("; "))) + } +} + +/// Same alternative-scoped feature lookup as `get_resource`, and for +/// the same reason - a feature name isn't unique across a whole +/// question, only within its own alternative. +#[cfg(feature = "ssr")] +async fn apply_transition( + state: &crate::server::AppState, + question_id: &str, + alt: &crate::content::Alternative, + user: &crate::auth::User, + item: TransitionItem, +) -> Result<(), ServerFnError> { + use crate::chain::hash_node; + use crate::events::{emit_answer_submitted, AnswerSubmitted}; + + let feature = alt .features .iter() - .find(|f| f.name == feature_name) + .find(|f| f.name == item.feature_name) .ok_or_else(|| ServerFnError::new("unknown feature"))?; let resource = feature .resource @@ -133,20 +173,13 @@ pub async fn transition_answer( let transition = resource .transitions .iter() - .find(|t| t.to == to) + .find(|t| t.to == item.to) .ok_or_else(|| ServerFnError::new("no such transition"))? .clone(); let group = resource .requires_group .as_deref() .ok_or_else(|| ServerFnError::new("resource has no transitions available"))?; - - let session: tower_sessions::Session = leptos_axum::extract().await?; - let user = session - .get::(SESSION_USER_KEY) - .await - .map_err(|e| ServerFnError::new(e.to_string()))? - .ok_or_else(|| ServerFnError::new("not signed in"))?; if !user.groups.iter().any(|g| g == group) { return Err(ServerFnError::new("not authorized")); } @@ -160,7 +193,7 @@ pub async fn transition_answer( .await .map_err(|e| ServerFnError::new(format!("resource bucket unavailable: {e}")))?; let bytes = store - .get(&item_id) + .get(&item.item_id) .await .map_err(|e| ServerFnError::new(e.to_string()))? .ok_or_else(|| ServerFnError::new("unknown answer"))?; @@ -172,7 +205,7 @@ pub async fn transition_answer( let decided_ms = chrono::Utc::now().timestamp_millis(); let decision_payload_for_event = - serde_json::json!({ "to": to, "item": item_id, "by": user.username }); + serde_json::json!({ "to": item.to, "item": item.item_id, "by": user.username }); // For any bucket with a declared state graph, the real fix for the // lost-update race two concurrent decisions on the same item used to @@ -182,18 +215,18 @@ pub async fn transition_answer( // the direct KV mutate this always did - not every resource has to // be event-sourced to keep working. if let Some(schema) = state.aggregates.load().get(bucket) { - crate::aggregates::transition(&state.jetstream, schema, &item_id, &to, decision_payload_for_event.clone(), decided_ms) + crate::aggregates::transition(&state.jetstream, schema, &item.item_id, &item.to, decision_payload_for_event.clone(), decided_ms) .await .map_err(|e| ServerFnError::new(e.to_string()))?; } - answer.state = to.clone(); + answer.state = item.to.clone(); answer.decided_ms = Some(decided_ms); answer.decided_by = Some(user.username.clone()); let data = serde_json::to_vec(&answer).map_err(|e| ServerFnError::new(e.to_string()))?; store - .put(&item_id, data.into()) + .put(&item.item_id, data.into()) .await .map_err(|e| ServerFnError::new(e.to_string()))?; @@ -201,12 +234,12 @@ pub async fn transition_answer( // submission hash, published the same way any other answer is - // question_id/alternative come from this call's own arguments and // the content-declared transition label, never a hardcoded value. - let parent_hashes = vec![item_id.clone()]; - let chain_hash = hash_node(&question_id, &parent_hashes, &decision_payload_for_event, decided_ms); + let parent_hashes = vec![item.item_id.clone()]; + let chain_hash = hash_node(question_id, &parent_hashes, &decision_payload_for_event, decided_ms); let event = AnswerSubmitted { chain_hash, parent_hashes, - question_id, + question_id: question_id.to_string(), alternative: transition.label, responses: decision_payload_for_event, timestamp_ms: decided_ms, diff --git a/src/app.rs b/src/app.rs index 44669cd..2416136 100644 --- a/src/app.rs +++ b/src/app.rs @@ -7,7 +7,7 @@ use leptos_router::{ }; use serde::{Deserialize, Serialize}; -use crate::answers::{Answer, SelfTransitionAnswer, TransitionAnswer}; +use crate::answers::{Answer, SelfTransitionAnswer, TransitionAnswers, TransitionItem}; use crate::auth::{current_user, User}; use crate::content::{is_qualified, Alternative, Question, Responsible, Transition}; use crate::resource::{get_requirement_options, get_resource}; @@ -436,6 +436,22 @@ fn AlternativeCard( } let has_action = alternative.action.is_some(); + let has_requirements = alternative.features.iter().any(|f| !f.requirements.is_empty()); + + // Shared across every resource feature on this alternative: which + // row(s) have a transition selected but not yet confirmed, keyed by + // (feature_name, item_id) so a batch can span more than one + // resource. One button confirms every selection at once (see + // `on_submit` below) instead of a Confirm button per row. + let pending_transitions: RwSignal> = + RwSignal::new(std::collections::HashMap::new()); + let transition_batch = ServerAction::::new(); + + Effect::new(move |_| { + if let Some(Ok(())) = transition_batch.value().get() { + pending_transitions.set(std::collections::HashMap::new()); + } + }); // File fields don't fit a live-typed RwSignal - they get // their own map of element refs, read (and uploaded) only at submit @@ -488,8 +504,28 @@ fn AlternativeCard( let file_refs_for_submit = file_refs.clone(); let select_field_map_for_submit = select_field_map.clone(); let select_multi_for_submit = select_multi.clone(); + let question_id_for_transition = question_id.clone(); + let alt_name_for_transition = alternative.name.clone(); let on_submit = move |ev: leptos::ev::SubmitEvent| { ev.prevent_default(); + + let pending = pending_transitions.get_untracked(); + if !pending.is_empty() { + let items = pending + .into_iter() + .map(|((feature_name, item_id), to)| TransitionItem { feature_name, item_id, to }) + .collect(); + transition_batch.dispatch(TransitionAnswers { + question_id: question_id_for_transition.clone(), + alternative: alt_name_for_transition.clone(), + items, + }); + } + + if !has_requirements { + return; + } + let field_map_for_submit = field_map_for_submit.clone(); let file_refs_for_submit = file_refs_for_submit.clone(); let select_field_map_for_submit = select_field_map_for_submit.clone(); @@ -610,6 +646,8 @@ fn AlternativeCard( alternative=alt_name.clone() feature_name=feature_name.clone() transitions=spec.transitions.clone() + pending_transitions=pending_transitions + transition_batch=transition_batch /> } })} @@ -781,7 +819,11 @@ fn AlternativeCard( @@ -805,6 +847,8 @@ fn ResourceFeature( alternative: String, feature_name: String, transitions: Vec, + pending_transitions: RwSignal>, + transition_batch: ServerAction, ) -> impl IntoView { let data = Resource::new( { @@ -820,12 +864,20 @@ fn ResourceFeature( |(q, a, f)| get_resource(q, a, f, std::collections::HashMap::new()), ); + // The shared batch button lives on `AlternativeCard`, above every + // feature on the page - any successful confirm refetches this + // feature's own list, whether or not the batch actually touched + // this particular resource (harmless extra refetch either way). + Effect::new(move |_| { + if let Some(Ok(())) = transition_batch.value().get() { + data.refetch(); + } + }); + view! {
"loading…"

}> {move || { - let question_id = question_id.clone(); - let alternative = alternative.clone(); let feature_name = feature_name.clone(); let transitions = transitions.clone(); data.get() @@ -833,12 +885,11 @@ fn ResourceFeature( Ok(value) => { view! { } .into_any() @@ -860,12 +911,11 @@ fn ResourceFeature( /// data decides. #[component] fn ResourceValue( - question_id: String, - alternative: String, feature_name: String, transitions: Vec, value: serde_json::Value, - data: Resource>, + pending_transitions: RwSignal>, + transition_batch: ServerAction, ) -> impl IntoView { if let serde_json::Value::Array(items) = &value { if let Ok(answers) = serde_json::from_value::>(value.clone()) { @@ -880,19 +930,16 @@ fn ResourceValue( each=move || answers.clone() key=|a| a.id.clone() children={ - let question_id = question_id.clone(); - let alternative = alternative.clone(); let feature_name = feature_name.clone(); let transitions = transitions.clone(); move |answer: Answer| { view! { } .into_any() @@ -1129,12 +1176,11 @@ fn SelectField( #[component] fn AnswerRow( - question_id: String, - alternative: String, feature_name: String, transitions: Vec, answer: Answer, - data: Resource>, + pending_transitions: RwSignal>, + transition_batch: ServerAction, ) -> impl IntoView { let is_open = answer.state == crate::answers::OPEN_STATE; let responses = answer @@ -1145,21 +1191,7 @@ fn AnswerRow( let submitted_ms = answer.submitted_ms; let state = answer.state.clone(); let item_id = answer.id.clone(); - - let transition = ServerAction::::new(); - // Toggle-select first (which transition, if any) - nothing is sent - // to the server until Confirm is pressed. Previously each button - // fired transition_answer immediately on click with no staging - // step and no visible confirmation once it landed (the list never - // refetched), so a click barely looked like it did anything. - let selected: RwSignal> = RwSignal::new(None); - - Effect::new(move |_| { - if let Some(Ok(())) = transition.value().get() { - selected.set(None); - data.refetch(); - } - }); + let key = (feature_name.clone(), item_id.clone()); view! {
@@ -1190,66 +1222,53 @@ fn AnswerRow( let transitions = transitions.clone(); move || is_open && !transitions.is_empty() }> -
-
- +
+ + {t.label.clone()} + } - on:click=move |_| { - selected - .update(|s| { - *s = if s.as_deref() == Some(to_for_click.as_str()) { - None - } else { - Some(to_for_click.clone()) - }; - }); - } - > - {t.label.clone()} - - } - .into_any() - } - /> -
- -
+ .into_any() + } + /> +
+
+ } + } } diff --git a/style/main.css b/style/main.css index a8a008b..369c7af 100644 --- a/style/main.css +++ b/style/main.css @@ -548,29 +548,6 @@ textarea:focus { gap: 0.6rem; } -.answer-confirm { - font-family: var(--sans); - font-weight: 600; - font-size: 0.82rem; - color: #0a0a0a; - background: var(--accent); - border: none; - border-radius: 0.5rem; - padding: 0.45rem 1rem; - cursor: pointer; - transition: filter 120ms; -} - -.answer-confirm:hover { - filter: brightness(1.08); -} - -.answer-confirm:disabled { - opacity: 0.5; - cursor: not-allowed; - filter: none; -} - .item-list { display: grid; gap: 0.8rem; @@ -657,6 +634,11 @@ textarea:focus { background: var(--accent); } +.select-option:disabled { + opacity: 0.5; + cursor: not-allowed; +} + @media (max-width: 640px) { .hero { padding: 3rem 1.25rem 2rem;