diff --git a/internal/board/board.go b/internal/board/board.go index eb164d3..bdc5345 100644 --- a/internal/board/board.go +++ b/internal/board/board.go @@ -14,7 +14,7 @@ type Board struct { db *bolt.DB } -// Open reads the board from the db. +// Open reads the board from the database. // If no board exists then a new one will be created. func Open(path string) (Board, error) { database, err := db.OpenDatabase(path) @@ -65,7 +65,7 @@ func (b *Board) Close() error { func (b *Board) StatusList() ([]Status, error) { data, err := db.ReadAll(b.db, db.StatusBucket) if err != nil { - return []Status{}, fmt.Errorf("unable to read the status list, %w", err) + return nil, fmt.Errorf("unable to read the status list, %w", err) } statuses := make([]Status, len(data)) @@ -78,7 +78,7 @@ func (b *Board) StatusList() ([]Status, error) { var status Status if err := decoder.Decode(&status); err != nil { - return []Status{}, fmt.Errorf("unable to decode data, %w", err) + return nil, fmt.Errorf("unable to decode data, %w", err) } statuses[ind] = status @@ -90,12 +90,18 @@ 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) if err != nil { return Status{}, fmt.Errorf("unable to read status [%d] from the db. %w", id, err) } + if data == nil { + return Status{}, StatusNotExistError{ID: id} + } + var status Status buf := bytes.NewBuffer(data) @@ -197,7 +203,7 @@ type CardArgs struct { NewContent string } -// CreateCard creates a card in the db. +// CreateCard creates a card in the database. func (b *Board) CreateCard(args CardArgs) (int, error) { statusList, err := b.StatusList() if err != nil { @@ -205,7 +211,7 @@ func (b *Board) CreateCard(args CardArgs) (int, error) { } if len(statusList) == 0 { - return 0, statusListEmptyError{} + return 0, StatusListEmptyError{} } card := Card{ @@ -231,11 +237,16 @@ func (b *Board) CreateCard(args CardArgs) (int, error) { return id, nil } -// Card returns a Card value from the db. -func (b *Board) Card(id int) (Card, error) { - data, err := db.Read(b.db, db.CardBucket, id) +// Card returns a Card value from the database. +// TODO: Handle edge case where the card does not exist in the db. +func (b *Board) Card(cardID int) (Card, error) { + data, err := db.Read(b.db, db.CardBucket, cardID) if err != nil { - return Card{}, fmt.Errorf("unable to read card [%d] from the db. %w", id, err) + return Card{}, fmt.Errorf("unable to read card [%d] from the db. %w", cardID, err) + } + + if data == nil { + return Card{}, CardNotExistError{ID: cardID} } var card Card @@ -283,7 +294,7 @@ type UpdateCardArgs struct { CardArgs } -// UpdateCard modifies an existing card in the db. +// UpdateCard modifies an existing card in the database. func (b *Board) UpdateCard(args UpdateCardArgs) error { card, err := b.Card(args.CardID) if err != nil { @@ -305,8 +316,27 @@ func (b *Board) UpdateCard(args UpdateCardArgs) error { return nil } -// DeleteCard deletes a card from the db. -// TODO: finish implementation. -// func (b *Board) DeleteCard(id int) error { -// return nil -// } +type DeleteCardArgs struct { + CardID int + StatusID int +} + +// 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) + } + + status, err := b.Status(args.StatusID) + if err != nil { + 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 nil +} diff --git a/internal/board/card.go b/internal/board/card.go index 91b7f9e..153941d 100644 --- a/internal/board/card.go +++ b/internal/board/card.go @@ -1,5 +1,15 @@ package board +import "fmt" + +type CardNotExistError struct { + ID int +} + +func (e CardNotExistError) Error() string { + return fmt.Sprintf("card ID '%d' does not exist in the database", e.ID) +} + // Card represents a card on a Kanban board. type Card struct { ID int diff --git a/internal/board/card_lifecycle_test.go b/internal/board/card_lifecycle_test.go index d82e14c..978b6b4 100644 --- a/internal/board/card_lifecycle_test.go +++ b/internal/board/card_lifecycle_test.go @@ -1,6 +1,7 @@ package board_test import ( + "errors" "os" "path/filepath" "reflect" @@ -32,8 +33,9 @@ func TestCardLifecycle(t *testing.T) { initialCardTitle := "A test card." initialCardContent := "Ensure that this card is safely stored in the database." expectedCardID := 1 + expectedStatusID := 1 - t.Run("Test Create Card", testCreateCard(kanban, initialCardTitle, initialCardContent, expectedCardID)) + t.Run("Test Create Card", testCreateCard(kanban, initialCardTitle, initialCardContent, expectedCardID, expectedStatusID)) t.Run("Test Read Card", testReadCard(kanban, expectedCardID, initialCardTitle, initialCardContent)) @@ -45,9 +47,11 @@ func TestCardLifecycle(t *testing.T) { modifiedCardContent2 := "Updated card content only." t.Run("Test Update Card Content", testUpdateCardContent(kanban, expectedCardID, modifiedCardTitle, modifiedCardContent2)) + + t.Run("Test Card Delete", testDeleteCard(kanban, expectedCardID, expectedStatusID)) } -func testCreateCard(kanban board.Board, title, content string, wantID int) func(t *testing.T) { +func testCreateCard(kanban board.Board, title, content string, expectedCardID, expectedStatusID int) func(t *testing.T) { return func(t *testing.T) { t.Log("When the card is created and saved to the database.") @@ -60,25 +64,23 @@ func testCreateCard(kanban board.Board, title, content string, wantID int) func( t.Fatalf("ERROR: Unable to create the test card, %s.", err) } - statusList, err := kanban.StatusList() + t.Logf("\t\tVerifying that the card's ID is in the expected status...") + + status, err := kanban.Status(expectedStatusID) if err != nil { - t.Fatalf("ERROR: Unable to run `ReadStatusList`, %s.", err) + t.Fatalf("ERROR: Unable to read status '%d', %v", expectedStatusID, err) } - if len(statusList) == 0 { - t.Fatal("ERROR: The status list appears to be empty.") + numCardIDs := len(status.CardIds) + + if numCardIDs != 1 { + t.Fatalf("ERROR: Unexpected number of cards in status '%d', want: %d, got %d.", expectedStatusID, 1, numCardIDs) } - cardIDs := statusList[0].CardIds - - if len(cardIDs) != 1 { - t.Fatalf("ERROR: Unexpected number of cards in the default status, want: %d, got %d.", 1, len(cardIDs)) - } - - if gotID := cardIDs[0]; wantID != gotID { - t.Errorf("%s\tUnexpected card ID found in the default status, want: %d, got %d.", failure, wantID, gotID) + if expectedCardID != status.CardIds[0] { + t.Errorf("%s\tUnexpected card ID found in the default status, want: %d, got %d.", failure, expectedCardID, status.CardIds[0]) } else { - t.Logf("%s\tExpected card ID found in the default status, got %d.", success, gotID) + t.Logf("%s\tExpected card ID found in the default status, got %d.", success, status.CardIds[0]) } } } @@ -175,3 +177,51 @@ func testUpdateCardContent(kanban board.Board, cardID int, expectedTitle, newCon } } } + +func testDeleteCard(kanban board.Board, cardID, statusID int) func(t *testing.T) { + return func(t *testing.T) { + t.Log("When deleting a card from the database.") + + args := board.DeleteCardArgs{ + CardID: cardID, + StatusID: statusID, + } + + if err := kanban.DeleteCard(args); err != nil { + t.Fatalf("ERROR: An error occurred when deleting the card from the database, %v", err) + } else { + t.Logf("%s\tNo errors occurred when deleting the card from the database.", success) + } + + t.Logf("\tVerifying that the card is removed from the database...") + + _, err := kanban.Card(cardID) + if err == nil { + t.Errorf("%s\tDid not receive the expected error when attempting to read the deleted card.", failure) + } else { + if errors.Is(err, board.CardNotExistError{}) { + t.Errorf( + "%s\tDid not receive the expected board.CardNotExistError when attempting to retrieve the deleted card, instead got '%v'.", + failure, + err, + ) + } else { + t.Logf("%s\tSuccessfully received board.CardNotExistError when attempting to retrieve the deleted card.", success) + } + } + + t.Logf("\tVerifying that the card's ID is removed from the status list...") + + status, err := kanban.Status(statusID) + if err != nil { + t.Fatalf("ERROR: Unable to read status '%d' from the database; %v", statusID, err) + } + + numCardIDs := len(status.CardIds) + if numCardIDs != 0 { + t.Errorf("%s\tUnexpected non-empty list of card IDs in status '%d', got '%+v' card IDs.", failure, statusID, status.CardIds) + } else { + t.Logf("%s\tThe card ID was successfully removed from the list of card in status '%d'.", success, statusID) + } + } +} diff --git a/internal/board/errors.go b/internal/board/errors.go deleted file mode 100644 index 4410340..0000000 --- a/internal/board/errors.go +++ /dev/null @@ -1,7 +0,0 @@ -package board - -type statusListEmptyError struct{} - -func (e statusListEmptyError) Error() string { - return "the status list must not be empty" -} diff --git a/internal/board/status.go b/internal/board/status.go index ec7aecc..f46e536 100644 --- a/internal/board/status.go +++ b/internal/board/status.go @@ -2,8 +2,23 @@ package board import ( "sort" + "fmt" ) +type StatusListEmptyError struct{} + +func (e StatusListEmptyError) Error() string { + return "the status list must not be empty" +} + +type StatusNotExistError struct { + ID int +} + +func (e StatusNotExistError) Error() string { + return fmt.Sprintf("status ID '%d' does not exist in the database", 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 dc473f4..a03d419 100644 --- a/internal/board/status_lifecycle_test.go +++ b/internal/board/status_lifecycle_test.go @@ -95,7 +95,7 @@ func testUpdateStatus(kanban board.Board, statusID int, newName string) func(t * t.Logf("%s\tStatus successfully updated.", success) } - t.Log("Verifying the new status.") + t.Log("\tVerifying the new status...") status, err := kanban.Status(statusID) if err != nil { @@ -122,49 +122,40 @@ func testUpdateStatus(kanban board.Board, statusID int, newName string) func(t * // } // } -func testMoveCardToStatus(b board.Board) func(t *testing.T) { +func testMoveCardToStatus(kanban board.Board) func(t *testing.T) { return func(t *testing.T) { t.Log("When moving a card between statuses.") title := "Test card." - cardArgs := board.CardArgs{ - NewTitle: title, - NewContent: "", - } + cardArgs := board.CardArgs{NewTitle: title, NewContent: ""} - cardID, err := b.CreateCard(cardArgs) + cardID, err := kanban.CreateCard(cardArgs) if err != nil { t.Fatalf("ERROR: Unable to create the card in the database, %v", err) } - statusList, err := b.StatusList() + statusList, err := kanban.StatusList() if err != nil { t.Fatalf("ERROR: Unable to retrieve the list of statuses from the database, %v", err) } - status0 := statusList[0] - status2 := statusList[2] + status0, status2 := statusList[0], statusList[2] - moveArgs := board.MoveToStatusArgs{ - CardID: cardID, - CurrentStatusID: status0.ID, - NextStatusID: status2.ID, - } + moveArgs := board.MoveToStatusArgs{CardID: cardID, CurrentStatusID: status0.ID, NextStatusID: status2.ID} - if err := b.MoveToStatus(moveArgs); err != nil { + if err := kanban.MoveToStatus(moveArgs); err != nil { t.Fatalf("ERROR: Unable to move the Card ID from '%s' to '%s', %v", status0.Name, status2.Name, err) } - t.Logf("Verifying that the card has moved to '%s'", status2.Name) + t.Logf("\tVerifying that the card has moved to '%s'...", status2.Name) - statusList, err = b.StatusList() + statusList, err = kanban.StatusList() if err != nil { t.Fatalf("ERROR: Unable to retrieve the list of statuses from the database, %v", err) } - status0 = statusList[0] - status2 = statusList[2] + status0, status2 = statusList[0], statusList[2] if len(status0.CardIds) != 0 { t.Errorf("%s\tUnexpected number of card IDs found in '%s', want: 0, got: %d", failure, status0.Name, len(status0.CardIds)) @@ -178,7 +169,7 @@ func testMoveCardToStatus(b board.Board) func(t *testing.T) { t.Logf("%s\tThe number of card IDs in '%s' is now %d", success, status2.Name, len(status2.CardIds)) } - card, err := b.Card(status2.CardIds[0]) + card, err := kanban.Card(status2.CardIds[0]) if err != nil { t.Fatalf("ERROR: Unable to retrieve the card from the database, %v", err) } diff --git a/internal/db/database.go b/internal/db/database.go index 0e9a7b4..ab2c32a 100644 --- a/internal/db/database.go +++ b/internal/db/database.go @@ -220,6 +220,29 @@ func WriteMany(database *bolt.DB, bucketName string, items []BoltItem) ([]int, e return ids, nil } +// Delete deletes a Bolt item from a specified bucket. +func Delete(db *bolt.DB, bucketName string, itemID int) error { + bucketNameBytes := []byte(bucketName) + + if err := db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket(bucketNameBytes) + + if bucket == nil { + return bucketNotExistError{bucket: bucketName} + } + + if err := bucket.Delete([]byte(strconv.Itoa(itemID))); err != nil { + return fmt.Errorf("an error occurred when deleting Bolt item '%d', %w", itemID, err) + } + + return nil + }); err != nil { + return fmt.Errorf("error deleting data from the '%s' bucket, %w", bucketName, err) + } + + return nil +} + // dbPath returns the path to the database file. If a path is given then that is returned. Otherwise the default path is returned. // For linux, the default location of the database file is $XDG_DATA_HOME/canal/canal.db. If the XDG_DATA_HOME environment // variable is not set then it will default to $HOME/.local/share/canal/canal.db. For all other operating systems the default diff --git a/internal/db/database_test.go b/internal/db/database_test.go index e95afc1..94bea60 100644 --- a/internal/db/database_test.go +++ b/internal/db/database_test.go @@ -31,7 +31,7 @@ func TestOpenDataBaseXDGDataDir(t *testing.T) { _ = db.Close() - wantDB := filepath.Join(testXdgDataHome, "canal", "canal.db") + wantDB := filepath.Join(testXdgDataHome, "pelican", "pelican.db") // ensure that the database file exists _, err = os.Stat(wantDB) @@ -118,18 +118,18 @@ func testReadStatusList(t *testing.T, database *bolt.DB) { got := make([]board.Status, len(data)) - for i, d := range data { + for ind, d := range data { buf := bytes.NewBuffer(d) decoder := gob.NewDecoder(buf) - var s board.Status + var status board.Status - if err := decoder.Decode(&s); err != nil { + if err := decoder.Decode(&status); err != nil { t.Fatalf("An error occurred whilst decoding data, %s", err) } - got[i] = s + got[ind] = status } want := []board.Status{ @@ -327,3 +327,66 @@ func testReadManyCards(t *testing.T, database *bolt.DB, cardIDs []int) { t.Logf("Expected list of cards read from the database: got %+v", got) } } + +func TestDeleteOneCard(t *testing.T) { + t.Parallel() + + var database *bolt.DB + + var err error + + projectDir, err := projectRoot() + if err != nil { + t.Fatalf(err.Error()) + } + + testDB := filepath.Join(projectDir, "test", "databases", "Database_TestDeleteOneCard.db") + os.Remove(testDB) + + if database, err = db.OpenDatabase(testDB); err != nil { + t.Fatalf("An error occurred whilst opening the test database %s, %s.", testDB, err) + } + + defer func() { + _ = database.Close() + }() + + // Create one card, get card ID. + card := board.Card{ + ID: -1, + Title: "Test card", + Content: "", + } + + cardID, err := db.Write(database, db.CardBucket, &card) + if err != nil { + t.Fatalf("ERROR: Unable to create the card in the database, %v", err) + } + + cards, err := db.ReadAll(database, db.CardBucket) + if err != nil { + t.Fatalf("ERROR: Unable to read the cards from the database, %v", err) + } + + numCards := len(cards) + if numCards != 1 { + t.Fatalf("ERROR: Unexpected number of cards returned from the card bucket; want 1; got %d", numCards) + } + + if err := db.Delete(database, db.CardBucket, cardID); err != nil { + t.Fatalf("ERROR: Unable to delete the card from the database, %v", err) + } + + // Get all cards, expect length = 0; error if not 0 + cards, err = db.ReadAll(database, db.CardBucket) + if err != nil { + t.Fatalf("ERROR: Unable to read the cards from the database, %v", err) + } + + numCards = len(cards) + if numCards != 0 { + t.Errorf("%s\tUnexpected number of cards returned from the card bucket; want 0; got %d", failure, numCards) + } else { + t.Logf("%s\tThe card was successfully deleted from the database.", success) + } +} diff --git a/internal/ui/column.go b/internal/ui/column.go index d1e268b..706aba9 100644 --- a/internal/ui/column.go +++ b/internal/ui/column.go @@ -26,12 +26,9 @@ func (u *UI) newColumn(status board.Status) (column, error) { cardList.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { switch event.Rune() { - case 'q': - u.pages.ShowPage(quitPageName) - u.SetFocus(u.quit) case 'a': u.pages.ShowPage(addPageName) - u.SetFocus(u.add) + u.SetFocus(u.addModal) case 'h': u.shiftColumnFocus(shiftLeft) case 'l': @@ -53,6 +50,15 @@ func (u *UI) newColumn(status board.Status) (column, error) { u.SetFocus(u.move) } + switch event.Key() { + case tcell.KeyCtrlQ: + u.pages.ShowPage(quitPageName) + u.SetFocus(u.quitModal) + case tcell.KeyCtrlD: + u.pages.ShowPage(deleteCardPageName) + u.SetFocus(u.deleteCardModal) + } + return event }) diff --git a/internal/ui/ui.go b/internal/ui/ui.go index 95686b4..e05d4ca 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -15,37 +15,40 @@ const ( ) const ( - mainPageName string = "main" - quitPageName string = "quit" - addPageName string = "add" - movePageName string = "move" + mainPageName string = "main" + quitPageName string = "quit" + addPageName string = "add" + movePageName string = "move" + deleteCardPageName string = "delete card" ) type UI struct { *tview.Application - columns []column - flex *tview.Flex - pages *tview.Pages - focusedColumn int - board board.Board - quit *tview.Modal - add *modalInput - move *tview.Flex + columns []column + flex *tview.Flex + pages *tview.Pages + focusedColumn int + board board.Board + quitModal *tview.Modal + addModal *modalInput + move *tview.Flex + deleteCardModal *tview.Modal } // NewUI returns a new UI value. func NewUI() UI { ui := UI{ - Application: tview.NewApplication(), - pages: tview.NewPages(), - flex: tview.NewFlex(), - quit: tview.NewModal(), - add: NewModalInput(), - focusedColumn: 0, - columns: nil, - move: nil, - board: board.Board{}, + Application: tview.NewApplication(), + pages: tview.NewPages(), + flex: tview.NewFlex(), + quitModal: tview.NewModal(), + addModal: NewModalInput(), + focusedColumn: 0, + columns: nil, + move: nil, + board: board.Board{}, + deleteCardModal: tview.NewModal(), } ui.init() @@ -58,15 +61,34 @@ func (u *UI) closeBoard() { _ = u.board.Close() } +// deleteCard deletes a card from the board. +func (u *UI) deleteCard() { + currentItem := u.columns[u.focusedColumn].cards.GetCurrentItem() + _, cardIDText := u.columns[u.focusedColumn].cards.GetItemText(currentItem) + cardID, _ := strconv.Atoi(cardIDText) + + statusID := u.columns[u.focusedColumn].statusID + + args := board.DeleteCardArgs{ + CardID: cardID, + StatusID: statusID, + } + + _ = u.board.DeleteCard(args) +} + // init initialises the UI. func (u *UI) init() { u.pages.AddPage(mainPageName, u.flex, true, true) u.initQuitModal() - u.pages.AddPage(quitPageName, u.quit, false, false) + u.pages.AddPage(quitPageName, u.quitModal, false, false) u.initAddInputModal() - u.pages.AddPage(addPageName, u.add, false, false) + u.pages.AddPage(addPageName, u.addModal, false, false) + + u.initDeleteCardModal() + u.pages.AddPage(deleteCardPageName, u.deleteCardModal, false, false) u.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { switch event.Rune() { @@ -93,24 +115,41 @@ func (u *UI) initAddInputModal() { u.setColumnFocus() } - u.add.SetDoneFunc(doneFunc) + u.addModal.SetDoneFunc(doneFunc) +} + +// initDeleteCardModal initialises the modal for deleting cards. +func (u *UI) initDeleteCardModal() { + doneFunc := func(_ int, buttonLabel string) { + if buttonLabel == "Confirm" { + u.deleteCard() + _ = u.refresh() + } + + u.pages.HidePage(deleteCardPageName) + u.setColumnFocus() + } + + u.deleteCardModal.SetText("Do you want to delete this card?"). + AddButtons([]string{"Confirm", "Cancel"}). + SetDoneFunc(doneFunc) } // initQuitModal initialises the quit modal. func (u *UI) initQuitModal() { - quitDoneFunc := func(_ int, buttonLabel string) { + doneFunc := func(_ int, buttonLabel string) { switch buttonLabel { case "Quit": u.shutdown() default: - u.pages.SwitchToPage(mainPageName) + u.pages.HidePage(quitPageName) u.setColumnFocus() } } - u.quit.SetText("Do you want to quit the application?"). + u.quitModal.SetText("Do you want to quit the application?"). AddButtons([]string{"Quit", "Cancel"}). - SetDoneFunc(quitDoneFunc) + SetDoneFunc(doneFunc) } // newCard creates and saves a new card to the database.