Bound inputs: a field that loads its value from a sibling-parameterized resource
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:
Bendik Aagaard Lynghaug
2026-08-13 13:49:42 +02:00
co-authored by Claude Sonnet 5
parent 3d8af23167
commit 357d755b8c
4 changed files with 257 additions and 3 deletions
+91
View File
@@ -226,6 +226,33 @@ pub struct Requirement {
/// stable id. Defaults to trying `_id` then `id`.
#[serde(default)]
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 {
@@ -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 requirement in &feature.requirements {
if requirement.kind == "select" && requirement.resource.is_none() {
@@ -437,6 +472,16 @@ pub fn validate_questions(
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 {
continue;
@@ -758,4 +803,50 @@ alternatives:
let questions = std::collections::HashMap::from([(question.id.clone(), question)]);
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"));
}
}