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
+73
-40
@@ -88,43 +88,83 @@ pub async fn store_answer(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Moves a stored answer from `open` to one of the target states its
|
/// One selected row's pending transition - `feature_name` is carried
|
||||||
/// own resource spec declares (`ResourceSpec.transitions`) - `to` is
|
/// per-item (not once per call) so a batch can span more than one
|
||||||
/// checked against that content-declared allow-list, never accepted as
|
/// resource feature on the same alternative, even though today's
|
||||||
/// an arbitrary string. Requires a signed-in member of the resource's
|
/// content never actually declares more than one transitionable
|
||||||
/// `requires_group`, regardless of whether the resource is otherwise
|
/// resource per alternative.
|
||||||
/// `public` - reads can be public, mutations always need identity.
|
#[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]
|
#[server]
|
||||||
pub async fn transition_answer(
|
pub async fn transition_answers(
|
||||||
question_id: String,
|
question_id: String,
|
||||||
alternative: String,
|
alternative: String,
|
||||||
feature_name: String,
|
items: Vec<TransitionItem>,
|
||||||
item_id: String,
|
|
||||||
to: String,
|
|
||||||
) -> Result<(), ServerFnError> {
|
) -> Result<(), ServerFnError> {
|
||||||
use crate::auth::{User, SESSION_USER_KEY};
|
use crate::auth::{User, SESSION_USER_KEY};
|
||||||
use crate::chain::hash_node;
|
|
||||||
use crate::events::{emit_answer_submitted, AnswerSubmitted};
|
|
||||||
use crate::server::AppState;
|
use crate::server::AppState;
|
||||||
|
|
||||||
let state = expect_context::<AppState>();
|
let state = expect_context::<AppState>();
|
||||||
let question = state
|
let alt = state
|
||||||
.questions
|
.questions
|
||||||
.load()
|
.load()
|
||||||
.get(&question_id)
|
.get(&question_id)
|
||||||
.cloned()
|
.and_then(|q| q.alternatives.iter().find(|a| a.name == alternative).cloned())
|
||||||
.ok_or_else(|| ServerFnError::new("unknown question"))?;
|
.ok_or_else(|| ServerFnError::new("unknown question or alternative"))?;
|
||||||
// Same alternative-scoped lookup as get_resource, and for the same
|
|
||||||
// reason - a feature name isn't unique across a whole question,
|
let session: tower_sessions::Session = leptos_axum::extract().await?;
|
||||||
// only within its own alternative.
|
let user = session
|
||||||
let feature = question
|
.get::<User>(SESSION_USER_KEY)
|
||||||
.alternatives
|
.await
|
||||||
.iter()
|
.map_err(|e| ServerFnError::new(e.to_string()))?
|
||||||
.find(|a| a.name == alternative)
|
.ok_or_else(|| ServerFnError::new("not signed in"))?;
|
||||||
.ok_or_else(|| ServerFnError::new("unknown alternative"))?
|
|
||||||
|
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
|
.features
|
||||||
.iter()
|
.iter()
|
||||||
.find(|f| f.name == feature_name)
|
.find(|f| f.name == item.feature_name)
|
||||||
.ok_or_else(|| ServerFnError::new("unknown feature"))?;
|
.ok_or_else(|| ServerFnError::new("unknown feature"))?;
|
||||||
let resource = feature
|
let resource = feature
|
||||||
.resource
|
.resource
|
||||||
@@ -133,20 +173,13 @@ pub async fn transition_answer(
|
|||||||
let transition = resource
|
let transition = resource
|
||||||
.transitions
|
.transitions
|
||||||
.iter()
|
.iter()
|
||||||
.find(|t| t.to == to)
|
.find(|t| t.to == item.to)
|
||||||
.ok_or_else(|| ServerFnError::new("no such transition"))?
|
.ok_or_else(|| ServerFnError::new("no such transition"))?
|
||||||
.clone();
|
.clone();
|
||||||
let group = resource
|
let group = resource
|
||||||
.requires_group
|
.requires_group
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.ok_or_else(|| ServerFnError::new("resource has no transitions available"))?;
|
.ok_or_else(|| ServerFnError::new("resource has no transitions available"))?;
|
||||||
|
|
||||||
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()))?
|
|
||||||
.ok_or_else(|| ServerFnError::new("not signed in"))?;
|
|
||||||
if !user.groups.iter().any(|g| g == group) {
|
if !user.groups.iter().any(|g| g == group) {
|
||||||
return Err(ServerFnError::new("not authorized"));
|
return Err(ServerFnError::new("not authorized"));
|
||||||
}
|
}
|
||||||
@@ -160,7 +193,7 @@ pub async fn transition_answer(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| ServerFnError::new(format!("resource bucket unavailable: {e}")))?;
|
.map_err(|e| ServerFnError::new(format!("resource bucket unavailable: {e}")))?;
|
||||||
let bytes = store
|
let bytes = store
|
||||||
.get(&item_id)
|
.get(&item.item_id)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ServerFnError::new(e.to_string()))?
|
.map_err(|e| ServerFnError::new(e.to_string()))?
|
||||||
.ok_or_else(|| ServerFnError::new("unknown answer"))?;
|
.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 decided_ms = chrono::Utc::now().timestamp_millis();
|
||||||
let decision_payload_for_event =
|
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
|
// 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
|
// 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
|
// the direct KV mutate this always did - not every resource has to
|
||||||
// be event-sourced to keep working.
|
// be event-sourced to keep working.
|
||||||
if let Some(schema) = state.aggregates.load().get(bucket) {
|
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
|
.await
|
||||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
.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_ms = Some(decided_ms);
|
||||||
answer.decided_by = Some(user.username.clone());
|
answer.decided_by = Some(user.username.clone());
|
||||||
|
|
||||||
let data = serde_json::to_vec(&answer).map_err(|e| ServerFnError::new(e.to_string()))?;
|
let data = serde_json::to_vec(&answer).map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
store
|
store
|
||||||
.put(&item_id, data.into())
|
.put(&item.item_id, data.into())
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
.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 -
|
// submission hash, published the same way any other answer is -
|
||||||
// question_id/alternative come from this call's own arguments and
|
// question_id/alternative come from this call's own arguments and
|
||||||
// the content-declared transition label, never a hardcoded value.
|
// the content-declared transition label, never a hardcoded value.
|
||||||
let parent_hashes = vec![item_id.clone()];
|
let parent_hashes = vec![item.item_id.clone()];
|
||||||
let chain_hash = hash_node(&question_id, &parent_hashes, &decision_payload_for_event, decided_ms);
|
let chain_hash = hash_node(question_id, &parent_hashes, &decision_payload_for_event, decided_ms);
|
||||||
let event = AnswerSubmitted {
|
let event = AnswerSubmitted {
|
||||||
chain_hash,
|
chain_hash,
|
||||||
parent_hashes,
|
parent_hashes,
|
||||||
question_id,
|
question_id: question_id.to_string(),
|
||||||
alternative: transition.label,
|
alternative: transition.label,
|
||||||
responses: decision_payload_for_event,
|
responses: decision_payload_for_event,
|
||||||
timestamp_ms: decided_ms,
|
timestamp_ms: decided_ms,
|
||||||
|
|||||||
+111
-92
@@ -7,7 +7,7 @@ use leptos_router::{
|
|||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
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::auth::{current_user, User};
|
||||||
use crate::content::{is_qualified, Alternative, Question, Responsible, Transition};
|
use crate::content::{is_qualified, Alternative, Question, Responsible, Transition};
|
||||||
use crate::resource::{get_requirement_options, get_resource};
|
use crate::resource::{get_requirement_options, get_resource};
|
||||||
@@ -436,6 +436,22 @@ fn AlternativeCard(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let has_action = alternative.action.is_some();
|
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
|
// File fields don't fit a live-typed RwSignal<String> - they get
|
||||||
// their own map of element refs, read (and uploaded) only at submit
|
// 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 file_refs_for_submit = file_refs.clone();
|
||||||
let select_field_map_for_submit = select_field_map.clone();
|
let select_field_map_for_submit = select_field_map.clone();
|
||||||
let select_multi_for_submit = select_multi.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| {
|
let on_submit = move |ev: leptos::ev::SubmitEvent| {
|
||||||
ev.prevent_default();
|
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 field_map_for_submit = field_map_for_submit.clone();
|
||||||
let file_refs_for_submit = file_refs_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();
|
let select_field_map_for_submit = select_field_map_for_submit.clone();
|
||||||
@@ -610,6 +646,8 @@ fn AlternativeCard(
|
|||||||
alternative=alt_name.clone()
|
alternative=alt_name.clone()
|
||||||
feature_name=feature_name.clone()
|
feature_name=feature_name.clone()
|
||||||
transitions=spec.transitions.clone()
|
transitions=spec.transitions.clone()
|
||||||
|
pending_transitions=pending_transitions
|
||||||
|
transition_batch=transition_batch
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
})}
|
})}
|
||||||
@@ -781,7 +819,11 @@ fn AlternativeCard(
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
class="alt-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_label.clone()}
|
||||||
</button>
|
</button>
|
||||||
@@ -805,6 +847,8 @@ fn ResourceFeature(
|
|||||||
alternative: String,
|
alternative: String,
|
||||||
feature_name: String,
|
feature_name: String,
|
||||||
transitions: Vec<Transition>,
|
transitions: Vec<Transition>,
|
||||||
|
pending_transitions: RwSignal<std::collections::HashMap<(String, String), String>>,
|
||||||
|
transition_batch: ServerAction<TransitionAnswers>,
|
||||||
) -> impl IntoView {
|
) -> impl IntoView {
|
||||||
let data = Resource::new(
|
let data = Resource::new(
|
||||||
{
|
{
|
||||||
@@ -820,12 +864,20 @@ fn ResourceFeature(
|
|||||||
|(q, a, f)| get_resource(q, a, f, std::collections::HashMap::new()),
|
|(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! {
|
view! {
|
||||||
<div class="resource">
|
<div class="resource">
|
||||||
<Suspense fallback=|| view! { <p class="resource-loading">"loading…"</p> }>
|
<Suspense fallback=|| view! { <p class="resource-loading">"loading…"</p> }>
|
||||||
{move || {
|
{move || {
|
||||||
let question_id = question_id.clone();
|
|
||||||
let alternative = alternative.clone();
|
|
||||||
let feature_name = feature_name.clone();
|
let feature_name = feature_name.clone();
|
||||||
let transitions = transitions.clone();
|
let transitions = transitions.clone();
|
||||||
data.get()
|
data.get()
|
||||||
@@ -833,12 +885,11 @@ fn ResourceFeature(
|
|||||||
Ok(value) => {
|
Ok(value) => {
|
||||||
view! {
|
view! {
|
||||||
<ResourceValue
|
<ResourceValue
|
||||||
question_id=question_id
|
|
||||||
alternative=alternative
|
|
||||||
feature_name=feature_name
|
feature_name=feature_name
|
||||||
transitions=transitions
|
transitions=transitions
|
||||||
value=value
|
value=value
|
||||||
data=data
|
pending_transitions=pending_transitions
|
||||||
|
transition_batch=transition_batch
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
.into_any()
|
.into_any()
|
||||||
@@ -860,12 +911,11 @@ fn ResourceFeature(
|
|||||||
/// data decides.
|
/// data decides.
|
||||||
#[component]
|
#[component]
|
||||||
fn ResourceValue(
|
fn ResourceValue(
|
||||||
question_id: String,
|
|
||||||
alternative: String,
|
|
||||||
feature_name: String,
|
feature_name: String,
|
||||||
transitions: Vec<Transition>,
|
transitions: Vec<Transition>,
|
||||||
value: serde_json::Value,
|
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 {
|
) -> impl IntoView {
|
||||||
if let serde_json::Value::Array(items) = &value {
|
if let serde_json::Value::Array(items) = &value {
|
||||||
if let Ok(answers) = serde_json::from_value::<Vec<Answer>>(value.clone()) {
|
if let Ok(answers) = serde_json::from_value::<Vec<Answer>>(value.clone()) {
|
||||||
@@ -880,19 +930,16 @@ fn ResourceValue(
|
|||||||
each=move || answers.clone()
|
each=move || answers.clone()
|
||||||
key=|a| a.id.clone()
|
key=|a| a.id.clone()
|
||||||
children={
|
children={
|
||||||
let question_id = question_id.clone();
|
|
||||||
let alternative = alternative.clone();
|
|
||||||
let feature_name = feature_name.clone();
|
let feature_name = feature_name.clone();
|
||||||
let transitions = transitions.clone();
|
let transitions = transitions.clone();
|
||||||
move |answer: Answer| {
|
move |answer: Answer| {
|
||||||
view! {
|
view! {
|
||||||
<AnswerRow
|
<AnswerRow
|
||||||
question_id=question_id.clone()
|
|
||||||
alternative=alternative.clone()
|
|
||||||
feature_name=feature_name.clone()
|
feature_name=feature_name.clone()
|
||||||
transitions=transitions.clone()
|
transitions=transitions.clone()
|
||||||
answer=answer
|
answer=answer
|
||||||
data=data
|
pending_transitions=pending_transitions
|
||||||
|
transition_batch=transition_batch
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
.into_any()
|
.into_any()
|
||||||
@@ -1129,12 +1176,11 @@ fn SelectField(
|
|||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
fn AnswerRow(
|
fn AnswerRow(
|
||||||
question_id: String,
|
|
||||||
alternative: String,
|
|
||||||
feature_name: String,
|
feature_name: String,
|
||||||
transitions: Vec<Transition>,
|
transitions: Vec<Transition>,
|
||||||
answer: Answer,
|
answer: Answer,
|
||||||
data: Resource<Result<serde_json::Value, ServerFnError>>,
|
pending_transitions: RwSignal<std::collections::HashMap<(String, String), String>>,
|
||||||
|
transition_batch: ServerAction<TransitionAnswers>,
|
||||||
) -> impl IntoView {
|
) -> impl IntoView {
|
||||||
let is_open = answer.state == crate::answers::OPEN_STATE;
|
let is_open = answer.state == crate::answers::OPEN_STATE;
|
||||||
let responses = answer
|
let responses = answer
|
||||||
@@ -1145,21 +1191,7 @@ fn AnswerRow(
|
|||||||
let submitted_ms = answer.submitted_ms;
|
let submitted_ms = answer.submitted_ms;
|
||||||
let state = answer.state.clone();
|
let state = answer.state.clone();
|
||||||
let item_id = answer.id.clone();
|
let item_id = answer.id.clone();
|
||||||
|
let key = (feature_name.clone(), item_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();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<article class="answer-row" class:decided=move || !is_open>
|
<article class="answer-row" class:decided=move || !is_open>
|
||||||
@@ -1190,66 +1222,53 @@ fn AnswerRow(
|
|||||||
let transitions = transitions.clone();
|
let transitions = transitions.clone();
|
||||||
move || is_open && !transitions.is_empty()
|
move || is_open && !transitions.is_empty()
|
||||||
}>
|
}>
|
||||||
<div class="answer-actions">
|
{
|
||||||
<div class="select-options">
|
let key = key.clone();
|
||||||
<For
|
let transitions = transitions.clone();
|
||||||
each={
|
view! {
|
||||||
let transitions = transitions.clone();
|
<div class="answer-actions">
|
||||||
move || transitions.clone()
|
<div class="select-options">
|
||||||
}
|
<For
|
||||||
key=|t| t.to.clone()
|
each={
|
||||||
children=move |t: Transition| {
|
let transitions = transitions.clone();
|
||||||
let to_for_selected = t.to.clone();
|
move || transitions.clone()
|
||||||
let to_for_click = t.to.clone();
|
}
|
||||||
view! {
|
key=|t| t.to.clone()
|
||||||
<button
|
children=move |t: Transition| {
|
||||||
type="button"
|
let key_for_class = key.clone();
|
||||||
class="select-option"
|
let key_for_click = key.clone();
|
||||||
class:selected=move || {
|
let to_for_class = t.to.clone();
|
||||||
selected.get().as_deref() == Some(to_for_selected.as_str())
|
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 |_| {
|
.into_any()
|
||||||
selected
|
}
|
||||||
.update(|s| {
|
/>
|
||||||
*s = if s.as_deref() == Some(to_for_click.as_str()) {
|
</div>
|
||||||
None
|
</div>
|
||||||
} 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>
|
|
||||||
</Show>
|
</Show>
|
||||||
</article>
|
</article>
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-23
@@ -548,29 +548,6 @@ textarea:focus {
|
|||||||
gap: 0.6rem;
|
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 {
|
.item-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.8rem;
|
gap: 0.8rem;
|
||||||
@@ -657,6 +634,11 @@ textarea:focus {
|
|||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.select-option:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.hero {
|
.hero {
|
||||||
padding: 3rem 1.25rem 2rem;
|
padding: 3rem 1.25rem 2rem;
|
||||||
|
|||||||
Reference in New Issue
Block a user