Project · Issues and boards: a board, issue cards, a task's panel, labels
KanbanBoard lays columns of cards side by side. A card drags to a place in any column: it rides raised at the pointer, a slot opens where it would land, the other cards glide aside on motion::Flip, and the drop asks the owner through on_move(card, column, index); a drop in its own place asks nothing. Option with an arrow moves the focused card, whose focus the board keys by card so it follows it across columns. A press, Enter or Space opens it. Sprint Board is this board. IssueCard shows an issue's status, name, assignee, title, priority, labels and due day. TaskDetailPanel lays a task out in full: its title edits in place, its status, priority, assignee and due day change where they stand, and its notes and steps follow; each edit hands the whole task on, and a blank title is not taken. LabelManager tends a team's labels: a hue picked from a popover, a name edited in place, a delete, and a field that makes a new one; names stay unique in any case, and a refused name says why. The fill rule changes: min_w_full, taken up in T30d, resolves against a block's outer width in taffy 0.9, so a field in a padded card ran past its padding. A component that fills now takes no width on its root, which blocks and flex columns stretch; FormField and the new roots do. The chart's hue names move to theme::HUE_NAMES, which the event editor and the label manager share. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
9b506e5d54
commit
62382f7e68
24 files changed
+1813
-31
No files matched your search
@@ -0,0 +1,237 @@
|
||||
use ely_gpui_component::{
|
||||
mail::Label,
|
||||
project::{
|
||||
IssueCard, KanbanBoard, KanbanCard, KanbanColumn, LabelManager, Status, Task,
|
||||
TaskDetailPanel,
|
||||
},
|
||||
theme::{ActiveTheme, Radius},
|
||||
};
|
||||
use gpui::{App, IntoElement, ParentElement, SharedString, Styled, Window, div, px};
|
||||
|
||||
use super::{board, quiet, studio_labels, team};
|
||||
use crate::{
|
||||
probe::probe,
|
||||
ui::{change, keep, section, set},
|
||||
};
|
||||
|
||||
/// The board's columns: a status each gathers, and the status a card dropped there takes.
|
||||
const LANES: [(&str, &str, Status, &[Status]); 3] = [
|
||||
(
|
||||
"todo",
|
||||
"Todo",
|
||||
Status::Todo,
|
||||
&[Status::Backlog, Status::Todo],
|
||||
),
|
||||
(
|
||||
"doing",
|
||||
"In progress",
|
||||
Status::InProgress,
|
||||
&[Status::InProgress, Status::InReview],
|
||||
),
|
||||
(
|
||||
"done",
|
||||
"Done",
|
||||
Status::Done,
|
||||
&[Status::Done, Status::Canceled],
|
||||
),
|
||||
];
|
||||
|
||||
pub fn kanban(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
|
||||
let tasks = board(window, cx);
|
||||
let listed = tasks.read(cx).clone();
|
||||
let opened = keep(
|
||||
"project-opened",
|
||||
|| Some(SharedString::from("brief")),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
let columns = LANES.map(|(key, title, _, statuses)| {
|
||||
let cards = listed
|
||||
.iter()
|
||||
.filter(|task| statuses.contains(&task.status))
|
||||
.map(|task| {
|
||||
let face = IssueCard::new(
|
||||
SharedString::from(format!("project-issue-{}", task.key)),
|
||||
task.clone(),
|
||||
);
|
||||
KanbanCard::new(task.key.clone(), face)
|
||||
});
|
||||
KanbanColumn::new(key, title).cards(cards)
|
||||
});
|
||||
let moved = tasks.clone();
|
||||
let board = columns
|
||||
.into_iter()
|
||||
.fold(KanbanBoard::new("project-board"), |board, column| {
|
||||
board.column(column)
|
||||
})
|
||||
.on_move(move |key, column, index, _, cx| {
|
||||
let (_, _, status, statuses) = LANES
|
||||
.into_iter()
|
||||
.find(|(lane, ..)| *lane == column.as_ref())
|
||||
.expect("a lane of the board");
|
||||
change(&moved, cx, |tasks| {
|
||||
let at = tasks
|
||||
.iter()
|
||||
.position(|task| task.key == *key)
|
||||
.expect("a task on the board");
|
||||
let mut task = tasks.remove(at);
|
||||
if !statuses.contains(&task.status) {
|
||||
task.status = status;
|
||||
}
|
||||
let lane: Vec<usize> = (0..tasks.len())
|
||||
.filter(|ix| statuses.contains(&tasks[*ix].status))
|
||||
.collect();
|
||||
let place = lane
|
||||
.get(index)
|
||||
.copied()
|
||||
.unwrap_or_else(|| lane.last().map_or(tasks.len(), |last| last + 1));
|
||||
tasks.insert(place, task);
|
||||
})
|
||||
})
|
||||
.on_open(move |key, _, cx| set(&opened, Some(key.clone()), cx));
|
||||
section(
|
||||
"KanbanBoard / KanbanColumn / KanbanCard · IssueCard · Sprint Board",
|
||||
"Columns of issue cards for a sprint. A card drags to a place in any column while the others glide aside, Option with an arrow moves the focused one, and a press or Enter opens it in the panel below. Sprint Board is this board.",
|
||||
cx,
|
||||
)
|
||||
.child(probe("project-board", div().child(board)))
|
||||
}
|
||||
|
||||
pub fn panel(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
|
||||
let tasks = board(window, cx);
|
||||
let opened = keep(
|
||||
"project-opened",
|
||||
|| Some(SharedString::from("brief")),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
let steps = keep(
|
||||
"project-steps",
|
||||
|| {
|
||||
vec![
|
||||
(SharedString::from("Gather the client's notes"), true),
|
||||
(SharedString::from("Draft the scope"), false),
|
||||
(SharedString::from("Share for comments"), false),
|
||||
]
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
let shown = opened
|
||||
.read(cx)
|
||||
.clone()
|
||||
.and_then(|key| tasks.read(cx).iter().find(|task| task.key == key).cloned());
|
||||
let theme = cx.theme();
|
||||
let body = match shown {
|
||||
Some(task) => {
|
||||
let (edited, closed, ticked, added) =
|
||||
(tasks.clone(), opened.clone(), steps.clone(), steps.clone());
|
||||
TaskDetailPanel::new("project-panel", task, team())
|
||||
.notes("The client wants the brief before the site visit: scope, budget range and the three questions for the survey.")
|
||||
.steps(steps.read(cx).clone())
|
||||
.on_change(move |task: &Task, _, cx| {
|
||||
let task = task.clone();
|
||||
change(&edited, cx, |tasks| {
|
||||
let at = tasks.iter().position(|each| each.key == task.key).expect("a listed task");
|
||||
tasks[at] = task;
|
||||
})
|
||||
})
|
||||
.on_close(move |_, cx| set(&closed, None, cx))
|
||||
.on_step(move |ix, done, _, cx| change(&ticked, cx, |steps| steps[ix].1 = done))
|
||||
.on_add_step(move |words, _, cx| {
|
||||
let words = words.clone();
|
||||
change(&added, cx, |steps| steps.push((words, false)))
|
||||
})
|
||||
.into_any_element()
|
||||
}
|
||||
None => quiet("Open a card on the board to see it here.".into(), cx).into_any_element(),
|
||||
};
|
||||
section(
|
||||
"TaskDetailPanel",
|
||||
"A task in full beside the board: its title edits in place, its properties change where they stand, and its notes and steps follow. Each edit shows on the board and in the list.",
|
||||
cx,
|
||||
)
|
||||
.child(probe(
|
||||
"project-panel",
|
||||
div()
|
||||
.w(px(400.))
|
||||
.p_5()
|
||||
.border_1()
|
||||
.border_color(theme.colors.border)
|
||||
.rounded(theme.radius(Radius::Lg))
|
||||
.child(body),
|
||||
))
|
||||
}
|
||||
|
||||
pub fn labels(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
|
||||
let labels = keep("project-labels", studio_labels, window, cx);
|
||||
let tasks = board(window, cx);
|
||||
let listed = labels.read(cx).clone();
|
||||
let (renamed, recolored, deleted, made) =
|
||||
(labels.clone(), labels.clone(), labels.clone(), labels);
|
||||
let (renamed_tasks, recolored_tasks, deleted_tasks) = (tasks.clone(), tasks.clone(), tasks);
|
||||
let manager = LabelManager::new("project-labels", listed)
|
||||
.on_rename(move |key, name, _, cx| {
|
||||
let name = name.clone();
|
||||
change(&renamed, cx, |labels| rename(labels.iter_mut(), key, &name));
|
||||
change(&renamed_tasks, cx, |tasks| {
|
||||
rename(
|
||||
tasks.iter_mut().flat_map(|task| task.labels.iter_mut()),
|
||||
key,
|
||||
&name,
|
||||
)
|
||||
});
|
||||
})
|
||||
.on_recolor(move |key, hue, _, cx| {
|
||||
change(&recolored, cx, |labels| {
|
||||
recolor(labels.iter_mut(), key, hue)
|
||||
});
|
||||
change(&recolored_tasks, cx, |tasks| {
|
||||
recolor(
|
||||
tasks.iter_mut().flat_map(|task| task.labels.iter_mut()),
|
||||
key,
|
||||
hue,
|
||||
)
|
||||
});
|
||||
})
|
||||
.on_delete(move |key, _, cx| {
|
||||
change(&deleted, cx, |labels| {
|
||||
labels.retain(|label| label.key != *key)
|
||||
});
|
||||
change(&deleted_tasks, cx, |tasks| {
|
||||
tasks
|
||||
.iter_mut()
|
||||
.for_each(|task| task.labels.retain(|label| label.key != *key))
|
||||
});
|
||||
})
|
||||
.on_create(move |name, _, cx| {
|
||||
let name = name.clone();
|
||||
change(&made, cx, |labels| {
|
||||
let key = SharedString::from(format!("label-{}", labels.len() + 1));
|
||||
let hue = labels.len() % 8;
|
||||
labels.push(Label::new(key, name, hue));
|
||||
})
|
||||
});
|
||||
section(
|
||||
"LabelManager",
|
||||
"A team's labels: a press on a hue picks another, a name edits in place, and the field at the end makes a new one. Names stay unique whatever their case, and each change shows on the cards.",
|
||||
cx,
|
||||
)
|
||||
.child(probe("project-label-manager", div().w(px(360.)).child(manager)))
|
||||
}
|
||||
|
||||
fn rename<'a>(
|
||||
labels: impl Iterator<Item = &'a mut Label>,
|
||||
key: &SharedString,
|
||||
name: &SharedString,
|
||||
) {
|
||||
labels
|
||||
.filter(|label| label.key == *key)
|
||||
.for_each(|label| label.name = name.clone());
|
||||
}
|
||||
|
||||
fn recolor<'a>(labels: impl Iterator<Item = &'a mut Label>, key: &SharedString, hue: usize) {
|
||||
labels
|
||||
.filter(|label| label.key == *key)
|
||||
.for_each(|label| label.hue = hue);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
use ely_gpui_component::{
|
||||
mail::Label,
|
||||
project::{IssueId, Person, Priority, Status, Task},
|
||||
theme::{ActiveTheme, TextSize},
|
||||
};
|
||||
@@ -7,6 +8,7 @@ use gpui::{
|
||||
};
|
||||
use jiff::{Timestamp, ToSpan, civil::Date, tz::TimeZone};
|
||||
|
||||
mod boards;
|
||||
mod tasks;
|
||||
|
||||
use super::Page;
|
||||
@@ -31,6 +33,14 @@ const SCRIPT: &[Step] = &[
|
||||
Step::UpAt("project-tasks", 26.0, 22.0),
|
||||
Step::Wait(200),
|
||||
Step::Shot("task-ticked"),
|
||||
Step::DownAt("project-board", 80.0, 80.0),
|
||||
Step::DragTo("project-board", 200.0, 90.0),
|
||||
Step::DragTo("project-board", 360.0, 110.0),
|
||||
Step::Wait(250),
|
||||
Step::Shot("board-drag"),
|
||||
Step::UpAt("project-board", 360.0, 110.0),
|
||||
Step::Wait(300),
|
||||
Step::Shot("board-dropped"),
|
||||
];
|
||||
|
||||
/// What a demo says it did, under it.
|
||||
@@ -57,6 +67,24 @@ fn team() -> Vec<Person> {
|
||||
]
|
||||
}
|
||||
|
||||
/// The studio's labels.
|
||||
fn studio_labels() -> Vec<Label> {
|
||||
vec![
|
||||
Label::new("client", "Client", 0),
|
||||
Label::new("site", "Site", 5),
|
||||
Label::new("render", "Render", 4),
|
||||
Label::new("budget", "Budget", 2),
|
||||
]
|
||||
}
|
||||
|
||||
/// One of the studio's labels, by key.
|
||||
fn tag(key: &str) -> Label {
|
||||
studio_labels()
|
||||
.into_iter()
|
||||
.find(|label| label.key.as_ref() == key)
|
||||
.expect("one of the studio's labels")
|
||||
}
|
||||
|
||||
/// A studio's open work around today: late, due soon, in review, done.
|
||||
fn work(today: Date) -> Vec<Task> {
|
||||
let day = |offset: i64| today.checked_add(offset.days()).expect("a day nearby");
|
||||
@@ -72,18 +100,22 @@ fn work(today: Date) -> Vec<Task> {
|
||||
.status(Status::InProgress)
|
||||
.priority(Priority::Urgent)
|
||||
.due(day(-1))
|
||||
.assignee(who("ana")),
|
||||
.assignee(who("ana"))
|
||||
.label(tag("client")),
|
||||
Task::new("samples", "Order plaster samples")
|
||||
.issue(IssueId::new("ARC", 44))
|
||||
.priority(Priority::High)
|
||||
.due(day(0))
|
||||
.assignee(who("ben")),
|
||||
.assignee(who("ben"))
|
||||
.label(tag("budget")),
|
||||
Task::new("model", "Photograph the site model")
|
||||
.issue(IssueId::new("ARC", 38))
|
||||
.status(Status::InReview)
|
||||
.priority(Priority::Medium)
|
||||
.due(day(3))
|
||||
.assignee(who("chloe")),
|
||||
.assignee(who("chloe"))
|
||||
.label(tag("site"))
|
||||
.label(tag("render")),
|
||||
Task::new("budget", "Revise the lighting budget")
|
||||
.issue(IssueId::new("ARC", 47))
|
||||
.priority(Priority::Low)
|
||||
@@ -93,7 +125,8 @@ fn work(today: Date) -> Vec<Task> {
|
||||
.status(Status::Done)
|
||||
.priority(Priority::Medium)
|
||||
.due(day(-4))
|
||||
.assignee(who("dev")),
|
||||
.assignee(who("dev"))
|
||||
.label(tag("site")),
|
||||
Task::new("archive", "Archive last year's renders")
|
||||
.issue(IssueId::new("ARC", 17))
|
||||
.status(Status::Backlog),
|
||||
@@ -111,5 +144,8 @@ fn render(window: &mut Window, cx: &mut App) -> AnyElement {
|
||||
.child(tasks::fields(window, cx))
|
||||
.child(tasks::list(window, cx))
|
||||
.child(tasks::subtasks(window, cx))
|
||||
.child(boards::kanban(window, cx))
|
||||
.child(boards::panel(window, cx))
|
||||
.child(boards::labels(window, cx))
|
||||
.into_any_element()
|
||||
}
|
||||
Reference in new issue
Block a user