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
4 changes: 2 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## Unreleased

### Updated
### Fixed

Document newly required `key` value for `tiles` when creating and updating reports via `dx studio reports`.
- `dx studio reports init --id`: The scaffolded YAML now includes each tile's `id`, so `dx studio reports update` preserves existing tiles (including their sizing) instead of re-creating them. `dx studio reports create` continues to strip tile IDs, since a new report always gets fresh tiles.

## 0.5.3 - 2026-06-26

Expand Down
98 changes: 95 additions & 3 deletions src/commands/studio/reports.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { EventEmitter } from "events";
import fs from "fs";

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { parse as parseYaml } from "yaml";

import { EXIT_CODES } from "../../errors.js";

Expand Down Expand Up @@ -240,6 +241,56 @@ describe("studio reports command", () => {
expect(body.name).toBe("Web Metrics");
});

it("--from-file strips tile ids before posting", async () => {
process.env.DX_API_BASE_URL = "https://api.example.com";
getToken.mockReturnValue("token-123");

const fetchMock = vi.fn().mockResolvedValueOnce(
new Response(JSON.stringify({ ok: true, report }), {
status: 200,
}),
);
vi.stubGlobal("fetch", fetchMock);

const { run } = await import("../../cli.js");
stubReadFileSyncForFixturePath(
"my-report.yaml",
[
"name: Web Metrics",
"owner_email: owner@example.com",
"tiles:",
" - id: tile_from_source",
" title: Median visit duration",
" sql: SELECT 1",
" chart_type: table",
" chart_config: {}",
"",
].join("\n"),
);

await run([
"node",
"dx",
"studio",
"reports",
"create",
"--from-file",
"./my-report.yaml",
]);

const body = JSON.parse(
(fetchMock.mock.calls[0][1] as { body: string }).body,
);
expect(body.tiles).toEqual([
{
title: "Median visit duration",
sql: "SELECT 1",
chart_type: "table",
chart_config: {},
},
]);
});

it("--from-file returns JSON with --json flag", async () => {
process.env.DX_API_BASE_URL = "https://api.example.com";
getToken.mockReturnValue("token-123");
Expand Down Expand Up @@ -535,12 +586,53 @@ describe("studio reports command", () => {
]);

const yaml = writeFileSyncSpy.mock.calls[0]?.[1] as string;
expect(yaml).not.toContain("id:");
// The report-level id is read-only and must not be written.
expect(yaml).not.toContain("id: rpt_new");
expect(yaml).not.toContain("url:");
expect(yaml).not.toContain("created_at:");
expect(yaml).not.toContain("updated_at:");
expect(yaml).not.toContain("tile_line");
expect(yaml).not.toContain("tile_table");
});

it("--id scaffolds each tile with its id so updates preserve tiles", async () => {
process.env.DX_API_BASE_URL = "https://api.example.com";
getToken.mockReturnValue("token-123");

vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValueOnce(
new Response(JSON.stringify(infoResponse), {
status: 200,
}),
),
);
const writeFileSyncSpy = vi
.spyOn(fs, "writeFileSync")
.mockImplementation(() => undefined);

const { run } = await import("../../cli.js");
await run([
"node",
"dx",
"studio",
"reports",
"init",
"./my-report.yaml",
"--id",
"rpt_new",
]);

const yaml = writeFileSyncSpy.mock.calls[0]?.[1] as string;
expect(yaml).toContain("id: tile_line");
expect(yaml).toContain("id: tile_table");

// The scaffolded YAML round-trips into an update payload that keeps IDs.
const parsed = parseYaml(yaml) as {
tiles: { id: string }[];
};
expect(parsed.tiles.map((tile) => tile.id)).toEqual([
"tile_line",
"tile_table",
]);
});

it("--id returns JSON with --json flag", async () => {
Expand Down
18 changes: 16 additions & 2 deletions src/commands/studio/reports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,7 @@ type StudioReport = {
};

type StudioReportTilePayload = {
id?: string;
title: string | null;
sql: string | null;
chart_type: string;
Expand Down Expand Up @@ -586,8 +587,20 @@ async function readYamlStdin(): Promise<unknown> {
}

function buildCreateReportPayload(raw: unknown): CreateStudioReportPayload {
const { id: _id, ...rest } = parseYamlObject(raw);
return rest as CreateStudioReportPayload;
const { id: _id, tiles, ...rest } = parseYamlObject(raw);
const payload = rest as CreateStudioReportPayload;
if (Array.isArray(tiles)) {
// A new report always gets fresh tiles, so drop any tile IDs carried over
// from an `init --id` scaffold (those IDs belong to the source report).
payload.tiles = tiles.map((tile) => {
if (!tile || typeof tile !== "object") {
return tile as StudioReportTilePayload;
}
const { id: _tileId, ...tileRest } = tile as StudioReportTilePayload;
return tileRest as StudioReportTilePayload;
});
}
return payload;
}

function buildUpdateReportPayload(
Expand Down Expand Up @@ -624,6 +637,7 @@ function studioReportToYaml(report: StudioReport): string {
edit_access_type: report.edit_access_type,
editor_emails: [],
tiles: report.tiles.map((tile) => ({
id: tile.id,
title: tile.title,
sql: tile.sql,
chart_type: tile.chart_type,
Expand Down
Loading