use crate::panels::plm_parts::Pending;
use crate::plm::PlmFuture;
use eframe::egui;
use std::collections::HashMap;
use std::task::Waker;
pub const INBOX_POLL_SECS: f64 = 60.0;
#[derive(Clone, Debug, Default, PartialEq, serde::Deserialize)]
pub struct Reviewer {
#[serde(default)]
pub kind: String,
#[serde(default)]
pub id: String,
#[serde(default)]
pub name: String,
#[serde(default)]
pub verdict: String,
#[serde(default)]
pub approved_by: Vec<String>,
}
#[derive(Clone, Debug, Default, PartialEq, serde::Deserialize)]
pub struct Decision {
#[serde(default)]
pub name: String,
#[serde(default)]
pub verdict: String,
#[serde(default)]
pub comment: String,
#[serde(default)]
pub at: u64,
#[serde(default)]
pub content_hash: Option<String>,
#[serde(default)]
pub current: Option<bool>,
}
#[derive(Clone, Debug, Default, PartialEq, serde::Deserialize)]
pub struct Round {
#[serde(default)]
pub id: String,
#[serde(default)]
pub status: String,
#[serde(default)]
pub opened_by: String,
#[serde(default)]
pub opened_at: u64,
#[serde(default)]
pub required_approvals: u32,
#[serde(default)]
pub approvals: usize,
#[serde(default)]
pub stale_approvals: usize,
#[serde(default)]
pub met: bool,
#[serde(default)]
pub due: Option<u64>,
#[serde(default)]
pub overdue: bool,
#[serde(default)]
pub reviewers: Vec<Reviewer>,
#[serde(default)]
pub decisions: Vec<Decision>,
#[serde(default)]
pub can_decide: bool,
#[serde(default)]
pub can_change: bool,
#[serde(default)]
pub live: bool,
}
#[derive(Clone, Debug, Default, PartialEq, serde::Deserialize)]
pub struct Comment {
#[serde(default)]
pub id: String,
#[serde(default)]
pub author: String,
#[serde(default)]
pub at: u64,
#[serde(default)]
pub body: String,
#[serde(default)]
pub parent: String,
}
#[derive(Clone, Debug, Default, PartialEq, serde::Deserialize)]
pub struct Rule {
#[serde(default)]
pub required_approvals: u32,
#[serde(default)]
pub due_days: u32,
#[serde(default)]
pub allow_self_approval: bool,
}
#[derive(Clone, Debug, Default, PartialEq, serde::Deserialize)]
pub struct RevisionReview {
#[serde(default)]
pub part_id: String,
#[serde(default)]
pub number: String,
#[serde(default)]
pub revision_id: String,
#[serde(default)]
pub label: String,
#[serde(default)]
pub lifecycle: String,
#[serde(default)]
pub current: Option<Round>,
#[serde(default)]
pub history: Vec<Round>,
#[serde(default)]
pub comments: Vec<Comment>,
#[serde(default)]
pub rule: Rule,
#[serde(default)]
pub can_comment: bool,
}
#[derive(Clone, Debug, Default, PartialEq, serde::Deserialize)]
pub struct InboxItem {
#[serde(default)]
pub kind: String,
#[serde(default)]
pub target: String,
#[serde(default)]
pub revision_id: String,
#[serde(default)]
pub title: String,
#[serde(default)]
pub name: String,
#[serde(default)]
pub status: String,
#[serde(default)]
pub due: Option<u64>,
#[serde(default)]
pub overdue: bool,
#[serde(default)]
pub approvals: usize,
#[serde(default)]
pub required_approvals: u32,
}
impl InboxItem {
pub fn event(&self) -> Option<PlmReviewEvent> {
match self.kind.as_str() {
"revision" => Some(PlmReviewEvent::OpenReview { part: self.target.clone(), revision: self.revision_id.clone() }),
"eco" => Some(PlmReviewEvent::OpenChangeOrder { eco: self.target.clone() }),
_ => None,
}
}
pub fn describe(&self) -> String {
let mut line = format!("{} {} — {} {}/{}", self.title, self.name, self.status, self.approvals, self.required_approvals);
if let Some(due) = self.due {
line.push_str(&format!(", due {}", date(due)));
}
if self.overdue {
line.push_str(" — OVERDUE");
}
line
}
}
#[derive(Clone, Debug, Default, PartialEq, serde::Deserialize)]
pub struct Inbox {
#[serde(default)]
pub waiting_on_me: Vec<InboxItem>,
#[serde(default)]
pub submitted: Vec<InboxItem>,
#[serde(default)]
pub count: usize,
}
#[derive(Clone, Debug, Default, PartialEq, serde::Deserialize)]
pub struct ChangeOrderItem {
#[serde(default)]
pub part_id: String,
#[serde(default)]
pub number: String,
#[serde(default)]
pub name: String,
#[serde(default)]
pub revision_id: String,
#[serde(default)]
pub label: String,
#[serde(default)]
pub lifecycle: String,
#[serde(default)]
pub action: String,
#[serde(default)]
pub note: String,
#[serde(default)]
pub problem: String,
}
#[derive(Clone, Debug, Default, PartialEq, serde::Deserialize)]
pub struct ChangeOrder {
#[serde(default)]
pub id: String,
#[serde(default)]
pub number: String,
#[serde(default)]
pub title: String,
#[serde(default)]
pub state: String,
#[serde(default)]
pub priority: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub reason: String,
#[serde(default)]
pub items: Vec<ChangeOrderItem>,
#[serde(default)]
pub current: Option<Round>,
#[serde(default)]
pub history: Vec<Round>,
#[serde(default)]
pub comments: Vec<Comment>,
#[serde(default)]
pub problems: Vec<String>,
#[serde(default)]
pub can_edit: bool,
#[serde(default)]
pub can_release: bool,
#[serde(default)]
pub can_comment: bool,
}
#[derive(Clone, Debug, Default, PartialEq, serde::Deserialize)]
pub struct Done {
#[serde(default)]
pub warnings: Vec<String>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Lock {
pub holder: Option<String>,
pub mine: bool,
}
impl Lock {
pub fn submit_block(&self, number: &str, label: &str) -> Option<String> {
match (&self.holder, self.mine) {
(Some(holder), false) => Some(format!(
"{number} rev {label} is checked out by {holder} — only {holder} can submit it for review (or check it in first)"
)),
_ => None,
}
}
}
#[derive(Clone, Debug, Default, PartialEq, serde::Serialize)]
pub struct SubmitRequest {
#[serde(skip_serializing_if = "Vec::is_empty")]
pub reviewers: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub due: Option<u64>,
#[serde(skip_serializing_if = "String::is_empty")]
pub note: String,
}
impl SubmitRequest {
pub fn from_form(reviewers: &str, due_days: &str, note: &str, now: u64) -> Result<Self, String> {
let reviewers = reviewers
.split([',', ' ', '\n', '\t'])
.map(str::trim)
.filter(|r| !r.is_empty())
.map(|r| if r.contains(':') { r.to_string() } else { format!("user:{r}") })
.collect();
let due = match due_days.trim() {
"" => None,
days => {
let days: u64 = days.parse().map_err(|_| format!("due in `{days}` days: give a whole number of days"))?;
if days == 0 {
return Err("due in 0 days: give at least 1, or leave it empty for no due date".into());
}
Some(now + days * 86_400)
}
};
Ok(Self { reviewers, due, note: note.trim().to_string() })
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Verdict {
Approve,
Reject,
}
impl Verdict {
pub fn as_str(self) -> &'static str {
match self {
Verdict::Approve => "approve",
Verdict::Reject => "reject",
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum PlmReviewEvent {
OpenReview { part: String, revision: String },
OpenChangeOrder { eco: String },
}
impl PlmReviewEvent {
pub fn from_lifecycle(event: &crate::panels::plm::PanelEvent) -> Option<Self> {
use crate::panels::plm::PanelEvent;
match event {
PanelEvent::OpenReview { part, revision } => Some(Self::OpenReview { part: part.clone(), revision: revision.clone() }),
PanelEvent::OpenChangeOrder { eco } => Some(Self::OpenChangeOrder { eco: eco.clone() }),
PanelEvent::Access(_) | PanelEvent::Open(_) => None,
}
}
}
pub fn save_warning(label: &str, lifecycle: &str) -> Option<String> {
(lifecycle == "inreview").then(|| {
format!(
"Revision {label} is in review. This save changes the document its reviewers are deciding on: \
approvals given so far will need renewing."
)
})
}
pub trait PlmReview {
fn review(&self, part: &str, revision: &str) -> PlmFuture<RevisionReview>;
fn lock(&self, part: &str, revision: &str) -> PlmFuture<Lock>;
fn submit(&self, part: &str, revision: &str, request: &SubmitRequest) -> PlmFuture<Done>;
fn decide(&self, part: &str, revision: &str, verdict: Verdict, comment: &str) -> PlmFuture<Done>;
fn comment(&self, part: &str, revision: &str, body: &str) -> PlmFuture<Done>;
fn inbox(&self) -> PlmFuture<Inbox>;
fn change_order(&self, eco: &str) -> PlmFuture<ChangeOrder>;
fn submit_change_order(&self, eco: &str, note: &str) -> PlmFuture<Done>;
fn decide_change_order(&self, eco: &str, verdict: Verdict, comment: &str) -> PlmFuture<Done>;
fn comment_change_order(&self, eco: &str, body: &str) -> PlmFuture<Done>;
fn release_change_order(&self, eco: &str) -> PlmFuture<Done>;
}
pub struct PlmReviewClient {
pub client: std::rc::Rc<crate::plm::client::PlmClient>,
}
impl PlmReviewClient {
fn json<T: serde::de::DeserializeOwned + 'static>(&self, method: &'static str, path: String, body: Option<serde_json::Value>) -> PlmFuture<T> {
let client = self.client.clone();
let body = body.map(|b| serde_json::to_vec(&b).expect("a JSON value serialises"));
Box::pin(async move {
let response = client.call(method, &path, body).await.map_err(|error| error.to_string())?;
serde_json::from_slice(&response.body)
.map_err(|error| format!("the PLM answered {path} with something this app cannot read: {error}"))
})
}
fn revision_path(part: &str, revision: &str, rest: &str) -> String {
format!("/api/parts/{}/revisions/{}{rest}", segment(part), segment(revision))
}
}
impl PlmReview for PlmReviewClient {
fn review(&self, part: &str, revision: &str) -> PlmFuture<RevisionReview> {
self.json("GET", Self::revision_path(part, revision, "/review"), None)
}
fn lock(&self, part: &str, revision: &str) -> PlmFuture<Lock> {
let view: PlmFuture<serde_json::Value> = self.json("GET", format!("/api/parts/{}", segment(part)), None);
let revision = revision.to_string();
Box::pin(async move {
let view = view.await?;
let row = view["revision_views"]
.as_array()
.and_then(|rows| rows.iter().find(|r| r["id"] == revision.as_str()))
.ok_or_else(|| format!("the PLM's part has no revision {revision}"))?;
Ok(Lock { holder: row["locked_by"].as_str().map(str::to_string), mine: row["locked_by_me"].as_bool().unwrap_or(false) })
})
}
fn submit(&self, part: &str, revision: &str, request: &SubmitRequest) -> PlmFuture<Done> {
let body = serde_json::to_value(request).expect("a submission serialises");
self.json("POST", Self::revision_path(part, revision, "/submit"), Some(body))
}
fn decide(&self, part: &str, revision: &str, verdict: Verdict, comment: &str) -> PlmFuture<Done> {
let body = serde_json::json!({ "verdict": verdict.as_str(), "comment": comment });
self.json("POST", Self::revision_path(part, revision, "/review/decision"), Some(body))
}
fn comment(&self, part: &str, revision: &str, body: &str) -> PlmFuture<Done> {
self.json("POST", Self::revision_path(part, revision, "/comments"), Some(serde_json::json!({ "body": body })))
}
fn inbox(&self) -> PlmFuture<Inbox> {
self.json("GET", "/api/inbox".into(), None)
}
fn change_order(&self, eco: &str) -> PlmFuture<ChangeOrder> {
self.json("GET", format!("/api/ecos/{}", segment(eco)), None)
}
fn submit_change_order(&self, eco: &str, note: &str) -> PlmFuture<Done> {
self.json("POST", format!("/api/ecos/{}/submit", segment(eco)), Some(serde_json::json!({ "note": note })))
}
fn decide_change_order(&self, eco: &str, verdict: Verdict, comment: &str) -> PlmFuture<Done> {
let body = serde_json::json!({ "verdict": verdict.as_str(), "comment": comment });
self.json("POST", format!("/api/ecos/{}/review/decision", segment(eco)), Some(body))
}
fn comment_change_order(&self, eco: &str, body: &str) -> PlmFuture<Done> {
self.json("POST", format!("/api/ecos/{}/comments", segment(eco)), Some(serde_json::json!({ "body": body })))
}
fn release_change_order(&self, eco: &str) -> PlmFuture<Done> {
self.json("POST", format!("/api/ecos/{}/release", segment(eco)), Some(serde_json::json!({})))
}
}
fn segment(text: &str) -> String {
let mut out = String::with_capacity(text.len());
for byte in text.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => out.push(byte as char),
other => out.push_str(&format!("%{other:02X}")),
}
}
out
}
pub fn date(unix: u64) -> String {
let z = (unix / 86_400) as i64 + 719_468;
let era = z.div_euclid(146_097);
let doe = z.rem_euclid(146_097);
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let day = doy - (153 * mp + 2) / 5 + 1;
let month = if mp < 10 { mp + 3 } else { mp - 9 };
let year = yoe + era * 400 + i64::from(month <= 2);
format!("{year:04}-{month:02}-{day:02}")
}
pub fn describe_round(round: &Round) -> String {
let mut line = format!("{} — {} of {} approval(s)", round.status, round.approvals, round.required_approvals);
if round.met {
line.push_str(", met");
}
if round.stale_approvals > 0 {
line.push_str(&format!(", {} given on an older version and need renewing", round.stale_approvals));
}
if let Some(due) = round.due {
line.push_str(&format!(", due {}", date(due)));
}
if round.overdue {
line.push_str(" — OVERDUE");
}
line
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Subject {
Revision { part: String, revision: String },
ChangeOrder { eco: String },
}
impl Subject {
pub fn of(event: &PlmReviewEvent) -> Self {
match event {
PlmReviewEvent::OpenReview { part, revision } => Subject::Revision { part: part.clone(), revision: revision.clone() },
PlmReviewEvent::OpenChangeOrder { eco } => Subject::ChangeOrder { eco: eco.clone() },
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub enum Loaded {
Revision(RevisionReview),
ChangeOrder(ChangeOrder),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Message {
Refused(String),
Done(String),
}
pub struct ReviewPanel {
pub subject: Subject,
loaded: Option<Loaded>,
load: Option<Pending<Loaded>>,
lock: Option<Lock>,
lock_load: Option<Pending<Lock>>,
action: Option<Pending<Done>>,
message: Option<Message>,
reviewers: String,
due_days: String,
note: String,
decision_comment: String,
new_comment: String,
acted: bool,
hits: HashMap<String, egui::Rect>,
}
impl ReviewPanel {
pub fn new(subject: Subject, server: &dyn PlmReview) -> Self {
let mut panel = Self {
subject,
loaded: None,
load: None,
lock: None,
lock_load: None,
action: None,
message: None,
reviewers: String::new(),
due_days: String::new(),
note: String::new(),
decision_comment: String::new(),
new_comment: String::new(),
acted: false,
hits: HashMap::new(),
};
panel.reload(server);
panel
}
pub fn loaded(&self) -> Option<&Loaded> {
self.loaded.as_ref()
}
pub fn lock(&self) -> Option<&Lock> {
self.lock.as_ref()
}
pub fn message(&self) -> Option<&Message> {
self.message.as_ref()
}
pub fn busy(&self) -> bool {
self.load.as_ref().is_some_and(Pending::is_open)
|| self.action.as_ref().is_some_and(Pending::is_open)
|| self.lock_load.as_ref().is_some_and(Pending::is_open)
}
pub fn take_acted(&mut self) -> bool {
std::mem::take(&mut self.acted)
}
pub fn hits(&self) -> &HashMap<String, egui::Rect> {
&self.hits
}
pub fn reload(&mut self, server: &dyn PlmReview) {
let future: PlmFuture<Loaded> = match &self.subject {
Subject::Revision { part, revision } => {
self.lock_load = Some(Pending::new(server.lock(part, revision)));
let request = server.review(part, revision);
Box::pin(async move { request.await.map(Loaded::Revision) })
}
Subject::ChangeOrder { eco } => {
let request = server.change_order(eco);
Box::pin(async move { request.await.map(Loaded::ChangeOrder) })
}
};
self.load = Some(Pending::new(future));
}
fn act(&mut self, request: PlmFuture<Done>) {
self.message = None;
self.acted = true;
self.action = Some(Pending::new(request));
}
pub fn poll(&mut self, waker: &Waker, server: &dyn PlmReview) -> bool {
let mut changed = false;
if let Some(outcome) = self.action.as_mut().and_then(|p| p.poll(waker)) {
self.action = None;
changed = true;
match outcome {
Ok(done) => {
self.message = (!done.warnings.is_empty()).then(|| Message::Done(done.warnings.join("\n")));
self.decision_comment.clear();
self.new_comment.clear();
self.note.clear();
self.reload(server);
}
Err(sentence) => self.message = Some(Message::Refused(sentence)),
}
}
if let Some(outcome) = self.lock_load.as_mut().and_then(|p| p.poll(waker)) {
self.lock_load = None;
changed = true;
self.lock = outcome.ok();
}
if let Some(outcome) = self.load.as_mut().and_then(|p| p.poll(waker)) {
self.load = None;
changed = true;
match outcome {
Ok(loaded) => self.loaded = Some(loaded),
Err(sentence) => self.message = Some(Message::Refused(sentence)),
}
}
changed
}
pub fn submit(&mut self, server: &dyn PlmReview, now: u64) {
match self.subject.clone() {
Subject::Revision { part, revision } => match SubmitRequest::from_form(&self.reviewers, &self.due_days, &self.note, now) {
Ok(request) => self.act(server.submit(&part, &revision, &request)),
Err(problem) => self.message = Some(Message::Refused(problem)),
},
Subject::ChangeOrder { eco } => {
let note = self.note.trim().to_string();
self.act(server.submit_change_order(&eco, ¬e));
}
}
}
pub fn decide(&mut self, server: &dyn PlmReview, verdict: Verdict) {
let comment = self.decision_comment.trim().to_string();
match self.subject.clone() {
Subject::Revision { part, revision } => self.act(server.decide(&part, &revision, verdict, &comment)),
Subject::ChangeOrder { eco } => self.act(server.decide_change_order(&eco, verdict, &comment)),
}
}
pub fn post_comment(&mut self, server: &dyn PlmReview) {
let body = self.new_comment.trim().to_string();
if body.is_empty() {
return;
}
match self.subject.clone() {
Subject::Revision { part, revision } => self.act(server.comment(&part, &revision, &body)),
Subject::ChangeOrder { eco } => self.act(server.comment_change_order(&eco, &body)),
}
}
pub fn release_change_order(&mut self, server: &dyn PlmReview) {
if let Subject::ChangeOrder { eco } = self.subject.clone() {
self.act(server.release_change_order(&eco));
}
}
pub fn set_form(&mut self, reviewers: &str, due_days: &str, note: &str) {
self.reviewers = reviewers.into();
self.due_days = due_days.into();
self.note = note.into();
}
pub fn set_decision_comment(&mut self, comment: &str) {
self.decision_comment = comment.into();
}
pub fn set_new_comment(&mut self, body: &str) {
self.new_comment = body.into();
}
pub fn show(&mut self, ui: &mut egui::Ui, server: &dyn PlmReview) -> Vec<PlmReviewEvent> {
let waker = repaint_waker(ui.ctx());
self.poll(&waker, server);
self.hits.clear();
let mut events = Vec::new();
let now = crate::recovery::wall_clock() as u64;
match &self.message {
Some(Message::Refused(sentence)) => {
ui.colored_label(ui.visuals().error_fg_color, sentence);
}
Some(Message::Done(warnings)) => {
ui.colored_label(ui.visuals().warn_fg_color, warnings);
}
None => {}
}
if self.busy() {
ui.spinner();
}
let Some(loaded) = self.loaded.clone() else {
return events;
};
match &loaded {
Loaded::Revision(review) => {
ui.heading(format!("{} rev {} — {}", review.number, review.label, review.lifecycle));
self.show_round(ui, server, review.current.as_ref());
if review.current.as_ref().map_or(true, |r| !r.live) && review.lifecycle == "draft" {
match self.lock.as_ref().and_then(|l| l.submit_block(&review.number, &review.label)) {
Some(block) => {
ui.separator();
let why = ui.label(block);
self.hits.insert("submit:blocked".into(), why.rect);
}
None => self.show_submit_form(ui, server, &review.rule, now),
}
}
self.show_history(ui, &review.history);
self.show_comments(ui, server, &review.comments, review.can_comment);
}
Loaded::ChangeOrder(order) => {
ui.heading(format!("{} — {} ({})", order.number, order.title, order.state));
if !order.description.is_empty() {
ui.label(&order.description);
}
for item in &order.items {
let mut text = format!("{} rev {} — {} ({})", item.number, item.label, item.action, item.lifecycle);
if !item.problem.is_empty() {
text.push_str(&format!(" — {}", item.problem));
}
let row = ui.selectable_label(false, text);
self.hits.insert(format!("eco:item:{}", crate::plm::identity::document_key(&item.part_id, &item.revision_id)), row.rect);
if row.double_clicked() {
events.push(PlmReviewEvent::OpenReview { part: item.part_id.clone(), revision: item.revision_id.clone() });
}
}
for problem in &order.problems {
ui.colored_label(ui.visuals().warn_fg_color, problem);
}
self.show_round(ui, server, order.current.as_ref());
let live = order.current.as_ref().is_some_and(|r| r.live);
ui.horizontal(|ui| {
if !live && order.can_edit && matches!(order.state.as_str(), "draft" | "open") {
let note = ui.add(egui::TextEdit::singleline(&mut self.note).hint_text("Note for the reviewers"));
self.hits.insert("eco:note".into(), note.rect);
let submit = ui.button("Submit for review");
self.hits.insert("eco:submit".into(), submit.rect);
if submit.clicked() {
self.submit(server, now);
}
}
if order.can_release {
let release = ui.button(format!("Release through {}", order.number));
self.hits.insert("eco:release".into(), release.rect);
if release.clicked() {
self.release_change_order(server);
}
}
});
self.show_history(ui, &order.history);
self.show_comments(ui, server, &order.comments, order.can_comment);
}
}
events
}
fn show_round(&mut self, ui: &mut egui::Ui, server: &dyn PlmReview, round: Option<&Round>) {
let Some(round) = round else {
ui.label("No review open.");
return;
};
ui.label(describe_round(round));
for reviewer in &round.reviewers {
let verdict = match (reviewer.kind.as_str(), reviewer.verdict.as_str()) {
("group", _) if !reviewer.approved_by.is_empty() => format!("approved by {}", reviewer.approved_by.join(", ")),
(_, "") => "waiting".to_string(),
(_, verdict) => verdict.to_string(),
};
ui.label(format!(" {} ({}): {verdict}", reviewer.name, reviewer.kind));
}
for decision in &round.decisions {
let mut line = format!(" {} {} on {}", decision.name, decision.verdict, date(decision.at));
if decision.current == Some(false) {
line.push_str(" (on an older version)");
}
if !decision.comment.is_empty() {
line.push_str(&format!(": {}", decision.comment));
}
ui.label(line);
}
if round.can_decide {
let comment = ui.add(egui::TextEdit::singleline(&mut self.decision_comment).hint_text("Comment with your decision"));
self.hits.insert("decide:comment".into(), comment.rect);
ui.horizontal(|ui| {
let approve = ui.button("Approve");
self.hits.insert("decide:approve".into(), approve.rect);
let reject = ui.button("Reject");
self.hits.insert("decide:reject".into(), reject.rect);
if approve.clicked() {
self.decide(server, Verdict::Approve);
} else if reject.clicked() {
self.decide(server, Verdict::Reject);
}
});
}
}
fn show_submit_form(&mut self, ui: &mut egui::Ui, server: &dyn PlmReview, rule: &Rule, now: u64) {
ui.separator();
ui.label(if rule.required_approvals == 0 {
"Submit for review. This part's rule needs no approval to release.".to_string()
} else {
format!("Submit for review. This part's rule needs {} approval(s) to release.", rule.required_approvals)
});
let reviewers = ui.add(egui::TextEdit::singleline(&mut self.reviewers).hint_text("More reviewers: ada, group:quality"));
self.hits.insert("submit:reviewers".into(), reviewers.rect);
let due_hint = if rule.due_days > 0 { format!("Due in days (the rule: {})", rule.due_days) } else { "Due in days".to_string() };
let due = ui.add(egui::TextEdit::singleline(&mut self.due_days).hint_text(due_hint));
self.hits.insert("submit:due".into(), due.rect);
let note = ui.add(egui::TextEdit::multiline(&mut self.note).hint_text("Note for the reviewers").desired_rows(2));
self.hits.insert("submit:note".into(), note.rect);
let go = ui.add_enabled(self.action.is_none(), egui::Button::new("Submit for review"));
self.hits.insert("submit:go".into(), go.rect);
if go.clicked() {
self.submit(server, now);
}
}
fn show_history(&mut self, ui: &mut egui::Ui, history: &[Round]) {
if history.is_empty() {
return;
}
ui.collapsing(format!("Earlier rounds ({})", history.len()), |ui| {
for round in history {
ui.label(format!("{} — opened {}", describe_round(round), date(round.opened_at)));
}
});
}
fn show_comments(&mut self, ui: &mut egui::Ui, server: &dyn PlmReview, comments: &[Comment], can_comment: bool) {
ui.separator();
for comment in comments {
let indent = if comment.parent.is_empty() { "" } else { " " };
ui.label(format!("{indent}{} ({}): {}", comment.author, date(comment.at), comment.body));
}
if can_comment {
ui.horizontal(|ui| {
let field = ui.add(egui::TextEdit::singleline(&mut self.new_comment).hint_text("Add a comment"));
self.hits.insert("comment:body".into(), field.rect);
let post = ui.button("Post");
self.hits.insert("comment:post".into(), post.rect);
if post.clicked() {
self.post_comment(server);
}
});
}
}
}
#[derive(Default)]
pub struct ReviewSection {
panel: Option<ReviewPanel>,
hits: HashMap<String, egui::Rect>,
}
impl ReviewSection {
pub fn new() -> Self {
Self::default()
}
pub fn open(&mut self, event: &PlmReviewEvent, server: &dyn PlmReview) {
let subject = Subject::of(event);
match &mut self.panel {
Some(panel) if panel.subject == subject => panel.reload(server),
_ => self.panel = Some(ReviewPanel::new(subject, server)),
}
}
pub fn panel(&self) -> Option<&ReviewPanel> {
self.panel.as_ref()
}
pub fn panel_mut(&mut self) -> Option<&mut ReviewPanel> {
self.panel.as_mut()
}
pub fn close(&mut self) {
self.panel = None;
}
pub fn hits(&self) -> &HashMap<String, egui::Rect> {
&self.hits
}
pub fn busy(&self) -> bool {
self.panel.as_ref().is_some_and(ReviewPanel::busy)
}
pub fn poll(&mut self, server: &dyn PlmReview) {
if let Some(panel) = self.panel.as_mut() {
panel.poll(std::task::Waker::noop(), server);
}
}
pub fn state_json(&self) -> serde_json::Value {
let Some(panel) = &self.panel else { return serde_json::Value::Null };
let subject = match &panel.subject {
Subject::Revision { part, revision } => serde_json::json!({ "kind": "revision", "part": part, "revision": revision }),
Subject::ChangeOrder { eco } => serde_json::json!({ "kind": "eco", "eco": eco }),
};
let round = |r: &Round| serde_json::json!({
"status": r.status, "approvals": r.approvals, "stale": r.stale_approvals, "required": r.required_approvals,
"met": r.met, "live": r.live, "canDecide": r.can_decide,
});
let (lifecycle, current, comments) = match panel.loaded() {
Some(Loaded::Revision(v)) => (Some(v.lifecycle.clone()), v.current.as_ref().map(round), v.comments.len()),
Some(Loaded::ChangeOrder(o)) => (Some(o.state.clone()), o.current.as_ref().map(round), o.comments.len()),
None => (None, None, 0),
};
let message = match panel.message() {
Some(Message::Refused(s)) => serde_json::json!({ "refused": s }),
Some(Message::Done(s)) => serde_json::json!({ "warned": s }),
None => serde_json::Value::Null,
};
serde_json::json!({ "subject": subject, "state": lifecycle, "round": current, "comments": comments, "message": message, "busy": panel.busy() })
}
pub fn take_acted(&mut self) -> bool {
self.panel.as_mut().is_some_and(ReviewPanel::take_acted)
}
pub fn ui(&mut self, ui: &mut egui::Ui, server: &dyn PlmReview) {
self.hits.clear();
let Some(panel) = self.panel.as_mut() else {
ui.label("Nothing open. Submit or review a revision from the lifecycle section, or open the inbox.");
return;
};
let events = panel.show(ui, server);
self.hits.extend(panel.hits().iter().map(|(k, r)| (k.clone(), *r)));
let close = ui.button("Close");
self.hits.insert("close".into(), close.rect);
if close.clicked() {
self.panel = None;
}
for event in events {
self.open(&event, server);
}
}
}
#[derive(Default)]
pub struct InboxBadge {
request: Option<Pending<Inbox>>,
inbox: Option<Inbox>,
asked_at: Option<f64>,
open: bool,
problem: Option<String>,
hits: HashMap<String, egui::Rect>,
}
impl InboxBadge {
pub fn new() -> Self {
Self::default()
}
pub fn count(&self) -> Option<usize> {
self.inbox.as_ref().map(|i| i.count)
}
pub fn inbox(&self) -> Option<&Inbox> {
self.inbox.as_ref()
}
pub fn busy(&self) -> bool {
self.request.is_some() || (self.asked_at.is_none() && self.inbox.is_some())
}
pub fn problem(&self) -> Option<&str> {
self.problem.as_deref()
}
pub fn hits(&self) -> &HashMap<String, egui::Rect> {
&self.hits
}
pub fn state_json(&self) -> String {
let Some(inbox) = &self.inbox else { return "null".into() };
let rows = |items: &[InboxItem]| items.iter().map(|i| serde_json::json!({ "kind": i.kind, "title": i.title, "target": i.target, "revision": i.revision_id, "key": format!("plm:inbox:row:{}:{}", crate::plm::identity::segment(&i.target), crate::plm::identity::segment(&i.revision_id)) })).collect::<Vec<_>>();
serde_json::json!({ "count": inbox.count, "open": self.open, "waitingOnMe": rows(&inbox.waiting_on_me), "submitted": rows(&inbox.submitted), "problem": self.problem }).to_string()
}
pub fn refresh(&mut self) {
self.asked_at = None;
}
pub fn tick(&mut self, now: f64, waker: &Waker, server: &dyn PlmReview) -> f64 {
if let Some(outcome) = self.request.as_mut().and_then(|p| p.poll(waker)) {
self.request = None;
match outcome {
Ok(inbox) => {
self.inbox = Some(inbox);
self.problem = None;
}
Err(sentence) => self.problem = Some(sentence),
}
}
let due = self.asked_at.map_or(true, |at| now - at >= INBOX_POLL_SECS);
if due && self.request.is_none() {
self.asked_at = Some(now);
self.request = Some(Pending::new(server.inbox()));
return self.tick(now, waker, server);
}
self.asked_at.map_or(0.0, |at| (at + INBOX_POLL_SECS - now).max(0.0))
}
pub fn show(&mut self, ui: &mut egui::Ui, server: &dyn PlmReview) -> Vec<PlmReviewEvent> {
let waker = repaint_waker(ui.ctx());
let now = ui.ctx().input(|i| i.time);
let next = self.tick(now, &waker, server);
ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(next.max(1.0)));
self.hits.clear();
let text = match self.count() {
Some(0) | None => "Inbox".to_string(),
Some(n) => format!("Inbox ({n})"),
};
let mut button = egui::Button::new(text);
if self.count().unwrap_or(0) > 0 {
button = button.fill(ui.visuals().selection.bg_fill);
}
let badge = ui.add(button);
let badge = match &self.problem {
Some(problem) => badge.on_hover_text(problem),
None => badge,
};
self.hits.insert("plm:inbox".into(), badge.rect);
if badge.clicked() {
self.open = !self.open;
}
let mut events = Vec::new();
if self.open {
let inbox = self.inbox.clone().unwrap_or_default();
egui::Window::new("Review inbox").id(egui::Id::new("plm_review:inbox:window")).open(&mut self.open).vscroll(true).show(ui.ctx(), |ui| {
for (heading, items) in [("Waiting on you", &inbox.waiting_on_me), ("You submitted", &inbox.submitted)] {
ui.strong(heading);
if items.is_empty() {
ui.label(" nothing");
}
for item in items {
let row = ui.selectable_label(false, item.describe());
self.hits.insert(format!("plm:inbox:row:{}:{}", crate::plm::identity::segment(&item.target), crate::plm::identity::segment(&item.revision_id)), row.rect);
if row.clicked() {
events.extend(item.event());
}
}
}
});
}
if !events.is_empty() {
self.open = false;
}
events
}
}
fn repaint_waker(ctx: &egui::Context) -> Waker {
struct Repaint(egui::Context);
impl std::task::Wake for Repaint {
fn wake(self: std::sync::Arc<Self>) {
self.0.request_repaint();
}
}
Waker::from(std::sync::Arc::new(Repaint(ctx.clone())))
}