7c488b2d83
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>
322 lines
7.6 KiB
Go
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
|
|
}
|