commit def222fd39720544427000bf6ea72c0ddd6241cc
parent 6c05603f522ff84f681f92b7c6b0b4c724542f2d
Author: Jack Mordaunt <jackmordaunt@gmail.com>
Date: Tue, 3 Nov 2020 17:41:55 +0800
feat: move tickets between stages
Diffstat:
| M | cmd/kanban/main.go | | | 182 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------- |
| M | icons/icons.go | | | 5 | +++++ |
| M | kanban.go | | | 75 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------- |
3 files changed, 216 insertions(+), 46 deletions(-)
diff --git a/cmd/kanban/main.go b/cmd/kanban/main.go
@@ -6,6 +6,7 @@ import (
"image/color"
"log"
"os"
+ "unsafe"
"git.sr.ht/~jackmordaunt/kanban"
@@ -31,7 +32,22 @@ func main() {
ui := &UI{
Window: w,
Th: th,
- Engine: &kanban.Engine{},
+ Engine: &kanban.Engine{
+ Stages: []kanban.Stage{
+ {
+ Name: "Todo",
+ },
+ {
+ Name: "In Progress",
+ },
+ {
+ Name: "Testing",
+ },
+ {
+ Name: "Done",
+ },
+ },
+ },
// TODO: render dynamically from storage.
Panels: []Panel{
{
@@ -75,12 +91,12 @@ type (
// this object.
type UI struct {
*app.Window
- Engine *kanban.Engine
- Th *material.Theme
- Panels []Panel
- Tickets []Ticket
- Modal layout.Widget
- TicketForm TicketForm
+ Engine *kanban.Engine
+ Th *material.Theme
+ Panels []Panel
+ TicketStates Map
+ Modal layout.Widget
+ TicketForm TicketForm
}
func (ui *UI) Loop() error {
@@ -111,6 +127,37 @@ func (ui *UI) Update(gtx C) {
}
}
}
+ for _, state := range ui.TicketStates.List() {
+ state := (*Ticket)(state)
+ if state.NextButton.Clicked() {
+ if err := func() error {
+ next, err := ui.Engine.NextStage(state.Stage)
+ if err != nil {
+ return fmt.Errorf("getting next stage: %w", err)
+ }
+ if err := ui.Engine.Move(next, state.Ticket.ID); err != nil {
+ return fmt.Errorf("moving ticket: %s", err)
+ }
+ return nil
+ }(); err != nil {
+ fmt.Printf("error: %s\n", err)
+ }
+ }
+ if state.PrevButton.Clicked() {
+ if err := func() error {
+ next, err := ui.Engine.PreviousStage(state.Stage)
+ if err != nil {
+ return fmt.Errorf("getting previous stage: %w", err)
+ }
+ if err := ui.Engine.Move(next, state.Ticket.ID); err != nil {
+ return fmt.Errorf("moving ticket: %s", err)
+ }
+ return nil
+ }(); err != nil {
+ fmt.Printf("error: %s\n", err)
+ }
+ }
+ }
if ui.TicketForm.Submit.Clicked() {
ticket, err := ui.TicketForm.Validate()
if err != nil {
@@ -134,18 +181,20 @@ func (ui *UI) Layout(gtx C) D {
return layout.Stack{}.Layout(
gtx,
layout.Stacked(func(gtx C) D {
+ ui.TicketStates.Begin()
var panels = make([]layout.FlexChild, len(ui.Panels))
- for ii := range ui.Panels {
- panel := &ui.Panels[ii]
- panels[ii] = layout.Flexed(1, func(gtx C) D {
- panel := panel
+ for kk := range ui.Panels {
+ panel := &ui.Panels[kk]
+ panels[kk] = layout.Flexed(1, func(gtx C) D {
stage, _ := ui.Engine.Stage(panel.Label)
- var cards = make([]layout.Widget, len(stage.Tickets))
+ var cards = make([]layout.ListElement, len(stage.Tickets))
for ii, ticket := range stage.Tickets {
- ticket := ticket
- cards[ii] = func(gtx C) D {
- // TODO: ticket state needs to live somewhere.
- return (&TicketStyle{Ticket: ticket}).Layout(gtx, ui.Th)
+ id := ticket.ID.String()
+ cards[ii] = func(gtx C, ii int) D {
+ t := (*Ticket)(ui.TicketStates.Next(id, unsafe.Pointer(&Ticket{})))
+ t.Ticket = stage.Tickets[ii]
+ t.Stage = stage.Name
+ return t.Layout(gtx, ui.Th)
}
}
return panel.Layout(gtx, ui.Th, cards...)
@@ -348,10 +397,11 @@ type Panel struct {
Color color.RGBA
Thickness unit.Value
CreateTicket widget.Clickable
+
layout.List
}
-func (p *Panel) Layout(gtx C, th *material.Theme, tickets ...layout.Widget) D {
+func (p *Panel) Layout(gtx C, th *material.Theme, tickets ...layout.ListElement) D {
return widget.Border{
Color: color.RGBA{A: 200},
Width: unit.Dp(0.5),
@@ -419,7 +469,7 @@ func (p *Panel) Layout(gtx C, th *material.Theme, tickets ...layout.Widget) D {
Width: unit.Dp(0.5),
Color: color.RGBA{A: 200},
}.Layout(gtx, func(gtx C) D {
- return tickets[ii](gtx)
+ return tickets[ii](gtx, ii)
})
})
})
@@ -430,16 +480,15 @@ func (p *Panel) Layout(gtx C, th *material.Theme, tickets ...layout.Widget) D {
})
}
-// TicketStyle renders a ticket control.
-type TicketStyle struct {
- kanban.Ticket
-}
-
+// Ticket renders a ticket control.
type Ticket struct {
- MoveButton widget.Clickable
+ kanban.Ticket
+ Stage string
+ NextButton widget.Clickable
+ PrevButton widget.Clickable
}
-func (t *TicketStyle) Layout(gtx C, th *material.Theme) D {
+func (t *Ticket) Layout(gtx C, th *material.Theme) D {
return layout.Flex{
Axis: layout.Horizontal,
}.Layout(
@@ -492,15 +541,84 @@ func (t *TicketStyle) Layout(gtx C, th *material.Theme) D {
}),
layout.Rigid(func(gtx C) D {
// bottom controls
- return Rect{
- Color: color.RGBA{A: 100},
- Size: f32.Point{
- X: float32(gtx.Constraints.Max.X),
- Y: float32(gtx.Px(unit.Dp(15))),
- },
- }.Layout(gtx)
+ gtx.Constraints.Max = image.Point{
+ X: gtx.Constraints.Max.X,
+ Y: gtx.Px(unit.Dp(20)),
+ }
+ gtx.Constraints.Min = image.Point{
+ Y: gtx.Px(unit.Dp(20)),
+ }
+ return layout.Stack{}.Layout(
+ gtx,
+ layout.Expanded(func(gtx C) D {
+ return Rect{
+ Color: color.RGBA{A: 100},
+ Size: f32.Point{
+ X: layout.FPt(gtx.Constraints.Max).X,
+ Y: layout.FPt(gtx.Constraints.Min).Y,
+ },
+ }.Layout(gtx)
+ }),
+ layout.Stacked(func(gtx C) D {
+ return layout.UniformInset(unit.Dp(2.5)).Layout(gtx, func(gtx C) D {
+ inset := layout.Inset{Left: unit.Dp(2), Right: unit.Dp(2)}
+ return layout.Flex{
+ Axis: layout.Horizontal,
+ }.Layout(
+ gtx,
+ layout.Flexed(1, func(gtx C) D {
+ return D{Size: gtx.Constraints.Max}
+ }),
+ layout.Flexed(1, func(gtx C) D {
+ return inset.Layout(gtx, func(gtx C) D {
+ btn := material.IconButton(th, &t.PrevButton, icons.BackIcon)
+ btn.Size = unit.Dp(15)
+ gtx.Constraints.Max.X = gtx.Px(unit.Dp(15))
+ return btn.Layout(gtx)
+ })
+ }),
+ layout.Flexed(1, func(gtx C) D {
+ return inset.Layout(gtx, func(gtx C) D {
+ btn := material.IconButton(th, &t.NextButton, icons.ForwardIcon)
+ btn.Size = unit.Dp(15)
+ gtx.Constraints.Max.X = gtx.Px(unit.Dp(15))
+ return btn.Layout(gtx)
+ })
+ }),
+ )
+ })
+ }),
+ )
}),
)
}),
)
}
+
+// Map of arbitrary data.
+type Map struct {
+ data map[string]unsafe.Pointer
+}
+
+func (m *Map) Begin() {
+ if m.data == nil {
+ m.data = make(map[string]unsafe.Pointer)
+ }
+}
+
+func (m *Map) Next(k string, v unsafe.Pointer) unsafe.Pointer {
+ if _, ok := m.data[k]; !ok {
+ m.data[k] = v
+ }
+ return m.data[k]
+}
+
+func (m *Map) List() []unsafe.Pointer {
+ list := []unsafe.Pointer{}
+ for _, v := range m.data {
+ if v != nil {
+ list = append(list, v)
+ }
+ }
+ return list
+}
diff --git a/icons/icons.go b/icons/icons.go
@@ -10,6 +10,11 @@ var BackIcon *widget.Icon = func() *widget.Icon {
return icon
}()
+var ForwardIcon *widget.Icon = func() *widget.Icon {
+ icon, _ := widget.NewIcon(icons.NavigationArrowForward)
+ return icon
+}()
+
var ClearIcon *widget.Icon = func() *widget.Icon {
icon, _ := widget.NewIcon(icons.ContentClear)
return icon
diff --git a/kanban.go b/kanban.go
@@ -1,41 +1,83 @@
package kanban
+import (
+ "fmt"
+ "strconv"
+)
+
// Kanban engine that drives the model.
type Engine struct {
- stages []Stage
+ Stages []Stage
+}
+
+// ListStages returns a list of stages.
+func (eng Engine) ListStages() ([]Stage, error) {
+ return eng.Stages, nil
+}
+
+// NextStage returns the stage that follows the specified one.
+func (eng Engine) NextStage(current string) (string, error) {
+ for ii, stage := range eng.Stages {
+ if stage.Name == current && ii < len(eng.Stages)-1 {
+ return eng.Stages[ii+1].Name, nil
+ }
+ }
+ return current, fmt.Errorf("no more stages after: %q", current)
}
-// Stages returns a list of stages.
-func (eng Engine) Stages() ([]Stage, error) {
- return eng.stages, nil
+// NextStage returns the stage that preceeds the specified one.
+func (eng Engine) PreviousStage(current string) (string, error) {
+ for ii, stage := range eng.Stages {
+ if stage.Name == current && ii > 0 {
+ return eng.Stages[ii-1].Name, nil
+ }
+ }
+ return current, fmt.Errorf("no more stages before: %q", current)
}
// Stage returns a stage by the given name.
// Creates an empty stage if it doesn't exist.
func (eng *Engine) Stage(name string) (Stage, error) {
- for _, stage := range eng.stages {
+ for _, stage := range eng.Stages {
if stage.Name == name {
return stage, nil
}
}
- eng.stages = append(eng.stages, Stage{Name: name})
- return eng.stages[len(eng.stages)-1], nil
+ eng.Stages = append(eng.Stages, Stage{Name: name})
+ return eng.Stages[len(eng.Stages)-1], nil
}
func (eng *Engine) Move(stage string, ticket ID) error {
- return nil
+ var found *Ticket
+ for ii := range eng.Stages {
+ stage := eng.Stages[ii]
+ for kk := range stage.Tickets {
+ t := stage.Tickets[kk]
+ if t.ID == ticket {
+ found = &t
+ if err := eng.Delete(ticket); err != nil {
+ return err
+ }
+ break
+ }
+ }
+ }
+ if found == nil {
+ return fmt.Errorf("ticket %q does not exist", ticket)
+ }
+ return eng.Assign(stage, *found)
}
func (eng *Engine) Assign(stage string, ticket Ticket) error {
ticket.ID = eng.nextID()
- for ii := range eng.stages {
- s := &eng.stages[ii]
+ for ii := range eng.Stages {
+ s := &eng.Stages[ii]
if s.Name == stage {
s.Tickets = append(s.Tickets, ticket)
return nil
}
}
- eng.stages = append(eng.stages, Stage{
+ eng.Stages = append(eng.Stages, Stage{
Name: stage,
Tickets: []Ticket{ticket},
})
@@ -43,7 +85,8 @@ func (eng *Engine) Assign(stage string, ticket Ticket) error {
}
func (eng *Engine) Delete(ticket ID) error {
- for _, s := range eng.stages {
+ for kk := range eng.Stages {
+ s := &eng.Stages[kk]
for ii := range s.Tickets {
if s.Tickets[ii].ID == ticket {
s.Tickets = append(s.Tickets[:ii], s.Tickets[ii+1:]...)
@@ -55,7 +98,7 @@ func (eng *Engine) Delete(ticket ID) error {
}
func (eng *Engine) Update(ticket Ticket) error {
- for _, s := range eng.stages {
+ for _, s := range eng.Stages {
for ii := range s.Tickets {
if s.Tickets[ii].ID == ticket.ID {
s.Tickets[ii] = ticket
@@ -68,7 +111,7 @@ func (eng *Engine) Update(ticket Ticket) error {
func (eng *Engine) nextID() ID {
var max int
- for _, stage := range eng.stages {
+ for _, stage := range eng.Stages {
for _, t := range stage.Tickets {
if int(t.ID) > max {
max = int(t.ID)
@@ -95,3 +138,7 @@ type Ticket struct {
Details string
References []ID
}
+
+func (id ID) String() string {
+ return strconv.Itoa(int(id))
+}