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,279 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user