Transition.from: state graphs deeper than one decision
Deploy / deploy (push) Successful in 1m0s

Transitions now declare which state they fire from (default "open",
today's implicit behavior - existing content needs no edits). AnswerRow
renders only the buttons legal from a row's current state; two declared
transitions may share a target (open -> declined, in_dialogue ->
declined), so apply_transition matches on (from, to) against the row's
actual state instead of the old blanket "already decided" check.
validate_questions/question_lint reject a from naming an undeclared
state or a from -> to pair the bucket's graph never declared.

store_answer's KV projection now starts a record at its schema's
declared initial state, not a hardcoded "open" - organizations begin
at "prospect", and the read model has to agree with the event log
about that.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Bendik Aagaard Lynghaug
2026-08-12 22:03:43 +02:00
co-authored by Claude Sonnet 5
parent 35663a28c1
commit 40694a8f3f
3 changed files with 144 additions and 17 deletions
+24 -7
View File
@@ -1183,7 +1183,24 @@ fn AnswerRow(
pending_transitions: RwSignal<std::collections::HashMap<(String, String), String>>,
transition_batch: ServerAction<TransitionAnswers>,
) -> impl IntoView {
let is_open = answer.state == crate::answers::OPEN_STATE;
// Only the transitions legal from this row's current state - two
// declared transitions may share a target (open -> declined and
// in_dialogue -> declined), each rendered only on rows actually in
// its `from` state.
let available: Vec<Transition> = transitions
.iter()
.filter(|t| t.from == answer.state)
.cloned()
.collect();
// "Decided" (dimmed) means this row has no legal move here. A
// read-only listing (no transitions declared at all) keeps the old
// rule - dim anything past `open` - since "no legal move" would
// dim every row indiscriminately.
let decided = if transitions.is_empty() {
answer.state != crate::answers::OPEN_STATE
} else {
available.is_empty()
};
let responses = answer
.responses
.as_object()
@@ -1195,7 +1212,7 @@ fn AnswerRow(
let key = (feature_name.clone(), item_id.clone());
view! {
<article class="answer-row" class:decided=move || !is_open>
<article class="answer-row" class:decided=move || decided>
<div class="answer-fields">
<For
each={
@@ -1220,19 +1237,19 @@ fn AnswerRow(
<span class="answer-time">{format_ms(submitted_ms)}</span>
</div>
<Show when={
let transitions = transitions.clone();
move || is_open && !transitions.is_empty()
let available = available.clone();
move || !available.is_empty()
}>
{
let key = key.clone();
let transitions = transitions.clone();
let available = available.clone();
view! {
<div class="answer-actions">
<div class="select-options">
<For
each={
let transitions = transitions.clone();
move || transitions.clone()
let available = available.clone();
move || available.clone()
}
key=|t| t.to.clone()
children=move |t: Transition| {