Skip to content

Commit 14af9ac

Browse files
authored
Merge pull request #36 from thebuilder/feat/configurable-dashboard-features
Configure analytics dashboard features and refine report dialogs
2 parents 9650dd5 + 69b3cff commit 14af9ac

39 files changed

Lines changed: 1496 additions & 493 deletions

‎src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-breakdown-grid.element.ts‎

Lines changed: 65 additions & 112 deletions
Large diffs are not rendered by default.

‎src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-components.test.ts‎

Lines changed: 176 additions & 30 deletions
Large diffs are not rendered by default.

‎src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dashboard-header.element.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ export class WebAnalyticsDashboardHeaderElement extends UmbElementMixin(LitEleme
5151

5252
render() {
5353
const connection = this.#connection();
54+
const showDateRange = connection?.isConfigured !== false;
5455
const hostname = this.#hostname();
5556
const siteLabel = hostname ?? connection?.displayName;
5657
const linkUrl = this.documentScoped ? this.route?.url ?? this.siteUrl : this.siteUrl;
@@ -85,7 +86,7 @@ export class WebAnalyticsDashboardHeaderElement extends UmbElementMixin(LitEleme
8586
${!this.documentScoped && this.connections.length > 1 ? html`
8687
<uui-select class="project-select" label="Analytics connection" .options=${this.#selectOptions()} @change=${this.#onConnectionChange}></uui-select>
8788
` : ""}
88-
<web-analytics-date-range-picker .preset=${this.preset} .range=${this.range}></web-analytics-date-range-picker>
89+
${showDateRange ? html`<web-analytics-date-range-picker .preset=${this.preset} .range=${this.range}></web-analytics-date-range-picker>` : ""}
8990
</div>
9091
</header>
9192
<div class="warnings">

‎src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dashboard.controller.test.ts‎

Lines changed: 122 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ describe("AnalyticsDashboardController", () => {
3333
expect(controller.state.connection).toBe("11111111-1111-1111-1111-111111111111");
3434
});
3535

36-
it("does not retain reports from the previous connection when the next one fails", async () => {
36+
it("does not request or retain reports for an unconfigured connection", async () => {
3737
const api = dashboardApi();
3838
api.connections.mockResolvedValue(ok({
3939
enabled: true,
@@ -43,25 +43,20 @@ describe("AnalyticsDashboardController", () => {
4343
{ 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."] },
4444
],
4545
}));
46-
const nextEvents = deferred<Awaited<ReturnType<DashboardApi["events"]>>>();
47-
api.events
48-
.mockResolvedValueOnce(ok({ rows: [{ eventName: "Demo event", visitors: 10, count: 12 }] }))
49-
.mockReturnValueOnce(nextEvents.promise);
46+
api.events.mockResolvedValueOnce(ok({ rows: [{ eventName: "Demo event", visitors: 10, count: 12 }] }));
5047
const controller = new AnalyticsDashboardController(vi.fn(), api, environment());
5148

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

56-
expect(controller.state.events).toEqual({ status: "loading" });
57-
58-
nextEvents.resolve({
59-
data: undefined,
60-
error: new Error("No access token"),
61-
response: new Response(null, { status: 401 }),
62-
});
63-
await vi.waitFor(() => expect(controller.state.events.status).toBe("error"));
64-
expect("previous" in controller.state.events).toBe(false);
53+
expect(controller.state.summary).toEqual({ status: "idle" });
54+
expect(controller.state.breakdowns).toEqual({});
55+
expect(controller.state.events).toEqual({ status: "idle" });
56+
expect(controller.state.flags).toEqual({ status: "idle" });
57+
expect(api.summary).toHaveBeenCalledTimes(1);
58+
expect(api.events).toHaveBeenCalledTimes(1);
59+
expect(api.flags).toHaveBeenCalledTimes(1);
6560
});
6661

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

113+
it("stores only canonical breakdown query state for a grouped dialog", async () => {
114+
const api = dashboardApi();
115+
const controller = new AnalyticsDashboardController(vi.fn(), api, environment());
116+
controller.connect();
117+
await vi.waitFor(() => expect(controller.state.summary.status).toBe("success"));
118+
await controller.openBreakdown("UtmCampaign", "UTM campaigns");
119+
120+
expect(controller.state.expandedBreakdown).toMatchObject({
121+
dimension: "UtmCampaign",
122+
headline: "UTM campaigns",
123+
search: "",
124+
report: { status: "success" },
125+
});
126+
});
127+
128+
it("does not reuse rows from a different breakdown query", async () => {
129+
const api = dashboardApi();
130+
const controller = new AnalyticsDashboardController(vi.fn(), api, environment());
131+
controller.connect();
132+
await vi.waitFor(() => expect(controller.state.summary.status).toBe("success"));
133+
api.breakdown.mockResolvedValueOnce(ok({
134+
dimension: "ReferrerHostname",
135+
rows: [{ value: "example.com", visitors: 12, pageViews: 18 }],
136+
}));
137+
await controller.openBreakdown("ReferrerHostname", "Referrers");
138+
const pending = deferred<ReturnType<typeof ok<{ dimension: "UtmSource"; rows: never[] }>>>();
139+
api.breakdown.mockReturnValueOnce(pending.promise);
140+
141+
const switching = controller.openBreakdown("UtmSource", "UTM sources");
142+
143+
expect(controller.state.expandedBreakdown?.report).toEqual({ status: "loading" });
144+
pending.resolve(ok({ dimension: "UtmSource", rows: [] }));
145+
await switching;
146+
});
147+
148+
it("retains rows while the same breakdown query refreshes", async () => {
149+
const api = dashboardApi();
150+
const controller = new AnalyticsDashboardController(vi.fn(), api, environment());
151+
controller.connect();
152+
await vi.waitFor(() => expect(controller.state.summary.status).toBe("success"));
153+
api.breakdown.mockResolvedValueOnce(ok({
154+
dimension: "Country",
155+
rows: [{ value: "DK", visitors: 12, pageViews: 18 }],
156+
}));
157+
await controller.openBreakdown("Country", "Countries");
158+
const pending = deferred<ReturnType<typeof ok<{ dimension: "Country"; rows: never[] }>>>();
159+
api.breakdown.mockReturnValueOnce(pending.promise);
160+
161+
const refreshing = controller.openBreakdown("Country", "Countries");
162+
163+
expect(controller.state.expandedBreakdown?.report).toEqual({
164+
status: "loading",
165+
previous: [{ value: "DK", visitors: 12, pageViews: 18 }],
166+
});
167+
pending.resolve(ok({ dimension: "Country", rows: [] }));
168+
await refreshing;
169+
});
170+
171+
it("does not reuse unfiltered rows for a searched breakdown", async () => {
172+
const api = dashboardApi();
173+
const controller = new AnalyticsDashboardController(vi.fn(), api, environment());
174+
controller.connect();
175+
await vi.waitFor(() => expect(controller.state.summary.status).toBe("success"));
176+
api.breakdown.mockResolvedValueOnce(ok({
177+
dimension: "Country",
178+
rows: [{ value: "DK", visitors: 12, pageViews: 18 }],
179+
}));
180+
await controller.openBreakdown("Country", "Countries");
181+
const pending = deferred<ReturnType<typeof ok<{ dimension: "Country"; rows: never[] }>>>();
182+
api.breakdown.mockReturnValueOnce(pending.promise);
183+
184+
const searching = controller.openBreakdown("Country", "Countries", { search: "denmark" });
185+
186+
expect(controller.state.expandedBreakdown?.report).toEqual({ status: "loading" });
187+
pending.resolve(ok({ dimension: "Country", rows: [] }));
188+
await searching;
189+
});
190+
118191
it("does not restore event details after the dialog closes during a request", async () => {
119192
const api = dashboardApi();
120193
const controller = new AnalyticsDashboardController(vi.fn(), api, environment());
@@ -448,6 +521,41 @@ describe("AnalyticsDashboardController", () => {
448521
expect(controller.cards().some((card) => card.kind === "tabbed-breakdown" && card.id === "utm")).toBe(true);
449522
});
450523

524+
it("does not request optional reports disabled for the connection", async () => {
525+
const api = dashboardApi();
526+
api.connections.mockResolvedValue(ok({
527+
enabled: true,
528+
defaultRangeDays: 30,
529+
connections: [{
530+
key: "11111111-1111-1111-1111-111111111111",
531+
displayName: "Traffic only",
532+
provider: "Vercel",
533+
capabilities: {
534+
...fullCapabilities,
535+
dimensions: fullCapabilities.dimensions.filter((dimension) => dimension !== "EventName"),
536+
events: false,
537+
eventDetails: false,
538+
eventProperties: false,
539+
globalEventFiltering: false,
540+
flags: false,
541+
},
542+
isDefault: true,
543+
isConfigured: true,
544+
baseUrl: "https://example.com",
545+
warnings: [],
546+
}],
547+
}));
548+
const controller = new AnalyticsDashboardController(vi.fn(), api, environment());
549+
550+
controller.connect();
551+
552+
await vi.waitFor(() => expect(controller.state.summary.status).toBe("success"));
553+
expect(api.events).not.toHaveBeenCalled();
554+
expect(api.flags).not.toHaveBeenCalled();
555+
expect(controller.state.events.status).toBe("idle");
556+
expect(controller.state.flags.status).toBe("idle");
557+
});
558+
451559
it("does not create a global event filter for providers that do not support it", async () => {
452560
const api = dashboardApi();
453561
const controller = new AnalyticsDashboardController(vi.fn(), api, environment());

‎src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dashboard.controller.ts‎

Lines changed: 34 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ type ReportScope = { documentId?: string; culture?: string; path?: string };
3838
export type ExpandedBreakdown = {
3939
dimension: AnalyticsDimension;
4040
headline: string;
41+
search: string;
4142
report: AsyncState<AnalyticsBreakdown["rows"]>;
4243
};
4344
export type SelectedEvent = {
@@ -199,6 +200,20 @@ export class AnalyticsDashboardController {
199200
async loadReports(): Promise<void> {
200201
const connection = this.state.connection;
201202
if (!connection) return;
203+
const selectedConnection = this.state.connections.find(({ key }) => key === connection);
204+
if (selectedConnection?.isConfigured === false) {
205+
this.#reportRequest.cancel();
206+
this.#closeDialogs();
207+
this.#utmRequest.cancel();
208+
this.#set({
209+
summary: idleState(),
210+
breakdowns: {},
211+
events: idleState(),
212+
flags: idleState(),
213+
utmCapability: "unknown",
214+
});
215+
return;
216+
}
202217
this.#closeDialogs();
203218
this.#utmRequest.cancel();
204219
const capabilities = this.#capabilities();
@@ -317,25 +332,33 @@ export class AnalyticsDashboardController {
317332

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

320-
async openBreakdown(dimension: AnalyticsDimension, headline: string, search = "", debounce = false): Promise<void> {
335+
async openBreakdown(
336+
dimension: AnalyticsDimension,
337+
headline: string,
338+
options: { search?: string; debounce?: boolean } = {},
339+
): Promise<void> {
321340
if (!supportsDimension(this.#capabilities(), dimension)) return;
322341
const connection = this.state.connection;
323342
if (!connection) return;
324-
const previous = this.state.expandedBreakdown?.dimension === dimension ? this.state.expandedBreakdown.report : undefined;
325-
this.#set({ expandedBreakdown: { dimension, headline, report: loadingState(previous) } });
343+
const search = options.search ?? "";
344+
const current = this.state.expandedBreakdown;
345+
const previous = current?.dimension === dimension && current.search === search ? current.report : undefined;
346+
this.#set({ expandedBreakdown: { dimension, headline, search, report: loadingState(previous) } });
326347
const run = (signal: AbortSignal) => this.#api.breakdown({
327348
path: { dimension },
328349
query: { ...this.#reportQuery(connection, this.#visitFilterQuery()), limit: 100, search: search || undefined },
329350
signal,
330351
});
331-
const result = await (debounce ? this.#expandedRequest.schedule(run) : this.#expandedRequest.run(run));
332-
if (result.status === "cancelled" || result.status === "stale" || this.state.expandedBreakdown?.dimension !== dimension) return;
352+
const result = await (options.debounce ? this.#expandedRequest.schedule(run) : this.#expandedRequest.run(run));
353+
if (result.status === "cancelled" || result.status === "stale"
354+
|| this.state.expandedBreakdown?.dimension !== dimension
355+
|| this.state.expandedBreakdown.search !== search) return;
333356
if (result.status === "error") {
334-
this.#set({ expandedBreakdown: { dimension, headline, report: errorState(reportErrorMessage(result.error), previous) } });
357+
this.#set({ expandedBreakdown: { dimension, headline, search, report: errorState(reportErrorMessage(result.error), previous) } });
335358
return;
336359
}
337360
const { data, error, response } = result.value;
338-
this.#set({ expandedBreakdown: { dimension, headline, report: error
361+
this.#set({ expandedBreakdown: { dimension, headline, search, report: error
339362
? errorState(apiErrorMessage(error, response?.status ?? 0), previous)
340363
: successState(data?.rows ?? []) } });
341364
}
@@ -344,7 +367,10 @@ export class AnalyticsDashboardController {
344367
const expanded = this.state.expandedBreakdown;
345368
if (!expanded) return;
346369
const value = expanded.dimension === "Country" ? countrySearchValue(search, this.#environment.languages) : search;
347-
void this.openBreakdown(expanded.dimension, expanded.headline, value, true);
370+
void this.openBreakdown(expanded.dimension, expanded.headline, {
371+
search: value,
372+
debounce: true,
373+
});
348374
}
349375

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

0 commit comments

Comments
 (0)