Resource-backed multi/single-select requirement
Deploy / deploy (push) Successful in 34s

Requirement gains an optional `resource` (reuses ResourceSpec/
ResourceSource/jq wholesale - a resource is a resource whether it's
displayed read-only or offered as choices to pick from) and `id_field`
(which field in each item is its stable id, defaults to _id then id).
`type: select` + `multiple` (already-existing field, previously file
-only) picks single vs multi. New get_requirement_options server fn
shares its auth/fetch/jq logic with get_resource via two extracted
helpers rather than duplicating it.

Submitted value is the selected id (single) or a JSON array of ids
(multi) - a new select_field_map (RwSignal<Vec<String>>, alongside the
existing field_map/file_refs maps, since a multi-select's value is a
set, not a string) threaded through the same nested <For> structure
the other requirement kinds already use.

Content validation extended: a `type: select` requirement declaring no
resource now fails at load time instead of rendering a dead field.
Caught a real bug in my own first version of that check while testing
it - it was nested inside a feature-level resource guard, so it never
ran unless the *feature* also happened to have its own resource.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Bendik Aagaard Lynghaug
2026-08-06 11:57:15 +02:00
co-authored by Claude Sonnet 5
parent 43fa496778
commit 755b796ad1
4 changed files with 324 additions and 43 deletions
+182 -8
View File
@@ -10,7 +10,7 @@ use serde::{Deserialize, Serialize};
use crate::answers::{Answer, SelfTransitionAnswer, TransitionAnswer};
use crate::auth::{current_user, User};
use crate::content::{is_qualified, Alternative, Question, Responsible, Transition};
use crate::resource::get_resource;
use crate::resource::{get_requirement_options, get_resource};
/// The visible site name/wordmark - "portal" is just this codebase's
/// working title, not necessarily what any given deployment is called.
@@ -431,15 +431,28 @@ fn AlternativeCard(
// File fields don't fit a live-typed RwSignal<String> - they get
// their own map of element refs, read (and uploaded) only at submit
// time, and are excluded from `field_map` below.
// time, and are excluded from `field_map` below. `select` fields
// don't fit it either (a multi-select's value is a *set* of ids,
// not one string) - they get their own `Vec<String>`-signal map,
// plus a plain (non-reactive, content-derived) record of which
// select fields are `multiple`, read back at submit time to decide
// whether to emit a JSON array or a single string.
let mut field_map: std::collections::HashMap<String, RwSignal<String>> =
std::collections::HashMap::new();
let mut file_refs: std::collections::HashMap<String, NodeRef<leptos::html::Input>> =
std::collections::HashMap::new();
let mut select_field_map: std::collections::HashMap<String, RwSignal<Vec<String>>> =
std::collections::HashMap::new();
let mut select_multi: std::collections::HashMap<String, bool> = std::collections::HashMap::new();
for feature in &alternative.features {
for req in &feature.requirements {
if req.kind == "file" {
file_refs.entry(req.name.clone()).or_insert_with(NodeRef::new);
} else if req.kind == "select" {
select_field_map
.entry(req.name.clone())
.or_insert_with(|| RwSignal::new(Vec::new()));
select_multi.insert(req.name.clone(), req.multiple);
} else {
field_map
.entry(req.name.clone())
@@ -465,10 +478,14 @@ fn AlternativeCard(
let parent_hash_for_submit = parent_hash.clone();
let field_map_for_submit = field_map.clone();
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 on_submit = move |ev: leptos::ev::SubmitEvent| {
ev.prevent_default();
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();
let select_multi_for_submit = select_multi_for_submit.clone();
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();
@@ -477,6 +494,16 @@ fn AlternativeCard(
for (name, sig) in field_map_for_submit.iter() {
map.insert(name.clone(), serde_json::Value::String(sig.get()));
}
for (name, sig) in select_field_map_for_submit.iter() {
let ids = sig.get();
let multiple = select_multi_for_submit.get(name).copied().unwrap_or(false);
let value = if multiple {
serde_json::Value::Array(ids.into_iter().map(serde_json::Value::String).collect())
} else {
serde_json::Value::String(ids.into_iter().next().unwrap_or_default())
};
map.insert(name.clone(), value);
}
// Only used under hydrate (file uploads are a browser-only
// concern); referenced unconditionally so an `ssr` build
// doesn't warn about it going unused.
@@ -546,12 +573,14 @@ fn AlternativeCard(
children={
let field_map = field_map.clone();
let file_refs = file_refs.clone();
let select_field_map = select_field_map.clone();
let field_prefix = field_prefix.clone();
let question_id = question_id.clone();
let alt_name = alternative.name.clone();
move |feature| {
let field_map = field_map.clone();
let file_refs = file_refs.clone();
let select_field_map = select_field_map.clone();
let field_prefix = field_prefix.clone();
let question_id = question_id.clone();
let alt_name = alt_name.clone();
@@ -590,7 +619,11 @@ fn AlternativeCard(
children={
let field_map = field_map.clone();
let file_refs = file_refs.clone();
let select_field_map = select_field_map.clone();
let field_prefix = field_prefix.clone();
let question_id = question_id.clone();
let alt_name = alt_name.clone();
let feature_name = feature_name.clone();
move |req| {
let field_id = format!("{}-{}", field_prefix, req.name);
let label_for = field_id.clone();
@@ -603,6 +636,28 @@ fn AlternativeCard(
let sig = field_map.get(&req.name).copied().unwrap_or_else(|| RwSignal::new(String::new()));
if req.kind == "select" {
let select_sig = select_field_map
.get(&req.name)
.copied()
.unwrap_or_else(|| RwSignal::new(Vec::new()));
return view! {
<div class="field">
{label_text}
<SelectField
question_id=question_id.clone()
alternative=alt_name.clone()
feature_name=feature_name.clone()
requirement_name=req.name.clone()
multiple=req.multiple
id_field=req.id_field.clone()
sig=select_sig
/>
</div>
}
.into_any();
}
if req.kind == "file" {
let file_ref = file_refs.get(&req.name).copied().unwrap_or_else(NodeRef::new);
return view! {
@@ -862,17 +917,26 @@ fn ResourceValue(
view! { <pre class="resource-raw">{value.to_string()}</pre> }.into_any()
}
/// Pulls the first present, non-null string field out of a JSON object
/// matching one of `keys`, tried in order - shared between `ItemCard`
/// and `SelectField`'s option rendering, since both need "guess a
/// display label out of an otherwise-arbitrary shaped object" and
/// should agree on the same guesses.
fn text_field(
obj: &serde_json::Map<String, serde_json::Value>,
keys: &[&str],
) -> Option<String> {
keys.iter().find_map(|k| obj.get(*k)).and_then(|v| v.as_str()).map(str::to_string)
}
/// One card in a generic (non-`Answer`) resource list - see
/// `ResourceValue`.
#[component]
fn ItemCard(item: serde_json::Value) -> impl IntoView {
let obj = item.as_object().cloned().unwrap_or_default();
let text_field = |keys: &[&str]| {
keys.iter().find_map(|k| obj.get(*k)).and_then(|v| v.as_str()).map(str::to_string)
};
let name = text_field(&["name", "title"]);
let description = text_field(&["description"]);
let url = text_field(&["url", "html_url"]);
let name = text_field(&obj, &["name", "title"]);
let description = text_field(&obj, &["description"]);
let url = text_field(&obj, &["url", "html_url"]);
let known = ["name", "title", "description", "url", "html_url"];
let extra: Vec<(String, String)> = obj
@@ -938,6 +1002,116 @@ fn ItemCard(item: serde_json::Value) -> impl IntoView {
}
}
/// A `type: select` requirement's options, fetched from
/// `Requirement.resource` (`get_requirement_options`) and rendered as
/// toggleable buttons - single-select (radio-like: picking one clears
/// any other) or multi-select (checkbox-like: each toggles
/// independently), per `multiple`. `sig` holds the currently-selected
/// id(s); the caller (`AlternativeCard`) reads it back at submit time.
#[component]
fn SelectField(
question_id: String,
alternative: String,
feature_name: String,
requirement_name: String,
multiple: bool,
/// Which field in each item is its stable id - `None` tries `_id`
/// then `id`, matching `Requirement.id_field`'s own default.
id_field: Option<String>,
sig: RwSignal<Vec<String>>,
) -> impl IntoView {
let data = Resource::new(
{
let question_id = question_id.clone();
let alternative = alternative.clone();
let feature_name = feature_name.clone();
let requirement_name = requirement_name.clone();
move || {
(
question_id.clone(),
alternative.clone(),
feature_name.clone(),
requirement_name.clone(),
)
}
},
|(q, a, f, r)| get_requirement_options(q, a, f, r, std::collections::HashMap::new()),
);
view! {
<div class="select-field">
<Suspense fallback=|| view! { <p class="resource-loading">"loading…"</p> }>
{move || {
let id_field = id_field.clone();
data.get()
.map(|res| match res {
Ok(serde_json::Value::Array(items)) if !items.is_empty() => {
view! {
<div class="select-options">
<For
each={
let items = items.clone();
move || items.clone()
}
key=|item| item.to_string()
children={
let id_field = id_field.clone();
move |item: serde_json::Value| {
let obj = item.as_object().cloned().unwrap_or_default();
let id = match &id_field {
Some(f) => obj
.get(f)
.and_then(|v| v.as_str())
.map(str::to_string),
None => text_field(&obj, &["_id", "id"]),
}
.unwrap_or_default();
let display = text_field(&obj, &["name", "title", "label"])
.unwrap_or_else(|| id.clone());
let id_for_selected = id.clone();
let id_for_click = id.clone();
view! {
<button
type="button"
class="select-option"
class:selected=move || {
sig.get().contains(&id_for_selected)
}
on:click=move |_| {
sig.update(|v| {
if let Some(pos) = v
.iter()
.position(|x| x == &id_for_click)
{
v.remove(pos);
} else if multiple {
v.push(id_for_click.clone());
} else {
*v = vec![id_for_click.clone()];
}
});
}
>
{display}
</button>
}
.into_any()
}
}
/>
</div>
}
.into_any()
}
Ok(_) => view! { <p class="resource-empty">"Nothing to pick from yet."</p> }.into_any(),
Err(e) => view! { <p class="resource-error">{e.to_string()}</p> }.into_any(),
})
}}
</Suspense>
</div>
}
}
#[component]
fn AnswerRow(
question_id: String,