feat: add backend support for status repositioning
All checks were successful
/ test (pull_request) Successful in 36s
/ lint (pull_request) Successful in 38s

Add backend support for repositioning statuses on the Kanban board.
Part of apollo/pelican#27
This commit is contained in:
Dan Anglin 2024-01-25 01:36:22 +00:00
parent 73547c49c6
commit e99c8cd877
Signed by: dananglin
GPG key ID: 0C1D44CFBEE68638
3 changed files with 137 additions and 20 deletions

View file

@ -11,6 +11,7 @@ import (
bolt "go.etcd.io/bbolt" bolt "go.etcd.io/bbolt"
) )
// Board is probably the heart of Pelican.
type Board struct { type Board struct {
db *bolt.DB db *bolt.DB
} }
@ -90,9 +91,7 @@ func (b *Board) StatusList() ([]Status, error) {
return statuses, nil return statuses, nil
} }
// Status returns a single status from the db. // Status returns a single status from the database.
// TODO: Add a test case that handles when a status does not exist.
// Or use in delete status case.
func (b *Board) Status(statusID int) (Status, error) { func (b *Board) Status(statusID int) (Status, error) {
data, err := db.Read(b.db, db.StatusBucket, statusID) data, err := db.Read(b.db, db.StatusBucket, statusID)
if err != nil { if err != nil {
@ -116,6 +115,7 @@ func (b *Board) Status(statusID int) (Status, error) {
return status, nil return status, nil
} }
// StatusArgs is an argument type for creating or updating statuses.
type StatusArgs struct { type StatusArgs struct {
Name string Name string
Position int Position int
@ -198,21 +198,44 @@ func (b *Board) DeleteStatus(statusID int) error {
return fmt.Errorf("unable to delete the status from the database; %w", err) return fmt.Errorf("unable to delete the status from the database; %w", err)
} }
if err := b.normaliseStatusesPositionValues(); err != nil { if err := b.normaliseStatusesPositionValuesFromDatabase(); err != nil {
return fmt.Errorf("unable to normalise the statuses position values; %w", err) return fmt.Errorf("unable to normalise the statuses position values; %w", err)
} }
return nil return nil
} }
// normaliseStatusesPositionValues retrieves the ordered list of statuses from the database and sets // RepositionStatus re-positions a Status value on a slice of Statuses.
// each status' positional value based on its position in the list. func (b *Board) RepositionStatus(currentIndex, targetIndex int) error {
func (b *Board) normaliseStatusesPositionValues() error {
statuses, err := b.StatusList() statuses, err := b.StatusList()
if err != nil { if err != nil {
return fmt.Errorf("unable to get the list of statuses; %w", err) return fmt.Errorf("unable to get the list of statuses; %w", err)
} }
statuses = shuffle(statuses, currentIndex, targetIndex)
if err := b.normaliseStatusesPositionValues(statuses); err != nil {
return fmt.Errorf("unable to normalise the statuses position values; %w", err)
}
return nil
}
// normaliseStatusesPositionValuesFromDatabase retrieves the ordered list of statuses from the database and sets
// each status' positional value based on its position in the list before saving the updates to the database.
func (b *Board) normaliseStatusesPositionValuesFromDatabase() error {
statuses, err := b.StatusList()
if err != nil {
return fmt.Errorf("unable to get the list of statuses; %w", err)
}
return b.normaliseStatusesPositionValues(statuses)
}
// normaliseStatusesPositionValues takes a list of statuses and sets
// each status' positional value based on its position in the list before
// saving the updates to the database.
func (b *Board) normaliseStatusesPositionValues(statuses []Status) error {
for i, status := range statuses { for i, status := range statuses {
updateArgs := UpdateStatusArgs{ updateArgs := UpdateStatusArgs{
StatusID: status.ID, StatusID: status.ID,
@ -230,6 +253,7 @@ func (b *Board) normaliseStatusesPositionValues() error {
return nil return nil
} }
// MoveToStatusArgs is an argument type for moving a card between statuses.
type MoveToStatusArgs struct { type MoveToStatusArgs struct {
CardID int CardID int
CurrentStatusID int CurrentStatusID int
@ -260,6 +284,7 @@ func (b *Board) MoveToStatus(args MoveToStatusArgs) error {
return nil return nil
} }
// CardArgs is an argument type for creating or updating cards.
type CardArgs struct { type CardArgs struct {
NewTitle string NewTitle string
NewDescription string NewDescription string
@ -360,6 +385,7 @@ func (b *Board) CardList(ids []int) ([]Card, error) {
return cards, nil return cards, nil
} }
// UpdateCardArgs is an argument type for updating a card.
type UpdateCardArgs struct { type UpdateCardArgs struct {
CardID int CardID int
CardArgs CardArgs
@ -387,6 +413,7 @@ func (b *Board) UpdateCard(args UpdateCardArgs) error {
return nil return nil
} }
// DeleteCardArgs is an argument type for deleting a card.
type DeleteCardArgs struct { type DeleteCardArgs struct {
CardID int CardID int
StatusID int StatusID int

26
internal/board/shuffle.go Normal file
View file

@ -0,0 +1,26 @@
package board
// shuffle takes a list of Statuses and swaps its position with the Status closer to its desired
// position until it reaches it.
//
// This is currently used to move specified columns forwards or backwards on the Kanban board.
// When a column changes position the other columns shuffle forward or backwards as required.
func shuffle(list []Status, oldIndex, newIndex int) []Status {
if newIndex == oldIndex {
return list
}
if newIndex < oldIndex {
for i := oldIndex; i > newIndex; i-- {
list[i], list[i-1] = list[i-1], list[i]
}
return list
}
for i := oldIndex; i < newIndex; i++ {
list[i], list[i+1] = list[i+1], list[i]
}
return list
}

View file

@ -2,6 +2,7 @@ package board_test
import ( import (
"errors" "errors"
"fmt"
"os" "os"
"path/filepath" "path/filepath"
"reflect" "reflect"
@ -57,25 +58,65 @@ func TestStatusLifecycle(t *testing.T) {
t.Run("Test Status Update (Doing to 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 (To Do to 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) t.Logf("Our current column positioning is: Backlog, In Progress, Done, On Hold, Next.")
// Rearrange the board so the order is: Backlog, Next, In Progress, On Hold, Done t.Logf("Let us rearrange the board so that the order is: Backlog, Next, In Progress, On Hold, Done...")
// NOTE: the statuses current index is the index in the slice that is received from the database.
// The wantPositions is used to evaluate each statuses Position value after all the reshuffling and renormalisation.
rearrangeCases := []struct {
statusName string
currentIndex int
targetIndex int
wantPositions map[int]string
}{
{
statusName: "Done",
currentIndex: 2,
targetIndex: 4,
wantPositions: map[int]string{
1: "Backlog",
2: "In Progress",
3: "On Hold",
4: "Next",
5: "Done",
},
},
{
statusName: "Next",
currentIndex: 3,
targetIndex: 1,
wantPositions: map[int]string{
1: "Backlog",
2: "Next",
3: "In Progress",
4: "On Hold",
5: "Done",
},
},
}
for i := range rearrangeCases {
t.Run(
fmt.Sprintf("Test Re-Arrange Status: %s", rearrangeCases[i].statusName),
testRepositionStatuses(kanban, rearrangeCases[i].currentIndex, rearrangeCases[i].targetIndex, rearrangeCases[i].wantPositions),
)
}
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. expectedPositionsAfterDelete := map[int]string{
expectedPositions := map[int]string{
1: "Backlog", 1: "Backlog",
2: "In Progress", 2: "Next",
3: "Done", 3: "In Progress",
4: "Next", 4: "Done",
} }
t.Logf("Let us now delete the 'On Hold' status from the database...") 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.Run("Test Delete Status (On Hold)", testDeleteEmptyStatus(kanban, statusOnHoldExpectedID, expectedPositionsAfterDelete))
t.Logf("Additionally, let us try to delete a status that contains a card...") 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)) t.Run("Test Delete a non-empty status", testDeleteNonEmptyStatus(kanban, 2))
} }
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) {
@ -157,10 +198,31 @@ func testUpdateStatus(kanban board.Board, statusID, expectedPosition int, newNam
} }
} }
// func testRearrangeBoard() func(t *testing.T) { func testRepositionStatuses(kanban board.Board, currentIndex, targetIndex int, wantPositions map[int]string) func(t *testing.T) {
// return func(t *testing.T) { return func(t *testing.T) {
// } t.Logf("When repositioning a status on the board.")
// }
if err := kanban.RepositionStatus(currentIndex, targetIndex); err != nil {
t.Fatalf("ERROR: Unable to reposition the status; %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 testMoveCardToStatus(kanban board.Board) func(t *testing.T) { func testMoveCardToStatus(kanban board.Board) func(t *testing.T) {
return func(t *testing.T) { return func(t *testing.T) {
@ -186,6 +248,8 @@ func testMoveCardToStatus(kanban board.Board) func(t *testing.T) {
if err := kanban.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.Fatalf("ERROR: Unable to move the Card ID from '%s' to '%s', %v", status0.Name, status2.Name, err)
} else {
t.Logf("%s\tThe MoveToStatus operation has completed without error.", success)
} }
t.Logf("\tVerifying that the card has moved to '%s'...", status2.Name) t.Logf("\tVerifying that the card has moved to '%s'...", status2.Name)