Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Metabase dashboard service and controller (re-pushed) #4157

Open
wants to merge 14 commits into
base: oz/analytics-metabase-collection
Choose a base branch
from
Open
74 changes: 74 additions & 0 deletions api.planx.uk/modules/analytics/docs.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,33 @@ components:
type: integer
description: Optional ID of the parent collection

NewDashboard:
type: object
properties:
templateId:
type: integer
description: ID of the template dashboard to copy from
description:
type: string
description: Optional description for the dashboard
collectionId:
type: integer
description: Optional ID of the collection to place the dashboard in
collectionPosition:
type: integer
description: Optional position within the collection
minimum: 0
filter:
type: string
description: Filter parameter to update
value:
type: string
description: Value to set for the filter
required:
- templateId
- filter
- value

paths:
/analytics/log-user-exit:
post:
Expand Down Expand Up @@ -83,3 +110,50 @@ paths:
error:
type: string
description: Error message

/metabase/dashboard/{slug}/{service}:
post:
summary: Create new Metabase dashboard
description: Creates a new dashboard in Metabase by copying a template and updating filters
tags:
- metabase
parameters:
- name: slug
in: path
required: true
schema:
type: string
description: PlanX team slug
- name: service
in: path
required: true
schema:
type: string
description: Service identifier
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/NewDashboard"
responses:
"201":
description: Dashboard created successfully
content:
application/json:
schema:
type: object
properties:
data:
type: string
description: Public link to the created dashboard
"400":
description: Bad request or dashboard creation failed
content:
application/json:
schema:
type: object
properties:
error:
type: string
description: Error message
25 changes: 25 additions & 0 deletions api.planx.uk/modules/analytics/metabase/dashboard/controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { createNewDashboard } from "./service.js";
import type { NewDashboardHandler } from "./types.js";

export const metabaseDashboardsController: NewDashboardHandler = async (
_req,
res,
) => {
try {
const params = {
...res.locals.parsedReq.params,
...res.locals.parsedReq.body,
};
const dashboard = await createNewDashboard(params);
return res.status(201).json({ data: dashboard });
} catch (error) {
console.error("Controller error:", error);
if (error instanceof Error) {
console.error("Error stack:", error.stack);
}
return res.status(400).json({
error:
error instanceof Error ? error.message : "An unexpected error occurred",
});
}
};
16 changes: 16 additions & 0 deletions api.planx.uk/modules/analytics/metabase/dashboard/copyDashboard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import type { CopyDashboardParams } from "./types.js";
import { $metabase } from "../shared/client.js";
import { toMetabaseParams } from "./types.js";

/** Returns the ID of the copied dashboard. */
export async function copyDashboard(
params: CopyDashboardParams,
): Promise<number> {
const metabaseParams = toMetabaseParams(params);
console.log({ metabaseParams });
const response = await $metabase.post(
`/api/dashboard/${params.templateId}/copy`,
metabaseParams,
);
return response.data.id;
}
185 changes: 184 additions & 1 deletion api.planx.uk/modules/analytics/metabase/dashboard/dashboard.test.ts
Original file line number Diff line number Diff line change
@@ -1 +1,184 @@
test.todo("should test dashboard creation, filtering and link generation");
import nock from "nock";
import { copyDashboard } from "./copyDashboard.js";
import { getDashboard } from "./getDashboard.js";
import { updateFilter } from "./updateFilter.js";
import { toMetabaseParams } from "./types.js";
import { generatePublicLink } from "./generatePublicLink.js";

const BASE_URL = process.env.METABASE_URL_EXT;

const params = {
name: "Template - Test Dashboard",
templateId: 7,
description: "Here is a description.",
collectionId: 4,
collectionPosition: 2,
};

