diff --git a/CHANGELOG.md b/CHANGELOG.md index 0aba1ec..861aa7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/commands/studio/reports.test.ts b/src/commands/studio/reports.test.ts index b71858e..071dcbb 100644 --- a/src/commands/studio/reports.test.ts +++ b/src/commands/studio/reports.test.ts @@ -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"; @@ -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"); @@ -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 () => { diff --git a/src/commands/studio/reports.ts b/src/commands/studio/reports.ts index 7dc5d78..85b8e2c 100644 --- a/src/commands/studio/reports.ts +++ b/src/commands/studio/reports.ts @@ -336,6 +336,7 @@ type StudioReport = { }; type StudioReportTilePayload = { + id?: string; title: string | null; sql: string | null; chart_type: string; @@ -586,8 +587,20 @@ async function readYamlStdin(): Promise { } 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( @@ -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,