From 6ae803c200c6cde9d1aa2df444bce9f17f7350f1 Mon Sep 17 00:00:00 2001 From: betegon Date: Thu, 12 Mar 2026 17:36:58 +0100 Subject: [PATCH 1/2] feat(dashboard): add widget add, edit, and delete commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add three widget management subcommands under `sentry dashboard widget`: - `widget add` — add a widget with inline --display/--query/--where/--group-by flags - `widget edit` — edit an existing widget by --index or --title, merging only changed fields - `widget delete` — remove a widget by --index or --title All widget commands use GET-modify-PUT pattern with server field stripping and auto-layout for new widgets. Co-Authored-By: Claude Opus 4.6 --- src/commands/dashboard/index.ts | 5 +- src/commands/dashboard/widget/add.ts | 227 +++++++++++++++++++ src/commands/dashboard/widget/delete.ts | 130 +++++++++++ src/commands/dashboard/widget/edit.ts | 289 ++++++++++++++++++++++++ src/commands/dashboard/widget/index.ts | 22 ++ src/lib/formatters/human.ts | 50 ++++ 6 files changed, 722 insertions(+), 1 deletion(-) create mode 100644 src/commands/dashboard/widget/add.ts create mode 100644 src/commands/dashboard/widget/delete.ts create mode 100644 src/commands/dashboard/widget/edit.ts create mode 100644 src/commands/dashboard/widget/index.ts diff --git a/src/commands/dashboard/index.ts b/src/commands/dashboard/index.ts index adcf750f..a47d1f6c 100644 --- a/src/commands/dashboard/index.ts +++ b/src/commands/dashboard/index.ts @@ -2,12 +2,14 @@ import { buildRouteMap } from "@stricli/core"; import { createCommand } from "./create.js"; import { listCommand } from "./list.js"; import { viewCommand } from "./view.js"; +import { widgetRoute } from "./widget/index.js"; export const dashboardRoute = buildRouteMap({ routes: { list: listCommand, view: viewCommand, create: createCommand, + widget: widgetRoute, }, docs: { brief: "Manage Sentry dashboards", @@ -16,7 +18,8 @@ export const dashboardRoute = buildRouteMap({ "Commands:\n" + " list List dashboards\n" + " view View a dashboard\n" + - " create Create a dashboard", + " create Create a dashboard\n" + + " widget Manage dashboard widgets (add, edit, delete)", hideRoute: {}, }, }); diff --git a/src/commands/dashboard/widget/add.ts b/src/commands/dashboard/widget/add.ts new file mode 100644 index 00000000..5b39f127 --- /dev/null +++ b/src/commands/dashboard/widget/add.ts @@ -0,0 +1,227 @@ +/** + * sentry dashboard widget add + * + * Add a widget to an existing dashboard using inline flags. + */ + +import type { SentryContext } from "../../../context.js"; +import { getDashboard, updateDashboard } from "../../../lib/api-client.js"; +import { parseOrgProjectArg } from "../../../lib/arg-parsing.js"; +import { buildCommand, numberParser } from "../../../lib/command.js"; +import { ValidationError } from "../../../lib/errors.js"; +import { formatWidgetAdded } from "../../../lib/formatters/human.js"; +import { buildDashboardUrl } from "../../../lib/sentry-urls.js"; +import { + assignDefaultLayout, + type DashboardDetail, + type DashboardWidget, + DISPLAY_TYPES, + parseAggregate, + parseSortExpression, + parseWidgetInput, + prepareDashboardForUpdate, + prepareWidgetQueries, + WIDGET_TYPES, +} from "../../../types/dashboard.js"; +import { + parseDashboardPositionalArgs, + resolveDashboardId, + resolveOrgFromTarget, +} from "../resolve.js"; + +type AddFlags = { + readonly display: string; + readonly dataset?: string; + readonly query?: string[]; + readonly where?: string; + readonly "group-by"?: string[]; + readonly sort?: string; + readonly limit?: number; + readonly json: boolean; + readonly fields?: string[]; +}; + +type AddResult = { + dashboard: DashboardDetail; + widget: DashboardWidget; + url: string; +}; + +/** + * Parse positional args for widget add. + * Last arg is widget title, rest go to dashboard resolution. + */ +function parseAddPositionalArgs(args: string[]): { + dashboardArgs: string[]; + title: string; +} { + if (args.length < 2) { + throw new ValidationError( + "Widget title is required as a positional argument.\n\n" + + "Example:\n" + + ' sentry dashboard widget add "My Widget" --display line --query count', + "title" + ); + } + + const title = args.at(-1) as string; + const dashboardArgs = args.slice(0, -1); + return { dashboardArgs, title }; +} + +export const addCommand = buildCommand({ + docs: { + brief: "Add a widget to a dashboard", + fullDescription: + "Add a widget to an existing Sentry dashboard.\n\n" + + "The dashboard can be specified by numeric ID or title.\n\n" + + "Examples:\n" + + " sentry dashboard widget add 'My Dashboard' \"Error Count\" \\\n" + + " --display big_number --query count\n\n" + + " sentry dashboard widget add 'My Dashboard' \"Errors by Browser\" \\\n" + + " --display line --query count --group-by browser.name\n\n" + + " sentry dashboard widget add 'My Dashboard' \"Top Endpoints\" \\\n" + + " --display table --query count --query p95:span.duration \\\n" + + " --group-by transaction --sort -count --limit 10\n\n" + + "Query shorthand (--query flag):\n" + + " count → count() (bare name = no-arg aggregate)\n" + + " p95:span.duration → p95(span.duration) (colon = function with arg)\n" + + " count() → count() (parens passthrough)\n\n" + + "Sort shorthand (--sort flag):\n" + + " count → count() (ascending)\n" + + " -count → -count() (descending)", + }, + output: { + json: true, + human: formatWidgetAdded, + }, + parameters: { + positional: { + kind: "array", + parameter: { + brief: "[] ", + parse: String, + }, + }, + flags: { + display: { + kind: "parsed", + parse: String, + brief: "Display type (line, bar, table, big_number, ...)", + }, + dataset: { + kind: "parsed", + parse: String, + brief: "Widget dataset (default: spans)", + optional: true, + }, + query: { + kind: "parsed", + parse: String, + brief: "Aggregate expression (e.g. count, p95:span.duration)", + variadic: true, + optional: true, + }, + where: { + kind: "parsed", + parse: String, + brief: "Search conditions filter (e.g. is:unresolved)", + optional: true, + }, + "group-by": { + kind: "parsed", + parse: String, + brief: "Group-by column (repeatable)", + variadic: true, + optional: true, + }, + sort: { + kind: "parsed", + parse: String, + brief: "Order by (prefix - for desc, e.g. -count)", + optional: true, + }, + limit: { + kind: "parsed", + parse: numberParser, + brief: "Result limit", + optional: true, + }, + }, + aliases: { + d: "display", + q: "query", + w: "where", + g: "group-by", + s: "sort", + n: "limit", + }, + }, + async func(this: SentryContext, flags: AddFlags, ...args: string[]) { + const { cwd } = this; + + const { dashboardArgs, title } = parseAddPositionalArgs(args); + const { dashboardRef, targetArg } = + parseDashboardPositionalArgs(dashboardArgs); + const parsed = parseOrgProjectArg(targetArg); + const orgSlug = await resolveOrgFromTarget( + parsed, + cwd, + "sentry dashboard widget add <org>/ <dashboard> <title> --display <type>" + ); + const dashboardId = await resolveDashboardId(orgSlug, dashboardRef); + + // Validate --display value early for better error messages + if ( + !DISPLAY_TYPES.includes(flags.display as (typeof DISPLAY_TYPES)[number]) + ) { + throw new ValidationError( + `Invalid --display value "${flags.display}".\nValid display types: ${DISPLAY_TYPES.join(", ")}`, + "display" + ); + } + if ( + flags.dataset && + !WIDGET_TYPES.includes(flags.dataset as (typeof WIDGET_TYPES)[number]) + ) { + throw new ValidationError( + `Invalid --dataset value "${flags.dataset}".\nValid datasets: ${WIDGET_TYPES.join(", ")}`, + "dataset" + ); + } + + const aggregates = (flags.query ?? ["count"]).map(parseAggregate); + const columns = flags["group-by"] ?? []; + const orderby = flags.sort ? parseSortExpression(flags.sort) : undefined; + + const raw = { + title, + displayType: flags.display, + ...(flags.dataset && { widgetType: flags.dataset }), + queries: [ + { + aggregates, + columns, + conditions: flags.where ?? "", + ...(orderby && { orderby }), + name: "", + }, + ], + ...(flags.limit !== undefined && { limit: flags.limit }), + }; + let newWidget = prepareWidgetQueries(parseWidgetInput(raw)); + + // GET current dashboard → append widget with auto-layout → PUT + const current = await getDashboard(orgSlug, dashboardId); + const updateBody = prepareDashboardForUpdate(current); + newWidget = assignDefaultLayout(newWidget, updateBody.widgets); + updateBody.widgets.push(newWidget); + + const updated = await updateDashboard(orgSlug, dashboardId, updateBody); + const url = buildDashboardUrl(orgSlug, dashboardId); + + return { + data: { dashboard: updated, widget: newWidget, url } as AddResult, + }; + }, +}); diff --git a/src/commands/dashboard/widget/delete.ts b/src/commands/dashboard/widget/delete.ts new file mode 100644 index 00000000..0b0b50f9 --- /dev/null +++ b/src/commands/dashboard/widget/delete.ts @@ -0,0 +1,130 @@ +/** + * sentry dashboard widget delete + * + * Remove a widget from an existing dashboard. + */ + +import type { SentryContext } from "../../../context.js"; +import { getDashboard, updateDashboard } from "../../../lib/api-client.js"; +import { parseOrgProjectArg } from "../../../lib/arg-parsing.js"; +import { buildCommand, numberParser } from "../../../lib/command.js"; +import { ValidationError } from "../../../lib/errors.js"; +import { formatWidgetDeleted } from "../../../lib/formatters/human.js"; +import { buildDashboardUrl } from "../../../lib/sentry-urls.js"; +import { + type DashboardDetail, + prepareDashboardForUpdate, +} from "../../../types/dashboard.js"; +import { + parseDashboardPositionalArgs, + resolveDashboardId, + resolveOrgFromTarget, +} from "../resolve.js"; + +type DeleteFlags = { + readonly index?: number; + readonly title?: string; + readonly json: boolean; + readonly fields?: string[]; +}; + +type DeleteResult = { + dashboard: DashboardDetail; + widgetTitle: string; + url: string; +}; + +export const deleteCommand = buildCommand({ + docs: { + brief: "Delete a widget from a dashboard", + fullDescription: + "Remove a widget from an existing Sentry dashboard.\n\n" + + "The dashboard can be specified by numeric ID or title.\n" + + "Identify the widget by --index (0-based) or --title.\n\n" + + "Examples:\n" + + " sentry dashboard widget delete 12345 --index 0\n" + + " sentry dashboard widget delete 'My Dashboard' --title 'Error Rate'", + }, + output: { + json: true, + human: formatWidgetDeleted, + }, + parameters: { + positional: { + kind: "array", + parameter: { + brief: "[<org/project>] <dashboard-id-or-title>", + parse: String, + }, + }, + flags: { + index: { + kind: "parsed", + parse: numberParser, + brief: "Widget index (0-based)", + optional: true, + }, + title: { + kind: "parsed", + parse: String, + brief: "Widget title to match", + optional: true, + }, + }, + aliases: { i: "index", t: "title" }, + }, + async func(this: SentryContext, flags: DeleteFlags, ...args: string[]) { + const { cwd } = this; + + if (flags.index === undefined && !flags.title) { + throw new ValidationError( + "Specify --index or --title to identify the widget to delete.", + "index" + ); + } + + const { dashboardRef, targetArg } = parseDashboardPositionalArgs(args); + const parsed = parseOrgProjectArg(targetArg); + const orgSlug = await resolveOrgFromTarget( + parsed, + cwd, + "sentry dashboard widget delete <org>/ <id> (--index <n> | --title <name>)" + ); + const dashboardId = await resolveDashboardId(orgSlug, dashboardRef); + + // GET current dashboard → find widget → splice → PUT + const current = await getDashboard(orgSlug, dashboardId); + const widgets = current.widgets ?? []; + + let widgetIndex: number; + if (flags.index !== undefined) { + if (flags.index < 0 || flags.index >= widgets.length) { + throw new ValidationError( + `Widget index ${flags.index} out of range (dashboard has ${widgets.length} widgets).`, + "index" + ); + } + widgetIndex = flags.index; + } else { + const matchIndex = widgets.findIndex((w) => w.title === flags.title); + if (matchIndex === -1) { + throw new ValidationError( + `No widget with title '${flags.title}' found in dashboard.`, + "title" + ); + } + widgetIndex = matchIndex; + } + + const widgetTitle = widgets[widgetIndex]?.title; + const updateBody = prepareDashboardForUpdate(current); + updateBody.widgets.splice(widgetIndex, 1); + + const updated = await updateDashboard(orgSlug, dashboardId, updateBody); + const url = buildDashboardUrl(orgSlug, dashboardId); + + return { + data: { dashboard: updated, widgetTitle, url } as DeleteResult, + }; + }, +}); diff --git a/src/commands/dashboard/widget/edit.ts b/src/commands/dashboard/widget/edit.ts new file mode 100644 index 00000000..40749dc9 --- /dev/null +++ b/src/commands/dashboard/widget/edit.ts @@ -0,0 +1,289 @@ +/** + * sentry dashboard widget edit + * + * Edit a widget in an existing dashboard using inline flags. + */ + +import type { SentryContext } from "../../../context.js"; +import { getDashboard, updateDashboard } from "../../../lib/api-client.js"; +import { parseOrgProjectArg } from "../../../lib/arg-parsing.js"; +import { buildCommand, numberParser } from "../../../lib/command.js"; +import { ValidationError } from "../../../lib/errors.js"; +import { formatWidgetEdited } from "../../../lib/formatters/human.js"; +import { buildDashboardUrl } from "../../../lib/sentry-urls.js"; +import { + type DashboardDetail, + type DashboardWidget, + type DashboardWidgetQuery, + DISPLAY_TYPES, + parseAggregate, + parseSortExpression, + parseWidgetInput, + prepareDashboardForUpdate, + prepareWidgetQueries, + WIDGET_TYPES, +} from "../../../types/dashboard.js"; +import { + parseDashboardPositionalArgs, + resolveDashboardId, + resolveOrgFromTarget, +} from "../resolve.js"; + +type EditFlags = { + readonly index?: number; + readonly title?: string; + readonly display?: string; + readonly dataset?: string; + readonly query?: string[]; + readonly where?: string; + readonly "group-by"?: string[]; + readonly sort?: string; + readonly limit?: number; + readonly "new-title"?: string; + readonly json: boolean; + readonly fields?: string[]; +}; + +type EditResult = { + dashboard: DashboardDetail; + widget: DashboardWidget; + url: string; +}; + +/** Resolve widget index from --index or --title flags */ +function resolveWidgetIndex( + widgets: DashboardWidget[], + index: number | undefined, + title: string | undefined +): number { + if (index !== undefined) { + if (index < 0 || index >= widgets.length) { + throw new ValidationError( + `Widget index ${index} out of range (dashboard has ${widgets.length} widgets).`, + "index" + ); + } + return index; + } + const matchIndex = widgets.findIndex((w) => w.title === title); + if (matchIndex === -1) { + throw new ValidationError( + `No widget with title '${title}' found in dashboard.`, + "title" + ); + } + return matchIndex; +} + +/** Validate enum flag values */ +function validateEditEnums(flags: EditFlags): void { + if ( + flags.display && + !DISPLAY_TYPES.includes(flags.display as (typeof DISPLAY_TYPES)[number]) + ) { + throw new ValidationError( + `Invalid --display value "${flags.display}".\nValid display types: ${DISPLAY_TYPES.join(", ")}`, + "display" + ); + } + if ( + flags.dataset && + !WIDGET_TYPES.includes(flags.dataset as (typeof WIDGET_TYPES)[number]) + ) { + throw new ValidationError( + `Invalid --dataset value "${flags.dataset}".\nValid datasets: ${WIDGET_TYPES.join(", ")}`, + "dataset" + ); + } +} + +/** Merge query-level flags over existing widget query */ +function mergeQueries( + flags: EditFlags, + existingQuery: DashboardWidgetQuery | undefined +): DashboardWidgetQuery[] | undefined { + const hasChanges = + flags.query || flags.where !== undefined || flags["group-by"] || flags.sort; + + if (!hasChanges) { + return; // signal: keep existing + } + + return [ + { + ...existingQuery, + ...(flags.query && { aggregates: flags.query.map(parseAggregate) }), + ...(flags.where !== undefined && { conditions: flags.where }), + ...(flags["group-by"] && { columns: flags["group-by"] }), + ...(flags.sort && { orderby: parseSortExpression(flags.sort) }), + }, + ]; +} + +/** Build the replacement widget object by merging flags over existing */ +function buildReplacement( + flags: EditFlags, + existing: DashboardWidget +): DashboardWidget { + const mergedQueries = mergeQueries(flags, existing.queries?.[0]); + + const widgetType = flags.dataset ?? existing.widgetType; + const limit = flags.limit !== undefined ? flags.limit : existing.limit; + + const raw: Record<string, unknown> = { + title: flags["new-title"] ?? existing.title, + displayType: flags.display ?? existing.displayType, + queries: mergedQueries ?? existing.queries, + layout: existing.layout, + }; + if (widgetType) { + raw.widgetType = widgetType; + } + if (limit !== undefined) { + raw.limit = limit; + } + + return prepareWidgetQueries(parseWidgetInput(raw)); +} + +export const editCommand = buildCommand({ + docs: { + brief: "Edit a widget in a dashboard", + fullDescription: + "Edit a widget in an existing Sentry dashboard.\n\n" + + "The dashboard can be specified by numeric ID or title.\n" + + "Identify the widget by --index (0-based) or --title.\n" + + "Only provided flags are changed — omitted values are preserved.\n\n" + + "Examples:\n" + + " sentry dashboard widget edit 12345 --title 'Error Rate' --display bar\n" + + " sentry dashboard widget edit 'My Dashboard' --index 0 --query p95:span.duration\n" + + " sentry dashboard widget edit 12345 --title 'Old Name' --new-title 'New Name'", + }, + output: { + json: true, + human: formatWidgetEdited, + }, + parameters: { + positional: { + kind: "array", + parameter: { + brief: "[<org/project>] <dashboard-id-or-title>", + parse: String, + }, + }, + flags: { + index: { + kind: "parsed", + parse: numberParser, + brief: "Widget index (0-based)", + optional: true, + }, + title: { + kind: "parsed", + parse: String, + brief: "Widget title to match", + optional: true, + }, + "new-title": { + kind: "parsed", + parse: String, + brief: "New widget title", + optional: true, + }, + display: { + kind: "parsed", + parse: String, + brief: "Display type (line, bar, table, big_number, ...)", + optional: true, + }, + dataset: { + kind: "parsed", + parse: String, + brief: "Widget dataset (default: spans)", + optional: true, + }, + query: { + kind: "parsed", + parse: String, + brief: "Aggregate expression (e.g. count, p95:span.duration)", + variadic: true, + optional: true, + }, + where: { + kind: "parsed", + parse: String, + brief: "Search conditions filter (e.g. is:unresolved)", + optional: true, + }, + "group-by": { + kind: "parsed", + parse: String, + brief: "Group-by column (repeatable)", + variadic: true, + optional: true, + }, + sort: { + kind: "parsed", + parse: String, + brief: "Order by (prefix - for desc, e.g. -count)", + optional: true, + }, + limit: { + kind: "parsed", + parse: numberParser, + brief: "Result limit", + optional: true, + }, + }, + aliases: { + i: "index", + t: "title", + d: "display", + q: "query", + w: "where", + g: "group-by", + s: "sort", + n: "limit", + }, + }, + async func(this: SentryContext, flags: EditFlags, ...args: string[]) { + const { cwd } = this; + + if (flags.index === undefined && !flags.title) { + throw new ValidationError( + "Specify --index or --title to identify the widget to edit.\n\n" + + "Example:\n" + + " sentry dashboard widget edit <dashboard> --title 'My Widget' --display bar", + "index" + ); + } + + validateEditEnums(flags); + + const { dashboardRef, targetArg } = parseDashboardPositionalArgs(args); + const parsed = parseOrgProjectArg(targetArg); + const orgSlug = await resolveOrgFromTarget( + parsed, + cwd, + "sentry dashboard widget edit <org>/ <dashboard> --title <name> --display <type>" + ); + const dashboardId = await resolveDashboardId(orgSlug, dashboardRef); + + // GET current dashboard → find widget → merge changes → PUT + const current = await getDashboard(orgSlug, dashboardId); + const widgets = current.widgets ?? []; + const widgetIndex = resolveWidgetIndex(widgets, flags.index, flags.title); + + const updateBody = prepareDashboardForUpdate(current); + const existing = updateBody.widgets[widgetIndex] as DashboardWidget; + const replacement = buildReplacement(flags, existing); + updateBody.widgets[widgetIndex] = replacement; + + const updated = await updateDashboard(orgSlug, dashboardId, updateBody); + const url = buildDashboardUrl(orgSlug, dashboardId); + + return { + data: { dashboard: updated, widget: replacement, url } as EditResult, + }; + }, +}); diff --git a/src/commands/dashboard/widget/index.ts b/src/commands/dashboard/widget/index.ts new file mode 100644 index 00000000..a3e4144c --- /dev/null +++ b/src/commands/dashboard/widget/index.ts @@ -0,0 +1,22 @@ +import { buildRouteMap } from "@stricli/core"; +import { addCommand } from "./add.js"; +import { deleteCommand } from "./delete.js"; +import { editCommand } from "./edit.js"; + +export const widgetRoute = buildRouteMap({ + routes: { + add: addCommand, + edit: editCommand, + delete: deleteCommand, + }, + docs: { + brief: "Manage dashboard widgets", + fullDescription: + "Add, edit, or delete widgets in a Sentry dashboard.\n\n" + + "Commands:\n" + + " add Add a widget to a dashboard\n" + + " edit Edit a widget in a dashboard\n" + + " delete Delete a widget from a dashboard", + hideRoute: {}, + }, +}); diff --git a/src/lib/formatters/human.ts b/src/lib/formatters/human.ts index 33602679..9f2dd0c8 100644 --- a/src/lib/formatters/human.ts +++ b/src/lib/formatters/human.ts @@ -2203,3 +2203,53 @@ export function formatDashboardView(result: { return renderMarkdown(lines.join("\n")); } + +/** + * Format a widget add result for human-readable output. + */ +export function formatWidgetAdded(result: { + dashboard: { id: string; widgets?: unknown[] }; + widget: { title: string }; + url: string; +}): string { + const widgetCount = result.dashboard.widgets?.length ?? 0; + const lines: string[] = [ + `Added widget '${escapeMarkdownInline(result.widget.title)}' to dashboard (now ${widgetCount} widgets)`, + "", + `URL: ${result.url}`, + ]; + return renderMarkdown(lines.join("\n")); +} + +/** + * Format a widget deletion result for human-readable output. + */ +export function formatWidgetDeleted(result: { + dashboard: { id: string; widgets?: unknown[] }; + widgetTitle: string; + url: string; +}): string { + const widgetCount = result.dashboard.widgets?.length ?? 0; + const lines: string[] = [ + `Removed widget '${escapeMarkdownInline(result.widgetTitle)}' from dashboard (now ${widgetCount} widgets)`, + "", + `URL: ${result.url}`, + ]; + return renderMarkdown(lines.join("\n")); +} + +/** + * Format a widget edit result for human-readable output. + */ +export function formatWidgetEdited(result: { + dashboard: { id: string }; + widget: { title: string }; + url: string; +}): string { + const lines: string[] = [ + `Updated widget '${escapeMarkdownInline(result.widget.title)}' in dashboard ${result.dashboard.id}`, + "", + `URL: ${result.url}`, + ]; + return renderMarkdown(lines.join("\n")); +} From 0d179ef4e6a465fe0b91069d720b0d00d6f1c373 Mon Sep 17 00:00:00 2001 From: betegon <miguelbetegongarcia@gmail.com> Date: Fri, 13 Mar 2026 12:13:44 +0100 Subject: [PATCH 2/2] refactor(dashboard): deduplicate widget validation and resolution into shared module - Move resolveWidgetIndex from edit.ts to resolve.ts (shared by edit + delete) - Extract validateWidgetEnums to resolve.ts (shared by add + edit) - Remove inline enum validation from add.ts and edit.ts - Remove inline widget-index logic from delete.ts Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --- src/commands/dashboard/resolve.ts | 64 +++++++++++++++++++++++++ src/commands/dashboard/widget/add.ts | 22 +-------- src/commands/dashboard/widget/delete.ts | 21 +------- src/commands/dashboard/widget/edit.ts | 53 ++------------------ 4 files changed, 71 insertions(+), 89 deletions(-) diff --git a/src/commands/dashboard/resolve.ts b/src/commands/dashboard/resolve.ts index 81f13c57..2dd0cd27 100644 --- a/src/commands/dashboard/resolve.ts +++ b/src/commands/dashboard/resolve.ts @@ -10,6 +10,11 @@ import type { parseOrgProjectArg } from "../../lib/arg-parsing.js"; import { ContextError, ValidationError } from "../../lib/errors.js"; import { resolveOrg } from "../../lib/resolve-target.js"; import { isAllDigits } from "../../lib/utils.js"; +import { + type DashboardWidget, + DISPLAY_TYPES, + WIDGET_TYPES, +} from "../../types/dashboard.js"; /** * Resolve org slug from a parsed org/project target argument. @@ -119,3 +124,62 @@ export async function resolveDashboardId( return match.id; } + +/** + * Resolve widget index from --index or --title flags. + * + * @param widgets - Array of widgets in the dashboard + * @param index - Explicit 0-based widget index + * @param title - Widget title to match + * @returns Resolved widget index + */ +export function resolveWidgetIndex( + widgets: DashboardWidget[], + index: number | undefined, + title: string | undefined +): number { + if (index !== undefined) { + if (index < 0 || index >= widgets.length) { + throw new ValidationError( + `Widget index ${index} out of range (dashboard has ${widgets.length} widgets).`, + "index" + ); + } + return index; + } + const matchIndex = widgets.findIndex((w) => w.title === title); + if (matchIndex === -1) { + throw new ValidationError( + `No widget with title '${title}' found in dashboard.`, + "title" + ); + } + return matchIndex; +} + +/** + * Validate --display and --dataset flag values against known enums. + * + * @param display - Display type flag value + * @param dataset - Dataset flag value + */ +export function validateWidgetEnums(display?: string, dataset?: string): void { + if ( + display && + !DISPLAY_TYPES.includes(display as (typeof DISPLAY_TYPES)[number]) + ) { + throw new ValidationError( + `Invalid --display value "${display}".\nValid display types: ${DISPLAY_TYPES.join(", ")}`, + "display" + ); + } + if ( + dataset && + !WIDGET_TYPES.includes(dataset as (typeof WIDGET_TYPES)[number]) + ) { + throw new ValidationError( + `Invalid --dataset value "${dataset}".\nValid datasets: ${WIDGET_TYPES.join(", ")}`, + "dataset" + ); + } +} diff --git a/src/commands/dashboard/widget/add.ts b/src/commands/dashboard/widget/add.ts index 5b39f127..389ccc1b 100644 --- a/src/commands/dashboard/widget/add.ts +++ b/src/commands/dashboard/widget/add.ts @@ -15,18 +15,17 @@ import { assignDefaultLayout, type DashboardDetail, type DashboardWidget, - DISPLAY_TYPES, parseAggregate, parseSortExpression, parseWidgetInput, prepareDashboardForUpdate, prepareWidgetQueries, - WIDGET_TYPES, } from "../../../types/dashboard.js"; import { parseDashboardPositionalArgs, resolveDashboardId, resolveOrgFromTarget, + validateWidgetEnums, } from "../resolve.js"; type AddFlags = { @@ -171,24 +170,7 @@ export const addCommand = buildCommand({ ); const dashboardId = await resolveDashboardId(orgSlug, dashboardRef); - // Validate --display value early for better error messages - if ( - !DISPLAY_TYPES.includes(flags.display as (typeof DISPLAY_TYPES)[number]) - ) { - throw new ValidationError( - `Invalid --display value "${flags.display}".\nValid display types: ${DISPLAY_TYPES.join(", ")}`, - "display" - ); - } - if ( - flags.dataset && - !WIDGET_TYPES.includes(flags.dataset as (typeof WIDGET_TYPES)[number]) - ) { - throw new ValidationError( - `Invalid --dataset value "${flags.dataset}".\nValid datasets: ${WIDGET_TYPES.join(", ")}`, - "dataset" - ); - } + validateWidgetEnums(flags.display, flags.dataset); const aggregates = (flags.query ?? ["count"]).map(parseAggregate); const columns = flags["group-by"] ?? []; diff --git a/src/commands/dashboard/widget/delete.ts b/src/commands/dashboard/widget/delete.ts index 0b0b50f9..ef9b17df 100644 --- a/src/commands/dashboard/widget/delete.ts +++ b/src/commands/dashboard/widget/delete.ts @@ -19,6 +19,7 @@ import { parseDashboardPositionalArgs, resolveDashboardId, resolveOrgFromTarget, + resolveWidgetIndex, } from "../resolve.js"; type DeleteFlags = { @@ -96,25 +97,7 @@ export const deleteCommand = buildCommand({ const current = await getDashboard(orgSlug, dashboardId); const widgets = current.widgets ?? []; - let widgetIndex: number; - if (flags.index !== undefined) { - if (flags.index < 0 || flags.index >= widgets.length) { - throw new ValidationError( - `Widget index ${flags.index} out of range (dashboard has ${widgets.length} widgets).`, - "index" - ); - } - widgetIndex = flags.index; - } else { - const matchIndex = widgets.findIndex((w) => w.title === flags.title); - if (matchIndex === -1) { - throw new ValidationError( - `No widget with title '${flags.title}' found in dashboard.`, - "title" - ); - } - widgetIndex = matchIndex; - } + const widgetIndex = resolveWidgetIndex(widgets, flags.index, flags.title); const widgetTitle = widgets[widgetIndex]?.title; const updateBody = prepareDashboardForUpdate(current); diff --git a/src/commands/dashboard/widget/edit.ts b/src/commands/dashboard/widget/edit.ts index 40749dc9..9612c521 100644 --- a/src/commands/dashboard/widget/edit.ts +++ b/src/commands/dashboard/widget/edit.ts @@ -15,18 +15,18 @@ import { type DashboardDetail, type DashboardWidget, type DashboardWidgetQuery, - DISPLAY_TYPES, parseAggregate, parseSortExpression, parseWidgetInput, prepareDashboardForUpdate, prepareWidgetQueries, - WIDGET_TYPES, } from "../../../types/dashboard.js"; import { parseDashboardPositionalArgs, resolveDashboardId, resolveOrgFromTarget, + resolveWidgetIndex, + validateWidgetEnums, } from "../resolve.js"; type EditFlags = { @@ -50,53 +50,6 @@ type EditResult = { url: string; }; -/** Resolve widget index from --index or --title flags */ -function resolveWidgetIndex( - widgets: DashboardWidget[], - index: number | undefined, - title: string | undefined -): number { - if (index !== undefined) { - if (index < 0 || index >= widgets.length) { - throw new ValidationError( - `Widget index ${index} out of range (dashboard has ${widgets.length} widgets).`, - "index" - ); - } - return index; - } - const matchIndex = widgets.findIndex((w) => w.title === title); - if (matchIndex === -1) { - throw new ValidationError( - `No widget with title '${title}' found in dashboard.`, - "title" - ); - } - return matchIndex; -} - -/** Validate enum flag values */ -function validateEditEnums(flags: EditFlags): void { - if ( - flags.display && - !DISPLAY_TYPES.includes(flags.display as (typeof DISPLAY_TYPES)[number]) - ) { - throw new ValidationError( - `Invalid --display value "${flags.display}".\nValid display types: ${DISPLAY_TYPES.join(", ")}`, - "display" - ); - } - if ( - flags.dataset && - !WIDGET_TYPES.includes(flags.dataset as (typeof WIDGET_TYPES)[number]) - ) { - throw new ValidationError( - `Invalid --dataset value "${flags.dataset}".\nValid datasets: ${WIDGET_TYPES.join(", ")}`, - "dataset" - ); - } -} - /** Merge query-level flags over existing widget query */ function mergeQueries( flags: EditFlags, @@ -258,7 +211,7 @@ export const editCommand = buildCommand({ ); } - validateEditEnums(flags); + validateWidgetEnums(flags.display, flags.dataset); const { dashboardRef, targetArg } = parseDashboardPositionalArgs(args); const parsed = parseOrgProjectArg(targetArg);