Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions tui/tabs/context_item.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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)
}
}
25 changes: 25 additions & 0 deletions tui/tabs/contexts.go
Original file line number Diff line number Diff line change
Expand Up @@ -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...).
Expand Down Expand Up @@ -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.
Expand Down
107 changes: 107 additions & 0 deletions tui/tabs/contexts_flow_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
9 changes: 9 additions & 0 deletions tui/tabs/tab.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading