Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ Add a widget to a dashboard
- `--y <value> - Grid row position (0-based)`
- `--width <value> - Widget width in grid columns (1–6)`
- `--height <value> - Widget height in grid rows (min 1)`
- `-l, --layout <value> - Layout mode: sequential (append in order) or dense (fill gaps) - (default: "sequential")`

**Examples:**

Expand Down
25 changes: 24 additions & 1 deletion src/commands/dashboard/widget/add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
prepareDashboardForUpdate,
validateWidgetLayout,
type WidgetLayoutFlags,
type WidgetLayoutMode,
} from "../../../types/dashboard.js";
import {
buildWidgetFromFlags,
Expand All @@ -34,6 +35,7 @@ import {
type AddFlags = WidgetQueryFlags &
WidgetLayoutFlags & {
readonly display: string;
readonly layout: string;
readonly json: boolean;
readonly fields?: string[];
};
Expand Down Expand Up @@ -178,6 +180,12 @@ export const addCommand = buildCommand({
brief: "Widget height in grid rows (min 1)",
optional: true,
},
layout: {
kind: "parsed",
parse: String,
brief: "Layout mode: sequential (append in order) or dense (fill gaps)",
default: "sequential",
},
},
aliases: {
d: "display",
Expand All @@ -186,6 +194,7 @@ export const addCommand = buildCommand({
g: "group-by",
s: "sort",
n: "limit",
l: "layout",
},
},
async *func(this: SentryContext, flags: AddFlags, ...args: string[]) {
Expand Down Expand Up @@ -217,6 +226,20 @@ export const addCommand = buildCommand({
limit: flags.limit,
});

// Validate layout mode before any network calls
if (
flags.layout &&
flags.layout !== "sequential" &&
flags.layout !== "dense"
) {
throw new ValidationError(
`Invalid --layout mode "${flags.layout}". Valid values: sequential, dense`,
"layout"
);
}
const layoutMode: WidgetLayoutMode =
flags.layout === "dense" ? "dense" : "sequential";

// Validate individual layout flag ranges before any network calls
// (catches --x -1, --width 7, etc. early without needing the dashboard)
validateWidgetLayout(flags);
Expand All @@ -230,7 +253,7 @@ export const addCommand = buildCommand({

// Always run auto-layout first to get default position and dimensions,
// then override with any explicit user flags.
newWidget = assignDefaultLayout(newWidget, updateBody.widgets);
newWidget = assignDefaultLayout(newWidget, updateBody.widgets, layoutMode);

const hasExplicitLayout =
flags.x !== undefined ||
Expand Down
113 changes: 98 additions & 15 deletions src/types/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -609,6 +609,16 @@ export function prepareWidgetQueries(
/** Sentry dashboard grid column count */
export const GRID_COLUMNS = 6;

/**
* Controls how the auto-placer positions new widgets in the grid.
*
* - `sequential` — Cursor-based append: place after the last widget,
* wrap to a new row on overflow. Never backfills interior gaps.
* - `dense` — First-fit packing: scan top-to-bottom, left-to-right
* and place in the first available gap. Produces compact layouts.
*/
export type WidgetLayoutMode = "sequential" | "dense";

/** Default widget dimensions by displayType */
const DEFAULT_WIDGET_SIZE: Partial<
Record<DisplayType, { w: number; h: number; minH: number }>
Expand Down Expand Up @@ -671,39 +681,112 @@ function regionFits(
return true;
}

/** Grid state computed from existing widget layouts */
type OccupiedGrid = { occupied: Set<string>; maxY: number };

/** Widget dimensions resolved from displayType */
type WidgetSize = { w: number; h: number; minH: number };

/**
* Dense (first-fit) placement: scan the grid top-to-bottom, left-to-right
* and place the widget in the first gap where it fits.
*/
function assignLayoutDense(
widget: DashboardWidget,
size: WidgetSize,
grid: OccupiedGrid
): DashboardWidget {
const { w, h, minH } = size;
for (let y = 0; y <= grid.maxY; y++) {
for (let x = 0; x <= GRID_COLUMNS - w; x++) {
if (regionFits(grid.occupied, { px: x, py: y, w, h })) {
return { ...widget, layout: { x, y, w, h, minH } };
}
}
}
return { ...widget, layout: { x: 0, y: grid.maxY, w, h, minH } };
}

/**
* Find the layout of the last widget in the array that has one.
* Reverse-scans because the API preserves insertion order.
*/
function findLastLayout(
widgets: DashboardWidget[]
): DashboardWidgetLayout | undefined {
for (let i = widgets.length - 1; i >= 0; i--) {
const layout = widgets[i]?.layout;
if (layout) {
return layout;
}
}
}

/**
* Sequential (cursor-based) placement: place the widget immediately to the
* right of the last existing widget on the same row. When the row overflows
* or the position overlaps a manually-placed widget, wrap to a fresh row
* below all existing content.
*/
function assignLayoutSequential(
widget: DashboardWidget,
existingWidgets: DashboardWidget[],
size: WidgetSize,
grid: OccupiedGrid
): DashboardWidget {
const { w, h, minH } = size;
const lastLayout = findLastLayout(existingWidgets);

if (lastLayout) {
const cursorX = lastLayout.x + lastLayout.w;
const cursorY = lastLayout.y;

// Place at cursor if it fits within the grid and doesn't overlap
if (
cursorX + w <= GRID_COLUMNS &&
regionFits(grid.occupied, { px: cursorX, py: cursorY, w, h })
) {
return { ...widget, layout: { x: cursorX, y: cursorY, w, h, minH } };
}
}

// Wrap to a new row below all existing content
return { ...widget, layout: { x: 0, y: grid.maxY, w, h, minH } };
}

/**
* Assign a default layout to a widget if it doesn't already have one.
* Packs the widget into the first available space in a 6-column grid,
* scanning rows top-to-bottom and left-to-right.
*
* Two placement modes are available:
* - `"sequential"` (default) — Cursor-based append: the widget is placed
* immediately after the last existing widget, wrapping to a new row when
* the current row overflows. Interior gaps are never backfilled.
* - `"dense"` — First-fit packing: the widget is placed in the first
* available gap, scanning top-to-bottom and left-to-right.
*
* @param widget - Widget that may be missing a layout
* @param existingWidgets - Widgets already in the dashboard (used to compute placement)
* @param mode - Layout strategy (`"sequential"` or `"dense"`)
* @returns Widget with layout guaranteed
*/
export function assignDefaultLayout(
widget: DashboardWidget,
existingWidgets: DashboardWidget[]
existingWidgets: DashboardWidget[],
mode: WidgetLayoutMode = "sequential"
): DashboardWidget {
if (widget.layout) {
return widget;
}

const { w, h, minH } =
const size =
DEFAULT_WIDGET_SIZE[widget.displayType as DisplayType] ?? FALLBACK_SIZE;
const grid = buildOccupiedGrid(existingWidgets);

const { occupied, maxY } = buildOccupiedGrid(existingWidgets);

// Scan rows to find the first position where the widget fits
for (let y = 0; y <= maxY; y++) {
for (let x = 0; x <= GRID_COLUMNS - w; x++) {
if (regionFits(occupied, { px: x, py: y, w, h })) {
return { ...widget, layout: { x, y, w, h, minH } };
}
}
if (mode === "dense") {
return assignLayoutDense(widget, size, grid);
}

// No gap found — place below everything
return { ...widget, layout: { x: 0, y: maxY, w, h, minH } };
return assignLayoutSequential(widget, existingWidgets, size, grid);
}

// ---------------------------------------------------------------------------
Expand Down
64 changes: 64 additions & 0 deletions test/commands/dashboard/widget/add.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -491,4 +491,68 @@ describe("dashboard widget add", () => {
const addedWidget = body.widgets.at(-1);
expect(addedWidget.queries[0].orderby).toBe("-count()");
});

// -- Layout mode flag tests -----------------------------------------------

test("--layout dense passes dense mode to auto-placer", async () => {
const { context } = createMockContext();
const func = await addCommand.loader();
await func.call(
context,
{ json: false, display: "big_number", query: ["count"], layout: "dense" },
"123",
"Dense Widget"
);

const body = updateDashboardSpy.mock.calls[0]?.[2];
const addedWidget = body.widgets.at(-1);
// Existing widgets: big_number at (0,0,2,1) and table at (2,0,4,2).
// Dense mode finds the first gap. The gap at (0,1) fits a 2x1 big_number.
expect(addedWidget.layout.x).toBe(0);
expect(addedWidget.layout.y).toBe(1);
});

test("--layout sequential (default) uses sequential placement", async () => {
const { context } = createMockContext();
const func = await addCommand.loader();
await func.call(
context,
{
json: false,
display: "big_number",
query: ["count"],
layout: "sequential",
},
"123",
"Sequential Widget"
);

const body = updateDashboardSpy.mock.calls[0]?.[2];
const addedWidget = body.widgets.at(-1);
// Existing widgets: big_number at (0,0,2,1) and table at (2,0,4,2).
// Last widget is table at x=2,w=4 → cursor=(6,0) → 6+2=8 > 6 → wrap to (0,2).
expect(addedWidget.layout.x).toBe(0);
expect(addedWidget.layout.y).toBe(2);
});

test("--layout invalid rejects with ValidationError", async () => {
const { context } = createMockContext();
const func = await addCommand.loader();
const err = await func
.call(
context,
{
json: false,
display: "big_number",
query: ["count"],
layout: "invalid",
},
"123",
"Bad Layout"
)
.catch((e: unknown) => e);

expect(err).toBeInstanceOf(ValidationError);
expect((err as ValidationError).message).toContain("--layout");
});
});
Loading
Loading