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:
co-authored by
Claude Sonnet 5
parent
43fa496778
commit
755b796ad1
+182
-8
@@ -10,7 +10,7 @@ use serde::{Deserialize, Serialize};
|
|||||||
use crate::answers::{Answer, SelfTransitionAnswer, TransitionAnswer};
|
use crate::answers::{Answer, SelfTransitionAnswer, TransitionAnswer};
|
||||||
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_resource;
|
use crate::resource::{get_requirement_options, get_resource};
|
||||||
|
|
||||||
/// The visible site name/wordmark - "portal" is just this codebase's
|
/// The visible site name/wordmark - "portal" is just this codebase's
|
||||||
/// working title, not necessarily what any given deployment is called.
|
/// 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
|
// 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
|
||||||
// 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>> =
|
let mut field_map: std::collections::HashMap<String, RwSignal<String>> =
|
||||||
std::collections::HashMap::new();
|
std::collections::HashMap::new();
|
||||||
let mut file_refs: std::collections::HashMap<String, NodeRef<leptos::html::Input>> =
|
let mut file_refs: std::collections::HashMap<String, NodeRef<leptos::html::Input>> =
|
||||||
std::collections::HashMap::new();
|
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 feature in &alternative.features {
|
||||||
for req in &feature.requirements {
|
for req in &feature.requirements {
|
||||||
if req.kind == "file" {
|
if req.kind == "file" {
|
||||||
file_refs.entry(req.name.clone()).or_insert_with(NodeRef::new);
|
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 {
|
} else {
|
||||||
field_map
|
field_map
|
||||||
.entry(req.name.clone())
|
.entry(req.name.clone())
|
||||||
@@ -465,10 +478,14 @@ fn AlternativeCard(
|
|||||||
let parent_hash_for_submit = parent_hash.clone();
|
let parent_hash_for_submit = parent_hash.clone();
|
||||||
let field_map_for_submit = field_map.clone();
|
let field_map_for_submit = field_map.clone();
|
||||||
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_multi_for_submit = select_multi.clone();
|
||||||
let on_submit = move |ev: leptos::ev::SubmitEvent| {
|
let on_submit = move |ev: leptos::ev::SubmitEvent| {
|
||||||
ev.prevent_default();
|
ev.prevent_default();
|
||||||
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_multi_for_submit = select_multi_for_submit.clone();
|
||||||
let question_id_for_submit = question_id_for_submit.clone();
|
let question_id_for_submit = question_id_for_submit.clone();
|
||||||
let alt_name_for_submit = alt_name_for_submit.clone();
|
let alt_name_for_submit = alt_name_for_submit.clone();
|
||||||
let parent_hash_for_submit = parent_hash_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() {
|
for (name, sig) in field_map_for_submit.iter() {
|
||||||
map.insert(name.clone(), serde_json::Value::String(sig.get()));
|
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
|
// Only used under hydrate (file uploads are a browser-only
|
||||||
// concern); referenced unconditionally so an `ssr` build
|
// concern); referenced unconditionally so an `ssr` build
|
||||||
// doesn't warn about it going unused.
|
// doesn't warn about it going unused.
|
||||||
@@ -546,12 +573,14 @@ fn AlternativeCard(
|
|||||||
children={
|
children={
|
||||||
let field_map = field_map.clone();
|
let field_map = field_map.clone();
|
||||||
let file_refs = file_refs.clone();
|
let file_refs = file_refs.clone();
|
||||||
|
let select_field_map = select_field_map.clone();
|
||||||
let field_prefix = field_prefix.clone();
|
let field_prefix = field_prefix.clone();
|
||||||
let question_id = question_id.clone();
|
let question_id = question_id.clone();
|
||||||
let alt_name = alternative.name.clone();
|
let alt_name = alternative.name.clone();
|
||||||
move |feature| {
|
move |feature| {
|
||||||
let field_map = field_map.clone();
|
let field_map = field_map.clone();
|
||||||
let file_refs = file_refs.clone();
|
let file_refs = file_refs.clone();
|
||||||
|
let select_field_map = select_field_map.clone();
|
||||||
let field_prefix = field_prefix.clone();
|
let field_prefix = field_prefix.clone();
|
||||||
let question_id = question_id.clone();
|
let question_id = question_id.clone();
|
||||||
let alt_name = alt_name.clone();
|
let alt_name = alt_name.clone();
|
||||||
@@ -590,7 +619,11 @@ fn AlternativeCard(
|
|||||||
children={
|
children={
|
||||||
let field_map = field_map.clone();
|
let field_map = field_map.clone();
|
||||||
let file_refs = file_refs.clone();
|
let file_refs = file_refs.clone();
|
||||||
|
let select_field_map = select_field_map.clone();
|
||||||
let field_prefix = field_prefix.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| {
|
move |req| {
|
||||||
let field_id = format!("{}-{}", field_prefix, req.name);
|
let field_id = format!("{}-{}", field_prefix, req.name);
|
||||||
let label_for = field_id.clone();
|
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()));
|
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" {
|
if req.kind == "file" {
|
||||||
let file_ref = file_refs.get(&req.name).copied().unwrap_or_else(NodeRef::new);
|
let file_ref = file_refs.get(&req.name).copied().unwrap_or_else(NodeRef::new);
|
||||||
return view! {
|
return view! {
|
||||||
@@ -862,17 +917,26 @@ fn ResourceValue(
|
|||||||
view! { <pre class="resource-raw">{value.to_string()}</pre> }.into_any()
|
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
|
/// One card in a generic (non-`Answer`) resource list - see
|
||||||
/// `ResourceValue`.
|
/// `ResourceValue`.
|
||||||
#[component]
|
#[component]
|
||||||
fn ItemCard(item: serde_json::Value) -> impl IntoView {
|
fn ItemCard(item: serde_json::Value) -> impl IntoView {
|
||||||
let obj = item.as_object().cloned().unwrap_or_default();
|
let obj = item.as_object().cloned().unwrap_or_default();
|
||||||
let text_field = |keys: &[&str]| {
|
let name = text_field(&obj, &["name", "title"]);
|
||||||
keys.iter().find_map(|k| obj.get(*k)).and_then(|v| v.as_str()).map(str::to_string)
|
let description = text_field(&obj, &["description"]);
|
||||||
};
|
let url = text_field(&obj, &["url", "html_url"]);
|
||||||
let name = text_field(&["name", "title"]);
|
|
||||||
let description = text_field(&["description"]);
|
|
||||||
let url = text_field(&["url", "html_url"]);
|
|
||||||
|
|
||||||
let known = ["name", "title", "description", "url", "html_url"];
|
let known = ["name", "title", "description", "url", "html_url"];
|
||||||
let extra: Vec<(String, String)> = obj
|
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]
|
#[component]
|
||||||
fn AnswerRow(
|
fn AnswerRow(
|
||||||
question_id: String,
|
question_id: String,
|
||||||
|
|||||||
+25
-1
@@ -209,13 +209,29 @@ pub struct Requirement {
|
|||||||
pub kind: String,
|
pub kind: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub optional: bool,
|
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)]
|
#[serde(default)]
|
||||||
pub multiple: bool,
|
pub multiple: bool,
|
||||||
/// `type: file` only - HTML `accept` hint (UX only, not a security
|
/// `type: file` only - HTML `accept` hint (UX only, not a security
|
||||||
/// boundary - the upload handler re-checks content-type itself).
|
/// boundary - the upload handler re-checks content-type itself).
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub accept: Option<String>,
|
pub accept: Option<String>,
|
||||||
|
/// `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<ResourceSpec>,
|
||||||
|
/// `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<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_requirement_type() -> String {
|
fn default_requirement_type() -> String {
|
||||||
@@ -372,6 +388,14 @@ pub fn validate_questions(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
for feature in &alternative.features {
|
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 {
|
let Some(resource) = &feature.resource else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
|||||||
+80
-21
@@ -23,26 +23,72 @@ pub async fn get_resource(
|
|||||||
feature_name: String,
|
feature_name: String,
|
||||||
params: std::collections::HashMap<String, String>,
|
params: std::collections::HashMap<String, String>,
|
||||||
) -> Result<serde_json::Value, ServerFnError> {
|
) -> Result<serde_json::Value, ServerFnError> {
|
||||||
use crate::auth::{User, SESSION_USER_KEY};
|
|
||||||
use crate::content::ResourceSource;
|
|
||||||
use crate::server::AppState;
|
use crate::server::AppState;
|
||||||
|
|
||||||
let state = expect_context::<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, ¶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<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, ¶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<crate::content::Feature, ServerFnError> {
|
||||||
let question = state
|
let question = state
|
||||||
.questions
|
.questions
|
||||||
.load()
|
.load()
|
||||||
.get(&question_id)
|
.get(question_id)
|
||||||
.cloned()
|
.cloned()
|
||||||
.ok_or_else(|| ServerFnError::new("unknown question"))?;
|
.ok_or_else(|| ServerFnError::new("unknown question"))?;
|
||||||
// Scoped to the named alternative first, not flattened across all
|
question
|
||||||
// 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
|
|
||||||
.alternatives
|
.alternatives
|
||||||
.iter()
|
.iter()
|
||||||
.find(|a| a.name == alternative)
|
.find(|a| a.name == alternative)
|
||||||
@@ -50,13 +96,17 @@ pub async fn get_resource(
|
|||||||
.features
|
.features
|
||||||
.iter()
|
.iter()
|
||||||
.find(|f| f.name == feature_name)
|
.find(|f| f.name == feature_name)
|
||||||
.ok_or_else(|| ServerFnError::new("unknown feature"))?;
|
.cloned()
|
||||||
let resource = feature
|
.ok_or_else(|| ServerFnError::new("unknown feature"))
|
||||||
.resource
|
}
|
||||||
.as_ref()
|
|
||||||
.ok_or_else(|| ServerFnError::new("feature has no resource"))?;
|
|
||||||
|
|
||||||
if !resource.public {
|
#[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
|
let group = resource
|
||||||
.requires_group
|
.requires_group
|
||||||
.as_deref()
|
.as_deref()
|
||||||
@@ -70,8 +120,17 @@ pub async fn get_resource(
|
|||||||
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"));
|
||||||
}
|
}
|
||||||
|
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 {
|
let value = match &resource.source {
|
||||||
ResourceSource::Kv { bucket } => {
|
ResourceSource::Kv { bucket } => {
|
||||||
let store = state
|
let store = state
|
||||||
@@ -110,12 +169,12 @@ pub async fn get_resource(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
ResourceSource::GiteaStarred { username } => {
|
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 } => {
|
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 {
|
match &resource.jq {
|
||||||
|
|||||||
@@ -411,6 +411,30 @@ input:focus, textarea:focus {
|
|||||||
margin-right: 0.3rem;
|
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) {
|
@media (max-width: 640px) {
|
||||||
.hero { padding: 3rem 1.25rem 2rem; }
|
.hero { padding: 3rem 1.25rem 2rem; }
|
||||||
.alt-card { padding: 1.4rem 1.3rem; }
|
.alt-card { padding: 1.4rem 1.3rem; }
|
||||||
|
|||||||
Reference in New Issue
Block a user