describe("Dashboard Operations", () => {
beforeEach(() => {
nock.cleanAll();
});

describe("getDashboard", () => {
test("gets dashboard name from Metabase", async () => {
const dashboardId = 7;
const metabaseMock = nock(BASE_URL!)
.get(`/api/dashboard/${dashboardId}`)
.reply(200, {
name: "Template - Test Dashboard",
});

const dashboard = await getDashboard(dashboardId);
expect(dashboard.name).toBe("Template - Test Dashboard");
expect(metabaseMock.isDone()).toBe(true);
});
});

describe("copyDashboard", () => {
test("copies dashboard template", async () => {
const metabaseMock = nock(BASE_URL!)
.post("/api/dashboard/7/copy", toMetabaseParams(params))
.reply(200, {
id: 42,
name: params.name,
description: params.description,
});

const dashboard = await copyDashboard(params);

expect(dashboard).toBe(42);
expect(metabaseMock.isDone()).toBe(true);
});

test("transforms params to snake case for Metabase API", async () => {
const snakeCaseParams = toMetabaseParams(params);
expect(snakeCaseParams).toHaveProperty("collection_id");
expect(snakeCaseParams.collection_id).toBe(4);
expect(snakeCaseParams).toHaveProperty("collection_position");
expect(snakeCaseParams.collection_position).toBe(2);
});

test("places new dashboard into correct parent", async () => {
const metabasePostMock = nock(BASE_URL!)
.post("/api/dashboard/7/copy", toMetabaseParams(params))
.reply(200, {
id: 42,
name: params.name,
collectionId: 4,
description: params.description,
});

const metabaseGetMock = nock(BASE_URL!)
.get("/api/dashboard/42")
.reply(200, {
name: params.name,
id: 42,
collection_id: 4,
});

const newDashboardId = await copyDashboard(params);
const checkDashboard = await getDashboard(newDashboardId);

expect(checkDashboard.collection_id).toBe(4);
expect(metabasePostMock.isDone()).toBe(true);
expect(metabaseGetMock.isDone()).toBe(true);
});
});

describe("updateFilter", () => {
const dashboardId = 123;
const filterName = "test_filter";
const filterValue = "new_value";

test("successfully updates string filter value", async () => {
nock(BASE_URL!)
.get(`/api/dashboard/${dashboardId}`)
.reply(200, {
parameters: [
{
name: filterName,
type: "string/=",
default: ["old_value"],
},
],
});

nock(BASE_URL!)
.put(`/api/dashboard/${dashboardId}`, {
parameters: [
{
name: filterName,
type: "string/=",
default: [filterValue],
},
],
})
.reply(200, {
parameters: [
{
name: filterName,
type: "string/=",
default: [filterValue],
},
],
param_fields: {},
});

await expect(
updateFilter({
dashboardId: dashboardId,
filter: filterName,
value: filterValue,
}),
).resolves.not.toThrow();
});

test("handles non-string filter type appropriately", async () => {
nock(BASE_URL!)
.get(`/api/dashboard/${dashboardId}`)
.reply(200, {
parameters: [
{
name: filterName,
slug: "event",
id: "30a24538",
type: "number/=",
sectionId: "number",
default: [42],
},
],
});

nock(BASE_URL!).put(`/api/dashboard/${dashboardId}`).reply(400, {
message: "Invalid parameter type. Expected number, got string.",
});

await expect(
updateFilter({
dashboardId: dashboardId,
filter: filterName,
value: "not_a_number",
}),
).rejects.toThrow(
"Filter type 'number/=' is not supported. Only string filters are currently supported.",
);
});
});

describe("generatePublicLink", () => {
test("generates public link", async () => {
const dashboardId = 8;
const testUuid = 1111111;

nock(BASE_URL!)
.post(`/api/dashboard/${dashboardId}/public_link`)
.reply(200, {
uuid: testUuid,
});

const link = await generatePublicLink(dashboardId);
expect(link).toBe(`${BASE_URL}/public/dashboard/${testUuid}`);
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { $metabase } from "../shared/client.js";

export async function generatePublicLink(params: number): Promise<string> {
const response = await $metabase.post(`/api/dashboard/${params}/public_link`);
const url = `${process.env.METABASE_URL_EXT}/public/dashboard/${response.data.uuid}`;
return url;
}
16 changes: 16 additions & 0 deletions api.planx.uk/modules/analytics/metabase/dashboard/getDashboard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { $metabase } from "../shared/client.js";
import type { GetDashboardResponse } from "./types.js";

/** Takes dashboard ID and returns dashboard with name as param; it's useful to return `response.data` to access other properties in testing. */
export async function getDashboard(
dashboardId: number,
): Promise<GetDashboardResponse> {
try {
const response = await $metabase.get(`/api/dashboard/${dashboardId}`);
console.log({ response });
return response.data;
} catch (error) {
console.error("Error in getDashboard:", error);
throw error;
}
}
42 changes: 42 additions & 0 deletions api.planx.uk/modules/analytics/metabase/dashboard/service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { copyDashboard } from "./copyDashboard.js";
import { getDashboard } from "./getDashboard.js";
import { updateFilter } from "./updateFilter.js";
import { generatePublicLink } from "./generatePublicLink.js";
import type { CreateNewDashboardParams } from "./types.js";

/**
* @returns The dashboard name (the Metabase API performs GETs with the dashboard ID, so we have to have that locally already--no need to return it here)
*/
export async function createNewDashboard({
teamName,
templateId,
description,
collectionId,
collectionPosition,
filter,
value,
}: CreateNewDashboardParams): Promise<string> {
try {
const template = await getDashboard(templateId);
const newName = template.name.replace("Template", teamName);
const copiedDashboardId = await copyDashboard({
name: newName,
templateId,
description,
collectionId,
collectionPosition,
});

// updateFilter() does not need to be saved to a variable because we don't need to access its output anywhere else
await updateFilter({
dashboardId: copiedDashboardId,
filter: filter,
value: value,
});
const publicLink = await generatePublicLink(copiedDashboardId);
return publicLink;
} catch (error) {
console.error("Error in createNewDashboard:", error);
throw error;
}
}
Loading
Loading