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

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export class WebAnalyticsDashboardHeaderElement extends UmbElementMixin(LitEleme

render() {
const connection = this.#connection();
const showDateRange = connection?.isConfigured !== false;
const hostname = this.#hostname();
const siteLabel = hostname ?? connection?.displayName;
const linkUrl = this.documentScoped ? this.route?.url ?? this.siteUrl : this.siteUrl;
Expand Down Expand Up @@ -85,7 +86,7 @@ export class WebAnalyticsDashboardHeaderElement extends UmbElementMixin(LitEleme
${!this.documentScoped && this.connections.length > 1 ? html`
<uui-select class="project-select" label="Analytics connection" .options=${this.#selectOptions()} @change=${this.#onConnectionChange}></uui-select>
` : ""}
<web-analytics-date-range-picker .preset=${this.preset} .range=${this.range}></web-analytics-date-range-picker>
${showDateRange ? html`<web-analytics-date-range-picker .preset=${this.preset} .range=${this.range}></web-analytics-date-range-picker>` : ""}
</div>
</header>
<div class="warnings">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ describe("AnalyticsDashboardController", () => {
expect(controller.state.connection).toBe("11111111-1111-1111-1111-111111111111");
});

it("does not retain reports from the previous connection when the next one fails", async () => {
it("does not request or retain reports for an unconfigured connection", async () => {
const api = dashboardApi();
api.connections.mockResolvedValue(ok({
enabled: true,
Expand All @@ -43,25 +43,20 @@ describe("AnalyticsDashboardController", () => {
{ key: "22222222-2222-2222-2222-222222222222", displayName: "Unconfigured", provider: "Vercel", capabilities: fullCapabilities, isDefault: false, isConfigured: false, baseUrl: undefined, warnings: ["No server-side access token is configured for this connection."] },
],
}));
const nextEvents = deferred<Awaited<ReturnType<DashboardApi["events"]>>>();
api.events
.mockResolvedValueOnce(ok({ rows: [{ eventName: "Demo event", visitors: 10, count: 12 }] }))
.mockReturnValueOnce(nextEvents.promise);
api.events.mockResolvedValueOnce(ok({ rows: [{ eventName: "Demo event", visitors: 10, count: 12 }] }));
const controller = new AnalyticsDashboardController(vi.fn(), api, environment());

controller.connect();
await vi.waitFor(() => expect(controller.state.events.status).toBe("success"));
controller.setConnection("22222222-2222-2222-2222-222222222222");

expect(controller.state.events).toEqual({ status: "loading" });

nextEvents.resolve({
data: undefined,
error: new Error("No access token"),
response: new Response(null, { status: 401 }),
});
await vi.waitFor(() => expect(controller.state.events.status).toBe("error"));
expect("previous" in controller.state.events).toBe(false);
expect(controller.state.summary).toEqual({ status: "idle" });
expect(controller.state.breakdowns).toEqual({});
expect(controller.state.events).toEqual({ status: "idle" });
expect(controller.state.flags).toEqual({ status: "idle" });
expect(api.summary).toHaveBeenCalledTimes(1);
expect(api.events).toHaveBeenCalledTimes(1);
expect(api.flags).toHaveBeenCalledTimes(1);
});

it("keeps the newest document scope when an older route request finishes last", async () => {
Expand Down Expand Up @@ -115,6 +110,84 @@ describe("AnalyticsDashboardController", () => {
expect(controller.state.expandedBreakdown).toBeUndefined();
});

it("stores only canonical breakdown query state for a grouped dialog", async () => {
const api = dashboardApi();
const controller = new AnalyticsDashboardController(vi.fn(), api, environment());
controller.connect();
await vi.waitFor(() => expect(controller.state.summary.status).toBe("success"));
await controller.openBreakdown("UtmCampaign", "UTM campaigns");

expect(controller.state.expandedBreakdown).toMatchObject({
dimension: "UtmCampaign",
headline: "UTM campaigns",
search: "",
report: { status: "success" },
});
});

it("does not reuse rows from a different breakdown query", async () => {
const api = dashboardApi();
const controller = new AnalyticsDashboardController(vi.fn(), api, environment());
controller.connect();
await vi.waitFor(() => expect(controller.state.summary.status).toBe("success"));
api.breakdown.mockResolvedValueOnce(ok({
dimension: "ReferrerHostname",
rows: [{ value: "example.com", visitors: 12, pageViews: 18 }],
}));
await controller.openBreakdown("ReferrerHostname", "Referrers");
const pending = deferred<ReturnType<typeof ok<{ dimension: "UtmSource"; rows: never[] }>>>();
api.breakdown.mockReturnValueOnce(pending.promise);

const switching = controller.openBreakdown("UtmSource", "UTM sources");

expect(controller.state.expandedBreakdown?.report).toEqual({ status: "loading" });
pending.resolve(ok({ dimension: "UtmSource", rows: [] }));
await switching;
});

it("retains rows while the same breakdown query refreshes", async () => {
const api = dashboardApi();
const controller = new AnalyticsDashboardController(vi.fn(), api, environment());
controller.connect();
await vi.waitFor(() => expect(controller.state.summary.status).toBe("success"));
api.breakdown.mockResolvedValueOnce(ok({
dimension: "Country",
rows: [{ value: "DK", visitors: 12, pageViews: 18 }],
}));
await controller.openBreakdown("Country", "Countries");
const pending = deferred<ReturnType<typeof ok<{ dimension: "Country"; rows: never[] }>>>();
api.breakdown.mockReturnValueOnce(pending.promise);

const refreshing = controller.openBreakdown("Country", "Countries");

expect(controller.state.expandedBreakdown?.report).toEqual({
status: "loading",
previous: [{ value: "DK", visitors: 12, pageViews: 18 }],
});
pending.resolve(ok({ dimension: "Country", rows: [] }));
await refreshing;
});

it("does not reuse unfiltered rows for a searched breakdown", async () => {
const api = dashboardApi();
const controller = new AnalyticsDashboardController(vi.fn(), api, environment());
controller.connect();
await vi.waitFor(() => expect(controller.state.summary.status).toBe("success"));
api.breakdown.mockResolvedValueOnce(ok({
dimension: "Country",
rows: [{ value: "DK", visitors: 12, pageViews: 18 }],
}));
await controller.openBreakdown("Country", "Countries");
const pending = deferred<ReturnType<typeof ok<{ dimension: "Country"; rows: never[] }>>>();
api.breakdown.mockReturnValueOnce(pending.promise);

const searching = controller.openBreakdown("Country", "Countries", { search: "denmark" });

expect(controller.state.expandedBreakdown?.report).toEqual({ status: "loading" });
pending.resolve(ok({ dimension: "Country", rows: [] }));
await searching;
});

it("does not restore event details after the dialog closes during a request", async () => {
const api = dashboardApi();
const controller = new AnalyticsDashboardController(vi.fn(), api, environment());
Expand Down Expand Up @@ -448,6 +521,41 @@ describe("AnalyticsDashboardController", () => {
expect(controller.cards().some((card) => card.kind === "tabbed-breakdown" && card.id === "utm")).toBe(true);
});

it("does not request optional reports disabled for the connection", async () => {
const api = dashboardApi();
api.connections.mockResolvedValue(ok({
enabled: true,
defaultRangeDays: 30,
connections: [{
key: "11111111-1111-1111-1111-111111111111",
displayName: "Traffic only",
provider: "Vercel",
capabilities: {
...fullCapabilities,
dimensions: fullCapabilities.dimensions.filter((dimension) => dimension !== "EventName"),
events: false,
eventDetails: false,
eventProperties: false,
globalEventFiltering: false,
flags: false,
},
isDefault: true,
isConfigured: true,
baseUrl: "https://example.com",
warnings: [],
}],
}));
const controller = new AnalyticsDashboardController(vi.fn(), api, environment());

controller.connect();

await vi.waitFor(() => expect(controller.state.summary.status).toBe("success"));
expect(api.events).not.toHaveBeenCalled();
expect(api.flags).not.toHaveBeenCalled();
expect(controller.state.events.status).toBe("idle");
expect(controller.state.flags.status).toBe("idle");
});

it("does not create a global event filter for providers that do not support it", async () => {
const api = dashboardApi();
const controller = new AnalyticsDashboardController(vi.fn(), api, environment());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ type ReportScope = { documentId?: string; culture?: string; path?: string };
export type ExpandedBreakdown = {
dimension: AnalyticsDimension;
headline: string;
search: string;
report: AsyncState<AnalyticsBreakdown["rows"]>;
};
export type SelectedEvent = {
Expand Down Expand Up @@ -199,6 +200,20 @@ export class AnalyticsDashboardController {
async loadReports(): Promise<void> {
const connection = this.state.connection;
if (!connection) return;
const selectedConnection = this.state.connections.find(({ key }) => key === connection);
if (selectedConnection?.isConfigured === false) {
this.#reportRequest.cancel();
this.#closeDialogs();
this.#utmRequest.cancel();
this.#set({
summary: idleState(),
breakdowns: {},
events: idleState(),
flags: idleState(),
utmCapability: "unknown",
});
return;
}
this.#closeDialogs();
this.#utmRequest.cancel();
const capabilities = this.#capabilities();
Expand Down Expand Up @@ -317,25 +332,33 @@ export class AnalyticsDashboardController {

clearFilters(): void { this.#set({ filters: [] }); this.#syncUrlState(); void this.loadReports(); }

async openBreakdown(dimension: AnalyticsDimension, headline: string, search = "", debounce = false): Promise<void> {
async openBreakdown(
dimension: AnalyticsDimension,
headline: string,
options: { search?: string; debounce?: boolean } = {},
): Promise<void> {
if (!supportsDimension(this.#capabilities(), dimension)) return;
const connection = this.state.connection;
if (!connection) return;
const previous = this.state.expandedBreakdown?.dimension === dimension ? this.state.expandedBreakdown.report : undefined;
this.#set({ expandedBreakdown: { dimension, headline, report: loadingState(previous) } });
const search = options.search ?? "";
const current = this.state.expandedBreakdown;
const previous = current?.dimension === dimension && current.search === search ? current.report : undefined;
this.#set({ expandedBreakdown: { dimension, headline, search, report: loadingState(previous) } });
const run = (signal: AbortSignal) => this.#api.breakdown({
path: { dimension },
query: { ...this.#reportQuery(connection, this.#visitFilterQuery()), limit: 100, search: search || undefined },
signal,
});
const result = await (debounce ? this.#expandedRequest.schedule(run) : this.#expandedRequest.run(run));
if (result.status === "cancelled" || result.status === "stale" || this.state.expandedBreakdown?.dimension !== dimension) return;
const result = await (options.debounce ? this.#expandedRequest.schedule(run) : this.#expandedRequest.run(run));
if (result.status === "cancelled" || result.status === "stale"
|| this.state.expandedBreakdown?.dimension !== dimension
|| this.state.expandedBreakdown.search !== search) return;
if (result.status === "error") {
this.#set({ expandedBreakdown: { dimension, headline, report: errorState(reportErrorMessage(result.error), previous) } });
this.#set({ expandedBreakdown: { dimension, headline, search, report: errorState(reportErrorMessage(result.error), previous) } });
return;
}
const { data, error, response } = result.value;
this.#set({ expandedBreakdown: { dimension, headline, report: error
this.#set({ expandedBreakdown: { dimension, headline, search, report: error
? errorState(apiErrorMessage(error, response?.status ?? 0), previous)
: successState(data?.rows ?? []) } });
}
Expand All @@ -344,7 +367,10 @@ export class AnalyticsDashboardController {
const expanded = this.state.expandedBreakdown;
if (!expanded) return;
const value = expanded.dimension === "Country" ? countrySearchValue(search, this.#environment.languages) : search;
void this.openBreakdown(expanded.dimension, expanded.headline, value, true);
void this.openBreakdown(expanded.dimension, expanded.headline, {
search: value,
debounce: true,
});
}

closeBreakdown(): void { this.#expandedRequest.cancel(); this.#set({ expandedBreakdown: undefined }); }
Expand Down
Loading