diff --git a/internal/board/board.go b/internal/board/board.go index 4f93348..5f15295 100644 --- a/internal/board/board.go +++ b/internal/board/board.go @@ -93,14 +93,14 @@ func (b *Board) StatusList() ([]Status, error) { // Status returns a single status from the db. // TODO: Add a test case that handles when a status does not exist. // Or use in delete status case. -func (b *Board) Status(id int) (Status, error) { - data, err := db.Read(b.db, db.StatusBucket, id) +func (b *Board) Status(statusID int) (Status, error) { + data, err := db.Read(b.db, db.StatusBucket, statusID) 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 { - return Status{}, StatusNotExistError{ID: id} + return Status{}, StatusNotExistError{ID: statusID} } var status Status @@ -110,33 +110,51 @@ func (b *Board) Status(id int) (Status, error) { decoder := gob.NewDecoder(buf) 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 } type StatusArgs struct { - Name string - Order int + Name string + Position int } -// CreateStatus creates a status in the db. +// CreateStatus creates a status in the database. 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{ ID: -1, - Name: args.Name, - Position: args.Order, + Name: name, + Position: pos, CardIds: 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 } +// UpdateStatusArgs is an argument type required for updating statuses. type UpdateStatusArgs struct { StatusID int StatusArgs @@ -153,8 +171,8 @@ func (b *Board) UpdateStatus(args UpdateStatusArgs) error { status.Name = args.Name } - if args.Order > 0 { - status.Position = args.Order + if args.Position > 0 { + status.Position = args.Position } if _, err := db.Write(b.db, db.StatusBucket, &status); err != nil { diff --git a/internal/board/status_lifecycle_test.go b/internal/board/status_lifecycle_test.go index a53783d..03b01e5 100644 --- a/internal/board/status_lifecycle_test.go +++ b/internal/board/status_lifecycle_test.go @@ -10,7 +10,7 @@ import ( ) 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() if err != nil { @@ -29,28 +29,43 @@ func TestStatusLifecycle(t *testing.T) { _ = 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" 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")) - // Rearrange the board so the order is To Do, On Hold, In Progress, Done - // Move a Card ID from To Do to In Progress + statusNextName := "Next" + statusNextExpectedID := 5 + 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)) } -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) { t.Log("When the status is created and saved to the database.") args := board.StatusArgs{ - Name: name, - Order: order, + Name: name, + Position: position, } 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) { 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 { 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) { t.Log("When the status' name is updated in the database.") args := board.UpdateStatusArgs{ StatusID: statusID, StatusArgs: board.StatusArgs{ - Name: newName, - Order: -1, + Name: newName, + Position: -1, }, } if err := kanban.UpdateStatus(args); err != nil { @@ -106,7 +127,7 @@ func testUpdateStatus(kanban board.Board, statusID int, newName string) func(t * ID: statusID, Name: newName, CardIds: nil, - Position: 2, + Position: expectedPosition, } if !reflect.DeepEqual(status, want) { diff --git a/internal/ui/cardform.go b/internal/ui/cardform.go index 0069017..f1bc501 100644 --- a/internal/ui/cardform.go +++ b/internal/ui/cardform.go @@ -5,20 +5,13 @@ import ( "github.com/rivo/tview" ) -type cardFormMode int - -const ( - create cardFormMode = iota - edit -) - type cardForm struct { *tview.Form frame *tview.Frame titleLabel string descLabel string - done func(string, string, bool, cardFormMode) - mode cardFormMode + done func(string, string, bool, formMode) + mode formMode } func newCardForm() *cardForm { @@ -91,7 +84,7 @@ func (c *cardForm) updateInputFields(title, description string) { 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 return c diff --git a/internal/ui/init.go b/internal/ui/init.go index 60b420d..7339d32 100644 --- a/internal/ui/init.go +++ b/internal/ui/init.go @@ -30,7 +30,7 @@ func (a *App) initColumns() error { // initCardForm initialises the card form. func (a *App) initCardForm() { - doneFunc := func(title, description string, success bool, mode cardFormMode) { + doneFunc := func(title, description string, success bool, mode formMode) { if success { switch mode { case create: @@ -52,7 +52,6 @@ func (a *App) initDeleteCardModal() { doneFunc := func(_ int, buttonLabel string) { if buttonLabel == "Confirm" { a.deleteFocusedCard() - a.refresh(true) } a.pages.HidePage(deleteCardModalPage) @@ -107,9 +106,14 @@ func (a *App) initStatusbar() { } func (a *App) initStatusForm() { - doneFunc := func(name string, success bool) { + doneFunc := func(name string, success bool, mode formMode) { if success { - a.editFocusedStatusColumn(name) + switch mode { + case create: + a.saveNewStatus(name) + case edit: + a.editFocusedStatusColumn(name) + } } a.pages.HidePage(statusFormPage) diff --git a/internal/ui/keymappings.go b/internal/ui/keymappings.go index a33f90a..cbc03d9 100644 --- a/internal/ui/keymappings.go +++ b/internal/ui/keymappings.go @@ -38,12 +38,19 @@ func (a *App) inputCapture() func(event *tcell.EventKey) *tcell.EventKey { } func (a *App) create() { - if a.mode == normal { + switch a.mode { + case normal: a.cardForm.mode = create a.cardForm.updateInputFields("", "") a.cardForm.frame.SetTitle(" Create Card ") a.pages.ShowPage(cardFormPage) 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.SetFocus(a.cardForm) case boardEdit: + a.statusForm.mode = edit statusName := a.focusedStatusName() a.statusForm.updateInputFields(statusName) a.statusForm.frame.SetTitle(" Edit Status Name ") @@ -129,7 +137,7 @@ func (a *App) selected() { a.statusSelection = statusSelection{0, 0, 0} a.updateBoardMode(normal) - a.refresh(false) + a.refresh(refreshArgs{updateFocusedColumnOnly: false, reinitialiseColumns: false}) } } diff --git a/internal/ui/statusform.go b/internal/ui/statusform.go index b36324d..f30690e 100644 --- a/internal/ui/statusform.go +++ b/internal/ui/statusform.go @@ -9,7 +9,8 @@ type statusForm struct { *tview.Form frame *tview.Frame nameLabel string - done func(string, bool) + done func(string, bool, formMode) + mode formMode } func newStatusForm() *statusForm { @@ -56,13 +57,13 @@ func newStatusForm() *statusForm { return } - obj.done(name.GetText(), true) + obj.done(name.GetText(), true, obj.mode) } }) obj.AddButton("Cancel", func() { 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) } -func (s *statusForm) setDoneFunc(handler func(string, bool)) *statusForm { +func (s *statusForm) setDoneFunc(handler func(string, bool, formMode)) *statusForm { s.done = handler return s diff --git a/internal/ui/ui.go b/internal/ui/ui.go index 7777707..9d4ab8b 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -10,6 +10,7 @@ import ( type ( boardMovement int boardMode string + formMode int ) const ( @@ -32,6 +33,11 @@ const ( boardEdit boardMode = "BOARD EDIT" ) +const ( + create formMode = iota + edit +) + type App struct { *tview.Application @@ -83,10 +89,6 @@ func NewApp(path string) (App, error) { // init initialises the UI. 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.initStatusbar() @@ -117,7 +119,7 @@ func (a *App) Init() error { a.SetRoot(a.pages, true) - a.refresh(false) + a.refresh(refreshArgs{updateFocusedColumnOnly: false, reinitialiseColumns: true}) return nil } @@ -138,7 +140,7 @@ func (a *App) saveCard(title, description string) { 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. @@ -161,7 +163,7 @@ func (a *App) editFocusedCard(title, description string) { 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. @@ -195,6 +197,8 @@ func (a *App) deleteFocusedCard() { } a.statusbar.displayMessage(infoLevel, "Card deleted successfully.") + + a.refresh(refreshArgs{updateFocusedColumnOnly: true, reinitialiseColumns: false}) } // updateAllColumns ensures that all columns are up-to-date. @@ -243,9 +247,22 @@ func (a *App) setColumnFocus() { 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. -func (a *App) refresh(updateFocusedColumnOnly bool) { - if updateFocusedColumnOnly { +func (a *App) refresh(args refreshArgs) { + // 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 { a.statusbar.displayMessage( errorLevel, @@ -327,5 +344,25 @@ func (a *App) editFocusedStatusColumn(newName string) { 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}) }