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
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user