feat(ui): add support for creating status columns
All checks were successful
/ test (pull_request) Successful in 34s
/ lint (pull_request) Successful in 46s

This commit adds support for creating new status columns. When the user
is in the 'board edit' mode, they can press the 'c' key to create a new
column. When a new column is created it automatically assumes the last
position on the board. We will add support later on to allow the user to
re-arrange the columns on the board.

Part of apollo/pelican#22
This commit is contained in:
Dan Anglin 2024-01-17 17:10:36 +00:00
parent f9fbda92ee
commit c1bb834a7f
Signed by: dananglin
GPG key ID: 0C1D44CFBEE68638
7 changed files with 141 additions and 59 deletions

View file

@ -93,14 +93,14 @@ 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)
func (b *Board) Status(statusID int) (Status, error) {
data, err := db.Read(b.db, db.StatusBucket, statusID)
if err != nil {
return Status{}, fmt.Errorf("unable to read status [%d] from the db. %w", id, err)
return Status{}, fmt.Errorf("unable to read status [%d] from the database; %w", statusID, err)
}
if data == nil {
return Status{}, StatusNotExistError{ID: id}
return Status{}, StatusNotExistError{ID: statusID}
}
var status Status
@ -110,33 +110,51 @@ func (b *Board) Status(id int) (Status, error) {
decoder := gob.NewDecoder(buf)
if err := decoder.Decode(&status); err != nil {
return Status{}, fmt.Errorf("unable to decode data into a Status object, %w", err)
return Status{}, fmt.Errorf("unable to decode data into a Status object; %w", err)
}
return status, nil
}
type StatusArgs struct {
Name string
Order int
Name string
Position int
}
// CreateStatus creates a status in the db.
// CreateStatus creates a status in the database.
func (b *Board) CreateStatus(args StatusArgs) error {
name := args.Name
pos := args.Position
if pos < 1 {
statuses, err := b.StatusList()
if err != nil {
return fmt.Errorf("unable to get the list of statuses; %w", err)
}
length := len(statuses)
if length == 0 {
pos = 1
} else {
pos = statuses[length-1].Position + 1
}
}
status := Status{
ID: -1,
Name: args.Name,
Position: args.Order,
Name: name,
Position: pos,
CardIds: nil,
}
if _, err := db.Write(b.db, db.StatusBucket, &status); err != nil {
return fmt.Errorf("unable to write the status to the db. %w", err)
return fmt.Errorf("unable to write the status to the database; %w", err)
}
return nil
}
// UpdateStatusArgs is an argument type required for updating statuses.
type UpdateStatusArgs struct {
StatusID int
StatusArgs
@ -153,8 +171,8 @@ func (b *Board) UpdateStatus(args UpdateStatusArgs) error {
status.Name = args.Name
}
if args.Order > 0 {
status.Position = args.Order
if args.Position > 0 {
status.Position = args.Position
}
if _, err := db.Write(b.db, db.StatusBucket, &status); err != nil {

View file

@ -10,7 +10,7 @@ import (
)
func TestStatusLifecycle(t *testing.T) {
t.Log("Testing the lifecycle of a status.")
t.Log("Testing the lifecycle of the statuses on the Kanban board...")
projectDir, err := projectRoot()
if err != nil {
@ -29,28 +29,43 @@ func TestStatusLifecycle(t *testing.T) {
_ = kanban.Close()
}()
// We've opened a new board with the default list of statuses: To Do, Doing, Done
t.Logf("We've opened a new board with the default list of statuses: 'To Do', 'Doing' and 'Done'")
t.Logf("Let us now create a new status called 'On Hold' in the 4th position...")
statusOnHoldName := "On Hold"
statusOnHoldExpectedID := 4
statusOnHoldOrder := 4
statusOnHoldBoardPosition := 4
t.Run("Test Create Status", testCreateStatus(kanban, statusOnHoldName, statusOnHoldOrder))
t.Run("Test Create Status (On Hold)", testCreateStatus(kanban, statusOnHoldName, statusOnHoldBoardPosition))
t.Run("Test Read Status (On Hold)", testReadStatus(kanban, statusOnHoldExpectedID, statusOnHoldName, statusOnHoldBoardPosition))
t.Run("Test Read Status", testReadStatus(kanban, statusOnHoldExpectedID, statusOnHoldName))
t.Logf("Additionally, let us create another status without specifying a position. It should take the last position on the board...")
t.Run("Test Status Update", testUpdateStatus(kanban, 2, "In Progress"))
// Rearrange the board so the order is To Do, On Hold, In Progress, Done
// Move a Card ID from To Do to In Progress
statusNextName := "Next"
statusNextExpectedID := 5
statusNextExpectedBoardPosition := 5
t.Run("Test Create Status (Next)", testCreateStatus(kanban, statusNextName, 0))
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"))
// (TODO: Rearranging statuses still needs to be implemented) Rearrange the board so the order is To Do, On Hold, In Progress, Done
t.Logf("Let us now try moving a card from one status to another...")
t.Run("Test Move Card To Status", testMoveCardToStatus(kanban))
}
func testCreateStatus(kanban board.Board, name string, order int) func(t *testing.T) {
func testCreateStatus(kanban board.Board, name string, position int) func(t *testing.T) {
return func(t *testing.T) {
t.Log("When the status is created and saved to the database.")
args := board.StatusArgs{
Name: name,
Order: order,
Name: name,
Position: position,
}
if err := kanban.CreateStatus(args); err != nil {
@ -61,7 +76,7 @@ func testCreateStatus(kanban board.Board, name string, order int) func(t *testin
}
}
func testReadStatus(kanban board.Board, statusID int, wantName string) func(t *testing.T) {
func testReadStatus(kanban board.Board, statusID int, wantName string, wantPosition int) func(t *testing.T) {
return func(t *testing.T) {
t.Log("When the status is read from the database.")
@ -75,18 +90,24 @@ func testReadStatus(kanban board.Board, statusID int, wantName string) func(t *t
} else {
t.Logf("%s\tSuccessfully received the '%s' status from the database.", success, status.Name)
}
if status.Position != wantPosition {
t.Errorf("%s\tUnexpected position found for %s, want: '%d', got: '%d'", failure, status.Name, wantPosition, status.Position)
} else {
t.Logf("%s\tSuccessfully received the expected position number for the '%s' status, got : '%d'.", success, status.Name, status.Position)
}
}
}
func testUpdateStatus(kanban board.Board, statusID int, newName string) func(t *testing.T) {
func testUpdateStatus(kanban board.Board, statusID, expectedPosition int, newName string) func(t *testing.T) {
return func(t *testing.T) {
t.Log("When the status' name is updated in the database.")
args := board.UpdateStatusArgs{
StatusID: statusID,
StatusArgs: board.StatusArgs{
Name: newName,
Order: -1,
Name: newName,
Position: -1,
},
}
if err := kanban.UpdateStatus(args); err != nil {
@ -106,7 +127,7 @@ func testUpdateStatus(kanban board.Board, statusID int, newName string) func(t *
ID: statusID,
Name: newName,
CardIds: nil,
Position: 2,
Position: expectedPosition,
}
if !reflect.DeepEqual(status, want) {

View file

@ -5,20 +5,13 @@ import (
"github.com/rivo/tview"
)
type cardFormMode int
const (
create cardFormMode = iota
edit
)
type cardForm struct {
*tview.Form
frame *tview.Frame
titleLabel string
descLabel string
done func(string, string, bool, cardFormMode)
mode cardFormMode
done func(string, string, bool, formMode)
mode formMode
}
func newCardForm() *cardForm {
@ -91,7 +84,7 @@ func (c *cardForm) updateInputFields(title, description string) {
c.AddTextArea(c.descLabel, description, 60, 10, 0, nil)
}
func (c *cardForm) setDoneFunc(handler func(string, string, bool, cardFormMode)) *cardForm {
func (c *cardForm) setDoneFunc(handler func(string, string, bool, formMode)) *cardForm {
c.done = handler
return c

View file

@ -30,7 +30,7 @@ func (a *App) initColumns() error {
// initCardForm initialises the card form.
func (a *App) initCardForm() {
doneFunc := func(title, description string, success bool, mode cardFormMode) {
doneFunc := func(title, description string, success bool, mode formMode) {
if success {
switch mode {
case create:
@ -52,7 +52,6 @@ func (a *App) initDeleteCardModal() {
doneFunc := func(_ int, buttonLabel string) {
if buttonLabel == "Confirm" {
a.deleteFocusedCard()
a.refresh(true)
}
a.pages.HidePage(deleteCardModalPage)
@ -107,9 +106,14 @@ func (a *App) initStatusbar() {
}
func (a *App) initStatusForm() {
doneFunc := func(name string, success bool) {
doneFunc := func(name string, success bool, mode formMode) {
if success {
a.editFocusedStatusColumn(name)
switch mode {
case create:
a.saveNewStatus(name)
case edit:
a.editFocusedStatusColumn(name)
}
}
a.pages.HidePage(statusFormPage)

View file

@ -38,12 +38,19 @@ func (a *App) inputCapture() func(event *tcell.EventKey) *tcell.EventKey {
}
func (a *App) create() {
if a.mode == normal {
switch a.mode {
case normal:
a.cardForm.mode = create
a.cardForm.updateInputFields("", "")
a.cardForm.frame.SetTitle(" Create Card ")
a.pages.ShowPage(cardFormPage)
a.SetFocus(a.cardForm)
case boardEdit:
a.statusForm.mode = create
a.statusForm.updateInputFields("")
a.statusForm.frame.SetTitle(" Create Status ")
a.pages.ShowPage(statusFormPage)
a.SetFocus(a.statusForm)
}
}
@ -70,6 +77,7 @@ func (a *App) edit() {
a.pages.ShowPage(cardFormPage)
a.SetFocus(a.cardForm)
case boardEdit:
a.statusForm.mode = edit
statusName := a.focusedStatusName()
a.statusForm.updateInputFields(statusName)
a.statusForm.frame.SetTitle(" Edit Status Name ")
@ -129,7 +137,7 @@ func (a *App) selected() {
a.statusSelection = statusSelection{0, 0, 0}
a.updateBoardMode(normal)
a.refresh(false)
a.refresh(refreshArgs{updateFocusedColumnOnly: false, reinitialiseColumns: false})
}
}

View file

@ -9,7 +9,8 @@ type statusForm struct {
*tview.Form
frame *tview.Frame
nameLabel string
done func(string, bool)
done func(string, bool, formMode)
mode formMode
}
func newStatusForm() *statusForm {
@ -56,13 +57,13 @@ func newStatusForm() *statusForm {
return
}
obj.done(name.GetText(), true)
obj.done(name.GetText(), true, obj.mode)
}
})
obj.AddButton("Cancel", func() {
if obj.done != nil {
obj.done("", false)
obj.done("", false, obj.mode)
}
})
@ -74,7 +75,7 @@ func (s *statusForm) updateInputFields(name string) {
s.AddInputField(s.nameLabel, name, 0, nil, nil)
}
func (s *statusForm) setDoneFunc(handler func(string, bool)) *statusForm {
func (s *statusForm) setDoneFunc(handler func(string, bool, formMode)) *statusForm {
s.done = handler
return s

View file

@ -10,6 +10,7 @@ import (
type (
boardMovement int
boardMode string
formMode int
)
const (
@ -32,6 +33,11 @@ const (
boardEdit boardMode = "BOARD EDIT"
)
const (
create formMode = iota
edit
)
type App struct {
*tview.Application
@ -83,10 +89,6 @@ func NewApp(path string) (App, error) {
// init initialises the UI.
func (a *App) Init() error {
if err := a.initColumns(); err != nil {
return fmt.Errorf("error initialising the status columns; %w", err)
}
a.columnFlex.SetInputCapture(a.inputCapture())
a.initStatusbar()
@ -117,7 +119,7 @@ func (a *App) Init() error {
a.SetRoot(a.pages, true)
a.refresh(false)
a.refresh(refreshArgs{updateFocusedColumnOnly: false, reinitialiseColumns: true})
return nil
}
@ -138,7 +140,7 @@ func (a *App) saveCard(title, description string) {
a.statusbar.displayMessage(infoLevel, "Card created successfully.")
a.refresh(false)
a.refresh(refreshArgs{updateFocusedColumnOnly: false, reinitialiseColumns: false})
}
// editFocusedCard saves an edited card to the database.
@ -161,7 +163,7 @@ func (a *App) editFocusedCard(title, description string) {
a.statusbar.displayMessage(infoLevel, "Card edited successfully.")
a.refresh(true)
a.refresh(refreshArgs{updateFocusedColumnOnly: true, reinitialiseColumns: false})
}
// getFocusedCard retrieves the details of the card in focus.
@ -195,6 +197,8 @@ func (a *App) deleteFocusedCard() {
}
a.statusbar.displayMessage(infoLevel, "Card deleted successfully.")
a.refresh(refreshArgs{updateFocusedColumnOnly: true, reinitialiseColumns: false})
}
// updateAllColumns ensures that all columns are up-to-date.
@ -243,9 +247,22 @@ func (a *App) setColumnFocus() {
a.SetFocus(a.columns[a.focusedColumn])
}
// refreshArgs is an argument type for the refresh method.
type refreshArgs struct {
updateFocusedColumnOnly bool
reinitialiseColumns bool
}
// refresh refreshes the UI.
func (a *App) refresh(updateFocusedColumnOnly bool) {
if updateFocusedColumnOnly {
func (a *App) refresh(args refreshArgs) {
// in some cases (e.g. a new column is created)
// we need to re-initialise the columnFlex primitive
if args.reinitialiseColumns {
args.updateFocusedColumnOnly = false
a.initColumns()
}
if args.updateFocusedColumnOnly {
if err := a.updateColumn(a.focusedColumn); err != nil {
a.statusbar.displayMessage(
errorLevel,
@ -327,5 +344,25 @@ func (a *App) editFocusedStatusColumn(newName string) {
a.statusbar.displayMessage(infoLevel, "Status updated successfully.")
a.refresh(true)
a.refresh(refreshArgs{updateFocusedColumnOnly: true, reinitialiseColumns: false})
}
// saveNewStatus saves a new status to the database and updates the Kanban board.
// The new status will be positioned last on the board. It's position can be modified
// by the user.
func (a *App) saveNewStatus(name string) {
args := board.StatusArgs{
Name: name,
}
err := a.board.CreateStatus(args)
if err != nil {
a.statusbar.displayMessage(errorLevel, fmt.Sprintf("Failed to create the status column: %v.", err))
return
}
a.statusbar.displayMessage(infoLevel, "Status column created successfully.")
a.refresh(refreshArgs{updateFocusedColumnOnly: false, reinitialiseColumns: true})
}