Bound inputs: a field that loads its value from a sibling-parameterized resource
Deploy / deploy (push) Successful in 1m7s
Deploy / deploy (push) Successful in 1m7s
Requirement.bind {field, param, resource}: when the watched sibling
changes (a file select, say), the bind's resource is fetched with the
sibling's value as a parameter and the result becomes this field's
value - the on-site editing flow for /develop-proposal, and a generic
select-plus-detail primitive anywhere else. Url sources gain {name}
path templating (percent-encoded, / preserved, SSRF check runs after
substitution); an empty sibling never fetches and never clears an
edit. Bound textareas render monospace.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
3d8af23167
commit
357d755b8c
+54
-1
@@ -10,7 +10,7 @@ use serde::{Deserialize, Serialize};
|
|||||||
use crate::answers::{Answer, SelfTransitionAnswer, TransitionAnswers, TransitionItem};
|
use crate::answers::{Answer, SelfTransitionAnswer, TransitionAnswers, TransitionItem};
|
||||||
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_requirement_options, get_resource};
|
use crate::resource::{get_requirement_binding, 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.
|
||||||
@@ -758,6 +758,58 @@ 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()));
|
||||||
|
|
||||||
|
// A bound field watches a sibling and loads its own value from
|
||||||
|
// the bind's resource whenever the sibling changes. An empty
|
||||||
|
// sibling never fetches - and never clears an edit.
|
||||||
|
if let Some(bind) = req
|
||||||
|
.bind
|
||||||
|
.clone()
|
||||||
|
.filter(|_| !matches!(req.kind.as_str(), "select" | "file"))
|
||||||
|
{
|
||||||
|
let watched_select = select_field_map.get(&bind.field).copied();
|
||||||
|
let watched_text = field_map.get(&bind.field).copied();
|
||||||
|
let param_name = bind.param_name().to_string();
|
||||||
|
let q = question_id.clone();
|
||||||
|
let a = alt_name.clone();
|
||||||
|
let f = feature_name.clone();
|
||||||
|
let rname = req.name.clone();
|
||||||
|
let bound = Resource::new(
|
||||||
|
move || {
|
||||||
|
let value = watched_select
|
||||||
|
.map(|s| s.get().first().cloned().unwrap_or_default())
|
||||||
|
.or_else(|| watched_text.map(|s| s.get()))
|
||||||
|
.unwrap_or_default();
|
||||||
|
(q.clone(), a.clone(), f.clone(), rname.clone(), param_name.clone(), value)
|
||||||
|
},
|
||||||
|
|(q, a, f, r, p, value)| async move {
|
||||||
|
if value.is_empty() {
|
||||||
|
return Ok(serde_json::Value::Null);
|
||||||
|
}
|
||||||
|
let params = std::collections::HashMap::from([(p, value)]);
|
||||||
|
get_requirement_binding(q, a, f, r, params).await
|
||||||
|
},
|
||||||
|
);
|
||||||
|
Effect::new(move |_| {
|
||||||
|
if let Some(Ok(value)) = bound.get() {
|
||||||
|
let text = match value {
|
||||||
|
serde_json::Value::Null => return,
|
||||||
|
serde_json::Value::String(s) => s,
|
||||||
|
// apply_jq wraps every yield in an array - a
|
||||||
|
// single string yield unwraps to itself.
|
||||||
|
serde_json::Value::Array(items) if items.len() == 1 => {
|
||||||
|
match &items[0] {
|
||||||
|
serde_json::Value::String(s) => s.clone(),
|
||||||
|
other => serde_json::to_string_pretty(other).unwrap_or_default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
other => serde_json::to_string_pretty(&other).unwrap_or_default(),
|
||||||
|
};
|
||||||
|
sig.set(text);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let is_bound = req.bind.is_some();
|
||||||
|
|
||||||
if req.kind == "select" {
|
if req.kind == "select" {
|
||||||
let select_sig = select_field_map
|
let select_sig = select_field_map
|
||||||
.get(&req.name)
|
.get(&req.name)
|
||||||
@@ -864,6 +916,7 @@ fn AlternativeCard(
|
|||||||
view! {
|
view! {
|
||||||
<textarea
|
<textarea
|
||||||
id=field_id.clone()
|
id=field_id.clone()
|
||||||
|
class:bound=is_bound
|
||||||
placeholder=placeholder.clone()
|
placeholder=placeholder.clone()
|
||||||
required=required
|
required=required
|
||||||
prop:value=move || sig.get()
|
prop:value=move || sig.get()
|
||||||
|
|||||||
@@ -226,6 +226,33 @@ pub struct Requirement {
|
|||||||
/// stable id. Defaults to trying `_id` then `id`.
|
/// stable id. Defaults to trying `_id` then `id`.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub id_field: Option<String>,
|
pub id_field: Option<String>,
|
||||||
|
/// Load this field's value from a resource whenever a sibling
|
||||||
|
/// field changes - e.g. a file select populating a textarea with
|
||||||
|
/// the selected file's current content.
|
||||||
|
#[serde(default)]
|
||||||
|
pub bind: Option<Bind>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A field's live data source, parameterized by a sibling field's
|
||||||
|
/// value. When the sibling changes, `resource` is fetched with the
|
||||||
|
/// value as a parameter and the result replaces this field's value
|
||||||
|
/// (an empty sibling never fetches, and never clears an edit).
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct Bind {
|
||||||
|
/// The sibling requirement (same alternative) to watch.
|
||||||
|
pub field: String,
|
||||||
|
/// The parameter name the sibling's value is sent as - `{name}`
|
||||||
|
/// templates into a Url source's path, otherwise it's appended as
|
||||||
|
/// a query pair. Defaults to `field`.
|
||||||
|
#[serde(default)]
|
||||||
|
pub param: Option<String>,
|
||||||
|
pub resource: ResourceSpec,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Bind {
|
||||||
|
pub fn param_name(&self) -> &str {
|
||||||
|
self.param.as_deref().unwrap_or(&self.field)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_requirement_type() -> String {
|
fn default_requirement_type() -> String {
|
||||||
@@ -429,6 +456,14 @@ pub fn validate_questions(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Bind targets resolve across the whole alternative, same
|
||||||
|
// scope as the signal maps the renderer builds.
|
||||||
|
let sibling_names: std::collections::HashSet<&str> = alternative
|
||||||
|
.features
|
||||||
|
.iter()
|
||||||
|
.flat_map(|f| f.requirements.iter())
|
||||||
|
.map(|r| r.name.as_str())
|
||||||
|
.collect();
|
||||||
for feature in &alternative.features {
|
for feature in &alternative.features {
|
||||||
for requirement in &feature.requirements {
|
for requirement in &feature.requirements {
|
||||||
if requirement.kind == "select" && requirement.resource.is_none() {
|
if requirement.kind == "select" && requirement.resource.is_none() {
|
||||||
@@ -437,6 +472,16 @@ pub fn validate_questions(
|
|||||||
question.id, alternative.name, feature.name, requirement.name
|
question.id, alternative.name, feature.name, requirement.name
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if let Some(bind) = &requirement.bind {
|
||||||
|
if bind.field == requirement.name
|
||||||
|
|| !sibling_names.contains(bind.field.as_str())
|
||||||
|
{
|
||||||
|
anyhow::bail!(
|
||||||
|
"question {:?} alternative {:?} feature {:?}: requirement {:?} binds to {:?}, which is not another requirement on this alternative",
|
||||||
|
question.id, alternative.name, feature.name, requirement.name, bind.field
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
let Some(resource) = &feature.resource else {
|
let Some(resource) = &feature.resource else {
|
||||||
continue;
|
continue;
|
||||||
@@ -758,4 +803,50 @@ alternatives:
|
|||||||
let questions = std::collections::HashMap::from([(question.id.clone(), question)]);
|
let questions = std::collections::HashMap::from([(question.id.clone(), question)]);
|
||||||
assert!(validate_questions(&questions, &Default::default()).is_ok());
|
assert!(validate_questions(&questions, &Default::default()).is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn question_with_bind(bind_field: &str) -> std::collections::HashMap<String, Question> {
|
||||||
|
let question: Question = serde_yaml::from_str(&format!(
|
||||||
|
r#"
|
||||||
|
id: /b
|
||||||
|
name: B
|
||||||
|
alternatives:
|
||||||
|
- name: A
|
||||||
|
features:
|
||||||
|
- name: ""
|
||||||
|
requirements:
|
||||||
|
- name: picker
|
||||||
|
type: select
|
||||||
|
resource:
|
||||||
|
public: true
|
||||||
|
source: {{ kind: url, url: "https://x.example/list" }}
|
||||||
|
- name: body
|
||||||
|
type: textarea
|
||||||
|
bind:
|
||||||
|
field: {bind_field}
|
||||||
|
resource:
|
||||||
|
public: true
|
||||||
|
source: {{ kind: url, url: "https://x.example/item/{{picker}}" }}
|
||||||
|
"#
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
std::collections::HashMap::from([(question.id.clone(), question)])
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bind_to_sibling_passes() {
|
||||||
|
assert!(validate_questions(&question_with_bind("picker"), &Default::default()).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bind_to_missing_sibling_is_rejected() {
|
||||||
|
let err =
|
||||||
|
validate_questions(&question_with_bind("bogus"), &Default::default()).unwrap_err();
|
||||||
|
assert!(err.to_string().contains("not another requirement"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bind_to_itself_is_rejected() {
|
||||||
|
let err = validate_questions(&question_with_bind("body"), &Default::default()).unwrap_err();
|
||||||
|
assert!(err.to_string().contains("not another requirement"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+102
-2
@@ -76,6 +76,37 @@ pub async fn get_requirement_options(
|
|||||||
fetch_resource_value(&state, resource, ¶ms).await
|
fetch_resource_value(&state, resource, ¶ms).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The `Requirement.bind`-backed counterpart to
|
||||||
|
/// `get_requirement_options`: fetches the resource a bound field
|
||||||
|
/// loads its value from, parameterized by the watched sibling's value
|
||||||
|
/// (already inside `params`, keyed by the bind's param name).
|
||||||
|
#[server]
|
||||||
|
pub async fn get_requirement_binding(
|
||||||
|
question_id: String,
|
||||||
|
alternative: String,
|
||||||
|
feature_name: String,
|
||||||
|
requirement_name: String,
|
||||||
|
// See get_resource's `params` for why this is needed.
|
||||||
|
#[server(default)]
|
||||||
|
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 bind = requirement
|
||||||
|
.bind
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| ServerFnError::new("requirement has no binding"))?;
|
||||||
|
authorize_resource(&bind.resource).await?;
|
||||||
|
fetch_resource_value(&state, &bind.resource, ¶ms).await
|
||||||
|
}
|
||||||
|
|
||||||
/// Scoped to the named alternative first, not flattened across all of
|
/// Scoped to the named alternative first, not flattened across all of
|
||||||
/// them - a feature name (often just "") is only unique within its own
|
/// them - a feature name (often just "") is only unique within its own
|
||||||
/// alternative, not across a whole question. Flattening silently
|
/// alternative, not across a whole question. Flattening silently
|
||||||
@@ -239,15 +270,60 @@ async fn fetch_gitea_json(
|
|||||||
/// that stops being true - `assert_public_https_url` fails closed on
|
/// that stops being true - `assert_public_https_url` fails closed on
|
||||||
/// anything that isn't `https://` and actually resolves to a public
|
/// anything that isn't `https://` and actually resolves to a public
|
||||||
/// address, not just a syntactically-plausible one.
|
/// address, not just a syntactically-plausible one.
|
||||||
|
/// Substitutes `{name}` placeholders in `url` with percent-encoded
|
||||||
|
/// param values (keeping `/`, since file paths legitimately contain
|
||||||
|
/// it) and returns the substituted url plus the params that were NOT
|
||||||
|
/// consumed by a placeholder. Runs before the SSRF check, so a value
|
||||||
|
/// can't steer the fetch anywhere the check wouldn't allow.
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
fn template_url(
|
||||||
|
url: &str,
|
||||||
|
params: &std::collections::HashMap<String, String>,
|
||||||
|
) -> (String, std::collections::HashMap<String, String>) {
|
||||||
|
let mut out = url.to_string();
|
||||||
|
let mut leftover = std::collections::HashMap::new();
|
||||||
|
for (k, v) in params {
|
||||||
|
let placeholder = format!("{{{k}}}");
|
||||||
|
if out.contains(&placeholder) {
|
||||||
|
let encoded: String = v
|
||||||
|
.split('/')
|
||||||
|
.map(|seg| urlencoding_encode(seg))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("/");
|
||||||
|
out = out.replace(&placeholder, &encoded);
|
||||||
|
} else {
|
||||||
|
leftover.insert(k.clone(), v.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(out, leftover)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Minimal percent-encoding for a single path segment - everything but
|
||||||
|
/// unreserved characters.
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
fn urlencoding_encode(seg: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(seg.len());
|
||||||
|
for b in seg.bytes() {
|
||||||
|
match b {
|
||||||
|
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
|
||||||
|
out.push(b as char)
|
||||||
|
}
|
||||||
|
_ => out.push_str(&format!("%{b:02X}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(feature = "ssr")]
|
#[cfg(feature = "ssr")]
|
||||||
async fn fetch_url_json(
|
async fn fetch_url_json(
|
||||||
url: &str,
|
url: &str,
|
||||||
params: &std::collections::HashMap<String, String>,
|
params: &std::collections::HashMap<String, String>,
|
||||||
) -> Result<serde_json::Value, ServerFnError> {
|
) -> Result<serde_json::Value, ServerFnError> {
|
||||||
let mut parsed = assert_public_https_url(url)
|
let (templated, leftover) = template_url(url, params);
|
||||||
|
let mut parsed = assert_public_https_url(&templated)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
for (k, v) in params {
|
for (k, v) in &leftover {
|
||||||
parsed.query_pairs_mut().append_pair(k, v);
|
parsed.query_pairs_mut().append_pair(k, v);
|
||||||
}
|
}
|
||||||
let client = openidconnect::reqwest::Client::new();
|
let client = openidconnect::reqwest::Client::new();
|
||||||
@@ -412,4 +488,28 @@ mod tests {
|
|||||||
assert!(!is_public_ip(&link_local));
|
assert!(!is_public_ip(&link_local));
|
||||||
assert!(is_public_ip(&public));
|
assert!(is_public_ip(&public));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn template_url_substitutes_and_keeps_slashes() {
|
||||||
|
let params = std::collections::HashMap::from([
|
||||||
|
("path".to_string(), "questions/a b.yaml".to_string()),
|
||||||
|
("ref".to_string(), "main".to_string()),
|
||||||
|
]);
|
||||||
|
let (url, leftover) =
|
||||||
|
template_url("https://x.example/api/contents/{path}", ¶ms);
|
||||||
|
// `/` survives, the space percent-encodes, and the param that
|
||||||
|
// matched a placeholder is consumed.
|
||||||
|
assert_eq!(url, "https://x.example/api/contents/questions/a%20b.yaml");
|
||||||
|
assert_eq!(leftover.len(), 1);
|
||||||
|
assert_eq!(leftover.get("ref").map(String::as_str), Some("main"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn jq_base64d_decodes_gitea_content() {
|
||||||
|
// "id: /x\n" base64-encoded, in the shape Gitea's contents API
|
||||||
|
// returns a file as.
|
||||||
|
let fixture = serde_json::json!({ "content": "aWQ6IC94Cg==", "encoding": "base64" });
|
||||||
|
let out = apply_jq(".content | @base64d", &fixture).unwrap();
|
||||||
|
assert_eq!(out, serde_json::json!(["id: /x\n"]));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -422,6 +422,16 @@ textarea {
|
|||||||
resize: vertical;
|
resize: vertical;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* A bound textarea holds loaded source text - monospace, roomy, no
|
||||||
|
soft-wrap surprises. */
|
||||||
|
textarea.bound {
|
||||||
|
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
min-height: 16rem;
|
||||||
|
white-space: pre;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
input:focus,
|
input:focus,
|
||||||
textarea:focus {
|
textarea:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
|
|||||||
Reference in New Issue
Block a user