Issue 18: Added a popup to modify table columns (#31)
Added a new popup to modify the columns of the table. With this new popup, the user can: - Show and hide columns - Move columns around - Add new columns which are derived from the value of an expression - Delete columns Also got the overlay mechanisms working.
This commit is contained in:
parent
f373a3313a
commit
982d3a9ca7
|
@ -22,6 +22,7 @@ import (
|
|||
"github.com/lmika/audax/internal/dynamo-browse/ui"
|
||||
"github.com/lmika/audax/internal/dynamo-browse/ui/keybindings"
|
||||
"github.com/lmika/audax/internal/dynamo-browse/ui/teamodels/styles"
|
||||
bus "github.com/lmika/events"
|
||||
"github.com/lmika/gopkgs/cli"
|
||||
"log"
|
||||
"net"
|
||||
|
@ -72,6 +73,8 @@ func main() {
|
|||
dynamoClient = dynamodb.NewFromConfig(cfg)
|
||||
}
|
||||
|
||||
eventBus := bus.New()
|
||||
|
||||
uiStyles := styles.DefaultStyles
|
||||
dynamoProvider := dynamo.NewProvider(dynamoClient)
|
||||
resultSetSnapshotStore := workspacestore.NewResultSetSnapshotStore(ws)
|
||||
|
@ -93,8 +96,10 @@ func main() {
|
|||
itemRendererService := itemrenderer.NewService(uiStyles.ItemView.FieldType, uiStyles.ItemView.MetaInfo)
|
||||
|
||||
state := controllers.NewState()
|
||||
tableReadController := controllers.NewTableReadController(state, tableService, workspaceService, itemRendererService, *flagTable, true)
|
||||
tableReadController := controllers.NewTableReadController(state, tableService, workspaceService, itemRendererService, eventBus, *flagTable)
|
||||
tableWriteController := controllers.NewTableWriteController(state, tableService, tableReadController, settingStore)
|
||||
columnsController := controllers.NewColumnsController(eventBus)
|
||||
exportController := controllers.NewExportController(state, columnsController)
|
||||
settingsController := controllers.NewSettingsController(settingStore)
|
||||
keyBindings := keybindings.Default()
|
||||
|
||||
|
@ -106,6 +111,8 @@ func main() {
|
|||
model := ui.NewModel(
|
||||
tableReadController,
|
||||
tableWriteController,
|
||||
columnsController,
|
||||
exportController,
|
||||
settingsController,
|
||||
itemRendererService,
|
||||
commandController,
|
||||
|
|
|
@ -16,6 +16,12 @@ func SetStatus(msg string) tea.Cmd {
|
|||
}
|
||||
}
|
||||
|
||||
func SetTeaMessage(event tea.Msg) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
return event
|
||||
}
|
||||
}
|
||||
|
||||
func PromptForInput(prompt string, onDone func(value string) tea.Msg) tea.Msg {
|
||||
return PromptForInputMsg{
|
||||
Prompt: prompt,
|
||||
|
|
108
internal/dynamo-browse/controllers/columns.go
Normal file
108
internal/dynamo-browse/controllers/columns.go
Normal file
|
@ -0,0 +1,108 @@
|
|||
package controllers
|
||||
|
||||
import (
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/lmika/audax/internal/common/ui/events"
|
||||
"github.com/lmika/audax/internal/dynamo-browse/models"
|
||||
"github.com/lmika/audax/internal/dynamo-browse/models/columns"
|
||||
"github.com/lmika/audax/internal/dynamo-browse/models/queryexpr"
|
||||
bus "github.com/lmika/events"
|
||||
)
|
||||
|
||||
type ColumnsController struct {
|
||||
// State
|
||||
colModel *columns.Columns
|
||||
resultSet *models.ResultSet
|
||||
}
|
||||
|
||||
func NewColumnsController(eventBus *bus.Bus) *ColumnsController {
|
||||
cc := &ColumnsController{}
|
||||
|
||||
eventBus.On(newResultSetEvent, cc.onNewResultSet)
|
||||
return cc
|
||||
}
|
||||
|
||||
func (cc *ColumnsController) Columns() *columns.Columns {
|
||||
return cc.colModel
|
||||
}
|
||||
|
||||
func (cc *ColumnsController) ToggleVisible(idx int) tea.Msg {
|
||||
cc.colModel.Columns[idx].Hidden = !cc.colModel.Columns[idx].Hidden
|
||||
return ColumnsUpdated{}
|
||||
}
|
||||
|
||||
func (cc *ColumnsController) ShiftColumnLeft(idx int) tea.Msg {
|
||||
if idx == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
col := cc.colModel.Columns[idx-1]
|
||||
cc.colModel.Columns[idx-1], cc.colModel.Columns[idx] = cc.colModel.Columns[idx], col
|
||||
|
||||
return ColumnsUpdated{}
|
||||
}
|
||||
|
||||
func (cc *ColumnsController) ShiftColumnRight(idx int) tea.Msg {
|
||||
if idx >= len(cc.colModel.Columns)-1 {
|
||||
return nil
|
||||
}
|
||||
|
||||
col := cc.colModel.Columns[idx+1]
|
||||
cc.colModel.Columns[idx+1], cc.colModel.Columns[idx] = cc.colModel.Columns[idx], col
|
||||
|
||||
return ColumnsUpdated{}
|
||||
}
|
||||
|
||||
func (cc *ColumnsController) SetColumnsToResultSet() tea.Msg {
|
||||
cc.colModel = columns.NewColumnsFromResultSet(cc.resultSet)
|
||||
return ColumnsUpdated{}
|
||||
}
|
||||
|
||||
func (cc *ColumnsController) onNewResultSet(rs *models.ResultSet, op resultSetUpdateOp) {
|
||||
cc.resultSet = rs
|
||||
|
||||
if cc.colModel == nil || (op == resultSetUpdateInit || op == resultSetUpdateQuery) {
|
||||
cc.colModel = columns.NewColumnsFromResultSet(rs)
|
||||
}
|
||||
}
|
||||
|
||||
func (cc *ColumnsController) AddColumn(afterIndex int) tea.Msg {
|
||||
return events.PromptForInput("column expr: ", func(value string) tea.Msg {
|
||||
colExpr, err := queryexpr.Parse(value)
|
||||
if err != nil {
|
||||
return events.Error(err)
|
||||
}
|
||||
|
||||
newCol := columns.Column{
|
||||
Name: colExpr.String(),
|
||||
Evaluator: columns.ExprFieldValueEvaluator{Expr: colExpr},
|
||||
}
|
||||
|
||||
if afterIndex >= len(cc.colModel.Columns)-1 {
|
||||
cc.colModel.Columns = append(cc.colModel.Columns, newCol)
|
||||
} else {
|
||||
newCols := make([]columns.Column, 0, len(cc.colModel.Columns)+1)
|
||||
|
||||
newCols = append(newCols, cc.colModel.Columns[:afterIndex+1]...)
|
||||
newCols = append(newCols, newCol)
|
||||
newCols = append(newCols, cc.colModel.Columns[afterIndex+1:]...)
|
||||
|
||||
cc.colModel.Columns = newCols
|
||||
}
|
||||
|
||||
return ColumnsUpdated{}
|
||||
})
|
||||
}
|
||||
|
||||
func (cc *ColumnsController) DeleteColumn(afterIndex int) tea.Msg {
|
||||
if len(cc.colModel.Columns) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
newCols := make([]columns.Column, 0, len(cc.colModel.Columns)-1)
|
||||
newCols = append(newCols, cc.colModel.Columns[:afterIndex]...)
|
||||
newCols = append(newCols, cc.colModel.Columns[afterIndex+1:]...)
|
||||
cc.colModel.Columns = newCols
|
||||
|
||||
return ColumnsUpdated{}
|
||||
}
|
5
internal/dynamo-browse/controllers/ctrlevents.go
Normal file
5
internal/dynamo-browse/controllers/ctrlevents.go
Normal file
|
@ -0,0 +1,5 @@
|
|||
package controllers
|
||||
|
||||
const (
|
||||
newResultSetEvent = "new_result_set"
|
||||
)
|
|
@ -13,6 +13,11 @@ type SetTableItemView struct {
|
|||
type SettingsUpdated struct {
|
||||
}
|
||||
|
||||
type ColumnsUpdated struct {
|
||||
}
|
||||
|
||||
type MoveLeftmostDisplayedColumnInTableViewBy int
|
||||
|
||||
type NewResultSet struct {
|
||||
ResultSet *models.ResultSet
|
||||
currentFilter string
|
||||
|
@ -59,3 +64,6 @@ type ResultSetUpdated struct {
|
|||
func (rs ResultSetUpdated) StatusMessage() string {
|
||||
return rs.statusMessage
|
||||
}
|
||||
|
||||
type ShowColumnOverlay struct{}
|
||||
type HideColumnOverlay struct{}
|
||||
|
|
57
internal/dynamo-browse/controllers/export.go
Normal file
57
internal/dynamo-browse/controllers/export.go
Normal file
|
@ -0,0 +1,57 @@
|
|||
package controllers
|
||||
|
||||
import (
|
||||
"encoding/csv"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/lmika/audax/internal/common/ui/events"
|
||||
"github.com/lmika/audax/internal/dynamo-browse/models"
|
||||
"github.com/pkg/errors"
|
||||
"os"
|
||||
)
|
||||
|
||||
type ExportController struct {
|
||||
state *State
|
||||
columns *ColumnsController
|
||||
}
|
||||
|
||||
func NewExportController(state *State, columns *ColumnsController) *ExportController {
|
||||
return &ExportController{state, columns}
|
||||
}
|
||||
|
||||
func (c *ExportController) ExportCSV(filename string) tea.Msg {
|
||||
resultSet := c.state.ResultSet()
|
||||
if resultSet == nil {
|
||||
return events.Error(errors.New("no result set"))
|
||||
}
|
||||
|
||||
f, err := os.Create(filename)
|
||||
if err != nil {
|
||||
return events.Error(errors.Wrapf(err, "cannot export to '%v'", filename))
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
cw := csv.NewWriter(f)
|
||||
defer cw.Flush()
|
||||
|
||||
columns := c.columns.Columns().VisibleColumns()
|
||||
|
||||
colNames := make([]string, len(columns))
|
||||
for i, c := range columns {
|
||||
colNames[i] = c.Name
|
||||
}
|
||||
if err := cw.Write(colNames); err != nil {
|
||||
return events.Error(errors.Wrapf(err, "cannot export to '%v'", filename))
|
||||
}
|
||||
|
||||
row := make([]string, len(columns))
|
||||
for _, item := range resultSet.Items() {
|
||||
for i, col := range columns {
|
||||
row[i], _ = models.AttributeToString(col.Evaluator.EvaluateForItem(item))
|
||||
}
|
||||
if err := cw.Write(row); err != nil {
|
||||
return events.Error(errors.Wrapf(err, "cannot export to '%v'", filename))
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
88
internal/dynamo-browse/controllers/export_test.go
Normal file
88
internal/dynamo-browse/controllers/export_test.go
Normal file
|
@ -0,0 +1,88 @@
|
|||
package controllers_test
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExportController_ExportCSV(t *testing.T) {
|
||||
t.Run("should export result set to CSV file", func(t *testing.T) {
|
||||
srv := newService(t, serviceConfig{tableName: "bravo-table"})
|
||||
|
||||
tempFile := tempFile(t)
|
||||
|
||||
invokeCommand(t, srv.readController.Init())
|
||||
invokeCommand(t, srv.exportController.ExportCSV(tempFile))
|
||||
|
||||
bts, err := os.ReadFile(tempFile)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Equal(t, string(bts), strings.Join([]string{
|
||||
"pk,sk,alpha,beta,gamma\n",
|
||||
"abc,222,This is another some value,1231,\n",
|
||||
"bbb,131,,2468,foobar\n",
|
||||
"foo,bar,This is some value,,\n",
|
||||
}, ""))
|
||||
})
|
||||
|
||||
t.Run("should return error if result set is not set", func(t *testing.T) {
|
||||
srv := newService(t, serviceConfig{tableName: "non-existant-table"})
|
||||
|
||||
tempFile := tempFile(t)
|
||||
|
||||
invokeCommandExpectingError(t, srv.readController.Init())
|
||||
invokeCommandExpectingError(t, srv.exportController.ExportCSV(tempFile))
|
||||
})
|
||||
|
||||
t.Run("should honour new columns in CSV file", func(t *testing.T) {
|
||||
srv := newService(t, serviceConfig{tableName: "alpha-table"})
|
||||
|
||||
tempFile := tempFile(t)
|
||||
|
||||
invokeCommand(t, srv.readController.Init())
|
||||
|
||||
invokeCommandWithPrompt(t, srv.columnsController.AddColumn(0), "address.no")
|
||||
invokeCommand(t, srv.columnsController.ShiftColumnLeft(1))
|
||||
invokeCommandWithPrompt(t, srv.columnsController.AddColumn(1), "address.street")
|
||||
invokeCommand(t, srv.columnsController.ShiftColumnLeft(1))
|
||||
|
||||
invokeCommand(t, srv.exportController.ExportCSV(tempFile))
|
||||
|
||||
bts, err := os.ReadFile(tempFile)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Equal(t, string(bts), strings.Join([]string{
|
||||
"pk,address.no,address.street,sk,address,age,alpha,beta,gamma,useMailing\n",
|
||||
"abc,123,Fake st.,111,,23,This is some value,,,true\n",
|
||||
"abc,,,222,,,This is another some value,1231,,\n",
|
||||
"bbb,,,131,,,,2468,foobar,\n",
|
||||
}, ""))
|
||||
})
|
||||
|
||||
t.Run("should honour hidden columns in CSV file", func(t *testing.T) {
|
||||
srv := newService(t, serviceConfig{tableName: "alpha-table"})
|
||||
|
||||
tempFile := tempFile(t)
|
||||
|
||||
invokeCommand(t, srv.readController.Init())
|
||||
|
||||
invokeCommand(t, srv.columnsController.ToggleVisible(1))
|
||||
invokeCommand(t, srv.columnsController.ToggleVisible(2))
|
||||
|
||||
invokeCommand(t, srv.exportController.ExportCSV(tempFile))
|
||||
|
||||
bts, err := os.ReadFile(tempFile)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Equal(t, string(bts), strings.Join([]string{
|
||||
"pk,age,alpha,beta,gamma,useMailing\n",
|
||||
"abc,23,This is some value,,,true\n",
|
||||
"abc,,This is another some value,1231,,\n",
|
||||
"bbb,,,2468,foobar,\n",
|
||||
}, ""))
|
||||
})
|
||||
|
||||
// Hidden items?
|
||||
}
|
|
@ -2,7 +2,6 @@ package controllers
|
|||
|
||||
import (
|
||||
"context"
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/lmika/audax/internal/common/ui/events"
|
||||
|
@ -11,18 +10,30 @@ import (
|
|||
"github.com/lmika/audax/internal/dynamo-browse/models/serialisable"
|
||||
"github.com/lmika/audax/internal/dynamo-browse/services/itemrenderer"
|
||||
"github.com/lmika/audax/internal/dynamo-browse/services/workspaces"
|
||||
bus "github.com/lmika/events"
|
||||
"github.com/pkg/errors"
|
||||
"golang.design/x/clipboard"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type resultSetUpdateOp int
|
||||
|
||||
const (
|
||||
resultSetUpdateInit resultSetUpdateOp = iota
|
||||
resultSetUpdateQuery
|
||||
resultSetUpdateFilter
|
||||
resultSetUpdateSnapshotRestore
|
||||
resultSetUpdateRescan
|
||||
resultSetUpdateTouch
|
||||
)
|
||||
|
||||
type TableReadController struct {
|
||||
tableService TableReadService
|
||||
workspaceService *workspaces.ViewSnapshotService
|
||||
itemRendererService *itemrenderer.Service
|
||||
eventBus *bus.Bus
|
||||
tableName string
|
||||
loadFromLastView bool
|
||||
|
||||
|
@ -37,14 +48,15 @@ func NewTableReadController(
|
|||
tableService TableReadService,
|
||||
workspaceService *workspaces.ViewSnapshotService,
|
||||
itemRendererService *itemrenderer.Service,
|
||||
eventBus *bus.Bus,
|
||||
tableName string,
|
||||
loadFromLastView bool,
|
||||
) *TableReadController {
|
||||
return &TableReadController{
|
||||
state: state,
|
||||
tableService: tableService,
|
||||
workspaceService: workspaceService,
|
||||
itemRendererService: itemRendererService,
|
||||
eventBus: eventBus,
|
||||
tableName: tableName,
|
||||
mutex: new(sync.Mutex),
|
||||
}
|
||||
|
@ -94,7 +106,7 @@ func (c *TableReadController) ScanTable(name string) tea.Msg {
|
|||
}
|
||||
resultSet = c.tableService.Filter(resultSet, c.state.Filter())
|
||||
|
||||
return c.setResultSetAndFilter(resultSet, c.state.Filter(), true)
|
||||
return c.setResultSetAndFilter(resultSet, c.state.Filter(), true, resultSetUpdateInit)
|
||||
}
|
||||
|
||||
func (c *TableReadController) PromptForQuery() tea.Msg {
|
||||
|
@ -117,7 +129,7 @@ func (c *TableReadController) runQuery(tableInfo *models.TableInfo, query, newFi
|
|||
newResultSet = c.tableService.Filter(newResultSet, newFilter)
|
||||
}
|
||||
|
||||
return c.setResultSetAndFilter(newResultSet, newFilter, pushSnapshot)
|
||||
return c.setResultSetAndFilter(newResultSet, newFilter, pushSnapshot, resultSetUpdateQuery)
|
||||
}
|
||||
|
||||
expr, err := queryexpr.Parse(query)
|
||||
|
@ -134,7 +146,7 @@ func (c *TableReadController) runQuery(tableInfo *models.TableInfo, query, newFi
|
|||
if newFilter != "" {
|
||||
newResultSet = c.tableService.Filter(newResultSet, newFilter)
|
||||
}
|
||||
return c.setResultSetAndFilter(newResultSet, newFilter, pushSnapshot)
|
||||
return c.setResultSetAndFilter(newResultSet, newFilter, pushSnapshot, resultSetUpdateQuery)
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -163,44 +175,11 @@ func (c *TableReadController) doIfNoneDirty(cmd tea.Cmd) tea.Msg {
|
|||
func (c *TableReadController) Rescan() tea.Msg {
|
||||
return c.doIfNoneDirty(func() tea.Msg {
|
||||
resultSet := c.state.ResultSet()
|
||||
return c.doScan(context.Background(), resultSet, resultSet.Query, true)
|
||||
return c.doScan(context.Background(), resultSet, resultSet.Query, true, resultSetUpdateRescan)
|
||||
})
|
||||
}
|
||||
|
||||
func (c *TableReadController) ExportCSV(filename string) tea.Msg {
|
||||
resultSet := c.state.ResultSet()
|
||||
if resultSet == nil {
|
||||
return events.Error(errors.New("no result set"))
|
||||
}
|
||||
|
||||
f, err := os.Create(filename)
|
||||
if err != nil {
|
||||
return events.Error(errors.Wrapf(err, "cannot export to '%v'", filename))
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
cw := csv.NewWriter(f)
|
||||
defer cw.Flush()
|
||||
|
||||
columns := resultSet.Columns()
|
||||
if err := cw.Write(columns); err != nil {
|
||||
return events.Error(errors.Wrapf(err, "cannot export to '%v'", filename))
|
||||
}
|
||||
|
||||
row := make([]string, len(columns))
|
||||
for _, item := range resultSet.Items() {
|
||||
for i, col := range columns {
|
||||
row[i], _ = item.AttributeValueAsString(col)
|
||||
}
|
||||
if err := cw.Write(row); err != nil {
|
||||
return events.Error(errors.Wrapf(err, "cannot export to '%v'", filename))
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *TableReadController) doScan(ctx context.Context, resultSet *models.ResultSet, query models.Queryable, pushBackstack bool) tea.Msg {
|
||||
func (c *TableReadController) doScan(ctx context.Context, resultSet *models.ResultSet, query models.Queryable, pushBackstack bool, op resultSetUpdateOp) tea.Msg {
|
||||
newResultSet, err := c.tableService.ScanOrQuery(ctx, resultSet.TableInfo, query)
|
||||
if err != nil {
|
||||
return events.Error(err)
|
||||
|
@ -208,10 +187,10 @@ func (c *TableReadController) doScan(ctx context.Context, resultSet *models.Resu
|
|||
|
||||
newResultSet = c.tableService.Filter(newResultSet, c.state.Filter())
|
||||
|
||||
return c.setResultSetAndFilter(newResultSet, c.state.Filter(), pushBackstack)
|
||||
return c.setResultSetAndFilter(newResultSet, c.state.Filter(), pushBackstack, op)
|
||||
}
|
||||
|
||||
func (c *TableReadController) setResultSetAndFilter(resultSet *models.ResultSet, filter string, pushBackstack bool) tea.Msg {
|
||||
func (c *TableReadController) setResultSetAndFilter(resultSet *models.ResultSet, filter string, pushBackstack bool, op resultSetUpdateOp) tea.Msg {
|
||||
if pushBackstack {
|
||||
if err := c.workspaceService.PushSnapshot(resultSet, filter); err != nil {
|
||||
log.Printf("cannot push snapshot: %v", err)
|
||||
|
@ -219,6 +198,9 @@ func (c *TableReadController) setResultSetAndFilter(resultSet *models.ResultSet,
|
|||
}
|
||||
|
||||
c.state.setResultSetAndFilter(resultSet, filter)
|
||||
|
||||
c.eventBus.Fire(newResultSetEvent, resultSet, op)
|
||||
|
||||
return c.state.buildNewResultSetMessage("")
|
||||
}
|
||||
|
||||
|
@ -238,7 +220,7 @@ func (c *TableReadController) Filter() tea.Msg {
|
|||
resultSet := c.state.ResultSet()
|
||||
newResultSet := c.tableService.Filter(resultSet, value)
|
||||
|
||||
return c.setResultSetAndFilter(newResultSet, value, true)
|
||||
return c.setResultSetAndFilter(newResultSet, value, true, resultSetUpdateFilter)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
@ -286,7 +268,7 @@ func (c *TableReadController) updateViewToSnapshot(viewSnapshot *serialisable.Vi
|
|||
log.Printf("backstack: setting filter to '%v'", viewSnapshot.Filter)
|
||||
|
||||
newResultSet := c.tableService.Filter(currentResultSet, viewSnapshot.Filter)
|
||||
return c.setResultSetAndFilter(newResultSet, viewSnapshot.Filter, false)
|
||||
return c.setResultSetAndFilter(newResultSet, viewSnapshot.Filter, false, resultSetUpdateSnapshotRestore)
|
||||
}
|
||||
|
||||
tableInfo := currentResultSet.TableInfo
|
||||
|
|
|
@ -81,38 +81,6 @@ func TestTableReadController_Rescan(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
func TestTableReadController_ExportCSV(t *testing.T) {
|
||||
t.Run("should export result set to CSV file", func(t *testing.T) {
|
||||
srv := newService(t, serviceConfig{tableName: "bravo-table"})
|
||||
|
||||
tempFile := tempFile(t)
|
||||
|
||||
invokeCommand(t, srv.readController.Init())
|
||||
invokeCommand(t, srv.readController.ExportCSV(tempFile))
|
||||
|
||||
bts, err := os.ReadFile(tempFile)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Equal(t, string(bts), strings.Join([]string{
|
||||
"pk,sk,alpha,beta,gamma\n",
|
||||
"abc,222,This is another some value,1231,\n",
|
||||
"bbb,131,,2468,foobar\n",
|
||||
"foo,bar,This is some value,,\n",
|
||||
}, ""))
|
||||
})
|
||||
|
||||
t.Run("should return error if result set is not set", func(t *testing.T) {
|
||||
srv := newService(t, serviceConfig{tableName: "non-existant-table"})
|
||||
|
||||
tempFile := tempFile(t)
|
||||
|
||||
invokeCommandExpectingError(t, srv.readController.Init())
|
||||
invokeCommandExpectingError(t, srv.readController.ExportCSV(tempFile))
|
||||
})
|
||||
|
||||
// Hidden items?
|
||||
}
|
||||
|
||||
func TestTableReadController_Query(t *testing.T) {
|
||||
t.Run("should run scan with filter based on user query", func(t *testing.T) {
|
||||
srv := newService(t, serviceConfig{tableName: "bravo-table"})
|
||||
|
@ -121,7 +89,7 @@ func TestTableReadController_Query(t *testing.T) {
|
|||
|
||||
invokeCommand(t, srv.readController.Init())
|
||||
invokeCommandWithPrompts(t, srv.readController.PromptForQuery(), `pk ^= "abc"`)
|
||||
invokeCommand(t, srv.readController.ExportCSV(tempFile))
|
||||
invokeCommand(t, srv.exportController.ExportCSV(tempFile))
|
||||
|
||||
bts, err := os.ReadFile(tempFile)
|
||||
assert.NoError(t, err)
|
||||
|
@ -138,7 +106,7 @@ func TestTableReadController_Query(t *testing.T) {
|
|||
tempFile := tempFile(t)
|
||||
|
||||
invokeCommandExpectingError(t, srv.readController.Init())
|
||||
invokeCommandExpectingError(t, srv.readController.ExportCSV(tempFile))
|
||||
invokeCommandExpectingError(t, srv.exportController.ExportCSV(tempFile))
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
@ -375,7 +375,7 @@ func (twc *TableWriteController) NoisyTouchItem(idx int) tea.Msg {
|
|||
return events.Error(err)
|
||||
}
|
||||
|
||||
return twc.tableReadControllers.doScan(ctx, resultSet, resultSet.Query, false)
|
||||
return twc.tableReadControllers.doScan(ctx, resultSet, resultSet.Query, false, resultSetUpdateTouch)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
@ -406,7 +406,7 @@ func (twc *TableWriteController) DeleteMarked() tea.Msg {
|
|||
return events.Error(err)
|
||||
}
|
||||
|
||||
return twc.tableReadControllers.doScan(ctx, resultSet, resultSet.Query, false)
|
||||
return twc.tableReadControllers.doScan(ctx, resultSet, resultSet.Query, false, resultSetUpdateTouch)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
@ -12,6 +12,7 @@ import (
|
|||
"github.com/lmika/audax/internal/dynamo-browse/services/tables"
|
||||
workspaces_service "github.com/lmika/audax/internal/dynamo-browse/services/workspaces"
|
||||
"github.com/lmika/audax/test/testdynamo"
|
||||
bus "github.com/lmika/events"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"testing"
|
||||
)
|
||||
|
@ -571,6 +572,8 @@ type services struct {
|
|||
readController *controllers.TableReadController
|
||||
writeController *controllers.TableWriteController
|
||||
settingsController *controllers.SettingsController
|
||||
columnsController *controllers.ColumnsController
|
||||
exportController *controllers.ExportController
|
||||
}
|
||||
|
||||
type serviceConfig struct {
|
||||
|
@ -590,11 +593,14 @@ func newService(t *testing.T, cfg serviceConfig) *services {
|
|||
|
||||
provider := dynamo.NewProvider(client)
|
||||
service := tables.NewService(provider, settingStore)
|
||||
eventBus := bus.New()
|
||||
|
||||
state := controllers.NewState()
|
||||
readController := controllers.NewTableReadController(state, service, workspaceService, itemRendererService, cfg.tableName, false)
|
||||
readController := controllers.NewTableReadController(state, service, workspaceService, itemRendererService, eventBus, cfg.tableName)
|
||||
writeController := controllers.NewTableWriteController(state, service, readController, settingStore)
|
||||
settingsController := controllers.NewSettingsController(settingStore)
|
||||
columnsController := controllers.NewColumnsController(eventBus)
|
||||
exportController := controllers.NewExportController(state, columnsController)
|
||||
|
||||
if cfg.isReadOnly {
|
||||
if err := settingStore.SetReadOnly(cfg.isReadOnly); err != nil {
|
||||
|
@ -608,5 +614,7 @@ func newService(t *testing.T, cfg serviceConfig) *services {
|
|||
readController: readController,
|
||||
writeController: writeController,
|
||||
settingsController: settingsController,
|
||||
columnsController: columnsController,
|
||||
exportController: exportController,
|
||||
}
|
||||
}
|
||||
|
|
|
@ -34,7 +34,7 @@ func compareScalarAttributes(x, y types.AttributeValue) (int, bool) {
|
|||
return 0, false
|
||||
}
|
||||
|
||||
func attributeToString(x types.AttributeValue) (string, bool) {
|
||||
func AttributeToString(x types.AttributeValue) (string, bool) {
|
||||
switch xVal := x.(type) {
|
||||
case *types.AttributeValueMemberS:
|
||||
return xVal.Value, true
|
||||
|
|
69
internal/dynamo-browse/models/columns/columns.go
Normal file
69
internal/dynamo-browse/models/columns/columns.go
Normal file
|
@ -0,0 +1,69 @@
|
|||
package columns
|
||||
|
||||
import (
|
||||
"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
|
||||
"github.com/lmika/audax/internal/dynamo-browse/models"
|
||||
"github.com/lmika/audax/internal/dynamo-browse/models/queryexpr"
|
||||
)
|
||||
|
||||
type Columns struct {
|
||||
TableInfo *models.TableInfo
|
||||
Columns []Column
|
||||
}
|
||||
|
||||
func NewColumnsFromResultSet(rs *models.ResultSet) *Columns {
|
||||
rsCols := rs.Columns()
|
||||
|
||||
cols := make([]Column, len(rsCols))
|
||||
for i, c := range rsCols {
|
||||
cols[i] = Column{
|
||||
Name: c,
|
||||
Evaluator: SimpleFieldValueEvaluator(c),
|
||||
}
|
||||
}
|
||||
|
||||
return &Columns{
|
||||
TableInfo: rs.TableInfo,
|
||||
Columns: cols,
|
||||
}
|
||||
}
|
||||
|
||||
func (cols *Columns) VisibleColumns() []Column {
|
||||
if cols == nil {
|
||||
return []Column{}
|
||||
}
|
||||
|
||||
visibleCols := make([]Column, 0)
|
||||
for _, col := range cols.Columns {
|
||||
if col.Hidden {
|
||||
continue
|
||||
}
|
||||
visibleCols = append(visibleCols, col)
|
||||
}
|
||||
return visibleCols
|
||||
}
|
||||
|
||||
type Column struct {
|
||||
Name string
|
||||
Evaluator FieldValueEvaluator
|
||||
Hidden bool
|
||||
}
|
||||
|
||||
type FieldValueEvaluator interface {
|
||||
EvaluateForItem(item models.Item) types.AttributeValue
|
||||
}
|
||||
|
||||
type SimpleFieldValueEvaluator string
|
||||
|
||||
func (sfve SimpleFieldValueEvaluator) EvaluateForItem(item models.Item) types.AttributeValue {
|
||||
return item[string(sfve)]
|
||||
}
|
||||
|
||||
type ExprFieldValueEvaluator struct {
|
||||
Expr *queryexpr.QueryExpr
|
||||
}
|
||||
|
||||
func (sfve ExprFieldValueEvaluator) EvaluateForItem(item models.Item) types.AttributeValue {
|
||||
val, _ := sfve.Expr.EvalItem(item)
|
||||
return val
|
||||
}
|
|
@ -2,7 +2,6 @@ package models
|
|||
|
||||
import (
|
||||
"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
|
||||
"github.com/lmika/audax/internal/dynamo-browse/models/itemrender"
|
||||
)
|
||||
|
||||
type ItemIndex struct {
|
||||
|
@ -34,9 +33,5 @@ func (i Item) KeyValue(info *TableInfo) map[string]types.AttributeValue {
|
|||
}
|
||||
|
||||
func (i Item) AttributeValueAsString(key string) (string, bool) {
|
||||
return attributeToString(i[key])
|
||||
}
|
||||
|
||||
func (i Item) Renderer(key string) itemrender.Renderer {
|
||||
return itemrender.ToRenderer(i[key])
|
||||
return AttributeToString(i[key])
|
||||
}
|
||||
|
|
|
@ -2,6 +2,7 @@ package queryexpr
|
|||
|
||||
import (
|
||||
"github.com/alecthomas/participle/v2"
|
||||
"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
|
||||
"github.com/lmika/audax/internal/dynamo-browse/models"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
@ -17,6 +18,10 @@ func (a *astExpr) evalToIR(tableInfo *models.TableInfo) (*irDisjunction, error)
|
|||
return a.Root.evalToIR(tableInfo)
|
||||
}
|
||||
|
||||
func (a *astExpr) evalItem(item models.Item) (types.AttributeValue, error) {
|
||||
return a.Root.evalItem(item)
|
||||
}
|
||||
|
||||
type astDisjunction struct {
|
||||
Operands []*astConjunction `parser:"@@ ('or' @@)*"`
|
||||
}
|
||||
|
@ -25,10 +30,16 @@ type astConjunction struct {
|
|||
Operands []*astBinOp `parser:"@@ ('and' @@)*"`
|
||||
}
|
||||
|
||||
// TODO: do this properly
|
||||
type astBinOp struct {
|
||||
Name string `parser:"@Ident"`
|
||||
Op string `parser:"@('^' '=' | '=')"`
|
||||
Value *astLiteralValue `parser:"@@"`
|
||||
Ref *astDot `parser:"@@"`
|
||||
Op string `parser:"( @('^' '=' | '=')"`
|
||||
Value *astLiteralValue `parser:"@@ )?"`
|
||||
}
|
||||
|
||||
type astDot struct {
|
||||
Name string `parser:"@Ident"`
|
||||
Quals []string `parser:"('.' @Ident)*"`
|
||||
}
|
||||
|
||||
type astLiteralValue struct {
|
||||
|
|
|
@ -2,6 +2,7 @@ package queryexpr
|
|||
|
||||
import (
|
||||
"github.com/aws/aws-sdk-go-v2/feature/dynamodb/expression"
|
||||
"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
|
||||
"github.com/lmika/audax/internal/dynamo-browse/models"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
@ -12,22 +13,40 @@ func (a *astBinOp) evalToIR(info *models.TableInfo) (irAtom, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
singleName, isSingleName := a.Ref.unqualifiedName()
|
||||
if !isSingleName {
|
||||
return nil, errors.Errorf("%v: cannot use dereferences", singleName)
|
||||
}
|
||||
|
||||
switch a.Op {
|
||||
case "=":
|
||||
return irFieldEq{name: a.Name, value: v}, nil
|
||||
return irFieldEq{name: singleName, value: v}, nil
|
||||
case "^=":
|
||||
strValue, isStrValue := v.(string)
|
||||
if !isStrValue {
|
||||
return nil, errors.New("operand '^=' must be string")
|
||||
}
|
||||
return irFieldBeginsWith{name: a.Name, prefix: strValue}, nil
|
||||
return irFieldBeginsWith{name: singleName, prefix: strValue}, nil
|
||||
}
|
||||
|
||||
return nil, errors.Errorf("unrecognised operator: %v", a.Op)
|
||||
}
|
||||
|
||||
func (a *astBinOp) evalItem(item models.Item) (types.AttributeValue, error) {
|
||||
left, err := a.Ref.evalItem(item)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if a.Op == "" {
|
||||
return left, nil
|
||||
}
|
||||
|
||||
return nil, errors.New("TODO")
|
||||
}
|
||||
|
||||
func (a *astBinOp) String() string {
|
||||
return a.Name + a.Op + a.Value.String()
|
||||
return a.Ref.String() + a.Op + a.Value.String()
|
||||
}
|
||||
|
||||
type irFieldEq struct {
|
||||
|
|
|
@ -2,6 +2,7 @@ package queryexpr
|
|||
|
||||
import (
|
||||
"github.com/aws/aws-sdk-go-v2/feature/dynamodb/expression"
|
||||
"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
|
||||
"github.com/lmika/audax/internal/dynamo-browse/models"
|
||||
"github.com/pkg/errors"
|
||||
"strings"
|
||||
|
@ -20,6 +21,14 @@ func (a *astConjunction) evalToIR(tableInfo *models.TableInfo) (*irConjunction,
|
|||
return &irConjunction{atoms: atoms}, nil
|
||||
}
|
||||
|
||||
func (a *astConjunction) evalItem(item models.Item) (types.AttributeValue, error) {
|
||||
if len(a.Operands) == 1 {
|
||||
return a.Operands[0].evalItem(item)
|
||||
}
|
||||
|
||||
return nil, errors.New("TODO")
|
||||
}
|
||||
|
||||
func (d *astConjunction) String() string {
|
||||
sb := new(strings.Builder)
|
||||
for i, operand := range d.Operands {
|
||||
|
|
|
@ -2,6 +2,7 @@ package queryexpr
|
|||
|
||||
import (
|
||||
"github.com/aws/aws-sdk-go-v2/feature/dynamodb/expression"
|
||||
"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
|
||||
"github.com/lmika/audax/internal/dynamo-browse/models"
|
||||
"github.com/pkg/errors"
|
||||
"strings"
|
||||
|
@ -20,6 +21,14 @@ func (a *astDisjunction) evalToIR(tableInfo *models.TableInfo) (*irDisjunction,
|
|||
return &irDisjunction{conj: conj}, nil
|
||||
}
|
||||
|
||||
func (a *astDisjunction) evalItem(item models.Item) (types.AttributeValue, error) {
|
||||
if len(a.Operands) == 1 {
|
||||
return a.Operands[0].evalItem(item)
|
||||
}
|
||||
|
||||
return nil, errors.New("TODO")
|
||||
}
|
||||
|
||||
func (d *astDisjunction) String() string {
|
||||
sb := new(strings.Builder)
|
||||
for i, operand := range d.Operands {
|
||||
|
|
47
internal/dynamo-browse/models/queryexpr/dot.go
Normal file
47
internal/dynamo-browse/models/queryexpr/dot.go
Normal file
|
@ -0,0 +1,47 @@
|
|||
package queryexpr
|
||||
|
||||
import (
|
||||
"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
|
||||
"github.com/lmika/audax/internal/dynamo-browse/models"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (dt *astDot) unqualifiedName() (string, bool) {
|
||||
if len(dt.Quals) == 0 {
|
||||
return dt.Name, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func (dt *astDot) evalItem(item models.Item) (types.AttributeValue, error) {
|
||||
res, hasV := item[dt.Name]
|
||||
if !hasV {
|
||||
return nil, NameNotFoundError(dt.String())
|
||||
}
|
||||
|
||||
for i, qualName := range dt.Quals {
|
||||
mapRes, isMapRes := res.(*types.AttributeValueMemberM)
|
||||
if !isMapRes {
|
||||
return nil, ValueNotAMapError(append([]string{dt.Name}, dt.Quals[:i+1]...))
|
||||
}
|
||||
|
||||
res, hasV = mapRes.Value[qualName]
|
||||
if !hasV {
|
||||
return nil, NameNotFoundError(dt.String())
|
||||
}
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (a *astDot) String() string {
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString(a.Name)
|
||||
for _, q := range a.Quals {
|
||||
sb.WriteRune('.')
|
||||
sb.WriteString(q)
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
20
internal/dynamo-browse/models/queryexpr/errors.go
Normal file
20
internal/dynamo-browse/models/queryexpr/errors.go
Normal file
|
@ -0,0 +1,20 @@
|
|||
package queryexpr
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// NameNotFoundError is returned if the given name cannot be found
|
||||
type NameNotFoundError string
|
||||
|
||||
func (n NameNotFoundError) Error() string {
|
||||
return fmt.Sprintf("%v: name not found", string(n))
|
||||
}
|
||||
|
||||
// ValueNotAMapError is return if the given name is not a map
|
||||
type ValueNotAMapError []string
|
||||
|
||||
func (n ValueNotAMapError) Error() string {
|
||||
return fmt.Sprintf("%v: name is not a map", strings.Join(n, "."))
|
||||
}
|
|
@ -1,6 +1,9 @@
|
|||
package queryexpr
|
||||
|
||||
import "github.com/lmika/audax/internal/dynamo-browse/models"
|
||||
import (
|
||||
"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
|
||||
"github.com/lmika/audax/internal/dynamo-browse/models"
|
||||
)
|
||||
|
||||
type QueryExpr struct {
|
||||
ast *astExpr
|
||||
|
@ -15,6 +18,10 @@ func (md *QueryExpr) Plan(tableInfo *models.TableInfo) (*models.QueryExecutionPl
|
|||
return ir.calcQuery(tableInfo)
|
||||
}
|
||||
|
||||
func (md *QueryExpr) EvalItem(item models.Item) (types.AttributeValue, error) {
|
||||
return md.ast.evalItem(item)
|
||||
}
|
||||
|
||||
func (md *QueryExpr) String() string {
|
||||
return md.ast.String()
|
||||
}
|
||||
|
|
|
@ -132,3 +132,67 @@ func TestModExpr_Query(t *testing.T) {
|
|||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestQueryExpr_EvalItem(t *testing.T) {
|
||||
var (
|
||||
item = models.Item{
|
||||
"alpha": &types.AttributeValueMemberS{Value: "alpha"},
|
||||
"bravo": &types.AttributeValueMemberN{Value: "123"},
|
||||
"charlie": &types.AttributeValueMemberM{
|
||||
Value: map[string]types.AttributeValue{
|
||||
"door": &types.AttributeValueMemberS{Value: "red"},
|
||||
"tree": &types.AttributeValueMemberS{Value: "green"},
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
t.Run("simple values", func(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
expr string
|
||||
expected types.AttributeValue
|
||||
}{
|
||||
// Simple values
|
||||
{expr: `alpha`, expected: &types.AttributeValueMemberS{Value: "alpha"}},
|
||||
{expr: `bravo`, expected: &types.AttributeValueMemberN{Value: "123"}},
|
||||
{expr: `charlie`, expected: item["charlie"]},
|
||||
|
||||
// Dot values
|
||||
{expr: `charlie.door`, expected: &types.AttributeValueMemberS{Value: "red"}},
|
||||
{expr: `charlie.tree`, expected: &types.AttributeValueMemberS{Value: "green"}},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.expr, func(t *testing.T) {
|
||||
modExpr, err := queryexpr.Parse(scenario.expr)
|
||||
assert.NoError(t, err)
|
||||
|
||||
res, err := modExpr.EvalItem(item)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Equal(t, scenario.expected, res)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("expression errors", func(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
expr string
|
||||
expectedError error
|
||||
}{
|
||||
{expr: `not_present`, expectedError: queryexpr.NameNotFoundError("not_present")},
|
||||
{expr: `alpha.bravo`, expectedError: queryexpr.ValueNotAMapError([]string{"alpha", "bravo"})},
|
||||
{expr: `charlie.tree.bla`, expectedError: queryexpr.ValueNotAMapError([]string{"charlie", "tree", "bla"})},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.expr, func(t *testing.T) {
|
||||
modExpr, err := queryexpr.Parse(scenario.expr)
|
||||
assert.NoError(t, err)
|
||||
|
||||
res, err := modExpr.EvalItem(item)
|
||||
assert.Nil(t, res)
|
||||
assert.Equal(t, scenario.expectedError, err)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
@ -8,6 +8,10 @@ import (
|
|||
)
|
||||
|
||||
func (a *astLiteralValue) dynamoValue() (types.AttributeValue, error) {
|
||||
if a == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
s, err := strconv.Unquote(a.StringVal)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "cannot unquote string")
|
||||
|
@ -16,6 +20,10 @@ func (a *astLiteralValue) dynamoValue() (types.AttributeValue, error) {
|
|||
}
|
||||
|
||||
func (a *astLiteralValue) goValue() (any, error) {
|
||||
if a == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
s, err := strconv.Unquote(a.StringVal)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "cannot unquote string")
|
||||
|
@ -24,5 +32,8 @@ func (a *astLiteralValue) goValue() (any, error) {
|
|||
}
|
||||
|
||||
func (a *astLiteralValue) String() string {
|
||||
if a == nil {
|
||||
return ""
|
||||
}
|
||||
return a.StringVal
|
||||
}
|
||||
|
|
|
@ -6,6 +6,13 @@ type TableInfo struct {
|
|||
DefinedAttributes []string
|
||||
}
|
||||
|
||||
func (ti *TableInfo) Equal(other *TableInfo) bool {
|
||||
return ti.Name == other.Name &&
|
||||
ti.Keys.PartitionKey == other.Keys.PartitionKey &&
|
||||
ti.Keys.SortKey == other.Keys.SortKey &&
|
||||
len(ti.DefinedAttributes) == len(other.DefinedAttributes) // Probably should be all
|
||||
}
|
||||
|
||||
type KeyAttribute struct {
|
||||
PartitionKey string
|
||||
SortKey string
|
||||
|
|
|
@ -32,13 +32,13 @@ func (s *Service) RenderItem(w io.Writer, item models.Item, resultSet *models.Re
|
|||
seenColumns := make(map[string]struct{})
|
||||
for _, colName := range resultSet.Columns() {
|
||||
seenColumns[colName] = struct{}{}
|
||||
if r := item.Renderer(colName); r != nil {
|
||||
if r := itemrender.ToRenderer(item[colName]); r != nil {
|
||||
s.renderItem(tabWriter, "", colName, r, styles)
|
||||
}
|
||||
}
|
||||
for k, _ := range item {
|
||||
if _, seen := seenColumns[k]; !seen {
|
||||
if r := item.Renderer(k); r != nil {
|
||||
if r := itemrender.ToRenderer(item[k]); r != nil {
|
||||
s.renderItem(tabWriter, "", k, r, styles)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,6 +4,15 @@ import "github.com/charmbracelet/bubbles/key"
|
|||
|
||||
func Default() *KeyBindings {
|
||||
return &KeyBindings{
|
||||
ColumnPopup: &FieldsPopupBinding{
|
||||
Close: key.NewBinding(key.WithKeys("ctrl+c", "esc"), key.WithHelp("ctrl+c/esc", "close popup")),
|
||||
ShiftColumnLeft: key.NewBinding(key.WithKeys("I", "shift column left")),
|
||||
ShiftColumnRight: key.NewBinding(key.WithKeys("K", "shift column right")),
|
||||
ToggleVisible: key.NewBinding(key.WithKeys(" ", "toggle column visible")),
|
||||
ResetColumns: key.NewBinding(key.WithKeys("R", "reset columns")),
|
||||
AddColumn: key.NewBinding(key.WithKeys("a", "add new column")),
|
||||
DeleteColumn: key.NewBinding(key.WithKeys("d", "delete column")),
|
||||
},
|
||||
TableView: &TableKeyBinding{
|
||||
MoveUp: key.NewBinding(key.WithKeys("i", "up")),
|
||||
MoveDown: key.NewBinding(key.WithKeys("k", "down")),
|
||||
|
@ -25,6 +34,7 @@ func Default() *KeyBindings {
|
|||
CycleLayoutForward: key.NewBinding(key.WithKeys("w"), key.WithHelp("w", "cycle layout forward")),
|
||||
CycleLayoutBackwards: key.NewBinding(key.WithKeys("W"), key.WithHelp("W", "cycle layout backward")),
|
||||
PromptForCommand: key.NewBinding(key.WithKeys(":"), key.WithHelp(":", "prompt for command")),
|
||||
ShowColumnOverlay: key.NewBinding(key.WithKeys("f"), key.WithHelp("f", "show column overlay")),
|
||||
Quit: key.NewBinding(key.WithKeys("ctrl+c", "esc"), key.WithHelp("ctrl+c/esc", "quit")),
|
||||
},
|
||||
}
|
||||
|
|
|
@ -3,8 +3,19 @@ package keybindings
|
|||
import "github.com/charmbracelet/bubbles/key"
|
||||
|
||||
type KeyBindings struct {
|
||||
TableView *TableKeyBinding `keymap:"item-table"`
|
||||
View *ViewKeyBindings `keymap:"view"`
|
||||
ColumnPopup *FieldsPopupBinding `keymap:"column-popup"`
|
||||
TableView *TableKeyBinding `keymap:"item-table"`
|
||||
View *ViewKeyBindings `keymap:"view"`
|
||||
}
|
||||
|
||||
type FieldsPopupBinding struct {
|
||||
Close key.Binding `keymap:"close"`
|
||||
ShiftColumnLeft key.Binding `keymap:"shift-column-left"`
|
||||
ShiftColumnRight key.Binding `keymap:"shift-column-right"`
|
||||
ToggleVisible key.Binding `keymap:"toggle-column-visible"`
|
||||
ResetColumns key.Binding `keymap:"reset-columns"`
|
||||
AddColumn key.Binding `keymap:"add-column"`
|
||||
DeleteColumn key.Binding `keymap:"delete-column"`
|
||||
}
|
||||
|
||||
type TableKeyBinding struct {
|
||||
|
@ -30,5 +41,6 @@ type ViewKeyBindings struct {
|
|||
CycleLayoutForward key.Binding `keymap:"cycle-layout-forward"`
|
||||
CycleLayoutBackwards key.Binding `keymap:"cycle-layout-backwards"`
|
||||
PromptForCommand key.Binding `keymap:"prompt-for-command"`
|
||||
ShowColumnOverlay key.Binding `keymap:"show-column-overlay"`
|
||||
Quit key.Binding `keymap:"quit"`
|
||||
}
|
||||
|
|
|
@ -9,6 +9,7 @@ import (
|
|||
"github.com/lmika/audax/internal/dynamo-browse/models"
|
||||
"github.com/lmika/audax/internal/dynamo-browse/services/itemrenderer"
|
||||
"github.com/lmika/audax/internal/dynamo-browse/ui/keybindings"
|
||||
"github.com/lmika/audax/internal/dynamo-browse/ui/teamodels/colselector"
|
||||
"github.com/lmika/audax/internal/dynamo-browse/ui/teamodels/dialogprompt"
|
||||
"github.com/lmika/audax/internal/dynamo-browse/ui/teamodels/dynamoitemedit"
|
||||
"github.com/lmika/audax/internal/dynamo-browse/ui/teamodels/dynamoitemview"
|
||||
|
@ -40,7 +41,9 @@ type Model struct {
|
|||
tableReadController *controllers.TableReadController
|
||||
tableWriteController *controllers.TableWriteController
|
||||
settingsController *controllers.SettingsController
|
||||
exportController *controllers.ExportController
|
||||
commandController *commandctrl.CommandController
|
||||
colSelector *colselector.Model
|
||||
itemEdit *dynamoitemedit.Model
|
||||
statusAndPrompt *statusandprompt.StatusAndPrompt
|
||||
tableSelect *tableselect.Model
|
||||
|
@ -57,6 +60,8 @@ type Model struct {
|
|||
func NewModel(
|
||||
rc *controllers.TableReadController,
|
||||
wc *controllers.TableWriteController,
|
||||
columnsController *controllers.ColumnsController,
|
||||
exportController *controllers.ExportController,
|
||||
settingsController *controllers.SettingsController,
|
||||
itemRendererService *itemrenderer.Service,
|
||||
cc *commandctrl.CommandController,
|
||||
|
@ -65,11 +70,12 @@ func NewModel(
|
|||
) Model {
|
||||
uiStyles := styles.DefaultStyles
|
||||
|
||||
dtv := dynamotableview.New(defaultKeyMap.TableView, settingsController, uiStyles)
|
||||
dtv := dynamotableview.New(defaultKeyMap.TableView, columnsController, settingsController, uiStyles)
|
||||
div := dynamoitemview.New(itemRendererService, uiStyles)
|
||||
mainView := layout.NewVBox(layout.LastChildFixedAt(14), dtv, div)
|
||||
|
||||
itemEdit := dynamoitemedit.NewModel(mainView)
|
||||
colSelector := colselector.New(mainView, defaultKeyMap, columnsController)
|
||||
itemEdit := dynamoitemedit.NewModel(colSelector)
|
||||
statusAndPrompt := statusandprompt.New(itemEdit, "", uiStyles.StatusAndPrompt)
|
||||
dialogPrompt := dialogprompt.New(statusAndPrompt)
|
||||
tableSelect := tableselect.New(dialogPrompt, uiStyles)
|
||||
|
@ -88,7 +94,7 @@ func NewModel(
|
|||
if len(args) == 0 {
|
||||
return events.Error(errors.New("expected filename"))
|
||||
}
|
||||
return rc.ExportCSV(args[0])
|
||||
return exportController.ExportCSV(args[0])
|
||||
},
|
||||
"unmark": commandctrl.NoArgCommand(rc.Unmark),
|
||||
"delete": commandctrl.NoArgCommand(wc.DeleteMarked),
|
||||
|
@ -174,6 +180,7 @@ func NewModel(
|
|||
tableWriteController: wc,
|
||||
commandController: cc,
|
||||
itemEdit: itemEdit,
|
||||
colSelector: colSelector,
|
||||
statusAndPrompt: statusAndPrompt,
|
||||
tableSelect: tableSelect,
|
||||
root: root,
|
||||
|
@ -184,16 +191,6 @@ func NewModel(
|
|||
}
|
||||
}
|
||||
|
||||
func (m Model) Init() tea.Cmd {
|
||||
// TODO: this should probably be moved somewhere else
|
||||
rcFilename := os.ExpandEnv(initRCFilename)
|
||||
if err := m.commandController.ExecuteFile(rcFilename); err != nil {
|
||||
log.Println(err)
|
||||
}
|
||||
|
||||
return m.tableReadController.Init
|
||||
}
|
||||
|
||||
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case controllers.SetTableItemView:
|
||||
|
@ -205,15 +202,16 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
events.SetStatus(msg.StatusMessage()),
|
||||
)
|
||||
case tea.KeyMsg:
|
||||
if !m.statusAndPrompt.InPrompt() && !m.tableSelect.Visible() {
|
||||
// TODO: use modes here
|
||||
if !m.statusAndPrompt.InPrompt() && !m.tableSelect.Visible() && !m.colSelector.ColSelectorVisible() {
|
||||
switch {
|
||||
case key.Matches(msg, m.keyMap.Mark):
|
||||
if idx := m.tableView.SelectedItemIndex(); idx >= 0 {
|
||||
return m, func() tea.Msg { return m.tableWriteController.ToggleMark(idx) }
|
||||
return m, events.SetTeaMessage(m.tableWriteController.ToggleMark(idx))
|
||||
}
|
||||
case key.Matches(msg, m.keyMap.CopyItemToClipboard):
|
||||
if idx := m.tableView.SelectedItemIndex(); idx >= 0 {
|
||||
return m, func() tea.Msg { return m.tableReadController.CopyItemToClipboard(idx) }
|
||||
return m, events.SetTeaMessage(m.tableReadController.CopyItemToClipboard(idx))
|
||||
}
|
||||
case key.Matches(msg, m.keyMap.Rescan):
|
||||
return m, m.tableReadController.Rescan
|
||||
|
@ -226,22 +224,18 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
case key.Matches(msg, m.keyMap.ViewForward):
|
||||
return m, m.tableReadController.ViewForward
|
||||
case key.Matches(msg, m.keyMap.CycleLayoutForward):
|
||||
return m, func() tea.Msg {
|
||||
return controllers.SetTableItemView{ViewIndex: utils.Cycle(m.mainViewIndex, 1, ViewModeCount)}
|
||||
}
|
||||
return m, events.SetTeaMessage(controllers.SetTableItemView{ViewIndex: utils.Cycle(m.mainViewIndex, 1, ViewModeCount)})
|
||||
case key.Matches(msg, m.keyMap.CycleLayoutBackwards):
|
||||
return m, func() tea.Msg {
|
||||
return controllers.SetTableItemView{ViewIndex: utils.Cycle(m.mainViewIndex, -1, ViewModeCount)}
|
||||
}
|
||||
return m, events.SetTeaMessage(controllers.SetTableItemView{ViewIndex: utils.Cycle(m.mainViewIndex, -1, ViewModeCount)})
|
||||
//case "e":
|
||||
// m.itemEdit.Visible()
|
||||
// return m, nil
|
||||
case key.Matches(msg, m.keyMap.ShowColumnOverlay):
|
||||
return m, events.SetTeaMessage(controllers.ShowColumnOverlay{})
|
||||
case key.Matches(msg, m.keyMap.PromptForCommand):
|
||||
return m, m.commandController.Prompt
|
||||
case key.Matches(msg, m.keyMap.PromptForTable):
|
||||
return m, func() tea.Msg {
|
||||
return m.tableReadController.ListTables()
|
||||
}
|
||||
return m, events.SetTeaMessage(m.tableReadController.ListTables())
|
||||
case key.Matches(msg, m.keyMap.Quit):
|
||||
return m, tea.Quit
|
||||
}
|
||||
|
@ -253,6 +247,16 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
return m, cmd
|
||||
}
|
||||
|
||||
func (m Model) Init() tea.Cmd {
|
||||
// TODO: this should probably be moved somewhere else
|
||||
rcFilename := os.ExpandEnv(initRCFilename)
|
||||
if err := m.commandController.ExecuteFile(rcFilename); err != nil {
|
||||
log.Println(err)
|
||||
}
|
||||
|
||||
return m.tableReadController.Init
|
||||
}
|
||||
|
||||
func (m Model) View() string {
|
||||
return m.root.View()
|
||||
}
|
||||
|
|
158
internal/dynamo-browse/ui/teamodels/colselector/colmodel.go
Normal file
158
internal/dynamo-browse/ui/teamodels/colselector/colmodel.go
Normal file
|
@ -0,0 +1,158 @@
|
|||
package colselector
|
||||
|
||||
import (
|
||||
"github.com/charmbracelet/bubbles/key"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/lmika/audax/internal/common/ui/events"
|
||||
"github.com/lmika/audax/internal/dynamo-browse/controllers"
|
||||
"github.com/lmika/audax/internal/dynamo-browse/models/columns"
|
||||
"github.com/lmika/audax/internal/dynamo-browse/ui/keybindings"
|
||||
"github.com/lmika/audax/internal/dynamo-browse/ui/teamodels/layout"
|
||||
table "github.com/lmika/go-bubble-table"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var frameColor = lipgloss.Color("63")
|
||||
|
||||
var frameStyle = lipgloss.NewStyle().
|
||||
Foreground(frameColor)
|
||||
var style = lipgloss.NewStyle().
|
||||
BorderStyle(lipgloss.NormalBorder()).
|
||||
BorderForeground(frameColor)
|
||||
|
||||
type colListModel struct {
|
||||
keyBinding *keybindings.KeyBindings
|
||||
colController *controllers.ColumnsController
|
||||
|
||||
rows []table.Row
|
||||
table table.Model
|
||||
}
|
||||
|
||||
func newColListModel(keyBinding *keybindings.KeyBindings, colController *controllers.ColumnsController) *colListModel {
|
||||
tbl := table.New(table.SimpleColumns([]string{"", "Name"}), 100, 100)
|
||||
tbl.SetRows([]table.Row{})
|
||||
|
||||
return &colListModel{
|
||||
keyBinding: keyBinding,
|
||||
colController: colController,
|
||||
table: tbl,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *colListModel) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *colListModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
switch {
|
||||
// Column operations
|
||||
case key.Matches(msg, m.keyBinding.ColumnPopup.ShiftColumnLeft):
|
||||
return m, events.SetTeaMessage(m.shiftColumnUp(m.table.Cursor()))
|
||||
case key.Matches(msg, m.keyBinding.ColumnPopup.ShiftColumnRight):
|
||||
return m, events.SetTeaMessage(m.shiftColumnDown(m.table.Cursor()))
|
||||
case key.Matches(msg, m.keyBinding.ColumnPopup.ToggleVisible):
|
||||
return m, events.SetTeaMessage(m.colController.ToggleVisible(m.table.Cursor()))
|
||||
case key.Matches(msg, m.keyBinding.ColumnPopup.Close):
|
||||
return m, events.SetTeaMessage(controllers.HideColumnOverlay{})
|
||||
case key.Matches(msg, m.keyBinding.ColumnPopup.ResetColumns):
|
||||
return m, events.SetTeaMessage(m.colController.SetColumnsToResultSet())
|
||||
case key.Matches(msg, m.keyBinding.ColumnPopup.AddColumn):
|
||||
return m, events.SetTeaMessage(m.colController.AddColumn(m.table.Cursor()))
|
||||
case key.Matches(msg, m.keyBinding.ColumnPopup.DeleteColumn):
|
||||
return m, events.SetTeaMessage(m.colController.DeleteColumn(m.table.Cursor()))
|
||||
|
||||
// Main table nav
|
||||
case key.Matches(msg, m.keyBinding.TableView.ColLeft):
|
||||
return m, events.SetTeaMessage(controllers.MoveLeftmostDisplayedColumnInTableViewBy(-1))
|
||||
case key.Matches(msg, m.keyBinding.TableView.ColRight):
|
||||
return m, events.SetTeaMessage(controllers.MoveLeftmostDisplayedColumnInTableViewBy(1))
|
||||
|
||||
// Table nav
|
||||
case key.Matches(msg, m.keyBinding.TableView.MoveUp):
|
||||
m.table.GoUp()
|
||||
return m, nil
|
||||
case key.Matches(msg, m.keyBinding.TableView.MoveDown):
|
||||
m.table.GoDown()
|
||||
return m, nil
|
||||
case key.Matches(msg, m.keyBinding.TableView.PageUp):
|
||||
m.table.GoPageUp()
|
||||
return m, nil
|
||||
case key.Matches(msg, m.keyBinding.TableView.PageDown):
|
||||
m.table.GoPageDown()
|
||||
return m, nil
|
||||
case key.Matches(msg, m.keyBinding.TableView.Home):
|
||||
m.table.GoTop()
|
||||
return m, nil
|
||||
case key.Matches(msg, m.keyBinding.TableView.End):
|
||||
m.table.GoBottom()
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
|
||||
var cmd tea.Cmd
|
||||
m.table, cmd = m.table.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (c *colListModel) View() string {
|
||||
innerView := lipgloss.JoinVertical(
|
||||
lipgloss.Top,
|
||||
lipgloss.PlaceHorizontal(overlayWidth-2, lipgloss.Center, "Columns"),
|
||||
frameStyle.Render(strings.Repeat(lipgloss.NormalBorder().Top, 48)),
|
||||
c.table.View(),
|
||||
)
|
||||
|
||||
view := style.Width(overlayWidth - 2).Height(overlayHeight - 2).Render(innerView)
|
||||
|
||||
return view
|
||||
}
|
||||
|
||||
func (c *colListModel) Resize(w, h int) layout.ResizingModel {
|
||||
c.table.SetSize(overlayWidth-4, overlayHeight-4)
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *colListModel) refreshTable() {
|
||||
colsFromController := c.colController.Columns()
|
||||
if len(c.rows) != len(colsFromController.Columns) {
|
||||
c.setColumnsFromModel(colsFromController)
|
||||
}
|
||||
c.table.UpdateView()
|
||||
}
|
||||
|
||||
func (c *colListModel) setColumnsFromModel(cols *columns.Columns) {
|
||||
if cols == nil {
|
||||
c.table.SetRows([]table.Row{})
|
||||
return
|
||||
}
|
||||
|
||||
colNames := make([]table.Row, len(cols.Columns))
|
||||
for i := range cols.Columns {
|
||||
colNames[i] = colListRowModel{c}
|
||||
}
|
||||
c.rows = colNames
|
||||
c.table.SetRows(colNames)
|
||||
|
||||
if c.table.Cursor() >= len(c.rows) {
|
||||
c.table.GoBottom()
|
||||
}
|
||||
}
|
||||
|
||||
func (c *colListModel) shiftColumnUp(cursor int) tea.Msg {
|
||||
msg := c.colController.ShiftColumnLeft(cursor)
|
||||
if msg != nil {
|
||||
c.table.GoUp()
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
func (c *colListModel) shiftColumnDown(cursor int) tea.Msg {
|
||||
msg := c.colController.ShiftColumnRight(cursor)
|
||||
if msg != nil {
|
||||
c.table.GoDown()
|
||||
}
|
||||
return msg
|
||||
}
|
74
internal/dynamo-browse/ui/teamodels/colselector/model.go
Normal file
74
internal/dynamo-browse/ui/teamodels/colselector/model.go
Normal file
|
@ -0,0 +1,74 @@
|
|||
package colselector
|
||||
|
||||
import (
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/lmika/audax/internal/dynamo-browse/controllers"
|
||||
"github.com/lmika/audax/internal/dynamo-browse/ui/keybindings"
|
||||
"github.com/lmika/audax/internal/dynamo-browse/ui/teamodels/layout"
|
||||
"github.com/lmika/audax/internal/dynamo-browse/ui/teamodels/utils"
|
||||
)
|
||||
|
||||
const (
|
||||
overlayWidth = 50
|
||||
overlayHeight = 25
|
||||
)
|
||||
|
||||
type Model struct {
|
||||
columnsController *controllers.ColumnsController
|
||||
subModel tea.Model
|
||||
colListModel *colListModel
|
||||
compositor *layout.Compositor
|
||||
w, h int
|
||||
}
|
||||
|
||||
func New(submodel tea.Model, keyBinding *keybindings.KeyBindings, columnsController *controllers.ColumnsController) *Model {
|
||||
colListModel := newColListModel(keyBinding, columnsController)
|
||||
|
||||
compositor := layout.NewCompositor(submodel)
|
||||
|
||||
return &Model{
|
||||
columnsController: columnsController,
|
||||
subModel: submodel,
|
||||
compositor: compositor,
|
||||
colListModel: colListModel,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) Init() tea.Cmd {
|
||||
return m.subModel.Init()
|
||||
}
|
||||
|
||||
func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var cc utils.CmdCollector
|
||||
switch msg := msg.(type) {
|
||||
case controllers.ShowColumnOverlay:
|
||||
m.colListModel.setColumnsFromModel(m.columnsController.Columns())
|
||||
m.compositor.SetOverlay(m.colListModel, m.w/2-overlayWidth/2, m.h/2-overlayHeight/2, overlayWidth, overlayHeight)
|
||||
case controllers.HideColumnOverlay:
|
||||
m.compositor.ClearOverlay()
|
||||
case controllers.ColumnsUpdated:
|
||||
m.colListModel.refreshTable()
|
||||
m.subModel = cc.Collect(m.subModel.Update(msg))
|
||||
case tea.KeyMsg:
|
||||
m.compositor = cc.Collect(m.compositor.Update(msg)).(*layout.Compositor)
|
||||
default:
|
||||
m.subModel = cc.Collect(m.subModel.Update(msg))
|
||||
}
|
||||
return m, cc.Cmd()
|
||||
}
|
||||
|
||||
func (m *Model) View() string {
|
||||
return m.compositor.View()
|
||||
}
|
||||
|
||||
func (m *Model) Resize(w, h int) layout.ResizingModel {
|
||||
m.w, m.h = w, h
|
||||
m.compositor.MoveOverlay(m.w/2-overlayWidth/2, m.h/2-overlayHeight/2)
|
||||
m.subModel = layout.Resize(m.subModel, w, h)
|
||||
m.colListModel = layout.Resize(m.colListModel, w, h).(*colListModel)
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *Model) ColSelectorVisible() bool {
|
||||
return m.compositor.HasOverlay()
|
||||
}
|
31
internal/dynamo-browse/ui/teamodels/colselector/tblmodel.go
Normal file
31
internal/dynamo-browse/ui/teamodels/colselector/tblmodel.go
Normal file
|
@ -0,0 +1,31 @@
|
|||
package colselector
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
table "github.com/lmika/go-bubble-table"
|
||||
"io"
|
||||
)
|
||||
|
||||
type colListRowModel struct {
|
||||
m *colListModel
|
||||
}
|
||||
|
||||
func (clr colListRowModel) Render(w io.Writer, model table.Model, index int) {
|
||||
cols := clr.m.colController.Columns()
|
||||
if cols == nil {
|
||||
return
|
||||
}
|
||||
|
||||
var style lipgloss.Style
|
||||
if index == model.Cursor() {
|
||||
style = model.Styles.SelectedRow
|
||||
}
|
||||
|
||||
col := clr.m.colController.Columns().Columns[index]
|
||||
if !col.Hidden {
|
||||
fmt.Fprintln(w, style.Render(fmt.Sprintf(".\t%v", col.Name)))
|
||||
} else {
|
||||
fmt.Fprintln(w, style.Render(fmt.Sprintf("✕\t%v", col.Name)))
|
||||
}
|
||||
}
|
|
@ -5,7 +5,7 @@ type columnModel struct {
|
|||
}
|
||||
|
||||
func (cm columnModel) Len() int {
|
||||
return len(cm.m.resultSet.Columns()[cm.m.colOffset:]) + 1
|
||||
return len(cm.m.columns[cm.m.colOffset:]) + 1
|
||||
}
|
||||
|
||||
func (cm columnModel) Header(index int) string {
|
||||
|
@ -13,5 +13,5 @@ func (cm columnModel) Header(index int) string {
|
|||
return ""
|
||||
}
|
||||
|
||||
return cm.m.resultSet.Columns()[cm.m.colOffset+index-1]
|
||||
return cm.m.columns[cm.m.colOffset+index-1].Name
|
||||
}
|
||||
|
|
|
@ -6,6 +6,7 @@ import (
|
|||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/lmika/audax/internal/dynamo-browse/controllers"
|
||||
"github.com/lmika/audax/internal/dynamo-browse/models"
|
||||
"github.com/lmika/audax/internal/dynamo-browse/models/columns"
|
||||
"github.com/lmika/audax/internal/dynamo-browse/ui/keybindings"
|
||||
"github.com/lmika/audax/internal/dynamo-browse/ui/teamodels/dynamoitemview"
|
||||
"github.com/lmika/audax/internal/dynamo-browse/ui/teamodels/frame"
|
||||
|
@ -26,35 +27,42 @@ type Setting interface {
|
|||
IsReadOnly() bool
|
||||
}
|
||||
|
||||
type ColumnsProvider interface {
|
||||
Columns() *columns.Columns
|
||||
}
|
||||
|
||||
type Model struct {
|
||||
frameTitle frame.FrameTitle
|
||||
table table.Model
|
||||
w, h int
|
||||
keyBinding *keybindings.TableKeyBinding
|
||||
setting Setting
|
||||
frameTitle frame.FrameTitle
|
||||
table table.Model
|
||||
w, h int
|
||||
keyBinding *keybindings.TableKeyBinding
|
||||
setting Setting
|
||||
columnsProvider ColumnsProvider
|
||||
|
||||
// model state
|
||||
isReadOnly bool
|
||||
colOffset int
|
||||
rows []table.Row
|
||||
columns []columns.Column
|
||||
resultSet *models.ResultSet
|
||||
}
|
||||
|
||||
func New(keyBinding *keybindings.TableKeyBinding, setting Setting, uiStyles styles.Styles) *Model {
|
||||
tbl := table.New(table.SimpleColumns([]string{"pk", "sk"}), 100, 100)
|
||||
rows := make([]table.Row, 0)
|
||||
tbl.SetRows(rows)
|
||||
|
||||
func New(keyBinding *keybindings.TableKeyBinding, columnsProvider ColumnsProvider, setting Setting, uiStyles styles.Styles) *Model {
|
||||
frameTitle := frame.NewFrameTitle("No table", true, uiStyles.Frames)
|
||||
isReadOnly := setting.IsReadOnly()
|
||||
|
||||
return &Model{
|
||||
isReadOnly: isReadOnly,
|
||||
frameTitle: frameTitle,
|
||||
table: tbl,
|
||||
keyBinding: keyBinding,
|
||||
setting: setting,
|
||||
model := &Model{
|
||||
isReadOnly: isReadOnly,
|
||||
frameTitle: frameTitle,
|
||||
keyBinding: keyBinding,
|
||||
setting: setting,
|
||||
columnsProvider: columnsProvider,
|
||||
}
|
||||
|
||||
model.table = table.New(columnModel{model}, 100, 100)
|
||||
model.table.SetRows([]table.Row{})
|
||||
|
||||
return model
|
||||
}
|
||||
|
||||
func (m *Model) Init() tea.Cmd {
|
||||
|
@ -67,9 +75,15 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
m.resultSet = msg.ResultSet
|
||||
m.updateTable()
|
||||
return m, m.postSelectedItemChanged
|
||||
case controllers.ColumnsUpdated:
|
||||
m.rebuildTable(&m.table)
|
||||
return m, m.postSelectedItemChanged
|
||||
case controllers.SettingsUpdated:
|
||||
m.updateTableHeading()
|
||||
return m, nil
|
||||
case controllers.MoveLeftmostDisplayedColumnInTableViewBy:
|
||||
m.setLeftmostDisplayedColumn(m.colOffset + int(msg))
|
||||
return m, nil
|
||||
case tea.KeyMsg:
|
||||
switch {
|
||||
// Table nav
|
||||
|
@ -106,8 +120,8 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
func (m *Model) setLeftmostDisplayedColumn(newCol int) {
|
||||
if newCol < 0 {
|
||||
m.colOffset = 0
|
||||
} else if newCol >= len(m.resultSet.Columns()) {
|
||||
m.colOffset = len(m.resultSet.Columns()) - 1
|
||||
} else if newCol >= len(m.columnsProvider.Columns().Columns) {
|
||||
m.colOffset = len(m.columnsProvider.Columns().Columns) - 1
|
||||
} else {
|
||||
m.colOffset = newCol
|
||||
}
|
||||
|
@ -139,14 +153,29 @@ func (m *Model) updateTableHeading() {
|
|||
func (m *Model) updateTable() {
|
||||
m.updateTableHeading()
|
||||
m.colOffset = 0
|
||||
m.rebuildTable()
|
||||
m.rebuildTable(nil)
|
||||
}
|
||||
|
||||
func (m *Model) rebuildTable() {
|
||||
func (m *Model) rebuildTable(targetTbl *table.Model) {
|
||||
var tbl table.Model
|
||||
|
||||
resultSet := m.resultSet
|
||||
|
||||
newTbl := table.New(columnModel{m}, m.w, m.h-m.frameTitle.HeaderHeight())
|
||||
// Use the target table model if you can, but if it's nil or the number of rows is smaller than the
|
||||
// existing table, create a new one
|
||||
if targetTbl == nil || len(resultSet.Items()) > len(m.rows) {
|
||||
tbl = table.New(columnModel{m}, m.w, m.h-m.frameTitle.HeaderHeight())
|
||||
if targetTbl != nil {
|
||||
tbl.GoBottom()
|
||||
}
|
||||
} else {
|
||||
tbl = *targetTbl
|
||||
}
|
||||
|
||||
m.columns = m.columnsProvider.Columns().VisibleColumns()
|
||||
|
||||
newRows := make([]table.Row, 0)
|
||||
|
||||
for i, r := range resultSet.Items() {
|
||||
if resultSet.Hidden(i) {
|
||||
continue
|
||||
|
@ -161,8 +190,9 @@ func (m *Model) rebuildTable() {
|
|||
}
|
||||
|
||||
m.rows = newRows
|
||||
newTbl.SetRows(newRows)
|
||||
m.table = newTbl
|
||||
tbl.SetRows(newRows)
|
||||
|
||||
m.table = tbl
|
||||
}
|
||||
|
||||
func (m *Model) SelectedItemIndex() int {
|
||||
|
|
|
@ -3,6 +3,7 @@ package dynamotableview
|
|||
import (
|
||||
"fmt"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/lmika/audax/internal/dynamo-browse/models/itemrender"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
|
@ -63,12 +64,12 @@ func (mtr itemTableRow) Render(w io.Writer, model table.Model, index int) {
|
|||
sb.WriteString(metaInfoStyle.Render("⋅\t"))
|
||||
}
|
||||
|
||||
for i, colName := range mtr.resultSet.Columns()[mtr.model.colOffset:] {
|
||||
for i, col := range mtr.model.columns[mtr.model.colOffset:] {
|
||||
if i > 0 {
|
||||
sb.WriteString(style.Render("\t"))
|
||||
}
|
||||
|
||||
if r := mtr.item.Renderer(colName); r != nil {
|
||||
if r := itemrender.ToRenderer(col.Evaluator.EvaluateForItem(mtr.item)); r != nil {
|
||||
sb.WriteString(style.Render(r.StringValue()))
|
||||
if mi := r.MetaInfo(); mi != "" {
|
||||
sb.WriteString(metaInfoStyle.Render(mi))
|
||||
|
|
|
@ -4,18 +4,21 @@ import (
|
|||
"bufio"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/mattn/go-runewidth"
|
||||
"github.com/muesli/ansi"
|
||||
"github.com/muesli/reflow/truncate"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Compositor struct {
|
||||
background ResizingModel
|
||||
background tea.Model
|
||||
|
||||
foreground ResizingModel
|
||||
foreground tea.Model
|
||||
foreX, foreY int
|
||||
foreW, foreH int
|
||||
}
|
||||
|
||||
func NewCompositor(background ResizingModel) *Compositor {
|
||||
func NewCompositor(background tea.Model) *Compositor {
|
||||
return &Compositor{
|
||||
background: background,
|
||||
}
|
||||
|
@ -26,9 +29,12 @@ func (c *Compositor) Init() tea.Cmd {
|
|||
}
|
||||
|
||||
func (c *Compositor) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
// TODO: allow the compositor the
|
||||
newM, cmd := c.background.Update(msg)
|
||||
c.background = newM.(ResizingModel)
|
||||
var cmd tea.Cmd
|
||||
if c.foreground != nil {
|
||||
c.foreground, cmd = c.foreground.Update(msg)
|
||||
} else {
|
||||
c.background, cmd = c.background.Update(msg)
|
||||
}
|
||||
return c, cmd
|
||||
}
|
||||
|
||||
|
@ -38,6 +44,18 @@ func (c *Compositor) SetOverlay(m ResizingModel, x, y, w, h int) {
|
|||
c.foreW, c.foreH = w, h
|
||||
}
|
||||
|
||||
func (c *Compositor) MoveOverlay(x, y int) {
|
||||
c.foreX, c.foreY = x, y
|
||||
}
|
||||
|
||||
func (c *Compositor) ClearOverlay() {
|
||||
c.foreground = nil
|
||||
}
|
||||
|
||||
func (c *Compositor) HasOverlay() bool {
|
||||
return c.foreground != nil
|
||||
}
|
||||
|
||||
func (c *Compositor) View() string {
|
||||
if c.foreground == nil {
|
||||
return c.background.View()
|
||||
|
@ -46,6 +64,7 @@ func (c *Compositor) View() string {
|
|||
// Need to compose
|
||||
backgroundView := c.background.View()
|
||||
foregroundViewLines := strings.Split(c.foreground.View(), "\n")
|
||||
_ = foregroundViewLines
|
||||
|
||||
backgroundScanner := bufio.NewScanner(strings.NewReader(backgroundView))
|
||||
compositeOutput := new(strings.Builder)
|
||||
|
@ -58,7 +77,7 @@ func (c *Compositor) View() string {
|
|||
|
||||
line := backgroundScanner.Text()
|
||||
if r >= c.foreY && r < c.foreY+c.foreH {
|
||||
compositeOutput.WriteString(line[:c.foreX])
|
||||
compositeOutput.WriteString(truncate.String(line, uint(c.foreX)))
|
||||
|
||||
foregroundScanPos := r - c.foreY
|
||||
if foregroundScanPos < len(foregroundViewLines) {
|
||||
|
@ -66,7 +85,10 @@ func (c *Compositor) View() string {
|
|||
compositeOutput.WriteString(lipgloss.PlaceHorizontal(c.foreW, lipgloss.Left, displayLine, lipgloss.WithWhitespaceChars(" ")))
|
||||
}
|
||||
|
||||
compositeOutput.WriteString(line[c.foreX+c.foreW:])
|
||||
rightStr := c.renderBackgroundUpTo(line, c.foreX+c.foreW)
|
||||
|
||||
// Need to find a way to cut the string here
|
||||
compositeOutput.WriteString(rightStr)
|
||||
} else {
|
||||
compositeOutput.WriteString(line)
|
||||
}
|
||||
|
@ -77,9 +99,33 @@ func (c *Compositor) View() string {
|
|||
}
|
||||
|
||||
func (c *Compositor) Resize(w, h int) ResizingModel {
|
||||
c.background = c.background.Resize(w, h)
|
||||
c.background = Resize(c.background, w, h)
|
||||
if c.foreground != nil {
|
||||
c.foreground = c.foreground.Resize(c.foreW, c.foreH)
|
||||
c.foreground = Resize(c.foreground, c.foreW, c.foreH)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Compositor) renderBackgroundUpTo(line string, x int) string {
|
||||
ansiSequences := new(strings.Builder)
|
||||
posX := 0
|
||||
inAnsi := false
|
||||
|
||||
for i, c := range line {
|
||||
if c == ansi.Marker {
|
||||
ansiSequences.WriteRune(c)
|
||||
inAnsi = true
|
||||
} else if inAnsi {
|
||||
ansiSequences.WriteRune(c)
|
||||
if ansi.IsTerminator(c) {
|
||||
inAnsi = false
|
||||
}
|
||||
} else {
|
||||
if posX >= x {
|
||||
return ansiSequences.String() + line[i:]
|
||||
}
|
||||
posX += runewidth.RuneWidth(c)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
|
|
@ -2,13 +2,13 @@ package main
|
|||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"github.com/aws/aws-sdk-go-v2/aws"
|
||||
"github.com/aws/aws-sdk-go-v2/config"
|
||||
"github.com/aws/aws-sdk-go-v2/service/dynamodb"
|
||||
"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
|
||||
"github.com/brianvoe/gofakeit/v6"
|
||||
"github.com/google/uuid"
|
||||
"github.com/lmika/audax/internal/dynamo-browse/models"
|
||||
"github.com/lmika/audax/internal/dynamo-browse/providers/dynamo"
|
||||
"github.com/lmika/audax/internal/dynamo-browse/services/tables"
|
||||
|
@ -18,9 +18,13 @@ import (
|
|||
)
|
||||
|
||||
func main() {
|
||||
var flagSeed = flag.Int64("seed", 0, "random seed to use")
|
||||
var flagCount = flag.Int("count", 500, "number of items to produce")
|
||||
flag.Parse()
|
||||
|
||||
ctx := context.Background()
|
||||
tableName := "business-addresses"
|
||||
totalItems := 500
|
||||
totalItems := *flagCount
|
||||
|
||||
cfg, err := config.LoadDefaultConfig(ctx)
|
||||
if err != nil {
|
||||
|
@ -53,8 +57,11 @@ func main() {
|
|||
|
||||
_, _ = tableService, tableInfo
|
||||
|
||||
log.Printf("using seed: %v", *flagSeed)
|
||||
gofakeit.Seed(*flagSeed)
|
||||
|
||||
for i := 0; i < totalItems; i++ {
|
||||
key := uuid.New().String()
|
||||
key := gofakeit.UUID()
|
||||
if err := tableService.Put(ctx, tableInfo, models.Item{
|
||||
"pk": &types.AttributeValueMemberS{Value: key},
|
||||
"sk": &types.AttributeValueMemberS{Value: key},
|
||||
|
@ -64,6 +71,12 @@ func main() {
|
|||
"phone": &types.AttributeValueMemberN{Value: gofakeit.Phone()},
|
||||
"web": &types.AttributeValueMemberS{Value: gofakeit.URL()},
|
||||
"officeOpened": &types.AttributeValueMemberBOOL{Value: gofakeit.Bool()},
|
||||
"colors": &types.AttributeValueMemberM{
|
||||
Value: map[string]types.AttributeValue{
|
||||
"door": &types.AttributeValueMemberS{Value: gofakeit.Color()},
|
||||
"front": &types.AttributeValueMemberS{Value: gofakeit.Color()},
|
||||
},
|
||||
},
|
||||
"ratings": &types.AttributeValueMemberL{Value: []types.AttributeValue{
|
||||
&types.AttributeValueMemberN{Value: fmt.Sprint(gofakeit.IntRange(0, 5))},
|
||||
&types.AttributeValueMemberN{Value: fmt.Sprint(gofakeit.IntRange(0, 5))},
|
||||
|
|
Loading…
Reference in a new issue