Keep nested data field names visible in compact pickers

This commit is contained in:
2026-09-06 04:40:08 +03:00
parent 0edadeebdb
commit 74989b6f25
2 changed files with 112 additions and 45 deletions
+105 -45
View File
@@ -11,10 +11,11 @@ pub struct FieldPathKey {
pub struct FieldPathChoice { pub struct FieldPathChoice {
pub key: FieldPathKey, pub key: FieldPathKey,
pub label: String, pub label: String,
pub picker_label: String,
} }
impl std::fmt::Display for FieldPathChoice { impl std::fmt::Display for FieldPathChoice {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.label) f.write_str(&self.picker_label)
} }
} }
@@ -49,8 +50,7 @@ fn named_choice(project: &Project, entry: data::CarriedFieldPath) -> FieldPathCh
.map(|e| format!("{} (@{})", e.name, e.alias)) .map(|e| format!("{} (@{})", e.name, e.alias))
.unwrap_or_else(|| id.to_string()) .unwrap_or_else(|| id.to_string())
}) })
.collect::<Vec<_>>() .collect::<Vec<_>>();
.join(" → ");
let direction = let direction =
if data::item_mechanism(project, entry.item) == Some(ExchangeMechanism::Operation) { if data::item_mechanism(project, entry.item) == Some(ExchangeMechanism::Operation) {
match entry.direction { match entry.direction {
@@ -63,54 +63,83 @@ fn named_choice(project: &Project, entry: data::CarriedFieldPath) -> FieldPathCh
} else { } else {
"" ""
}; };
let terminal = qualified
.last()
.expect("A field path always has a terminal");
let owner_path = qualified[..qualified.len() - 1].join(" → ");
let picker_label = format!(
"{terminal}{direction} — {}{}",
project.named(entry.item),
if owner_path.is_empty() {
String::new()
} else {
format!(" / {owner_path}")
},
);
FieldPathChoice { FieldPathChoice {
key: FieldPathKey { key: FieldPathKey {
path: entry.path, path: entry.path,
field: entry.field, field: entry.field,
}, },
label: format!("{} / {qualified}{direction}", project.named(entry.item)), label: format!(
"{} / {}{direction}",
project.named(entry.item),
qualified.join(" → ")
),
picker_label,
} }
} }
// A bounded picker must expose its distinguishing field before a long path.
// Keep the full address separately for the wrapping detail text.
fn sorted_choices(mut choices: Vec<FieldPathChoice>) -> Vec<FieldPathChoice> {
choices.sort_by_cached_key(|choice| choice.picker_label.to_lowercase());
choices
}
pub fn choices( pub fn choices(
project: &Project, project: &Project,
exchange: &Relation, exchange: &Relation,
phase: &MessageKind, phase: &MessageKind,
) -> Result<Vec<FieldPathChoice>, String> { ) -> Result<Vec<FieldPathChoice>, String> {
Ok(data::try_carried_field_paths(project, exchange)? Ok(sorted_choices(
.into_iter() data::try_carried_field_paths(project, exchange)?
.filter(|entry| phase_allows(project, entry, phase)) .into_iter()
.map(|entry| named_choice(project, entry)) .filter(|entry| phase_allows(project, entry, phase))
.collect()) .map(|entry| named_choice(project, entry))
.collect(),
))
} }
/// Direct carried fields only: an unrelated deep branch must not prevent /// Direct carried fields only: an unrelated deep branch must not prevent
/// browsing a root field. Preserve the core resolver's first carried owner. /// browsing a root field. Preserve the core resolver's first carried owner.
pub fn roots(project: &Project, exchange: &Relation, phase: &MessageKind) -> Vec<FieldPathChoice> { pub fn roots(project: &Project, exchange: &Relation, phase: &MessageKind) -> Vec<FieldPathChoice> {
let mut seen = std::collections::BTreeSet::new(); let mut seen = std::collections::BTreeSet::new();
exchange sorted_choices(
.body exchange
.items() .body
.iter() .items()
.flat_map(|item| { .iter()
data::effective_fields(project, *item) .flat_map(|item| {
.into_iter() data::effective_fields(project, *item)
.filter_map(|field| { .into_iter()
let SharedBody::DataField { direction, .. } = field.shared()? else { .filter_map(|field| {
return None; let SharedBody::DataField { direction, .. } = field.shared()? else {
}; return None;
Some(data::CarriedFieldPath { };
item: *item, Some(data::CarriedFieldPath {
path: vec![], item: *item,
field: field.id, path: vec![],
direction: *direction, field: field.id,
direction: *direction,
})
}) })
}) })
}) .filter(|entry| seen.insert(entry.field))
.filter(|entry| seen.insert(entry.field)) .filter(|entry| phase_allows(project, entry, phase))
.filter(|entry| phase_allows(project, entry, phase)) .map(|entry| named_choice(project, entry))
.map(|entry| named_choice(project, entry)) .collect(),
.collect() )
} }
/// Resolve authored finite paths as well as the finite representative picker list. /// Resolve authored finite paths as well as the finite representative picker list.
@@ -166,20 +195,22 @@ pub fn children(
} }
let mut path = key.path.clone(); let mut path = key.path.clone();
path.push(key.field); path.push(key.field);
Ok(data::effective_fields(project, *record) Ok(sorted_choices(
.into_iter() data::effective_fields(project, *record)
.map(|field| { .into_iter()
named_choice( .map(|field| {
project, named_choice(
data::CarriedFieldPath { project,
item: entry.item, data::CarriedFieldPath {
path: path.clone(), item: entry.item,
field: field.id, path: path.clone(),
direction: entry.direction, field: field.id,
}, direction: entry.direction,
) },
}) )
.collect()) })
.collect(),
))
} }
#[cfg(test)] #[cfg(test)]
@@ -482,4 +513,33 @@ pub(crate) mod tests {
.contains("128") .contains("128")
); );
} }
#[test]
fn recursive_picker_starts_with_distinct_leaf_names_and_sorts_them() {
let (f, next) = recursive_payload();
let exchange = &f.project.relations[&f.exchange];
let context = FieldPathKey {
path: vec![f.origin, next, next],
field: next,
};
let visible = children(&f.project, exchange, &MessageKind::Call, &context).unwrap();
let leaf = visible
.iter()
.find(|choice| choice.key.field == f.leaf)
.unwrap();
assert!(leaf.to_string().starts_with("latitude (@latitude)"));
assert!(leaf.label.contains("origin (@origin) → next (@next)"));
assert!(
visible.windows(2).all(
|pair| pair[0].to_string().to_lowercase() <= pair[1].to_string().to_lowercase()
)
);
assert_ne!(
visible[0].to_string().chars().take(24).collect::<String>(),
visible[1].to_string().chars().take(24).collect::<String>()
);
assert_eq!(
resolve(&f.project, exchange, &MessageKind::Call, &leaf.key),
Some(leaf.clone())
);
}
} }
@@ -363,6 +363,10 @@ fn message_value<'a>(
{ {
out = out.push(text("Current address needs review. Choose an explicit path to resolve a legacy, ambiguous or incompatible value.").size(11).color(style::MUTED)); out = out.push(text("Current address needs review. Choose an explicit path to resolve a legacy, ambiguous or incompatible value.").size(11).color(style::MUTED));
let retained = crate::data_values::FieldPathChoice { let retained = crate::data_values::FieldPathChoice {
picker_label: format!(
"{} · current address needs review",
project.named(key.field)
),
label: format!( label: format!(
"Current address · {} / {}", "Current address · {} / {}",
key.path key.path
@@ -377,6 +381,9 @@ fn message_value<'a>(
selected = Some(retained.clone()); selected = Some(retained.clone());
choices.push(retained); choices.push(retained);
} }
if let Some(choice) = &selected {
out = out.push(text(choice.label.clone()).size(11).color(style::MUTED));
}
let owned = path.to_owned(); let owned = path.to_owned();
out = out.push( out = out.push(
pick_list(choices, selected, move |choice| { pick_list(choices, selected, move |choice| {