708 lines
27 KiB
Rust
708 lines
27 KiB
Rust
//! Typed editing of stored observations, independently of the live counterparts.
|
||
//! The checked-in, contract-tested OpenAPI descriptor supplies only field shapes.
|
||
//! Serde and core historical validation remain the admission authority.
|
||
use super::*;
|
||
use iced::widget::column;
|
||
use std::sync::OnceLock;
|
||
|
||
pub(super) const TOGGLE: &str = "/$stored_history";
|
||
pub(super) const PAGE: &str = "/$stored_history_page";
|
||
pub(super) const VARIANT: &str = "/$stored_history_variant";
|
||
const ROOT: &str = "/baseline";
|
||
const FACETS: &str = "/baseline/semantic/facets";
|
||
const PAGE_SIZE: usize = 12;
|
||
const MAX_DEPTH: usize = 32;
|
||
|
||
#[derive(Default)]
|
||
pub(super) struct State {
|
||
pub open: bool,
|
||
pub pages: BTreeMap<String, usize>,
|
||
}
|
||
|
||
pub(super) fn is_path(path: &str) -> bool {
|
||
path == ROOT || path.starts_with("/baseline/")
|
||
}
|
||
|
||
fn api() -> &'static Value {
|
||
static API: OnceLock<Value> = OnceLock::new();
|
||
API.get_or_init(|| {
|
||
serde_json::from_str(include_str!("../../../../docs/openapi.json"))
|
||
.expect("The bundled OpenAPI descriptor is valid JSON")
|
||
})
|
||
}
|
||
|
||
fn resolve(mut schema: &'static Value) -> Result<&'static Value, &'static str> {
|
||
for _ in 0..MAX_DEPTH {
|
||
let Some(reference) = schema.get("$ref").and_then(Value::as_str) else {
|
||
return Ok(schema);
|
||
};
|
||
schema = reference
|
||
.strip_prefix('#')
|
||
.and_then(|path| api().pointer(path))
|
||
.ok_or("Stored-history descriptor has an unresolved local reference.")?;
|
||
}
|
||
Err("Stored-history descriptor reference depth exceeded.")
|
||
}
|
||
|
||
fn named(name: &str) -> Result<&'static Value, &'static str> {
|
||
api()["components"]["schemas"]
|
||
.get(name)
|
||
.ok_or("Stored-history descriptor is missing a required type.")
|
||
}
|
||
|
||
fn alternatives(schema: &'static Value) -> Option<&'static Vec<Value>> {
|
||
schema
|
||
.get("oneOf")
|
||
.or_else(|| schema.get("anyOf"))
|
||
.and_then(Value::as_array)
|
||
}
|
||
|
||
fn at_schema(schema: &'static Value, parts: &[&str], depth: usize) -> Option<&'static Value> {
|
||
if depth > MAX_DEPTH {
|
||
return None;
|
||
}
|
||
let schema = resolve(schema).ok()?;
|
||
let Some((part, rest)) = parts.split_first() else {
|
||
return Some(schema);
|
||
};
|
||
if let Some(options) = alternatives(schema) {
|
||
return options.iter().find_map(|s| at_schema(s, parts, depth + 1));
|
||
}
|
||
match schema.get("type").and_then(Value::as_str) {
|
||
Some("object") => at_schema(schema.get("properties")?.get(*part)?, rest, depth + 1),
|
||
Some("array") if part.parse::<usize>().is_ok() => {
|
||
at_schema(schema.get("items")?, rest, depth + 1)
|
||
}
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
fn schema_at(path: &str) -> Option<&'static Value> {
|
||
let tail = path.strip_prefix(ROOT)?;
|
||
let parts: Vec<_> = tail
|
||
.trim_start_matches('/')
|
||
.split('/')
|
||
.filter(|s| !s.is_empty())
|
||
.collect();
|
||
at_schema(named("TraceBaseline").ok()?, &parts, 0)
|
||
}
|
||
|
||
pub(super) fn has_field(path: &str) -> bool {
|
||
schema_at(path).is_some()
|
||
}
|
||
|
||
pub(super) fn free_string(path: &str) -> bool {
|
||
fn text_shape(schema: &'static Value, depth: usize) -> bool {
|
||
if depth > MAX_DEPTH {
|
||
return false;
|
||
}
|
||
let Ok(schema) = resolve(schema) else {
|
||
return false;
|
||
};
|
||
if let Some(options) = alternatives(schema) {
|
||
return options.iter().any(|s| text_shape(s, depth + 1));
|
||
}
|
||
schema.get("type").and_then(Value::as_str) == Some("string")
|
||
&& schema.get("enum").is_none()
|
||
&& schema.get("format").and_then(Value::as_str) != Some("uuid")
|
||
}
|
||
schema_at(path).is_some_and(|s| text_shape(s, 0))
|
||
}
|
||
|
||
/// A shape seed is a draft, never an acknowledged historical fact. UUIDs start
|
||
/// empty. Observed starts NA on both sides, which core rejects. Typed defaults
|
||
/// inside Value are introduced only after the engineer chooses that branch.
|
||
fn seed(schema: &'static Value, depth: usize) -> Result<Value, &'static str> {
|
||
if depth > MAX_DEPTH {
|
||
return Err("Stored-history constructor depth exceeded.");
|
||
}
|
||
let schema = resolve(schema)?;
|
||
if let Some(options) = alternatives(schema) {
|
||
if options
|
||
.iter()
|
||
.filter_map(|s| resolve(s).ok())
|
||
.filter_map(|s| s.get("enum").and_then(Value::as_array))
|
||
.any(|values| values.contains(&json!("NotApplicable")))
|
||
{
|
||
return Ok(json!("NotApplicable"));
|
||
}
|
||
if options.iter().any(|s| matches_value(s, &Value::Null, 0)) {
|
||
return Ok(Value::Null);
|
||
}
|
||
return seed(
|
||
options
|
||
.first()
|
||
.ok_or("Stored-history union has no constructors.")?,
|
||
depth + 1,
|
||
);
|
||
}
|
||
if let Some(values) = schema.get("enum").and_then(Value::as_array) {
|
||
return values
|
||
.first()
|
||
.cloned()
|
||
.ok_or("Stored-history enumeration has no values.");
|
||
}
|
||
Ok(match schema.get("type").and_then(Value::as_str) {
|
||
Some("object") => {
|
||
let fields = schema
|
||
.get("properties")
|
||
.and_then(Value::as_object)
|
||
.ok_or("Stored-history object has no closed field descriptor.")?;
|
||
let mut result = serde_json::Map::new();
|
||
for (name, schema) in fields {
|
||
result.insert(name.clone(), seed(schema, depth + 1)?);
|
||
}
|
||
Value::Object(result)
|
||
}
|
||
Some("array") => json!([]),
|
||
Some("boolean") => json!(false),
|
||
Some("integer") => json!(0),
|
||
Some("number") => json!(0.0),
|
||
Some("string") => json!(""),
|
||
Some("null") => Value::Null,
|
||
_ => return Err("Unsupported stored-history field descriptor."),
|
||
})
|
||
}
|
||
|
||
/// Shape matching selects the displayed constructor; it is not validation.
|
||
/// Required values may be incomplete while their chosen branch stays editable.
|
||
fn matches_value(schema: &'static Value, value: &Value, depth: usize) -> bool {
|
||
if depth > MAX_DEPTH {
|
||
return false;
|
||
}
|
||
let Ok(schema) = resolve(schema) else {
|
||
return false;
|
||
};
|
||
if let Some(options) = alternatives(schema) {
|
||
return options.iter().any(|s| matches_value(s, value, depth + 1));
|
||
}
|
||
if let Some(choices) = schema.get("enum").and_then(Value::as_array) {
|
||
return choices.contains(value);
|
||
}
|
||
match schema.get("type").and_then(Value::as_str) {
|
||
Some("object") => value.as_object().is_some_and(|fields| {
|
||
schema
|
||
.get("properties")
|
||
.and_then(Value::as_object)
|
||
.is_some_and(|properties| {
|
||
fields.keys().all(|key| properties.contains_key(key))
|
||
&& schema
|
||
.get("required")
|
||
.and_then(Value::as_array)
|
||
.is_none_or(|required| {
|
||
required
|
||
.iter()
|
||
.filter_map(Value::as_str)
|
||
.all(|key| fields.contains_key(key))
|
||
})
|
||
})
|
||
}),
|
||
Some("array") => value.is_array(),
|
||
Some("boolean") => value.is_boolean(),
|
||
Some("integer" | "number") => value.is_number(),
|
||
Some("string") => value.is_string(),
|
||
Some("null") => value.is_null(),
|
||
_ => false,
|
||
}
|
||
}
|
||
|
||
fn label(name: &str) -> String {
|
||
match name {
|
||
"oa" => return "Operational observation".into(),
|
||
"sa" => return "System observation".into(),
|
||
"NotApplicable" => return "Not applicable".into(),
|
||
"Value" => return "Observed value".into(),
|
||
"keys" => return "Exact historical keys".into(),
|
||
"review" => return "Stored observations".into(),
|
||
_ => {}
|
||
}
|
||
let mut out = String::new();
|
||
let mut previous_lower = false;
|
||
for c in name.chars() {
|
||
if c == '_' {
|
||
out.push(' ');
|
||
} else {
|
||
if c.is_uppercase() && previous_lower {
|
||
out.push(' ');
|
||
}
|
||
out.push(c);
|
||
}
|
||
previous_lower = c.is_lowercase();
|
||
}
|
||
let mut chars = out.chars();
|
||
chars
|
||
.next()
|
||
.map(|c| c.to_uppercase().collect::<String>() + chars.as_str())
|
||
.unwrap_or_default()
|
||
}
|
||
|
||
fn branch_label(schema: &'static Value) -> String {
|
||
let Ok(schema) = resolve(schema) else {
|
||
return "Unavailable type".into();
|
||
};
|
||
if let Some(word) = schema
|
||
.get("enum")
|
||
.and_then(Value::as_array)
|
||
.filter(|v| v.len() == 1)
|
||
.and_then(|v| v[0].as_str())
|
||
{
|
||
return label(word);
|
||
}
|
||
if schema.get("enum").is_some() {
|
||
return "Enumeration · choose below".into();
|
||
}
|
||
if let Some(fields) = schema.get("properties").and_then(Value::as_object)
|
||
&& fields.len() == 1
|
||
{
|
||
return label(fields.keys().next().unwrap());
|
||
}
|
||
match schema.get("type").and_then(Value::as_str) {
|
||
Some("null") => "Absent (none)".into(),
|
||
Some("object") => "Set record".into(),
|
||
Some("array") => "Set list".into(),
|
||
Some("string") if schema.get("format").and_then(Value::as_str) == Some("uuid") => {
|
||
"Set historical UUID".into()
|
||
}
|
||
Some("string") => "Set text".into(),
|
||
Some("integer" | "number") => "Set number".into(),
|
||
_ => "Set value / choose constructor".into(),
|
||
}
|
||
}
|
||
|
||
#[derive(Clone, PartialEq, Eq)]
|
||
struct FactChoice {
|
||
label: String,
|
||
value: Value,
|
||
}
|
||
impl std::fmt::Display for FactChoice {
|
||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||
f.write_str(&self.label)
|
||
}
|
||
}
|
||
|
||
/// Flatten only the closed FacetReview selector levels, not observation values.
|
||
/// Collection and incident-end choices are selectors too, so none is guessed.
|
||
fn fact_choices() -> Result<&'static Vec<FactChoice>, &'static str> {
|
||
static CHOICES: OnceLock<Result<Vec<FactChoice>, &'static str>> = OnceLock::new();
|
||
CHOICES
|
||
.get_or_init(|| {
|
||
let root = resolve(named("FacetReview")?)?;
|
||
let mut result = vec![];
|
||
for branch in alternatives(root).ok_or("FacetReview must be a closed union.")? {
|
||
let outer = resolve(branch)?;
|
||
let (family, payload) = outer["properties"]
|
||
.as_object()
|
||
.and_then(|v| v.iter().next())
|
||
.ok_or("Facet family is missing.")?;
|
||
let payload = resolve(payload)?;
|
||
if let Some(leaves) = alternatives(payload) {
|
||
for leaf in leaves {
|
||
let mut value = serde_json::Map::new();
|
||
value.insert(family.clone(), seed(leaf, 0)?);
|
||
result.push(FactChoice {
|
||
label: format!("{} / {}", label(family), branch_label(leaf)),
|
||
value: Value::Object(value),
|
||
});
|
||
}
|
||
} else if family == "Artifact" {
|
||
let leaves = alternatives(resolve(&payload["properties"]["review"])?)
|
||
.ok_or("Artifact facts must be a closed union.")?;
|
||
for leaf in leaves {
|
||
let mut value = seed(branch, 0)?;
|
||
value[family]["review"] = seed(leaf, 0)?;
|
||
result.push(FactChoice {
|
||
label: format!("Artifact / {}", branch_label(leaf)),
|
||
value,
|
||
});
|
||
}
|
||
} else if matches!(family.as_str(), "Member" | "Order" | "Incident") {
|
||
let field = if family == "Incident" {
|
||
"end"
|
||
} else {
|
||
"collection"
|
||
};
|
||
let choices = resolve(&payload["properties"][field])?["enum"]
|
||
.as_array()
|
||
.ok_or("Historical selector choices are missing.")?;
|
||
for choice in choices {
|
||
let mut value = seed(branch, 0)?;
|
||
value[family][field] = choice.clone();
|
||
result.push(FactChoice {
|
||
label: format!(
|
||
"{} / {}",
|
||
label(family),
|
||
label(choice.as_str().unwrap_or(""))
|
||
),
|
||
value,
|
||
});
|
||
}
|
||
} else {
|
||
result.push(FactChoice {
|
||
label: label(family),
|
||
value: seed(branch, 0)?,
|
||
});
|
||
}
|
||
}
|
||
Ok(result)
|
||
})
|
||
.as_ref()
|
||
.map_err(|error| *error)
|
||
}
|
||
|
||
fn variant_message(path: &str, value: Value, present: bool) -> Message {
|
||
Message::FieldShape(
|
||
if present {
|
||
format!("{VARIANT}{path}")
|
||
} else {
|
||
path.into()
|
||
},
|
||
value,
|
||
)
|
||
}
|
||
|
||
fn branch_seed(schema: &'static Value) -> Result<Value, &'static str> {
|
||
let schema = resolve(schema)?;
|
||
if let Some(values) = schema
|
||
.get("enum")
|
||
.and_then(Value::as_array)
|
||
.filter(|v| v.len() == 1)
|
||
{
|
||
Ok(values[0].clone())
|
||
} else {
|
||
seed(schema, 0)
|
||
}
|
||
}
|
||
|
||
fn branch_has_fields(schema: &'static Value) -> bool {
|
||
let Ok(schema) = resolve(schema) else {
|
||
return false;
|
||
};
|
||
schema.get("type").and_then(Value::as_str) != Some("null")
|
||
&& schema
|
||
.get("enum")
|
||
.and_then(Value::as_array)
|
||
.is_none_or(|values| values.len() > 1)
|
||
}
|
||
|
||
/// Constructor identity is independent of a primitive's edited content. In
|
||
/// particular, absent, an empty UUID/text, a number and a list are different
|
||
/// branches. String enum tags and externally tagged objects retain their variant.
|
||
pub(super) fn constructor_tag(path: &str, value: &Value) -> String {
|
||
fn enum_value(schema: &'static Value, value: &Value, depth: usize) -> bool {
|
||
if depth > MAX_DEPTH {
|
||
return false;
|
||
}
|
||
let Ok(schema) = resolve(schema) else {
|
||
return false;
|
||
};
|
||
if let Some(options) = alternatives(schema) {
|
||
return options
|
||
.iter()
|
||
.any(|option| enum_value(option, value, depth + 1));
|
||
}
|
||
schema
|
||
.get("enum")
|
||
.and_then(Value::as_array)
|
||
.is_some_and(|values| values.contains(value))
|
||
}
|
||
match value {
|
||
Value::Null => "null".into(),
|
||
Value::Bool(_) => "boolean".into(),
|
||
Value::Number(_) => "number".into(),
|
||
Value::Array(_) => "array".into(),
|
||
Value::String(word)
|
||
if schema_at(path).is_some_and(|schema| enum_value(schema, value, 0)) =>
|
||
{
|
||
format!("enum:{word}")
|
||
}
|
||
Value::String(_) => "string".into(),
|
||
Value::Object(fields) => format!(
|
||
"object:{}",
|
||
fields.keys().cloned().collect::<Vec<_>>().join("/")
|
||
),
|
||
}
|
||
}
|
||
|
||
fn paging<'a>(draft: &'a Draft, path: &str, count: usize) -> (usize, View<'a, Message>) {
|
||
let last = count.saturating_sub(1) / PAGE_SIZE;
|
||
let page = draft
|
||
.history
|
||
.pages
|
||
.get(path)
|
||
.copied()
|
||
.unwrap_or(0)
|
||
.min(last);
|
||
let event = |page| Message::FieldShape(format!("{PAGE}{path}"), json!(page));
|
||
let view = row![
|
||
button("Previous").on_press_maybe((page > 0).then(|| event(page - 1))),
|
||
text(format!(
|
||
"{}–{} of {count}",
|
||
if count == 0 { 0 } else { page * PAGE_SIZE + 1 },
|
||
((page + 1) * PAGE_SIZE).min(count)
|
||
))
|
||
.size(11),
|
||
button("Next").on_press_maybe((page < last).then(|| event(page + 1)))
|
||
]
|
||
.spacing(6)
|
||
.align_y(iced::Center)
|
||
.into();
|
||
(page, view)
|
||
}
|
||
|
||
fn render<'a>(
|
||
draft: &'a Draft,
|
||
project: &'a Project,
|
||
schema: &'static Value,
|
||
value: Option<&'a Value>,
|
||
path: &str,
|
||
title: &str,
|
||
depth: usize,
|
||
) -> View<'a, Message> {
|
||
if depth > MAX_DEPTH {
|
||
return text("Stored-history field nesting exceeds the supported editor depth.")
|
||
.size(11)
|
||
.into();
|
||
}
|
||
let schema = match resolve(schema) {
|
||
Ok(s) => s,
|
||
Err(e) => return text(e).size(11).into(),
|
||
};
|
||
let present = value.is_some();
|
||
let value = value.unwrap_or(&Value::Null);
|
||
if let Some(options) = alternatives(schema) {
|
||
let initial: Vec<_> = match options.iter().map(branch_seed).collect::<Result<_, _>>() {
|
||
Ok(values) => values,
|
||
Err(error) => {
|
||
return text(format!(
|
||
"Stored-history constructor is unavailable: {error}"
|
||
))
|
||
.size(11)
|
||
.into();
|
||
}
|
||
};
|
||
let labels: Vec<_> = options.iter().map(branch_label).collect();
|
||
let selected = options.iter().position(|s| matches_value(s, value, 0));
|
||
let path_owned = path.to_string();
|
||
let labels_owned = labels.clone();
|
||
let mut out = column![
|
||
text(title.to_owned()).size(11).color(style::MUTED),
|
||
pick_list(
|
||
labels,
|
||
selected.map(|i| branch_label(&options[i])),
|
||
move |choice| {
|
||
let index = labels_owned.iter().position(|v| v == &choice).unwrap();
|
||
variant_message(&path_owned, initial[index].clone(), present)
|
||
}
|
||
)
|
||
.placeholder("Choose stored value / constructor…")
|
||
.text_size(12)
|
||
.padding(8)
|
||
.width(Fill)
|
||
]
|
||
.spacing(6);
|
||
if let Some(index) = selected {
|
||
let branch = resolve(&options[index]).unwrap_or(&options[index]);
|
||
if branch_has_fields(branch) {
|
||
out = out.push(render(
|
||
draft,
|
||
project,
|
||
branch,
|
||
Some(value),
|
||
path,
|
||
"",
|
||
depth + 1,
|
||
));
|
||
}
|
||
}
|
||
return out.into();
|
||
}
|
||
if let Some(words) = schema.get("enum").and_then(Value::as_array) {
|
||
let choices: Vec<_> = words
|
||
.iter()
|
||
.filter_map(Value::as_str)
|
||
.map(str::to_owned)
|
||
.collect();
|
||
let selected = value
|
||
.as_str()
|
||
.map(str::to_owned)
|
||
.filter(|v| choices.contains(v));
|
||
let path = path.to_string();
|
||
return column![
|
||
text(title.to_owned()).size(11).color(style::MUTED),
|
||
pick_list(choices, selected, move |word| Message::FieldShape(
|
||
path.clone(),
|
||
json!(word)
|
||
))
|
||
.placeholder("Choose an explicit value…")
|
||
.text_size(12)
|
||
.padding(8)
|
||
.width(Fill)
|
||
]
|
||
.spacing(6)
|
||
.into();
|
||
}
|
||
match schema.get("type").and_then(Value::as_str) {
|
||
Some("object") => {
|
||
let Some(fields) = schema.get("properties").and_then(Value::as_object) else {
|
||
return text("Unsupported stored-history object shape.").size(11).into();
|
||
};
|
||
let mut out = column![].spacing(8);
|
||
if !title.is_empty() { out = out.push(text(title.to_owned()).size(12).color(style::INK)); }
|
||
for (key, child_schema) in fields {
|
||
out = out.push(render(draft, project, child_schema, value.get(key), &format!("{path}/{key}"), &label(key), depth + 1));
|
||
}
|
||
out.into()
|
||
}
|
||
Some("array") => {
|
||
let Some(items) = value.as_array() else {
|
||
return button("Initialize empty list").on_press(Message::FieldShape(path.into(), json!([]))).into();
|
||
};
|
||
let Some(item_schema) = schema.get("items") else { return text("Missing history list item type.").into(); };
|
||
let mut out = column![text(title.to_owned()).size(12)].spacing(8);
|
||
if path == FACETS {
|
||
match fact_choices() {
|
||
Ok(choices) => {
|
||
let path = path.to_owned();
|
||
out = out.push(pick_list(choices.clone(), None::<FactChoice>, move |choice| Message::FieldAppend(path.clone(), choice.value))
|
||
.placeholder("Add: choose family and fact…").text_size(12).padding(8).width(Fill));
|
||
}
|
||
Err(error) => { out = out.push(text(error).size(11)); }
|
||
}
|
||
} else {
|
||
match seed(item_schema, 0) {
|
||
Ok(initial) => out = out.push(button("Add list value").on_press(Message::FieldAppend(path.into(), initial))),
|
||
Err(error) => out = out.push(text(error).size(11)),
|
||
}
|
||
}
|
||
let (page, pager) = paging(draft, path, items.len());
|
||
if items.len() > PAGE_SIZE { out = out.push(pager); }
|
||
for (index, item) in items.iter().enumerate().skip(page * PAGE_SIZE).take(PAGE_SIZE) {
|
||
let item_path = format!("{path}/{index}");
|
||
let mut card = column![render(draft, project, item_schema, Some(item), &item_path, &format!("{} {}", title, index + 1), depth + 1)].spacing(8);
|
||
if path == FACETS
|
||
&& let Some(current) = current_observation(draft, project, &item_path)
|
||
{
|
||
card = card.push(button("Use current counterpart observations")
|
||
.on_press(Message::FieldShape(item_path.clone(), json!(current))).style(style::nav));
|
||
}
|
||
card = card.push(row![
|
||
button("↑").on_press_maybe((index > 0).then(|| Message::FieldArray(path.into(), Some(index), -1))),
|
||
button("↓").on_press_maybe((index + 1 < items.len()).then(|| Message::FieldArray(path.into(), Some(index), 1))),
|
||
button("Remove").on_press(Message::FieldArray(path.into(), Some(index), 0))
|
||
].spacing(6));
|
||
out = out.push(container(card).padding(10).style(style::subtle));
|
||
}
|
||
if items.is_empty() { out = out.push(text("Empty list · no stored entries").size(11).color(style::MUTED)); }
|
||
out.into()
|
||
}
|
||
Some("boolean") => {
|
||
let path = path.to_owned();
|
||
checkbox(value.as_bool().unwrap_or(false)).label(title.to_owned())
|
||
.on_toggle(move |v| Message::FieldShape(path.clone(), json!(v))).text_size(12).into()
|
||
}
|
||
Some("integer" | "number" | "string") => {
|
||
let is_uuid = schema.get("format").and_then(Value::as_str) == Some("uuid");
|
||
let numeric = schema.get("type").and_then(Value::as_str) != Some("string");
|
||
let mut out = column![text(title.to_owned()).size(11).color(style::MUTED)].spacing(5);
|
||
if let Some(input) = draft.inputs.get(path) {
|
||
let owned = path.to_owned();
|
||
if !numeric && !is_uuid && !draft.exact_strings {
|
||
out = out.push(text_editor(&input.editor).on_action(move |a| Message::FieldText(owned.clone(), a)).size(12).height(72).padding(8));
|
||
} else {
|
||
out = out.push(text_input(if is_uuid { "Historical UUID, including deleted identities" } else { "Stored value" }, &input.raw)
|
||
.on_input(move |raw| Message::Field(owned.clone(), raw)).size(12).padding(8));
|
||
}
|
||
} else {
|
||
match seed(schema, 0) {
|
||
Ok(value) => out = out.push(button("Set stored value").on_press(Message::FieldShape(path.into(), value))),
|
||
Err(error) => out = out.push(text(error).size(11)),
|
||
}
|
||
}
|
||
if is_uuid { out = out.push(text("Historical identity · existence in the current model is not required.").size(10).color(style::MUTED)); }
|
||
out.into()
|
||
}
|
||
Some("null") => text("Absent (none)").size(11).into(),
|
||
_ => text("Unsupported stored-history descriptor. The draft is retained; this field cannot be edited with these controls.").size(11).into(),
|
||
}
|
||
}
|
||
|
||
fn current_observation(draft: &Draft, project: &Project, item_path: &str) -> Option<FacetReview> {
|
||
let raw = draft
|
||
.materialize_scopes(&[item_path.to_owned(), "/oa".into(), "/sa".into()])
|
||
.ok()?;
|
||
let fact: FacetReview = serde_json::from_value(raw.pointer(item_path)?.clone()).ok()?;
|
||
let oa = raw.get("oa")?.as_str()?.parse().ok()?;
|
||
let sa = raw.get("sa")?.as_str()?.parse().ok()?;
|
||
semantic::capture_facet(project, oa, sa, &fact.selector()).ok()
|
||
}
|
||
|
||
pub(super) fn view<'a>(
|
||
draft: &'a Draft,
|
||
project: &'a Project,
|
||
value: &'a Value,
|
||
) -> View<'a, Message> {
|
||
let count = value
|
||
.pointer("/semantic/facets")
|
||
.and_then(Value::as_array)
|
||
.map_or(0, Vec::len);
|
||
let mut out = column![
|
||
text("Stored review history").size(12).color(style::INK),
|
||
text(if value.is_null() {
|
||
"No stored baseline".into()
|
||
} else {
|
||
format!("Prose baseline · {count} semantic observations")
|
||
})
|
||
.size(11)
|
||
.color(style::MUTED),
|
||
button(if draft.history.open {
|
||
"Close stored-history editor"
|
||
} else {
|
||
"Edit stored history (advanced)…"
|
||
})
|
||
.on_press(Message::FieldShape(
|
||
TOGGLE.into(),
|
||
json!(!draft.history.open)
|
||
))
|
||
.style(style::nav)
|
||
]
|
||
.spacing(8);
|
||
if !draft.history.open {
|
||
return out.into();
|
||
}
|
||
out = out.push(text("These edits change the trace's stored observations only. They do not change either counterpart. Ordinary Keep/Take review remains available from Traceability.").size(11));
|
||
if value.is_null() {
|
||
return match named("TraceBaseline").and_then(|s| seed(s, 0)) {
|
||
Ok(initial) => out
|
||
.push(
|
||
button("Start empty stored baseline")
|
||
.on_press(Message::FieldShape(ROOT.into(), initial)),
|
||
)
|
||
.into(),
|
||
Err(error) => out.push(text(error).size(11)).into(),
|
||
};
|
||
}
|
||
out = out.push(
|
||
button("Clear whole stored baseline")
|
||
.on_press(Message::FieldShape(ROOT.into(), Value::Null))
|
||
.style(style::nav),
|
||
);
|
||
match named("TraceBaseline") {
|
||
Ok(schema) => out = out.push(render(draft, project, schema, Some(value), ROOT, "", 0)),
|
||
Err(error) => out = out.push(text(error).size(11)),
|
||
}
|
||
let checked = draft.materialize_scopes(&[ROOT.into()]).and_then(|raw| {
|
||
let baseline: TraceBaseline =
|
||
serde_json::from_value(raw["baseline"].clone()).map_err(|e| e.to_string())?;
|
||
baseline.semantic.as_ref().map_or(Ok(()), |b| {
|
||
semantic::validate_semantic_baseline(b).map_err(|e| e.join("; "))
|
||
})
|
||
});
|
||
if let Err(error) = checked {
|
||
out = out.push(text(format!("Stored history is incomplete or invalid: {error}")).size(11));
|
||
}
|
||
out.into()
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests;
|