Skip to content

Commit 383a98f

Browse files
betegonclaudegithub-actions[bot]
authored
feat(dashboard): add widget add, edit, and delete commands (#407)
## Summary - Add `sentry dashboard widget add` — add a widget with inline `--display`/`--query`/`--where`/`--group-by`/`--sort`/`--limit` flags - Add `sentry dashboard widget edit` — edit an existing widget by `--index` or `--title`, merging only changed fields - Add `sentry dashboard widget delete` — remove a widget by `--index` or `--title` - Add widget formatters (`formatWidgetAdded`, `formatWidgetDeleted`, `formatWidgetEdited`) All widget commands use the GET-modify-PUT pattern with server field stripping and auto-layout for new widgets. Stacked on #406. ## Test plan - [x] `bun run typecheck` — no new type errors - [x] `bun run lint` — passes - [x] `bun test test/types/dashboard.test.ts` — 53 tests pass 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
1 parent b600314 commit 383a98f

File tree

18 files changed

+1722
-25
lines changed

18 files changed

+1722
-25
lines changed

src/commands/dashboard/create.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,9 @@ export const createCommand = buildCommand({
138138
"Examples:\n" +
139139
" sentry dashboard create 'My Dashboard'\n" +
140140
" sentry dashboard create my-org/ 'My Dashboard'\n" +
141-
" sentry dashboard create my-org/my-project 'My Dashboard'",
141+
" sentry dashboard create my-org/my-project 'My Dashboard'\n\n" +
142+
"Add widgets after creation with:\n" +
143+
' sentry dashboard widget add <dashboard> "My Widget" --display line --query count',
142144
},
143145
output: {
144146
human: formatDashboardCreated,
@@ -162,10 +164,12 @@ export const createCommand = buildCommand({
162164

163165
const dashboard = await createDashboard(orgSlug, {
164166
title,
167+
widgets: [],
165168
projects: projectIds.length > 0 ? projectIds : undefined,
166169
});
167170
const url = buildDashboardUrl(orgSlug, dashboard.id);
168171

169172
yield new CommandOutput({ ...dashboard, url } as CreateResult);
173+
return { hint: `Dashboard: ${url}` };
170174
},
171175
});

src/commands/dashboard/index.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,14 @@ import { buildRouteMap } from "@stricli/core";
22
import { createCommand } from "./create.js";
33
import { listCommand } from "./list.js";
44
import { viewCommand } from "./view.js";
5+
import { widgetRoute } from "./widget/index.js";
56

67
export const dashboardRoute = buildRouteMap({
78
routes: {
89
list: listCommand,
910
view: viewCommand,
1011
create: createCommand,
12+
widget: widgetRoute,
1113
},
1214
docs: {
1315
brief: "Manage Sentry dashboards",
@@ -16,7 +18,8 @@ export const dashboardRoute = buildRouteMap({
1618
"Commands:\n" +
1719
" list List dashboards\n" +
1820
" view View a dashboard\n" +
19-
" create Create a dashboard",
21+
" create Create a dashboard\n" +
22+
" widget Manage dashboard widgets (add, edit, delete)",
2023
hideRoute: {},
2124
},
2225
});

src/commands/dashboard/resolve.ts

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,27 @@ import type { parseOrgProjectArg } from "../../lib/arg-parsing.js";
1010
import { ContextError, ValidationError } from "../../lib/errors.js";
1111
import { resolveOrg } from "../../lib/resolve-target.js";
1212
import { isAllDigits } from "../../lib/utils.js";
13+
import {
14+
type DashboardWidget,
15+
DISPLAY_TYPES,
16+
parseAggregate,
17+
parseSortExpression,
18+
parseWidgetInput,
19+
prepareWidgetQueries,
20+
validateAggregateNames,
21+
WIDGET_TYPES,
22+
} from "../../types/dashboard.js";
23+
24+
/** Shared widget query flags used by `add` and `edit` commands */
25+
export type WidgetQueryFlags = {
26+
readonly display?: string;
27+
readonly dataset?: string;
28+
readonly query?: string[];
29+
readonly where?: string;
30+
readonly "group-by"?: string[];
31+
readonly sort?: string;
32+
readonly limit?: number;
33+
};
1334

1435
/**
1536
* Resolve org slug from a parsed org/project target argument.
@@ -119,3 +140,114 @@ export async function resolveDashboardId(
119140

120141
return match.id;
121142
}
143+
144+
/**
145+
* Resolve widget index from --index or --title flags.
146+
*
147+
* @param widgets - Array of widgets in the dashboard
148+
* @param index - Explicit 0-based widget index
149+
* @param title - Widget title to match
150+
* @returns Resolved widget index
151+
*/
152+
export function resolveWidgetIndex(
153+
widgets: DashboardWidget[],
154+
index: number | undefined,
155+
title: string | undefined
156+
): number {
157+
if (index !== undefined) {
158+
if (index < 0 || index >= widgets.length) {
159+
throw new ValidationError(
160+
`Widget index ${index} out of range (dashboard has ${widgets.length} widgets).`,
161+
"index"
162+
);
163+
}
164+
return index;
165+
}
166+
const lowerTitle = (title ?? "").toLowerCase();
167+
const matchIndex = widgets.findIndex(
168+
(w) => w.title.toLowerCase() === lowerTitle
169+
);
170+
if (matchIndex === -1) {
171+
throw new ValidationError(
172+
`No widget with title '${title}' found in dashboard.`,
173+
"title"
174+
);
175+
}
176+
return matchIndex;
177+
}
178+
179+
/**
180+
* Build a widget from user-provided flag values.
181+
*
182+
* Shared between `dashboard widget add` and `dashboard widget edit`.
183+
* Parses aggregate shorthand, sort expressions, and validates via Zod schema.
184+
*
185+
* @param opts - Widget configuration from parsed flags
186+
* @returns Validated widget with computed query fields
187+
*/
188+
export function buildWidgetFromFlags(opts: {
189+
title: string;
190+
display: string;
191+
dataset?: string;
192+
query?: string[];
193+
where?: string;
194+
groupBy?: string[];
195+
sort?: string;
196+
limit?: number;
197+
}): DashboardWidget {
198+
const aggregates = (opts.query ?? ["count"]).map(parseAggregate);
199+
validateAggregateNames(aggregates, opts.dataset);
200+
201+
const columns = opts.groupBy ?? [];
202+
// Auto-default orderby to first aggregate descending when group-by is used.
203+
// Without this, chart widgets (line/area/bar) with group-by + limit error
204+
// because the dashboard can't determine which top N groups to display.
205+
let orderby = opts.sort ? parseSortExpression(opts.sort) : undefined;
206+
if (columns.length > 0 && !orderby && aggregates.length > 0) {
207+
orderby = `-${aggregates[0]}`;
208+
}
209+
210+
const raw = {
211+
title: opts.title,
212+
displayType: opts.display,
213+
...(opts.dataset && { widgetType: opts.dataset }),
214+
queries: [
215+
{
216+
aggregates,
217+
columns,
218+
conditions: opts.where ?? "",
219+
...(orderby && { orderby }),
220+
name: "",
221+
},
222+
],
223+
...(opts.limit !== undefined && { limit: opts.limit }),
224+
};
225+
return prepareWidgetQueries(parseWidgetInput(raw));
226+
}
227+
228+
/**
229+
* Validate --display and --dataset flag values against known enums.
230+
*
231+
* @param display - Display type flag value
232+
* @param dataset - Dataset flag value
233+
*/
234+
export function validateWidgetEnums(display?: string, dataset?: string): void {
235+
if (
236+
display &&
237+
!DISPLAY_TYPES.includes(display as (typeof DISPLAY_TYPES)[number])
238+
) {
239+
throw new ValidationError(
240+
`Invalid --display value "${display}".\nValid display types: ${DISPLAY_TYPES.join(", ")}`,
241+
"display"
242+
);
243+
}
244+
if (
245+
dataset &&
246+
!WIDGET_TYPES.includes(dataset as (typeof WIDGET_TYPES)[number])
247+
) {
248+
throw new ValidationError(
249+
`Invalid --dataset value "${dataset}".\nValid datasets: ${WIDGET_TYPES.join(", ")}`,
250+
"dataset"
251+
);
252+
}
253+
}

src/commands/dashboard/view.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
FRESH_ALIASES,
1717
FRESH_FLAG,
1818
} from "../../lib/list-command.js";
19+
import { withProgress } from "../../lib/polling.js";
1920
import { buildDashboardUrl } from "../../lib/sentry-urls.js";
2021
import type { DashboardDetail } from "../../types/dashboard.js";
2122
import {
@@ -87,8 +88,12 @@ export const viewCommand = buildCommand({
8788
return;
8889
}
8990

90-
const dashboard = await getDashboard(orgSlug, dashboardId);
91+
const dashboard = await withProgress(
92+
{ message: "Fetching dashboard...", json: flags.json },
93+
() => getDashboard(orgSlug, dashboardId)
94+
);
9195

9296
yield new CommandOutput({ ...dashboard, url } as ViewResult);
97+
return { hint: `Dashboard: ${url}` };
9398
},
9499
});

0 commit comments

Comments
 (0)