From c7557219929292381fabc6ffb730bb8cf4003ee3 Mon Sep 17 00:00:00 2001 From: piekstra Date: Wed, 18 Feb 2026 12:27:37 -0500 Subject: [PATCH] feat(jtk): add dashboard management commands Add commands for managing Jira dashboards and their gadgets: - dashboards list (with --search and --max flags) - dashboards get (shows details + gadgets) - dashboards create --name [--description ] - dashboards delete - dashboards gadgets list - dashboards gadgets remove Closes #147 --- tools/jtk/api/dashboards.go | 203 +++++++++ tools/jtk/api/dashboards_test.go | 161 +++++++ tools/jtk/cmd/jtk/main.go | 2 + .../jtk/internal/cmd/dashboards/dashboards.go | 393 ++++++++++++++++++ .../cmd/dashboards/dashboards_test.go | 186 +++++++++ 5 files changed, 945 insertions(+) create mode 100644 tools/jtk/api/dashboards.go create mode 100644 tools/jtk/api/dashboards_test.go create mode 100644 tools/jtk/internal/cmd/dashboards/dashboards.go create mode 100644 tools/jtk/internal/cmd/dashboards/dashboards_test.go diff --git a/tools/jtk/api/dashboards.go b/tools/jtk/api/dashboards.go new file mode 100644 index 0000000..c0cb8e4 --- /dev/null +++ b/tools/jtk/api/dashboards.go @@ -0,0 +1,203 @@ +package api + +import ( + "encoding/json" + "fmt" + "net/url" + "strconv" +) + +// Dashboard represents a Jira dashboard +type Dashboard struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Owner *User `json:"owner,omitempty"` + View string `json:"view,omitempty"` + IsFavourite bool `json:"isFavourite,omitempty"` + Popularity int `json:"popularity,omitempty"` + EditPerm []SharePerm `json:"editPermissions,omitempty"` + SharePerm []SharePerm `json:"sharePermissions,omitempty"` +} + +// SharePerm represents a dashboard sharing permission +type SharePerm struct { + Type string `json:"type"` // "global", "project", "group", etc. +} + +// DashboardGadget represents a gadget on a dashboard +type DashboardGadget struct { + ID int `json:"id"` + Title string `json:"title"` + ModuleID string `json:"moduleKey,omitempty"` + URI string `json:"uri,omitempty"` + Color string `json:"color,omitempty"` + Position DashboardGadgetPos `json:"position,omitempty"` + Props map[string]interface{} `json:"properties,omitempty"` +} + +// DashboardGadgetPos represents the position of a gadget on a dashboard +type DashboardGadgetPos struct { + Row int `json:"row"` + Column int `json:"column"` +} + +// DashboardsResponse represents a paginated list of dashboards +type DashboardsResponse struct { + StartAt int `json:"startAt"` + MaxResults int `json:"maxResults"` + Total int `json:"total"` + Dashboards []Dashboard `json:"dashboards"` +} + +// DashboardGadgetsResponse represents a list of gadgets on a dashboard +type DashboardGadgetsResponse struct { + Gadgets []DashboardGadget `json:"gadgets"` +} + +// CreateDashboardRequest represents a request to create a dashboard +type CreateDashboardRequest struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + EditPermissions []SharePerm `json:"editPermissions"` + SharePermissions []SharePerm `json:"sharePermissions"` +} + +// GetDashboards returns a paginated list of dashboards +func (c *Client) GetDashboards(startAt, maxResults int) (*DashboardsResponse, error) { + params := map[string]string{} + if startAt > 0 { + params["startAt"] = strconv.Itoa(startAt) + } + if maxResults > 0 { + params["maxResults"] = strconv.Itoa(maxResults) + } + + urlStr := buildURL(fmt.Sprintf("%s/dashboard", c.BaseURL), params) + + body, err := c.get(urlStr) + if err != nil { + return nil, err + } + + var result DashboardsResponse + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("failed to parse dashboards: %w", err) + } + + return &result, nil +} + +// SearchDashboards searches for dashboards by name +func (c *Client) SearchDashboards(name string, maxResults int) (*DashboardSearchResponse, error) { + params := map[string]string{} + if name != "" { + params["dashboardName"] = name + } + if maxResults > 0 { + params["maxResults"] = strconv.Itoa(maxResults) + } + + urlStr := buildURL(fmt.Sprintf("%s/dashboard/search", c.BaseURL), params) + + body, err := c.get(urlStr) + if err != nil { + return nil, err + } + + var result DashboardSearchResponse + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("failed to parse dashboard search: %w", err) + } + + return &result, nil +} + +// DashboardSearchResponse represents the response from dashboard search +type DashboardSearchResponse struct { + StartAt int `json:"startAt"` + MaxResults int `json:"maxResults"` + Total int `json:"total"` + Values []Dashboard `json:"values"` +} + +// GetDashboard returns a dashboard by ID +func (c *Client) GetDashboard(dashboardID string) (*Dashboard, error) { + if dashboardID == "" { + return nil, fmt.Errorf("dashboard ID is required") + } + + urlStr := fmt.Sprintf("%s/dashboard/%s", c.BaseURL, url.PathEscape(dashboardID)) + + body, err := c.get(urlStr) + if err != nil { + return nil, err + } + + var dash Dashboard + if err := json.Unmarshal(body, &dash); err != nil { + return nil, fmt.Errorf("failed to parse dashboard: %w", err) + } + + return &dash, nil +} + +// CreateDashboard creates a new dashboard +func (c *Client) CreateDashboard(req CreateDashboardRequest) (*Dashboard, error) { + urlStr := fmt.Sprintf("%s/dashboard", c.BaseURL) + + body, err := c.post(urlStr, req) + if err != nil { + return nil, err + } + + var dash Dashboard + if err := json.Unmarshal(body, &dash); err != nil { + return nil, fmt.Errorf("failed to parse dashboard: %w", err) + } + + return &dash, nil +} + +// DeleteDashboard deletes a dashboard by ID +func (c *Client) DeleteDashboard(dashboardID string) error { + if dashboardID == "" { + return fmt.Errorf("dashboard ID is required") + } + + urlStr := fmt.Sprintf("%s/dashboard/%s", c.BaseURL, url.PathEscape(dashboardID)) + _, err := c.delete(urlStr) + return err +} + +// GetDashboardGadgets returns the gadgets on a dashboard +func (c *Client) GetDashboardGadgets(dashboardID string) (*DashboardGadgetsResponse, error) { + if dashboardID == "" { + return nil, fmt.Errorf("dashboard ID is required") + } + + urlStr := fmt.Sprintf("%s/dashboard/%s/gadget", c.BaseURL, url.PathEscape(dashboardID)) + + body, err := c.get(urlStr) + if err != nil { + return nil, err + } + + var result DashboardGadgetsResponse + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("failed to parse gadgets: %w", err) + } + + return &result, nil +} + +// RemoveDashboardGadget removes a gadget from a dashboard +func (c *Client) RemoveDashboardGadget(dashboardID string, gadgetID int) error { + if dashboardID == "" { + return fmt.Errorf("dashboard ID is required") + } + + urlStr := fmt.Sprintf("%s/dashboard/%s/gadget/%d", c.BaseURL, url.PathEscape(dashboardID), gadgetID) + _, err := c.delete(urlStr) + return err +} diff --git a/tools/jtk/api/dashboards_test.go b/tools/jtk/api/dashboards_test.go new file mode 100644 index 0000000..3215a31 --- /dev/null +++ b/tools/jtk/api/dashboards_test.go @@ -0,0 +1,161 @@ +package api + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetDashboards(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/rest/api/3/dashboard", r.URL.Path) + json.NewEncoder(w).Encode(DashboardsResponse{ + Total: 1, + Dashboards: []Dashboard{ + {ID: "10001", Name: "My Dashboard"}, + }, + }) + })) + defer server.Close() + + client, err := New(ClientConfig{URL: server.URL, Email: "t@t.com", APIToken: "tok"}) + require.NoError(t, err) + + result, err := client.GetDashboards(0, 50) + require.NoError(t, err) + require.Len(t, result.Dashboards, 1) + assert.Equal(t, "My Dashboard", result.Dashboards[0].Name) +} + +func TestSearchDashboards(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/rest/api/3/dashboard/search", r.URL.Path) + assert.Equal(t, "Sprint", r.URL.Query().Get("dashboardName")) + json.NewEncoder(w).Encode(DashboardSearchResponse{ + Total: 1, + Values: []Dashboard{ + {ID: "10002", Name: "Sprint Board"}, + }, + }) + })) + defer server.Close() + + client, err := New(ClientConfig{URL: server.URL, Email: "t@t.com", APIToken: "tok"}) + require.NoError(t, err) + + result, err := client.SearchDashboards("Sprint", 50) + require.NoError(t, err) + require.Len(t, result.Values, 1) + assert.Equal(t, "Sprint Board", result.Values[0].Name) +} + +func TestGetDashboard(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/rest/api/3/dashboard/10001", r.URL.Path) + json.NewEncoder(w).Encode(Dashboard{ + ID: "10001", + Name: "My Dashboard", + }) + })) + defer server.Close() + + client, err := New(ClientConfig{URL: server.URL, Email: "t@t.com", APIToken: "tok"}) + require.NoError(t, err) + + dash, err := client.GetDashboard("10001") + require.NoError(t, err) + assert.Equal(t, "My Dashboard", dash.Name) +} + +func TestGetDashboard_EmptyID(t *testing.T) { + _, err := (&Client{}).GetDashboard("") + assert.Error(t, err) +} + +func TestCreateDashboard(t *testing.T) { + var capturedBody []byte + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "POST", r.Method) + capturedBody, _ = io.ReadAll(r.Body) + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(Dashboard{ID: "10099", Name: "New Board"}) + })) + defer server.Close() + + client, err := New(ClientConfig{URL: server.URL, Email: "t@t.com", APIToken: "tok"}) + require.NoError(t, err) + + dash, err := client.CreateDashboard(CreateDashboardRequest{ + Name: "New Board", + EditPermissions: []SharePerm{{Type: "global"}}, + SharePermissions: []SharePerm{{Type: "global"}}, + }) + require.NoError(t, err) + assert.Equal(t, "10099", dash.ID) + assert.Equal(t, "New Board", dash.Name) + + var req CreateDashboardRequest + err = json.Unmarshal(capturedBody, &req) + require.NoError(t, err) + assert.Equal(t, "New Board", req.Name) +} + +func TestDeleteDashboard(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/rest/api/3/dashboard/10001", r.URL.Path) + assert.Equal(t, "DELETE", r.Method) + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + client, err := New(ClientConfig{URL: server.URL, Email: "t@t.com", APIToken: "tok"}) + require.NoError(t, err) + + err = client.DeleteDashboard("10001") + require.NoError(t, err) +} + +func TestDeleteDashboard_EmptyID(t *testing.T) { + assert.Error(t, (&Client{}).DeleteDashboard("")) +} + +func TestGetDashboardGadgets(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/rest/api/3/dashboard/10001/gadget", r.URL.Path) + json.NewEncoder(w).Encode(DashboardGadgetsResponse{ + Gadgets: []DashboardGadget{ + {ID: 1, Title: "Filter Results"}, + {ID: 2, Title: "Pie Chart"}, + }, + }) + })) + defer server.Close() + + client, err := New(ClientConfig{URL: server.URL, Email: "t@t.com", APIToken: "tok"}) + require.NoError(t, err) + + result, err := client.GetDashboardGadgets("10001") + require.NoError(t, err) + require.Len(t, result.Gadgets, 2) + assert.Equal(t, "Filter Results", result.Gadgets[0].Title) +} + +func TestRemoveDashboardGadget(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/rest/api/3/dashboard/10001/gadget/42", r.URL.Path) + assert.Equal(t, "DELETE", r.Method) + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + client, err := New(ClientConfig{URL: server.URL, Email: "t@t.com", APIToken: "tok"}) + require.NoError(t, err) + + err = client.RemoveDashboardGadget("10001", 42) + require.NoError(t, err) +} diff --git a/tools/jtk/cmd/jtk/main.go b/tools/jtk/cmd/jtk/main.go index b7f82e1..0660034 100644 --- a/tools/jtk/cmd/jtk/main.go +++ b/tools/jtk/cmd/jtk/main.go @@ -13,6 +13,7 @@ import ( "github.com/open-cli-collective/jira-ticket-cli/internal/cmd/comments" "github.com/open-cli-collective/jira-ticket-cli/internal/cmd/completion" "github.com/open-cli-collective/jira-ticket-cli/internal/cmd/configcmd" + "github.com/open-cli-collective/jira-ticket-cli/internal/cmd/dashboards" "github.com/open-cli-collective/jira-ticket-cli/internal/cmd/fields" "github.com/open-cli-collective/jira-ticket-cli/internal/cmd/initcmd" "github.com/open-cli-collective/jira-ticket-cli/internal/cmd/issues" @@ -46,6 +47,7 @@ func run() error { attachments.Register(rootCmd, opts) automation.Register(rootCmd, opts) boards.Register(rootCmd, opts) + dashboards.Register(rootCmd, opts) projects.Register(rootCmd, opts) sprints.Register(rootCmd, opts) users.Register(rootCmd, opts) diff --git a/tools/jtk/internal/cmd/dashboards/dashboards.go b/tools/jtk/internal/cmd/dashboards/dashboards.go new file mode 100644 index 0000000..34b06c2 --- /dev/null +++ b/tools/jtk/internal/cmd/dashboards/dashboards.go @@ -0,0 +1,393 @@ +package dashboards + +import ( + "fmt" + "strconv" + + "github.com/spf13/cobra" + + "github.com/open-cli-collective/jira-ticket-cli/api" + "github.com/open-cli-collective/jira-ticket-cli/internal/cmd/root" +) + +// Register registers the dashboards commands +func Register(parent *cobra.Command, opts *root.Options) { + cmd := &cobra.Command{ + Use: "dashboards", + Aliases: []string{"dashboard", "dash"}, + Short: "Manage dashboards", + Long: "Commands for listing, creating, and managing Jira dashboards and their gadgets.", + } + + cmd.AddCommand(newListCmd(opts)) + cmd.AddCommand(newGetCmd(opts)) + cmd.AddCommand(newCreateCmd(opts)) + cmd.AddCommand(newDeleteCmd(opts)) + cmd.AddCommand(newGadgetsCmd(opts)) + + parent.AddCommand(cmd) +} + +func newListCmd(opts *root.Options) *cobra.Command { + var search string + var maxResults int + + cmd := &cobra.Command{ + Use: "list", + Short: "List dashboards", + Long: "List accessible dashboards. Use --search to filter by name.", + Example: ` jtk dashboards list + jtk dashboards list --search "Sprint" + jtk dashboards list --max 10`, + RunE: func(cmd *cobra.Command, args []string) error { + return runList(opts, search, maxResults) + }, + } + + cmd.Flags().StringVar(&search, "search", "", "Search dashboards by name") + cmd.Flags().IntVar(&maxResults, "max", 50, "Maximum number of results") + + return cmd +} + +func runList(opts *root.Options, search string, maxResults int) error { + v := opts.View() + + client, err := opts.APIClient() + if err != nil { + return err + } + + if search != "" { + result, err := client.SearchDashboards(search, maxResults) + if err != nil { + return err + } + + if len(result.Values) == 0 { + v.Info("No dashboards found matching %q", search) + return nil + } + + if opts.Output == "json" { + return v.JSON(result.Values) + } + + return renderDashboardTable(v, result.Values) + } + + result, err := client.GetDashboards(0, maxResults) + if err != nil { + return err + } + + if len(result.Dashboards) == 0 { + v.Info("No dashboards found") + return nil + } + + if opts.Output == "json" { + return v.JSON(result.Dashboards) + } + + return renderDashboardTable(v, result.Dashboards) +} + +func newGetCmd(opts *root.Options) *cobra.Command { + cmd := &cobra.Command{ + Use: "get ", + Short: "Get dashboard details", + Long: "Get details of a specific dashboard including its gadgets.", + Example: ` jtk dashboards get 10001 + jtk dashboards get 10001 -o json`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runGet(opts, args[0]) + }, + } + + return cmd +} + +func runGet(opts *root.Options, dashboardID string) error { + v := opts.View() + + client, err := opts.APIClient() + if err != nil { + return err + } + + dash, err := client.GetDashboard(dashboardID) + if err != nil { + return err + } + + // Also get gadgets + gadgets, err := client.GetDashboardGadgets(dashboardID) + if err != nil { + return fmt.Errorf("failed to get gadgets: %w", err) + } + + if opts.Output == "json" { + return v.JSON(map[string]interface{}{ + "dashboard": dash, + "gadgets": gadgets.Gadgets, + }) + } + + v.Println("ID: %s", dash.ID) + v.Println("Name: %s", dash.Name) + if dash.Description != "" { + v.Println("Description: %s", dash.Description) + } + if dash.Owner != nil { + v.Println("Owner: %s", dash.Owner.DisplayName) + } + if dash.View != "" { + v.Println("URL: %s", dash.View) + } + + if len(gadgets.Gadgets) > 0 { + v.Println("") + v.Println("Gadgets (%d):", len(gadgets.Gadgets)) + + headers := []string{"ID", "TITLE", "MODULE", "POSITION"} + var rows [][]string + + for _, g := range gadgets.Gadgets { + pos := fmt.Sprintf("row=%d col=%d", g.Position.Row, g.Position.Column) + rows = append(rows, []string{ + strconv.Itoa(g.ID), + g.Title, + g.ModuleID, + pos, + }) + } + return v.Table(headers, rows) + } + + v.Info("\nNo gadgets on this dashboard") + return nil +} + +func newCreateCmd(opts *root.Options) *cobra.Command { + var name string + var description string + + cmd := &cobra.Command{ + Use: "create", + Short: "Create a new dashboard", + Long: "Create a new Jira dashboard.", + Example: ` jtk dashboards create --name "My Dashboard" + jtk dashboards create --name "Sprint Board" --description "Sprint tracking"`, + RunE: func(cmd *cobra.Command, args []string) error { + return runCreate(opts, name, description) + }, + } + + cmd.Flags().StringVar(&name, "name", "", "Dashboard name (required)") + cmd.Flags().StringVar(&description, "description", "", "Dashboard description") + _ = cmd.MarkFlagRequired("name") + + return cmd +} + +func runCreate(opts *root.Options, name, description string) error { + v := opts.View() + + client, err := opts.APIClient() + if err != nil { + return err + } + + req := api.CreateDashboardRequest{ + Name: name, + Description: description, + EditPermissions: []api.SharePerm{}, + SharePermissions: []api.SharePerm{}, + } + + dash, err := client.CreateDashboard(req) + if err != nil { + return err + } + + if opts.Output == "json" { + return v.JSON(dash) + } + + v.Success("Created dashboard %s (%s)", dash.Name, dash.ID) + if dash.View != "" { + v.Info("URL: %s", dash.View) + } + + return nil +} + +func newDeleteCmd(opts *root.Options) *cobra.Command { + cmd := &cobra.Command{ + Use: "delete ", + Short: "Delete a dashboard", + Long: "Delete a Jira dashboard by its ID.", + Example: ` jtk dashboards delete 10001`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runDelete(opts, args[0]) + }, + } + + return cmd +} + +func runDelete(opts *root.Options, dashboardID string) error { + v := opts.View() + + client, err := opts.APIClient() + if err != nil { + return err + } + + if err := client.DeleteDashboard(dashboardID); err != nil { + return err + } + + if opts.Output == "json" { + return v.JSON(map[string]string{"status": "deleted", "dashboardId": dashboardID}) + } + + v.Success("Deleted dashboard %s", dashboardID) + return nil +} + +func newGadgetsCmd(opts *root.Options) *cobra.Command { + cmd := &cobra.Command{ + Use: "gadgets", + Short: "Manage dashboard gadgets", + Long: "Commands for listing and removing gadgets on dashboards.", + } + + cmd.AddCommand(newGadgetsListCmd(opts)) + cmd.AddCommand(newGadgetsRemoveCmd(opts)) + + return cmd +} + +func newGadgetsListCmd(opts *root.Options) *cobra.Command { + cmd := &cobra.Command{ + Use: "list ", + Short: "List gadgets on a dashboard", + Long: "List all gadgets on a specific dashboard.", + Example: ` jtk dashboards gadgets list 10001 + jtk dashboards gadgets list 10001 -o json`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runGadgetsList(opts, args[0]) + }, + } + + return cmd +} + +func runGadgetsList(opts *root.Options, dashboardID string) error { + v := opts.View() + + client, err := opts.APIClient() + if err != nil { + return err + } + + result, err := client.GetDashboardGadgets(dashboardID) + if err != nil { + return err + } + + if len(result.Gadgets) == 0 { + v.Info("No gadgets on dashboard %s", dashboardID) + return nil + } + + if opts.Output == "json" { + return v.JSON(result.Gadgets) + } + + headers := []string{"ID", "TITLE", "MODULE", "POSITION"} + var rows [][]string + + for _, g := range result.Gadgets { + pos := fmt.Sprintf("row=%d col=%d", g.Position.Row, g.Position.Column) + rows = append(rows, []string{ + strconv.Itoa(g.ID), + g.Title, + g.ModuleID, + pos, + }) + } + + return v.Table(headers, rows) +} + +func newGadgetsRemoveCmd(opts *root.Options) *cobra.Command { + cmd := &cobra.Command{ + Use: "remove ", + Short: "Remove a gadget from a dashboard", + Long: "Remove a gadget from a dashboard by its ID.", + Example: ` jtk dashboards gadgets remove 10001 42`, + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + gadgetID, err := strconv.Atoi(args[1]) + if err != nil { + return fmt.Errorf("invalid gadget ID: %s", args[1]) + } + return runGadgetsRemove(opts, args[0], gadgetID) + }, + } + + return cmd +} + +func runGadgetsRemove(opts *root.Options, dashboardID string, gadgetID int) error { + v := opts.View() + + client, err := opts.APIClient() + if err != nil { + return err + } + + if err := client.RemoveDashboardGadget(dashboardID, gadgetID); err != nil { + return err + } + + if opts.Output == "json" { + return v.JSON(map[string]interface{}{ + "status": "removed", + "dashboardId": dashboardID, + "gadgetId": gadgetID, + }) + } + + v.Success("Removed gadget %d from dashboard %s", gadgetID, dashboardID) + return nil +} + +type viewWriter interface { + Table(headers []string, rows [][]string) error +} + +func renderDashboardTable(v viewWriter, dashboards []api.Dashboard) error { + headers := []string{"ID", "NAME", "OWNER", "FAVOURITE"} + var rows [][]string + + for _, d := range dashboards { + owner := "" + if d.Owner != nil { + owner = d.Owner.DisplayName + } + fav := "" + if d.IsFavourite { + fav = "yes" + } + rows = append(rows, []string{d.ID, d.Name, owner, fav}) + } + + return v.Table(headers, rows) +} diff --git a/tools/jtk/internal/cmd/dashboards/dashboards_test.go b/tools/jtk/internal/cmd/dashboards/dashboards_test.go new file mode 100644 index 0000000..a0be12d --- /dev/null +++ b/tools/jtk/internal/cmd/dashboards/dashboards_test.go @@ -0,0 +1,186 @@ +package dashboards + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/open-cli-collective/jira-ticket-cli/api" + "github.com/open-cli-collective/jira-ticket-cli/internal/cmd/root" +) + +func TestRunList(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(api.DashboardsResponse{ + Total: 1, + Dashboards: []api.Dashboard{ + {ID: "10001", Name: "Sprint Board", Owner: &api.User{DisplayName: "Alice"}}, + }, + }) + })) + defer server.Close() + + client, err := api.New(api.ClientConfig{URL: server.URL, Email: "t@t.com", APIToken: "tok"}) + require.NoError(t, err) + + var stdout bytes.Buffer + opts := &root.Options{Output: "table", Stdout: &stdout, Stderr: &bytes.Buffer{}} + opts.SetAPIClient(client) + + err = runList(opts, "", 50) + require.NoError(t, err) + assert.Contains(t, stdout.String(), "Sprint Board") +} + +func TestRunList_Search(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "Sprint", r.URL.Query().Get("dashboardName")) + json.NewEncoder(w).Encode(api.DashboardSearchResponse{ + Total: 1, + Values: []api.Dashboard{ + {ID: "10002", Name: "Sprint Board"}, + }, + }) + })) + defer server.Close() + + client, err := api.New(api.ClientConfig{URL: server.URL, Email: "t@t.com", APIToken: "tok"}) + require.NoError(t, err) + + var stdout bytes.Buffer + opts := &root.Options{Output: "table", Stdout: &stdout, Stderr: &bytes.Buffer{}} + opts.SetAPIClient(client) + + err = runList(opts, "Sprint", 50) + require.NoError(t, err) + assert.Contains(t, stdout.String(), "Sprint Board") +} + +func TestRunGet(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/rest/api/3/dashboard/10001": + json.NewEncoder(w).Encode(api.Dashboard{ + ID: "10001", + Name: "My Dashboard", + }) + case "/rest/api/3/dashboard/10001/gadget": + json.NewEncoder(w).Encode(api.DashboardGadgetsResponse{ + Gadgets: []api.DashboardGadget{ + {ID: 1, Title: "Filter Results"}, + }, + }) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + client, err := api.New(api.ClientConfig{URL: server.URL, Email: "t@t.com", APIToken: "tok"}) + require.NoError(t, err) + + var stdout bytes.Buffer + opts := &root.Options{Output: "table", Stdout: &stdout, Stderr: &bytes.Buffer{}} + opts.SetAPIClient(client) + + err = runGet(opts, "10001") + require.NoError(t, err) + assert.Contains(t, stdout.String(), "My Dashboard") + assert.Contains(t, stdout.String(), "Filter Results") +} + +func TestRunCreate(t *testing.T) { + var capturedBody []byte + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedBody, _ = io.ReadAll(r.Body) + json.NewEncoder(w).Encode(api.Dashboard{ID: "10099", Name: "New Board"}) + })) + defer server.Close() + + client, err := api.New(api.ClientConfig{URL: server.URL, Email: "t@t.com", APIToken: "tok"}) + require.NoError(t, err) + + var stdout bytes.Buffer + opts := &root.Options{Output: "table", Stdout: &stdout, Stderr: &bytes.Buffer{}} + opts.SetAPIClient(client) + + err = runCreate(opts, "New Board", "Description") + require.NoError(t, err) + assert.Contains(t, stdout.String(), "Created") + + var req api.CreateDashboardRequest + err = json.Unmarshal(capturedBody, &req) + require.NoError(t, err) + assert.Equal(t, "New Board", req.Name) + assert.Equal(t, "Description", req.Description) +} + +func TestRunDelete(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/rest/api/3/dashboard/10001", r.URL.Path) + assert.Equal(t, "DELETE", r.Method) + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + client, err := api.New(api.ClientConfig{URL: server.URL, Email: "t@t.com", APIToken: "tok"}) + require.NoError(t, err) + + var stdout bytes.Buffer + opts := &root.Options{Output: "table", Stdout: &stdout, Stderr: &bytes.Buffer{}} + opts.SetAPIClient(client) + + err = runDelete(opts, "10001") + require.NoError(t, err) + assert.Contains(t, stdout.String(), "Deleted") +} + +func TestRunGadgetsList(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(api.DashboardGadgetsResponse{ + Gadgets: []api.DashboardGadget{ + {ID: 1, Title: "Filter Results", ModuleID: "com.atlassian.jira.gadgets:filter-results-gadget"}, + {ID: 2, Title: "Pie Chart", ModuleID: "com.atlassian.jira.gadgets:pie-chart-gadget"}, + }, + }) + })) + defer server.Close() + + client, err := api.New(api.ClientConfig{URL: server.URL, Email: "t@t.com", APIToken: "tok"}) + require.NoError(t, err) + + var stdout bytes.Buffer + opts := &root.Options{Output: "table", Stdout: &stdout, Stderr: &bytes.Buffer{}} + opts.SetAPIClient(client) + + err = runGadgetsList(opts, "10001") + require.NoError(t, err) + assert.Contains(t, stdout.String(), "Filter Results") + assert.Contains(t, stdout.String(), "Pie Chart") +} + +func TestRunGadgetsRemove(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/rest/api/3/dashboard/10001/gadget/42", r.URL.Path) + assert.Equal(t, "DELETE", r.Method) + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + client, err := api.New(api.ClientConfig{URL: server.URL, Email: "t@t.com", APIToken: "tok"}) + require.NoError(t, err) + + var stdout bytes.Buffer + opts := &root.Options{Output: "table", Stdout: &stdout, Stderr: &bytes.Buffer{}} + opts.SetAPIClient(client) + + err = runGadgetsRemove(opts, "10001", 42) + require.NoError(t, err) + assert.Contains(t, stdout.String(), "Removed") +}