Skip to content

Commit 5192f92

Browse files
authored
feat(dashboard): render text widget markdown content in dashboard view (#624)
## Summary - Add `TextResult` data type and render text widget markdown content in `dashboard view` instead of showing an "unsupported" placeholder - Text widgets don't query any API — their content lives in `widget.description`, so we intercept them early and return a `TextResult` directly - Markdown is rendered via the existing `renderMarkdown()` pipeline, width-constrained with `wrap-ansi` to fit inside the widget box - Empty/whitespace-only content shows an `(empty)` placeholder ## Changes | File | Change | |------|--------| | `src/types/dashboard.ts` | Add `TextResult` type, update `WidgetDataResult` union | | `src/lib/api/dashboards.ts` | Early return for text widgets in `queryWidgetData` (no API call) | | `src/commands/dashboard/view.ts` | Pass `description` through `buildViewData` for JSON output | | `src/lib/formatters/dashboard.ts` | Add `renderTextContent()`, `text` case in renderer, `description` field on view type | | `test/lib/formatters/dashboard.test.ts` | Text widget rendering tests (content, empty, whitespace) | | `test/types/dashboard.test.ts` | `TextResult` type conformance tests |
1 parent 07d9b16 commit 5192f92

File tree

7 files changed

+212
-3
lines changed

7 files changed

+212
-3
lines changed

src/commands/dashboard/view.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,9 @@ function buildViewData(
113113
title: w.title,
114114
displayType: w.displayType,
115115
widgetType: w.widgetType,
116+
description: (w as Record<string, unknown>).description as
117+
| string
118+
| undefined,
116119
layout: w.layout,
117120
queries: w.queries,
118121
data: widgetResults.get(i) ?? {

src/lib/api/dashboards.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
type ScalarResult,
2121
TABLE_DISPLAY_TYPES,
2222
type TableResult,
23+
type TextResult,
2324
TIMESERIES_DISPLAY_TYPES,
2425
type TimeseriesResult,
2526
type WidgetDataResult,
@@ -442,6 +443,16 @@ async function queryWidgetData(
442443
params: WidgetQueryParams
443444
): Promise<WidgetDataResult> {
444445
const { widget } = params;
446+
447+
// Text widgets carry markdown in `description`, no API query needed
448+
if (widget.displayType === "text") {
449+
const description = (widget as Record<string, unknown>).description;
450+
return {
451+
type: "text",
452+
content: typeof description === "string" ? description : "",
453+
} satisfies TextResult;
454+
}
455+
445456
const dataset = mapWidgetTypeToDataset(widget.widgetType);
446457
if (!dataset) {
447458
return {

src/lib/formatters/dashboard.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
import chalk from "chalk";
1212
import stringWidth from "string-width";
13+
import wrapAnsi from "wrap-ansi";
1314

1415
import type {
1516
DashboardWidgetQuery,
@@ -19,6 +20,7 @@ import type {
1920
WidgetDataResult,
2021
} from "../../types/dashboard.js";
2122
import { COLORS, muted, terminalLink } from "./colors.js";
23+
import { renderMarkdown } from "./markdown.js";
2224

2325
import type { HumanRenderer } from "./output.js";
2426
import { isPlainOutput } from "./plain-detect.js";
@@ -45,6 +47,8 @@ export type DashboardViewWidget = {
4547
title: string;
4648
displayType: string;
4749
widgetType?: string;
50+
/** Markdown content for text widgets (from API passthrough field) */
51+
description?: string;
4852
layout?: { x: number; y: number; w: number; h: number };
4953
queries?: DashboardWidgetQuery[];
5054
data: WidgetDataResult;
@@ -1537,6 +1541,26 @@ function buildTimeAxis(opts: {
15371541
return [plain ? axisStr : muted(axisStr), plain ? labelStr : muted(labelStr)];
15381542
}
15391543

1544+
/**
1545+
* Render text widget markdown content as terminal lines.
1546+
*
1547+
* Pipeline: markdown → renderMarkdown() (ANSI-styled) → wrap-ansi
1548+
* (width-constrained) → split into lines. Empty/missing content renders
1549+
* as a muted placeholder.
1550+
*/
1551+
function renderTextContent(content: string, innerWidth: number): string[] {
1552+
if (!content.trim()) {
1553+
return [isPlainOutput() ? "(empty)" : muted("(empty)")];
1554+
}
1555+
1556+
const rendered = renderMarkdown(content);
1557+
const wrapped = wrapAnsi(rendered, innerWidth, {
1558+
hard: true,
1559+
trim: false,
1560+
});
1561+
return wrapped.split("\n");
1562+
}
1563+
15401564
/** Render placeholder content for unsupported/error widgets (no title/border). */
15411565
function renderPlaceholderContent(message: string): string[] {
15421566
return [isPlainOutput() ? `(${message})` : muted(`(${message})`)];
@@ -1573,6 +1597,9 @@ function renderContentLines(opts: {
15731597
case "scalar":
15741598
return renderBigNumberContent(data, { innerWidth, contentHeight });
15751599

1600+
case "text":
1601+
return renderTextContent(data.content, innerWidth);
1602+
15761603
case "unsupported":
15771604
return renderPlaceholderContent(data.reason);
15781605

src/types/dashboard.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -922,6 +922,12 @@ export type ScalarResult = {
922922
unit?: string | null;
923923
};
924924

925+
/** Markdown text content for text widgets (no API query — content from widget.description) */
926+
export type TextResult = {
927+
type: "text";
928+
content: string;
929+
};
930+
925931
/** Widget type not supported for data fetching */
926932
export type UnsupportedResult = {
927933
type: "unsupported";
@@ -941,13 +947,15 @@ export type ErrorResult = {
941947
* - `timeseries` → sparkline charts
942948
* - `table` → text table
943949
* - `scalar` → big number display
950+
* - `text` → rendered markdown content
944951
* - `unsupported` → placeholder message
945952
* - `error` → error message
946953
*/
947954
export type WidgetDataResult =
948955
| TimeseriesResult
949956
| TableResult
950957
| ScalarResult
958+
| TextResult
951959
| UnsupportedResult
952960
| ErrorResult;
953961

test/lib/api/dashboards.test.ts

Lines changed: 77 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,20 @@
11
/**
22
* Dashboard API helper tests
33
*
4-
* Tests for periodToSeconds and computeOptimalInterval from
5-
* src/lib/api/dashboards.ts.
4+
* Tests for periodToSeconds, computeOptimalInterval, and queryAllWidgets
5+
* from src/lib/api/dashboards.ts.
66
*/
77

88
import { describe, expect, test } from "bun:test";
99
import {
1010
computeOptimalInterval,
1111
periodToSeconds,
12+
queryAllWidgets,
1213
} from "../../../src/lib/api/dashboards.js";
13-
import type { DashboardWidget } from "../../../src/types/dashboard.js";
14+
import type {
15+
DashboardDetail,
16+
DashboardWidget,
17+
} from "../../../src/types/dashboard.js";
1418

1519
// ---------------------------------------------------------------------------
1620
// periodToSeconds
@@ -138,3 +142,73 @@ describe("computeOptimalInterval", () => {
138142
expect(result).toBe("1m");
139143
});
140144
});
145+
146+
// ---------------------------------------------------------------------------
147+
// queryAllWidgets — text widget handling
148+
// ---------------------------------------------------------------------------
149+
150+
/**
151+
* Build a minimal DashboardDetail with the given widgets.
152+
*
153+
* Text widgets carry markdown in `description` (a passthrough field not in
154+
* the typed schema), so widgets are cast via `as any`.
155+
*/
156+
function makeDashboard(widgets: Record<string, unknown>[]): DashboardDetail {
157+
return { id: "1", title: "Test Dashboard", widgets } as DashboardDetail;
158+
}
159+
160+
describe("queryAllWidgets", () => {
161+
// Text widgets return immediately — no API call, no mocking needed.
162+
// regionUrl and orgSlug are unused for text widgets.
163+
const UNUSED_URL = "https://unused.example.com";
164+
const UNUSED_ORG = "unused-org";
165+
166+
test("returns TextResult for text widget with description", async () => {
167+
const dashboard = makeDashboard([
168+
{ title: "Notes", displayType: "text", description: "# Hello\n**bold**" },
169+
]);
170+
171+
const results = await queryAllWidgets(UNUSED_URL, UNUSED_ORG, dashboard);
172+
173+
expect(results.size).toBe(1);
174+
expect(results.get(0)).toEqual({
175+
type: "text",
176+
content: "# Hello\n**bold**",
177+
});
178+
});
179+
180+
test("returns TextResult with empty content when description is missing", async () => {
181+
const dashboard = makeDashboard([
182+
{ title: "Empty Notes", displayType: "text" },
183+
]);
184+
185+
const results = await queryAllWidgets(UNUSED_URL, UNUSED_ORG, dashboard);
186+
187+
expect(results.get(0)).toEqual({ type: "text", content: "" });
188+
});
189+
190+
test("returns TextResult with empty content for non-string description", async () => {
191+
const dashboard = makeDashboard([
192+
{ title: "Bad Description", displayType: "text", description: 42 },
193+
]);
194+
195+
const results = await queryAllWidgets(UNUSED_URL, UNUSED_ORG, dashboard);
196+
197+
expect(results.get(0)).toEqual({ type: "text", content: "" });
198+
});
199+
200+
test("handles multiple text widgets in a single dashboard", async () => {
201+
const dashboard = makeDashboard([
202+
{ title: "Notes 1", displayType: "text", description: "First" },
203+
{ title: "Notes 2", displayType: "text", description: "Second" },
204+
{ title: "Notes 3", displayType: "text", description: "Third" },
205+
]);
206+
207+
const results = await queryAllWidgets(UNUSED_URL, UNUSED_ORG, dashboard);
208+
209+
expect(results.size).toBe(3);
210+
expect(results.get(0)).toEqual({ type: "text", content: "First" });
211+
expect(results.get(1)).toEqual({ type: "text", content: "Second" });
212+
expect(results.get(2)).toEqual({ type: "text", content: "Third" });
213+
});
214+
});

test/lib/formatters/dashboard.test.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import type {
3333
ErrorResult,
3434
ScalarResult,
3535
TableResult,
36+
TextResult,
3637
TimeseriesResult,
3738
UnsupportedResult,
3839
} from "../../../src/types/dashboard.js";
@@ -184,6 +185,14 @@ function makeScalarData(overrides: Partial<ScalarResult> = {}): ScalarResult {
184185
};
185186
}
186187

188+
function makeTextData(overrides: Partial<TextResult> = {}): TextResult {
189+
return {
190+
type: "text",
191+
content: "# Notes\nSome **important** text here.",
192+
...overrides,
193+
};
194+
}
195+
187196
function makeUnsupportedData(
188197
overrides: Partial<UnsupportedResult> = {}
189198
): UnsupportedResult {
@@ -1036,6 +1045,55 @@ describe("formatDashboardWithData", () => {
10361045
});
10371046
});
10381047

1048+
describe("text widget", () => {
1049+
test("renders markdown content", () => {
1050+
const data = makeDashboardData({
1051+
widgets: [
1052+
makeWidget({
1053+
title: "Notes",
1054+
displayType: "text",
1055+
data: makeTextData({
1056+
content: "# Hello\nSome **bold** text",
1057+
}),
1058+
}),
1059+
],
1060+
});
1061+
const output = formatDashboardWithData(data);
1062+
expect(output).toContain("Notes");
1063+
expect(output).toContain("Hello");
1064+
expect(output).toContain("bold");
1065+
});
1066+
1067+
test("renders empty placeholder for missing content", () => {
1068+
const data = makeDashboardData({
1069+
widgets: [
1070+
makeWidget({
1071+
title: "Empty Notes",
1072+
displayType: "text",
1073+
data: makeTextData({ content: "" }),
1074+
}),
1075+
],
1076+
});
1077+
const output = formatDashboardWithData(data);
1078+
expect(output).toContain("Empty Notes");
1079+
expect(output).toContain("(empty)");
1080+
});
1081+
1082+
test("renders empty placeholder for whitespace-only content", () => {
1083+
const data = makeDashboardData({
1084+
widgets: [
1085+
makeWidget({
1086+
title: "Whitespace Notes",
1087+
displayType: "text",
1088+
data: makeTextData({ content: " \n " }),
1089+
}),
1090+
],
1091+
});
1092+
const output = formatDashboardWithData(data);
1093+
expect(output).toContain("(empty)");
1094+
});
1095+
});
1096+
10391097
describe("mixed widget types (no layout — sequential blocks)", () => {
10401098
test("renders all widget types in order", () => {
10411099
const widgets: DashboardViewWidget[] = [

test/types/dashboard.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,11 @@ import {
3131
SpanAggregateFunctionSchema,
3232
stripWidgetServerFields,
3333
TABLE_DISPLAY_TYPES,
34+
type TextResult,
3435
TIMESERIES_DISPLAY_TYPES,
3536
validateWidgetLayout,
3637
WIDGET_TYPES,
38+
type WidgetDataResult,
3739
type WidgetType,
3840
} from "../../src/types/dashboard.js";
3941

@@ -879,3 +881,29 @@ describe("display type sets", () => {
879881
expect(TABLE_DISPLAY_TYPES.has("line")).toBe(false);
880882
});
881883
});
884+
885+
describe("TextResult", () => {
886+
test("satisfies WidgetDataResult discriminated union", () => {
887+
const result: WidgetDataResult = {
888+
type: "text",
889+
content: "# Hello",
890+
} satisfies TextResult;
891+
expect(result.type).toBe("text");
892+
});
893+
894+
test("is included in WidgetDataResult union", () => {
895+
const results: WidgetDataResult[] = [
896+
{ type: "timeseries", series: [] },
897+
{ type: "table", columns: [], rows: [] },
898+
{ type: "scalar", value: 42 },
899+
{ type: "text", content: "some markdown" },
900+
{ type: "unsupported", reason: "not supported" },
901+
{ type: "error", message: "failed" },
902+
];
903+
const textResult = results.find((r) => r.type === "text");
904+
expect(textResult).toBeDefined();
905+
if (textResult?.type === "text") {
906+
expect(textResult.content).toBe("some markdown");
907+
}
908+
});
909+
});

0 commit comments

Comments
 (0)