feat(ui): add support for creating status columns
All checks were successful
/ test (pull_request) Successful in 34s
/ lint (pull_request) Successful in 46s

This commit adds support for creating new status columns. When the user
is in the 'board edit' mode, they can press the 'c' key to create a new
column. When a new column is created it automatically assumes the last
position on the board. We will add support later on to allow the user to
re-arrange the columns on the board.

Part of apollo/pelican#22
This commit is contained in:
Dan Anglin 2024-01-17 17:10:36 +00:00
parent f9fbda92ee
commit c1bb834a7f
Signed by: dananglin
GPG key ID: 0C1D44CFBEE68638
7 changed files with 141 additions and 59 deletions

View file

@ -93,14 +93,14 @@ func (b *Board) StatusList() ([]Status, error) {
// Status returns a single status from the db. // Status returns a single status from the db.
// TODO: Add a test case that handles when a status does not exist. // TODO: Add a test case that handles when a status does not exist.
// Or use in delete status case. // Or use in delete status case.
func (b *Board) Status(id int) (Status, error) { func (b *Board) Status(statusID int) (Status, error) {
data, err := db.Read(b.db, db.StatusBucket, id) data, err := db.Read(b.db, db.StatusBucket, statusID)
if err != nil { if err != nil {
return Status{}, fmt.Errorf("unable to read status [%d] from the db. %w", id, err) return Status{}, fmt.Errorf("unable to read status [%d] from the database; %w", statusID, err)
} }
if data == nil { if data == nil {
return Status{}, StatusNotExistError{ID: id} return Status{}, StatusNotExistError{ID: statusID}
} }
var status Status var status Status
@ -110,33 +110,51 @@ func (b *Board) Status(id int) (Status, error) {
decoder := gob.NewDecoder(buf) decoder := gob.NewDecoder(buf)
if err := decoder.Decode(&status); err != nil { if err := decoder.Decode(&status); err != nil {
return Status{}, fmt.Errorf("unable to decode data into a Status object, %w", err) return Status{}, fmt.Errorf("unable to decode data into a Status object; %w", err)
} }
return status, nil return status, nil
} }
type StatusArgs struct { type StatusArgs struct {
Name string Name string
Order int Position int
} }
// CreateStatus creates a status in the db. // CreateStatus creates a status in the database.
func (b *Board) CreateStatus(args StatusArgs) error { func (b *Board) CreateStatus(args StatusArgs) error {
name := args.Name
pos := args.Position
if pos < 1 {
statuses, err := b.StatusList()
if err != nil {
return fmt.Errorf("unable to get the list of statuses; %w", err)
}
length := len(statuses)
if length == 0 {
pos = 1
} else {
pos = statuses[length-1].Position + 1
}
}
status := Status{ status := Status{
ID: -1, ID: -1,
Name: args.Name, Name: name,
Position: args.Order, Position: pos,
CardIds: nil, CardIds: nil,
} }
if _, err := db.Write(b.db, db.StatusBucket, &status); err != nil { if _, err := db.Write(b.db, db.StatusBucket, &status); err != nil {
return fmt.Errorf("unable to write the status to the db. %w", err) return fmt.Errorf("unable to write the status to the database; %w", err)
} }
return nil return nil
} }
// UpdateStatusArgs is an argument type required for updating statuses.
type UpdateStatusArgs struct { type UpdateStatusArgs struct {
StatusID int StatusID int
StatusArgs StatusArgs
@ -153,8 +171,8 @@ func (b *Board) UpdateStatus(args UpdateStatusArgs) error {
status.Name = args.Name status.Name = args.Name
} }
if args.Order > 0 { if args.Position > 0 {
status.Position = args.Order status.Position = args.Position
} }
if _, err := db.Write(b.db, db.StatusBucket, &status); err != nil { if _, err := db.Write(b.db, db.StatusBucket, &status); err != nil {

View file

@ -10,7 +10,7 @@ import (
) )
func TestStatusLifecycle(t *testing.T) { func TestStatusLifecycle(t *testing.T) {
t.Log("Testing the lifecycle of a status.") t.Log("Testing the lifecycle of the statuses on the Kanban board...")
projectDir, err := projectRoot() projectDir, err := projectRoot()
if err != nil { if err != nil {
@ -29,28 +29,43 @@ func TestStatusLifecycle(t *testing.T) {
_ = kanban.Close() _ = kanban.Close()
}() }()
// We've opened a new board with the default list of statuses: To Do, Doing, Done t.Logf("We've opened a new board with the default list of statuses: 'To Do', 'Doing' and 'Done'")
t.Logf("Let us now create a new status called 'On Hold' in the 4th position...")
statusOnHoldName := "On Hold" statusOnHoldName := "On Hold"
statusOnHoldExpectedID := 4 statusOnHoldExpectedID := 4
statusOnHoldOrder := 4 statusOnHoldBoardPosition := 4
t.Run("Test Create Status", testCreateStatus(kanban, statusOnHoldName, statusOnHoldOrder)) t.Run("Test Create Status (On Hold)", testCreateStatus(kanban, statusOnHoldName, statusOnHoldBoardPosition))
t.Run("Test Read Status (On Hold)", testReadStatus(kanban, statusOnHoldExpectedID, statusOnHoldName, statusOnHoldBoardPosition))
t.Run("Test Read Status", testReadStatus(kanban, statusOnHoldExpectedID, statusOnHoldName)) t.Logf("Additionally, let us create another status without specifying a position. It should take the last position on the board...")
t.Run("Test Status Update", testUpdateStatus(kanban, 2, "In Progress")) statusNextName := "Next"
// Rearrange the board so the order is To Do, On Hold, In Progress, Done statusNextExpectedID := 5
// Move a Card ID from To Do to In Progress statusNextExpectedBoardPosition := 5
t.Run("Test Create Status (Next)", testCreateStatus(kanban, statusNextName, 0))
t.Run("Test Read Status (Next)", testReadStatus(kanban, statusNextExpectedID, statusNextName, statusNextExpectedBoardPosition))
t.Logf("Let us now update the names of two of our statuses...")
t.Run("Test Status Update (In Progress)", testUpdateStatus(kanban, 2, 2, "In Progress"))
t.Run("Test Status Update (Backlog)", testUpdateStatus(kanban, 1, 1, "Backlog"))
// (TODO: Rearranging statuses still needs to be implemented) Rearrange the board so the order is To Do, On Hold, In Progress, Done
t.Logf("Let us now try moving a card from one status to another...")
t.Run("Test Move Card To Status", testMoveCardToStatus(kanban)) t.Run("Test Move Card To Status", testMoveCardToStatus(kanban))
} }
func testCreateStatus(kanban board.Board, name string, order int) func(t *testing.T) { func testCreateStatus(kanban board.Board, name string, position int) func(t *testing.T) {
return func(t *testing.T) { return func(t *testing.T) {
t.Log("When the status is created and saved to the database.") t.Log("When the status is created and saved to the database.")
args := board.StatusArgs{ args := board.StatusArgs{
Name: name, Name: name,
Order: order, Position: position,
} }
if err := kanban.CreateStatus(args); err != nil { if err := kanban.CreateStatus(args); err != nil {
@ -61,7 +76,7 @@ func testCreateStatus(kanban board.Board, name string, order int) func(t *testin
} }
} }
func testReadStatus(kanban board.Board, statusID int, wantName string) func(t *testing.T) { func testReadStatus(kanban board.Board, statusID int, wantName string, wantPosition int) func(t *testing.T) {
return func(t *testing.T) { return func(t *testing.T) {
t.Log("When the status is read from the database.") t.Log("When the status is read from the database.")
@ -75,18 +90,24 @@ func testReadStatus(kanban board.Board, statusID int, wantName string) func(t *t
} else { } else {
t.Logf("%s\tSuccessfully received the '%s' status from the database.", success, status.Name) t.Logf("%s\tSuccessfully received the '%s' status from the database.", success, status.Name)
} }
if status.Position != wantPosition {
t.Errorf("%s\tUnexpected position found for %s, want: '%d', got: '%d'", failure, status.Name, wantPosition, status.Position)
} else {
t.Logf("%s\tSuccessfully received the expected position number for the '%s' status, got : '%d'.", success, status.Name, status.Position)
}
} }
} }
func testUpdateStatus(kanban board.Board, statusID int, newName string) func(t *testing.T) { func testUpdateStatus(kanban board.Board, statusID, expectedPosition int, newName string) func(t *testing.T) {
return func(t *testing.T) { return func(t *testing.T) {
t.Log("When the status' name is updated in the database.") t.Log("When the status' name is updated in the database.")
args := board.UpdateStatusArgs{ args := board.UpdateStatusArgs{
StatusID: statusID, StatusID: statusID,
StatusArgs: board.StatusArgs{ StatusArgs: board.StatusArgs{
Name: newName, Name: newName,
Order: -1, Position: -1,
}, },
} }
if err := kanban.UpdateStatus(args); err != nil { if err := kanban.UpdateStatus(args); err != nil {
@ -106,7 +127,7 @@ func testUpdateStatus(kanban board.Board, statusID int, newName string) func(t *
ID: statusID, ID: statusID,
Name: newName, Name: newName,
CardIds: nil, CardIds: nil,
Position: 2, Position: expectedPosition,
} }
if !reflect.DeepEqual(status, want) { if !reflect.DeepEqual(status, want) {

View file

@ -5,20 +5,13 @@ import (
"github.com/rivo/tview" "github.com/rivo/tview"
) )
type cardFormMode int
const (
create cardFormMode = iota
edit
)
type cardForm struct { type cardForm struct {
*tview.Form *tview.Form
frame *tview.Frame frame *tview.Frame
titleLabel string titleLabel string
descLabel string descLabel string
done func(string, string, bool, cardFormMode) done func(string, string, bool, formMode)
mode cardFormMode mode formMode
} }
func newCardForm() *cardForm { func newCardForm() *cardForm {
@ -91,7 +84,7 @@ func (c *cardForm) updateInputFields(title, description string) {
c.AddTextArea(c.descLabel, description, 60, 10, 0, nil) c.AddTextArea(c.descLabel, description, 60, 10, 0, nil)
} }
func (c *cardForm) setDoneFunc(handler func(string, string, bool, cardFormMode)) *cardForm { func (c *cardForm) setDoneFunc(handler func(string, string, bool, formMode)) *cardForm {
c.done = handler c.done = handler
return c return c

View file

@ -30,7 +30,7 @@ func (a *App) initColumns() error {
// initCardForm initialises the card form. // initCardForm initialises the card form.
func (a *App) initCardForm() { func (a *App) initCardForm() {
doneFunc := func(title, description string, success bool, mode cardFormMode) { doneFunc := func(title, description string, success bool, mode formMode) {
if success { if success {
switch mode { switch mode {
case create: case create:
@ -52,7 +52,6 @@ func (a *App) initDeleteCardModal() {
doneFunc := func(_ int, buttonLabel string) { doneFunc := func(_ int, buttonLabel string) {
if buttonLabel == "Confirm" { if buttonLabel == "Confirm" {
a.deleteFocusedCard() a.deleteFocusedCard()
a.refresh(true)
} }
a.pages.HidePage(deleteCardModalPage) a.pages.HidePage(deleteCardModalPage)
@ -107,9 +106,14 @@ func (a *App) initStatusbar() {
} }
func (a *App) initStatusForm() { func (a *App) initStatusForm() {
doneFunc := func(name string, success bool) { doneFunc := func(name string, success bool, mode formMode) {
if success { if success {
a.editFocusedStatusColumn(name) switch mode {
case create:
a.saveNewStatus(name)
case edit:
a.editFocusedStatusColumn(name)
}
} }
a.pages.HidePage(statusFormPage) a.pages.HidePage(statusFormPage)

View file

@ -38,12 +38,19 @@ func (a *App) inputCapture() func(event *tcell.EventKey) *tcell.EventKey {
} }
func (a *App) create() { func (a *App) create() {
if a.mode == normal { switch a.mode {
case normal:
a.cardForm.mode = create a.cardForm.mode = create
a.cardForm.updateInputFields("", "") a.cardForm.updateInputFields("", "")
a.cardForm.frame.SetTitle(" Create Card ") a.cardForm.frame.SetTitle(" Create Card ")
a.pages.ShowPage(cardFormPage) a.pages.ShowPage(cardFormPage)
a.SetFocus(a.cardForm) a.SetFocus(a.cardForm)
case boardEdit:
a.statusForm.mode = create
a.statusForm.updateInputFields("")
a.statusForm.frame.SetTitle(" Create Status ")
a.pages.ShowPage(statusFormPage)
a.SetFocus(a.statusForm)
} }
} }
@ -70,6 +77,7 @@ func (a *App) edit() {
a.pages.ShowPage(cardFormPage) a.pages.ShowPage(cardFormPage)
a.SetFocus(a.cardForm) a.SetFocus(a.cardForm)
case boardEdit: case boardEdit:
a.statusForm.mode = edit
statusName := a.focusedStatusName() statusName := a.focusedStatusName()
a.statusForm.updateInputFields(statusName) a.statusForm.updateInputFields(statusName)
a.statusForm.frame.SetTitle(" Edit Status Name ") a.statusForm.frame.SetTitle(" Edit Status Name ")
@ -129,7 +137,7 @@ func (a *App) selected() {
a.statusSelection = statusSelection{0, 0, 0} a.statusSelection = statusSelection{0, 0, 0}
a.updateBoardMode(normal) a.updateBoardMode(normal)
a.refresh(false) a.refresh(refreshArgs{updateFocusedColumnOnly: false, reinitialiseColumns: false})
} }
} }

View file

@ -9,7 +9,8 @@ type statusForm struct {
*tview.Form *tview.Form
frame *tview.Frame frame *tview.Frame
nameLabel string nameLabel string
done func(string, bool) done func(string, bool, formMode)
mode formMode
} }
func newStatusForm() *statusForm { func newStatusForm() *statusForm {
@ -56,13 +57,13 @@ func newStatusForm() *statusForm {
return return
} }
obj.done(name.GetText(), true) obj.done(name.GetText(), true, obj.mode)
} }
}) })
obj.AddButton("Cancel", func() { obj.AddButton("Cancel", func() {
if obj.done != nil { if obj.done != nil {
obj.done("", false) obj.done("", false, obj.mode)
} }
}) })
@ -74,7 +75,7 @@ func (s *statusForm) updateInputFields(name string) {
s.AddInputField(s.nameLabel, name, 0, nil, nil) s.AddInputField(s.nameLabel, name, 0, nil, nil)
} }
func (s *statusForm) setDoneFunc(handler func(string, bool)) *statusForm { func (s *statusForm) setDoneFunc(handler func(string, bool, formMode)) *statusForm {
s.done = handler s.done = handler
return s return s

View file

@ -10,6 +10,7 @@ import (
type ( type (
boardMovement int boardMovement int
boardMode string boardMode string
formMode int
) )
const ( const (
@ -32,6 +33,11 @@ const (
boardEdit boardMode = "BOARD EDIT" boardEdit boardMode = "BOARD EDIT"
) )
const (
create formMode = iota
edit
)
type App struct { type App struct {
*tview.Application *tview.Application
@ -83,10 +89,6 @@ func NewApp(path string) (App, error) {
// init initialises the UI. // init initialises the UI.
func (a *App) Init() error { func (a *App) Init() error {
if err := a.initColumns(); err != nil {
return fmt.Errorf("error initialising the status columns; %w", err)
}
a.columnFlex.SetInputCapture(a.inputCapture()) a.columnFlex.SetInputCapture(a.inputCapture())
a.initStatusbar() a.initStatusbar()
@ -117,7 +119,7 @@ func (a *App) Init() error {
a.SetRoot(a.pages, true) a.SetRoot(a.pages, true)
a.refresh(false) a.refresh(refreshArgs{updateFocusedColumnOnly: false, reinitialiseColumns: true})
return nil return nil
} }
@ -138,7 +140,7 @@ func (a *App) saveCard(title, description string) {
a.statusbar.displayMessage(infoLevel, "Card created successfully.") a.statusbar.displayMessage(infoLevel, "Card created successfully.")
a.refresh(false) a.refresh(refreshArgs{updateFocusedColumnOnly: false, reinitialiseColumns: false})
} }
// editFocusedCard saves an edited card to the database. // editFocusedCard saves an edited card to the database.
@ -161,7 +163,7 @@ func (a *App) editFocusedCard(title, description string) {
a.statusbar.displayMessage(infoLevel, "Card edited successfully.") a.statusbar.displayMessage(infoLevel, "Card edited successfully.")
a.refresh(true) a.refresh(refreshArgs{updateFocusedColumnOnly: true, reinitialiseColumns: false})
} }
// getFocusedCard retrieves the details of the card in focus. // getFocusedCard retrieves the details of the card in focus.
@ -195,6 +197,8 @@ func (a *App) deleteFocusedCard() {
} }
a.statusbar.displayMessage(infoLevel, "Card deleted successfully.") a.statusbar.displayMessage(infoLevel, "Card deleted successfully.")
a.refresh(refreshArgs{updateFocusedColumnOnly: true, reinitialiseColumns: false})
} }
// updateAllColumns ensures that all columns are up-to-date. // updateAllColumns ensures that all columns are up-to-date.
@ -243,9 +247,22 @@ func (a *App) setColumnFocus() {
a.SetFocus(a.columns[a.focusedColumn]) a.SetFocus(a.columns[a.focusedColumn])
} }
// refreshArgs is an argument type for the refresh method.
type refreshArgs struct {
updateFocusedColumnOnly bool
reinitialiseColumns bool
}
// refresh refreshes the UI. // refresh refreshes the UI.
func (a *App) refresh(updateFocusedColumnOnly bool) { func (a *App) refresh(args refreshArgs) {
if updateFocusedColumnOnly { // in some cases (e.g. a new column is created)
// we need to re-initialise the columnFlex primitive
if args.reinitialiseColumns {
args.updateFocusedColumnOnly = false
a.initColumns()
}
if args.updateFocusedColumnOnly {
if err := a.updateColumn(a.focusedColumn); err != nil { if err := a.updateColumn(a.focusedColumn); err != nil {
a.statusbar.displayMessage( a.statusbar.displayMessage(
errorLevel, errorLevel,
@ -327,5 +344,25 @@ func (a *App) editFocusedStatusColumn(newName string) {
a.statusbar.displayMessage(infoLevel, "Status updated successfully.") a.statusbar.displayMessage(infoLevel, "Status updated successfully.")
a.refresh(true) a.refresh(refreshArgs{updateFocusedColumnOnly: true, reinitialiseColumns: false})
}
// saveNewStatus saves a new status to the database and updates the Kanban board.
// The new status will be positioned last on the board. It's position can be modified
// by the user.
func (a *App) saveNewStatus(name string) {
args := board.StatusArgs{
Name: name,
}
err := a.board.CreateStatus(args)
if err != nil {
a.statusbar.displayMessage(errorLevel, fmt.Sprintf("Failed to create the status column: %v.", err))
return
}
a.statusbar.displayMessage(infoLevel, "Status column created successfully.")
a.refresh(refreshArgs{updateFocusedColumnOnly: false, reinitialiseColumns: true})
} }