Skip to content

Commit 52ae975

Browse files
betegonclaude
andcommitted
feat(dashboard): add clickable titles, --limit flag, and loading spinner to list
- Title column renders as markdown link to dashboard URL (clickable in terminal) - Add --limit flag (default 30) with per_page passed to API - Wrap fetch in withProgress() for "Fetching dashboards..." spinner - Add jsonTransform to keep JSON output as plain array Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent cae9905 commit 52ae975

File tree

3 files changed

+76
-21
lines changed

3 files changed

+76
-21
lines changed

src/commands/dashboard/list.ts

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,29 +13,40 @@ import { escapeMarkdownCell } from "../../lib/formatters/markdown.js";
1313
import { type Column, writeTable } from "../../lib/formatters/table.js";
1414
import {
1515
applyFreshFlag,
16+
buildListLimitFlag,
1617
FRESH_ALIASES,
1718
FRESH_FLAG,
1819
} from "../../lib/list-command.js";
19-
import { buildDashboardsListUrl } from "../../lib/sentry-urls.js";
20+
import { withProgress } from "../../lib/polling.js";
21+
import {
22+
buildDashboardsListUrl,
23+
buildDashboardUrl,
24+
} from "../../lib/sentry-urls.js";
2025
import type { DashboardListItem } from "../../types/dashboard.js";
2126
import type { Writer } from "../../types/index.js";
2227
import { resolveOrgFromTarget } from "./resolve.js";
2328

2429
type ListFlags = {
2530
readonly web: boolean;
2631
readonly fresh: boolean;
32+
readonly limit: number;
2733
readonly json: boolean;
2834
readonly fields?: string[];
2935
};
3036

