pelican/internal/ui/ui.go
Dan Anglin d167039176
All checks were successful
/ test (pull_request) Successful in 3m13s
/ lint (pull_request) Successful in 1m4s
feat(ui): add support for editing cards
This commit adds support for editing an existing card's title and
description. The (previously named) input modal has been enhanced to
support both creating and editing cards.

Part of apollo/pelican#14
2024-01-10 18:21:14 +00:00

362 lines
8.1 KiB
Go

package ui
import (
"fmt"
"codeflow.dananglin.me.uk/apollo/pelican/internal/board"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
)
type (
boardMovement int
boardMode int
)
const (
next boardMovement = iota
previous
)
const (
normal boardMode = iota
selection
)
const (
mainPage string = "main"
quitPage string = "quit"
cardModalPage string = "card modal"
deleteCardModalPage string = "delete card modal"
)
type UI struct {
*tview.Application
columns []*column
flex *tview.Flex
pages *tview.Pages
focusedColumn int
board board.Board
mode boardMode
quitModal *tview.Modal
cardModal *cardModal
deleteCardModal *tview.Modal
statusSelection statusSelection
}
// NewUI returns a new UI value.
func NewUI(path string) (UI, error) {
kanban, err := board.Open(path)
if err != nil {
return UI{}, fmt.Errorf("unable to open the project's board; %w", err)
}
userInterface := UI{
Application: tview.NewApplication(),
pages: tview.NewPages(),
flex: tview.NewFlex(),
quitModal: tview.NewModal(),
cardModal: newCardModal(),
focusedColumn: 0,
columns: nil,
board: kanban,
deleteCardModal: tview.NewModal(),
mode: normal,
statusSelection: statusSelection{0, 0, 0},
}
if err := userInterface.init(); err != nil {
return UI{}, fmt.Errorf("received an error after running the initialisation; %w", err)
}
return userInterface, nil
}
// closeBoard closes the board.
func (u *UI) closeBoard() {
_ = u.board.Close()
}
// init initialises the UI.
func (u *UI) init() error {
err := u.initColumns()
if err != nil {
return fmt.Errorf("error initialising the status columns; %w", err)
}
u.flex.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
key, letter := event.Key(), event.Rune()
switch {
case letter == 'h' || key == tcell.KeyLeft:
u.shiftColumnFocus(previous)
case letter == 'l' || key == tcell.KeyRight:
u.shiftColumnFocus(next)
case letter == 'c':
if u.mode == normal {
u.cardModal.mode = create
u.cardModal.updateInputFields("", "")
u.pages.ShowPage(cardModalPage)
u.SetFocus(u.cardModal)
}
case letter == 'm':
if u.mode == normal {
focusedCard := u.columns[u.focusedColumn].focusedCard
u.statusSelection.cardID = u.columns[u.focusedColumn].cards[focusedCard].id
u.statusSelection.currentStatusID = u.columns[u.focusedColumn].statusID
u.mode = selection
}
case letter == 'e':
if u.mode == normal {
u.cardModal.mode = edit
focusedCard := u.columns[u.focusedColumn].focusedCard
cardID := u.columns[u.focusedColumn].cards[focusedCard].id
card, _ := u.board.Card(cardID)
u.cardModal.updateInputFields(card.Title, card.Description)
u.pages.ShowPage(cardModalPage)
u.SetFocus(u.cardModal)
}
case key == tcell.KeyCtrlD:
if u.mode == normal {
u.pages.ShowPage(deleteCardModalPage)
u.SetFocus(u.deleteCardModal)
}
case key == tcell.KeyCtrlQ:
if u.mode == normal {
u.pages.ShowPage(quitPage)
u.SetFocus(u.quitModal)
}
case key == tcell.KeyESC:
if u.mode != normal {
u.mode = normal
}
case key == tcell.KeyEnter:
if u.mode == selection {
u.statusSelection.nextStatusID = u.columns[u.focusedColumn].statusID
if u.statusSelection.currentStatusID != u.statusSelection.nextStatusID {
u.statusSelection.moveCard(u.board)
}
u.statusSelection = statusSelection{0, 0, 0}
u.mode = normal
_ = u.refresh(false)
}
}
return event
})
u.pages.AddPage(mainPage, u.flex, true, true)
u.initQuitModal()
u.pages.AddPage(quitPage, u.quitModal, false, false)
u.initCardModal()
u.pages.AddPage(cardModalPage, u.cardModal, false, false)
u.initDeleteCardModal()
u.pages.AddPage(deleteCardModalPage, u.deleteCardModal, false, false)
u.SetRoot(u.pages, true)
if err := u.refresh(false); err != nil {
return fmt.Errorf("error refreshing the board; %w", err)
}
return nil
}
// initCardModal initialises the card modal.
func (u *UI) initCardModal() {
doneFunc := func(title, description string, success bool, mode cardModalMode) {
if success {
switch mode {
case create:
_ = u.newCard(title, description)
case edit:
focusedCard := u.columns[u.focusedColumn].focusedCard
cardID := u.columns[u.focusedColumn].cards[focusedCard].id
_ = u.editCard(cardID, title, description)
}
}
u.pages.HidePage(cardModalPage)
u.setColumnFocus()
}
u.cardModal.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(true)
}
u.pages.HidePage(deleteCardModalPage)
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() {
doneFunc := func(_ int, buttonLabel string) {
switch buttonLabel {
case "Quit":
u.shutdown()
default:
u.pages.HidePage(quitPage)
u.setColumnFocus()
}
}
u.quitModal.SetText("Do you want to quit the application?").
AddButtons([]string{"Quit", "Cancel"}).
SetDoneFunc(doneFunc)
}
// newCard creates and saves a new card to the database.
func (u *UI) newCard(title, description string) error {
args := board.CardArgs{
NewTitle: title,
NewDescription: description,
}
if _, err := u.board.CreateCard(args); err != nil {
return fmt.Errorf("unable to create card, %w", err)
}
_ = u.refresh(false)
return nil
}
// editCard saves and edited card to the database.
func (u *UI) editCard(cardID int, title, description string) error {
args := board.UpdateCardArgs{
CardID: cardID,
CardArgs: board.CardArgs{
NewTitle: title,
NewDescription: description,
},
}
if err := u.board.UpdateCard(args); err != nil {
return fmt.Errorf("unable to edit card with ID: %d; %w", cardID, err)
}
_ = u.refresh(true)
return nil
}
// deleteCard deletes a card from the board.
func (u *UI) deleteCard() {
statusID := u.columns[u.focusedColumn].statusID
focusedCard := u.columns[u.focusedColumn].focusedCard
cardID := u.columns[u.focusedColumn].cards[focusedCard].id
args := board.DeleteCardArgs{
CardID: cardID,
StatusID: statusID,
}
_ = u.board.DeleteCard(args)
}
// initColumns initialises the columns of the Kanban board.
func (u *UI) initColumns() error {
u.flex.Clear()
statusList, err := u.board.StatusList()
if err != nil {
return fmt.Errorf("unable to retrieve the list of statuses; %w", err)
}
columns := make([]*column, len(statusList))
for i := range statusList {
column := newColumn(statusList[i].ID, statusList[i].Name, u.boardMode)
u.flex.AddItem(column, 50, 1, true)
columns[i] = column
}
u.columns = columns
return nil
}
func (u *UI) updateAllColumns() error {
for i := range u.columns {
if err := u.updateColumn(i); err != nil {
return err
}
}
return nil
}
func (u *UI) updateColumn(index int) error {
if err := u.columns[index].update(u.board); err != nil {
return fmt.Errorf("unable to update column; %w", err)
}
return nil
}
func (u *UI) shiftColumnFocus(movement boardMovement) {
switch movement {
case next:
if u.focusedColumn == len(u.columns)-1 {
u.focusedColumn = 0
} else {
u.focusedColumn++
}
case previous:
if u.focusedColumn == 0 {
u.focusedColumn = len(u.columns) - 1
} else {
u.focusedColumn--
}
}
u.setColumnFocus()
}
func (u *UI) setColumnFocus() {
u.SetFocus(u.columns[u.focusedColumn])
}
// refresh refreshes the UI.
func (u *UI) refresh(updateFocusedColumnOnly bool) error {
if updateFocusedColumnOnly {
if err := u.updateColumn(u.focusedColumn); err != nil {
return err
}
} else {
if err := u.updateAllColumns(); err != nil {
return err
}
}
u.setColumnFocus()
return nil
}
// shutdown shuts down the application.
func (u *UI) shutdown() {
u.closeBoard()
u.Stop()
}
// boardMode returns the current board mode.
func (u *UI) boardMode() boardMode {
return u.mode
}