fix: delete cards

This commit is contained in:
Dan Anglin 2023-04-28 22:38:26 +01:00
parent 4086a46065
commit 5005550cee
Signed by: dananglin
GPG key ID: 0C1D44CFBEE68638
10 changed files with 315 additions and 95 deletions

View file

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

View file

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

View file

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

View file

@ -1,7 +0,0 @@
package board
type statusListEmptyError struct{}
func (e statusListEmptyError) Error() string {
return "the status list must not be empty"
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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