Files
Samuel Enocsson 7c488b2d83 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>
2025-12-04 22:31:28 +01:00

322 lines
7.6 KiB
Go

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
}