feat: move a card between statuses #3
12 changed files with 549 additions and 208 deletions
|
@ -7,7 +7,9 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
if err := ui.App(); err != nil {
|
canal := ui.NewApp()
|
||||||
fmt.Printf("Error: %s", err)
|
|
||||||
|
if err := canal.Run(); err != nil {
|
||||||
|
fmt.Printf("Error: an error occurred while running Canal, %s", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
3
go.mod
3
go.mod
|
@ -3,7 +3,8 @@ module forge.dananglin.me.uk/code/dananglin/canal
|
||||||
go 1.16
|
go 1.16
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/eiannone/keyboard v0.0.0-20200508000154-caf4b762e807
|
github.com/gdamore/tcell/v2 v2.4.1-0.20210905002822-f057f0a857a1
|
||||||
github.com/magefile/mage v1.11.0
|
github.com/magefile/mage v1.11.0
|
||||||
|
github.com/rivo/tview v0.0.0-20210923051754-2cb20002bc4c
|
||||||
go.etcd.io/bbolt v1.3.6
|
go.etcd.io/bbolt v1.3.6
|
||||||
)
|
)
|
||||||
|
|
25
go.sum
25
go.sum
|
@ -1,8 +1,27 @@
|
||||||
github.com/eiannone/keyboard v0.0.0-20200508000154-caf4b762e807 h1:jdjd5e68T4R/j4PWxfZqcKY8KtT9oo8IPNVuV4bSXDQ=
|
github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko=
|
||||||
github.com/eiannone/keyboard v0.0.0-20200508000154-caf4b762e807/go.mod h1:Xoiu5VdKMvbRgHuY7+z64lhu/7lvax/22nzASF6GrO8=
|
github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg=
|
||||||
|
github.com/gdamore/tcell/v2 v2.4.1-0.20210905002822-f057f0a857a1 h1:QqwPZCwh/k1uYqq6uXSb9TRDhTkfQbO80v8zhnIe5zM=
|
||||||
|
github.com/gdamore/tcell/v2 v2.4.1-0.20210905002822-f057f0a857a1/go.mod h1:Az6Jt+M5idSED2YPGtwnfJV0kXohgdCBPmHGSYc1r04=
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||||
github.com/magefile/mage v1.11.0 h1:C/55Ywp9BpgVVclD3lRnSYCwXTYxmSppIgLeDYlNuls=
|
github.com/magefile/mage v1.11.0 h1:C/55Ywp9BpgVVclD3lRnSYCwXTYxmSppIgLeDYlNuls=
|
||||||
github.com/magefile/mage v1.11.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A=
|
github.com/magefile/mage v1.11.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A=
|
||||||
|
github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU=
|
||||||
|
github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||||
|
github.com/rivo/tview v0.0.0-20210923051754-2cb20002bc4c h1:ye4bWm8SafYmr0DADOKSfeVZ1Swzm9aLW+baCOcHDWE=
|
||||||
|
github.com/rivo/tview v0.0.0-20210923051754-2cb20002bc4c/go.mod h1:WIfMkQNY+oq/mWwtsjOYHIZBuwthioY2srOmljJkTnk=
|
||||||
|
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
|
||||||
|
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||||
go.etcd.io/bbolt v1.3.6 h1:/ecaJf0sk1l4l6V4awd65v2C3ILy7MSj+s/x1ADCIMU=
|
go.etcd.io/bbolt v1.3.6 h1:/ecaJf0sk1l4l6V4awd65v2C3ILy7MSj+s/x1ADCIMU=
|
||||||
go.etcd.io/bbolt v1.3.6/go.mod h1:qXsaaIqmgQH0T+OPdb99Bf+PKfBBQVAdyD6TY9G8XM4=
|
go.etcd.io/bbolt v1.3.6/go.mod h1:qXsaaIqmgQH0T+OPdb99Bf+PKfBBQVAdyD6TY9G8XM4=
|
||||||
golang.org/x/sys v0.0.0-20200923182605-d9f96fdee20d h1:L/IKR6COd7ubZrs2oTnTi73IhgqJ71c9s80WsQnh0Es=
|
|
||||||
golang.org/x/sys v0.0.0-20200923182605-d9f96fdee20d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20200923182605-d9f96fdee20d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210309074719-68d13333faf2 h1:46ULzRKLh1CwgRq2dC5SlBzEqqNCi8rreOZnNrbqcIY=
|
||||||
|
golang.org/x/sys v0.0.0-20210309074719-68d13333faf2/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/term v0.0.0-20201210144234-2321bbc49cbf/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
|
golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d h1:SZxvLBoTP5yHO3Frd4z4vrF+DBX9vMVanchswa69toE=
|
||||||
|
golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
golang.org/x/text v0.3.6 h1:aRYxNxv6iGQlyVaZmk6ZgYEDa+Jg18DxebPSrd6bg1M=
|
||||||
|
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
|
17
internal/board/args.go
Normal file
17
internal/board/args.go
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
package board
|
||||||
|
|
||||||
|
type CardArgs struct {
|
||||||
|
NewTitle string
|
||||||
|
NewContent string
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdateCardArgs struct {
|
||||||
|
CardID int
|
||||||
|
CardArgs
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdateCardStatusArgs struct {
|
||||||
|
CardID int
|
||||||
|
OldStatusID int
|
||||||
|
NewStatusID int
|
||||||
|
}
|
|
@ -10,8 +10,8 @@ import (
|
||||||
bolt "go.etcd.io/bbolt"
|
bolt "go.etcd.io/bbolt"
|
||||||
)
|
)
|
||||||
|
|
||||||
// LoadBoard reads the board from the database. If no board exists then a new one will be created.
|
// OpenProject reads the project from the database. If no board exists then a new one will be created.
|
||||||
func LoadBoard(path string) (*bolt.DB, error) {
|
func OpenProject(path string) (*bolt.DB, error) {
|
||||||
db, err := database.OpenDatabase(path)
|
db, err := database.OpenDatabase(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("unable to open the database, %w", err)
|
return nil, fmt.Errorf("unable to open the database, %w", err)
|
||||||
|
@ -31,7 +31,7 @@ func LoadBoard(path string) (*bolt.DB, error) {
|
||||||
boltItems[i] = &newStatusList[i]
|
boltItems[i] = &newStatusList[i]
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := database.WriteMany(db, database.StatusBucket, boltItems); err != nil {
|
if _, err := database.WriteMany(db, database.StatusBucket, boltItems); err != nil {
|
||||||
return nil, fmt.Errorf("unable to save the default status list to the database, %w", err)
|
return nil, fmt.Errorf("unable to save the default status list to the database, %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -88,7 +88,7 @@ func DeleteStatus(db *bolt.DB) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateCard creates a card in the database.
|
// CreateCard creates a card in the database.
|
||||||
func CreateCard(db *bolt.DB, title, content string) error {
|
func CreateCard(db *bolt.DB, args CardArgs) error {
|
||||||
statusList, err := ReadStatusList(db)
|
statusList, err := ReadStatusList(db)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("unable to read the status list, %w", err)
|
return fmt.Errorf("unable to read the status list, %w", err)
|
||||||
|
@ -100,8 +100,8 @@ func CreateCard(db *bolt.DB, title, content string) error {
|
||||||
|
|
||||||
card := Card{
|
card := Card{
|
||||||
ID: -1,
|
ID: -1,
|
||||||
Title: title,
|
Title: args.NewTitle,
|
||||||
Content: content,
|
Content: args.NewContent,
|
||||||
}
|
}
|
||||||
|
|
||||||
cardID, err := database.Write(db, database.CardBucket, &card)
|
cardID, err := database.Write(db, database.CardBucket, &card)
|
||||||
|
@ -140,19 +140,46 @@ func ReadCard(db *bolt.DB, id int) (Card, error) {
|
||||||
return card, nil
|
return card, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ReadCardList returns a list of Card values from the database.
|
||||||
|
// TODO: function needs testing.
|
||||||
|
func ReadCardList(db *bolt.DB, ids []int) ([]Card, error) {
|
||||||
|
data, err := database.ReadMany(db, database.CardBucket, ids)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("unable to read card list from the database, %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cards := make([]Card, len(data))
|
||||||
|
|
||||||
|
for i, d := range data {
|
||||||
|
buf := bytes.NewBuffer(d)
|
||||||
|
|
||||||
|
decoder := gob.NewDecoder(buf)
|
||||||
|
|
||||||
|
var c Card
|
||||||
|
|
||||||
|
if err := decoder.Decode(&c); err != nil {
|
||||||
|
return nil, fmt.Errorf("unable to decode data, %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cards[i] = c
|
||||||
|
}
|
||||||
|
|
||||||
|
return cards, nil
|
||||||
|
}
|
||||||
|
|
||||||
// UpdateCard modifies an existing card and saves the modification to the database.
|
// UpdateCard modifies an existing card and saves the modification to the database.
|
||||||
func UpdateCard(db *bolt.DB, id int, title, content string) error {
|
func UpdateCard(db *bolt.DB, args UpdateCardArgs) error {
|
||||||
card, err := ReadCard(db, id)
|
card, err := ReadCard(db, args.CardID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(title) > 0 {
|
if len(args.NewTitle) > 0 {
|
||||||
card.Title = title
|
card.Title = args.NewTitle
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(content) > 0 {
|
if len(args.NewContent) > 0 {
|
||||||
card.Content = content
|
card.Content = args.NewContent
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := database.Write(db, database.CardBucket, &card); err != nil {
|
if _, err := database.Write(db, database.CardBucket, &card); err != nil {
|
||||||
|
@ -162,9 +189,9 @@ func UpdateCard(db *bolt.DB, id int, title, content string) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// MoveCard moves a card between statuses.
|
// UpdateCardStatus moves a card between statuses.
|
||||||
// TODO: finish implementation.
|
// TODO: finish implementation.
|
||||||
func MoveCard(db *bolt.DB, fromStatusID, toStatusID int) error {
|
func UpdateCardStatus(db *bolt.DB, args UpdateCardStatusArgs) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -22,7 +22,7 @@ func TestCardLifecycle(t *testing.T) {
|
||||||
testDBPath := filepath.Join(projectDir, "test", "databases", "Board_TestCardLifecycle.db")
|
testDBPath := filepath.Join(projectDir, "test", "databases", "Board_TestCardLifecycle.db")
|
||||||
os.Remove(testDBPath)
|
os.Remove(testDBPath)
|
||||||
|
|
||||||
db, err := board.LoadBoard(testDBPath)
|
db, err := board.OpenProject(testDBPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Unable to open the test database %s, %s.", testDBPath, err)
|
t.Fatalf("Unable to open the test database %s, %s.", testDBPath, err)
|
||||||
}
|
}
|
||||||
|
|
|
@ -51,55 +51,57 @@ func OpenDatabase(path string) (*bolt.DB, error) {
|
||||||
return db, nil
|
return db, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// WriteMany saves one or more statuses to the status bucket.
|
// Read retrieves a Bolt item from a specified bucket and returns the data in bytes.
|
||||||
func WriteMany(db *bolt.DB, bucketName string, items []BoltItem) error {
|
func Read(db *bolt.DB, bucketName string, id int) ([]byte, error) {
|
||||||
if len(items) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
bucket := []byte(bucketName)
|
bucket := []byte(bucketName)
|
||||||
|
|
||||||
err := db.Update(func(tx *bolt.Tx) error {
|
var data []byte
|
||||||
|
|
||||||
|
if err := db.View(func(tx *bolt.Tx) error {
|
||||||
b := tx.Bucket(bucket)
|
b := tx.Bucket(bucket)
|
||||||
|
|
||||||
if b == nil {
|
if b == nil {
|
||||||
return bucketNotExistError{bucket: string(bucket)}
|
return bucketNotExistError{bucket: string(bucket)}
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, i := range items {
|
data = b.Get([]byte(strconv.Itoa(id)))
|
||||||
var err error
|
|
||||||
|
|
||||||
if i.Id() < 1 {
|
return nil
|
||||||
var id uint64
|
}); err != nil {
|
||||||
if id, err = b.NextSequence(); err != nil {
|
return []byte{}, fmt.Errorf("error while reading the Bolt item from the database, %w", err)
|
||||||
return fmt.Errorf("unable to generate ID, %w", err)
|
}
|
||||||
}
|
|
||||||
i.UpdateId(int(id))
|
|
||||||
}
|
|
||||||
|
|
||||||
buf := new(bytes.Buffer)
|
return data, nil
|
||||||
encoder := gob.NewEncoder(buf)
|
}
|
||||||
if err = encoder.Encode(i); err != nil {
|
|
||||||
return fmt.Errorf("unable to encode data, %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err = b.Put([]byte(strconv.Itoa(i.Id())), buf.Bytes()); err != nil {
|
// ReadMany reads one or more Bolt items from the specified bucket.
|
||||||
return fmt.Errorf("unable to add the status to the bucket, %w", err)
|
func ReadMany(db *bolt.DB, bucketName string, ids []int) ([][]byte, error) {
|
||||||
}
|
bucket := []byte(bucketName)
|
||||||
|
|
||||||
|
var output [][]byte
|
||||||
|
|
||||||
|
err := db.View(func(tx *bolt.Tx) error {
|
||||||
|
b := tx.Bucket(bucket)
|
||||||
|
|
||||||
|
if b == nil {
|
||||||
|
return bucketNotExistError{bucket: bucketName}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, v := range ids {
|
||||||
|
data := b.Get([]byte(strconv.Itoa(v)))
|
||||||
|
output = append(output, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("error while saving the statuses to the database, %w", err)
|
return output, fmt.Errorf("error while retrieving the data from the database, %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return output, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Create ReadMany
|
// ReadAll retrieves all the Bolt Items from the specified bucket.
|
||||||
|
|
||||||
// ReadAll retrieves all the statuses from the status bucket.
|
|
||||||
func ReadAll(db *bolt.DB, bucketName string) ([][]byte, error) {
|
func ReadAll(db *bolt.DB, bucketName string) ([][]byte, error) {
|
||||||
bucket := []byte(bucketName)
|
bucket := []byte(bucketName)
|
||||||
|
|
||||||
|
@ -109,7 +111,7 @@ func ReadAll(db *bolt.DB, bucketName string) ([][]byte, error) {
|
||||||
b := tx.Bucket(bucket)
|
b := tx.Bucket(bucket)
|
||||||
|
|
||||||
if b == nil {
|
if b == nil {
|
||||||
return bucketNotExistError{bucket: string(bucket)}
|
return bucketNotExistError{bucket: bucketName}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := b.ForEach(func(_, v []byte) error {
|
if err := b.ForEach(func(_, v []byte) error {
|
||||||
|
@ -168,27 +170,54 @@ func Write(db *bolt.DB, bucketName string, item BoltItem) (int, error) {
|
||||||
return item.Id(), nil
|
return item.Id(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read retrieves a Bolt item from a specified bucket and returns the data in bytes.
|
// WriteMany saves one or more Bolt items to the status bucket.
|
||||||
func Read(db *bolt.DB, bucketName string, id int) ([]byte, error) {
|
func WriteMany(db *bolt.DB, bucketName string, items []BoltItem) ([]int, error) {
|
||||||
|
if len(items) == 0 {
|
||||||
|
return []int{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ids := make([]int, len(items))
|
||||||
|
|
||||||
bucket := []byte(bucketName)
|
bucket := []byte(bucketName)
|
||||||
|
|
||||||
var data []byte
|
err := db.Update(func(tx *bolt.Tx) error {
|
||||||
|
|
||||||
if err := db.View(func(tx *bolt.Tx) error {
|
|
||||||
b := tx.Bucket(bucket)
|
b := tx.Bucket(bucket)
|
||||||
|
|
||||||
if b == nil {
|
if b == nil {
|
||||||
return bucketNotExistError{bucket: string(bucket)}
|
return bucketNotExistError{bucket: string(bucket)}
|
||||||
}
|
}
|
||||||
|
|
||||||
data = b.Get([]byte(strconv.Itoa(id)))
|
for i, v := range items {
|
||||||
|
var err error
|
||||||
|
|
||||||
|
if v.Id() < 1 {
|
||||||
|
var id uint64
|
||||||
|
if id, err = b.NextSequence(); err != nil {
|
||||||
|
return fmt.Errorf("unable to generate ID, %w", err)
|
||||||
|
}
|
||||||
|
v.UpdateId(int(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
buf := new(bytes.Buffer)
|
||||||
|
encoder := gob.NewEncoder(buf)
|
||||||
|
if err = encoder.Encode(v); err != nil {
|
||||||
|
return fmt.Errorf("unable to encode data, %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = b.Put([]byte(strconv.Itoa(v.Id())), buf.Bytes()); err != nil {
|
||||||
|
return fmt.Errorf("unable to add the Bolt Item to the %s bucket, %w", bucketName, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ids[i] = v.Id()
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}); err != nil {
|
})
|
||||||
return []byte{}, fmt.Errorf("error while reading the Bolt item from the database, %w", err)
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("error while saving the Bolt Items to the database, %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return data, nil
|
return ids, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// dbPath returns the path to the database file. If a path is given then that is returned. Otherwise the default path is returned.
|
// dbPath returns the path to the database file. If a path is given then that is returned. Otherwise the default path is returned.
|
||||||
|
|
|
@ -112,7 +112,7 @@ func testWriteStatusList(t *testing.T, db *bolt.DB) {
|
||||||
boltItems[i] = &newStatusList[i]
|
boltItems[i] = &newStatusList[i]
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := database.WriteMany(db, database.StatusBucket, boltItems); err != nil {
|
if _, err := database.WriteMany(db, database.StatusBucket, boltItems); err != nil {
|
||||||
t.Fatalf("An error occurred whilst writing the initial status list to the database, %s", err)
|
t.Fatalf("An error occurred whilst writing the initial status list to the database, %s", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -175,7 +175,7 @@ func testReadStatusList(t *testing.T, db *bolt.DB) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestReadAndWriteCard(t *testing.T) {
|
func TestReadAndWriteCards(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
var db *bolt.DB
|
var db *bolt.DB
|
||||||
|
@ -198,20 +198,41 @@ func TestReadAndWriteCard(t *testing.T) {
|
||||||
_ = db.Close()
|
_ = db.Close()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
cardID := testWriteCard(t, db)
|
singleCard := board.Card{
|
||||||
testReadCard(t, db, cardID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testWriteCard(t *testing.T, db *bolt.DB) int {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
newCard := board.Card{
|
|
||||||
ID: -1,
|
ID: -1,
|
||||||
Title: "A test task.",
|
Title: "A test task.",
|
||||||
Content: "This task should be completed.",
|
Content: "This task should be completed.",
|
||||||
}
|
}
|
||||||
|
|
||||||
cardID, err := database.Write(db, database.CardBucket, &newCard)
|
singleCardID := testWriteOneCard(t, db, singleCard)
|
||||||
|
testReadOneCard(t, db, singleCardID)
|
||||||
|
|
||||||
|
manyCards := []board.Card{
|
||||||
|
{
|
||||||
|
ID: -1,
|
||||||
|
Title: "Test card A.",
|
||||||
|
Content: "This is test card A.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: -1,
|
||||||
|
Title: "Test card B.",
|
||||||
|
Content: "This is test card B.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: -1,
|
||||||
|
Title: "Test card C.",
|
||||||
|
Content: "This is test card C.",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
manyCardIDs := testWriteManyCard(t, db, manyCards)
|
||||||
|
testReadManyCards(t, db, manyCardIDs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testWriteOneCard(t *testing.T, db *bolt.DB, card board.Card) int {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
cardID, err := database.Write(db, database.CardBucket, &card)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("An error occurred whilst writing the card to the database, %s", err)
|
t.Fatalf("An error occurred whilst writing the card to the database, %s", err)
|
||||||
}
|
}
|
||||||
|
@ -219,7 +240,7 @@ func testWriteCard(t *testing.T, db *bolt.DB) int {
|
||||||
return cardID
|
return cardID
|
||||||
}
|
}
|
||||||
|
|
||||||
func testReadCard(t *testing.T, db *bolt.DB, cardID int) {
|
func testReadOneCard(t *testing.T, db *bolt.DB, cardID int) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
data, err := database.Read(db, database.CardBucket, cardID)
|
data, err := database.Read(db, database.CardBucket, cardID)
|
||||||
|
@ -250,6 +271,72 @@ func testReadCard(t *testing.T, db *bolt.DB, cardID int) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testWriteManyCard(t *testing.T, db *bolt.DB, cards []board.Card) []int {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
boltItems := make([]database.BoltItem, len(cards))
|
||||||
|
|
||||||
|
for i := range cards {
|
||||||
|
boltItems[i] = &cards[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
ids, err := database.WriteMany(db, database.CardBucket, boltItems)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("An error occurred whilst writing many cards to the database, %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
func testReadManyCards(t *testing.T, db *bolt.DB, cardIDs []int) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
data, err := database.ReadMany(db, database.CardBucket, cardIDs)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("An error occurred whilst reading the data from the database, %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := make([]board.Card, len(data))
|
||||||
|
|
||||||
|
for i, d := range data {
|
||||||
|
buf := bytes.NewBuffer(d)
|
||||||
|
|
||||||
|
decoder := gob.NewDecoder(buf)
|
||||||
|
|
||||||
|
var c board.Card
|
||||||
|
|
||||||
|
if err := decoder.Decode(&c); err != nil {
|
||||||
|
t.Fatalf("An error occurred whilst decoding data, %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got[i] = c
|
||||||
|
}
|
||||||
|
|
||||||
|
want := []board.Card{
|
||||||
|
{
|
||||||
|
ID: 2,
|
||||||
|
Title: "Test card A.",
|
||||||
|
Content: "This is test card A.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: 3,
|
||||||
|
Title: "Test card B.",
|
||||||
|
Content: "This is test card B.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: 4,
|
||||||
|
Title: "Test card C.",
|
||||||
|
Content: "This is test card C.",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Errorf("Unexpected list of cards read from the database: got %+v, want %+v", got, want)
|
||||||
|
} else {
|
||||||
|
t.Logf("Expected list of cards read from the database: got %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func projectRoot() (string, error) {
|
func projectRoot() (string, error) {
|
||||||
cwd, err := os.Getwd()
|
cwd, err := os.Getwd()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
154
internal/ui/app.go
Normal file
154
internal/ui/app.go
Normal file
|
@ -0,0 +1,154 @@
|
||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"forge.dananglin.me.uk/code/dananglin/canal/internal/board"
|
||||||
|
"github.com/rivo/tview"
|
||||||
|
bolt "go.etcd.io/bbolt"
|
||||||
|
)
|
||||||
|
|
||||||
|
type shiftDirection int
|
||||||
|
|
||||||
|
const (
|
||||||
|
shiftLeft shiftDirection = iota
|
||||||
|
shiftRight
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
mainPage string = "main"
|
||||||
|
quitPage string = "quit"
|
||||||
|
addPage string = "add"
|
||||||
|
)
|
||||||
|
|
||||||
|
// App does some magical stuff.
|
||||||
|
type App struct {
|
||||||
|
*tview.Application
|
||||||
|
|
||||||
|
columns []column
|
||||||
|
flex *tview.Flex
|
||||||
|
pages *tview.Pages
|
||||||
|
focusedColumn int
|
||||||
|
db *bolt.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// shutdown shuts down the application.
|
||||||
|
func (a *App) shutdown() {
|
||||||
|
a.closeDB()
|
||||||
|
a.Stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
// closeDB closes the BoltDB database.
|
||||||
|
func (a *App) closeDB() {
|
||||||
|
if a.db != nil {
|
||||||
|
_ = a.db.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// openProject opens the kanban project.
|
||||||
|
func (a *App) openProject(path string) error {
|
||||||
|
if a.db != nil && len(a.db.Path()) > 0 {
|
||||||
|
a.db.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := board.OpenProject(path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("unable to load board, %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
a.db = db
|
||||||
|
|
||||||
|
if err = a.refresh(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// refresh refreshes the UI.
|
||||||
|
func (a *App) refresh() error {
|
||||||
|
statusList, err := board.ReadStatusList(a.db)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("unable to get the status list, %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
a.updateBoard(statusList)
|
||||||
|
|
||||||
|
a.setColumnFocus()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) updateBoard(statusList []board.Status) error {
|
||||||
|
a.flex.Clear()
|
||||||
|
columns := make([]column, len(statusList))
|
||||||
|
|
||||||
|
for i := range statusList {
|
||||||
|
columns[i] = a.newColumn(statusList[i].ID, statusList[i].Name)
|
||||||
|
|
||||||
|
if len(statusList[i].CardIds) > 0 {
|
||||||
|
cards, err := board.ReadCardList(a.db, statusList[i].CardIds)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("unable to get the card list. %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range cards {
|
||||||
|
columns[i].cards.AddItem(fmt.Sprintf("[%d] %s", c.Id(), c.Title), "", 0, nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
a.flex.AddItem(columns[i].cards, 0, 1, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
a.columns = columns
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) shiftColumnFocus(s shiftDirection) {
|
||||||
|
switch s {
|
||||||
|
case shiftRight:
|
||||||
|
if a.focusedColumn == len(a.columns)-1 {
|
||||||
|
a.focusedColumn = 0
|
||||||
|
} else {
|
||||||
|
a.focusedColumn++
|
||||||
|
}
|
||||||
|
case shiftLeft:
|
||||||
|
if a.focusedColumn == 0 {
|
||||||
|
a.focusedColumn = len(a.columns) - 1
|
||||||
|
} else {
|
||||||
|
a.focusedColumn--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
a.setColumnFocus()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) setColumnFocus() {
|
||||||
|
a.SetFocus(a.columns[a.focusedColumn].cards)
|
||||||
|
}
|
||||||
|
|
||||||
|
// newCard creates a new card and saves it to the database.
|
||||||
|
func (a *App) newCard(title, content string) error {
|
||||||
|
args := board.CardArgs{
|
||||||
|
NewTitle: title,
|
||||||
|
NewContent: content,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := board.CreateCard(a.db, args); err != nil {
|
||||||
|
return fmt.Errorf("unable to create card, %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
a.refresh()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Move 'Add' to the centre of the app
|
||||||
|
// TODO: Customize list primitive or create a new one
|
||||||
|
// TODO: If customizing exisiing list primitive, wrap list around a column type. Add statusID to it.
|
||||||
|
// TODO: Update card status (card ID, oldStatus, newStatus)
|
||||||
|
|
||||||
|
//func viewCard() error {
|
||||||
|
//return nil
|
||||||
|
//}
|
41
internal/ui/column.go
Normal file
41
internal/ui/column.go
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gdamore/tcell/v2"
|
||||||
|
"github.com/rivo/tview"
|
||||||
|
)
|
||||||
|
|
||||||
|
type column struct {
|
||||||
|
statusID int
|
||||||
|
statusName string
|
||||||
|
cards *tview.List
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) newColumn(statusID int, statusName string) column {
|
||||||
|
l := tview.NewList()
|
||||||
|
|
||||||
|
l.SetBorder(true)
|
||||||
|
l.ShowSecondaryText(false)
|
||||||
|
l.SetTitle(" " + statusName + " ")
|
||||||
|
l.SetHighlightFullLine(true)
|
||||||
|
l.SetSelectedFocusOnly(true)
|
||||||
|
l.SetWrapAround(false)
|
||||||
|
|
||||||
|
l.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||||
|
if event.Rune() == 'h' || event.Key() == tcell.KeyLeft {
|
||||||
|
a.shiftColumnFocus(shiftLeft)
|
||||||
|
} else if event.Rune() == 'l' || event.Key() == tcell.KeyRight {
|
||||||
|
a.shiftColumnFocus(shiftRight)
|
||||||
|
}
|
||||||
|
|
||||||
|
return event
|
||||||
|
})
|
||||||
|
|
||||||
|
c := column{
|
||||||
|
statusID: statusID,
|
||||||
|
statusName: statusName,
|
||||||
|
cards: l,
|
||||||
|
}
|
||||||
|
|
||||||
|
return c
|
||||||
|
}
|
100
internal/ui/init.go
Normal file
100
internal/ui/init.go
Normal file
|
@ -0,0 +1,100 @@
|
||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gdamore/tcell/v2"
|
||||||
|
"github.com/rivo/tview"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewApp returns a new App.
|
||||||
|
func NewApp() App {
|
||||||
|
a := App{
|
||||||
|
Application: tview.NewApplication(),
|
||||||
|
pages: tview.NewPages(),
|
||||||
|
flex: tview.NewFlex(),
|
||||||
|
focusedColumn: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
initApp(&a)
|
||||||
|
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
|
||||||
|
// initApp initialises App
|
||||||
|
func initApp(a *App) {
|
||||||
|
a.pages.AddPage(mainPage, a.flex, true, true)
|
||||||
|
|
||||||
|
quit := newQuitModal(a)
|
||||||
|
a.pages.AddPage(quitPage, quit, false, false)
|
||||||
|
|
||||||
|
add := newAddForm(a)
|
||||||
|
a.pages.AddPage(addPage, add, false, false)
|
||||||
|
|
||||||
|
a.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||||
|
if event.Rune() == 'q' {
|
||||||
|
a.pages.ShowPage(quitPage)
|
||||||
|
a.SetFocus(quit)
|
||||||
|
} else if event.Rune() == 'o' {
|
||||||
|
a.openProject("")
|
||||||
|
} else if event.Rune() == 'a' {
|
||||||
|
a.pages.ShowPage(addPage)
|
||||||
|
a.SetFocus(add)
|
||||||
|
}
|
||||||
|
|
||||||
|
return event
|
||||||
|
})
|
||||||
|
|
||||||
|
a.SetRoot(a.pages, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// newQuitModal returns a new modal for the user's confirmation
|
||||||
|
// for quitting the application.
|
||||||
|
func newQuitModal(a *App) *tview.Modal {
|
||||||
|
|
||||||
|
quit := *tview.NewModal()
|
||||||
|
|
||||||
|
quitDoneFunc := func(_ int, buttonLabel string) {
|
||||||
|
switch buttonLabel {
|
||||||
|
case "Quit":
|
||||||
|
a.shutdown()
|
||||||
|
default:
|
||||||
|
a.pages.SwitchToPage("main")
|
||||||
|
a.setColumnFocus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
quit.SetText("Do you want to quit the application?").
|
||||||
|
AddButtons([]string{"Quit", "Cancel"}).
|
||||||
|
SetDoneFunc(quitDoneFunc)
|
||||||
|
|
||||||
|
return &quit
|
||||||
|
}
|
||||||
|
|
||||||
|
// newAddForm creates a new Form primitive for creating a new card.
|
||||||
|
func newAddForm(a *App) *tview.Form {
|
||||||
|
add := tview.NewForm()
|
||||||
|
|
||||||
|
titleField := "Title"
|
||||||
|
|
||||||
|
add.AddInputField(titleField, "", 0, nil, nil)
|
||||||
|
|
||||||
|
add.AddButton("Save", func() {
|
||||||
|
title := add.GetFormItemByLabel(titleField).(*tview.InputField).GetText()
|
||||||
|
// TODO: error value needs handling
|
||||||
|
_ = a.newCard(title, "")
|
||||||
|
add.GetFormItemByLabel(titleField).(*tview.InputField).SetText("")
|
||||||
|
a.pages.SwitchToPage(mainPage)
|
||||||
|
a.setColumnFocus()
|
||||||
|
})
|
||||||
|
|
||||||
|
add.AddButton("Cancel", func() {
|
||||||
|
a.pages.SwitchToPage(mainPage)
|
||||||
|
add.GetFormItemByLabel(titleField).(*tview.InputField).SetText("")
|
||||||
|
a.setColumnFocus()
|
||||||
|
})
|
||||||
|
|
||||||
|
add.SetBorder(true)
|
||||||
|
|
||||||
|
add.SetTitle(" New Card ")
|
||||||
|
|
||||||
|
return add
|
||||||
|
}
|
|
@ -1,136 +0,0 @@
|
||||||
// Right now this will be a very simple, scuffed interface.
|
|
||||||
package ui
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"forge.dananglin.me.uk/code/dananglin/canal/internal/board"
|
|
||||||
"github.com/eiannone/keyboard"
|
|
||||||
bolt "go.etcd.io/bbolt"
|
|
||||||
)
|
|
||||||
|
|
||||||
func App() error {
|
|
||||||
var db *bolt.DB
|
|
||||||
|
|
||||||
var err error
|
|
||||||
|
|
||||||
keysEvents, err := keyboard.GetKeys(10)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("unable to create the keysEvent channel, %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
defer func() {
|
|
||||||
_ = keyboard.Close()
|
|
||||||
}()
|
|
||||||
|
|
||||||
fmt.Println(usage())
|
|
||||||
|
|
||||||
app:
|
|
||||||
for {
|
|
||||||
event := <-keysEvents
|
|
||||||
if event.Err != nil {
|
|
||||||
return fmt.Errorf("keys event error: %w", event.Err)
|
|
||||||
}
|
|
||||||
|
|
||||||
switch event.Rune {
|
|
||||||
case 'q':
|
|
||||||
break app
|
|
||||||
case 'r':
|
|
||||||
if err = refresh(db); err != nil {
|
|
||||||
fmt.Printf("Error: Unable to refresh board, %s\n", err)
|
|
||||||
}
|
|
||||||
case 'a':
|
|
||||||
if err = newCard(db); err != nil {
|
|
||||||
fmt.Printf("Error: Unable to add a card, %s\n", err)
|
|
||||||
}
|
|
||||||
case 'v':
|
|
||||||
if err = viewCard(db, 1); err != nil {
|
|
||||||
fmt.Printf("Error: Unable to view card, %s\n", err)
|
|
||||||
}
|
|
||||||
case 'o':
|
|
||||||
// TODO: How do we close the db?
|
|
||||||
db, err = openProject("")
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("Error: Unable to open the project, %s\n", err)
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
fmt.Println("Error: Unknown key event.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
db.Close()
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func refresh(db *bolt.DB) error {
|
|
||||||
statusList, err := board.ReadStatusList(db)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("unable to get the status list, %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("--------------------\n")
|
|
||||||
|
|
||||||
for _, s := range statusList {
|
|
||||||
fmt.Printf("Status ID: %d\nStatus Name: \"%s\"\nCard IDs: %v\n\n", s.ID, s.Name, s.CardIds)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("--------------------\n\n\n")
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func newCard(db *bolt.DB) error {
|
|
||||||
title := "A card title"
|
|
||||||
|
|
||||||
content := "As a user, this is a ticket for me.\nAs a user, I want to close it."
|
|
||||||
|
|
||||||
if err := board.CreateCard(db, title, content); err != nil {
|
|
||||||
return fmt.Errorf("unable to create card, %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Println("Sample card created successfully.")
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func viewCard(db *bolt.DB, id int) error {
|
|
||||||
card, err := board.ReadCard(db, id)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("unable to read card, %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("====================\n")
|
|
||||||
fmt.Printf("[%d] %s\n", card.ID, card.Title)
|
|
||||||
fmt.Printf("--------------------\n")
|
|
||||||
fmt.Println(card.Content)
|
|
||||||
fmt.Printf("====================\n")
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func openProject(path string) (*bolt.DB, error) {
|
|
||||||
db, err := board.LoadBoard(path)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("unable to load board, %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err = refresh(db); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return db, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func usage() string {
|
|
||||||
usage := `
|
|
||||||
Press 'o' to open the project
|
|
||||||
Press 'r' to refresh the board
|
|
||||||
Press 'a' to add a sample card
|
|
||||||
Press 'v' to view the first sample card
|
|
||||||
Press 'q' to quit
|
|
||||||
|
|
||||||
`
|
|
||||||
|
|
||||||
return usage
|
|
||||||
}
|
|
Loading…
Reference in a new issue