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
}
// 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

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)
}
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

View file

@ -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)
}
}
}

View file

@ -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.

View file

@ -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)
}
}

View file

@ -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})
}