//! Typed data contracts use the same draft and command boundary as other properties. use super::*; use iced::widget::column; fn element(draft: &Draft, project: &Project) -> Option { serde_json::from_value(draft.materialize().unwrap_or_else(|_| draft.value.clone())) .ok() .or_else(|| project.elements.get(&draft.original).cloned()) } fn definition(project: &Project, reference: &TypeRef) -> Option { Some(match reference { TypeRef::Boolean => DataTypeDefinition::Boolean, TypeRef::Integer => DataTypeDefinition::Integer { minimum: None, maximum: None, }, TypeRef::Real => DataTypeDefinition::Real { minimum: None, maximum: None, }, TypeRef::String => DataTypeDefinition::String { minimum_length: 0, maximum_length: None, }, TypeRef::Named(id) => match project.elements.get(id)?.shared()? { SharedBody::DataType { definition, .. } => definition.clone(), _ => return None, }, }) } fn literal(definition: &DataTypeDefinition) -> Option { Some(match definition { DataTypeDefinition::Boolean => json!({"Boolean": false}), DataTypeDefinition::Integer { minimum, .. } => json!({"Integer": minimum.unwrap_or(0)}), DataTypeDefinition::Real { minimum, .. } => json!({"Real": minimum.unwrap_or(0.)}), DataTypeDefinition::String { .. } => json!({"String": ""}), DataTypeDefinition::Enumeration { literals } => json!({"Enumeration": literals.first()?}), DataTypeDefinition::Record { .. } => return None, }) } pub(super) fn property<'a>( draft: &'a Draft, project: &'a Project, value: &'a Value, path: &str, ) -> Option> { if path.split('/').rev().nth(1) == Some("values") && value.is_object() { return Some(message_value(draft, project, value, path)); } let leaf = path.rsplit('/').next()?; if leaf == "direction" && path.contains("/DataField/") { let operation = element(draft, project) .and_then(|e| e.parent) .and_then(|id| data::item_mechanism(project, id)) == Some(ExchangeMechanism::Operation); if !operation { let mut out = column![ text("Parameter direction").size(11).color(style::MUTED), text("Unspecified · direction applies to direct Operation parameters.").size(11) ] .spacing(5); if value.as_str() != Some("Unspecified") { out = out .push( text(format!( "Current {:?} requires review.", value.as_str().unwrap_or_default() )) .size(11), ) .push( button(text("Use Unspecified").size(11)) .style(style::nav) .on_press(Message::FieldShape(path.into(), json!("Unspecified"))), ); } return Some(out.into()); } } let bounded = (path.contains("/definition/Integer/") || path.contains("/definition/Real/")) && matches!(leaf, "minimum" | "maximum"); let count_max = matches!(leaf, "maximum_length" | "source_maximum" | "target_maximum") || (leaf == "maximum" && path.contains("/DataField/")); if bounded || count_max { let label = match leaf { "minimum" => "Inclusive lower bound", "maximum" if bounded => "Inclusive upper bound", "maximum_length" => "Maximum Unicode characters", "source_maximum" => "Source maximum count", "target_maximum" => "Target maximum count", _ => "Maximum count", }; let mut out = column![text(label).size(11).color(style::MUTED)].spacing(5); if value.is_null() { out = out.push( button( text(if bounded { "Unbounded · set bound" } else { "Unlimited · set maximum" }) .size(12), ) .style(style::nav) .on_press(Message::FieldShape( path.into(), if path.contains("/Real/") { json!(0.) } else if count_max { let minimum_path = path.replace("maximum", "minimum"); json!( draft .materialize() .ok() .and_then(|v| v.pointer(&minimum_path).and_then(Value::as_u64)) .unwrap_or(1) .max(1) ) } else { json!(0) }, )), ); } else if let Some(input) = draft.inputs.get(path) { let owned = path.to_owned(); out = out .push( text_input("", &input.raw) .on_input(move |s| Message::Field(owned.clone(), s)) .size(12) .padding(8), ) .push( button(text("Clear bound").size(10)) .style(style::nav) .on_press(Message::FieldShape(path.into(), Value::Null)), ); } return Some(out.into()); } if leaf == "default" && (path.contains("/DataField/") || path.contains("/DataType/")) { let owner = element(draft, project); let (definition, collection, reference, minimum) = match owner.as_ref().and_then(Element::shared) { Some(SharedBody::DataType { definition, .. }) => { (Some(definition.clone()), false, false, 0) } Some(SharedBody::DataField { type_ref, storage, minimum, .. }) => ( definition(project, type_ref), true, *storage == FieldStorage::Reference, *minimum, ), _ => (None, false, false, 0), }; let initial = definition.as_ref().and_then(literal); let mut out = column![text("Default").size(11).color(style::MUTED)].spacing(6); if reference { out = out.push( text("References have no literal default; they identify an existing record.") .size(11) .color(style::MUTED), ); } if value.is_null() { out = out.push(text("Absent · no authored default").size(12)); if !reference { if let Some(initial) = initial { out = out.push( button(text("Set literal default").size(11)) .style(style::nav) .on_press(Message::FieldShape( path.into(), if collection { json!([initial]) } else { initial }, )), ); } if collection && minimum == 0 { out = out.push( button(text("Set explicit empty collection").size(11)) .style(style::nav) .on_press(Message::FieldShape(path.into(), json!([]))), ); } } } else { out = out.push( button(text("Clear default · make absent").size(11)) .style(style::nav) .on_press(Message::FieldShape(path.into(), Value::Null)), ); if let Some(values) = value.as_array() { if values.is_empty() { out = out.push(text("Explicit empty collection []").size(12)); } for (i, value) in values.iter().enumerate() { out = out .push(literal_editor( draft, project, value, &format!("{path}/{i}"), definition.as_ref(), )) .push( button(text("Remove value").size(10)) .style(style::nav) .on_press(Message::FieldArray(path.into(), Some(i), 0)), ); } if let Some(initial) = initial && !reference { out = out.push( button(text("+ Add literal").size(11)) .style(style::nav) .on_press(Message::FieldAppend(path.into(), initial)), ); } } else { out = out.push(literal_editor( draft, project, value, path, definition.as_ref(), )); } } return Some(out.into()); } None } fn literal_editor<'a>( draft: &'a Draft, project: &'a Project, value: &'a Value, path: &str, definition: Option<&DataTypeDefinition>, ) -> View<'a, Message> { if let Some(DataTypeDefinition::Enumeration { literals }) = definition { let current = draft .materialize() .ok() .and_then(|v| { v.pointer(&format!("{path}/Enumeration")) .and_then(Value::as_str) .map(str::to_owned) }) .or_else(|| { value .get("Enumeration") .and_then(Value::as_str) .map(str::to_owned) }); let mut choices = literals.clone(); if let Some(current) = ¤t && !choices.contains(current) { choices.push(current.clone()); } let path = path.to_owned(); let picker = pick_list(choices, current, move |s| { Message::FieldShape(path.clone(), json!({"Enumeration": s})) }) .text_size(12) .padding(8) .width(Fill); return if value.get("Enumeration").is_none() { column![text("Current default uses another literal type. Choose an enumeration literal or clear the default.").size(11).color(style::MUTED),picker].spacing(5).into() } else { picker.into() }; } let mut out = column![].spacing(5); if let Some(fields) = value.as_object() { for (tag, payload) in fields { out = out.push(super::property( draft, project, payload, &format!("{path}/{tag}"), &friendly(tag), true, )); } } else { out = out.push( text("Current literal requires review; clear it to choose a typed value.").size(11), ); } out.into() } fn message_value<'a>( draft: &'a Draft, project: &'a Project, value: &'a Value, path: &str, ) -> View<'a, Message> { let edited = draft.materialize().unwrap_or_else(|_| draft.value.clone()); let row = edited.pointer(path).unwrap_or(value).clone(); let current = serde_json::from_value::(row.clone()).ok(); let owner = element(draft, project); let context = owner .as_ref() .and_then(Element::shared) .and_then(|body| match body { SharedBody::Occurrence { body: OccurrenceBody::Exchange { exchange: Some(id), message_kind, .. }, .. } => project.relations.get(id).map(|r| (r, message_kind)), _ => None, }); let current_key = current .as_ref() .map(|value| crate::data_values::FieldPathKey { path: value.path.clone(), field: value.field, }); let selected = context.and_then(|(exchange, phase)| { current_key .as_ref() .and_then(|key| crate::data_values::resolve(project, exchange, phase, key)) }); let browser = draft .value_browsers .get(path) .cloned() .unwrap_or_else(|| ValueBrowser { scope: selected .as_ref() .and_then(|choice| crate::data_values::parent(&choice.key)), page: 0, }); let mut out = column![text("Qualified carried field").size(11).color(style::MUTED)].spacing(6); if let Some(choice) = &selected { out = out.push(text(choice.label.clone()).size(11)); } else if let Some(key) = ¤t_key && !key.field.is_nil() { out = out.push( text(format!( "Current address needs review: {} / {}. Choose an explicit field.", key.path .iter() .map(|id| project.named(*id)) .collect::>() .join(" → "), project.named(key.field) )) .size(11) .color(style::MUTED), ); } if let Some((exchange, phase)) = context { let location = browser .scope .as_ref() .and_then(|key| crate::data_values::resolve(project, exchange, phase, key)) .map(|choice| choice.label) .unwrap_or_else(|| "Root carried fields".into()); out = out.push( text(format!("Browse: {location}")) .size(11) .color(style::MUTED), ); if let Some(scope) = &browser.scope { let mut navigation = row![button(text("↑ Up").size(11)).style(style::nav).on_press( Message::FieldValueBrowse(path.into(), crate::data_values::parent(scope), 0) )] .spacing(4); if !scope.path.is_empty() { navigation = navigation.push( button(text("Root fields").size(11)) .style(style::nav) .on_press(Message::FieldValueBrowse(path.into(), None, 0)), ); } out = out.push(navigation); } match crate::data_values::at_scope(project, exchange, phase, browser.scope.as_ref()) { Ok(choices) => { let count = choices.len(); let (visible, page, pages) = field_page(choices, browser.page); // Selection detail stays visible independently of browser location/page. let visible_selection = selected .clone() .filter(|choice| visible.iter().any(|c| c.key == choice.key)); let owned = path.to_owned(); out = out.push( pick_list(visible, visible_selection, move |choice| { Message::FieldValuePath(owned.clone(), choice.key) }) .placeholder("Choose a field here…") .text_size(12) .padding(8) .width(Fill), ); if pages > 1 { out = out.push( row![ button(text("Previous").size(11)) .style(style::nav) .on_press_maybe((page > 0).then(|| Message::FieldValueBrowse( path.into(), browser.scope.clone(), page - 1 ))), text(format!("{} / {pages} · {count} fields", page + 1)).size(11), button(text("Next").size(11)) .style(style::nav) .on_press_maybe((page + 1 < pages).then(|| { Message::FieldValueBrowse( path.into(), browser.scope.clone(), page + 1, ) })) ] .spacing(4) .align_y(iced::Alignment::Center), ); } else if count == 0 { out = out.push( text("No compatible fields at this location.") .size(11) .color(style::MUTED), ); } } Err(error) => out = out.push(text(error).size(11).color(style::MUTED)), } if let Some(choice) = &selected && browser.scope.as_ref() != Some(&choice.key) && crate::data_values::children(project, exchange, phase, &choice.key) .is_ok_and(|children| !children.is_empty()) { out = out.push( button( text(format!( "Browse fields in {} →", project.named(choice.key.field) )) .size(11), ) .style(style::nav) .on_press(Message::FieldValueBrowse( path.into(), Some(choice.key.clone()), 0, )), ); } } else { out = out.push( text("Choose a message exchange to browse its carried fields.") .size(11) .color(style::MUTED), ); } if let Some(expression) = value.get("expression") { out = out.push(super::property( draft, project, expression, &format!("{path}/expression"), "Value expression", false, )); } out.into() } const FIELDS_PER_PAGE: usize = 24; fn field_page( choices: Vec, requested: usize, ) -> (Vec, usize, usize) { let pages = choices.len().div_ceil(FIELDS_PER_PAGE).max(1); let page = requested.min(pages - 1); ( choices .into_iter() .skip(page * FIELDS_PER_PAGE) .take(FIELDS_PER_PAGE) .collect(), page, pages, ) } #[cfg(test)] mod tests { use super::*; #[test] fn selecting_qualified_path_preserves_expression_typed_before_the_next_render() { let (mut f, next) = crate::data_values::tests::recursive_payload(); if let Some(SharedBody::DataField { direction, .. }) = f .project .elements .get_mut(&f.destination) .unwrap() .shared_mut() { *direction = ParameterDirection::InOut; } let call = f .project .add_element(Kind::Occurrence, Layer::Oa, "Call", Some(f.scenario)) .unwrap(); *f.project .elements .get_mut(&call) .unwrap() .shared_mut() .unwrap() = SharedBody::Occurrence { body: OccurrenceBody::Exchange { exchange: Some(f.exchange), sender: None, receiver: None, message_kind: MessageKind::Call, reply_to: None, values: vec![MessageValue { path: vec![f.origin], field: f.leaf, expression: "old".into(), }], }, precondition: String::new(), postcondition: String::new(), }; f.project .elements .get_mut(&f.scenario) .unwrap() .steps_mut() .unwrap() .push(call); let mut app = crate::app::App::with_project(f.project.clone()); let path = "/body/Oa/Shared/Occurrence/body/Exchange/values/0"; let _ = app.update(Message::Select(call)); let _ = app.update(Message::Field( format!("{path}/expression"), "newly typed".into(), )); let before_browse = app.draft.as_ref().unwrap().materialize().unwrap(); for (scope, page) in [ (None, 0), ( Some(crate::data_values::FieldPathKey { path: vec![f.origin], field: next, }), 999, ), ] { let _ = app.update(Message::FieldValueBrowse(path.into(), scope, page)); let _ = super::super::view(app.draft.as_ref().unwrap(), app.project()); assert_eq!( app.draft.as_ref().unwrap().materialize().unwrap(), before_browse ); assert_eq!(app.project(), &f.project); } let _ = app.update(Message::FieldValuePath( path.into(), crate::data_values::FieldPathKey { path: vec![f.destination], field: f.leaf, }, )); let _ = app.update(Message::Apply); let Some(SharedBody::Occurrence { body: OccurrenceBody::Exchange { values, .. }, .. }) = app.project().elements[&call].shared() else { panic!() }; assert_eq!( values, &vec![MessageValue { path: vec![f.destination], field: f.leaf, expression: "newly typed".into() }], "{}", app.status ); let _ = app.update(Message::Undo); assert_eq!(app.project(), &f.project); let nested = crate::data_values::children( app.project(), &app.project().relations[&f.exchange], &MessageKind::Call, &crate::data_values::FieldPathKey { path: vec![f.origin], field: next, }, ) .unwrap() .into_iter() .find(|c| c.key.field == f.leaf) .unwrap(); let _ = app.update(Message::Select(call)); let _ = app.update(Message::FieldValuePath(path.into(), nested.key.clone())); let _ = app.update(Message::Apply); let Some(SharedBody::Occurrence { body: OccurrenceBody::Exchange { values, .. }, .. }) = app.project().elements[&call].shared() else { panic!() }; assert_eq!(values[0].path, vec![f.origin, next], "{}", app.status); assert!( crate::data_values::resolve( app.project(), &app.project().relations[&f.exchange], &MessageKind::Call, &nested.key ) .is_some() ); let _ = super::super::view(app.draft.as_ref().unwrap(), app.project()); } #[test] fn field_browser_pages_every_direct_field_without_flattening_other_records() { let f = crate::data_values::tests::wide_payload(); let exchange = &f.project.relations[&f.exchange]; let roots = crate::data_values::at_scope(&f.project, exchange, &MessageKind::Call, None).unwrap(); assert!(roots.len() > FIELDS_PER_PAGE); assert!(roots.iter().all(|choice| choice.key.path.is_empty())); let scope = crate::data_values::FieldPathKey { path: vec![], field: f.origin, }; let children = crate::data_values::at_scope(&f.project, exchange, &MessageKind::Call, Some(&scope)) .unwrap(); assert!(children.len() > FIELDS_PER_PAGE); assert!( children .iter() .all(|choice| choice.key.path == vec![f.origin]) ); for choices in [roots, children] { let (_, _, pages) = field_page(choices.clone(), 0); let visited: Vec<_> = (0..pages) .flat_map(|page| { let (visible, actual, _) = field_page(choices.clone(), page); assert_eq!(actual, page); assert!(visible.len() <= FIELDS_PER_PAGE); visible }) .collect(); assert_eq!(visited, choices); assert_eq!(field_page(choices, usize::MAX).1, pages - 1); } assert_eq!(field_page(vec![], usize::MAX), (vec![], 0, 1)); } #[test] fn numeric_bound_drafts_keep_partial_and_full_precision_values_out_of_accepted_state() { let mut p = Project::empty(); let id = p .add_element(Kind::DataType, Layer::Oa, "Counter", None) .unwrap(); let mut draft = Draft::from_project(&p, id).unwrap(); let definition = "/body/Oa/Shared/DataType/definition"; draft .shape( definition, json!({"Integer":{"minimum":null,"maximum":i64::MAX}}), ) .unwrap(); let maximum = format!("{definition}/Integer/maximum"); draft.input(&maximum, "-".into()); assert!(draft.command().is_err()); assert!(draft.shape(&maximum, Value::Null).is_err()); assert_eq!(draft.inputs[&maximum].raw, "-"); draft.input(&maximum, "9223372036854775808".into()); assert!(draft.command().is_err()); draft.input(&maximum, i64::MAX.to_string()); draft .shape( "/body/Oa/Shared/DataType/default", json!({"Integer":i64::MAX}), ) .unwrap(); let accepted = execute_batch(&p, &[draft.command().unwrap()]).unwrap(); assert!(matches!( accepted.elements[&id].shared(), Some(SharedBody::DataType { definition: DataTypeDefinition::Integer { maximum: Some(i64::MAX), .. }, default: Some(DataValue::Integer(i64::MAX)), .. }) )); draft.input(&maximum, "10".into()); assert!(execute_batch(&accepted, &[draft.command().unwrap()]).is_err()); assert_eq!(draft.inputs[&maximum].raw, "10"); } #[test] fn defaults_distinguish_absent_empty_and_typed_literals_with_escaped_unicode() { let mut p = Project::empty(); let item = p .add_element(Kind::ExchangeItem, Layer::Oa, "Payload", None) .unwrap(); let id = p .add_element(Kind::DataField, Layer::Oa, "Optional names", Some(item)) .unwrap(); let mut draft = Draft::from_project(&p, id).unwrap(); let root = "/body/Oa/Shared/DataField"; draft.shape(&format!("{root}/minimum"), json!(0)).unwrap(); draft .shape(&format!("{root}/maximum"), Value::Null) .unwrap(); draft.shape(&format!("{root}/default"), json!([])).unwrap(); let empty = execute_batch(&p, &[draft.command().unwrap()]).unwrap(); assert!( matches!(empty.elements[&id].shared(),Some(SharedBody::DataField {default:Some(v),..}) if v.is_empty()) ); draft .shape(&format!("{root}/default"), Value::Null) .unwrap(); let absent = execute_batch(&empty, &[draft.command().unwrap()]).unwrap(); assert!(matches!( absent.elements[&id].shared(), Some(SharedBody::DataField { default: None, .. }) )); draft .shape(&format!("{root}/default"), json!([{"String":"é🙂\n"}])) .unwrap(); draft.input(&format!("{root}/default/0/String"), "é🙂\n".into()); draft .append(&format!("{root}/default"), json!({"String":"next"})) .unwrap(); assert_eq!( draft .materialize() .unwrap() .pointer(&format!("{root}/default/0/String")), Some(&json!("é🙂\n")) ); draft.array(&format!("{root}/default"), Some(1), 0).unwrap(); draft.set_exact(true).unwrap(); let saved = execute_batch(&absent, &[draft.command().unwrap()]).unwrap(); assert!( matches!(saved.elements[&id].shared(),Some(SharedBody::DataField {default:Some(v),..}) if v == &vec![DataValue::String("é🙂\n".into())]) ); let _ = super::super::view(&draft, &saved); draft .shape(&format!("{root}/storage"), json!("Reference")) .unwrap(); assert!(execute_batch(&saved, &[draft.command().unwrap()]).is_err()); } #[test] fn supertype_picker_excludes_descendants_and_keeps_original_inherited_field_identity() { let mut p = Project::empty(); let base = p .add_element(Kind::DataType, Layer::Oa, "Base", None) .unwrap(); let child = p .add_element(Kind::DataType, Layer::Oa, "Child", None) .unwrap(); for (id, supertype) in [(base, None), (child, Some(base))] { *p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::DataType { definition: DataTypeDefinition::Record { supertype }, default: None, unit: String::new(), }; } let field = p .add_element(Kind::DataField, Layer::Oa, "Owned by base", Some(base)) .unwrap(); let draft = Draft::from_project(&p, base).unwrap(); let path = "/body/Oa/Shared/DataType/definition/Record/supertype"; assert!(!reference_ids(&draft, &p, path).contains(&child)); let child_draft = Draft::from_project(&p, child).unwrap(); assert!(reference_ids(&child_draft, &p, path).contains(&base)); assert_eq!(data::effective_fields(&p, child)[0].id, field); assert_eq!(p.elements[&field].parent, Some(base)); } }