feat: add support for deleting status columns
All checks were successful
/ test (pull_request) Successful in 29s
/ lint (pull_request) Successful in 3m48s

Add support for deleting status columns. If a column is not empty then
it will not be deleted and the user will see an error message in the
status bar.

Part of apollo/pelican#24
This commit is contained in:
Dan Anglin 2024-01-18 22:36:33 +00:00
parent 6ff571a716
commit d532c86475
Signed by: dananglin
GPG key ID: 0C1D44CFBEE68638
6 changed files with 176 additions and 36 deletions

View file

@ -160,11 +160,11 @@ type UpdateStatusArgs struct {
StatusArgs StatusArgs
} }
// UpdateStatus modifies an existing status in the db. // UpdateStatus modifies an existing status in the database.
func (b *Board) UpdateStatus(args UpdateStatusArgs) error { func (b *Board) UpdateStatus(args UpdateStatusArgs) error {
status, err := b.Status(args.StatusID) status, err := b.Status(args.StatusID)
if err != nil { 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 { if len(args.Name) > 0 {
@ -182,10 +182,53 @@ func (b *Board) UpdateStatus(args UpdateStatusArgs) error {
return nil return nil
} }
// TODO: Finish implementation. // DeleteStatus deletes a status from the database.
// func (b *Board) DeleteStatus() error { // A status can only be deleted if it does not contain any cards.
// return nil 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 { type MoveToStatusArgs struct {
CardID int CardID int
@ -346,18 +389,18 @@ type DeleteCardArgs struct {
// DeleteCard deletes a card from the database. // DeleteCard deletes a card from the database.
func (b *Board) DeleteCard(args DeleteCardArgs) error { func (b *Board) DeleteCard(args DeleteCardArgs) error {
if err := db.Delete(b.db, db.CardBucket, args.CardID); err != nil { 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) status, err := b.Status(args.StatusID)
if err != nil { 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) status.RemoveCardID(args.CardID)
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 update the status in the database, %w", err) return fmt.Errorf("unable to update the status in the database; %w", err)
} }
return nil return nil

View file

@ -19,6 +19,14 @@ func (e StatusNotExistError) Error() string {
return fmt.Sprintf("status ID '%d' does not exist in the database", e.ID) 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. // Status represents the status of the Kanban board.
type Status struct { type Status struct {
ID int ID int

View file

@ -1,6 +1,7 @@
package board_test package board_test
import ( import (
"errors"
"os" "os"
"path/filepath" "path/filepath"
"reflect" "reflect"
@ -50,13 +51,28 @@ func TestStatusLifecycle(t *testing.T) {
t.Run("Test Read Status (Next)", testReadStatus(kanban, statusNextExpectedID, statusNextName, statusNextExpectedBoardPosition)) 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.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 (Doing to In Progress)", testUpdateStatus(kanban, 2, 2, "In Progress"))
t.Run("Test Status Update (Backlog)", testUpdateStatus(kanban, 1, 1, "Backlog")) 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.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))
// 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) { 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)
}
}
}

View file

@ -24,6 +24,7 @@ func (a *App) initColumns() error {
} }
a.columns = columns a.columns = columns
a.focusedColumn = 0
return nil return nil
} }
@ -47,23 +48,28 @@ func (a *App) initCardForm() {
a.cardForm.setDoneFunc(doneFunc) a.cardForm.setDoneFunc(doneFunc)
} }
// initDeleteCardModal initialises the modal for deleting cards. // initDeleteModal initialises the modal for deleting cards or statuses.
func (a *App) initDeleteCardModal() { func (a *App) initDeleteModal() {
doneFunc := func(_ int, buttonLabel string) { doneFunc := func(_ int, buttonLabel string) {
if buttonLabel == "Confirm" { 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.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.deleteModal.SetBorder(true).SetBorderColor(tcell.ColorOrangeRed)
a.deleteCardModal.SetBackgroundColor(tcell.ColorBlack.TrueColor()) a.deleteModal.SetBackgroundColor(tcell.ColorBlack.TrueColor())
a.deleteCardModal.SetButtonBackgroundColor(tcell.ColorBlueViolet.TrueColor()) a.deleteModal.SetButtonBackgroundColor(tcell.ColorBlueViolet.TrueColor())
a.deleteCardModal.SetTextColor(tcell.ColorWhite.TrueColor()) a.deleteModal.SetTextColor(tcell.ColorWhite.TrueColor())
} }
// initQuitModal initialises the quit modal. // initQuitModal initialises the quit modal.

View file

@ -87,16 +87,22 @@ func (a *App) edit() {
} }
func (a *App) delete() { func (a *App) delete() {
if a.mode == normal { switch a.mode {
case normal:
card, ok := a.getFocusedCard() card, ok := a.getFocusedCard()
if !ok { if !ok {
return return
} }
text := fmt.Sprintf("Do you want to delete '%s'?", card.Title) text := fmt.Sprintf("Do you want to delete the CARD %q?", card.Title)
a.deleteCardModal.SetText(text) a.deleteModal.SetText(text)
a.pages.ShowPage(deleteCardModalPage) a.pages.ShowPage(deleteModalPage)
a.SetFocus(a.deleteCardModal) 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)
} }
} }

View file

@ -19,12 +19,12 @@ const (
) )
const ( const (
mainPage string = "main" mainPage string = "main"
quitPage string = "quit" quitPage string = "quit"
cardFormPage string = "card form" cardFormPage string = "card form"
deleteCardModalPage string = "delete card modal" deleteModalPage string = "delete modal"
viewPage string = "view" viewPage string = "view"
statusFormPage string = "status form" statusFormPage string = "status form"
) )
const ( const (
@ -51,7 +51,7 @@ type App struct {
modeView *modeView modeView *modeView
quitModal *tview.Modal quitModal *tview.Modal
cardForm *cardForm cardForm *cardForm
deleteCardModal *tview.Modal deleteModal *tview.Modal
statusSelection statusSelection statusSelection statusSelection
cardView *cardView cardView *cardView
statusbar *statusbar statusbar *statusbar
@ -75,7 +75,7 @@ func NewApp(path string) (App, error) {
focusedColumn: 0, focusedColumn: 0,
columns: nil, columns: nil,
board: kanban, board: kanban,
deleteCardModal: tview.NewModal(), deleteModal: tview.NewModal(),
mode: normal, mode: normal,
modeView: newModeView(), modeView: newModeView(),
statusSelection: statusSelection{0, 0, 0}, statusSelection: statusSelection{0, 0, 0},
@ -108,8 +108,8 @@ func (a *App) Init() error {
a.initCardForm() a.initCardForm()
a.pages.AddPage(cardFormPage, a.cardForm, false, false) a.pages.AddPage(cardFormPage, a.cardForm, false, false)
a.initDeleteCardModal() a.initDeleteModal()
a.pages.AddPage(deleteCardModalPage, a.deleteCardModal, false, false) a.pages.AddPage(deleteModalPage, a.deleteModal, false, false)
a.initCardView() a.initCardView()
a.pages.AddPage(viewPage, a.cardView, false, false) 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}) 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})
}