Add sorting, user assignment, and UI improvements

Features:
- Add work item sorting by ID, Type, State (keys 1, 2, 3)
- Add user assignment modal with team member filtering (key a)
- Add parent work item title display in details panel
- Preserve sort order and selection on panel reload

UI improvements:
- Remove zebra striping from work items list
- Remove priority column from list and details
- Align metadata fields in details panel
- Add markdown rendering for descriptions (using glamour)
- Add state colors: To Do (orange), In Progress (purple), Done (green), Testing (yellow)

🤖 Generated with [Claude Code](https://claude.ai/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Samuel Enocsson
2025-12-04 22:31:28 +01:00
parent 2555afce19
commit 7c488b2d83
7 changed files with 682 additions and 12 deletions
+62 -1
View File
@@ -42,6 +42,7 @@ type App struct {
helpPanel components.HelpPanel
stateModal components.StateModal
branchModal components.BranchModal
assignModal components.AssignModal
// State
activePanel Panel
@@ -55,6 +56,7 @@ type App struct {
areas []models.Area
workItems []models.WorkItem
statesByType map[string][]models.WorkItemStateInfo
teamMembers []models.TeamMember
// Services
client *api.Client
@@ -84,6 +86,7 @@ func NewApp(client *api.Client) App {
helpPanel: components.NewHelpPanel(keys, styles),
stateModal: components.NewStateModal(styles, keys),
branchModal: components.NewBranchModal(styles, keys),
assignModal: components.NewAssignModal(styles, keys),
activePanel: PanelWorkItems,
viewMode: ViewMain,
loading: true,
@@ -130,6 +133,15 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return a, tea.Batch(cmds...)
}
if a.assignModal.IsVisible() {
newModal, cmd := a.assignModal.Update(msg)
a.assignModal = newModal
if cmd != nil {
cmds = append(cmds, cmd)
}
return a, tea.Batch(cmds...)
}
// Global keys
if key.Matches(msg, a.keys.Quit) && !a.helpPanel.IsVisible() && a.viewMode == ViewMain {
return a, tea.Quit
@@ -195,6 +207,17 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
}
// Open assign modal (only when work items panel is active)
if key.Matches(msg, a.keys.Assign) && a.activePanel == PanelWorkItems {
if item := a.workItemsPanel.SelectedItem(); item != nil {
a.assignModal.SetItem(item)
a.assignModal.SetMembers(a.teamMembers)
a.assignModal.SetSize(a.width, a.height)
a.assignModal.SetVisible(true)
return a, nil
}
}
// Update active panel
switch a.activePanel {
case PanelFilter:
@@ -215,6 +238,7 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
a.iterations = msg.iterations
a.areas = msg.areas
a.statesByType = msg.statesByType
a.teamMembers = msg.teamMembers
a.stateModal.SetStatesByType(a.statesByType)
filterState := models.NewFilterState(a.iterations, a.areas, a.statesByType)
@@ -268,6 +292,7 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// Modal was closed, nothing special to do
a.stateModal.SetVisible(false)
a.branchModal.SetVisible(false)
a.assignModal.SetVisible(false)
case components.StateChangeRequestMsg:
a.stateModal.SetVisible(false)
@@ -289,6 +314,17 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case components.BranchCreateErrorMsg:
a.err = msg.Err
case components.AssignRequestMsg:
a.assignModal.SetVisible(false)
a.loading = true
return a, assignWorkItemCmd(a.client, msg.Item.ID, msg.UserEmail, msg.UserName, a.filterPanel.FilterState())
case assignedMsg:
a.loading = false
a.statusMsg = fmt.Sprintf("Assigned to %s", msg.userName)
// Refresh work items to show updated assignment
return a, loadWorkItemsCmd(a.client, a.filterPanel.FilterState())
}
// Update selected item in details panel
@@ -313,6 +349,11 @@ func (a App) View() string {
return a.branchModal.View()
}
// Render assign modal if visible
if a.assignModal.IsVisible() {
return a.assignModal.View()
}
// Render help overlay if visible
if a.helpPanel.IsVisible() {
_ = a.renderMainView()
@@ -455,6 +496,7 @@ type dataLoadedMsg struct {
iterations []models.Iteration
areas []models.Area
statesByType map[string][]models.WorkItemStateInfo
teamMembers []models.TeamMember
}
type workItemsLoadedMsg struct {
@@ -469,6 +511,10 @@ type stateChangedMsg struct {
newState string
}
type assignedMsg struct {
userName string
}
// Commands
func loadDataCmd(client *api.Client) tea.Cmd {
@@ -486,7 +532,12 @@ func loadDataCmd(client *api.Client) tea.Cmd {
// Non-fatal - we can still work with hardcoded states
statesByType = make(map[string][]models.WorkItemStateInfo)
}
return dataLoadedMsg{iterations: iterations, areas: areas, statesByType: statesByType}
teamMembers, err := client.GetTeamMembers()
if err != nil {
// Non-fatal - we can still work without team members
teamMembers = []models.TeamMember{}
}
return dataLoadedMsg{iterations: iterations, areas: areas, statesByType: statesByType, teamMembers: teamMembers}
}
}
@@ -530,3 +581,13 @@ func createBranchCmd(branchName string) tea.Cmd {
return components.BranchCreatedMsg{BranchName: branchName}
}
}
func assignWorkItemCmd(client *api.Client, itemID int, userEmail, userName string, filterState *models.FilterState) tea.Cmd {
return func() tea.Msg {
err := client.AssignWorkItem(itemID, userEmail)
if err != nil {
return errMsg{err: err}
}
return assignedMsg{userName: userName}
}
}
+321
View File
@@ -0,0 +1,321 @@
package components
import (
"strings"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/samuelenocsson/devops-tui/internal/models"
"github.com/samuelenocsson/devops-tui/internal/ui/theme"
)
// AssignModal is a modal for assigning work items to users
type AssignModal struct {
visible bool
item *models.WorkItem
members []models.TeamMember
filtered []models.TeamMember
cursor int
styles theme.Styles
keys theme.KeyMap
width int
height int
filterInput textinput.Model
filterEnabled bool
}
// NewAssignModal creates a new assign modal
func NewAssignModal(styles theme.Styles, keys theme.KeyMap) AssignModal {
ti := textinput.New()
ti.Placeholder = "Type to filter..."
ti.CharLimit = 50
ti.Width = 30
return AssignModal{
styles: styles,
keys: keys,
filterInput: ti,
}
}
// Init initializes the modal
func (m AssignModal) Init() tea.Cmd {
return nil
}
// Update handles messages
func (m AssignModal) Update(msg tea.Msg) (AssignModal, tea.Cmd) {
if !m.visible {
return m, nil
}
switch msg := msg.(type) {
case tea.KeyMsg:
// Handle filter input
if m.filterEnabled {
switch msg.String() {
case "esc":
if m.filterInput.Value() != "" {
m.filterInput.SetValue("")
m.applyFilter()
return m, nil
}
m.visible = false
return m, func() tea.Msg { return ModalClosedMsg{} }
case "enter":
if len(m.filtered) > 0 && m.item != nil {
selected := m.filtered[m.cursor]
return m, func() tea.Msg {
return AssignRequestMsg{
Item: *m.item,
UserEmail: selected.UniqueName,
UserName: selected.DisplayName,
}
}
}
case "up":
if m.cursor > 0 {
m.cursor--
}
return m, nil
case "down":
if m.cursor < len(m.filtered)-1 {
m.cursor++
}
return m, nil
default:
var cmd tea.Cmd
m.filterInput, cmd = m.filterInput.Update(msg)
m.applyFilter()
return m, cmd
}
return m, nil
}
switch {
case key.Matches(msg, m.keys.Up):
if m.cursor > 0 {
m.cursor--
}
case key.Matches(msg, m.keys.Down):
if m.cursor < len(m.filtered)-1 {
m.cursor++
}
case key.Matches(msg, m.keys.Select):
if m.item != nil && m.cursor < len(m.filtered) {
selected := m.filtered[m.cursor]
return m, func() tea.Msg {
return AssignRequestMsg{
Item: *m.item,
UserEmail: selected.UniqueName,
UserName: selected.DisplayName,
}
}
}
case key.Matches(msg, m.keys.Back):
m.visible = false
return m, func() tea.Msg { return ModalClosedMsg{} }
case msg.String() == "/":
m.filterEnabled = true
m.filterInput.Focus()
return m, textinput.Blink
}
}
return m, nil
}
func (m *AssignModal) applyFilter() {
filter := strings.ToLower(m.filterInput.Value())
if filter == "" {
m.filtered = m.members
} else {
m.filtered = make([]models.TeamMember, 0)
for _, member := range m.members {
if strings.Contains(strings.ToLower(member.DisplayName), filter) ||
strings.Contains(strings.ToLower(member.UniqueName), filter) {
m.filtered = append(m.filtered, member)
}
}
}
// Reset cursor if out of bounds
if m.cursor >= len(m.filtered) {
m.cursor = 0
}
}
// View renders the modal
func (m AssignModal) View() string {
if !m.visible {
return ""
}
// Modal dimensions
modalWidth := 50
visibleItems := 8
modalHeight := visibleItems + 10
// Build content
var b strings.Builder
// Title
title := lipgloss.NewStyle().Bold(true).Render("Assign To")
if m.item != nil {
itemInfo := lipgloss.NewStyle().
Foreground(lipgloss.Color("#9CA3AF")).
Render("#" + itoa(m.item.ID) + " " + truncateStr(m.item.Title, 35))
b.WriteString(title + "\n")
b.WriteString(itemInfo + "\n\n")
}
// Current assignee
if m.item != nil {
currentStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#60A5FA"))
assigned := m.item.AssignedTo
if assigned == "" {
assigned = "Unassigned"
}
b.WriteString(currentStyle.Render("Current: "+assigned) + "\n\n")
}
// Filter input
if m.filterEnabled {
b.WriteString(m.filterInput.View() + "\n\n")
} else {
filterHint := lipgloss.NewStyle().Foreground(lipgloss.Color("#6B7280")).Render("Press / to filter")
b.WriteString(filterHint + "\n\n")
}
// Unassign option first
unassignCursor := " "
if m.cursor == 0 && len(m.filtered) == 0 {
unassignCursor = "▸ "
}
// Member options
if len(m.filtered) == 0 {
b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("#6B7280")).Render(" No members found") + "\n")
} else {
// Calculate scroll offset
offset := 0
if m.cursor >= visibleItems {
offset = m.cursor - visibleItems + 1
}
end := offset + visibleItems
if end > len(m.filtered) {
end = len(m.filtered)
}
for i := offset; i < end; i++ {
member := m.filtered[i]
cursor := " "
if i == m.cursor {
cursor = "▸ "
}
style := lipgloss.NewStyle()
if i == m.cursor {
style = style.Bold(true).Foreground(lipgloss.Color("#7C3AED"))
}
// Highlight if this is the current assignee
if m.item != nil && member.DisplayName == m.item.AssignedTo {
style = style.Foreground(lipgloss.Color("#10B981"))
}
// Show name, truncate if needed
name := truncateStr(member.DisplayName, modalWidth-10)
b.WriteString(cursor + style.Render(name) + "\n")
}
// Show scroll indicator
if len(m.filtered) > visibleItems {
scrollInfo := lipgloss.NewStyle().Foreground(lipgloss.Color("#6B7280")).
Render(" (" + itoa(m.cursor+1) + "/" + itoa(len(m.filtered)) + ")")
b.WriteString(scrollInfo + "\n")
}
}
_ = unassignCursor // Reserved for future unassign option
// Help text
b.WriteString("\n")
helpStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#6B7280"))
if m.filterEnabled {
b.WriteString(helpStyle.Render("Enter: confirm Esc: clear/close"))
} else {
b.WriteString(helpStyle.Render("Enter: confirm /: filter Esc: cancel"))
}
// Modal style
modalStyle := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("#7C3AED")).
Padding(1, 2).
Width(modalWidth).
Height(modalHeight).
Background(lipgloss.Color("#1F2937"))
modal := modalStyle.Render(b.String())
// Center the modal
return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, modal)
}
// SetVisible sets the visibility
func (m *AssignModal) SetVisible(visible bool) {
m.visible = visible
if visible {
m.cursor = 0
m.filterInput.SetValue("")
m.filterEnabled = false
m.filterInput.Blur()
m.applyFilter()
// Try to set cursor to current assignee
if m.item != nil && m.item.AssignedTo != "" {
for i, member := range m.filtered {
if member.DisplayName == m.item.AssignedTo {
m.cursor = i
break
}
}
}
}
}
// IsVisible returns whether the modal is visible
func (m *AssignModal) IsVisible() bool {
return m.visible
}
// SetItem sets the work item to modify
func (m *AssignModal) SetItem(item *models.WorkItem) {
m.item = item
}
// SetMembers sets the available team members
func (m *AssignModal) SetMembers(members []models.TeamMember) {
m.members = members
m.applyFilter()
}
// SetSize sets the modal container size
func (m *AssignModal) SetSize(width, height int) {
m.width = width
m.height = height
}
// AssignRequestMsg is sent when user confirms assignment
type AssignRequestMsg struct {
Item models.WorkItem
UserEmail string
UserName string
}
// AssignedMsg is sent when assignment is complete
type AssignedMsg struct {
Item models.WorkItem
}
+125 -10
View File
@@ -2,6 +2,7 @@ package components
import (
"fmt"
"sort"
"strings"
"github.com/charmbracelet/bubbles/key"
@@ -11,6 +12,23 @@ import (
"github.com/samuelenocsson/devops-tui/internal/ui/theme"
)
// SortField represents the field to sort by
type SortField int
const (
SortByID SortField = iota
SortByState
SortByType
)
// SortDirection represents the sort direction
type SortDirection int
const (
SortAsc SortDirection = iota
SortDesc
)
// Column definitions
type column struct {
title string
@@ -21,15 +39,17 @@ type column struct {
// WorkItemsPanel is the work items list component
type WorkItemsPanel struct {
items []models.WorkItem
cursor int
styles theme.Styles
keys theme.KeyMap
width int
height int
focused bool
offset int // For scrolling
columns []column
items []models.WorkItem
cursor int
styles theme.Styles
keys theme.KeyMap
width int
height int
focused bool
offset int // For scrolling
columns []column
sortField SortField
sortDir SortDirection
}
// NewWorkItemsPanel creates a new work items panel
@@ -78,6 +98,12 @@ func (w WorkItemsPanel) Update(msg tea.Msg) (WorkItemsPanel, tea.Cmd) {
if w.SelectedItem() != nil {
return w, func() tea.Msg { return ViewWorkItemMsg{Item: *w.SelectedItem()} }
}
case key.Matches(msg, w.keys.SortByID):
w.toggleSort(SortByID)
case key.Matches(msg, w.keys.SortByState):
w.toggleSort(SortByState)
case key.Matches(msg, w.keys.SortByType):
w.toggleSort(SortByType)
}
}
@@ -172,14 +198,37 @@ func (w *WorkItemsPanel) renderHeader(colWidths []int) string {
Bold(true).
Foreground(lipgloss.Color("#9CA3AF"))
sortedStyle := lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#7C3AED"))
var parts []string
for i, col := range w.columns {
width := colWidths[i]
title := col.title
// Add sort indicator
isSorted := (col.title == "ID" && w.sortField == SortByID) ||
(col.title == "STATE" && w.sortField == SortByState) ||
(col.title == "TYPE" && w.sortField == SortByType)
if isSorted {
arrow := "▲"
if w.sortDir == SortDesc {
arrow = "▼"
}
title = title + arrow
}
if len(title) > width {
title = title[:width]
}
parts = append(parts, headerStyle.Width(width).Render(title))
if isSorted {
parts = append(parts, sortedStyle.Width(width).Render(title))
} else {
parts = append(parts, headerStyle.Width(width).Render(title))
}
}
return " " + strings.Join(parts, " ")
@@ -305,6 +354,46 @@ func (w *WorkItemsPanel) moveToBottom() {
}
}
func (w *WorkItemsPanel) toggleSort(field SortField) {
if w.sortField == field {
// Toggle direction if same field
if w.sortDir == SortAsc {
w.sortDir = SortDesc
} else {
w.sortDir = SortAsc
}
} else {
w.sortField = field
w.sortDir = SortAsc
}
w.sortItems()
}
func (w *WorkItemsPanel) sortItems() {
if len(w.items) == 0 {
return
}
sort.SliceStable(w.items, func(i, j int) bool {
var less bool
switch w.sortField {
case SortByID:
less = w.items[i].ID < w.items[j].ID
case SortByState:
less = string(w.items[i].State) < string(w.items[j].State)
case SortByType:
less = string(w.items[i].Type) < string(w.items[j].Type)
default:
less = w.items[i].ID < w.items[j].ID
}
if w.sortDir == SortDesc {
return !less
}
return less
})
}
// SetSize sets the size of the work items panel
func (w *WorkItemsPanel) SetSize(width, height int) {
w.width = width
@@ -330,13 +419,30 @@ func (w *WorkItemsPanel) SetFocused(focused bool) {
// SetItems sets the work items
func (w *WorkItemsPanel) SetItems(items []models.WorkItem) {
// Remember currently selected item ID
var selectedID int
if w.cursor >= 0 && w.cursor < len(w.items) {
selectedID = w.items[w.cursor].ID
}
oldLen := len(w.items)
w.items = items
// Re-apply current sort
w.sortItems()
// Only reset position if this is new data (not just a refresh)
if oldLen == 0 && len(items) > 0 {
w.cursor = 0
w.offset = 0
} else if selectedID > 0 {
// Try to restore cursor to previously selected item
for i, item := range w.items {
if item.ID == selectedID {
w.cursor = i
break
}
}
}
// Clamp cursor to valid range
@@ -346,6 +452,15 @@ func (w *WorkItemsPanel) SetItems(items []models.WorkItem) {
if w.cursor < 0 {
w.cursor = 0
}
// Adjust offset to keep cursor visible
visible := w.visibleItemCount()
if w.cursor < w.offset {
w.offset = w.cursor
}
if w.cursor >= w.offset+visible {
w.offset = w.cursor - visible + 1
}
}
// SelectedItem returns the currently selected work item
+24 -1
View File
@@ -25,6 +25,12 @@ type KeyMap struct {
Quit key.Binding
ChangeState key.Binding
CreateBranch key.Binding
Assign key.Binding
// Sorting
SortByID key.Binding
SortByState key.Binding
SortByType key.Binding
}
// DefaultKeyMap returns the default key bindings
@@ -102,6 +108,22 @@ func DefaultKeyMap() KeyMap {
key.WithKeys("b"),
key.WithHelp("b", "create branch"),
),
Assign: key.NewBinding(
key.WithKeys("a"),
key.WithHelp("a", "assign"),
),
SortByID: key.NewBinding(
key.WithKeys("1"),
key.WithHelp("1", "sort by ID"),
),
SortByType: key.NewBinding(
key.WithKeys("2"),
key.WithHelp("2", "sort by type"),
),
SortByState: key.NewBinding(
key.WithKeys("3"),
key.WithHelp("3", "sort by state"),
),
}
}
@@ -122,7 +144,8 @@ func (k KeyMap) FullHelp() [][]key.Binding {
{k.Up, k.Down, k.Top, k.Bottom},
{k.NextPanel, k.PrevPanel},
{k.Select, k.Open, k.View},
{k.ChangeState, k.CreateBranch},
{k.ChangeState, k.CreateBranch, k.Assign},
{k.SortByID, k.SortByType, k.SortByState},
{k.Search, k.Refresh},
{k.Help, k.Back, k.Quit},
}