diff --git a/cmd/dynamo-browse/main.go b/cmd/dynamo-browse/main.go index f72de9c..f56a40b 100644 --- a/cmd/dynamo-browse/main.go +++ b/cmd/dynamo-browse/main.go @@ -43,8 +43,9 @@ func main() { tableService := tables.NewService(dynamoProvider) - tableReadController := controllers.NewTableReadController(tableService, *flagTable) - tableWriteController := controllers.NewTableWriteController(tableService, tableReadController) + state := controllers.NewState() + tableReadController := controllers.NewTableReadController(state, tableService, *flagTable) + tableWriteController := controllers.NewTableWriteController(state, tableService, tableReadController) commandController := commandctrl.NewCommandController() model := ui.NewModel(tableReadController, tableWriteController, commandController) diff --git a/internal/dynamo-browse/controllers/state.go b/internal/dynamo-browse/controllers/state.go index a711e6d..3518e6b 100644 --- a/internal/dynamo-browse/controllers/state.go +++ b/internal/dynamo-browse/controllers/state.go @@ -1,28 +1,46 @@ package controllers import ( - "context" + "sync" "github.com/lmika/awstools/internal/dynamo-browse/models" ) type State struct { - ResultSet *models.ResultSet - SelectedItem models.Item - - // InReadWriteMode indicates whether modifications can be made to the table - InReadWriteMode bool + mutex *sync.Mutex + resultSet *models.ResultSet + filter string } -type stateContextKeyType struct{} - -var stateContextKey = stateContextKeyType{} - -func CurrentState(ctx context.Context) State { - state, _ := ctx.Value(stateContextKey).(State) - return state +func NewState() *State { + return &State{ + mutex: new(sync.Mutex), + } } -func ContextWithState(ctx context.Context, state State) context.Context { - return context.WithValue(ctx, stateContextKey, state) +func (s *State) ResultSet() *models.ResultSet { + s.mutex.Lock() + defer s.mutex.Unlock() + return s.resultSet +} + +func (s *State) Filter() string { + s.mutex.Lock() + defer s.mutex.Unlock() + return s.filter +} + +func (s *State) withResultSet(rs func(*models.ResultSet)) { + s.mutex.Lock() + defer s.mutex.Unlock() + + rs(s.resultSet) +} + +func (s *State) setResultSetAndFilter(resultSet *models.ResultSet, filter string) { + s.mutex.Lock() + defer s.mutex.Unlock() + + s.resultSet = resultSet + s.filter = filter } diff --git a/internal/dynamo-browse/controllers/tableread.go b/internal/dynamo-browse/controllers/tableread.go index 23b6d83..07bbea8 100644 --- a/internal/dynamo-browse/controllers/tableread.go +++ b/internal/dynamo-browse/controllers/tableread.go @@ -16,13 +16,15 @@ type TableReadController struct { tableName string // state - mutex *sync.Mutex - resultSet *models.ResultSet - filter string + mutex *sync.Mutex + state *State + //resultSet *models.ResultSet + //filter string } -func NewTableReadController(tableService TableReadService, tableName string) *TableReadController { +func NewTableReadController(state *State, tableService TableReadService, tableName string) *TableReadController { return &TableReadController{ + state: state, tableService: tableService, tableName: tableName, mutex: new(sync.Mutex), @@ -68,19 +70,19 @@ func (c *TableReadController) ScanTable(name string) tea.Cmd { return events.Error(err) } - return c.setResultSetAndFilter(resultSet, c.filter) + return c.setResultSetAndFilter(resultSet, c.state.Filter()) } } func (c *TableReadController) Rescan() tea.Cmd { return func() tea.Msg { - return c.doScan(context.Background(), c.resultSet) + return c.doScan(context.Background(), c.state.ResultSet()) } } func (c *TableReadController) ExportCSV(filename string) tea.Cmd { return func() tea.Msg { - resultSet := c.resultSet + resultSet := c.state.ResultSet() if resultSet == nil { return events.Error(errors.New("no result set")) } @@ -119,39 +121,30 @@ func (c *TableReadController) doScan(ctx context.Context, resultSet *models.Resu return events.Error(err) } - newResultSet = c.tableService.Filter(newResultSet, c.filter) + newResultSet = c.tableService.Filter(newResultSet, c.state.Filter()) - return c.setResultSetAndFilter(newResultSet, c.filter) + return c.setResultSetAndFilter(newResultSet, c.state.Filter()) } -func (c *TableReadController) ResultSet() *models.ResultSet { - c.mutex.Lock() - defer c.mutex.Unlock() - - return c.resultSet -} +//func (c *TableReadController) ResultSet() *models.ResultSet { +// c.mutex.Lock() +// defer c.mutex.Unlock() +// +// return c.resultSet +//} func (c *TableReadController) setResultSetAndFilter(resultSet *models.ResultSet, filter string) tea.Msg { - c.mutex.Lock() - defer c.mutex.Unlock() - - c.resultSet = resultSet - c.filter = filter + c.state.setResultSetAndFilter(resultSet, filter) return NewResultSet{resultSet} } func (c *TableReadController) Unmark() tea.Cmd { return func() tea.Msg { - resultSet := c.ResultSet() - - for i := range resultSet.Items() { - resultSet.SetMark(i, false) - } - - c.mutex.Lock() - defer c.mutex.Unlock() - - c.resultSet = resultSet + c.state.withResultSet(func(resultSet *models.ResultSet) { + for i := range resultSet.Items() { + resultSet.SetMark(i, false) + } + }) return ResultSetUpdated{} } } @@ -162,7 +155,7 @@ func (c *TableReadController) Filter() tea.Cmd { Prompt: "filter: ", OnDone: func(value string) tea.Cmd { return func() tea.Msg { - resultSet := c.ResultSet() + resultSet := c.state.ResultSet() newResultSet := c.tableService.Filter(resultSet, value) return c.setResultSetAndFilter(newResultSet, value) diff --git a/internal/dynamo-browse/controllers/tableread_test.go b/internal/dynamo-browse/controllers/tableread_test.go index 006a390..d061394 100644 --- a/internal/dynamo-browse/controllers/tableread_test.go +++ b/internal/dynamo-browse/controllers/tableread_test.go @@ -22,7 +22,7 @@ func TestTableReadController_InitTable(t *testing.T) { service := tables.NewService(provider) t.Run("should prompt for table if no table name provided", func(t *testing.T) { - readController := controllers.NewTableReadController(service, "") + readController := controllers.NewTableReadController(controllers.NewState(), service, "") cmd := readController.Init() event := cmd() @@ -31,7 +31,7 @@ func TestTableReadController_InitTable(t *testing.T) { }) t.Run("should scan table if table name provided", func(t *testing.T) { - readController := controllers.NewTableReadController(service, "") + readController := controllers.NewTableReadController(controllers.NewState(), service, "") cmd := readController.Init() event := cmd() @@ -46,7 +46,7 @@ func TestTableReadController_ListTables(t *testing.T) { provider := dynamo.NewProvider(client) service := tables.NewService(provider) - readController := controllers.NewTableReadController(service, "") + readController := controllers.NewTableReadController(controllers.NewState(), service, "") t.Run("returns a list of tables", func(t *testing.T) { cmd := readController.ListTables() @@ -70,7 +70,7 @@ func TestTableReadController_ExportCSV(t *testing.T) { provider := dynamo.NewProvider(client) service := tables.NewService(provider) - readController := controllers.NewTableReadController(service, "alpha-table") + readController := controllers.NewTableReadController(controllers.NewState(), service, "alpha-table") t.Run("should export result set to CSV file", func(t *testing.T) { tempFile := tempFile(t) @@ -91,7 +91,7 @@ func TestTableReadController_ExportCSV(t *testing.T) { t.Run("should return error if result set is not set", func(t *testing.T) { tempFile := tempFile(t) - readController := controllers.NewTableReadController(service, "non-existant-table") + readController := controllers.NewTableReadController(controllers.NewState(), service, "non-existant-table") invokeCommandExpectingError(t, readController.Init()) invokeCommandExpectingError(t, readController.ExportCSV(tempFile)) @@ -123,6 +123,17 @@ func invokeCommand(t *testing.T, cmd tea.Cmd) { } } +func invokeCommandWithPrompt(t *testing.T, cmd tea.Cmd, promptValue string) { + msg := cmd() + + pi, isPi := msg.(events.PromptForInputMsg) + if !isPi { + assert.Fail(t, fmt.Sprintf("expected prompt for input but didn't get one")) + } + + invokeCommand(t, pi.OnDone(promptValue)) +} + func invokeCommandExpectingError(t *testing.T, cmd tea.Cmd) { msg := cmd() diff --git a/internal/dynamo-browse/controllers/tablewrite.go b/internal/dynamo-browse/controllers/tablewrite.go index 4b56b5f..a7276e1 100644 --- a/internal/dynamo-browse/controllers/tablewrite.go +++ b/internal/dynamo-browse/controllers/tablewrite.go @@ -3,18 +3,22 @@ package controllers import ( "context" "fmt" + "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" tea "github.com/charmbracelet/bubbletea" "github.com/lmika/awstools/internal/common/ui/events" + "github.com/lmika/awstools/internal/dynamo-browse/models" "github.com/lmika/awstools/internal/dynamo-browse/services/tables" ) type TableWriteController struct { + state *State tableService *tables.Service tableReadControllers *TableReadController } -func NewTableWriteController(tableService *tables.Service, tableReadControllers *TableReadController) *TableWriteController { +func NewTableWriteController(state *State, tableService *tables.Service, tableReadControllers *TableReadController) *TableWriteController { return &TableWriteController{ + state: state, tableService: tableService, tableReadControllers: tableReadControllers, } @@ -22,16 +26,46 @@ func NewTableWriteController(tableService *tables.Service, tableReadControllers func (twc *TableWriteController) ToggleMark(idx int) tea.Cmd { return func() tea.Msg { - resultSet := twc.tableReadControllers.ResultSet() - resultSet.SetMark(idx, !resultSet.Marked(idx)) + twc.state.withResultSet(func(resultSet *models.ResultSet) { + resultSet.SetMark(idx, !resultSet.Marked(idx)) + }) return ResultSetUpdated{} } } +func (twc *TableWriteController) NewItem() tea.Cmd { + return func() tea.Msg { + twc.state.withResultSet(func(set *models.ResultSet) { + set.AddNewItem(models.Item{}, models.ItemAttribute{ + New: true, + Dirty: true, + }) + }) + return NewResultSet{twc.state.ResultSet()} + } +} + +func (twc *TableWriteController) SetItemValue(idx int, key string) tea.Cmd { + return func() tea.Msg { + return events.PromptForInputMsg{ + Prompt: "string value: ", + OnDone: func(value string) tea.Cmd { + return func() tea.Msg { + twc.state.withResultSet(func(set *models.ResultSet) { + set.Items()[idx][key] = &types.AttributeValueMemberS{Value: value} + set.SetDirty(idx, true) + }) + return ResultSetUpdated{} + } + }, + } + } +} + func (twc *TableWriteController) DeleteMarked() tea.Cmd { return func() tea.Msg { - resultSet := twc.tableReadControllers.ResultSet() + resultSet := twc.state.ResultSet() markedItems := resultSet.MarkedItems() if len(markedItems) == 0 { diff --git a/internal/dynamo-browse/controllers/tablewrite_test.go b/internal/dynamo-browse/controllers/tablewrite_test.go index a8f5c1a..745a869 100644 --- a/internal/dynamo-browse/controllers/tablewrite_test.go +++ b/internal/dynamo-browse/controllers/tablewrite_test.go @@ -1,6 +1,11 @@ package controllers_test import ( + "github.com/lmika/awstools/internal/dynamo-browse/controllers" + "github.com/lmika/awstools/internal/dynamo-browse/providers/dynamo" + "github.com/lmika/awstools/internal/dynamo-browse/services/tables" + "github.com/lmika/awstools/test/testdynamo" + "github.com/stretchr/testify/assert" "testing" ) @@ -173,24 +178,53 @@ func setupController(t *testing.T) (*controllers.TableWriteController, controlle tableService: tableService, }, cleanupFn } - -var testData = testdynamo.TestData{ - { - "pk": "abc", - "sk": "222", - "alpha": "This is another some value", - "beta": 1231, - }, - { - "pk": "abc", - "sk": "111", - "alpha": "This is some value", - }, - { - "pk": "bbb", - "sk": "131", - "beta": 2468, - "gamma": "foobar", - }, -} */ + +func TestTableWriteController_NewItem(t *testing.T) { + client, cleanupFn := testdynamo.SetupTestTable(t, testData) + defer cleanupFn() + + provider := dynamo.NewProvider(client) + service := tables.NewService(provider) + + t.Run("should add a new empty item at the end of the result set", func(t *testing.T) { + state := controllers.NewState() + readController := controllers.NewTableReadController(state, service, "alpha-table") + writeController := controllers.NewTableWriteController(state, service, readController) + + invokeCommand(t, readController.Init()) + assert.Len(t, state.ResultSet().Items(), 3) + + invokeCommand(t, writeController.NewItem()) + newResultSet := state.ResultSet() + assert.Len(t, newResultSet.Items(), 4) + assert.Len(t, newResultSet.Items()[3], 0) + assert.True(t, newResultSet.IsNew(3)) + assert.True(t, newResultSet.IsDirty(3)) + }) +} + +func TestTableWriteController_SetItemValue(t *testing.T) { + client, cleanupFn := testdynamo.SetupTestTable(t, testData) + defer cleanupFn() + + provider := dynamo.NewProvider(client) + service := tables.NewService(provider) + + t.Run("should add a new empty item at the end of the result set", func(t *testing.T) { + state := controllers.NewState() + readController := controllers.NewTableReadController(state, service, "alpha-table") + writeController := controllers.NewTableWriteController(state, service, readController) + + invokeCommand(t, readController.Init()) + before, _ := state.ResultSet().Items()[0].AttributeValueAsString("alpha") + assert.Equal(t, "This is some value", before) + assert.False(t, state.ResultSet().IsDirty(0)) + + invokeCommandWithPrompt(t, writeController.SetItemValue(0, "alpha"), "a new value") + + after, _ := state.ResultSet().Items()[0].AttributeValueAsString("alpha") + assert.Equal(t, "a new value", after) + assert.True(t, state.ResultSet().IsDirty(0)) + }) +} diff --git a/internal/dynamo-browse/models/models.go b/internal/dynamo-browse/models/models.go index e709e1b..40e4f31 100644 --- a/internal/dynamo-browse/models/models.go +++ b/internal/dynamo-browse/models/models.go @@ -10,6 +10,8 @@ type ResultSet struct { type ItemAttribute struct { Marked bool Hidden bool + Dirty bool + New bool } func (rs *ResultSet) Items() []Item { @@ -21,6 +23,11 @@ func (rs *ResultSet) SetItems(items []Item) { rs.attributes = make([]ItemAttribute, len(items)) } +func (rs *ResultSet) AddNewItem(item Item, attrs ItemAttribute) { + rs.items = append(rs.items, item) + rs.attributes = append(rs.attributes, attrs) +} + func (rs *ResultSet) SetMark(idx int, marked bool) { rs.attributes[idx].Marked = marked } @@ -29,6 +36,14 @@ func (rs *ResultSet) SetHidden(idx int, hidden bool) { rs.attributes[idx].Hidden = hidden } +func (rs *ResultSet) SetDirty(idx int, dirty bool) { + rs.attributes[idx].Dirty = dirty +} + +func (rs *ResultSet) SetNew(idx int, isNew bool) { + rs.attributes[idx].New = isNew +} + func (rs *ResultSet) Marked(idx int) bool { return rs.attributes[idx].Marked } @@ -37,6 +52,14 @@ func (rs *ResultSet) Hidden(idx int) bool { return rs.attributes[idx].Hidden } +func (rs *ResultSet) IsDirty(idx int) bool { + return rs.attributes[idx].Dirty +} + +func (rs *ResultSet) IsNew(idx int) bool { + return rs.attributes[idx].New +} + func (rs *ResultSet) MarkedItems() []Item { items := make([]Item, 0) for i, itemAttr := range rs.attributes { diff --git a/internal/dynamo-browse/ui/model.go b/internal/dynamo-browse/ui/model.go index f59642b..28ff05b 100644 --- a/internal/dynamo-browse/ui/model.go +++ b/internal/dynamo-browse/ui/model.go @@ -48,6 +48,15 @@ func NewModel(rc *controllers.TableReadController, wc *controllers.TableWriteCon }, "unmark": commandctrl.NoArgCommand(rc.Unmark()), "delete": commandctrl.NoArgCommand(wc.DeleteMarked()), + + // TEMP + "new-item": commandctrl.NoArgCommand(wc.NewItem()), + "set": func(args []string) tea.Cmd { + if len(args) != 1 { + return events.SetError(errors.New("expected attribute key")) + } + return wc.SetItemValue(dtv.SelectedItemIndex(), args[0]) + }, }, }) diff --git a/internal/dynamo-browse/ui/teamodels/dynamotableview/model.go b/internal/dynamo-browse/ui/teamodels/dynamotableview/model.go index 2bf3f6f..c12bd7a 100644 --- a/internal/dynamo-browse/ui/teamodels/dynamotableview/model.go +++ b/internal/dynamo-browse/ui/teamodels/dynamotableview/model.go @@ -135,5 +135,6 @@ func (m *Model) postSelectedItemChanged() tea.Msg { } func (m *Model) Refresh() { + m.table.SetRows(m.rows) } diff --git a/test/cmd/load-test-table/main.go b/test/cmd/load-test-table/main.go index 1db13d7..df59068 100644 --- a/test/cmd/load-test-table/main.go +++ b/test/cmd/load-test-table/main.go @@ -19,7 +19,7 @@ import ( func main() { ctx := context.Background() tableName := "awstools-test" - totalItems := 300 + totalItems := 10 cfg, err := config.LoadDefaultConfig(ctx) if err != nil { @@ -27,7 +27,7 @@ func main() { } dynamoClient := dynamodb.NewFromConfig(cfg, - dynamodb.WithEndpointResolver(dynamodb.EndpointResolverFromURL("http://localhost:8000"))) + dynamodb.WithEndpointResolver(dynamodb.EndpointResolverFromURL("http://localhost:18000"))) if _, err = dynamoClient.DeleteTable(ctx, &dynamodb.DeleteTableInput{ TableName: aws.String(tableName),