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
+93 -34
View File
@@ -23,26 +23,72 @@ pub async fn get_resource(
feature_name: String,
params: std::collections::HashMap<String, String>,
) -> Result<serde_json::Value, ServerFnError> {
use crate::auth::{User, SESSION_USER_KEY};
use crate::content::ResourceSource;
use crate::server::AppState;
let state = expect_context::<AppState>();
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, &params).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<String, String>,
) -> Result<serde_json::Value, ServerFnError> {
use crate::server::AppState;
let state = expect_context::<AppState>();
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, &params).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<crate::content::Feature, ServerFnError> {
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::<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) {
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::<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) {
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<String, String>,
) -> Result<serde_json::Value, ServerFnError> {
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"), &params).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"), &params).await?
fetch_gitea_json(state, &format!("/api/v1/orgs/{org}/repos"), params).await?
}
ResourceSource::Url { url } => fetch_url_json(url, &params).await?,
ResourceSource::Url { url } => fetch_url_json(url, params).await?,
};
match &resource.jq {