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>
280 lines
9.6 KiB
Rust
280 lines
9.6 KiB
Rust
use std::rc::Rc;
|
|
|
|
use gpui::{
|
|
AnyElement, App, ElementId, FontWeight, IntoElement, ParentElement, RenderOnce, SharedString,
|
|
Styled, Window, div, prelude::*,
|
|
};
|
|
|
|
use super::{
|
|
card::label_chip,
|
|
marks::{AssigneePicker, IssueIdBadge, StatusSelect},
|
|
work::{Person, Priority, Task},
|
|
};
|
|
use crate::{
|
|
buttons::{ButtonVariant, IconButton},
|
|
documents::Checklist,
|
|
forms::{Choice, DatePicker, InlineEdit, OnValue, Run, Select},
|
|
primitives::{Icon, IconName},
|
|
theme::{ActiveTheme, ControlSize, IconSize, TextSize},
|
|
};
|
|
|
|
type OnTask = Rc<dyn Fn(&Task, &mut Window, &mut App)>;
|
|
type OnStep = Rc<dyn Fn(usize, bool, &mut Window, &mut App)>;
|
|
type Edit = Rc<dyn Fn(&dyn Fn(&mut Task), &mut Window, &mut App)>;
|
|
|
|
/// A task laid out in full: its status and issue with a way to close, its title to edit in place, its status, priority, assignee, due day and labels, its notes, and its steps. Each edit hands the whole task on.
|
|
#[derive(IntoElement)]
|
|
pub struct TaskDetailPanel {
|
|
id: ElementId,
|
|
task: Task,
|
|
people: Vec<Person>,
|
|
notes: Option<SharedString>,
|
|
steps: Vec<(SharedString, bool)>,
|
|
on_change: Option<OnTask>,
|
|
on_close: Option<Run>,
|
|
on_step: Option<OnStep>,
|
|
on_add_step: Option<OnValue>,
|
|
}
|
|
|
|
impl TaskDetailPanel {
|
|
/// `people` are those it can go to.
|
|
pub fn new(
|
|
id: impl Into<ElementId>,
|
|
task: Task,
|
|
people: impl IntoIterator<Item = Person>,
|
|
) -> Self {
|
|
Self {
|
|
id: id.into(),
|
|
task,
|
|
people: people.into_iter().collect(),
|
|
notes: None,
|
|
steps: Vec::new(),
|
|
on_change: None,
|
|
on_close: None,
|
|
on_step: None,
|
|
on_add_step: None,
|
|
}
|
|
}
|
|
|
|
pub fn notes(mut self, notes: impl Into<SharedString>) -> Self {
|
|
self.notes = Some(notes.into());
|
|
self
|
|
}
|
|
|
|
/// Its steps, each done or not.
|
|
pub fn steps(
|
|
mut self,
|
|
steps: impl IntoIterator<Item = (impl Into<SharedString>, bool)>,
|
|
) -> Self {
|
|
self.steps = steps
|
|
.into_iter()
|
|
.map(|(step, done)| (step.into(), done))
|
|
.collect();
|
|
self
|
|
}
|
|
|
|
/// Gets the task after each edit.
|
|
pub fn on_change(mut self, handler: impl Fn(&Task, &mut Window, &mut App) + 'static) -> Self {
|
|
self.on_change = Some(Rc::new(handler));
|
|
self
|
|
}
|
|
|
|
pub fn on_close(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
|
|
self.on_close = Some(Rc::new(handler));
|
|
self
|
|
}
|
|
|
|
/// Gets a step's place and whether it is now done.
|
|
pub fn on_step(
|
|
mut self,
|
|
handler: impl Fn(usize, bool, &mut Window, &mut App) + 'static,
|
|
) -> Self {
|
|
self.on_step = Some(Rc::new(handler));
|
|
self
|
|
}
|
|
|
|
/// A field under the steps; gets a new step's words.
|
|
pub fn on_add_step(
|
|
mut self,
|
|
handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
|
|
) -> Self {
|
|
self.on_add_step = Some(Rc::new(handler));
|
|
self
|
|
}
|
|
}
|
|
|
|
/// A property's name over a fixed column and its control, which drops below when narrow.
|
|
fn property(name: &'static str, control: AnyElement, cx: &App) -> impl IntoElement {
|
|
let theme = cx.theme();
|
|
div()
|
|
.flex()
|
|
.flex_wrap()
|
|
.items_center()
|
|
.gap_x_3()
|
|
.gap_y_1()
|
|
.child(
|
|
div()
|
|
.w(theme.label_width())
|
|
.flex_none()
|
|
.text_size(theme.text_size(TextSize::Sm))
|
|
.text_color(theme.colors.fg_muted)
|
|
.child(name),
|
|
)
|
|
.child(div().flex_1().min_w(theme.label_width()).child(control))
|
|
}
|
|
|
|
impl RenderOnce for TaskDetailPanel {
|
|
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
|
let id = self.id.clone();
|
|
let task = self.task;
|
|
let edit: Edit = {
|
|
let (task, on_change, id) = (task.clone(), self.on_change, id.clone());
|
|
Rc::new(move |apply, window, cx| {
|
|
let mut next = task.clone();
|
|
apply(&mut next);
|
|
log::info!("task panel {id:?}: {} changed", next.key);
|
|
if let Some(on_change) = &on_change {
|
|
on_change(&next, window, cx);
|
|
}
|
|
})
|
|
};
|
|
let theme = cx.theme();
|
|
let colors = &theme.colors;
|
|
let head = div()
|
|
.flex()
|
|
.items_center()
|
|
.justify_between()
|
|
.gap_2()
|
|
.child(
|
|
div()
|
|
.flex()
|
|
.items_center()
|
|
.gap_2()
|
|
.child(
|
|
Icon::new(task.status.icon())
|
|
.size(IconSize::Sm)
|
|
.color(colors.fg_muted),
|
|
)
|
|
.children(task.issue.clone().map(IssueIdBadge::new)),
|
|
)
|
|
.children(self.on_close.map(|close| {
|
|
IconButton::new((id.clone(), "close"), IconName::X)
|
|
.variant(ButtonVariant::Ghost)
|
|
.size(ControlSize::Sm)
|
|
.tooltip("Close")
|
|
.on_click(move |_, window, cx| close(window, cx))
|
|
}));
|
|
let title = {
|
|
let edit = edit.clone();
|
|
InlineEdit::new((id.clone(), "title"), task.title.clone()).on_commit(
|
|
move |title, window, cx| {
|
|
let title = SharedString::from(title.trim().to_string());
|
|
if title.is_empty() {
|
|
log::info!("task panel: a blank title is not taken");
|
|
return;
|
|
}
|
|
edit(&move |task| task.title = title.clone(), window, cx)
|
|
},
|
|
)
|
|
};
|
|
let status = {
|
|
let edit = edit.clone();
|
|
StatusSelect::new((id.clone(), "status"), task.status)
|
|
.size(ControlSize::Sm)
|
|
.on_change(move |status, window, cx| {
|
|
edit(&move |task| task.status = status, window, cx)
|
|
})
|
|
};
|
|
let priority = {
|
|
let edit = edit.clone();
|
|
let choices =
|
|
Priority::ALL.map(|priority| Choice::new(priority.key(), priority.words()));
|
|
Select::new((id.clone(), "priority"), choices)
|
|
.selected(task.priority.key())
|
|
.size(ControlSize::Sm)
|
|
.on_change(move |key: &SharedString, window, cx| {
|
|
let priority = Priority::of_key(key);
|
|
edit(&move |task| task.priority = priority, window, cx)
|
|
})
|
|
};
|
|
let assignee = {
|
|
let edit = edit.clone();
|
|
let picker = AssigneePicker::new((id.clone(), "assignee"), self.people)
|
|
.size(ControlSize::Sm)
|
|
.on_change(move |person, window, cx| {
|
|
let person = person.cloned();
|
|
edit(&move |task| task.assignee = person.clone(), window, cx)
|
|
});
|
|
match &task.assignee {
|
|
Some(person) => picker.assignee(person.key.clone()),
|
|
None => picker,
|
|
}
|
|
};
|
|
let due = {
|
|
let edit = edit.clone();
|
|
DatePicker::new((id.clone(), "due"), task.due)
|
|
.size(ControlSize::Sm)
|
|
.on_change(move |day, window, cx| {
|
|
edit(&move |task| task.due = Some(day), window, cx)
|
|
})
|
|
};
|
|
let labels = match task.labels.is_empty() {
|
|
true => div()
|
|
.text_size(theme.text_size(TextSize::Sm))
|
|
.text_color(colors.fg_subtle)
|
|
.child("None"),
|
|
false => div()
|
|
.flex()
|
|
.flex_wrap()
|
|
.gap_x_3()
|
|
.gap_y_1()
|
|
.children(task.labels.iter().map(|label| label_chip(label, cx))),
|
|
};
|
|
let steps = (!self.steps.is_empty() || self.on_add_step.is_some()).then(|| {
|
|
let steps = Checklist::new((id.clone(), "steps"), "Subtasks", self.steps);
|
|
let steps = match self.on_step {
|
|
Some(on_step) => {
|
|
steps.on_toggle(move |ix, done, window, cx| on_step(ix, done, window, cx))
|
|
}
|
|
None => steps,
|
|
};
|
|
match self.on_add_step {
|
|
Some(on_add) => steps.on_add("Add a subtask", move |words, window, cx| {
|
|
on_add(words, window, cx)
|
|
}),
|
|
None => steps,
|
|
}
|
|
});
|
|
div()
|
|
.debug_selector(|| "task-panel".into())
|
|
.flex()
|
|
.flex_col()
|
|
.gap_5()
|
|
.child(head)
|
|
.child(
|
|
div()
|
|
.text_size(theme.text_size(TextSize::Lg))
|
|
.font_weight(FontWeight::SEMIBOLD)
|
|
.child(title),
|
|
)
|
|
.child(
|
|
div()
|
|
.flex()
|
|
.flex_col()
|
|
.gap_2()
|
|
.child(property("Status", status.into_any_element(), cx))
|
|
.child(property("Priority", priority.into_any_element(), cx))
|
|
.child(property("Assignee", assignee.into_any_element(), cx))
|
|
.child(property("Due", due.into_any_element(), cx))
|
|
.child(property("Labels", labels.into_any_element(), cx)),
|
|
)
|
|
.children(self.notes.map(|notes| {
|
|
div()
|
|
.text_size(theme.text_size(TextSize::Sm))
|
|
.text_color(colors.fg)
|
|
.child(notes)
|
|
}))
|
|
.children(steps)
|
|
}
|
|
}
|