One shared, content-labeled Confirm button per alternative instead of one per row
Deploy / deploy (push) Successful in 1m5s
Deploy / deploy (push) Successful in 1m5s
Each AnswerRow used to carry its own ServerAction<TransitionAnswer> and its own "Confirm" button - a page with N open rows showed N buttons. Selecting a transition now just toggles an entry in a shared pending_transitions map (keyed by feature_name+item_id) owned by AlternativeCard; one button, labeled from the alternative's own consequence field (same mechanism Subscribers' "Send" already used), confirms every selection at once via the new batch transition_answers. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
84c3fad339
commit
95ad459b24
+111
-92
@@ -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<std::collections::HashMap<(String, String), String>> =
|
||||
RwSignal::new(std::collections::HashMap::new());
|
||||
let transition_batch = ServerAction::<TransitionAnswers>::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<String> - 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(
|
||||
<button
|
||||
type="submit"
|
||||
class="alt-submit"
|
||||
disabled=move || submit.pending().get()
|
||||
disabled=move || {
|
||||
(!has_requirements && pending_transitions.get().is_empty())
|
||||
|| submit.pending().get()
|
||||
|| transition_batch.pending().get()
|
||||
}
|
||||
>
|
||||
{button_label.clone()}
|
||||
</button>
|
||||
@@ -805,6 +847,8 @@ fn ResourceFeature(
|
||||
alternative: String,
|
||||
feature_name: String,
|
||||
transitions: Vec<Transition>,
|
||||
pending_transitions: RwSignal<std::collections::HashMap<(String, String), String>>,
|
||||
transition_batch: ServerAction<TransitionAnswers>,
|
||||
) -> 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! {
|
||||
<div class="resource">
|
||||
<Suspense fallback=|| view! { <p class="resource-loading">"loading…"</p> }>
|
||||
{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! {
|
||||
<ResourceValue
|
||||
question_id=question_id
|
||||
alternative=alternative
|
||||
feature_name=feature_name
|
||||
transitions=transitions
|
||||
value=value
|
||||
data=data
|
||||
pending_transitions=pending_transitions
|
||||
transition_batch=transition_batch
|
||||
/>
|
||||
}
|
||||
.into_any()
|
||||
@@ -860,12 +911,11 @@ fn ResourceFeature(
|
||||
/// data decides.
|
||||
#[component]
|
||||
fn ResourceValue(
|
||||
question_id: String,
|
||||
alternative: String,
|
||||
feature_name: String,
|
||||
transitions: Vec<Transition>,
|
||||
value: serde_json::Value,
|
||||
data: Resource<Result<serde_json::Value, ServerFnError>>,
|
||||
pending_transitions: RwSignal<std::collections::HashMap<(String, String), String>>,
|
||||
transition_batch: ServerAction<TransitionAnswers>,
|
||||
) -> impl IntoView {
|
||||
if let serde_json::Value::Array(items) = &value {
|
||||
if let Ok(answers) = serde_json::from_value::<Vec<Answer>>(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! {
|
||||
<AnswerRow
|
||||
question_id=question_id.clone()
|
||||
alternative=alternative.clone()
|
||||
feature_name=feature_name.clone()
|
||||
transitions=transitions.clone()
|
||||
answer=answer
|
||||
data=data
|
||||
pending_transitions=pending_transitions
|
||||
transition_batch=transition_batch
|
||||
/>
|
||||
}
|
||||
.into_any()
|
||||
@@ -1129,12 +1176,11 @@ fn SelectField(
|
||||
|
||||
#[component]
|
||||
fn AnswerRow(
|
||||
question_id: String,
|
||||
alternative: String,
|
||||
feature_name: String,
|
||||
transitions: Vec<Transition>,
|
||||
answer: Answer,
|
||||
data: Resource<Result<serde_json::Value, ServerFnError>>,
|
||||
pending_transitions: RwSignal<std::collections::HashMap<(String, String), String>>,
|
||||
transition_batch: ServerAction<TransitionAnswers>,
|
||||
) -> 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::<TransitionAnswer>::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<Option<String>> = 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! {
|
||||
<article class="answer-row" class:decided=move || !is_open>
|
||||
@@ -1190,66 +1222,53 @@ fn AnswerRow(
|
||||
let transitions = transitions.clone();
|
||||
move || is_open && !transitions.is_empty()
|
||||
}>
|
||||
<div class="answer-actions">
|
||||
<div class="select-options">
|
||||
<For
|
||||
each={
|
||||
let transitions = transitions.clone();
|
||||
move || transitions.clone()
|
||||
}
|
||||
key=|t| t.to.clone()
|
||||
children=move |t: Transition| {
|
||||
let to_for_selected = t.to.clone();
|
||||
let to_for_click = t.to.clone();
|
||||
view! {
|
||||
<button
|
||||
type="button"
|
||||
class="select-option"
|
||||
class:selected=move || {
|
||||
selected.get().as_deref() == Some(to_for_selected.as_str())
|
||||
{
|
||||
let key = key.clone();
|
||||
let transitions = transitions.clone();
|
||||
view! {
|
||||
<div class="answer-actions">
|
||||
<div class="select-options">
|
||||
<For
|
||||
each={
|
||||
let transitions = transitions.clone();
|
||||
move || transitions.clone()
|
||||
}
|
||||
key=|t| t.to.clone()
|
||||
children=move |t: Transition| {
|
||||
let key_for_class = key.clone();
|
||||
let key_for_click = key.clone();
|
||||
let to_for_class = t.to.clone();
|
||||
let to_for_click = t.to.clone();
|
||||
view! {
|
||||
<button
|
||||
type="button"
|
||||
class="select-option"
|
||||
class:selected=move || {
|
||||
pending_transitions.get().get(&key_for_class) == Some(&to_for_class)
|
||||
}
|
||||
disabled=move || transition_batch.pending().get()
|
||||
on:click=move |_| {
|
||||
let key = key_for_click.clone();
|
||||
let to = to_for_click.clone();
|
||||
pending_transitions.update(|m| {
|
||||
if m.get(&key) == Some(&to) {
|
||||
m.remove(&key);
|
||||
} else {
|
||||
m.insert(key, to);
|
||||
}
|
||||
});
|
||||
}
|
||||
>
|
||||
{t.label.clone()}
|
||||
</button>
|
||||
}
|
||||
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()}
|
||||
</button>
|
||||
}
|
||||
.into_any()
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="answer-confirm"
|
||||
disabled=move || selected.get().is_none() || transition.pending().get()
|
||||
on:click={
|
||||
let question_id = question_id.clone();
|
||||
let alternative = alternative.clone();
|
||||
let feature_name = feature_name.clone();
|
||||
let item_id = item_id.clone();
|
||||
move |_| {
|
||||
let Some(to) = selected.get() else { return };
|
||||
transition.dispatch(TransitionAnswer {
|
||||
question_id: question_id.clone(),
|
||||
alternative: alternative.clone(),
|
||||
feature_name: feature_name.clone(),
|
||||
item_id: item_id.clone(),
|
||||
to,
|
||||
});
|
||||
}
|
||||
}
|
||||
>
|
||||
{move || if transition.pending().get() { "Confirming…" } else { "Confirm" }}
|
||||
</button>
|
||||
</div>
|
||||
.into_any()
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</Show>
|
||||
</article>
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user