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:
@@ -0,0 +1,51 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/samuelenocsson/devops-tui/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// teamMembersResponse represents the response from team members API
|
||||||
|
type teamMembersResponse struct {
|
||||||
|
Value []teamMemberItem `json:"value"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type teamMemberItem struct {
|
||||||
|
Identity identityRef `json:"identity"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type identityRef struct {
|
||||||
|
DisplayName string `json:"displayName"`
|
||||||
|
UniqueName string `json:"uniqueName"`
|
||||||
|
ID string `json:"id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTeamMembers fetches all members of the configured team
|
||||||
|
func (c *Client) GetTeamMembers() ([]models.TeamMember, error) {
|
||||||
|
// Azure DevOps API: GET https://dev.azure.com/{org}/_apis/projects/{project}/teams/{team}/members
|
||||||
|
url := fmt.Sprintf("https://dev.azure.com/%s/_apis/projects/%s/teams/%s/members?api-version=%s",
|
||||||
|
c.organization, c.project, c.team, apiVersion)
|
||||||
|
|
||||||
|
resp, err := c.doRequest("GET", url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var apiResp teamMembersResponse
|
||||||
|
if err := decode(resp, &apiResp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
members := make([]models.TeamMember, 0, len(apiResp.Value))
|
||||||
|
for _, item := range apiResp.Value {
|
||||||
|
members = append(members, models.TeamMember{
|
||||||
|
ID: item.Identity.ID,
|
||||||
|
DisplayName: item.Identity.DisplayName,
|
||||||
|
UniqueName: item.Identity.UniqueName,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return members, nil
|
||||||
|
}
|
||||||
@@ -167,9 +167,60 @@ func (c *Client) GetWorkItems(ids []string) ([]models.WorkItem, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch parent titles
|
||||||
|
c.populateParentTitles(allItems)
|
||||||
|
|
||||||
return allItems, nil
|
return allItems, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// populateParentTitles fetches titles for all parent work items
|
||||||
|
func (c *Client) populateParentTitles(items []models.WorkItem) {
|
||||||
|
// Collect unique parent IDs
|
||||||
|
parentIDs := make(map[int]bool)
|
||||||
|
for _, item := range items {
|
||||||
|
if item.ParentID > 0 {
|
||||||
|
parentIDs[item.ParentID] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(parentIDs) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to string slice
|
||||||
|
ids := make([]string, 0, len(parentIDs))
|
||||||
|
for id := range parentIDs {
|
||||||
|
ids = append(ids, fmt.Sprintf("%d", id))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch parent work items (only need ID and Title)
|
||||||
|
endpoint := fmt.Sprintf("/wit/workitems?ids=%s&fields=System.Id,System.Title", strings.Join(ids, ","))
|
||||||
|
resp, err := c.get(endpoint)
|
||||||
|
if err != nil {
|
||||||
|
return // Silently fail - parent titles are optional
|
||||||
|
}
|
||||||
|
|
||||||
|
var apiResp workItemsResponse
|
||||||
|
if err := decode(resp, &apiResp); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build ID -> Title map
|
||||||
|
titleMap := make(map[int]string)
|
||||||
|
for _, item := range apiResp.Value {
|
||||||
|
titleMap[item.ID] = item.Fields.Title
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update items with parent titles
|
||||||
|
for i := range items {
|
||||||
|
if items[i].ParentID > 0 {
|
||||||
|
if title, ok := titleMap[items[i].ParentID]; ok {
|
||||||
|
items[i].ParentTitle = title
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// GetWorkItem fetches a single work item by ID
|
// GetWorkItem fetches a single work item by ID
|
||||||
func (c *Client) GetWorkItem(id int) (*models.WorkItem, error) {
|
func (c *Client) GetWorkItem(id int) (*models.WorkItem, error) {
|
||||||
fields := "System.Id,System.Title,System.State,System.WorkItemType,System.AssignedTo,System.IterationPath,System.AreaPath,System.Description,System.Tags,System.Parent,Microsoft.VSTS.Common.Priority,System.CreatedDate,System.ChangedDate"
|
fields := "System.Id,System.Title,System.State,System.WorkItemType,System.AssignedTo,System.IterationPath,System.AreaPath,System.Description,System.Tags,System.Parent,Microsoft.VSTS.Common.Priority,System.CreatedDate,System.ChangedDate"
|
||||||
@@ -186,6 +237,19 @@ func (c *Client) GetWorkItem(id int) (*models.WorkItem, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
wi := c.convertWorkItem(item)
|
wi := c.convertWorkItem(item)
|
||||||
|
|
||||||
|
// Fetch parent title if parent exists
|
||||||
|
if wi.ParentID > 0 {
|
||||||
|
parentEndpoint := fmt.Sprintf("/wit/workitems/%d?fields=System.Title", wi.ParentID)
|
||||||
|
parentResp, err := c.get(parentEndpoint)
|
||||||
|
if err == nil {
|
||||||
|
var parentItem workItemAPIItem
|
||||||
|
if decode(parentResp, &parentItem) == nil {
|
||||||
|
wi.ParentTitle = parentItem.Fields.Title
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return &wi, nil
|
return &wi, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -252,6 +316,33 @@ func (c *Client) UpdateWorkItemState(id int, newState string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AssignWorkItem assigns a work item to a user
|
||||||
|
// Pass empty string to unassign
|
||||||
|
func (c *Client) AssignWorkItem(id int, userEmail string) error {
|
||||||
|
// Azure DevOps uses JSON Patch format
|
||||||
|
patchDoc := []map[string]interface{}{
|
||||||
|
{
|
||||||
|
"op": "add",
|
||||||
|
"path": "/fields/System.AssignedTo",
|
||||||
|
"value": userEmail,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyBytes, err := json.Marshal(patchDoc)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshaling patch document: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
endpoint := fmt.Sprintf("/wit/workitems/%d", id)
|
||||||
|
resp, err := c.patch(endpoint, bytes.NewReader(bodyBytes))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// stripHTML removes HTML tags from a string
|
// stripHTML removes HTML tags from a string
|
||||||
func stripHTML(s string) string {
|
func stripHTML(s string) string {
|
||||||
// Simple HTML tag removal
|
// Simple HTML tag removal
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
// TeamMember represents a team member
|
||||||
|
type TeamMember struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
DisplayName string `json:"displayName"`
|
||||||
|
UniqueName string `json:"uniqueName"`
|
||||||
|
}
|
||||||
+62
-1
@@ -42,6 +42,7 @@ type App struct {
|
|||||||
helpPanel components.HelpPanel
|
helpPanel components.HelpPanel
|
||||||
stateModal components.StateModal
|
stateModal components.StateModal
|
||||||
branchModal components.BranchModal
|
branchModal components.BranchModal
|
||||||
|
assignModal components.AssignModal
|
||||||
|
|
||||||
// State
|
// State
|
||||||
activePanel Panel
|
activePanel Panel
|
||||||
@@ -55,6 +56,7 @@ type App struct {
|
|||||||
areas []models.Area
|
areas []models.Area
|
||||||
workItems []models.WorkItem
|
workItems []models.WorkItem
|
||||||
statesByType map[string][]models.WorkItemStateInfo
|
statesByType map[string][]models.WorkItemStateInfo
|
||||||
|
teamMembers []models.TeamMember
|
||||||
|
|
||||||
// Services
|
// Services
|
||||||
client *api.Client
|
client *api.Client
|
||||||
@@ -84,6 +86,7 @@ func NewApp(client *api.Client) App {
|
|||||||
helpPanel: components.NewHelpPanel(keys, styles),
|
helpPanel: components.NewHelpPanel(keys, styles),
|
||||||
stateModal: components.NewStateModal(styles, keys),
|
stateModal: components.NewStateModal(styles, keys),
|
||||||
branchModal: components.NewBranchModal(styles, keys),
|
branchModal: components.NewBranchModal(styles, keys),
|
||||||
|
assignModal: components.NewAssignModal(styles, keys),
|
||||||
activePanel: PanelWorkItems,
|
activePanel: PanelWorkItems,
|
||||||
viewMode: ViewMain,
|
viewMode: ViewMain,
|
||||||
loading: true,
|
loading: true,
|
||||||
@@ -130,6 +133,15 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return a, tea.Batch(cmds...)
|
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
|
// Global keys
|
||||||
if key.Matches(msg, a.keys.Quit) && !a.helpPanel.IsVisible() && a.viewMode == ViewMain {
|
if key.Matches(msg, a.keys.Quit) && !a.helpPanel.IsVisible() && a.viewMode == ViewMain {
|
||||||
return a, tea.Quit
|
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
|
// Update active panel
|
||||||
switch a.activePanel {
|
switch a.activePanel {
|
||||||
case PanelFilter:
|
case PanelFilter:
|
||||||
@@ -215,6 +238,7 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
a.iterations = msg.iterations
|
a.iterations = msg.iterations
|
||||||
a.areas = msg.areas
|
a.areas = msg.areas
|
||||||
a.statesByType = msg.statesByType
|
a.statesByType = msg.statesByType
|
||||||
|
a.teamMembers = msg.teamMembers
|
||||||
a.stateModal.SetStatesByType(a.statesByType)
|
a.stateModal.SetStatesByType(a.statesByType)
|
||||||
filterState := models.NewFilterState(a.iterations, a.areas, 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
|
// Modal was closed, nothing special to do
|
||||||
a.stateModal.SetVisible(false)
|
a.stateModal.SetVisible(false)
|
||||||
a.branchModal.SetVisible(false)
|
a.branchModal.SetVisible(false)
|
||||||
|
a.assignModal.SetVisible(false)
|
||||||
|
|
||||||
case components.StateChangeRequestMsg:
|
case components.StateChangeRequestMsg:
|
||||||
a.stateModal.SetVisible(false)
|
a.stateModal.SetVisible(false)
|
||||||
@@ -289,6 +314,17 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
|
|
||||||
case components.BranchCreateErrorMsg:
|
case components.BranchCreateErrorMsg:
|
||||||
a.err = msg.Err
|
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
|
// Update selected item in details panel
|
||||||
@@ -313,6 +349,11 @@ func (a App) View() string {
|
|||||||
return a.branchModal.View()
|
return a.branchModal.View()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Render assign modal if visible
|
||||||
|
if a.assignModal.IsVisible() {
|
||||||
|
return a.assignModal.View()
|
||||||
|
}
|
||||||
|
|
||||||
// Render help overlay if visible
|
// Render help overlay if visible
|
||||||
if a.helpPanel.IsVisible() {
|
if a.helpPanel.IsVisible() {
|
||||||
_ = a.renderMainView()
|
_ = a.renderMainView()
|
||||||
@@ -455,6 +496,7 @@ type dataLoadedMsg struct {
|
|||||||
iterations []models.Iteration
|
iterations []models.Iteration
|
||||||
areas []models.Area
|
areas []models.Area
|
||||||
statesByType map[string][]models.WorkItemStateInfo
|
statesByType map[string][]models.WorkItemStateInfo
|
||||||
|
teamMembers []models.TeamMember
|
||||||
}
|
}
|
||||||
|
|
||||||
type workItemsLoadedMsg struct {
|
type workItemsLoadedMsg struct {
|
||||||
@@ -469,6 +511,10 @@ type stateChangedMsg struct {
|
|||||||
newState string
|
newState string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type assignedMsg struct {
|
||||||
|
userName string
|
||||||
|
}
|
||||||
|
|
||||||
// Commands
|
// Commands
|
||||||
|
|
||||||
func loadDataCmd(client *api.Client) tea.Cmd {
|
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
|
// Non-fatal - we can still work with hardcoded states
|
||||||
statesByType = make(map[string][]models.WorkItemStateInfo)
|
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}
|
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}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package components
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/key"
|
"github.com/charmbracelet/bubbles/key"
|
||||||
@@ -11,6 +12,23 @@ import (
|
|||||||
"github.com/samuelenocsson/devops-tui/internal/ui/theme"
|
"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
|
// Column definitions
|
||||||
type column struct {
|
type column struct {
|
||||||
title string
|
title string
|
||||||
@@ -30,6 +48,8 @@ type WorkItemsPanel struct {
|
|||||||
focused bool
|
focused bool
|
||||||
offset int // For scrolling
|
offset int // For scrolling
|
||||||
columns []column
|
columns []column
|
||||||
|
sortField SortField
|
||||||
|
sortDir SortDirection
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewWorkItemsPanel creates a new work items panel
|
// 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 {
|
if w.SelectedItem() != nil {
|
||||||
return w, func() tea.Msg { return ViewWorkItemMsg{Item: *w.SelectedItem()} }
|
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,15 +198,38 @@ func (w *WorkItemsPanel) renderHeader(colWidths []int) string {
|
|||||||
Bold(true).
|
Bold(true).
|
||||||
Foreground(lipgloss.Color("#9CA3AF"))
|
Foreground(lipgloss.Color("#9CA3AF"))
|
||||||
|
|
||||||
|
sortedStyle := lipgloss.NewStyle().
|
||||||
|
Bold(true).
|
||||||
|
Foreground(lipgloss.Color("#7C3AED"))
|
||||||
|
|
||||||
var parts []string
|
var parts []string
|
||||||
for i, col := range w.columns {
|
for i, col := range w.columns {
|
||||||
width := colWidths[i]
|
width := colWidths[i]
|
||||||
title := col.title
|
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 {
|
if len(title) > width {
|
||||||
title = title[:width]
|
title = title[:width]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if isSorted {
|
||||||
|
parts = append(parts, sortedStyle.Width(width).Render(title))
|
||||||
|
} else {
|
||||||
parts = append(parts, headerStyle.Width(width).Render(title))
|
parts = append(parts, headerStyle.Width(width).Render(title))
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return " " + strings.Join(parts, " ")
|
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
|
// SetSize sets the size of the work items panel
|
||||||
func (w *WorkItemsPanel) SetSize(width, height int) {
|
func (w *WorkItemsPanel) SetSize(width, height int) {
|
||||||
w.width = width
|
w.width = width
|
||||||
@@ -330,13 +419,30 @@ func (w *WorkItemsPanel) SetFocused(focused bool) {
|
|||||||
|
|
||||||
// SetItems sets the work items
|
// SetItems sets the work items
|
||||||
func (w *WorkItemsPanel) SetItems(items []models.WorkItem) {
|
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)
|
oldLen := len(w.items)
|
||||||
w.items = items
|
w.items = items
|
||||||
|
|
||||||
|
// Re-apply current sort
|
||||||
|
w.sortItems()
|
||||||
|
|
||||||
// Only reset position if this is new data (not just a refresh)
|
// Only reset position if this is new data (not just a refresh)
|
||||||
if oldLen == 0 && len(items) > 0 {
|
if oldLen == 0 && len(items) > 0 {
|
||||||
w.cursor = 0
|
w.cursor = 0
|
||||||
w.offset = 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
|
// Clamp cursor to valid range
|
||||||
@@ -346,6 +452,15 @@ func (w *WorkItemsPanel) SetItems(items []models.WorkItem) {
|
|||||||
if w.cursor < 0 {
|
if w.cursor < 0 {
|
||||||
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
|
// SelectedItem returns the currently selected work item
|
||||||
|
|||||||
@@ -25,6 +25,12 @@ type KeyMap struct {
|
|||||||
Quit key.Binding
|
Quit key.Binding
|
||||||
ChangeState key.Binding
|
ChangeState key.Binding
|
||||||
CreateBranch key.Binding
|
CreateBranch key.Binding
|
||||||
|
Assign key.Binding
|
||||||
|
|
||||||
|
// Sorting
|
||||||
|
SortByID key.Binding
|
||||||
|
SortByState key.Binding
|
||||||
|
SortByType key.Binding
|
||||||
}
|
}
|
||||||
|
|
||||||
// DefaultKeyMap returns the default key bindings
|
// DefaultKeyMap returns the default key bindings
|
||||||
@@ -102,6 +108,22 @@ func DefaultKeyMap() KeyMap {
|
|||||||
key.WithKeys("b"),
|
key.WithKeys("b"),
|
||||||
key.WithHelp("b", "create branch"),
|
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.Up, k.Down, k.Top, k.Bottom},
|
||||||
{k.NextPanel, k.PrevPanel},
|
{k.NextPanel, k.PrevPanel},
|
||||||
{k.Select, k.Open, k.View},
|
{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.Search, k.Refresh},
|
||||||
{k.Help, k.Back, k.Quit},
|
{k.Help, k.Back, k.Quit},
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user