Establish OA/SA prototype baseline and first-release work
This commit is contained in:
@@ -0,0 +1,900 @@
|
||||
//! Explicit, reviewable relationship and cross-perspective operations.
|
||||
use crate::{
|
||||
app::{Choice, Message},
|
||||
style,
|
||||
views::short,
|
||||
};
|
||||
use iced::{
|
||||
Element, Fill,
|
||||
widget::{Space, button, column, pick_list, row, scrollable, text, text_editor, text_input},
|
||||
};
|
||||
use oa_core::{transitions::*, *};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct RelationAction {
|
||||
pub label: &'static str,
|
||||
pub kind: RelationKind,
|
||||
pub incoming: bool,
|
||||
}
|
||||
impl std::fmt::Display for RelationAction {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(self.label)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Event {
|
||||
Cancel,
|
||||
Apply,
|
||||
RelationName(String),
|
||||
RelationKind(RelationKind),
|
||||
Condition(text_editor::Action),
|
||||
Source(Choice),
|
||||
Target(Choice),
|
||||
Scope(String),
|
||||
MappingTarget(Id, Choice),
|
||||
MappingKind(Id, Kind),
|
||||
Resolve(Id, TransitionField, FieldDecision),
|
||||
Preview,
|
||||
}
|
||||
pub enum Workflow {
|
||||
Relation(Relationship),
|
||||
ManualTrace {
|
||||
name: String,
|
||||
source: Choice,
|
||||
target: Option<Choice>,
|
||||
},
|
||||
Transition(Box<Transition>),
|
||||
}
|
||||
pub struct Relationship {
|
||||
pub name: String,
|
||||
pub kind: RelationKind,
|
||||
pub source: Option<Choice>,
|
||||
pub target: Option<Choice>,
|
||||
pub condition: text_editor::Content,
|
||||
layer: Layer,
|
||||
sources: Vec<Choice>,
|
||||
targets: Vec<Choice>,
|
||||
}
|
||||
|
||||
impl Relationship {
|
||||
fn accepts_source(&self, p: &Project, id: Id) -> bool {
|
||||
if p.layer_of(id) != Some(self.layer) {
|
||||
return false;
|
||||
}
|
||||
if self.kind == RelationKind::ExchangeAllocation {
|
||||
return p
|
||||
.relations
|
||||
.get(&id)
|
||||
.is_some_and(|r| r.kind() == RelationKind::BoundaryExchange);
|
||||
}
|
||||
p.elements.get(&id).is_some_and(|e| {
|
||||
Kind::ALL
|
||||
.into_iter()
|
||||
.any(|target| target.supports(self.layer) && self.kind.permits(e.kind(), target))
|
||||
})
|
||||
}
|
||||
|
||||
fn refresh_choices(&mut self, p: &Project) {
|
||||
self.sources = p
|
||||
.elements
|
||||
.keys()
|
||||
.chain(p.relations.keys())
|
||||
.copied()
|
||||
.filter(|id| self.accepts_source(p, *id))
|
||||
.map(|id| choice(p, id))
|
||||
.collect();
|
||||
self.sources.sort_by(|a, b| a.label.cmp(&b.label));
|
||||
self.targets = p
|
||||
.elements
|
||||
.keys()
|
||||
.chain(p.relations.keys())
|
||||
.copied()
|
||||
.filter(|id| {
|
||||
self.source
|
||||
.as_ref()
|
||||
.is_some_and(|source| validation::can_connect(p, self.kind, source.id, *id))
|
||||
})
|
||||
.map(|id| choice(p, id))
|
||||
.collect();
|
||||
self.targets.sort_by(|a, b| a.label.cmp(&b.label));
|
||||
}
|
||||
|
||||
fn engineering_name(&self, p: &Project, source: Id, target: Id) -> String {
|
||||
if !self.name.trim().is_empty() {
|
||||
return self.name.clone();
|
||||
}
|
||||
let verb = match self.kind {
|
||||
RelationKind::CapabilityInclude => "includes",
|
||||
RelationKind::CapabilityExtend => "extends",
|
||||
RelationKind::CapabilityGeneralization => "specializes",
|
||||
RelationKind::MissionCapability => "exploits",
|
||||
RelationKind::MissionInvolvement | RelationKind::CapabilityInvolvement => {
|
||||
"participates in"
|
||||
}
|
||||
RelationKind::Allocation => "performs",
|
||||
_ => return format!("{}: {} → {}", self.kind, p.named(source), p.named(target)),
|
||||
};
|
||||
format!("{} {verb} {}", p.named(source), p.named(target))
|
||||
}
|
||||
}
|
||||
pub struct Transition {
|
||||
pub request: TransitionRequest,
|
||||
pub plan: TransitionPlan,
|
||||
}
|
||||
impl Workflow {
|
||||
pub fn manual_trace(p: &Project, selected: Id) -> Result<Self, String> {
|
||||
if p.layer_of(selected).is_none()
|
||||
|| !(p.elements.contains_key(&selected) || p.relations.contains_key(&selected))
|
||||
{
|
||||
return Err("Select an OA or SA element or relationship to trace.".into());
|
||||
}
|
||||
Ok(Self::ManualTrace {
|
||||
name: "Engineering correspondence".into(),
|
||||
source: choice(p, selected),
|
||||
target: None,
|
||||
})
|
||||
}
|
||||
pub fn relationship(p: &Project, selected: Id, layer: Layer) -> Self {
|
||||
let kind = match p.elements.get(&selected).map(oa_core::Element::kind) {
|
||||
Some(Kind::OperationalMission | Kind::Mission) => RelationKind::MissionCapability,
|
||||
Some(Kind::Capability | Kind::SystemCapability) => RelationKind::CapabilityInclude,
|
||||
Some(Kind::Role) => RelationKind::RoleAssignment,
|
||||
Some(Kind::Process | Kind::FunctionalChain) => RelationKind::ChainInvolvement,
|
||||
Some(kind) if kind.is_performer() => RelationKind::Allocation,
|
||||
Some(kind) if kind.is_state() => RelationKind::StateTransition,
|
||||
_ if layer == Layer::Oa => RelationKind::Interaction,
|
||||
_ => RelationKind::FunctionalExchange,
|
||||
};
|
||||
Self::relationship_kind(p, selected, layer, kind, false)
|
||||
}
|
||||
|
||||
pub fn relationship_kind(
|
||||
p: &Project,
|
||||
selected: Id,
|
||||
layer: Layer,
|
||||
kind: RelationKind,
|
||||
incoming: bool,
|
||||
) -> Self {
|
||||
let mut draft = Relationship {
|
||||
name: String::new(),
|
||||
kind,
|
||||
source: (!incoming).then(|| choice(p, selected)),
|
||||
target: incoming.then(|| choice(p, selected)),
|
||||
condition: text_editor::Content::new(),
|
||||
layer,
|
||||
sources: vec![],
|
||||
targets: vec![],
|
||||
};
|
||||
if draft
|
||||
.source
|
||||
.as_ref()
|
||||
.is_some_and(|s| !draft.accepts_source(p, s.id))
|
||||
{
|
||||
draft.source = None;
|
||||
}
|
||||
draft.refresh_choices(p);
|
||||
Self::Relation(draft)
|
||||
}
|
||||
pub fn transition(p: &Project, selected: Id) -> Result<Self, String> {
|
||||
let layer = p
|
||||
.layer_of(selected)
|
||||
.ok_or("Select an element or relationship in OA or SA.")?;
|
||||
let direction = if layer == Layer::Oa {
|
||||
TransitionDirection::OaToSa
|
||||
} else {
|
||||
TransitionDirection::SaToOa
|
||||
};
|
||||
let mut request = TransitionRequest::new(direction, vec![selected]);
|
||||
request.scope = DependencyScope::Required;
|
||||
let plan = plan_transition(p, &request).map_err(|e| e.to_string())?;
|
||||
Ok(Self::Transition(Box::new(Transition { request, plan })))
|
||||
}
|
||||
/// Return a complete accepted candidate only on explicit Apply. Intermediate
|
||||
/// choices and previews never mutate the Session.
|
||||
pub fn update(&mut self, p: &Project, event: Event) -> Result<Option<Project>, String> {
|
||||
match self {
|
||||
Self::ManualTrace {
|
||||
name,
|
||||
source,
|
||||
target,
|
||||
} => match event {
|
||||
Event::RelationName(value) => *name = value,
|
||||
Event::Target(value) => *target = Some(value),
|
||||
Event::Apply => {
|
||||
let target = target
|
||||
.as_ref()
|
||||
.ok_or("Choose a counterpart in the other perspective.")?
|
||||
.id;
|
||||
if name.trim().is_empty() {
|
||||
return Err("Give the trace an engineering name.".into());
|
||||
}
|
||||
let (oa, sa) = if p.layer_of(source.id) == Some(Layer::Oa) {
|
||||
(source.id, target)
|
||||
} else {
|
||||
(target, source.id)
|
||||
};
|
||||
let id = Id::new_v4();
|
||||
let trace = Trace {
|
||||
id,
|
||||
alias: p.fresh_alias("correspondence", "trace", id),
|
||||
name: name.clone(),
|
||||
description: String::new(),
|
||||
oa,
|
||||
sa,
|
||||
kind: TraceKind::Manual,
|
||||
baseline: None,
|
||||
};
|
||||
return execute_batch(p, &[Command::CreateTrace(trace)])
|
||||
.map(Some)
|
||||
.map_err(|e| e.to_string());
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
Self::Relation(d) => match event {
|
||||
Event::RelationName(name) => d.name = name,
|
||||
Event::RelationKind(kind) => {
|
||||
d.kind = kind;
|
||||
d.target = None;
|
||||
if d.source
|
||||
.as_ref()
|
||||
.is_some_and(|s| !d.accepts_source(p, s.id))
|
||||
{
|
||||
d.source = None;
|
||||
}
|
||||
d.refresh_choices(p);
|
||||
}
|
||||
Event::Source(source) => {
|
||||
d.source = Some(source);
|
||||
if d.target.as_ref().is_some_and(|target| {
|
||||
!validation::can_connect(
|
||||
p,
|
||||
d.kind,
|
||||
d.source.as_ref().unwrap().id,
|
||||
target.id,
|
||||
)
|
||||
}) {
|
||||
d.target = None;
|
||||
}
|
||||
d.refresh_choices(p);
|
||||
}
|
||||
Event::Target(target) => d.target = Some(target),
|
||||
Event::Condition(action) => d.condition.perform(action),
|
||||
Event::Apply => {
|
||||
let a = d.source.as_ref().ok_or("Choose a source.")?.id;
|
||||
let b = d.target.as_ref().ok_or("Choose a target.")?.id;
|
||||
let id = Id::new_v4();
|
||||
let name = d.engineering_name(p, a, b);
|
||||
let mut body = RelationBody::new(d.kind, a, b);
|
||||
if let RelationBody::CapabilityExtend { condition, .. } = &mut body {
|
||||
*condition = d.condition.text();
|
||||
}
|
||||
let relation = Relation {
|
||||
id,
|
||||
alias: p.fresh_alias(&name, "relation", id),
|
||||
name,
|
||||
description: String::new(),
|
||||
body,
|
||||
};
|
||||
return execute_batch(p, &[Command::CreateRelation(relation)])
|
||||
.map(Some)
|
||||
.map_err(|e| e.to_string());
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
Self::Transition(d) => {
|
||||
match event {
|
||||
Event::Scope(scope) => {
|
||||
d.request.scope = match scope.as_str() {
|
||||
"Required references" => DependencyScope::Required,
|
||||
"Whole subtree" => DependencyScope::Subtree,
|
||||
_ => DependencyScope::SelectedOnly,
|
||||
}
|
||||
}
|
||||
Event::MappingTarget(source, target) => {
|
||||
let target = (!target.id.is_nil()).then_some(target.id);
|
||||
let previous = d
|
||||
.request
|
||||
.mappings
|
||||
.iter()
|
||||
.find(|m| m.source == source)
|
||||
.map(|m| m.target)
|
||||
.unwrap_or_else(|| {
|
||||
d.plan
|
||||
.changes
|
||||
.iter()
|
||||
.find(|c| c.source == source)
|
||||
.map(|c| c.target)
|
||||
.or_else(|| {
|
||||
d.plan
|
||||
.conflicts
|
||||
.iter()
|
||||
.find(|c| c.source == Some(source))
|
||||
.and_then(|c| c.target)
|
||||
})
|
||||
// Planned new identities are not an existing counterpart.
|
||||
.filter(|id| p.contains(*id))
|
||||
});
|
||||
if previous != target {
|
||||
d.request.resolutions.retain(|r| r.source != source);
|
||||
}
|
||||
if let Some(mapping) =
|
||||
d.request.mappings.iter_mut().find(|m| m.source == source)
|
||||
{
|
||||
mapping.target = target;
|
||||
} else {
|
||||
d.request.mappings.push(TransitionMapping {
|
||||
source,
|
||||
target,
|
||||
target_kind: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
Event::MappingKind(source, kind) => {
|
||||
let mapping = d.request.mappings.iter().find(|m| m.source == source);
|
||||
let planned_target = d
|
||||
.plan
|
||||
.changes
|
||||
.iter()
|
||||
.find(|c| c.source == source)
|
||||
.map(|c| c.target);
|
||||
let previous = mapping
|
||||
.and_then(|m| m.target_kind)
|
||||
.or_else(|| {
|
||||
planned_target.and_then(|target| {
|
||||
p.elements.get(&target).map(|e| e.kind()).or_else(|| {
|
||||
d.plan.commands.iter().find_map(|command| match command {
|
||||
Command::CreateElement(e) if e.id == target => {
|
||||
Some(e.kind())
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
.or_else(|| {
|
||||
p.elements.get(&source).and_then(|e| {
|
||||
let kinds = counterpart_kinds(e.kind(), d.request.direction);
|
||||
(kinds.len() == 1).then(|| kinds[0])
|
||||
})
|
||||
});
|
||||
// Adding a kind mapping explicitly requests creation. If
|
||||
// it replaces an inferred existing target, that is also
|
||||
// a new review context even when its kind is identical.
|
||||
let replaces_existing = mapping.is_none()
|
||||
&& planned_target.is_some_and(|target| p.contains(target));
|
||||
if previous != Some(kind) || replaces_existing {
|
||||
d.request.resolutions.retain(|r| r.source != source);
|
||||
}
|
||||
if let Some(mapping) =
|
||||
d.request.mappings.iter_mut().find(|m| m.source == source)
|
||||
{
|
||||
mapping.target_kind = Some(kind);
|
||||
} else {
|
||||
d.request.mappings.push(TransitionMapping {
|
||||
source,
|
||||
target: None,
|
||||
target_kind: Some(kind),
|
||||
});
|
||||
}
|
||||
}
|
||||
Event::Resolve(source, field, decision) => {
|
||||
d.request
|
||||
.resolutions
|
||||
.retain(|r| r.source != source || r.field != field);
|
||||
d.request.resolutions.push(FieldResolution {
|
||||
source,
|
||||
field,
|
||||
decision,
|
||||
});
|
||||
}
|
||||
Event::Apply => {
|
||||
let mut candidate =
|
||||
apply_transition(p, &d.plan).map_err(|e| e.to_string())?;
|
||||
let layer = d.request.direction.target_layer();
|
||||
let diagram = d
|
||||
.request
|
||||
.destination_diagram
|
||||
.or_else(|| {
|
||||
candidate
|
||||
.diagrams
|
||||
.values()
|
||||
.find(|d| d.layer == layer)
|
||||
.map(|d| d.id)
|
||||
})
|
||||
.unwrap_or_else(|| candidate.ensure_diagram(layer));
|
||||
for change in &d.plan.changes {
|
||||
if candidate.elements.contains_key(&change.target) {
|
||||
let index = candidate.diagrams[&diagram].nodes.len();
|
||||
candidate.place(
|
||||
diagram,
|
||||
change.target,
|
||||
Position {
|
||||
x: 60. + (index % 3) as f32 * 280.,
|
||||
y: 60. + (index / 3) as f32 * 150.,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
return execute_batch(&candidate, &[])
|
||||
.map(Some)
|
||||
.map_err(|e| e.to_string());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
d.plan = plan_transition(p, &d.request).map_err(|e| e.to_string())?;
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
pub fn title(&self) -> &'static str {
|
||||
match self {
|
||||
Self::ManualTrace { .. } => "Link existing counterparts",
|
||||
Self::Relation(_) => "Create relationship",
|
||||
Self::Transition(t) => {
|
||||
if t.request.direction == TransitionDirection::OaToSa {
|
||||
"Operational → System"
|
||||
} else {
|
||||
"System → Operational"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn view<'a>(&'a self, p: &'a Project) -> Element<'a, Message> {
|
||||
let mut content = column![text(self.title()).size(23)].spacing(14);
|
||||
match self {
|
||||
Self::ManualTrace {
|
||||
name,
|
||||
source,
|
||||
target,
|
||||
} => {
|
||||
let mut choices = p
|
||||
.elements
|
||||
.keys()
|
||||
.chain(p.relations.keys())
|
||||
.filter(|id| {
|
||||
p.layer_of(**id)
|
||||
.is_some_and(|layer| Some(layer) != p.layer_of(source.id))
|
||||
})
|
||||
.map(|id| choice(p, *id))
|
||||
.collect::<Vec<_>>();
|
||||
choices.sort_by(|a, b| a.label.cmp(&b.label));
|
||||
content = content.push(text("Record an explicit engineering correspondence between existing records. This link preserves both records and does not propagate properties.").size(13).color(style::MUTED))
|
||||
.push(text(format!("From: {}",source.label)).size(14))
|
||||
.push(text_input("Trace name",name).padding(10).on_input(|v|Message::Workflow(Event::RelationName(v))))
|
||||
.push(pick_list(choices,target.clone(),|v|Message::Workflow(Event::Target(v))).placeholder("Choose counterpart in the other perspective").width(Fill).padding(10).text_size(13));
|
||||
}
|
||||
Self::Relation(d) => {
|
||||
let kinds = RelationKind::ALL
|
||||
.into_iter()
|
||||
.filter(|kind| {
|
||||
*kind == RelationKind::ExchangeAllocation
|
||||
|| Kind::ALL.into_iter().any(|source| {
|
||||
source.supports(d.layer)
|
||||
&& Kind::ALL.into_iter().any(|target| {
|
||||
target.supports(d.layer) && kind.permits(source, target)
|
||||
})
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let (source_label, target_label) = match d.kind {
|
||||
RelationKind::CapabilityInclude => {
|
||||
("Including capability", "Included capability")
|
||||
}
|
||||
RelationKind::CapabilityExtend => {
|
||||
("Extending capability", "Extended capability")
|
||||
}
|
||||
RelationKind::CapabilityGeneralization => {
|
||||
("Specific capability", "General capability")
|
||||
}
|
||||
RelationKind::MissionCapability => ("Mission", "Exploited capability"),
|
||||
RelationKind::MissionInvolvement => ("Participant", "Mission"),
|
||||
RelationKind::CapabilityInvolvement => ("Participant or work", "Capability"),
|
||||
_ => ("Source", "Target"),
|
||||
};
|
||||
content = content
|
||||
.push(text(d.kind.hint()).size(13).color(style::MUTED))
|
||||
.push(
|
||||
pick_list(kinds, Some(d.kind), |v| {
|
||||
Message::Workflow(Event::RelationKind(v))
|
||||
})
|
||||
.text_size(13)
|
||||
.padding(9)
|
||||
.width(Fill),
|
||||
)
|
||||
.push(text(source_label).size(12))
|
||||
.push(
|
||||
pick_list(d.sources.as_slice(), d.source.as_ref(), |v| {
|
||||
Message::Workflow(Event::Source(v))
|
||||
})
|
||||
.placeholder("Choose source")
|
||||
.text_size(13)
|
||||
.padding(9)
|
||||
.width(Fill),
|
||||
)
|
||||
.push(text(target_label).size(12))
|
||||
.push(
|
||||
pick_list(d.targets.as_slice(), d.target.as_ref(), |v| {
|
||||
Message::Workflow(Event::Target(v))
|
||||
})
|
||||
.placeholder("Choose a compatible target")
|
||||
.text_size(13)
|
||||
.padding(9)
|
||||
.width(Fill),
|
||||
);
|
||||
if d.source.is_some() && d.targets.is_empty() {
|
||||
content = content.push(text("No compatible target yet. Create the needed model element, then connect it here.").size(12).color(style::MUTED));
|
||||
}
|
||||
if d.kind == RelationKind::CapabilityExtend {
|
||||
content = content.push(text("Extension condition").size(12)).push(
|
||||
text_editor(&d.condition)
|
||||
.placeholder("When does the extending capability add behavior?")
|
||||
.on_action(|action| Message::Workflow(Event::Condition(action)))
|
||||
.height(72)
|
||||
.padding(9),
|
||||
);
|
||||
}
|
||||
content = content.push(
|
||||
text_input("Name (generated from endpoints when blank)", &d.name)
|
||||
.on_input(|v| Message::Workflow(Event::RelationName(v)))
|
||||
.padding(10)
|
||||
.size(13),
|
||||
);
|
||||
if let (Some(source), Some(target)) = (&d.source, &d.target) {
|
||||
content = content.push(
|
||||
text(d.engineering_name(p, source.id, target.id))
|
||||
.size(13)
|
||||
.color(style::TEAL),
|
||||
);
|
||||
}
|
||||
}
|
||||
Self::Transition(d) => {
|
||||
content=content.push(text("Create or reconcile traced counterparts. Parent and performer remain independent. Descriptive updates are reviewed here; structural changes are created only for new counterparts.").size(12).color(style::MUTED));
|
||||
let scope = match d.request.scope {
|
||||
DependencyScope::SelectedOnly => "Selected only",
|
||||
DependencyScope::Required => "Required references",
|
||||
DependencyScope::Subtree => "Whole subtree",
|
||||
}
|
||||
.to_string();
|
||||
content = content.push(
|
||||
row![
|
||||
text("Include").size(12),
|
||||
pick_list(
|
||||
vec![
|
||||
"Selected only".to_string(),
|
||||
"Required references".to_string(),
|
||||
"Whole subtree".to_string()
|
||||
],
|
||||
Some(scope),
|
||||
|v| Message::Workflow(Event::Scope(v))
|
||||
)
|
||||
.text_size(12)
|
||||
.padding(8),
|
||||
Space::new().width(Fill),
|
||||
button(text("Refresh preview").size(12))
|
||||
.style(style::nav)
|
||||
.on_press(Message::Workflow(Event::Preview))
|
||||
]
|
||||
.spacing(10)
|
||||
.align_y(iced::Center),
|
||||
);
|
||||
let sources = d
|
||||
.request
|
||||
.sources
|
||||
.iter()
|
||||
.copied()
|
||||
.chain(d.plan.changes.iter().map(|c| c.source))
|
||||
.chain(d.plan.conflicts.iter().filter_map(|c| c.source))
|
||||
.collect::<BTreeSet<_>>();
|
||||
for source in sources {
|
||||
let mapping = d.request.mappings.iter().find(|m| m.source == source);
|
||||
let existing = p
|
||||
.traces
|
||||
.values()
|
||||
.filter_map(|t| {
|
||||
if d.request.direction == TransitionDirection::OaToSa && t.oa == source
|
||||
{
|
||||
Some(t.sa)
|
||||
} else if d.request.direction == TransitionDirection::SaToOa
|
||||
&& t.sa == source
|
||||
{
|
||||
Some(t.oa)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let target = mapping.map(|m| m.target).unwrap_or_else(|| {
|
||||
if existing.len() == 1 {
|
||||
Some(existing[0])
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
let mut targets = vec![Choice {
|
||||
id: Id::nil(),
|
||||
label: "Create new counterpart".into(),
|
||||
}];
|
||||
targets.extend(
|
||||
p.elements
|
||||
.keys()
|
||||
.chain(p.relations.keys())
|
||||
.filter(|id| {
|
||||
p.layer_of(**id) == Some(d.request.direction.target_layer())
|
||||
&& p.elements.contains_key(&source)
|
||||
== p.elements.contains_key(id)
|
||||
})
|
||||
.map(|id| choice(p, *id)),
|
||||
);
|
||||
let selected = targets
|
||||
.iter()
|
||||
.find(|c| Some(c.id) == target.or(Some(Id::nil())))
|
||||
.cloned();
|
||||
let mut item = column![
|
||||
text(format!(
|
||||
"{} · {}",
|
||||
short(p.named(source), 70),
|
||||
p.elements
|
||||
.get(&source)
|
||||
.map(|e| e.kind().label())
|
||||
.unwrap_or("Relationship")
|
||||
))
|
||||
.size(14),
|
||||
pick_list(targets, selected, move |v| Message::Workflow(
|
||||
Event::MappingTarget(source, v)
|
||||
))
|
||||
.text_size(12)
|
||||
.padding(8)
|
||||
.width(Fill)
|
||||
]
|
||||
.spacing(7);
|
||||
if target.is_none()
|
||||
&& let Some(e) = p.elements.get(&source)
|
||||
{
|
||||
let kinds = counterpart_kinds(e.kind(), d.request.direction);
|
||||
if !kinds.is_empty() {
|
||||
let selected = mapping.and_then(|m| m.target_kind).or_else(|| {
|
||||
if kinds.len() == 1 {
|
||||
Some(kinds[0])
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
item = item.push(
|
||||
pick_list(kinds, selected, move |kind| {
|
||||
Message::Workflow(Event::MappingKind(source, kind))
|
||||
})
|
||||
.placeholder("Choose counterpart kind / system boundary")
|
||||
.text_size(12)
|
||||
.padding(8)
|
||||
.width(Fill),
|
||||
);
|
||||
}
|
||||
}
|
||||
content = content.push(
|
||||
iced::widget::container(item)
|
||||
.padding(12)
|
||||
.style(style::subtle),
|
||||
);
|
||||
}
|
||||
let mut changes = column![].spacing(8);
|
||||
for change in &d.plan.changes {
|
||||
changes = changes.push(
|
||||
text(format!("{:?} · {}", change.action, p.named(change.source)))
|
||||
.size(12)
|
||||
.color(style::TEAL),
|
||||
);
|
||||
for field in &change.fields {
|
||||
changes = changes.push(
|
||||
text(format!(
|
||||
"{:?}: {} → {}",
|
||||
field.field,
|
||||
short(&field.before, 90),
|
||||
short(&field.after, 90)
|
||||
))
|
||||
.size(12),
|
||||
);
|
||||
}
|
||||
}
|
||||
content = content.push(changes);
|
||||
let mut fields = d
|
||||
.plan
|
||||
.conflicts
|
||||
.iter()
|
||||
.filter_map(|c| Some((c.source?, c.field?)))
|
||||
.chain(d.request.resolutions.iter().map(|r| (r.source, r.field)))
|
||||
.fold(Vec::new(), |mut out, field| {
|
||||
if !out.contains(&field) {
|
||||
out.push(field);
|
||||
}
|
||||
out
|
||||
});
|
||||
// Keep property widgets in the same order while a resolution
|
||||
// changes the preview, so continuous typing retains focus.
|
||||
fields.sort_by_key(|(source, field)| (*source, *field as u8));
|
||||
let mut general_conflicts = column![].spacing(8);
|
||||
for conflict in &d.plan.conflicts {
|
||||
if conflict.field.is_none() {
|
||||
general_conflicts = general_conflicts.push(
|
||||
text(&conflict.message)
|
||||
.size(12)
|
||||
.color(iced::Color::from_rgb8(178, 54, 58)),
|
||||
);
|
||||
}
|
||||
}
|
||||
content = content.push(general_conflicts);
|
||||
for (source, field) in fields {
|
||||
let target = d
|
||||
.plan
|
||||
.conflicts
|
||||
.iter()
|
||||
.find(|c| c.source == Some(source) && c.field == Some(field))
|
||||
.and_then(|c| c.target)
|
||||
.or_else(|| {
|
||||
d.request
|
||||
.mappings
|
||||
.iter()
|
||||
.find(|m| m.source == source)
|
||||
.and_then(|m| m.target)
|
||||
})
|
||||
.or_else(|| {
|
||||
d.plan
|
||||
.changes
|
||||
.iter()
|
||||
.find(|c| c.source == source)
|
||||
.map(|c| c.target)
|
||||
})
|
||||
.or_else(|| {
|
||||
p.traces.values().find_map(|t| {
|
||||
if d.request.direction == TransitionDirection::OaToSa
|
||||
&& t.oa == source
|
||||
{
|
||||
Some(t.sa)
|
||||
} else if d.request.direction == TransitionDirection::SaToOa
|
||||
&& t.sa == source
|
||||
{
|
||||
Some(t.oa)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
});
|
||||
let value = |id| {
|
||||
p.properties(id)
|
||||
.map(|v| match field {
|
||||
TransitionField::Name => v.name,
|
||||
TransitionField::Description => v.description,
|
||||
})
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let resolution = d
|
||||
.request
|
||||
.resolutions
|
||||
.iter()
|
||||
.find(|r| r.source == source && r.field == field);
|
||||
let merged = resolution
|
||||
.and_then(|r| {
|
||||
if let FieldDecision::Value(v) = &r.decision {
|
||||
Some(v.as_str())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.unwrap_or("");
|
||||
let status = resolution
|
||||
.map(|r| match &r.decision {
|
||||
FieldDecision::KeepTarget => "Keep target",
|
||||
FieldDecision::TakeSource => "Take source",
|
||||
FieldDecision::Value(_) => "Use merged text",
|
||||
})
|
||||
.unwrap_or("Choose how to resolve this conflict");
|
||||
let card = column![
|
||||
text(format!(
|
||||
"{:?} · {} → {}",
|
||||
field,
|
||||
p.named(source),
|
||||
target.map(|id| p.named(id)).unwrap_or("New counterpart")
|
||||
))
|
||||
.size(14),
|
||||
text(format!(
|
||||
"Current target: {}",
|
||||
target.map(value).unwrap_or_default()
|
||||
))
|
||||
.size(12),
|
||||
text(format!("Proposed source: {}", value(source))).size(12),
|
||||
text(status).size(12).color(style::TEAL),
|
||||
row![
|
||||
button(text("Keep target").size(12))
|
||||
.padding(8)
|
||||
.style(style::nav)
|
||||
.on_press(Message::Workflow(Event::Resolve(
|
||||
source,
|
||||
field,
|
||||
FieldDecision::KeepTarget
|
||||
))),
|
||||
button(text("Take source").size(12))
|
||||
.padding(8)
|
||||
.style(style::nav)
|
||||
.on_press(Message::Workflow(Event::Resolve(
|
||||
source,
|
||||
field,
|
||||
FieldDecision::TakeSource
|
||||
)))
|
||||
]
|
||||
.spacing(8),
|
||||
text_input("Or enter merged text", merged)
|
||||
.padding(9)
|
||||
.size(12)
|
||||
.on_input(move |v| Message::Workflow(Event::Resolve(
|
||||
source,
|
||||
field,
|
||||
FieldDecision::Value(v)
|
||||
)))
|
||||
]
|
||||
.spacing(8);
|
||||
content = content.push(
|
||||
iced::widget::container(card)
|
||||
.padding(12)
|
||||
.style(style::subtle),
|
||||
);
|
||||
}
|
||||
for warning in &d.plan.warnings {
|
||||
content = content.push(
|
||||
text(format!("Modeling warning · {warning}"))
|
||||
.size(12)
|
||||
.color(style::MUTED),
|
||||
);
|
||||
}
|
||||
content = content.push(
|
||||
text(format!(
|
||||
"{} changes · {} unresolved conflicts · {} modeling warnings",
|
||||
d.plan.changes.len(),
|
||||
d.plan.conflicts.len(),
|
||||
d.plan.warnings.len()
|
||||
))
|
||||
.size(12),
|
||||
);
|
||||
}
|
||||
}
|
||||
let enabled = match self {
|
||||
Self::ManualTrace { name, target, .. } => target.is_some() && !name.trim().is_empty(),
|
||||
Self::Relation(d) => d
|
||||
.source
|
||||
.as_ref()
|
||||
.zip(d.target.as_ref())
|
||||
.is_some_and(|(a, b)| validation::can_connect(p, d.kind, a.id, b.id)),
|
||||
Self::Transition(d) => d.plan.conflicts.is_empty(),
|
||||
};
|
||||
column![
|
||||
scrollable(content.padding(iced::Padding {
|
||||
right: 14.,
|
||||
..Default::default()
|
||||
}))
|
||||
.height(480),
|
||||
row![
|
||||
Space::new().width(Fill),
|
||||
button(text("Cancel").size(12))
|
||||
.padding(10)
|
||||
.style(style::nav)
|
||||
.on_press(Message::Workflow(Event::Cancel)),
|
||||
button(text("Apply reviewed change").size(12))
|
||||
.padding(10)
|
||||
.on_press_maybe(enabled.then_some(Message::Workflow(Event::Apply)))
|
||||
]
|
||||
.spacing(10)
|
||||
]
|
||||
.spacing(18)
|
||||
.into()
|
||||
}
|
||||
}
|
||||
fn choice(p: &Project, id: Id) -> Choice {
|
||||
Choice {
|
||||
id,
|
||||
label: format!(
|
||||
"{} · {}",
|
||||
p.named(id),
|
||||
p.layer_of(id).map(|l| l.to_string()).unwrap_or_default()
|
||||
),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user