Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 19 additions & 2 deletions crates/dashboard-app/src/captures.rs
Original file line number Diff line number Diff line change
Expand Up @@ -518,13 +518,13 @@ fn projects(cx: &mut HeadlessAppContext, out: &Path, suffix: &str) {
use dashboard_core::write::WriteError;
let name = |state: &str| format!("projects-{state}{suffix}");
let folder = Folder::new();
let open_dialog = |cx: &mut HeadlessAppContext, scopes: &[&str], folder: &Folder| {
let open_dialog_sized = |cx: &mut HeadlessAppContext, scopes: &[&str], folder: &Folder, height: f32| {
let backend = FakeProjects::new(scopes);
let (handle, view) = open_with(
cx,
demo::checkouts(),
1100.,
760.,
height,
Options {
write: backend.clone(),
config_path: Some(folder.config_dir().join("config.toml")),
Expand All @@ -548,6 +548,8 @@ fn projects(cx: &mut HeadlessAppContext, out: &Path, suffix: &str) {
settle(cx);
(handle, manage, backend)
};
let open_dialog =
|cx: &mut HeadlessAppContext, scopes: &[&str], folder: &Folder| open_dialog_sized(cx, scopes, folder, 760.);
let click = |cx: &mut HeadlessAppContext, handle: AnyWindowHandle, id: &str| {
let id = gpui_kit::SharedString::from(id.to_owned());
cx.update_window(handle, |_, window, cx| window.click(id, cx)).unwrap();
Expand Down Expand Up @@ -576,6 +578,8 @@ fn projects(cx: &mut HeadlessAppContext, out: &Path, suffix: &str) {
click(cx, handle, "project-row-2");
click(cx, handle, "project-priority-0");
save(cx, handle, out, &name("edit-priority"));
click(cx, handle, "project-risk-8");
save(cx, handle, out, &name("edit-risk"));
let fork = folder.checkout("fork", "https://github.com/someone/hs-buddy.git");
set(cx, handle, &manage, "folder", &fork.to_string_lossy());
// The button can sit below the fold at 150%; press it directly.
Expand Down Expand Up @@ -604,6 +608,19 @@ fn projects(cx: &mut HeadlessAppContext, out: &Path, suffix: &str) {
let (read_only, _, _) = open_dialog(cx, &["read", "write:work"], &folder);
save(cx, read_only, out, &name("read-only"));

// A risk level changed elsewhere, in a window tall enough to show the whole conflict panel. gh-x has a checkout,
// so no Link checkout box takes the room.
let (tall, _, tall_backend) = open_dialog_sized(cx, &["read", "write:projects"], &folder, 1000.);
click(cx, tall, "project-row-3");
click(cx, tall, "project-risk-8");
tall_backend.fail_next(WriteError::StaleVersion, |rows| {
let row = rows.iter_mut().find(|row| row["slug"] == "gh-x").unwrap();
row["riskLevel"] = serde_json::json!(6);
row["version"] = serde_json::json!(5);
});
key(cx, tall, &["ctrl-enter"]);
save(cx, tall, out, &name("risk-conflict"));

// Frames for the recording, at 100% only: keyboard alone, adding a project and setting its priority.
if !suffix.is_empty() {
return;
Expand Down
63 changes: 60 additions & 3 deletions crates/dashboard-app/src/manage/projects.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
//! The Projects dialog (#34): every pmd project, sorted by priority, with an editor that tracks a new repo, sets
//! P0–P5 or no tier, edits goal, notes and requires, untracks or re-tracks a project, and links a local checkout so
//! P0–P5 or no tier and a risk level from 0 to 10, edits goal, notes and requires, untracks or re-tracks a project, and links a local checkout so
//! the dashboard shows it.
//!
//! pmd's projects table is the source of truth for priority (option C of project-manager#50), so there is no reset to
Expand Down Expand Up @@ -53,7 +53,9 @@ fn upsert(projects: &mut ProjectsDialog, project: Project) {

/// The priority choices: P0 to P5, then no tier (empty).
const PRIORITY_CHOICES: [&str; 7] = ["0", "1", "2", "3", "4", "5", ""];
/// The fields typed as text; priority and tracked are chosen.
/// The risk level choices, 0 to 10. There is no "unset": a row without one keeps it unset until one is picked.
const RISK_CHOICES: [&str; 11] = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10"];
/// The fields typed as text; priority, risk level and tracked are chosen.
const TEXT_FIELDS: [&str; 5] = ["repo", "slug", "goal", "notes", "requires"];

pub struct ProjectsDialog {
Expand Down Expand Up @@ -81,6 +83,7 @@ pub struct ProjectEditor {
form: Form,
inputs: Vec<(&'static str, Entity<InputState>)>,
priorities: Vec<Control>,
risks: Vec<Control>,
folder: Entity<InputState>,
link: Control,
/// A checkout link's result: the message, and whether it is an error.
Expand Down Expand Up @@ -394,6 +397,7 @@ impl Manage {
form,
inputs,
priorities: controls(PRIORITY_CHOICES.len(), cx),
risks: controls(RISK_CHOICES.len(), cx),
folder,
link: control(cx),
link_result: None,
Expand Down Expand Up @@ -458,6 +462,19 @@ impl Manage {
cx.notify();
}

fn set_project_risk(&mut self, risk: &str, cx: &mut Context<Self>) {
let writable = self.projects_writable();
let Some(editor) = self.open_projects().and_then(|(_, projects)| projects.editor.as_mut()) else {
return;
};
if editor.locked(writable) {
return;
}
// Like priority, picking only edits the form.
editor.form.set("riskLevel", risk);
cx.notify();
}

/// Ctrl+Enter or Save: checks the project, then one `PUT` of all of it.
pub fn save_project(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.put_project(None, window, cx);
Expand Down Expand Up @@ -834,6 +851,7 @@ impl Manage {
.child(div().w(rems(10.)).flex_none().child("Slug"))
.child(div().flex_1().min_w_0().child("Repository"))
.child(div().w(rems(5.)).flex_none().child("Priority"))
.child(div().w(rems(4.)).flex_none().child("Risk"))
.child(div().w(rems(6.)).flex_none().child("Tracked"))
.child(div().w(rems(6.)).flex_none().child("Checkout")),
);
Expand All @@ -856,10 +874,11 @@ impl Manage {
.role(Role::ListBoxOption)
.aria_selected(selected)
.aria_label(format!(
"{}, {}, {}, {}, checkout {}",
"{}, {}, {}, risk {}, {}, checkout {}",
project.slug,
project.repo,
project.priority_label(),
project.risk_label(),
if project.tracked { "tracked" } else { "untracked" },
linked
))
Expand All @@ -882,6 +901,7 @@ impl Manage {
.child(div().w(rems(10.)).flex_none().truncate().child(project.slug.clone()))
.child(div().flex_1().min_w_0().truncate().child(project.repo.clone()))
.child(div().w(rems(5.)).flex_none().child(project.priority_label()))
.child(div().w(rems(4.)).flex_none().child(project.risk_label()))
.child(
div()
.w(rems(6.))
Expand Down Expand Up @@ -1071,6 +1091,43 @@ impl Manage {
}),
)
.into_any_element()
} else if field == "riskLevel" {
v_flex()
.gap_1()
.child(
h_flex()
.gap_1()
.flex_wrap()
.children(RISK_CHOICES.iter().zip(&editor.risks).map(|(risk, control)| {
let risk = *risk;
chip(
format!("project-risk-{risk}"),
risk.to_owned(),
&control.focus,
form.value("riskLevel") == risk,
locked,
move |manage, _, cx| manage.set_project_risk(risk, cx),
cx,
)
})),
)
.when(form.value("riskLevel").is_empty(), |this| {
let text = if creating {
"Not set: pmd starts a new project at 5."
} else {
"Not set in pmd. Pick one to set it."
};
this.child(
div()
.id("project-risk-unset")
.test_support()
.aria_label(text)
.text_xs()
.text_color(theme.muted_foreground)
.child(text),
)
})
.into_any_element()
} else if field == "tracked" {
div()
.id("project-tracked")
Expand Down
139 changes: 126 additions & 13 deletions crates/dashboard-app/src/projects_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,17 +21,29 @@ fn project(slug: &str, priority: Option<u8>, tracked: bool, goal: &str) -> Value
"requires": [], "tracked": tracked, "source": "portfolio", "version": 2})
}

/// A believable portfolio: tiers P0 to P4, one without a tier, one untracked P5.
/// A believable portfolio: tiers P0 to P4, one without a tier, one untracked P5, each with a risk level.
pub(crate) fn sample_projects() -> Vec<Value> {
vec![
project("set-it-free-loop", Some(1), true, "Ship the pause control"),
project("gh-x", Some(3), true, "Maintenance: keep healthy, no new initiatives."),
project("chief", None, true, "Run the fleet"),
project("project-dashboard", Some(0), true, "Manage pmd from the dashboard"),
project("hs-buddy", Some(2), true, "Sign the Windows installer"),
project("weather-cli", Some(5), false, "Dormant"),
project("tickdown", Some(4), false, "Paused for now"),
[
(project("set-it-free-loop", Some(1), true, "Ship the pause control"), 6),
(
project("gh-x", Some(3), true, "Maintenance: keep healthy, no new initiatives."),
4,
),
(project("chief", None, true, "Run the fleet"), 9),
(
project("project-dashboard", Some(0), true, "Manage pmd from the dashboard"),
3,
),
(project("hs-buddy", Some(2), true, "Sign the Windows installer"), 7),
(project("weather-cli", Some(5), false, "Dormant"), 2),
(project("tickdown", Some(4), false, "Paused for now"), 2),
]
.into_iter()
.map(|(mut row, risk)| {
row["riskLevel"] = json!(risk);
row
})
.collect()
}

type Change = Box<dyn FnOnce(&mut Vec<Value>) + Send>;
Expand Down Expand Up @@ -113,6 +125,10 @@ impl WriteBackend for FakeProjects {
row["slug"] = json!(slug);
row["source"] = json!("api");
row["version"] = json!(1);
// pmd starts a new project at risk 5 unless the body sets one.
if row.get("riskLevel").is_none() {
row["riskLevel"] = json!(5);
}
rows.push(row.clone());
Ok(row)
}
Expand Down Expand Up @@ -299,15 +315,17 @@ async fn the_list_sorts_by_priority_hides_untracked_and_shows_which_have_a_check
);
assert_eq!(
text_of(cx, handle, "project-row-3"),
"gh-x, hemsoft-dev/gh-x, P3, tracked, checkout Linked"
"gh-x, hemsoft-dev/gh-x, P3, risk 4/10, tracked, checkout Linked"
);
assert_eq!(
text_of(cx, handle, "project-row-4"),
"chief, hemsoft-dev/chief, —, tracked, checkout —"
"chief, hemsoft-dev/chief, —, risk 9/10, tracked, checkout —"
);
click(cx, handle, "projects-show-untracked");
assert_eq!(visible(cx, &manage).len(), 7);
assert!(text_of(cx, handle, "project-row-4").starts_with("tickdown, hemsoft-dev/tickdown, P4, untracked"));
assert!(
text_of(cx, handle, "project-row-4").starts_with("tickdown, hemsoft-dev/tickdown, P4, risk 2/10, untracked")
);
set_input(cx, handle, &manage, "filter", "PAUSE");
assert_eq!(
visible(cx, &manage),
Expand Down Expand Up @@ -353,7 +371,7 @@ async fn a_priority_pick_writes_only_on_save_and_the_table_hears_of_it(cx: &mut
assert_eq!(
writes[0].body,
json!({"repo": "hemsoft-dev/gh-x", "priority": 0, "goal": "Maintenance: keep healthy, no new initiatives.",
"notes": "", "requires": [], "tracked": true})
"notes": "", "requires": [], "tracked": true, "riskLevel": 4})
);
assert_eq!(wrote.get(), 1, "the table reloads its priorities");
// No tier is null.
Expand Down Expand Up @@ -479,6 +497,101 @@ async fn changed_elsewhere_shows_both_values_and_reapplies_on_the_new_version(cx
assert_eq!(backend.row("gh-x")["priority"], json!(1));
}

#[gpui_kit::test]
async fn a_risk_level_pick_writes_only_on_save_and_the_list_shows_it(cx: &mut TestAppContext) {
let folder = Folder::new();
let backend = FakeProjects::new(&["read", "write:projects"]);
let (handle, view, manage) = open_with(cx, backend.clone(), &folder);
open_dialog(cx, handle, &view).await;
edit(cx, handle, &manage, "gh-x");
assert_eq!(form_value(cx, &manage, "riskLevel"), "4");
assert!(!exists(cx, handle, "project-risk-unset"));
click(cx, handle, "project-risk-10");
assert_eq!(form_value(cx, &manage, "riskLevel"), "10");
assert!(
backend.writes.lock().unwrap().is_empty(),
"picking alone writes nothing"
);
press(cx, handle, "ctrl-enter");
settle(cx, handle, &view).await;
let writes = backend.writes.lock().unwrap().clone();
assert_eq!(writes.len(), 1);
assert_eq!(writes[0].precondition, Precondition::IfMatch(2));
assert_eq!(writes[0].body["riskLevel"], json!(10));
assert_eq!(
writes[0].body["priority"],
json!(3),
"the rest of the row goes as stored"
);
assert_eq!(backend.row("gh-x")["riskLevel"], json!(10));
click(cx, handle, "projects-back");
let ix = visible(cx, &manage).iter().position(|s| s == "gh-x").unwrap();
assert_eq!(
text_of(cx, handle, &format!("project-row-{ix}")),
"gh-x, hemsoft-dev/gh-x, P3, risk 10/10, tracked, checkout Linked"
);
}

#[gpui_kit::test]
async fn a_risk_level_changed_elsewhere_shows_both_values_and_reapplies(cx: &mut TestAppContext) {
let folder = Folder::new();
let backend = FakeProjects::new(&["read", "write:projects"]);
let (handle, view, manage) = open_with(cx, backend.clone(), &folder);
open_dialog(cx, handle, &view).await;
edit(cx, handle, &manage, "gh-x");
click(cx, handle, "project-risk-8");
backend.fail_next(WriteError::StaleVersion, |rows| {
let row = rows.iter_mut().find(|row| row["slug"] == "gh-x").unwrap();
row["riskLevel"] = json!(6);
row["version"] = json!(6);
});
click(cx, handle, "project-save");
settle(cx, handle, &view).await;
frame(cx, handle);
assert_eq!(text_of(cx, handle, "conflict-riskLevel"), "Risk level: yours 8, now 6");
assert!(!exists(cx, handle, "conflict-priority"), "only what differs");
click(cx, handle, "conflict-reapply");
settle(cx, handle, &view).await;
assert_eq!(backend.writes.lock().unwrap()[1].precondition, Precondition::IfMatch(6));
assert_eq!(backend.row("gh-x")["riskLevel"], json!(8));
}

#[gpui_kit::test]
async fn a_project_without_a_risk_level_saves_without_one(cx: &mut TestAppContext) {
let folder = Folder::new();
let backend = FakeProjects::new(&["read", "write:projects"]);
// As an older pmd lists it: no riskLevel at all.
for row in backend.rows.lock().unwrap().iter_mut() {
row.as_object_mut().unwrap().remove("riskLevel");
}
let (handle, view, manage) = open_with(cx, backend.clone(), &folder);
open_dialog(cx, handle, &view).await;
assert_eq!(
text_of(cx, handle, "project-row-3"),
"gh-x, hemsoft-dev/gh-x, P3, risk —, tracked, checkout Linked"
);
edit(cx, handle, &manage, "gh-x");
assert_eq!(form_value(cx, &manage, "riskLevel"), "");
assert_eq!(
text_of(cx, handle, "project-risk-unset"),
"Not set in pmd. Pick one to set it."
);
click(cx, handle, "project-priority-1");
press(cx, handle, "ctrl-enter");
settle(cx, handle, &view).await;
let writes = backend.writes.lock().unwrap().clone();
assert_eq!(writes.len(), 1);
assert_eq!(writes[0].body["priority"], json!(1));
assert!(writes[0].body.get("riskLevel").is_none(), "no bogus risk level is sent");
// A new project leaves it to pmd too.
click(cx, handle, "projects-back");
click(cx, handle, "projects-add");
assert_eq!(
text_of(cx, handle, "project-risk-unset"),
"Not set: pmd starts a new project at 5."
);
}

fn config_text(folder: &Folder) -> String {
std::fs::read_to_string(folder.config_dir().join("config.toml")).unwrap()
}
Expand Down
Loading
Loading