From 755b796ad14a6e42567d262ecaa7e4e1f27f70e4 Mon Sep 17 00:00:00 2001 From: Bendik Aagaard Lynghaug Date: Thu, 6 Aug 2026 11:57:15 +0200 Subject: [PATCH] Resource-backed multi/single-select requirement 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>, 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 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 --- src/app.rs | 190 ++++++++++++++++++++++++++++++++++++++++++++++-- src/content.rs | 26 ++++++- src/resource.rs | 127 +++++++++++++++++++++++--------- style/main.css | 24 ++++++ 4 files changed, 324 insertions(+), 43 deletions(-) diff --git a/src/app.rs b/src/app.rs index 7af8ca5..0044907 100644 --- a/src/app.rs +++ b/src/app.rs @@ -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 - 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`-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> = std::collections::HashMap::new(); let mut file_refs: std::collections::HashMap> = std::collections::HashMap::new(); + let mut select_field_map: std::collections::HashMap>> = + std::collections::HashMap::new(); + let mut select_multi: std::collections::HashMap = 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! { +
+ {label_text} + +
+ } + .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! {
{value.to_string()}
}.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, + keys: &[&str], +) -> Option { + 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, + sig: RwSignal>, +) -> 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! { +
+ "loading…"

}> + {move || { + let id_field = id_field.clone(); + data.get() + .map(|res| match res { + Ok(serde_json::Value::Array(items)) if !items.is_empty() => { + view! { +
+ 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! { + + } + .into_any() + } + } + /> +
+ } + .into_any() + } + Ok(_) => view! {

"Nothing to pick from yet."

}.into_any(), + Err(e) => view! {

{e.to_string()}

}.into_any(), + }) + }} +
+
+ } +} + #[component] fn AnswerRow( question_id: String, diff --git a/src/content.rs b/src/content.rs index 2e53a46..3bd509f 100644 --- a/src/content.rs +++ b/src/content.rs @@ -209,13 +209,29 @@ pub struct Requirement { pub kind: String, #[serde(default)] pub optional: bool, - /// `type: file` only - accept multiple files. + /// `type: file` - accept multiple files. `type: select` - pick more + /// than one option (checkbox-style toggle) instead of exactly one + /// (radio-style); the submitted value is a JSON array of ids + /// instead of a single id string. #[serde(default)] pub multiple: bool, /// `type: file` only - HTML `accept` hint (UX only, not a security /// boundary - the upload handler re-checks content-type itself). #[serde(default)] pub accept: Option, + /// `type: select` only - where the selectable options come from. + /// Reuses the exact same `ResourceSpec`/`ResourceSource`/`jq` + /// mechanism a `Feature.resource` uses (`resource::get_requirement_options`) + /// - a resource is a resource regardless of whether it's displayed + /// read-only or offered as choices to pick from. + #[serde(default)] + pub resource: Option, + /// `type: select` only - which field in each resource item is that + /// option's stable identifier, submitted as the requirement's value + /// (or one entry of it, if `multiple`). Defaults to trying `_id` + /// then `id` if unset. + #[serde(default)] + pub id_field: Option, } fn default_requirement_type() -> String { @@ -372,6 +388,14 @@ pub fn validate_questions( } } for feature in &alternative.features { + for requirement in &feature.requirements { + if requirement.kind == "select" && requirement.resource.is_none() { + anyhow::bail!( + "question {:?} alternative {:?} feature {:?}: requirement {:?} is type: select but declares no resource to select from", + question.id, alternative.name, feature.name, requirement.name + ); + } + } let Some(resource) = &feature.resource else { continue; }; diff --git a/src/resource.rs b/src/resource.rs index b347517..a6da68e 100644 --- a/src/resource.rs +++ b/src/resource.rs @@ -23,26 +23,72 @@ pub async fn get_resource( feature_name: String, params: std::collections::HashMap, ) -> Result { - use crate::auth::{User, SESSION_USER_KEY}; - use crate::content::ResourceSource; use crate::server::AppState; let state = expect_context::(); + let feature = find_feature(&state, &question_id, &alternative, &feature_name)?; + let resource = feature + .resource + .as_ref() + .ok_or_else(|| ServerFnError::new("feature has no resource"))?; + authorize_resource(resource).await?; + fetch_resource_value(&state, resource, ¶ms).await +} + +/// The `Requirement.resource`-backed counterpart to `get_resource` - +/// same lookup/auth/fetch machinery, just resolving through a named +/// requirement's own resource (the option source for a `type: select` +/// field) instead of a feature's. A resource is a resource regardless +/// of whether it's displayed read-only or offered as choices to pick +/// from, so this deliberately doesn't duplicate the source-dispatch or +/// jq-shaping logic - see `fetch_resource_value`. +#[server] +pub async fn get_requirement_options( + question_id: String, + alternative: String, + feature_name: String, + requirement_name: String, + params: std::collections::HashMap, +) -> Result { + use crate::server::AppState; + + let state = expect_context::(); + let feature = find_feature(&state, &question_id, &alternative, &feature_name)?; + let requirement = feature + .requirements + .iter() + .find(|r| r.name == requirement_name) + .ok_or_else(|| ServerFnError::new("unknown requirement"))?; + let resource = requirement + .resource + .as_ref() + .ok_or_else(|| ServerFnError::new("requirement has no resource"))?; + authorize_resource(resource).await?; + fetch_resource_value(&state, resource, ¶ms).await +} + +/// Scoped to the named alternative first, not flattened across all of +/// them - a feature name (often just "") is only unique within its own +/// alternative, not across a whole question. Flattening silently +/// resolved every same-named feature to whichever alternative happened +/// to be first, so "Subscribers" (and any other later resource-listing +/// alternative sharing an unnamed feature with an earlier one on the +/// same question) always read the first alternative's bucket instead +/// of its own. +#[cfg(feature = "ssr")] +fn find_feature( + state: &crate::server::AppState, + question_id: &str, + alternative: &str, + feature_name: &str, +) -> Result { let question = state .questions .load() - .get(&question_id) + .get(question_id) .cloned() .ok_or_else(|| ServerFnError::new("unknown question"))?; - // Scoped to the named alternative first, not flattened across all - // of them - a feature name (often just "") is only unique within - // its own alternative, not across a whole question. Flattening - // silently resolved every same-named feature to whichever - // alternative happened to be first, so "Subscribers" (and any - // other later resource-listing alternative sharing an unnamed - // feature with an earlier one on the same question) always read - // the first alternative's bucket instead of its own. - let feature = question + question .alternatives .iter() .find(|a| a.name == alternative) @@ -50,27 +96,40 @@ pub async fn get_resource( .features .iter() .find(|f| f.name == feature_name) - .ok_or_else(|| ServerFnError::new("unknown feature"))?; - let resource = feature - .resource - .as_ref() - .ok_or_else(|| ServerFnError::new("feature has no resource"))?; + .cloned() + .ok_or_else(|| ServerFnError::new("unknown feature")) +} - if !resource.public { - let group = resource - .requires_group - .as_deref() - .ok_or_else(|| ServerFnError::new("resource is not accessible"))?; - 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")); - } +#[cfg(feature = "ssr")] +async fn authorize_resource(resource: &crate::content::ResourceSpec) -> Result<(), ServerFnError> { + use crate::auth::{User, SESSION_USER_KEY}; + + if resource.public { + return Ok(()); } + let group = resource + .requires_group + .as_deref() + .ok_or_else(|| ServerFnError::new("resource is not accessible"))?; + 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")); + } + Ok(()) +} + +#[cfg(feature = "ssr")] +async fn fetch_resource_value( + state: &crate::server::AppState, + resource: &crate::content::ResourceSpec, + params: &std::collections::HashMap, +) -> Result { + use crate::content::ResourceSource; let value = match &resource.source { ResourceSource::Kv { bucket } => { @@ -110,12 +169,12 @@ pub async fn get_resource( } } ResourceSource::GiteaStarred { username } => { - fetch_gitea_json(&state, &format!("/api/v1/users/{username}/starred"), ¶ms).await? + fetch_gitea_json(state, &format!("/api/v1/users/{username}/starred"), params).await? } ResourceSource::GiteaOrgRepos { org } => { - fetch_gitea_json(&state, &format!("/api/v1/orgs/{org}/repos"), ¶ms).await? + fetch_gitea_json(state, &format!("/api/v1/orgs/{org}/repos"), params).await? } - ResourceSource::Url { url } => fetch_url_json(url, ¶ms).await?, + ResourceSource::Url { url } => fetch_url_json(url, params).await?, }; match &resource.jq { diff --git a/style/main.css b/style/main.css index 6d39aee..c2a687b 100644 --- a/style/main.css +++ b/style/main.css @@ -411,6 +411,30 @@ input:focus, textarea:focus { margin-right: 0.3rem; } +.select-field { margin-top: 0.3rem; } + +.select-options { display: flex; flex-wrap: wrap; gap: 0.5rem; } + +.select-option { + font-family: var(--sans); + font-size: 0.85rem; + color: var(--ink); + background: var(--paper); + border: 1px solid var(--line); + border-radius: 999px; + padding: 0.4rem 0.9rem; + cursor: pointer; + transition: border-color 120ms, color 120ms, background 120ms; +} + +.select-option:hover { border-color: var(--accent); color: var(--accent); } + +.select-option.selected { + border-color: var(--accent); + color: var(--paper); + background: var(--accent); +} + @media (max-width: 640px) { .hero { padding: 3rem 1.25rem 2rem; } .alt-card { padding: 1.4rem 1.3rem; }