Add typed data contract editing and bounded dictionary views
This commit is contained in:
@@ -115,6 +115,7 @@ pub enum Message {
|
||||
BrowseGroup(crate::explorer::Group),
|
||||
ExplorerPage(usize),
|
||||
CreateKind(Kind),
|
||||
Data(crate::data_view::Event),
|
||||
Add(bool),
|
||||
AddRelation,
|
||||
PortAction(crate::port_workflow::Action),
|
||||
@@ -127,6 +128,8 @@ pub enum Message {
|
||||
Field(String, String),
|
||||
FieldText(String, text_editor::Action),
|
||||
FieldShape(String, Value),
|
||||
FieldAppend(String, Value),
|
||||
FieldValuePath(String, crate::data_values::FieldPathKey),
|
||||
FieldVariant(String, Value),
|
||||
FieldArray(String, Option<usize>, i32),
|
||||
Apply,
|
||||
@@ -204,6 +207,7 @@ pub struct App {
|
||||
pub page: Page,
|
||||
pub guide: crate::guide::State,
|
||||
pub behavior: crate::behavior::State,
|
||||
pub data_state: crate::data_view::State,
|
||||
pub inspector_tab: crate::navigation::InspectorTab,
|
||||
pub layer: Layer,
|
||||
pub selected: Id,
|
||||
@@ -292,6 +296,7 @@ impl App {
|
||||
page: Page::Diagram,
|
||||
guide: crate::guide::State::default(),
|
||||
behavior: Default::default(),
|
||||
data_state: Default::default(),
|
||||
inspector_tab: Default::default(),
|
||||
layer,
|
||||
selected,
|
||||
@@ -764,6 +769,8 @@ impl App {
|
||||
Message::Field(..)
|
||||
| Message::FieldText(..)
|
||||
| Message::FieldShape(..)
|
||||
| Message::FieldAppend(..)
|
||||
| Message::FieldValuePath(..)
|
||||
| Message::FieldVariant(..)
|
||||
| Message::FieldArray(..)
|
||||
| Message::ElementKind(_)
|
||||
@@ -896,6 +903,7 @@ impl App {
|
||||
self.connection = None;
|
||||
}
|
||||
if previous_layer != self.layer {
|
||||
self.data_state = Default::default();
|
||||
self.explorer_index = crate::explorer::Index::new(self.project(), self.layer);
|
||||
self.explorer_focus = None;
|
||||
self.explorer_group = None;
|
||||
@@ -1124,6 +1132,20 @@ impl App {
|
||||
self.status = e;
|
||||
}
|
||||
}
|
||||
Message::FieldAppend(path, value) => {
|
||||
if let Some(draft) = &mut self.draft
|
||||
&& let Err(error) = draft.append(&path, value)
|
||||
{
|
||||
self.status = error;
|
||||
}
|
||||
}
|
||||
Message::FieldValuePath(path, key) => {
|
||||
if let Some(draft) = &mut self.draft
|
||||
&& let Err(error) = draft.value_path(&path, key)
|
||||
{
|
||||
self.status = error;
|
||||
}
|
||||
}
|
||||
Message::FieldVariant(path, value) => {
|
||||
if let Some(d) = &mut self.draft {
|
||||
let result = d.materialize().and_then(|current| {
|
||||
@@ -1384,6 +1406,19 @@ impl App {
|
||||
self.fit();
|
||||
}
|
||||
}
|
||||
Message::Data(event) => {
|
||||
if self.data_state.update(&event) {
|
||||
return Task::none();
|
||||
}
|
||||
if self.apply() {
|
||||
match crate::data_view::command(self.project(), self.layer, event) {
|
||||
Ok(command) => {
|
||||
self.execute(command);
|
||||
}
|
||||
Err(error) => self.status = error,
|
||||
}
|
||||
}
|
||||
}
|
||||
Message::CreateKind(kind) => self.create_kind = kind,
|
||||
Message::Add(child) => {
|
||||
if self.apply() {
|
||||
@@ -3847,3 +3882,50 @@ mod lint_tests {
|
||||
assert!(app.project().elements.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod data_paging_lifecycle_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn paging_preserves_same_context_and_resets_on_layer_or_document_replacement() {
|
||||
let mut p = Project::empty();
|
||||
let mut oa = None;
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
for index in 0..70 {
|
||||
let id = p
|
||||
.add_element(
|
||||
Kind::Interface,
|
||||
layer,
|
||||
&format!("Interface {layer} {index}"),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
if layer == Layer::Oa {
|
||||
oa = Some(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut app = App::with_project(p.clone());
|
||||
let page = Message::Data(crate::data_view::Event::Page {
|
||||
section: crate::data_view::Section::Catalog,
|
||||
owner: None,
|
||||
page: 2,
|
||||
});
|
||||
let _ = app.update(Message::Page(Page::Data));
|
||||
let _ = app.update(page.clone());
|
||||
let retained = app.data_state.clone();
|
||||
let _ = app.update(Message::Page(Page::Catalog));
|
||||
let _ = app.update(Message::Page(Page::Data));
|
||||
let _ = app.update(Message::Layer(Layer::Oa));
|
||||
assert_eq!(app.data_state, retained);
|
||||
let _ = app.update(Message::Layer(Layer::Sa));
|
||||
assert_eq!(app.data_state, crate::data_view::State::default());
|
||||
let _ = app.update(page.clone());
|
||||
let _ = app.update(Message::Select(oa.unwrap()));
|
||||
assert_eq!(app.layer, Layer::Oa);
|
||||
assert_eq!(app.data_state, crate::data_view::State::default());
|
||||
let _ = app.update(page);
|
||||
app.reset(p, None);
|
||||
assert_eq!(app.data_state, crate::data_view::State::default());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,7 +78,8 @@ pub enum Event {
|
||||
SecondGuard(String),
|
||||
Operator(Operator),
|
||||
Binding(Id, Choice),
|
||||
Value(Id, String),
|
||||
Value(crate::data_values::FieldPathKey, String),
|
||||
ValueFocus(crate::data_values::FieldPathKey),
|
||||
}
|
||||
#[derive(Default)]
|
||||
pub struct State {
|
||||
@@ -100,7 +101,8 @@ struct Draft {
|
||||
second_guard: String,
|
||||
operator: FragmentOperator,
|
||||
bindings: BTreeMap<Id, Id>,
|
||||
values: BTreeMap<Id, String>,
|
||||
values: BTreeMap<crate::data_values::FieldPathKey, String>,
|
||||
value_focus: Option<crate::data_values::FieldPathKey>,
|
||||
}
|
||||
impl State {
|
||||
pub(super) fn pending_owner(&self) -> Option<Id> {
|
||||
@@ -146,6 +148,7 @@ impl State {
|
||||
operator: FragmentOperator::Alternative,
|
||||
bindings: BTreeMap::new(),
|
||||
values: BTreeMap::new(),
|
||||
value_focus: None,
|
||||
});
|
||||
}
|
||||
Event::Cancel => self.acknowledge_commit(),
|
||||
@@ -161,21 +164,34 @@ impl State {
|
||||
d.bindings.clear();
|
||||
}
|
||||
Event::Exchange(c) => {
|
||||
d.exchange = Some(c.id);
|
||||
d.values.clear();
|
||||
d.sender = None;
|
||||
d.receiver = None;
|
||||
if d.exchange != Some(c.id) {
|
||||
d.exchange = Some(c.id);
|
||||
d.values.clear();
|
||||
d.value_focus = None;
|
||||
d.sender = None;
|
||||
d.receiver = None;
|
||||
}
|
||||
}
|
||||
Event::Sender(c) => d.sender = Some(c.id),
|
||||
Event::Receiver(c) => d.receiver = Some(c.id),
|
||||
Event::Parent(c) => d.parent = c.id,
|
||||
Event::Style(style) => {
|
||||
let previous = d.style;
|
||||
d.style = style;
|
||||
d.call = None;
|
||||
d.exchange = None;
|
||||
d.sender = None;
|
||||
d.receiver = None;
|
||||
d.values.clear();
|
||||
if previous != style {
|
||||
if previous == MessageStyle::Timer || style == MessageStyle::Timer {
|
||||
d.exchange = None;
|
||||
d.sender = None;
|
||||
d.receiver = None;
|
||||
d.call = None;
|
||||
} else if (previous == MessageStyle::Reply)
|
||||
!= (style == MessageStyle::Reply)
|
||||
{
|
||||
std::mem::swap(&mut d.sender, &mut d.receiver);
|
||||
d.call = None;
|
||||
}
|
||||
d.retain_value_paths(p);
|
||||
}
|
||||
}
|
||||
Event::Call(c) => {
|
||||
d.call = Some(c.id);
|
||||
@@ -190,6 +206,10 @@ impl State {
|
||||
..
|
||||
}) = p.elements.get(&c.id).and_then(|e| e.shared())
|
||||
{
|
||||
if d.exchange != *exchange {
|
||||
d.values.clear();
|
||||
d.value_focus = None;
|
||||
}
|
||||
d.exchange = *exchange;
|
||||
d.sender = *receiver;
|
||||
d.receiver = *sender;
|
||||
@@ -202,6 +222,14 @@ impl State {
|
||||
Event::Value(id, s) => {
|
||||
d.values.insert(id, s);
|
||||
}
|
||||
Event::ValueFocus(key)
|
||||
if d.exchange
|
||||
.and_then(|id| p.relations.get(&id))
|
||||
.and_then(|r| crate::data_values::resolve(p, r, &d.kind(), &key))
|
||||
.is_some() =>
|
||||
{
|
||||
d.value_focus = Some(key);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -322,7 +350,7 @@ fn exchanges(p: &Project, d: &Draft) -> Vec<Choice> {
|
||||
exchange: Some(r.id),
|
||||
sender: None,
|
||||
receiver: None,
|
||||
message_kind: MessageKind::Asynchronous,
|
||||
message_kind: d.kind(),
|
||||
reply_to: None,
|
||||
values: vec![],
|
||||
},
|
||||
@@ -373,6 +401,37 @@ fn calls(p: &Project, d: &Draft) -> Vec<Choice> {
|
||||
.collect()
|
||||
}
|
||||
impl Draft {
|
||||
fn retain_value_paths(&mut self, p: &Project) {
|
||||
let kind = self.kind();
|
||||
let exchange = self.exchange.and_then(|id| p.relations.get(&id));
|
||||
self.values.retain(|key, _| {
|
||||
exchange
|
||||
.and_then(|r| crate::data_values::resolve(p, r, &kind, key))
|
||||
.is_some()
|
||||
});
|
||||
if self.value_focus.as_ref().is_some_and(|key| {
|
||||
exchange
|
||||
.and_then(|r| crate::data_values::resolve(p, r, &kind, key))
|
||||
.is_none()
|
||||
}) {
|
||||
self.value_focus = None;
|
||||
}
|
||||
}
|
||||
fn value_choices(
|
||||
&self,
|
||||
p: &Project,
|
||||
exchange: &Relation,
|
||||
) -> Result<Vec<crate::data_values::FieldPathChoice>, String> {
|
||||
let mut choices = crate::data_values::choices(p, exchange, &self.kind())?;
|
||||
for key in self.values.keys().chain(self.value_focus.iter()) {
|
||||
if !choices.iter().any(|choice| &choice.key == key)
|
||||
&& let Some(choice) = crate::data_values::resolve(p, exchange, &self.kind(), key)
|
||||
{
|
||||
choices.push(choice);
|
||||
}
|
||||
}
|
||||
Ok(choices)
|
||||
}
|
||||
fn kind(&self) -> MessageKind {
|
||||
match self.style {
|
||||
MessageStyle::Asynchronous => MessageKind::Asynchronous,
|
||||
@@ -405,7 +464,8 @@ impl Draft {
|
||||
.iter()
|
||||
.filter(|(_, v)| !v.trim().is_empty())
|
||||
.map(|(field, expression)| MessageValue {
|
||||
field: *field,
|
||||
path: field.path.clone(),
|
||||
field: field.field,
|
||||
expression: expression.clone(),
|
||||
})
|
||||
.collect(),
|
||||
@@ -762,20 +822,65 @@ pub fn view<'a>(app: &'a App, owner: Id, mut out: Column<'a, Message>) -> Elemen
|
||||
);
|
||||
}
|
||||
if let Some(exchange) = d.exchange.and_then(|id| p.relations.get(&id)) {
|
||||
for field in validation::carried_fields(p, exchange) {
|
||||
form = form.push(
|
||||
row![
|
||||
label(p.named(field)),
|
||||
text_input(
|
||||
"Optional value expression",
|
||||
d.values.get(&field).map(String::as_str).unwrap_or("")
|
||||
)
|
||||
.on_input(move |s| event(Event::Value(field, s)))
|
||||
match d.value_choices(p, exchange) {
|
||||
Ok(choices) => {
|
||||
let focus = d.value_focus.as_ref().and_then(|key| {
|
||||
crate::data_values::resolve(p, exchange, &d.kind(), key)
|
||||
});
|
||||
form = form.push(
|
||||
pick_list(choices.clone(), focus.clone(), |choice| {
|
||||
event(Event::ValueFocus(choice.key))
|
||||
})
|
||||
.placeholder("Explore a record or choose a value path…")
|
||||
.text_size(12)
|
||||
.padding(8)
|
||||
.size(12)
|
||||
]
|
||||
.spacing(7),
|
||||
);
|
||||
.width(Fill),
|
||||
);
|
||||
if let Some(focus) = focus {
|
||||
if let Some(parent) = crate::data_values::parent(&focus.key) {
|
||||
form = form.push(
|
||||
button(text("Up to enclosing field").size(11))
|
||||
.on_press(event(Event::ValueFocus(parent))),
|
||||
);
|
||||
}
|
||||
match crate::data_values::children(p, exchange, &d.kind(), &focus.key) {
|
||||
Ok(children) if !children.is_empty() => {
|
||||
form = form.push(
|
||||
pick_list(
|
||||
children,
|
||||
None::<crate::data_values::FieldPathChoice>,
|
||||
|choice| event(Event::ValueFocus(choice.key)),
|
||||
)
|
||||
.placeholder("Choose a field inside this record…")
|
||||
.text_size(12)
|
||||
.padding(8)
|
||||
.width(Fill),
|
||||
)
|
||||
}
|
||||
Err(error) => {
|
||||
form = form.push(text(error).size(11).color(style::MUTED))
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
for choice in choices {
|
||||
let key = choice.key;
|
||||
form = form.push(
|
||||
column![
|
||||
text(choice.label).size(11),
|
||||
text_input(
|
||||
"Optional value expression",
|
||||
d.values.get(&key).map(String::as_str).unwrap_or("")
|
||||
)
|
||||
.on_input(move |s| event(Event::Value(key.clone(), s)))
|
||||
.padding(8)
|
||||
.size(12)
|
||||
]
|
||||
.spacing(4),
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(error) => form = form.push(text(error).size(12).color(style::MUTED)),
|
||||
}
|
||||
}
|
||||
if d.style == MessageStyle::CallReply {
|
||||
@@ -1189,3 +1294,200 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod data_contract_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn scenario_form_commits_two_reused_record_values_and_correlated_reply_atomically() {
|
||||
let mut f = crate::data_values::tests::payload();
|
||||
if let Some(SharedBody::DataField { direction, .. }) = f
|
||||
.project
|
||||
.elements
|
||||
.get_mut(&f.destination)
|
||||
.unwrap()
|
||||
.shared_mut()
|
||||
{
|
||||
*direction = ParameterDirection::InOut;
|
||||
}
|
||||
let source = f.project.relations[&f.exchange].source();
|
||||
let target = f.project.relations[&f.exchange].target();
|
||||
let mut lines = vec![];
|
||||
for (name, represented) in [("Requester", source), ("Performer", target)] {
|
||||
let id = f
|
||||
.project
|
||||
.add_element(Kind::Lifeline, Layer::Oa, name, Some(f.scenario))
|
||||
.unwrap();
|
||||
*f.project
|
||||
.elements
|
||||
.get_mut(&id)
|
||||
.unwrap()
|
||||
.shared_mut()
|
||||
.unwrap() = SharedBody::Lifeline {
|
||||
represented: Some(represented),
|
||||
};
|
||||
lines.push(id);
|
||||
}
|
||||
let mut state = State::default();
|
||||
for event in [
|
||||
Event::Start(f.scenario, Tool::Message),
|
||||
Event::Name("Move coordinates".into()),
|
||||
Event::Exchange(named_choice(&f.project, f.exchange)),
|
||||
Event::Sender(named_choice(&f.project, lines[0])),
|
||||
Event::Receiver(named_choice(&f.project, lines[1])),
|
||||
] {
|
||||
state.update(&f.project, event).unwrap();
|
||||
}
|
||||
assert!(
|
||||
exchanges(&f.project, state.draft.as_ref().unwrap())
|
||||
.iter()
|
||||
.any(|c| c.id == f.exchange)
|
||||
);
|
||||
for (parameter, expression) in [(f.origin, "45.0"), (f.destination, "46.0")] {
|
||||
state
|
||||
.update(
|
||||
&f.project,
|
||||
Event::Value(
|
||||
crate::data_values::FieldPathKey {
|
||||
path: vec![parameter],
|
||||
field: f.leaf,
|
||||
},
|
||||
expression.into(),
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let before = state.draft.as_ref().unwrap().values.clone();
|
||||
state
|
||||
.update(&f.project, Event::Style(MessageStyle::Call))
|
||||
.unwrap();
|
||||
state
|
||||
.update(
|
||||
&f.project,
|
||||
Event::Exchange(named_choice(&f.project, f.exchange)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(state.draft.as_ref().unwrap().values, before);
|
||||
assert_eq!(state.draft.as_ref().unwrap().sender, Some(lines[0]));
|
||||
assert_eq!(state.draft.as_ref().unwrap().receiver, Some(lines[1]));
|
||||
state
|
||||
.update(&f.project, Event::Style(MessageStyle::CallReply))
|
||||
.unwrap();
|
||||
assert_eq!(state.draft.as_ref().unwrap().values, before);
|
||||
let command = state.prepare_pending(&f.project).unwrap();
|
||||
let mut document =
|
||||
crate::document::Document::from_project(f.project.clone(), Default::default()).unwrap();
|
||||
document.execute(command).unwrap();
|
||||
let messages = document
|
||||
.project()
|
||||
.children(f.scenario)
|
||||
.filter_map(|e| match e.shared()? {
|
||||
SharedBody::Occurrence {
|
||||
body:
|
||||
OccurrenceBody::Exchange {
|
||||
message_kind,
|
||||
values,
|
||||
..
|
||||
},
|
||||
..
|
||||
} => Some((message_kind, values)),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let call = messages
|
||||
.iter()
|
||||
.find(|(kind, _)| **kind == MessageKind::Call)
|
||||
.unwrap()
|
||||
.1;
|
||||
assert_eq!(call.len(), 2);
|
||||
assert_eq!(
|
||||
call.iter().map(|v| v.field).collect::<BTreeSet<_>>(),
|
||||
BTreeSet::from([f.leaf])
|
||||
);
|
||||
assert_eq!(
|
||||
call.iter().map(|v| v.path.clone()).collect::<BTreeSet<_>>(),
|
||||
BTreeSet::from([vec![f.origin], vec![f.destination]])
|
||||
);
|
||||
assert!(
|
||||
messages
|
||||
.iter()
|
||||
.find(|(kind, _)| **kind == MessageKind::Reply)
|
||||
.unwrap()
|
||||
.1
|
||||
.is_empty()
|
||||
);
|
||||
assert!(document.undo());
|
||||
assert_eq!(document.project(), &f.project);
|
||||
assert!(!document.undo());
|
||||
}
|
||||
#[test]
|
||||
fn recursive_value_focus_exposes_authored_leaf_and_phase_changes_retain_surviving_keys() {
|
||||
let (f, next) = crate::data_values::tests::recursive_payload();
|
||||
let mut state = State::default();
|
||||
state
|
||||
.update(&f.project, Event::Start(f.scenario, Tool::Message))
|
||||
.unwrap();
|
||||
state
|
||||
.update(
|
||||
&f.project,
|
||||
Event::Exchange(named_choice(&f.project, f.exchange)),
|
||||
)
|
||||
.unwrap();
|
||||
let key = crate::data_values::FieldPathKey {
|
||||
path: vec![f.origin, next],
|
||||
field: f.leaf,
|
||||
};
|
||||
state
|
||||
.update(&f.project, Event::ValueFocus(key.clone()))
|
||||
.unwrap();
|
||||
assert!(
|
||||
state
|
||||
.draft
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.value_choices(&f.project, &f.project.relations[&f.exchange])
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|c| c.key == key)
|
||||
);
|
||||
state
|
||||
.update(
|
||||
&f.project,
|
||||
Event::Value(key.clone(), "recursive expression".into()),
|
||||
)
|
||||
.unwrap();
|
||||
state
|
||||
.update(&f.project, Event::Style(MessageStyle::Call))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
state.draft.as_ref().unwrap().values[&key],
|
||||
"recursive expression"
|
||||
);
|
||||
state
|
||||
.update(&f.project, Event::Style(MessageStyle::Asynchronous))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
state.draft.as_ref().unwrap().values[&key],
|
||||
"recursive expression"
|
||||
);
|
||||
state
|
||||
.update(&f.project, Event::Style(MessageStyle::Reply))
|
||||
.unwrap();
|
||||
assert!(
|
||||
state.draft.as_ref().unwrap().values.is_empty(),
|
||||
"In parameters do not become reply values"
|
||||
);
|
||||
let out = crate::data_values::FieldPathKey {
|
||||
path: vec![f.destination],
|
||||
field: f.leaf,
|
||||
};
|
||||
state
|
||||
.update(&f.project, Event::Value(out.clone(), "reply value".into()))
|
||||
.unwrap();
|
||||
state
|
||||
.update(&f.project, Event::Style(MessageStyle::Asynchronous))
|
||||
.unwrap();
|
||||
assert_eq!(state.draft.as_ref().unwrap().values[&out], "reply value");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -432,11 +432,12 @@ mod tests {
|
||||
let provided = element(&mut p, Kind::Interface, Layer::Sa, "Reporting");
|
||||
let required = element(&mut p, Kind::Interface, Layer::Sa, "Results");
|
||||
for (interface, item) in [(provided, report), (required, result)] {
|
||||
*p.elements
|
||||
.get_mut(&interface)
|
||||
.unwrap()
|
||||
.shared_mut()
|
||||
.unwrap() = SharedBody::Interface { items: vec![item] };
|
||||
p.relate(
|
||||
RelationKind::InterfaceItemAllocation,
|
||||
"Allocated item",
|
||||
interface,
|
||||
item,
|
||||
);
|
||||
}
|
||||
p.relate(
|
||||
RelationKind::InterfaceProvision,
|
||||
|
||||
@@ -0,0 +1,375 @@
|
||||
//! One address for a carried value, shared by scenario creation and property editing.
|
||||
use oa_core::*;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct FieldPathKey {
|
||||
pub path: Vec<Id>,
|
||||
pub field: Id,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct FieldPathChoice {
|
||||
pub key: FieldPathKey,
|
||||
pub label: String,
|
||||
}
|
||||
impl std::fmt::Display for FieldPathChoice {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(&self.label)
|
||||
}
|
||||
}
|
||||
|
||||
fn phase_allows(project: &Project, entry: &data::CarriedFieldPath, phase: &MessageKind) -> bool {
|
||||
data::item_mechanism(project, entry.item) != Some(ExchangeMechanism::Operation)
|
||||
|| match phase {
|
||||
MessageKind::Call => matches!(
|
||||
entry.direction,
|
||||
ParameterDirection::Unspecified
|
||||
| ParameterDirection::In
|
||||
| ParameterDirection::InOut
|
||||
),
|
||||
MessageKind::Reply => matches!(
|
||||
entry.direction,
|
||||
ParameterDirection::Unspecified
|
||||
| ParameterDirection::Out
|
||||
| ParameterDirection::InOut
|
||||
| ParameterDirection::Return
|
||||
),
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
fn named_choice(project: &Project, entry: data::CarriedFieldPath) -> FieldPathChoice {
|
||||
let qualified = entry
|
||||
.path
|
||||
.iter()
|
||||
.chain(std::iter::once(&entry.field))
|
||||
.map(|id| {
|
||||
project
|
||||
.elements
|
||||
.get(id)
|
||||
.map(|e| format!("{} (@{})", e.name, e.alias))
|
||||
.unwrap_or_else(|| id.to_string())
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" → ");
|
||||
let direction =
|
||||
if data::item_mechanism(project, entry.item) == Some(ExchangeMechanism::Operation) {
|
||||
match entry.direction {
|
||||
ParameterDirection::Unspecified => " · direction unspecified — review",
|
||||
ParameterDirection::In => " · In",
|
||||
ParameterDirection::Out => " · Out",
|
||||
ParameterDirection::InOut => " · InOut",
|
||||
ParameterDirection::Return => " · Return",
|
||||
}
|
||||
} else {
|
||||
""
|
||||
};
|
||||
FieldPathChoice {
|
||||
key: FieldPathKey {
|
||||
path: entry.path,
|
||||
field: entry.field,
|
||||
},
|
||||
label: format!("{} / {qualified}{direction}", project.named(entry.item)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn choices(
|
||||
project: &Project,
|
||||
exchange: &Relation,
|
||||
phase: &MessageKind,
|
||||
) -> Result<Vec<FieldPathChoice>, String> {
|
||||
Ok(data::try_carried_field_paths(project, exchange)?
|
||||
.into_iter()
|
||||
.filter(|entry| phase_allows(project, entry, phase))
|
||||
.map(|entry| named_choice(project, entry))
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Resolve authored finite paths as well as the finite representative picker list.
|
||||
/// In particular, optional recursive Value records may be traversed repeatedly.
|
||||
pub fn resolve(
|
||||
project: &Project,
|
||||
exchange: &Relation,
|
||||
phase: &MessageKind,
|
||||
key: &FieldPathKey,
|
||||
) -> Option<FieldPathChoice> {
|
||||
data::carried_value_path(project, exchange, &key.path, key.field)
|
||||
.filter(|entry| phase_allows(project, entry, phase))
|
||||
.map(|entry| named_choice(project, entry))
|
||||
}
|
||||
|
||||
pub fn parent(key: &FieldPathKey) -> Option<FieldPathKey> {
|
||||
let (&field, path) = key.path.split_last()?;
|
||||
Some(FieldPathKey {
|
||||
path: path.to_vec(),
|
||||
field,
|
||||
})
|
||||
}
|
||||
|
||||
/// Expand exactly one selected record. This keeps recursive authoring explicit
|
||||
/// and finite, and never traverses Reference storage or manufactures value paths.
|
||||
pub fn children(
|
||||
project: &Project,
|
||||
exchange: &Relation,
|
||||
phase: &MessageKind,
|
||||
key: &FieldPathKey,
|
||||
) -> Result<Vec<FieldPathChoice>, String> {
|
||||
if resolve(project, exchange, phase, key).is_none() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
let Some(SharedBody::DataField {
|
||||
type_ref: TypeRef::Named(record),
|
||||
storage: FieldStorage::Value,
|
||||
..
|
||||
}) = project.elements.get(&key.field).and_then(Element::shared)
|
||||
else {
|
||||
return Ok(vec![]);
|
||||
};
|
||||
if !data::is_record(project, *record) {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
if key.path.len() >= data::MAX_VALUE_PATH_DEPTH {
|
||||
return Err(format!(
|
||||
"A value path can contain at most {} enclosing fields.",
|
||||
data::MAX_VALUE_PATH_DEPTH
|
||||
));
|
||||
}
|
||||
let mut path = key.path.clone();
|
||||
path.push(key.field);
|
||||
Ok(data::effective_fields(project, *record)
|
||||
.into_iter()
|
||||
.filter_map(|field| {
|
||||
resolve(
|
||||
project,
|
||||
exchange,
|
||||
phase,
|
||||
&FieldPathKey {
|
||||
path: path.clone(),
|
||||
field: field.id,
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod tests {
|
||||
use super::*;
|
||||
|
||||
pub struct Payload {
|
||||
pub project: Project,
|
||||
pub exchange: Id,
|
||||
pub item: Id,
|
||||
pub leaf: Id,
|
||||
pub origin: Id,
|
||||
pub destination: Id,
|
||||
pub scenario: Id,
|
||||
}
|
||||
pub fn payload() -> Payload {
|
||||
let mut p = Project::empty();
|
||||
let item = p
|
||||
.add_element(Kind::ExchangeItem, Layer::Oa, "Move", None)
|
||||
.unwrap();
|
||||
*p.elements.get_mut(&item).unwrap().shared_mut().unwrap() = SharedBody::ExchangeItem {
|
||||
kind: ExchangeItemKind::Information,
|
||||
mechanism: ExchangeMechanism::Operation,
|
||||
supertype: None,
|
||||
};
|
||||
let record = p
|
||||
.add_element(Kind::DataType, Layer::Oa, "Coordinate", None)
|
||||
.unwrap();
|
||||
*p.elements.get_mut(&record).unwrap().shared_mut().unwrap() = SharedBody::DataType {
|
||||
definition: DataTypeDefinition::Record { supertype: None },
|
||||
default: None,
|
||||
unit: String::new(),
|
||||
};
|
||||
let leaf = p
|
||||
.add_element(Kind::DataField, Layer::Oa, "latitude", Some(record))
|
||||
.unwrap();
|
||||
let origin = p
|
||||
.add_element(Kind::DataField, Layer::Oa, "origin", Some(item))
|
||||
.unwrap();
|
||||
let destination = p
|
||||
.add_element(Kind::DataField, Layer::Oa, "destination", Some(item))
|
||||
.unwrap();
|
||||
for (id, type_ref, direction) in [
|
||||
(leaf, TypeRef::Real, ParameterDirection::Unspecified),
|
||||
(origin, TypeRef::Named(record), ParameterDirection::In),
|
||||
(destination, TypeRef::Named(record), ParameterDirection::Out),
|
||||
] {
|
||||
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::DataField {
|
||||
type_ref,
|
||||
direction,
|
||||
minimum: 1,
|
||||
maximum: Some(1),
|
||||
unit: String::new(),
|
||||
storage: FieldStorage::Value,
|
||||
default: None,
|
||||
};
|
||||
}
|
||||
let source = p
|
||||
.add_element(Kind::Activity, Layer::Oa, "Request", None)
|
||||
.unwrap();
|
||||
let target = p
|
||||
.add_element(Kind::Activity, Layer::Oa, "Perform", None)
|
||||
.unwrap();
|
||||
let exchange = p.relate(RelationKind::Interaction, "Movement", source, target);
|
||||
p.relations.get_mut(&exchange).unwrap().body = RelationBody::Interaction {
|
||||
source,
|
||||
target,
|
||||
items: vec![item],
|
||||
};
|
||||
let scenario = p
|
||||
.add_element(Kind::Scenario, Layer::Oa, "Conversation", None)
|
||||
.unwrap();
|
||||
validate_candidate(&p).unwrap();
|
||||
Payload {
|
||||
project: p,
|
||||
exchange,
|
||||
item,
|
||||
leaf,
|
||||
origin,
|
||||
destination,
|
||||
scenario,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn phase_choices_keep_reused_record_addresses_distinct() {
|
||||
let mut f = payload();
|
||||
assert_eq!(
|
||||
data::item_mechanism(&f.project, f.item),
|
||||
Some(ExchangeMechanism::Operation)
|
||||
);
|
||||
let call = choices(
|
||||
&f.project,
|
||||
&f.project.relations[&f.exchange],
|
||||
&MessageKind::Call,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
call.iter()
|
||||
.any(|c| c.key.path == [f.origin] && c.key.field == f.leaf)
|
||||
);
|
||||
assert!(!call.iter().any(|c| c.key.path == [f.destination]));
|
||||
let reply = choices(
|
||||
&f.project,
|
||||
&f.project.relations[&f.exchange],
|
||||
&MessageKind::Reply,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
reply
|
||||
.iter()
|
||||
.any(|c| c.key.path == [f.destination] && c.key.field == f.leaf)
|
||||
);
|
||||
assert!(!reply.iter().any(|c| c.key.path == [f.origin]));
|
||||
if let Some(SharedBody::DataField { direction, .. }) = f
|
||||
.project
|
||||
.elements
|
||||
.get_mut(&f.destination)
|
||||
.unwrap()
|
||||
.shared_mut()
|
||||
{
|
||||
*direction = ParameterDirection::InOut;
|
||||
}
|
||||
let both = choices(
|
||||
&f.project,
|
||||
&f.project.relations[&f.exchange],
|
||||
&MessageKind::Call,
|
||||
)
|
||||
.unwrap();
|
||||
let leaves = both
|
||||
.iter()
|
||||
.filter(|c| c.key.field == f.leaf)
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(leaves.len(), 2);
|
||||
assert_ne!(leaves[0].key, leaves[1].key);
|
||||
assert_ne!(leaves[0].label, leaves[1].label);
|
||||
let values = leaves
|
||||
.into_iter()
|
||||
.map(|c| (c.key.clone(), "1".to_string()))
|
||||
.collect::<std::collections::BTreeMap<_, _>>();
|
||||
assert_eq!(values.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_parameter_stops_descent_and_unspecified_direction_remains_visible() {
|
||||
let mut f = payload();
|
||||
if let Some(SharedBody::DataField {
|
||||
storage, direction, ..
|
||||
}) = f.project.elements.get_mut(&f.origin).unwrap().shared_mut()
|
||||
{
|
||||
*storage = FieldStorage::Reference;
|
||||
*direction = ParameterDirection::Unspecified;
|
||||
}
|
||||
let call = choices(
|
||||
&f.project,
|
||||
&f.project.relations[&f.exchange],
|
||||
&MessageKind::Call,
|
||||
)
|
||||
.unwrap();
|
||||
let direct = call.iter().find(|c| c.key.field == f.origin).unwrap();
|
||||
assert!(direct.key.path.is_empty());
|
||||
assert!(direct.label.contains("unspecified"));
|
||||
assert!(!call.iter().any(|c| c.key.path.contains(&f.origin)));
|
||||
}
|
||||
pub fn recursive_payload() -> (Payload, Id) {
|
||||
let mut f = payload();
|
||||
let record = f.project.elements[&f.leaf].parent.unwrap();
|
||||
let next = f
|
||||
.project
|
||||
.add_element(Kind::DataField, Layer::Oa, "next", Some(record))
|
||||
.unwrap();
|
||||
*f.project
|
||||
.elements
|
||||
.get_mut(&next)
|
||||
.unwrap()
|
||||
.shared_mut()
|
||||
.unwrap() = SharedBody::DataField {
|
||||
type_ref: TypeRef::Named(record),
|
||||
minimum: 0,
|
||||
maximum: Some(1),
|
||||
unit: String::new(),
|
||||
direction: ParameterDirection::Unspecified,
|
||||
storage: FieldStorage::Value,
|
||||
default: None,
|
||||
};
|
||||
validate_candidate(&f.project).unwrap();
|
||||
(f, next)
|
||||
}
|
||||
#[test]
|
||||
fn recursive_records_can_be_drilled_into_and_valid_imported_paths_resolve() {
|
||||
let (f, next) = recursive_payload();
|
||||
let exchange = &f.project.relations[&f.exchange];
|
||||
let record = FieldPathKey {
|
||||
path: vec![f.origin],
|
||||
field: next,
|
||||
};
|
||||
let nested = children(&f.project, exchange, &MessageKind::Call, &record).unwrap();
|
||||
let leaf = nested.iter().find(|c| c.key.field == f.leaf).unwrap();
|
||||
assert_eq!(leaf.key.path, vec![f.origin, next]);
|
||||
assert!(
|
||||
!choices(&f.project, exchange, &MessageKind::Call)
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|c| c.key == leaf.key)
|
||||
);
|
||||
assert_eq!(
|
||||
resolve(&f.project, exchange, &MessageKind::Call, &leaf.key),
|
||||
Some(leaf.clone())
|
||||
);
|
||||
assert_eq!(parent(&leaf.key), Some(record));
|
||||
let deepest = FieldPathKey {
|
||||
path: std::iter::once(f.origin)
|
||||
.chain(std::iter::repeat_n(next, data::MAX_VALUE_PATH_DEPTH - 1))
|
||||
.collect(),
|
||||
field: next,
|
||||
};
|
||||
assert!(resolve(&f.project, exchange, &MessageKind::Call, &deepest).is_some());
|
||||
assert!(
|
||||
children(&f.project, exchange, &MessageKind::Call, &deepest)
|
||||
.unwrap_err()
|
||||
.contains("128")
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -292,6 +292,7 @@ impl DiagramCanvas<'_> {
|
||||
request.placement = Some(node);
|
||||
} else if let Some(edge) = self
|
||||
.hit_label(&scene.routed, local)
|
||||
.or_else(|| self.hit_association_label(&scene.routed, local))
|
||||
.or_else(|| self.hit_edge(&scene.routed, local).map(|(id, _)| id))
|
||||
{
|
||||
request.object = edge;
|
||||
@@ -369,6 +370,33 @@ impl DiagramCanvas<'_> {
|
||||
})
|
||||
.map(|edge| edge.id)
|
||||
}
|
||||
fn hit_association_label(&self, routed: &RoutedView, p: Point) -> Option<Id> {
|
||||
let p = self.world(p);
|
||||
routed
|
||||
.edges
|
||||
.values()
|
||||
.flat_map(|edge| {
|
||||
self.project
|
||||
.relations
|
||||
.get(&edge.id)
|
||||
.into_iter()
|
||||
.flat_map(move |relation| routing::association_end_geometry(relation, edge))
|
||||
.map(move |end| (edge.id, end.label))
|
||||
})
|
||||
.filter(|(_, label)| {
|
||||
Rectangle::new(
|
||||
point(label.bounds.position),
|
||||
Size::new(label.bounds.size.width, label.bounds.size.height),
|
||||
)
|
||||
.contains(p)
|
||||
})
|
||||
.min_by(|a, b| {
|
||||
p.distance(point(a.1.center))
|
||||
.total_cmp(&p.distance(point(b.1.center)))
|
||||
.then(a.0.cmp(&b.0))
|
||||
})
|
||||
.map(|(id, _)| id)
|
||||
}
|
||||
fn preview_diagram(&self, state: &State) -> Diagram {
|
||||
let mut diagram = self.project.diagrams[&self.diagram].clone();
|
||||
if let (Some((id, _, original)), Some(preview)) = (state.drag, state.preview) {
|
||||
@@ -725,7 +753,10 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
|
||||
state.drag = Some((id, p, position));
|
||||
state.preview = Some(position);
|
||||
Some(Action::publish(Message::SelectNode(id)).and_capture())
|
||||
} else if let Some(id) = self.hit_label(&scene.routed, p) {
|
||||
} else if let Some(id) = self
|
||||
.hit_label(&scene.routed, p)
|
||||
.or_else(|| self.hit_association_label(&scene.routed, p))
|
||||
{
|
||||
Some(Action::publish(Message::Select(id)).and_capture())
|
||||
} else if let Some((id, segment)) = self.hit_edge(&scene.routed, p) {
|
||||
if id == self.selected
|
||||
@@ -1010,6 +1041,37 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
if let Some(relation) = self.project.relations.get(&edge.id) {
|
||||
for end in routing::association_end_geometry(relation, routed) {
|
||||
if end.navigable {
|
||||
let [a, tip, b] = navigation_mark(end.tip, end.inward);
|
||||
f.stroke(
|
||||
&Path::new(|p| {
|
||||
p.move_to(a);
|
||||
p.line_to(tip);
|
||||
p.line_to(b);
|
||||
}),
|
||||
Stroke::default().with_color(color).with_width(1.7),
|
||||
);
|
||||
}
|
||||
let label = &end.label;
|
||||
f.fill_rectangle(
|
||||
point(label.bounds.position),
|
||||
Size::new(label.bounds.size.width, label.bounds.size.height),
|
||||
Color::WHITE,
|
||||
);
|
||||
f.fill_text(Text {
|
||||
content: label.text.clone(),
|
||||
position: point(label.center),
|
||||
color: if active { style::TEAL } else { style::MUTED },
|
||||
size: 13.into(),
|
||||
align_x: iced::widget::text::Alignment::Center,
|
||||
align_y: iced::alignment::Vertical::Center,
|
||||
max_width: f32::INFINITY,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
for n in &view.nodes {
|
||||
if !containers.contains(&n.id) {
|
||||
@@ -1257,6 +1319,17 @@ fn endpoint_position(view: &ProjectedView, id: Id) -> Option<Position> {
|
||||
})
|
||||
})
|
||||
}
|
||||
fn navigation_mark(tip: Position, inward: Position) -> [Point; 3] {
|
||||
let tip = point(tip);
|
||||
let delta = point(inward) - tip;
|
||||
let direction = delta / delta.x.hypot(delta.y).max(0.001);
|
||||
let normal = Vector::new(-direction.y, direction.x) * 4.;
|
||||
[
|
||||
tip + direction * 9. + normal,
|
||||
tip,
|
||||
tip + direction * 9. - normal,
|
||||
]
|
||||
}
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum PortGlyph {
|
||||
Function,
|
||||
@@ -1544,6 +1617,21 @@ pub fn export_svg(p: &Project, diagram: Id) -> String {
|
||||
if !label.text.is_empty() {
|
||||
svg.push_str(&format!("<rect x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\" fill=\"white\"/><text x=\"{}\" y=\"{}\" text-anchor=\"middle\" dominant-baseline=\"central\" font-family=\"sans-serif\" font-size=\"13\" fill=\"#637487\">{}</text>",label.bounds.position.x,label.bounds.position.y,label.bounds.size.width,label.bounds.size.height,label.center.x,label.center.y,escape(&label.text)));
|
||||
}
|
||||
if let Some(relation) = p.relations.get(&edge.id) {
|
||||
for end in routing::association_end_geometry(relation, route) {
|
||||
let side = if end.source_end { "source" } else { "target" };
|
||||
svg.push_str(&format!(
|
||||
"<g data-association=\"{}\" data-end=\"{side}\">",
|
||||
edge.id
|
||||
));
|
||||
if end.navigable {
|
||||
let [a, tip, b] = navigation_mark(end.tip, end.inward);
|
||||
svg.push_str(&format!("<path data-navigation=\"{side}\" d=\"M {} {} L {} {} L {} {}\" fill=\"none\" stroke=\"#8999a9\" stroke-width=\"1.7\"/>",a.x,a.y,tip.x,tip.y,b.x,b.y));
|
||||
}
|
||||
let label = &end.label;
|
||||
svg.push_str(&format!("<rect x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\" fill=\"white\"/><text x=\"{}\" y=\"{}\" text-anchor=\"middle\" dominant-baseline=\"central\" font-family=\"sans-serif\" font-size=\"13\" fill=\"#637487\">{}</text></g>",label.bounds.position.x,label.bounds.position.y,label.bounds.size.width,label.bounds.size.height,label.center.x,label.center.y,escape(&label.text)));
|
||||
}
|
||||
}
|
||||
}
|
||||
for n in &view.nodes {
|
||||
if !containers.contains(&n.id) {
|
||||
@@ -2353,6 +2441,98 @@ mod tests {
|
||||
assert!(geometry.bounds.position.y <= route.label.bounds.position.y);
|
||||
}
|
||||
#[test]
|
||||
fn association_end_contracts_share_svg_geometry_and_select_without_moving_name_label() {
|
||||
for self_loop in [false, true] {
|
||||
let mut p = Project::empty();
|
||||
let a = p.add(Kind::DataType, "Tree", 100., 100.);
|
||||
let b = if self_loop {
|
||||
a
|
||||
} else {
|
||||
p.add(Kind::DataType, "Branch", 650., 100.)
|
||||
};
|
||||
for id in BTreeSet::from([a, b]) {
|
||||
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::DataType {
|
||||
definition: DataTypeDefinition::Record { supertype: None },
|
||||
default: None,
|
||||
unit: String::new(),
|
||||
};
|
||||
}
|
||||
let edge = p.relate(RelationKind::DataAssociation, "Hierarchy", a, b);
|
||||
p.relations.get_mut(&edge).unwrap().body = RelationBody::DataAssociation {
|
||||
source: a,
|
||||
target: b,
|
||||
source_role: "parent".into(),
|
||||
target_role: "children".into(),
|
||||
source_minimum: 0,
|
||||
source_maximum: Some(1),
|
||||
target_minimum: 0,
|
||||
target_maximum: None,
|
||||
source_navigable: false,
|
||||
target_navigable: true,
|
||||
};
|
||||
let diagram = p.ensure_diagram(Layer::Oa);
|
||||
let projected = project_view(&p, diagram);
|
||||
assert!(
|
||||
!projected
|
||||
.edges
|
||||
.iter()
|
||||
.find(|e| e.id == edge)
|
||||
.unwrap()
|
||||
.oriented
|
||||
);
|
||||
let routed = route_diagram(&p, &p.diagrams[&diagram], &projected);
|
||||
let ends = routing::association_end_geometry(&p.relations[&edge], &routed.edges[&edge]);
|
||||
assert_eq!(ends.len(), 2);
|
||||
let svg = export_svg(&p, diagram);
|
||||
assert!(svg.contains("parent [0..1]</text>"));
|
||||
assert!(svg.contains("children [0..*]</text>"));
|
||||
assert!(!svg.contains("data-navigation=\"source\""));
|
||||
assert!(svg.contains("data-navigation=\"target\""));
|
||||
let mut canvas = DiagramCanvas {
|
||||
project: &p,
|
||||
diagram,
|
||||
selected: edge,
|
||||
zoom: 0.7,
|
||||
pan: Vector::new(350., 350.),
|
||||
context: Context {
|
||||
document: p.id,
|
||||
diagram,
|
||||
revision: 0,
|
||||
epoch: 0,
|
||||
},
|
||||
selected_bend: None,
|
||||
editable: true,
|
||||
connection: None,
|
||||
};
|
||||
for end in ends {
|
||||
assert!(svg.contains(&format!(
|
||||
"x=\"{}\" y=\"{}\" text-anchor",
|
||||
end.label.center.x, end.label.center.y
|
||||
)));
|
||||
let at = Point::new(
|
||||
end.label.center.x * canvas.zoom + canvas.pan.x,
|
||||
end.label.center.y * canvas.zoom + canvas.pan.y,
|
||||
);
|
||||
let mut state = State::default();
|
||||
assert_eq!(canvas.hit_association_label(&routed, at), Some(edge));
|
||||
assert!(
|
||||
matches!(pointer(&canvas,&mut state,mouse::Event::ButtonPressed(mouse::Button::Left),at),Some(Message::Select(id)) if id==edge)
|
||||
);
|
||||
assert!(
|
||||
state.route_drag.is_none(),
|
||||
"Endpoint contract labels never drag the association name"
|
||||
);
|
||||
assert_eq!(
|
||||
canvas
|
||||
.context_request(&state, at, Rectangle::with_size(Size::new(1800., 1200.)))
|
||||
.object,
|
||||
edge
|
||||
);
|
||||
canvas.selected = p.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn detached_label_selects_connection_and_occluding_card_owns_its_hit() {
|
||||
let (mut p, diagram, edge) = route_fixture();
|
||||
p.diagrams.get_mut(&diagram).unwrap().routes[0].label = Some(EdgeLabel {
|
||||
|
||||
+127
-13
@@ -14,6 +14,7 @@ use iced::{
|
||||
use oa_core::*;
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
mod data_fields;
|
||||
mod port_fields;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -218,7 +219,7 @@ impl Draft {
|
||||
} else if path.ends_with("bindings") {
|
||||
json!({"referenced": Id::nil(), "local": Id::nil()})
|
||||
} else if path.ends_with("values") {
|
||||
json!({"field": Id::nil(), "expression": ""})
|
||||
json!({"path": [], "field": Id::nil(), "expression": ""})
|
||||
} else if path.ends_with("routes") {
|
||||
json!({"edge": Id::nil(), "waypoints": [], "label": null})
|
||||
} else if path.ends_with("waypoints") {
|
||||
@@ -242,6 +243,33 @@ impl Draft {
|
||||
self.rebuild();
|
||||
Ok(())
|
||||
}
|
||||
pub fn append(&mut self, path: &str, value: Value) -> Result<(), String> {
|
||||
let mut candidate = self.materialize()?;
|
||||
candidate
|
||||
.pointer_mut(path)
|
||||
.and_then(Value::as_array_mut)
|
||||
.ok_or("List no longer exists.")?
|
||||
.push(value);
|
||||
self.value = candidate;
|
||||
self.rebuild();
|
||||
Ok(())
|
||||
}
|
||||
pub fn value_path(
|
||||
&mut self,
|
||||
path: &str,
|
||||
key: crate::data_values::FieldPathKey,
|
||||
) -> Result<(), String> {
|
||||
let mut candidate = self.materialize()?;
|
||||
let row = candidate
|
||||
.pointer_mut(path)
|
||||
.and_then(Value::as_object_mut)
|
||||
.ok_or("Message value no longer exists.")?;
|
||||
row.insert("path".into(), json!(key.path));
|
||||
row.insert("field".into(), json!(key.field));
|
||||
self.value = candidate;
|
||||
self.rebuild();
|
||||
Ok(())
|
||||
}
|
||||
pub fn command(&self) -> Result<Command, String> {
|
||||
let mut value = self.materialize()?;
|
||||
let decode = |e: serde_json::Error| e.to_string();
|
||||
@@ -411,6 +439,7 @@ fn reference(path: &str) -> bool {
|
||||
| "Named"
|
||||
| "diagram"
|
||||
| "machine"
|
||||
| "supertype"
|
||||
) || matches!(
|
||||
parent,
|
||||
"items"
|
||||
@@ -440,6 +469,7 @@ fn optional_reference(path: &str) -> bool {
|
||||
| "reply_to"
|
||||
| "lifeline"
|
||||
| "state"
|
||||
| "supertype"
|
||||
)
|
||||
}
|
||||
fn multiline(path: &str) -> bool {
|
||||
@@ -462,9 +492,11 @@ fn free_string(path: &str) -> bool {
|
||||
multiline(path)
|
||||
|| matches!(
|
||||
path.rsplit('/').next(),
|
||||
Some("name" | "uri" | "revision" | "fragment" | "unit")
|
||||
Some("name" | "uri" | "revision" | "fragment" | "unit" | "source_role" | "target_role")
|
||||
)
|
||||
|| path.split('/').rev().nth(1) == Some("literals")
|
||||
|| (path.contains("/default/")
|
||||
&& matches!(path.rsplit('/').next(), Some("String" | "Enumeration")))
|
||||
}
|
||||
|
||||
// These are choices for the closed model, not a second validation boundary.
|
||||
@@ -662,21 +694,37 @@ fn reference_ids(draft: &Draft, project: &Project, path: &str) -> Vec<Id> {
|
||||
.collect();
|
||||
}
|
||||
if field == "field" {
|
||||
let address_path = path
|
||||
.strip_suffix("/field")
|
||||
.and_then(|row| value.pointer(&format!("{row}/path")))
|
||||
.and_then(|v| serde_json::from_value::<Vec<Id>>(v.clone()).ok())
|
||||
.unwrap_or_default();
|
||||
return element
|
||||
.as_ref()
|
||||
.and_then(|e| match e.shared()? {
|
||||
SharedBody::Occurrence {
|
||||
body:
|
||||
OccurrenceBody::Exchange {
|
||||
exchange: Some(id), ..
|
||||
exchange: Some(id),
|
||||
message_kind,
|
||||
..
|
||||
},
|
||||
..
|
||||
} => project.relations.get(id),
|
||||
} => project
|
||||
.relations
|
||||
.get(id)
|
||||
.and_then(|r| crate::data_values::choices(project, r, message_kind).ok()),
|
||||
_ => None,
|
||||
})
|
||||
.map(|r| validation::carried_fields(project, r).into_iter().collect())
|
||||
.unwrap_or_default();
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter(|choice| choice.key.path == address_path)
|
||||
.map(|choice| choice.key.field)
|
||||
.collect::<BTreeSet<_>>()
|
||||
.into_iter()
|
||||
.collect();
|
||||
}
|
||||
|
||||
let scenario = element
|
||||
.as_ref()
|
||||
.and_then(|e| e.parent)
|
||||
@@ -707,6 +755,40 @@ fn reference_ids(draft: &Draft, project: &Project, path: &str) -> Vec<Id> {
|
||||
return false;
|
||||
}
|
||||
match field {
|
||||
"supertype" => element.as_ref().is_some_and(|owner| {
|
||||
if candidate.id == owner.id
|
||||
|| candidate.kind() != owner.kind()
|
||||
|| (candidate.kind() == Kind::DataType
|
||||
&& !data::is_record(project, candidate.id))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if let (
|
||||
Some(SharedBody::ExchangeItem {
|
||||
kind, mechanism, ..
|
||||
}),
|
||||
Some(SharedBody::ExchangeItem {
|
||||
kind: base_kind,
|
||||
mechanism: base_mechanism,
|
||||
..
|
||||
}),
|
||||
) = (owner.shared(), candidate.shared())
|
||||
&& (kind != base_kind
|
||||
|| (*base_mechanism != ExchangeMechanism::Unspecified
|
||||
&& mechanism != base_mechanism))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let mut next = Some(candidate.id);
|
||||
let mut seen = BTreeSet::new();
|
||||
while let Some(id) = next {
|
||||
if id == owner.id || !seen.insert(id) {
|
||||
return false;
|
||||
}
|
||||
next = project.elements.get(&id).and_then(data::supertype);
|
||||
}
|
||||
true
|
||||
}),
|
||||
"machine" => candidate.kind() == Kind::StateMachine,
|
||||
"parent" => element.as_ref().is_some_and(|child| {
|
||||
if candidate.id == draft.original
|
||||
@@ -1058,6 +1140,20 @@ fn variants(path: &str, value: &Value, kind: RecordKind) -> Vec<(String, Value)>
|
||||
let words: &[&str] = match field {
|
||||
"side" if path.starts_with("/port_anchors/") => &["Left", "Right", "Top", "Bottom"],
|
||||
"layer" => &["Oa", "Sa"],
|
||||
"mechanism" => &["Unspecified", "Flow", "Event", "Operation", "SharedData"],
|
||||
"storage" => &["Value", "Reference"],
|
||||
"direction" if path.contains("DataField") => {
|
||||
&["Unspecified", "In", "Out", "InOut", "Return"]
|
||||
}
|
||||
"transmission" => &[
|
||||
"Unspecified",
|
||||
"Synchronous",
|
||||
"Asynchronous",
|
||||
"Unicast",
|
||||
"Multicast",
|
||||
"Broadcast",
|
||||
],
|
||||
"acquisition" => &["Unspecified", "Read", "Accept"],
|
||||
"operator" => &["Alternative", "Parallel", "Loop", "Optional"],
|
||||
"viewpoint" if path.contains("/Oa/") => &[
|
||||
"OperationalActivity",
|
||||
@@ -1066,7 +1162,7 @@ fn variants(path: &str, value: &Value, kind: RecordKind) -> Vec<(String, Value)>
|
||||
],
|
||||
"viewpoint" => &["Functional", "BoundaryInterface", "MixedLegacy"],
|
||||
"phase" => &["Entry", "Do", "Exit"],
|
||||
"kind" if path.contains("ExchangeItem") => &["Information", "Material", "Energy", "Event"],
|
||||
"kind" if path.contains("ExchangeItem") => &["Information", "Material", "Energy"],
|
||||
"kind" if path.contains("Control") => &["Choice", "Merge", "Fork", "Join", "Loop"],
|
||||
"kind" if path.contains("State") => &[
|
||||
"State",
|
||||
@@ -1132,11 +1228,20 @@ fn variants(path: &str, value: &Value, kind: RecordKind) -> Vec<(String, Value)>
|
||||
if field == "definition" {
|
||||
return vec![
|
||||
("Boolean".into(), json!("Boolean")),
|
||||
("Integer".into(), json!("Integer")),
|
||||
("Real".into(), json!("Real")),
|
||||
("Text".into(), json!("String")),
|
||||
(
|
||||
"Integer".into(),
|
||||
json!({"Integer":{"minimum":null,"maximum":null}}),
|
||||
),
|
||||
(
|
||||
"Real".into(),
|
||||
json!({"Real":{"minimum":null,"maximum":null}}),
|
||||
),
|
||||
(
|
||||
"Text".into(),
|
||||
json!({"String":{"minimum_length":0,"maximum_length":null}}),
|
||||
),
|
||||
("Enumeration".into(), json!({"Enumeration":{"literals":[]}})),
|
||||
("Record".into(), json!("Record")),
|
||||
("Record".into(), json!({"Record":{"supertype":null}})),
|
||||
];
|
||||
}
|
||||
if field == "type_ref" {
|
||||
@@ -1273,6 +1378,9 @@ fn property<'a>(
|
||||
label: &str,
|
||||
skip_variant: bool,
|
||||
) -> View<'a, Message> {
|
||||
if let Some(view) = data_fields::property(draft, project, value, path) {
|
||||
return view;
|
||||
}
|
||||
let label_view = || text(label.to_owned()).size(11).color(style::MUTED);
|
||||
if path.ends_with("/Port/legacy") {
|
||||
let mut content = column![].spacing(4);
|
||||
@@ -2075,6 +2183,8 @@ mod tests {
|
||||
Layer::Oa,
|
||||
SharedBody::ExchangeItem {
|
||||
kind: ExchangeItemKind::Energy,
|
||||
mechanism: ExchangeMechanism::Flow,
|
||||
supertype: None,
|
||||
},
|
||||
);
|
||||
let mut draft = Draft::from_project(&p, item).unwrap();
|
||||
@@ -2091,8 +2201,12 @@ mod tests {
|
||||
let interface = p
|
||||
.add_element(Kind::Interface, Layer::Oa, "Power contract", None)
|
||||
.unwrap();
|
||||
p.elements.get_mut(&interface).unwrap().body =
|
||||
ElementBody::shared_in(Layer::Oa, SharedBody::Interface { items: vec![item] });
|
||||
p.relate(
|
||||
RelationKind::InterfaceItemAllocation,
|
||||
"Allocated item",
|
||||
interface,
|
||||
item,
|
||||
);
|
||||
assert!(execute_batch(&p, &[draft.command().unwrap()]).is_err());
|
||||
assert!(draft.dirty());
|
||||
assert_eq!(p.elements[&item].layer(), Layer::Oa);
|
||||
|
||||
@@ -0,0 +1,683 @@
|
||||
//! 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<Element> {
|
||||
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<DataTypeDefinition> {
|
||||
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<Value> {
|
||||
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<View<'a, Message>> {
|
||||
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::<MessageValue>(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 choices = context
|
||||
.map(|(r, phase)| crate::data_values::choices(project, r, phase))
|
||||
.unwrap_or_else(|| Ok(vec![]));
|
||||
let mut out = column![text("Qualified carried field").size(11).color(style::MUTED)].spacing(6);
|
||||
match choices {
|
||||
Err(error) => out = out.push(text(error).size(11).color(style::MUTED)),
|
||||
Ok(mut choices) => {
|
||||
let current_key = current.as_ref().map(|v| crate::data_values::FieldPathKey {
|
||||
path: v.path.clone(),
|
||||
field: v.field,
|
||||
});
|
||||
let mut selected = current_key
|
||||
.as_ref()
|
||||
.and_then(|key| choices.iter().find(|c| &c.key == key))
|
||||
.cloned()
|
||||
.or_else(|| {
|
||||
context.and_then(|(r, phase)| {
|
||||
current_key
|
||||
.as_ref()
|
||||
.and_then(|key| crate::data_values::resolve(project, r, phase, key))
|
||||
})
|
||||
});
|
||||
if let Some(choice) = &selected
|
||||
&& !choices.iter().any(|c| c.key == choice.key)
|
||||
{
|
||||
choices.push(choice.clone());
|
||||
}
|
||||
if selected.is_none()
|
||||
&& let Some(key) = current_key.clone()
|
||||
&& !key.field.is_nil()
|
||||
{
|
||||
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 {
|
||||
label: format!(
|
||||
"Current address · {} / {}",
|
||||
key.path
|
||||
.iter()
|
||||
.map(|id| project.named(*id))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" → "),
|
||||
project.named(key.field)
|
||||
),
|
||||
key,
|
||||
};
|
||||
selected = Some(retained.clone());
|
||||
choices.push(retained);
|
||||
}
|
||||
let owned = path.to_owned();
|
||||
out = out.push(
|
||||
pick_list(choices, selected, move |choice| {
|
||||
Message::FieldValuePath(owned.clone(), choice.key)
|
||||
})
|
||||
.placeholder("Choose a carried field path…")
|
||||
.text_size(12)
|
||||
.padding(8)
|
||||
.width(Fill),
|
||||
);
|
||||
}
|
||||
}
|
||||
if let (Some((exchange, phase)), Some(current)) = (context, current) {
|
||||
let key = crate::data_values::FieldPathKey {
|
||||
path: current.path,
|
||||
field: current.field,
|
||||
};
|
||||
if crate::data_values::resolve(project, exchange, phase, &key).is_some() {
|
||||
if let Some(parent) = crate::data_values::parent(&key) {
|
||||
out = out.push(
|
||||
button(text("Up to enclosing field").size(11))
|
||||
.style(style::nav)
|
||||
.on_press(Message::FieldValuePath(path.into(), parent)),
|
||||
);
|
||||
}
|
||||
match crate::data_values::children(project, exchange, phase, &key) {
|
||||
Ok(children) if !children.is_empty() => {
|
||||
let path = path.to_owned();
|
||||
out = out.push(
|
||||
pick_list(
|
||||
children,
|
||||
None::<crate::data_values::FieldPathChoice>,
|
||||
move |choice| Message::FieldValuePath(path.clone(), choice.key),
|
||||
)
|
||||
.placeholder("Choose a field inside this record…")
|
||||
.text_size(12)
|
||||
.padding(8)
|
||||
.width(Fill),
|
||||
);
|
||||
}
|
||||
Err(error) => out = out.push(text(error).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()
|
||||
}
|
||||
|
||||
#[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 _ = 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 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));
|
||||
}
|
||||
}
|
||||
@@ -144,13 +144,7 @@ fn component_items_fit(project: &Project, port: Id, items: &[Id]) -> bool {
|
||||
}
|
||||
let declared: Vec<_> = interfaces
|
||||
.iter()
|
||||
.filter_map(|id| project.elements.get(id))
|
||||
.filter_map(|e| match e.shared() {
|
||||
Some(SharedBody::Interface { items }) => Some(items),
|
||||
_ => None,
|
||||
})
|
||||
.flatten()
|
||||
.copied()
|
||||
.flat_map(|id| data::interface_items(project, *id))
|
||||
.collect();
|
||||
items_satisfy(project, items, &declared)
|
||||
}
|
||||
@@ -714,8 +708,12 @@ mod tests {
|
||||
interface,
|
||||
);
|
||||
assert!(reference_ids(&draft, &project, target).is_empty());
|
||||
project.elements.get_mut(&interface).unwrap().body =
|
||||
ElementBody::shared_in(Layer::Sa, SharedBody::Interface { items: vec![item] });
|
||||
project.relate(
|
||||
RelationKind::InterfaceItemAllocation,
|
||||
"Allocated item",
|
||||
interface,
|
||||
item,
|
||||
);
|
||||
assert_eq!(reference_ids(&draft, &project, target), vec![output]);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ mod behavior;
|
||||
mod connection_edit;
|
||||
mod connection_tool;
|
||||
mod context_menu;
|
||||
mod data_values;
|
||||
mod data_view;
|
||||
mod diagram;
|
||||
mod document;
|
||||
mod explorer;
|
||||
|
||||
@@ -157,12 +157,9 @@ pub fn port_context_rows(project: &Project, port: Id) -> Vec<String> {
|
||||
}
|
||||
if let Some(legacy) = legacy {
|
||||
rows.push(legacy_line(&context, legacy));
|
||||
if let Some(interface) = legacy.interface
|
||||
&& let Some(SharedBody::Interface { items }) =
|
||||
context.element(interface).and_then(Element::shared)
|
||||
{
|
||||
for item in items {
|
||||
origins.entry(*item).or_default().push(format!(
|
||||
if let Some(interface) = legacy.interface {
|
||||
for item in data::interface_items(project, interface) {
|
||||
origins.entry(item).or_default().push(format!(
|
||||
"preserved interface {} (suggestion only; not an active typed contract)",
|
||||
context.label(interface)
|
||||
));
|
||||
@@ -711,11 +708,17 @@ mod tests {
|
||||
let (other_fe, commands) = exchange(&mut project, source, target, vec![second]);
|
||||
project = execute_batch(&project, &commands).unwrap();
|
||||
let interface = add(&mut project, Kind::Interface, "Incident service");
|
||||
project.elements.get_mut(&interface).unwrap().body = ElementBody::shared_in(
|
||||
Layer::Sa,
|
||||
SharedBody::Interface {
|
||||
items: vec![first, second],
|
||||
},
|
||||
project.relate(
|
||||
RelationKind::InterfaceItemAllocation,
|
||||
"First item",
|
||||
interface,
|
||||
first,
|
||||
);
|
||||
project.relate(
|
||||
RelationKind::InterfaceItemAllocation,
|
||||
"Second item",
|
||||
interface,
|
||||
second,
|
||||
);
|
||||
let connection = &project.relations[&boundary];
|
||||
let (a, b) = (connection.source(), connection.target());
|
||||
@@ -790,8 +793,12 @@ mod tests {
|
||||
..
|
||||
} = fixture();
|
||||
let interface = add(&mut project, Kind::Interface, "Preserved contract");
|
||||
project.elements.get_mut(&interface).unwrap().body =
|
||||
ElementBody::shared_in(Layer::Sa, SharedBody::Interface { items: vec![first] });
|
||||
project.relate(
|
||||
RelationKind::InterfaceItemAllocation,
|
||||
"Allocated item",
|
||||
interface,
|
||||
first,
|
||||
);
|
||||
let legacy = port(
|
||||
&mut project,
|
||||
source,
|
||||
|
||||
@@ -168,12 +168,9 @@ impl State {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let Some(SharedBody::Interface { items }) = old
|
||||
.and_then(|old| old.interface)
|
||||
.and_then(|id| p.elements.get(&id))
|
||||
.and_then(|e| e.shared())
|
||||
{
|
||||
incoming.extend(items);
|
||||
if let Some(interface) = old.and_then(|old| old.interface) {
|
||||
let items = data::interface_items(p, interface);
|
||||
incoming.extend(items.iter().copied());
|
||||
outgoing.extend(items);
|
||||
}
|
||||
for r in p.relations.values() {
|
||||
|
||||
@@ -250,7 +250,7 @@ fn opening_legacy_source_surfaces_report_without_modifying_original_bytes() {
|
||||
assert_eq!(app.page, Page::Validation);
|
||||
assert_eq!(app.project(), &original);
|
||||
assert!(!app.dirty());
|
||||
assert!(app.source_snapshot.starts_with("arcadia 3;"));
|
||||
assert!(app.source_snapshot.starts_with("arcadia 4;"));
|
||||
assert!(serde_json::from_slice::<serde_json::Value>(&bytes).unwrap()["schema_version"] == 2);
|
||||
}
|
||||
|
||||
|
||||
@@ -703,69 +703,7 @@ fn inspector(app: &App) -> View<'_, Message> {
|
||||
}
|
||||
|
||||
fn data_catalog(app: &App) -> View<'_, Message> {
|
||||
let p = app.project();
|
||||
let mut content = column![heading(
|
||||
"Data & interfaces",
|
||||
"Define information, material and energy at the system boundary."
|
||||
)]
|
||||
.spacing(15);
|
||||
for kind in Kind::ALL {
|
||||
let is_data = matches!(
|
||||
kind,
|
||||
Kind::ExchangeItem | Kind::DataType | Kind::DataField | Kind::Interface | Kind::Port
|
||||
);
|
||||
if !is_data {
|
||||
continue;
|
||||
}
|
||||
let elements = p
|
||||
.elements
|
||||
.values()
|
||||
.filter(|e| e.layer() == app.layer && e.kind() == kind)
|
||||
.collect::<Vec<_>>();
|
||||
if elements.is_empty() {
|
||||
continue;
|
||||
}
|
||||
content = content.push(
|
||||
text(format!("{} · {}", kind.label(), elements.len()))
|
||||
.size(15)
|
||||
.color(style::kind_color(kind)),
|
||||
);
|
||||
for e in elements {
|
||||
content = content.push(crate::context_menu::Area::new(
|
||||
button(
|
||||
row![
|
||||
column![
|
||||
text(&e.name).size(14),
|
||||
label(format!(
|
||||
"@{}{}",
|
||||
e.alias,
|
||||
e.parent
|
||||
.map(|id| format!(" · in {}", p.named(id)))
|
||||
.unwrap_or_default()
|
||||
)),
|
||||
text(short(&e.description, 125))
|
||||
.size(12)
|
||||
.color(style::MUTED)
|
||||
]
|
||||
.spacing(5)
|
||||
.width(Fill),
|
||||
text("→").size(17)
|
||||
]
|
||||
.align_y(iced::Center),
|
||||
)
|
||||
.padding(14)
|
||||
.width(Fill)
|
||||
.style(if e.id == app.selected {
|
||||
style::selected
|
||||
} else {
|
||||
style::nav
|
||||
})
|
||||
.on_press(Message::Select(e.id)),
|
||||
move |anchor| crate::model_menu::open(e.id, anchor),
|
||||
));
|
||||
}
|
||||
}
|
||||
scrollable(content.padding(24)).into()
|
||||
crate::data_view::view(app)
|
||||
}
|
||||
|
||||
fn traces(app: &App) -> View<'_, Message> {
|
||||
@@ -1099,6 +1037,51 @@ fn checks(app: &App) -> View<'_, Message> {
|
||||
app.project().named(endpoint.exchange)
|
||||
)));
|
||||
}
|
||||
card=card.push(label(format!("Data migration: {} semantic defaults · {} interface allocations · {} unresolved value addresses",report.semantic_defaults.len(),report.generated_interface_allocations.len(),report.unresolved_message_values.len())));
|
||||
for default in report.semantic_defaults.iter().take(6) {
|
||||
card = card
|
||||
.push(action(
|
||||
&format!(
|
||||
"{} · {}",
|
||||
app.project().named(default.object),
|
||||
default.field
|
||||
),
|
||||
Message::Select(default.object),
|
||||
))
|
||||
.push(label(&default.description));
|
||||
}
|
||||
for allocation in report.generated_interface_allocations.iter().take(6) {
|
||||
card = card
|
||||
.push(action(
|
||||
&format!(
|
||||
"Created item allocation · @{} · order {}",
|
||||
allocation.alias, allocation.order
|
||||
),
|
||||
Message::Select(allocation.id),
|
||||
))
|
||||
.push(label(format!(
|
||||
"{} → {}",
|
||||
app.project().named(allocation.source),
|
||||
app.project().named(allocation.target)
|
||||
)));
|
||||
}
|
||||
for value in report.unresolved_message_values.iter().take(6) {
|
||||
card = card
|
||||
.push(action(
|
||||
&format!(
|
||||
"Review qualified value · {}",
|
||||
app.project().named(value.occurrence)
|
||||
),
|
||||
Message::EditProperties(value.occurrence),
|
||||
))
|
||||
.push(label(&value.reason));
|
||||
}
|
||||
if report.semantic_defaults.len() > 6
|
||||
|| report.generated_interface_allocations.len() > 6
|
||||
|| report.unresolved_message_values.len() > 6
|
||||
{
|
||||
card=card.push(label("Showing the first six entries in each data migration category. Model checks below link remaining unresolved contracts."));
|
||||
}
|
||||
for boundary in &report.orientation_defaults {
|
||||
card = card.push(action(
|
||||
&format!(
|
||||
|
||||
Reference in New Issue
Block a user