diff --git a/tui/tabs/context_item.go b/tui/tabs/context_item.go index 1fbaf0d..a209855 100644 --- a/tui/tabs/context_item.go +++ b/tui/tabs/context_item.go @@ -7,6 +7,7 @@ import ( "github.com/charmbracelet/bubbles/list" "github.com/lvlcn-t/azctx/config" "github.com/lvlcn-t/azctx/tui/details" + "github.com/lvlcn-t/azctx/tui/form" "github.com/lvlcn-t/azctx/tui/styles" ) @@ -77,3 +78,65 @@ func (i *ContextItem) Details() details.View { }, } } + +// contextForm builds the create or edit form for a context. On edit the name is +// pre-filled and locked; tenant and credential must reference existing entries. +// The tenant and credential help text lists the available names. +func contextForm(intent formIntent, store *config.Store, item details.Item) form.Model { + var name, tenant, credential, subscription string + title := "New context" + readonly := false + if ctx, ok := item.(*ContextItem); ok && intent == intentEdit { + name = ctx.Name + tenant = ctx.Tenant.Name + credential = ctx.Credential.Name + subscription = ctx.Subscription + title = "Edit context" + readonly = true + } + + return form.New(title, []form.Field{ + {Key: fieldName, Label: labelName, Placeholder: "my-context", Value: name, Required: true, ReadOnly: readonly}, + { + Key: fieldTenant, Label: "Tenant", Value: tenant, Required: true, + Placeholder: strings.Join(tenantNames(store), ", "), + Validate: existsValidator("tenant", tenantNames(store)), + }, + { + Key: fieldCredential, Label: "Credential", Value: credential, Required: true, + Placeholder: strings.Join(credentialNames(store), ", "), + Validate: existsValidator("credential", credentialNames(store)), + }, + {Key: fieldSubscription, Label: "Subscription", Value: subscription, Placeholder: "optional"}, + }) +} + +// tenantNames returns the configured tenant names. +func tenantNames(store *config.Store) []string { + names := make([]string, 0, len(store.Config.Tenants)) + for _, t := range store.Config.Tenants { + names = append(names, t.Name) + } + return names +} + +// credentialNames returns the configured credential names. +func credentialNames(store *config.Store) []string { + names := make([]string, 0, len(store.Config.Credentials)) + for _, c := range store.Config.Credentials { + names = append(names, c.Name) + } + return names +} + +// existsValidator rejects a value that is not one of the allowed names. +func existsValidator(kind string, allowed []string) func(string) error { + return func(value string) error { + for _, name := range allowed { + if name == value { + return nil + } + } + return fmt.Errorf("%s %q does not exist", kind, value) + } +} diff --git a/tui/tabs/contexts.go b/tui/tabs/contexts.go index 3e2698e..c0df4f0 100644 --- a/tui/tabs/contexts.go +++ b/tui/tabs/contexts.go @@ -25,6 +25,10 @@ func contextsTab(s *state.UI, l listBuilder) *ContextsTab { //nolint:gocritic // } tk := newTabKeys(sel, view, keys.New(keys.Escape).WithHelp("close").Bind()) + tk.Create = keys.New(keys.Create).WithHelp("new").Bind() + tk.Edit = keys.New(keys.Edit).WithHelp("edit").Bind() + tk.Rename = keys.New(keys.Rename).WithHelp("rename").Bind() + tk.Delete = keys.New(keys.Delete).WithHelp("delete").Bind() items := contextItems(s.Config()) return &ContextsTab{ list: l.WithItems(items...). @@ -63,6 +67,27 @@ func (t *ContextsTab) Update(msg tea.Msg) (TabAction, tea.Cmd) { } return ShowDetails(item), nil + case keys.Matches(msg, t.keys.Create): + return Create(), nil + + case keys.Matches(msg, t.keys.Edit): + if item, ok := t.list.SelectedItem().(*ContextItem); ok { + return Edit(item), nil + } + return NoAction(), nil + + case keys.Matches(msg, t.keys.Rename): + if item, ok := t.list.SelectedItem().(*ContextItem); ok { + return Rename(item), nil + } + return NoAction(), nil + + case keys.Matches(msg, t.keys.Delete): + if item, ok := t.list.SelectedItem().(*ContextItem); ok { + return Delete(item), nil + } + return NoAction(), nil + case keys.Matches(msg, t.keys.Close): // Catch close events to prevent the list from exiting when the user // spams the esc key while filtering. diff --git a/tui/tabs/contexts_flow_test.go b/tui/tabs/contexts_flow_test.go new file mode 100644 index 0000000..6cbea86 --- /dev/null +++ b/tui/tabs/contexts_flow_test.go @@ -0,0 +1,107 @@ +package tabs + +import ( + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/lvlcn-t/azctx/tui/state" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTabs_CreateContext(t *testing.T) { + path := writeConfig(t, baseConfig()) + tabs := newTabsOn(t, contextsTabIndex) + + // 'n' opens the create form. + tabs.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("n")}) + require.True(t, tabs.state.Is(state.FormView)) + + // name, tenant, credential, subscription. + typeRunes(tabs, "prod") + tabs.Update(tea.KeyMsg{Type: tea.KeyTab}) + typeRunes(tabs, "corp") + tabs.Update(tea.KeyMsg{Type: tea.KeyTab}) + typeRunes(tabs, "user") + tabs.Update(tea.KeyMsg{Type: tea.KeyTab}) + typeRunes(tabs, "sub-prod") + cmd := tabs.Update(tea.KeyMsg{Type: tea.KeyEnter}) + drain(tabs, cmd) + + require.True(t, tabs.state.Is(state.Tabs)) + got, found := readConfig(t, path).ContextByName("prod") + require.True(t, found) + assert.Equal(t, "corp", got.Details.Tenant) + assert.Equal(t, "user", got.Details.Credential) + assert.Equal(t, "sub-prod", got.Details.Subscription) +} + +func TestTabs_CreateContext_RejectsUnknownTenant(t *testing.T) { + writeConfig(t, baseConfig()) + tabs := newTabsOn(t, contextsTabIndex) + + tabs.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("n")}) + typeRunes(tabs, "prod") + tabs.Update(tea.KeyMsg{Type: tea.KeyTab}) + typeRunes(tabs, "ghost") // not an existing tenant + tabs.Update(tea.KeyMsg{Type: tea.KeyTab}) + typeRunes(tabs, "user") + cmd := tabs.Update(tea.KeyMsg{Type: tea.KeyEnter}) + drain(tabs, cmd) + + // Validation blocks submission; the form stays open with an inline error. + require.True(t, tabs.state.Is(state.FormView)) + assert.Contains(t, tabs.form.View(), "does not exist") +} + +func TestTabs_EditContext_UpdatesInPlace(t *testing.T) { + path := writeConfig(t, baseConfig()) + tabs := newTabsOn(t, contextsTabIndex) + + // 'e' opens the edit form for the selected (only) context 'dev'. + tabs.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("e")}) + require.True(t, tabs.state.Is(state.FormView)) + require.Equal(t, "dev", tabs.form.Values()["name"]) + + // Name is locked; focus starts on tenant. Move to subscription and edit it. + tabs.Update(tea.KeyMsg{Type: tea.KeyTab}) // tenant -> credential + tabs.Update(tea.KeyMsg{Type: tea.KeyTab}) // credential -> subscription + typeRunes(tabs, "sub-new") + cmd := tabs.Update(tea.KeyMsg{Type: tea.KeyEnter}) + drain(tabs, cmd) + + cfg := readConfig(t, path) + require.Len(t, cfg.Contexts, 1) + assert.Equal(t, "dev", cfg.Contexts[0].Name) + assert.Equal(t, "sub-new", cfg.Contexts[0].Details.Subscription) +} + +func TestTabs_RenameContext(t *testing.T) { + path := writeConfig(t, baseConfig()) + tabs := newTabsOn(t, contextsTabIndex) + + tabs.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("r")}) + require.True(t, tabs.state.Is(state.FormView)) + + typeRunes(tabs, "development") + cmd := tabs.Update(tea.KeyMsg{Type: tea.KeyEnter}) + drain(tabs, cmd) + + cfg := readConfig(t, path) + require.Len(t, cfg.Contexts, 1) + assert.Equal(t, "development", cfg.Contexts[0].Name) +} + +func TestTabs_DeleteContext(t *testing.T) { + path := writeConfig(t, baseConfig()) + tabs := newTabsOn(t, contextsTabIndex) + + tabs.Update(tea.KeyMsg{Type: tea.KeyCtrlD}) + require.True(t, tabs.state.Is(state.ConfirmView)) + + cmd := tabs.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("y")}) + drain(tabs, cmd) + + _, found := readConfig(t, path).ContextByName("dev") + assert.False(t, found) +} diff --git a/tui/tabs/tab.go b/tui/tabs/tab.go index 7f83bb7..0b5b9e6 100644 --- a/tui/tabs/tab.go +++ b/tui/tabs/tab.go @@ -12,6 +12,15 @@ import ( // labelName is the shared display label for an entry's name field. const labelName = "Name" +// Form field keys shared across the entry forms. +const ( + fieldName = "name" + fieldID = "id" + fieldTenant = "tenant" + fieldCredential = "credential" + fieldSubscription = "subscription" +) + // Tab represents a single tab in the UI, responsible for rendering its content and handling interactions. type Tab interface { Resize(width, height int) diff --git a/tui/tabs/tabs.go b/tui/tabs/tabs.go index ecb6ee8..c25fc57 100644 --- a/tui/tabs/tabs.go +++ b/tui/tabs/tabs.go @@ -21,6 +21,11 @@ type Manager interface { UpdateTenant(store *config.Store, name, id string) error RenameTenant(store *config.Store, oldName, newName string) (contexts.RenameResult, error) DeleteTenant(store *config.Store, name string) (contexts.DeleteResult, error) + + CreateContext(store *config.Store, next config.Context) error + UpdateContext(store *config.Store, next config.Context, subscriptionChanged bool) error + RenameContext(store *config.Store, oldName, newName string) error + DeleteContext(store *config.Store, name string) (contexts.DeleteResult, error) } // formIntent records what a submitted form should do. @@ -275,13 +280,22 @@ func (t *Tabs) openDelete(item details.Item) tea.Cmd { // for edit and rename. The second return is false when the active tab does not // support the requested form. func (t *Tabs) buildForm(intent formIntent, item details.Item) (form.Model, bool) { - if _, ok := t.tabs[t.active].(*TenantsTab); ok { + switch t.tabs[t.active].(type) { + case *TenantsTab: if intent == intentRename { return renameForm("tenant", item), true } return tenantForm(intent, item), true + + case *ContextsTab: + if intent == intentRename { + return renameForm("context", item), true + } + return contextForm(intent, t.state.Config(), item), true + + default: + return form.Model{}, false } - return form.Model{}, false } // applyForm persists the submitted form values according to the active tab and @@ -289,8 +303,49 @@ func (t *Tabs) buildForm(intent formIntent, item details.Item) (form.Model, bool func (t *Tabs) applyForm(values map[string]string) tea.Cmd { t.state.Transition(state.Tabs) - // PR2 wires tenants only; contexts and credentials follow. - return t.applyTenantForm(values) + switch t.tabs[t.active].(type) { + case *TenantsTab: + return t.applyTenantForm(values) + case *ContextsTab: + return t.applyContextForm(values) + default: + return nil + } +} + +// applyContextForm maps a submitted context form to the matching intent method. +func (t *Tabs) applyContextForm(values map[string]string) tea.Cmd { + store := t.state.Config() + next := config.Context{ + Name: values[fieldName], + Details: config.ContextDetails{ + Tenant: values[fieldTenant], + Credential: values[fieldCredential], + Subscription: values[fieldSubscription], + }, + } + + switch t.intent { + case intentCreate: + err := t.manager.CreateContext(store, next) + return t.finish(err, "created context "+next.Name) + + case intentEdit: + // The form always carries the subscription value, so treat it as changed. + err := t.manager.UpdateContext(store, next, true) + return t.finish(err, "updated context "+next.Name) + + case intentRename: + item, ok := t.pending.(*ContextItem) + if !ok { + return nil + } + err := t.manager.RenameContext(store, item.Name, values[fieldName]) + return t.finish(err, "renamed context "+item.Name+" to "+values[fieldName]) + + default: + return nil + } } // applyTenantForm maps a submitted tenant form to the matching intent method. @@ -299,20 +354,20 @@ func (t *Tabs) applyTenantForm(values map[string]string) tea.Cmd { switch t.intent { case intentCreate: - err := t.manager.CreateTenant(store, values["name"], values["id"]) - return t.finish(err, "created tenant "+values["name"]) + err := t.manager.CreateTenant(store, values[fieldName], values[fieldID]) + return t.finish(err, "created tenant "+values[fieldName]) case intentEdit: - err := t.manager.UpdateTenant(store, values["name"], values["id"]) - return t.finish(err, "updated tenant "+values["name"]) + err := t.manager.UpdateTenant(store, values[fieldName], values[fieldID]) + return t.finish(err, "updated tenant "+values[fieldName]) case intentRename: item, ok := t.pending.(*TenantItem) if !ok { return nil } - result, err := t.manager.RenameTenant(store, item.Name, values["name"]) - return t.finish(err, renameStatus("tenant", item.Name, values["name"], result.UpdatedContexts)) + result, err := t.manager.RenameTenant(store, item.Name, values[fieldName]) + return t.finish(err, renameStatus("tenant", item.Name, values[fieldName], result.UpdatedContexts)) default: return nil @@ -321,13 +376,22 @@ func (t *Tabs) applyTenantForm(values map[string]string) tea.Cmd { // applyDelete performs the pending delete. func (t *Tabs) applyDelete() tea.Cmd { - item, ok := t.pending.(*TenantItem) - if !ok { + switch item := t.pending.(type) { + case *TenantItem: + result, err := t.manager.DeleteTenant(t.state.Config(), item.Name) + return t.finish(err, deleteStatus("tenant", item.Name, result.OrphanedContexts)) + + case *ContextItem: + result, err := t.manager.DeleteContext(t.state.Config(), item.Name) + status := "deleted context " + item.Name + if result.WasActive { + status += " (warning: removed the active context; use a context to select a new one)" + } + return t.finish(err, status) + + default: return nil } - - result, err := t.manager.DeleteTenant(t.state.Config(), item.Name) - return t.finish(err, deleteStatus("tenant", item.Name, result.OrphanedContexts)) } // finish reloads the tabs after a write and records a status message. @@ -363,10 +427,14 @@ func (t *Tabs) reload() error { // deletableLabel returns a human label for a deletable item. func deletableLabel(item details.Item) (string, bool) { - if tenant, ok := item.(*TenantItem); ok { - return "tenant " + tenant.Name, true + switch it := item.(type) { + case *TenantItem: + return "tenant " + it.Name, true + case *ContextItem: + return "context " + it.Name, true + default: + return "", false } - return "", false } // deleteStatus builds the status line for a delete, warning about orphans. @@ -389,16 +457,26 @@ func renameStatus(kind, oldName, newName string, updated []string) string { // renameForm builds a single-field form asking for the entry's new name. func renameForm(kind string, item details.Item) form.Model { - current := "" - if named, ok := item.(interface{ Title() string }); ok { - current = named.Title() - } - + current := entryName(item) return form.New("Rename "+kind+" "+current, []form.Field{ - {Key: "name", Label: "New name", Placeholder: current, Required: true}, + {Key: fieldName, Label: "New name", Placeholder: current, Required: true}, }) } +// entryName returns the config name of a list item, without any display marker. +func entryName(item details.Item) string { + switch it := item.(type) { + case *TenantItem: + return it.Name + case *ContextItem: + return it.Name + case *CredentialItem: + return it.Name + default: + return "" + } +} + func (t *Tabs) handleInteractiveSelect(item details.Item) tea.Cmd { ctx, ok := item.(*ContextItem) if !ok { diff --git a/tui/tabs/tabs_test.go b/tui/tabs/tabs_test.go index 4e6d6c1..f836b8a 100644 --- a/tui/tabs/tabs_test.go +++ b/tui/tabs/tabs_test.go @@ -12,8 +12,11 @@ import ( "github.com/stretchr/testify/require" ) -// tenantsTabIndex is the position of the tenants tab in New's slice. -const tenantsTabIndex = 1 +// Tab positions in New's slice. +const ( + contextsTabIndex = 0 + tenantsTabIndex = 1 +) func writeConfig(t *testing.T, cfg *config.Config) string { t.Helper() @@ -50,9 +53,10 @@ func baseConfig() *config.Config { } } -// newTenantTabs builds a Tabs on the tenants tab, backed by a real manager and -// the config at the AZCTX path. It sizes the tabs so the list is usable. -func newTenantTabs(t *testing.T) *Tabs { +// newTabsOn builds a Tabs positioned on the given tab index, backed by a real +// manager and the config at the AZCTX path. It sizes the tabs so the list is +// usable. +func newTabsOn(t *testing.T, index int) *Tabs { t.Helper() loader := config.NewLoader() @@ -65,11 +69,17 @@ func newTenantTabs(t *testing.T) *Tabs { tabs := New(s, contexts.New()) tabs.Resize() - tabs.active = tenantsTabIndex + tabs.active = index return tabs } +// newTenantTabs builds a Tabs on the tenants tab. +func newTenantTabs(t *testing.T) *Tabs { + t.Helper() + return newTabsOn(t, tenantsTabIndex) +} + func typeRunes(tabs *Tabs, s string) { for _, r := range s { tabs.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}}) diff --git a/tui/tabs/tenant_items.go b/tui/tabs/tenant_items.go index 3746dd9..6911c9b 100644 --- a/tui/tabs/tenant_items.go +++ b/tui/tabs/tenant_items.go @@ -55,7 +55,7 @@ func tenantForm(intent formIntent, item details.Item) form.Model { } return form.New(title, []form.Field{ - {Key: "name", Label: labelName, Placeholder: "my-tenant", Value: name, Required: true, ReadOnly: readonly}, - {Key: "id", Label: "ID", Placeholder: "00000000-0000-0000-0000-000000000000", Value: id, Required: true}, + {Key: fieldName, Label: labelName, Placeholder: "my-tenant", Value: name, Required: true, ReadOnly: readonly}, + {Key: fieldID, Label: "ID", Placeholder: "00000000-0000-0000-0000-000000000000", Value: id, Required: true}, }) }