feat(ui): add support for creating status columns #29
7 changed files with 141 additions and 59 deletions
|
@ -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,7 +110,7 @@ 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
|
||||
|
@ -118,25 +118,43 @@ func (b *Board) Status(id int) (Status, error) {
|
|||
|
||||
type StatusArgs struct {
|
||||
Name string
|
||||
Order int
|
||||
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 {
|
||||
|
|
|
@ -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,
|
||||
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,10 +90,16 @@ 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.")
|
||||
|
||||
|
@ -86,7 +107,7 @@ func testUpdateStatus(kanban board.Board, statusID int, newName string) func(t *
|
|||
StatusID: statusID,
|
||||
StatusArgs: board.StatusArgs{
|
||||
Name: newName,
|
||||
Order: -1,
|
||||
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) {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,10 +106,15 @@ func (a *App) initStatusbar() {
|
|||
}
|
||||
|
||||
func (a *App) initStatusForm() {
|
||||
doneFunc := func(name string, success bool) {
|
||||
doneFunc := func(name string, success bool, mode formMode) {
|
||||
if success {
|
||||
switch mode {
|
||||
case create:
|
||||
a.saveNewStatus(name)
|
||||
case edit:
|
||||
a.editFocusedStatusColumn(name)
|
||||
}
|
||||
}
|
||||
|
||||
a.pages.HidePage(statusFormPage)
|
||||
a.setColumnFocus()
|
||||
|
|
|
@ -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})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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})
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue