kanban

Kanban client in Gio
Log | Files | Refs | README | LICENSE

commit ceeb4b1100656117d3827dd80520d2c626e96dcb
parent 40391070a55f0cc94dfce699f34739fdd0153822
Author: Jack Mordaunt <jackmordaunt@gmail.com>
Date:   Sun, 11 Apr 2021 14:14:52 +0800

feat(storage): add project archive semantics

Projects can now be archived. An archived project doesn't show up on standard queries.

Archival means: 'move this data to cold storage so that I don't have to see it, but keep it around in case I want to restore it.'

Diffstat:
Mcmd/kanban/main.go | 19+++++++------------
Mcmd/kanban/ui.go | 100++++++++++++++++++++++++++++++++-----------------------------------------------
Mcmd/kanban/widgets.go | 55+++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mstorage/bolt/bolt.go | 81++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------
Mstorage/lazy/lazy.go | 55+++++++++++++++++++++++++++++++++++++++++++++----------
Mstorage/mem/mem.go | 41++++++++++++++++++++++++++++++++++++-----
Mstorage/storage.go | 8++++++++
7 files changed, 255 insertions(+), 104 deletions(-)

diff --git a/cmd/kanban/main.go b/cmd/kanban/main.go @@ -14,8 +14,7 @@ import ( "gioui.org/unit" "gioui.org/widget/material" "git.sr.ht/~jackmordaunt/kanban/storage" - "git.sr.ht/~jackmordaunt/kanban/storage/lazy" - "git.sr.ht/~jackmordaunt/kanban/storage/mem" + "git.sr.ht/~jackmordaunt/kanban/storage/bolt" "gioui.org/app" ) @@ -36,17 +35,13 @@ func main() { defer stopper.Stop() } storage, err := func() (storage.Storer, error) { - if MemStorage { - return mem.New(), nil - } else { - data, err := app.DataDir() - if err != nil { - return nil, fmt.Errorf("data dir: %v", err) - } - db := filepath.Join(data, "kanban.db") - fmt.Printf("%s\n", db) - return lazy.Open(db) + data, err := app.DataDir() + if err != nil { + return nil, fmt.Errorf("data dir: %v", err) } + db := filepath.Join(data, "kanban.db") + fmt.Printf("%s\n", db) + return bolt.Open(db) }() if err != nil { log.Fatalf("storage driver: %v\n", err) diff --git a/cmd/kanban/ui.go b/cmd/kanban/ui.go @@ -81,10 +81,11 @@ type UI struct { Modal layout.Widget // Form state. - TicketForm TicketForm - TicketDetails TicketDetails - DeleteDialog DeleteDialog - ProjectForm ProjectForm + TicketForm TicketForm + TicketDetails TicketDetails + DeleteDialog DeleteDialog + ProjectForm ProjectForm + ArchiveProjectConfirmation ArchiveProjectConfirmation // FocusedTicket struct { // ID kanban.ID @@ -274,6 +275,24 @@ func (ui *UI) Update(gtx C) { if ui.EditProjectBtn.Clicked() { ui.EditProject() } + if ui.ProjectForm.Delete.Button.Clicked() { + ui.ShowDeleteProjectConfirmation() + } + if ui.ArchiveProjectConfirmation.SubmitBtn.Clicked() { + if ui.ArchiveProjectConfirmation.Confirmation.Text() == ui.Project.Name { + if err := ui.Storage.Archive(ui.Project.ID); err != nil { + log.Printf("error: archiving project: %v", err) + } + ui.Save() + if len(ui.Projects) > 0 { + ui.Project = &ui.Projects[0] + } + ui.Clear() + } + } + if ui.ArchiveProjectConfirmation.CancelBtn.Clicked() { + ui.Clear() + } } // Layout UI. @@ -452,56 +471,7 @@ const ( // Refocus to the ticket in the given direction. // Allows movement between tickets and stages in sequential order. -func (ui *UI) Refocus(d Direction) { - // var ( - // project kanban.Project - // stage kanban.Stage - // ) - // if err := ui.Project.Find("ID", ui.ActiveProject, &project); err != nil { - // log.Printf("error: %v", err) - // return - // } - // if err := ui.Project.Find("ID", projet.St) - - // for { - // switch d { - // case NextTicket: - // ui.FocusedTicket.Index++ - // if ui.FocusedTicket.Index > len(stages[ui.FocusedTicket.Stage].Tickets) { - // ui.FocusedTicket.Index = 1 - // ui.FocusedTicket.Stage++ - // if ui.FocusedTicket.Stage > len(stages)-1 { - // ui.FocusedTicket.Stage = 0 - // } - // } - // case PreviousTicket: - // ui.FocusedTicket.Index-- - // if ui.FocusedTicket.Index < 1 { - // ui.FocusedTicket.Stage-- - // if ui.FocusedTicket.Stage < 0 { - // ui.FocusedTicket.Stage = len(stages) - 1 - // } - // ui.FocusedTicket.Index = len(stages[ui.FocusedTicket.Stage].Tickets) - // } - // case NextStage: - // ui.FocusedTicket.Index = 1 - // ui.FocusedTicket.Stage++ - // if ui.FocusedTicket.Stage > len(stages)-1 { - // ui.FocusedTicket.Stage = 0 - // } - // case PreviousStage: - // ui.FocusedTicket.Index = 1 - // ui.FocusedTicket.Stage-- - // if ui.FocusedTicket.Stage < 0 { - // ui.FocusedTicket.Stage = len(stages) - 1 - // } - // } - // if stage := stages[ui.FocusedTicket.Stage]; !stage.Empty() { - // break - // } - // } - // ui.FocusedTicket.ID = stages[ui.FocusedTicket.Stage].Tickets[ui.FocusedTicket.Index-1].ID -} +func (ui *UI) Refocus(d Direction) {} // Clear resets navigational state. func (ui *UI) Clear() { @@ -509,12 +479,7 @@ func (ui *UI) Clear() { ui.TicketForm = TicketForm{} ui.ProjectForm = ProjectForm{} ui.DeleteDialog = DeleteDialog{} - // @cleanup - // ui.FocusedTicket = struct { - // ID kanban.ID - // Index int - // Stage kanban.ID - // }{} + ui.ArchiveProjectConfirmation = ArchiveProjectConfirmation{} } // InspectTicket opens the ticket details card for the given ticket. @@ -569,6 +534,15 @@ func (ui *UI) EditProject() { } } +func (ui *UI) ShowDeleteProjectConfirmation() { + if ui.Project == nil { + return + } + ui.Modal = func(gtx C) D { + return ui.ArchiveProjectConfirmation.Layout(gtx, ui.Th) + } +} + // Projects is a list of Project entities with added behaviours. type Projects []kanban.Project @@ -595,6 +569,12 @@ func (ui *UI) Save() { if err := ui.Storage.Save(ui.Projects...); err != nil { log.Printf("error: saving projects: %v", err) } + // Remove any zeroed out projects because they don't exist anymore. + for ii, p := range ui.Projects { + if p.ID == uuid.Nil { + ui.Projects = append(ui.Projects[:ii], ui.Projects[ii+1:]...) + } + } } // sync any project dependent state when a project has changed. diff --git a/cmd/kanban/widgets.go b/cmd/kanban/widgets.go @@ -110,7 +110,10 @@ func (f *TicketForm) Layout(gtx C, th *material.Theme, stage string) D { // ProjectForm renders a form for manipulating projects. type ProjectForm struct { *kanban.Project - Name component.TextField + Name component.TextField + Delete struct { + Button widget.Clickable + } SubmitBtn widget.Clickable CancelBtn widget.Clickable } @@ -127,7 +130,7 @@ func (f *ProjectForm) Submit() { } func (f *ProjectForm) Mode() Mode { - if f.Project.ID != uuid.Nil { + if f.Project != nil { return ModeEdit } return ModeCreate @@ -148,6 +151,14 @@ func (f *ProjectForm) Layout(gtx C, th *material.Theme) D { layout.Rigid(func(gtx C) D { return f.Name.Layout(gtx, th, "Project Name") }), + layout.Rigid(func(gtx C) D { + if f.Mode() != ModeEdit { + return D{} + } + btn := material.Button(th, &f.Delete.Button, "Archive") + btn.Background = color.NRGBA{R: 200, A: 200} + return btn.Layout(gtx) + }), ) }, Actions: []control.Action{ @@ -459,3 +470,43 @@ func (t *TicketDetails) Layout(gtx C, th *material.Theme) D { }, }.Layout(gtx, th) } + +// ArchiveProjectConfirmation displays a dialog prompting the user to confirm +// the name of the project to confirm the archival. +// This is a safety step to avoid accidentally archiving a project. +type ArchiveProjectConfirmation struct { + Confirmation component.TextField + SubmitBtn widget.Clickable + CancelBtn widget.Clickable +} + +func (dpc *ArchiveProjectConfirmation) Layout(gtx C, th *material.Theme) D { + return control.Card{ + Title: "Archive Project", + Body: func(gtx C) D { + return layout.Flex{ + Axis: layout.Vertical, + Alignment: layout.Middle, + }.Layout( + gtx, + layout.Rigid(func(gtx C) D { + return dpc.Confirmation.Layout(gtx, th, "Project") + }), + ) + }, + Actions: []control.Action{ + { + Clickable: &dpc.SubmitBtn, + Label: "Archive", + Fg: th.ContrastFg, + Bg: color.NRGBA{R: 200, A: 255}, + }, + { + Clickable: &dpc.CancelBtn, + Label: "Cancel", + Fg: th.Fg, + Bg: th.Bg, + }, + }, + }.Layout(gtx, th) +} diff --git a/storage/bolt/bolt.go b/storage/bolt/bolt.go @@ -24,6 +24,7 @@ func (b Bucket) String() string { var ( BucketProject Bucket = Bucket("Project") + BucketArchive Bucket = Bucket("Archive") ) func Open(path string) (*Storer, error) { @@ -35,6 +36,9 @@ func Open(path string) (*Storer, error) { if _, err := tx.CreateBucketIfNotExists(BucketProject); err != nil { return err } + if _, err := tx.CreateBucketIfNotExists(BucketArchive); err != nil { + return err + } return nil }); err != nil { return nil, fmt.Errorf("initializing buckets: %w", err) @@ -63,9 +67,13 @@ func (db *Storer) Create(p kanban.Project) error { }) } +// Save persists the provided projects. +// If a project is nil, or doesn't exist in the store, it will be zeroed out +// in the slice. +// The caller can then cleanup the slice by removing the zeroed out projects. func (db *Storer) Save(projects ...kanban.Project) error { return db.Update(func(tx *bolt.Tx) error { - for _, p := range projects { + for ii, p := range projects { if p.ID == uuid.Nil { continue } @@ -77,8 +85,14 @@ func (db *Storer) Save(projects ...kanban.Project) error { if err != nil { return fmt.Errorf("serializing project: %w", err) } - if err := tx.Bucket(BucketProject).Put(id, v); err != nil { - return fmt.Errorf("updating project: %w", err) + if b := tx.Bucket(BucketProject); b != nil { + if b.Get(id) == nil { + projects[ii] = kanban.Project{} + continue + } + if err := b.Put(id, v); err != nil { + return fmt.Errorf("updating project: %w", err) + } } } return nil @@ -99,18 +113,8 @@ func (db *Storer) Find(id uuid.UUID) (p kanban.Project, ok bool, err error) { }) } -func (db *Storer) List() (list []kanban.Project, err error) { - return list, db.View(func(tx *bolt.Tx) error { - c := tx.Bucket(BucketProject).Cursor() - for k, v := c.First(); k != nil; k, v = c.Next() { - var p kanban.Project - if err := json.Unmarshal(v, &p); err != nil { - return fmt.Errorf("deserializing project: %w", err) - } - list = append(list, p) - } - return nil - }) +func (db *Storer) List() ([]kanban.Project, error) { + return db.list(BucketProject) } func (db *Storer) Load(projects []kanban.Project) error { @@ -147,3 +151,50 @@ func (db *Storer) Count() (count int, err error) { return nil }) } + +func (db *Storer) Archive(id uuid.UUID) error { + return db.move(id, BucketProject, BucketArchive) +} + +func (db *Storer) Restore(id uuid.UUID) error { + return db.move(id, BucketArchive, BucketProject) +} + +func (db *Storer) ListArchived() ([]kanban.Project, error) { + return db.list(BucketArchive) +} + +func (db *Storer) move(id uuid.UUID, from, to Bucket) error { + k, err := id.MarshalBinary() + if err != nil { + return fmt.Errorf("serializing ID: %w", err) + } + return db.Update(func(tx *bolt.Tx) error { + fromBucket := tx.Bucket(from) + v := fromBucket.Get(k) + if v == nil { + return fmt.Errorf("project does not exist in %q bucket: %q", from, id) + } + if err := fromBucket.Delete(k); err != nil { + return fmt.Errorf("deleting project from %q bucket: %w", from, err) + } + if err := tx.Bucket(to).Put(k, v); err != nil { + return fmt.Errorf("placing project in %q bucket: %w", to, err) + } + return nil + }) +} + +func (db *Storer) list(from Bucket) (list []kanban.Project, err error) { + return list, db.View(func(tx *bolt.Tx) error { + c := tx.Bucket(from).Cursor() + for k, v := c.First(); k != nil; k, v = c.Next() { + var p kanban.Project + if err := json.Unmarshal(v, &p); err != nil { + return fmt.Errorf("deserializing project: %w", err) + } + list = append(list, p) + } + return nil + }) +} diff --git a/storage/lazy/lazy.go b/storage/lazy/lazy.go @@ -28,20 +28,20 @@ func Open(path string) (*Storer, error) { if err != nil { return nil, err } - s := &Storer{ + s := Storer{ Cache: mem.New(), Disk: disk, } - return s, s.Populate() + return &s, s.Populate() } // Create a project. Saves to disk. func (s *Storer) Create(p kanban.Project) error { if err := s.Cache.Create(p); err != nil { - return fmt.Errorf("creating on disk: %v", err) + return fmt.Errorf("creating on disk: %w", err) } if err := s.Disk.Create(p); err != nil { - return fmt.Errorf("creating on disk: %v", err) + return fmt.Errorf("creating on disk: %w", err) } return nil } @@ -58,7 +58,7 @@ func (s *Storer) Save(projects ...kanban.Project) error { } if !reflect.DeepEqual(p, old) { if err := s.Disk.Save(p); err != nil { - return fmt.Errorf("saving to disk: %v", err) + return fmt.Errorf("saving to disk: %w", err) } return s.Refresh(p.ID) } @@ -74,14 +74,14 @@ func (s *Storer) Find(id uuid.UUID) (kanban.Project, bool, error) { // List projects. func (s *Storer) List() ([]kanban.Project, error) { - return s.Disk.List() + return s.Cache.List() } // Refresh a project entity by loading from disk. func (s *Storer) Refresh(id uuid.UUID) error { p, ok, err := s.Disk.Find(id) if err != nil { - return fmt.Errorf("loading from disk: %v", err) + return fmt.Errorf("loading from disk: %w", err) } if !ok { return fmt.Errorf("project does not exist: %v", id) @@ -93,18 +93,35 @@ func (s *Storer) Refresh(id uuid.UUID) error { func (s *Storer) Populate() error { projects, err := s.Disk.List() if err != nil { - return fmt.Errorf("loading projects from disk: %v", err) + return fmt.Errorf("loading projects from disk: %w", err) } for _, p := range projects { if err := s.Cache.Create(p); err != nil { - return fmt.Errorf("saving project to cache: %v", err) + return fmt.Errorf("saving project to cache: %w", err) + } + } + archived, err := s.Disk.ListArchived() + if err != nil { + return fmt.Errorf("loading archived projects from disk: %w", err) + } + for _, p := range archived { + if err := func() error { + if err := s.Cache.Create(p); err != nil { + return err + } + if err := s.Cache.Archive(p.ID); err != nil { + return err + } + return nil + }(); err != nil { + return fmt.Errorf("saving archived projects to cache: %w", err) } } return nil } func (s *Storer) Load(projects []kanban.Project) error { - return s.Disk.Load(projects) + return s.Cache.Load(projects) } func (s *Storer) Close() error { @@ -114,3 +131,21 @@ func (s *Storer) Close() error { func (s *Storer) Count() (int, error) { return s.Cache.Count() } + +func (s *Storer) Archive(id uuid.UUID) error { + if err := s.Disk.Archive(id); err != nil { + return err + } + return s.Populate() +} + +func (s *Storer) Restore(id uuid.UUID) error { + if err := s.Disk.Restore(id); err != nil { + return err + } + return s.Populate() +} + +func (s *Storer) ListArchived() ([]kanban.Project, error) { + return s.Cache.ListArchived() +} diff --git a/storage/mem/mem.go b/storage/mem/mem.go @@ -3,23 +3,25 @@ package mem import ( "fmt" - "git.sr.ht/~jackmordaunt/kanban/storage" "github.com/google/uuid" "git.sr.ht/~jackmordaunt/kanban" ) -var _ storage.Storer = (*Storer)(nil) +// var _ storage.Storer = (*Storer)(nil) // Storer implements in-memory storage for Projects. type Storer struct { - Data map[uuid.UUID]kanban.Project - Order []uuid.UUID + Data map[uuid.UUID]kanban.Project + Order []uuid.UUID + Archived map[uuid.UUID]kanban.Project + ArchivedOrder []uuid.UUID } func New() *Storer { return &Storer{ - Data: make(map[uuid.UUID]kanban.Project), + Data: make(map[uuid.UUID]kanban.Project), + Archived: make(map[uuid.UUID]kanban.Project), } } @@ -72,3 +74,32 @@ func (s *Storer) Load(projects []kanban.Project) error { } return nil } + +func (s *Storer) Archive(id uuid.UUID) error { + p, ok := s.Data[id] + if !ok { + return nil + } + delete(s.Data, id) + s.Archived[id] = p + return nil +} + +func (s *Storer) Restore(id uuid.UUID) error { + p, ok := s.Archived[id] + if !ok { + return nil + } + delete(s.Archived, id) + s.Data[id] = p + return nil +} + +func (s *Storer) ListArchived() (list []kanban.Project, err error) { + for _, id := range s.ArchivedOrder { + if p, ok := s.Archived[id]; ok { + list = append(list, p) + } + } + return list, nil +} diff --git a/storage/storage.go b/storage/storage.go @@ -23,4 +23,12 @@ type Storer interface { List() ([]kanban.Project, error) // Count returns the number of projects that exist in the store. Count() (int, error) + // Archive a project. + // An archived project will have it's data saved, but won't show up under + // normal queries. + Archive(uuid.UUID) error + // ListArchived lists all archived projects. + ListArchived() ([]kanban.Project, error) + // Restore takes an archived project and makes it live again. + Restore(uuid.UUID) error }