Review closed semantic constructor groups with retained typed drafts

This commit is contained in:
2026-09-06 07:49:01 +03:00
parent 0e47384532
commit 1a68ae0795
10 changed files with 2298 additions and 193 deletions
+155 -16
View File
@@ -46,7 +46,8 @@ pub struct Draft {
pub inputs: BTreeMap<String, Input>,
pub exact_strings: bool,
context: Option<Value>,
message_timer_draft: Option<Input>,
pub semantic_groups: bool,
constructor_drafts: BTreeMap<(String, String), (Value, BTreeMap<String, Input>)>,
port_contract_drafts: BTreeMap<String, Value>,
pub value_browsers: BTreeMap<String, ValueBrowser>,
}
@@ -86,7 +87,8 @@ impl Draft {
inputs: BTreeMap::new(),
exact_strings: false,
context: None,
message_timer_draft: None,
semantic_groups: false,
constructor_drafts: BTreeMap::new(),
port_contract_drafts: BTreeMap::new(),
value_browsers: BTreeMap::new(),
};
@@ -250,27 +252,73 @@ impl Draft {
self.rebuild_retaining(|old| (!within(old, path)).then(|| old.to_owned()));
Ok(())
}
/// Message-kind selection preserves the timer's exact input/editor state
/// while its payload is temporarily hidden by another kind.
pub fn message_kind_variant(&mut self, path: &str, value: Value) -> Result<(), String> {
serde_json::from_value::<MessageKind>(value.clone()).map_err(|e| e.to_string())?;
/// Preserve each selected constructor branch, including incomplete raw input
/// and editor state. No sibling fields are parsed or cleared by a switch.
pub fn variant_scoped(&mut self, path: &str, proposed: Value) -> Result<(), String> {
let current = self
.value
.pointer(path)
.ok_or("Message kind no longer exists.")?;
if same_variant(current, &value) {
.ok_or("Property no longer exists.")?
.clone();
if same_variant(&current, &proposed) {
return Ok(());
}
let timer_path = format!("{path}/Timer/expression");
if let Some(input) = self.inputs.remove(&timer_path) {
self.message_timer_draft = Some(input);
let constructor = path.ends_with("/DataType/definition")
|| path.ends_with("/Port/contract")
|| path.ends_with("/Occurrence/body")
|| path.ends_with("/Event/body")
|| path.ends_with("/message_kind");
if !constructor {
return self.shape_scoped(path, proposed);
}
self.shape_scoped(path, value)?;
if self.inputs.contains_key(&timer_path)
&& let Some(input) = self.message_timer_draft.take()
// Input/output direction changes carry the item declaration. Read those
// raw reference strings before stashing their editor buffers so an
// incomplete reference cannot silently fall back to its stored UUID.
let port_current = if path.ends_with("/Port/contract") {
self.materialize_scopes(&[path.to_owned()])?
.pointer(path)
.cloned()
} else {
None
};
let tag = |value: &Value| {
value
.as_str()
.or_else(|| value.as_object()?.keys().next().map(String::as_str))
.unwrap_or("")
.to_owned()
};
let previous_key = (path.to_owned(), tag(&current));
let next_key = (path.to_owned(), tag(&proposed));
let mut inputs = BTreeMap::new();
for key in self
.inputs
.keys()
.filter(|key| within(key, path))
.cloned()
.collect::<Vec<_>>()
{
self.inputs.insert(timer_path, input);
inputs.insert(key.clone(), self.inputs.remove(&key).unwrap());
}
self.constructor_drafts
.insert(previous_key, (current.clone(), inputs));
let saved = self.constructor_drafts.remove(&next_key);
let (value, restored) = if let Some(saved) = saved {
saved
} else {
let value = if path.ends_with("/Port/contract") {
port_fields::switch_contract(
port_current.as_ref().unwrap_or(&current),
proposed,
&mut BTreeMap::new(),
)
} else {
proposed
};
(value, BTreeMap::new())
};
self.shape_scoped(path, value)?;
self.inputs.extend(restored);
Ok(())
}
fn rebuild_retaining(&mut self, new_path: impl Fn(&str) -> Option<String>) {
@@ -1315,6 +1363,7 @@ fn variants(path: &str, value: &Value, kind: RecordKind) -> Vec<(String, Value)>
"layer" => &["Oa", "Sa"],
"mechanism" => &["Unspecified", "Flow", "Event", "Operation", "SharedData"],
"storage" => &["Value", "Reference"],
"direction" if path.contains("/Port/legacy/") => &["In", "Out", "InOut"],
"direction" if path.contains("DataField") => {
&["Unspecified", "In", "Out", "InOut", "Return"]
}
@@ -1571,7 +1620,7 @@ fn property<'a>(
return view;
}
let label_view = || text(label.to_owned()).size(11).color(style::MUTED);
if path.ends_with("/Port/legacy") {
if path.ends_with("/Port/legacy") && !draft.semantic_groups {
let mut content = column![].spacing(4);
if !value.is_null() {
content = content.push(
@@ -1585,6 +1634,29 @@ fn property<'a>(
}
return content.into();
}
if path.ends_with("/Port/legacy") && draft.semantic_groups && !skip_variant {
let mut content = column![
label_view(),
text(
"Retained generic association is part of this group. Change or clear it explicitly."
)
.size(11)
]
.spacing(5);
if value.is_null() {
content = content.push(button(text("Add generic association").size(11)).on_press(
Message::FieldShape(path.into(), json!({"direction":"InOut","interface":null})),
));
} else {
content = content
.push(property(draft, project, value, path, label, true))
.push(
button(text("Clear generic association").size(11))
.on_press(Message::FieldShape(path.into(), Value::Null)),
);
}
return content.into();
}
let enum_choices = if skip_variant {
vec![]
} else {
@@ -1842,6 +1914,73 @@ fn property<'a>(
mod tests {
use super::*;
#[test]
fn semantic_port_group_choices_use_combined_owner_and_retain_invalid_transferred_items() {
let mut p = Project::empty();
let function = p
.add_element(Kind::Function, Layer::Sa, "Function", None)
.unwrap();
let component = p
.add_element(Kind::System, Layer::Sa, "Component", None)
.unwrap();
let item = p
.add_element(Kind::ExchangeItem, Layer::Sa, "Item", None)
.unwrap();
let id = Id::new_v4();
p.elements.insert(
id,
ports::make_port(
&p,
id,
function,
"Port",
PortContract::FunctionInput {
incoming_items: vec![item],
},
)
.unwrap(),
);
let mut draft = Draft::from_project(&p, id).unwrap();
draft.semantic_groups = true;
let path = "/body/Sa/Shared/Port/contract";
let input = format!("{path}/FunctionInput/incoming_items/0");
draft.input(&input, "incomplete-id".into());
let cursor = draft.inputs[&input].editor.cursor();
draft
.variant_scoped(path, json!({"FunctionOutput":{"outgoing_items":[]}}))
.unwrap();
assert_eq!(
draft.inputs[&format!("{path}/FunctionOutput/outgoing_items/0")].raw,
"incomplete-id"
);
assert!(serde_json::from_value::<Element>(draft.materialize().unwrap()).is_err());
draft
.variant_scoped(path, json!({"FunctionInput":{"incoming_items":[]}}))
.unwrap();
assert_eq!(draft.inputs[&input].raw, "incomplete-id");
assert_eq!(draft.inputs[&input].editor.cursor(), cursor);
let choices =
port_fields::variants(&draft, &p, path, draft.value.pointer(path).unwrap()).unwrap();
assert!(
choices
.iter()
.any(|(_, v)| v.get("FunctionInput").is_some())
);
assert!(choices.iter().any(|(_, v)| *v == json!("Legacy")));
assert!(!choices.iter().any(|(_, v)| v.get("Component").is_some()));
let mut context = draft.value.clone();
context["parent"] = json!(component);
draft.set_context(Some(context));
let choices =
port_fields::variants(&draft, &p, path, draft.value.pointer(path).unwrap()).unwrap();
assert!(choices.iter().any(|(_, v)| v.get("Component").is_some()));
assert!(
!choices
.iter()
.any(|(_, v)| v.get("FunctionInput").is_some())
);
}
fn ids(draft: &Draft, project: &Project, path: &str) -> Vec<Id> {
reference_ids(draft, project, path)
}