diff --git a/internal/board/board.go b/internal/board/board.go index 5f15295..a41493b 100644 --- a/internal/board/board.go +++ b/internal/board/board.go @@ -160,11 +160,11 @@ type UpdateStatusArgs struct { StatusArgs } -// UpdateStatus modifies an existing status in the db. +// UpdateStatus modifies an existing status in the database. func (b *Board) UpdateStatus(args UpdateStatusArgs) error { status, err := b.Status(args.StatusID) if err != nil { - return fmt.Errorf("unable to retrieve the status from the db. %w", err) + return fmt.Errorf("unable to retrieve the status from the database. %w", err) } if len(args.Name) > 0 { @@ -182,10 +182,53 @@ func (b *Board) UpdateStatus(args UpdateStatusArgs) error { return nil } -// TODO: Finish implementation. -// func (b *Board) DeleteStatus() error { -// return nil -// } +// DeleteStatus deletes a status from the database. +// A status can only be deleted if it does not contain any cards. +func (b *Board) DeleteStatus(statusID int) error { + status, err := b.Status(statusID) + if err != nil { + return fmt.Errorf("unable to retrieve the status from the database; %w", err) + } + + if len(status.CardIds) > 0 { + return StatusNotEmptyError{ID: statusID} + } + + if err := db.Delete(b.db, db.StatusBucket, statusID); err != nil { + return fmt.Errorf("unable to delete the status from the database; %w", err) + } + + if err := b.normaliseStatusesPositionValues(); err != nil { + return fmt.Errorf("unable to normalise the statuses position values; %w", err) + } + + return nil +} + +// normaliseStatusesPositionValues retrieves the ordered list of statuses from the database and sets +// each status' positional value based on its position in the list. +func (b *Board) normaliseStatusesPositionValues() error { + statuses, err := b.StatusList() + if err != nil { + return fmt.Errorf("unable to get the list of statuses; %w", err) + } + + for i, status := range statuses { + updateArgs := UpdateStatusArgs{ + StatusID: status.ID, + StatusArgs: StatusArgs{ + Name: "", + Position: i + 1, + }, + } + + if err := b.UpdateStatus(updateArgs); err != nil { + return fmt.Errorf("unable to update the status %q; %w", status.Name, err) + } + } + + return nil +} type MoveToStatusArgs struct { CardID int @@ -346,18 +389,18 @@ type DeleteCardArgs struct { // DeleteCard deletes a card from the database. func (b *Board) DeleteCard(args DeleteCardArgs) error { if err := db.Delete(b.db, db.CardBucket, args.CardID); err != nil { - return fmt.Errorf("unable to delete the card from the database, %w", err) + return fmt.Errorf("unable to delete the card from the database; %w", err) } status, err := b.Status(args.StatusID) if err != nil { - return fmt.Errorf("unable to read Status '%d' from the database, %w", args.StatusID, err) + return fmt.Errorf("unable to read Status '%d' from the database; %w", args.StatusID, err) } status.RemoveCardID(args.CardID) if _, err := db.Write(b.db, db.StatusBucket, &status); err != nil { - return fmt.Errorf("unable to update the status in the database, %w", err) + return fmt.Errorf("unable to update the status in the database; %w", err) } return nil diff --git a/internal/board/status.go b/internal/board/status.go index b75f594..6ac1d64 100644 --- a/internal/board/status.go +++ b/internal/board/status.go @@ -19,6 +19,14 @@ func (e StatusNotExistError) Error() string { return fmt.Sprintf("status ID '%d' does not exist in the database", e.ID) } +type StatusNotEmptyError struct { + ID int +} + +func (e StatusNotEmptyError) Error() string { + return fmt.Sprintf("status ID '%d' must contain no cards before deletion", e.ID) +} + // Status represents the status of the Kanban board. type Status struct { ID int diff --git a/internal/board/status_lifecycle_test.go b/internal/board/status_lifecycle_test.go index 03b01e5..6b8a288 100644 --- a/internal/board/status_lifecycle_test.go +++ b/internal/board/status_lifecycle_test.go @@ -1,6 +1,7 @@ package board_test import ( + "errors" "os" "path/filepath" "reflect" @@ -50,13 +51,28 @@ func TestStatusLifecycle(t *testing.T) { 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")) + t.Run("Test Status Update (Doing to In Progress)", testUpdateStatus(kanban, 2, 2, "In Progress")) + t.Run("Test Status Update (To Do to 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 + // (TODO: Rearranging statuses still needs to be implemented) + // Rearrange the board so the order is: Backlog, Next, In Progress, On Hold, Done t.Logf("Let us now try moving a card from one status to another...") t.Run("Test Move Card To Status", testMoveCardToStatus(kanban)) + + // TODO: This needs to be updated when we re-arrange the board. + expectedPositions := map[int]string{ + 1: "Backlog", + 2: "In Progress", + 3: "Done", + 4: "Next", + } + + t.Logf("Let us now delete the 'On Hold' status from the database...") + t.Run("Test Delete Status (On Hold)", testDeleteEmptyStatus(kanban, statusOnHoldExpectedID, expectedPositions)) + + t.Logf("Additionally, let us try to delete a status that contains a card...") + t.Run("Test Delete a non-empty status", testDeleteNonEmptyStatus(kanban, 3)) } func testCreateStatus(kanban board.Board, name string, position int) func(t *testing.T) { @@ -202,3 +218,47 @@ func testMoveCardToStatus(kanban board.Board) func(t *testing.T) { } } } + +func testDeleteEmptyStatus(kanban board.Board, statusID int, wantPositions map[int]string) func(t *testing.T) { + return func(t *testing.T) { + t.Log("When deleting an empty status.") + + err := kanban.DeleteStatus(statusID) + if err != nil { + t.Fatalf("ERROR: an error was received when attempting to delete the status from the database; %v", err) + } + + statuses, err := kanban.StatusList() + if err != nil { + t.Fatalf("ERROR: an error was received when attempting to get the list of statuses from the database; %v", err) + } + + gotPositions := make(map[int]string) + for _, status := range statuses { + gotPositions[status.Position] = status.Name + } + + if !reflect.DeepEqual(wantPositions, gotPositions) { + t.Errorf("%s\tUnexpected positions received from the database; want: %v, got %v", failure, wantPositions, gotPositions) + } else { + t.Logf("%s\tExpected positions received from the database; got %v", success, gotPositions) + } + } +} + +func testDeleteNonEmptyStatus(kanban board.Board, statusID int) func(t *testing.T) { + return func(t *testing.T) { + t.Log("When deleting a non-empty status.") + + err := kanban.DeleteStatus(statusID) + + switch { + case err == nil: + t.Errorf("%s\tExpected an error for deleting a non-empty status but received 'nil'", failure) + case errors.As(err, &board.StatusNotEmptyError{}): + t.Logf("%s\tExpected error received after attempting to delete a non-empty status; got: '%v'", success, err) + default: + t.Errorf("%s\tUnexpected error received after attempting to delete a non-empty status; got: '%v'", failure, err) + } + } +} diff --git a/internal/ui/init.go b/internal/ui/init.go index 7339d32..beb71b0 100644 --- a/internal/ui/init.go +++ b/internal/ui/init.go @@ -24,6 +24,7 @@ func (a *App) initColumns() error { } a.columns = columns + a.focusedColumn = 0 return nil } @@ -47,23 +48,28 @@ func (a *App) initCardForm() { a.cardForm.setDoneFunc(doneFunc) } -// initDeleteCardModal initialises the modal for deleting cards. -func (a *App) initDeleteCardModal() { +// initDeleteModal initialises the modal for deleting cards or statuses. +func (a *App) initDeleteModal() { doneFunc := func(_ int, buttonLabel string) { if buttonLabel == "Confirm" { - a.deleteFocusedCard() + switch a.mode { + case normal: + a.deleteFocusedCard() + case boardEdit: + a.deleteFocusedStatusColumn() + } } - a.pages.HidePage(deleteCardModalPage) + a.pages.HidePage(deleteModalPage) a.setColumnFocus() } - a.deleteCardModal.AddButtons([]string{"Confirm", "Cancel"}).SetDoneFunc(doneFunc) + a.deleteModal.AddButtons([]string{"Cancel", "Confirm"}).SetDoneFunc(doneFunc) - a.deleteCardModal.SetBorder(true).SetBorderColor(tcell.ColorOrangeRed) - a.deleteCardModal.SetBackgroundColor(tcell.ColorBlack.TrueColor()) - a.deleteCardModal.SetButtonBackgroundColor(tcell.ColorBlueViolet.TrueColor()) - a.deleteCardModal.SetTextColor(tcell.ColorWhite.TrueColor()) + a.deleteModal.SetBorder(true).SetBorderColor(tcell.ColorOrangeRed) + a.deleteModal.SetBackgroundColor(tcell.ColorBlack.TrueColor()) + a.deleteModal.SetButtonBackgroundColor(tcell.ColorBlueViolet.TrueColor()) + a.deleteModal.SetTextColor(tcell.ColorWhite.TrueColor()) } // initQuitModal initialises the quit modal. diff --git a/internal/ui/keymappings.go b/internal/ui/keymappings.go index cbc03d9..0a021d5 100644 --- a/internal/ui/keymappings.go +++ b/internal/ui/keymappings.go @@ -87,16 +87,22 @@ func (a *App) edit() { } func (a *App) delete() { - if a.mode == normal { + switch a.mode { + case normal: card, ok := a.getFocusedCard() if !ok { return } - text := fmt.Sprintf("Do you want to delete '%s'?", card.Title) - a.deleteCardModal.SetText(text) - a.pages.ShowPage(deleteCardModalPage) - a.SetFocus(a.deleteCardModal) + text := fmt.Sprintf("Do you want to delete the CARD %q?", card.Title) + a.deleteModal.SetText(text) + a.pages.ShowPage(deleteModalPage) + a.SetFocus(a.deleteModal) + case boardEdit: + text := fmt.Sprintf("Do you want to delete the STATUS %q?", a.focusedStatusName()) + a.deleteModal.SetText(text) + a.pages.ShowPage(deleteModalPage) + a.SetFocus(a.deleteModal) } } diff --git a/internal/ui/ui.go b/internal/ui/ui.go index 9d4ab8b..0b5e1c2 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -19,12 +19,12 @@ const ( ) const ( - mainPage string = "main" - quitPage string = "quit" - cardFormPage string = "card form" - deleteCardModalPage string = "delete card modal" - viewPage string = "view" - statusFormPage string = "status form" + mainPage string = "main" + quitPage string = "quit" + cardFormPage string = "card form" + deleteModalPage string = "delete modal" + viewPage string = "view" + statusFormPage string = "status form" ) const ( @@ -51,7 +51,7 @@ type App struct { modeView *modeView quitModal *tview.Modal cardForm *cardForm - deleteCardModal *tview.Modal + deleteModal *tview.Modal statusSelection statusSelection cardView *cardView statusbar *statusbar @@ -75,7 +75,7 @@ func NewApp(path string) (App, error) { focusedColumn: 0, columns: nil, board: kanban, - deleteCardModal: tview.NewModal(), + deleteModal: tview.NewModal(), mode: normal, modeView: newModeView(), statusSelection: statusSelection{0, 0, 0}, @@ -108,8 +108,8 @@ func (a *App) Init() error { a.initCardForm() a.pages.AddPage(cardFormPage, a.cardForm, false, false) - a.initDeleteCardModal() - a.pages.AddPage(deleteCardModalPage, a.deleteCardModal, false, false) + a.initDeleteModal() + a.pages.AddPage(deleteModalPage, a.deleteModal, false, false) a.initCardView() a.pages.AddPage(viewPage, a.cardView, false, false) @@ -366,3 +366,20 @@ func (a *App) saveNewStatus(name string) { a.refresh(refreshArgs{updateFocusedColumnOnly: false, reinitialiseColumns: true}) } + +// deleteFocusedStatusColumn deletes the focused status column from the database. +// If the column is not empty, the column will not be deleted and an error will +// be shown in the status bar. +func (a *App) deleteFocusedStatusColumn() { + statusID := a.focusedStatusID() + + if err := a.board.DeleteStatus(statusID); err != nil { + a.statusbar.displayMessage(errorLevel, fmt.Sprintf("Failed to delete the status column: %v.", err)) + + return + } + + a.statusbar.displayMessage(infoLevel, "Status deleted successfully.") + + a.refresh(refreshArgs{updateFocusedColumnOnly: false, reinitialiseColumns: true}) +}