37+
type DashboardListResult = {
38+
dashboards: DashboardListItem[];
39+
orgSlug: string;
40+
};
41+
3142
/**
3243
* Format dashboard list for human-readable terminal output.
3344
*
34-
* Renders a table with ID, title, and widget count columns.
45+
* Renders a table with ID, title (clickable link), and widget count columns.
3546
* Returns "No dashboards found." for empty results.
3647
*/
37-
function formatDashboardListHuman(dashboards: DashboardListItem[]): string {
38-
if (dashboards.length === 0) {
48+
function formatDashboardListHuman(result: DashboardListResult): string {
49+
if (result.dashboards.length === 0) {
3950
return "No dashboards found.";
4051
}
4152

@@ -45,9 +56,9 @@ function formatDashboardListHuman(dashboards: DashboardListItem[]): string {
4556
widgets: string;
4657
};
4758

48-
const rows: DashboardRow[] = dashboards.map((d) => ({
59+
const rows: DashboardRow[] = result.dashboards.map((d) => ({
4960
id: d.id,
50-
title: escapeMarkdownCell(d.title),
61+
title: `[${escapeMarkdownCell(d.title)}](${buildDashboardUrl(result.orgSlug, d.id)})`,
5162
widgets: String(d.widgetDisplay?.length ?? 0),
5263
}));
5364

@@ -75,7 +86,11 @@ export const listCommand = buildCommand({
7586
" sentry dashboard list --json\n" +
7687
" sentry dashboard list --web",
7788
},
78-
output: { json: true, human: formatDashboardListHuman },
89+
output: {
90+
json: true,
91+
human: formatDashboardListHuman,
92+
jsonTransform: (result: DashboardListResult) => result.dashboards,
93+
},
7994
parameters: {
8095
positional: {
8196
kind: "tuple",
@@ -95,9 +110,10 @@ export const listCommand = buildCommand({
95110
brief: "Open in browser",
96111
default: false,
97112
},
113+
limit: buildListLimitFlag("dashboards"),
98114
fresh: FRESH_FLAG,
99115
},
100-
aliases: { ...FRESH_ALIASES, w: "web" },
116+
aliases: { ...FRESH_ALIASES, w: "web", n: "limit" },
101117
},
102118
async func(this: SentryContext, flags: ListFlags, target?: string) {
103119
applyFreshFlag(flags);
@@ -115,11 +131,14 @@ export const listCommand = buildCommand({
115131
return;
116132
}
117133

118-
const dashboards = await listDashboards(orgSlug);
134+
const dashboards = await withProgress(
135+
{ message: `Fetching dashboards (up to ${flags.limit})...` },
136+
() => listDashboards(orgSlug, { perPage: flags.limit })
137+
);
119138
const url = buildDashboardsListUrl(orgSlug);
120139

121140
return {
122-
data: dashboards,
141+
data: { dashboards, orgSlug } as DashboardListResult,
123142
hint: dashboards.length > 0 ? `Dashboards: ${url}` : undefined,
124143
};
125144
},

src/lib/api/dashboards.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,15 +18,18 @@ import { apiRequestToRegion } from "./infrastructure.js";
1818
* List dashboards in an organization.
1919
*
2020
* @param orgSlug - Organization slug
21+
* @param options - Optional pagination parameters
2122
* @returns Array of dashboard list items
2223
*/
2324
export async function listDashboards(
24-
orgSlug: string
25+
orgSlug: string,
26+
options: { perPage?: number } = {}
2527
): Promise<DashboardListItem[]> {
2628
const regionUrl = await resolveOrgRegion(orgSlug);
2729
const { data } = await apiRequestToRegion<DashboardListItem[]>(
2830
regionUrl,
29-
`/organizations/${orgSlug}/dashboards/`
31+
`/organizations/${orgSlug}/dashboards/`,
32+
{ params: { per_page: options.perPage } }
3033
);
3134
return data;
3235
}

test/commands/dashboard/list.test.ts

Lines changed: 42 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ import * as apiClient from "../../../src/lib/api-client.js";
2121
// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking
2222
import * as browser from "../../../src/lib/browser.js";
2323
// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking
24+
import * as polling from "../../../src/lib/polling.js";
25+
// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking
2426
import * as resolveTarget from "../../../src/lib/resolve-target.js";
2527
import type { DashboardListItem } from "../../../src/types/dashboard.js";
2628

@@ -71,19 +73,28 @@ describe("dashboard list command", () => {
7173
let listDashboardsSpy: ReturnType<typeof spyOn>;
7274
let resolveOrgSpy: ReturnType<typeof spyOn>;
7375
let openInBrowserSpy: ReturnType<typeof spyOn>;
76+
let withProgressSpy: ReturnType<typeof spyOn>;
7477

7578
beforeEach(() => {
7679
listDashboardsSpy = spyOn(apiClient, "listDashboards");
7780
resolveOrgSpy = spyOn(resolveTarget, "resolveOrg");
7881
openInBrowserSpy = spyOn(browser, "openInBrowser").mockResolvedValue(
7982
undefined as never
8083
);
84+
// Bypass spinner — just run the callback directly
85+
withProgressSpy = spyOn(polling, "withProgress").mockImplementation(
86+
(_opts, fn) =>
87+
fn(() => {
88+
/* no-op setMessage */
89+
})
90+
);
8191
});
8292

8393
afterEach(() => {
8494
listDashboardsSpy.mockRestore();
8595
resolveOrgSpy.mockRestore();
8696
openInBrowserSpy.mockRestore();
97+
withProgressSpy.mockRestore();
8798
});
8899

89100
test("outputs JSON array of dashboards with --json", async () => {
@@ -94,7 +105,7 @@ describe("dashboard list command", () => {
94105
const func = await listCommand.loader();
95106
await func.call(
96107
context,
97-
{ json: true, web: false, fresh: false },
108+
{ json: true, web: false, fresh: false, limit: 30 },
98109
undefined
99110
);
100111

@@ -115,7 +126,7 @@ describe("dashboard list command", () => {
115126
const func = await listCommand.loader();
116127
await func.call(
117128
context,
118-
{ json: true, web: false, fresh: false },
129+
{ json: true, web: false, fresh: false, limit: 30 },
119130
undefined
120131
);
121132

@@ -131,7 +142,7 @@ describe("dashboard list command", () => {
131142
const func = await listCommand.loader();
132143
await func.call(
133144
context,
134-
{ json: false, web: false, fresh: false },
145+
{ json: false, web: false, fresh: false, limit: 30 },
135146
undefined
136147
);
137148

@@ -151,7 +162,7 @@ describe("dashboard list command", () => {
151162
const func = await listCommand.loader();
152163
await func.call(
153164
context,
154-
{ json: false, web: false, fresh: false },
165+
{ json: false, web: false, fresh: false, limit: 30 },
155166
undefined
156167
);
157168

@@ -167,7 +178,7 @@ describe("dashboard list command", () => {
167178
const func = await listCommand.loader();
168179
await func.call(
169180
context,
170-
{ json: false, web: false, fresh: false },
181+
{ json: false, web: false, fresh: false, limit: 30 },
171182
undefined
172183
);
173184

@@ -184,11 +195,11 @@ describe("dashboard list command", () => {
184195
const func = await listCommand.loader();
185196
await func.call(
186197
context,
187-
{ json: true, web: false, fresh: false },
198+
{ json: true, web: false, fresh: false, limit: 30 },
188199
"my-org/"
189200
);
190201

191-
expect(listDashboardsSpy).toHaveBeenCalledWith("my-org");
202+
expect(listDashboardsSpy).toHaveBeenCalledWith("my-org", { perPage: 30 });
192203
});
193204

194205
test("throws ContextError when org cannot be resolved", async () => {
@@ -198,7 +209,11 @@ describe("dashboard list command", () => {
198209
const func = await listCommand.loader();
199210

200211
await expect(
201-
func.call(context, { json: false, web: false, fresh: false }, undefined)
212+
func.call(
213+
context,
214+
{ json: false, web: false, fresh: false, limit: 30 },
215+
undefined
216+
)
202217
).rejects.toThrow("Organization");
203218
});
204219

@@ -209,11 +224,29 @@ describe("dashboard list command", () => {
209224
const func = await listCommand.loader();
210225
await func.call(
211226
context,
212-
{ json: false, web: true, fresh: false },
227+
{ json: false, web: true, fresh: false, limit: 30 },
213228
undefined
214229
);
215230

216231
expect(openInBrowserSpy).toHaveBeenCalled();
217232
expect(listDashboardsSpy).not.toHaveBeenCalled();
218233
});
234+
235+
test("passes limit to API via withProgress", async () => {
236+
resolveOrgSpy.mockResolvedValue({ org: "test-org" });
237+
listDashboardsSpy.mockResolvedValue([DASHBOARD_A]);
238+
239+
const { context } = createMockContext();
240+
const func = await listCommand.loader();
241+
await func.call(
242+
context,
243+
{ json: true, web: false, fresh: false, limit: 10 },
244+
undefined
245+
);
246+
247+
expect(withProgressSpy).toHaveBeenCalled();
248+
expect(listDashboardsSpy).toHaveBeenCalledWith("test-org", {
249+
perPage: 10,
250+
});
251+
});
219252
});

0 commit comments

Comments
 (0)