From 808b01d5e232971e8aa504da998ba85b368762b6 Mon Sep 17 00:00:00 2001 From: Daniel Schmidt Date: Thu, 30 Jul 2026 12:31:43 +0200 Subject: [PATCH] Share event and flag drilldown flows --- .../analytics-breakdown-grid.element.ts | 142 +++++--- .../analytics/analytics-components.test.ts | 130 ++++--- .../analytics-dashboard.controller.test.ts | 295 +++++++++++++-- .../analytics-dashboard.controller.ts | 303 +++++----------- .../analytics/analytics-dashboard.element.ts | 190 ++++++---- .../src/analytics/analytics-dialog-frame.ts | 35 ++ .../analytics/analytics-drilldown-filter.ts | 63 ++++ .../analytics-feature-drilldown.controller.ts | 338 ++++++++++++++++++ .../src/analytics/analytics-filter-action.ts | 33 ++ .../src/analytics/analytics-metric-table.ts | 52 +++ .../src/analytics/analytics-table-skeleton.ts | 10 + .../src/analytics/breakdown-dialog.element.ts | 87 ++--- .../src/analytics/breakdown-table.element.ts | 18 +- .../src/analytics/dashboard-capabilities.ts | 7 +- .../analytics/dashboard-drilldown-state.ts | 18 + .../src/analytics/dashboard-url-state.test.ts | 36 +- .../src/analytics/dashboard-url-state.ts | 39 +- .../src/analytics/document-route.test.ts | 2 +- .../event-details-dialog.element.test.ts | 27 ++ .../analytics/event-details-dialog.element.ts | 74 ++-- .../analytics/event-dialog.element.test.ts | 12 + .../src/analytics/event-dialog.element.ts | 44 +-- .../src/analytics/event-table.element.ts | 103 +++--- .../Client/src/analytics/flag-card.element.ts | 110 ------ .../src/analytics/flag-dialog.element.test.ts | 57 +++ .../src/analytics/flag-dialog.element.ts | 59 +++ .../src/analytics/flag-table.element.test.ts | 120 +++++++ .../src/analytics/flag-table.element.ts | 99 +++++ .../Client/src/api/types.gen.ts | 31 ++ .../src/settings/settings-dashboard.test.ts | 4 +- .../src/settings/settings-model.test.ts | 4 +- .../AnalyticsConnectionRegistry.cs | 3 + .../AnalyticsProviderCapabilities.cs | 5 + .../Controllers/AnalyticsReportFilters.cs | 70 ++++ .../Controllers/WebAnalyticsApiController.cs | 44 ++- .../Models/AnalyticsModels.cs | 9 +- .../Providers/PlausibleProvider.cs | 1 + .../Providers/VercelProvider.cs | 1 + .../Services/AnalyticsReportService.cs | 8 +- .../Services/MockAnalyticsClient.cs | 19 +- .../Services/PlausibleAnalyticsClient.cs | 13 + .../Services/VercelAnalyticsClient.cs | 9 +- .../WebAnalyticsOptionsValidatorTests.cs | 2 + .../WebAnalyticsApiControllerTests.cs | 89 ++++- .../Services/AnalyticsReportServiceTests.cs | 22 ++ .../Services/MockAnalyticsClientTests.cs | 27 ++ .../Services/VercelAnalyticsClientTests.cs | 40 +++ 47 files changed, 2191 insertions(+), 713 deletions(-) create mode 100644 src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dialog-frame.ts create mode 100644 src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-drilldown-filter.ts create mode 100644 src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-feature-drilldown.controller.ts create mode 100644 src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-filter-action.ts create mode 100644 src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-metric-table.ts create mode 100644 src/TheBuilder.WebAnalytics/Client/src/analytics/dashboard-drilldown-state.ts delete mode 100644 src/TheBuilder.WebAnalytics/Client/src/analytics/flag-card.element.ts create mode 100644 src/TheBuilder.WebAnalytics/Client/src/analytics/flag-dialog.element.test.ts create mode 100644 src/TheBuilder.WebAnalytics/Client/src/analytics/flag-dialog.element.ts create mode 100644 src/TheBuilder.WebAnalytics/Client/src/analytics/flag-table.element.test.ts create mode 100644 src/TheBuilder.WebAnalytics/Client/src/analytics/flag-table.element.ts create mode 100644 src/TheBuilder.WebAnalytics/Controllers/AnalyticsReportFilters.cs diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-breakdown-grid.element.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-breakdown-grid.element.ts index ae2eb19..c025fb1 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-breakdown-grid.element.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-breakdown-grid.element.ts @@ -1,14 +1,14 @@ -import { LitElement, css, customElement, html, property } from "@umbraco-cms/backoffice/external/lit"; +import { LitElement, css, customElement, html, property, type TemplateResult } from "@umbraco-cms/backoffice/external/lit"; import { UmbElementMixin } from "@umbraco-cms/backoffice/element-api"; import { UmbTextStyles } from "@umbraco-cms/backoffice/style"; import type { AnalyticsBreakdown, AnalyticsDimension, AnalyticsEventsReport, AnalyticsFlagsReport } from "../api/types.gen.js"; import { breakdownMetricTotal, topBreakdownRows } from "./breakdown-rows.js"; import { selectedCardDimension, type AcquisitionView, type DashboardCard } from "./dashboard-cards.js"; -import type { AnalyticsFilter, AudienceDimension, DashboardMetric, UtmDimension } from "./dashboard-url-state.js"; +import type { AnalyticsEventFilter, AnalyticsFilter, AnalyticsFlagFilter, AudienceDimension, DashboardMetric, UtmDimension } from "./dashboard-url-state.js"; import { topEventRows } from "./event-rows.js"; import "./breakdown-table.element.js"; import "./event-table.element.js"; -import "./flag-card.element.js"; +import "./flag-table.element.js"; import { isInitialLoading, stateData, type AsyncState } from "./async-state.js"; import type { ReportTabGroup } from "./report-tabs.js"; @@ -18,8 +18,9 @@ export class WebAnalyticsBreakdownGridElement extends UmbElementMixin(LitElement @property({ attribute: false }) breakdowns: Partial>> = {}; @property({ attribute: false }) events: AsyncState = { status: "loading" }; @property({ attribute: false }) flags: AsyncState = { status: "loading" }; - @property({ attribute: false }) selectedFlag?: AsyncState; @property({ attribute: false }) filters: AnalyticsFilter[] = []; + @property({ attribute: false }) eventFilter?: AnalyticsEventFilter; + @property({ attribute: false }) flagFilter?: AnalyticsFlagFilter; @property() metric: DashboardMetric = "visitors"; @property() audienceDimension: AudienceDimension = "DeviceType"; @property() acquisitionView: AcquisitionView = "referrers"; @@ -66,12 +67,41 @@ export class WebAnalyticsBreakdownGridElement extends UmbElementMixin(LitElement }; } + #renderCardFrame({ + busy, + content, + layout = "report", + wide = false, + hint, + footer, + }: { + busy: boolean; + content: TemplateResult; + layout?: "report" | "empty"; + wide?: boolean; + hint?: TemplateResult; + footer: { + visible: boolean; + content?: TemplateResult; + }; + }) { + return html` + +
+ ${content} + ${hint} + ${footer.visible ? html`
${footer.content}
` : ""} +
+
+ `; + } + #renderBreakdownFooter( loading: boolean, unavailable: string | undefined, hasRows: boolean, selected: ReturnType, - ) { + ): TemplateResult | undefined { if (!loading && hasRows && !unavailable) { return html` this.#dispatch("view-breakdown", selected)}>View all @@ -79,7 +109,7 @@ export class WebAnalyticsBreakdownGridElement extends UmbElementMixin(LitElement } return !loading && unavailable ? html` this.#dispatch("retry-reports")}>Retry` - : ""; + : undefined; } #renderCard(card: DashboardCard) { @@ -93,10 +123,10 @@ export class WebAnalyticsBreakdownGridElement extends UmbElementMixin(LitElement const planLimited = card.kind === "tabbed-breakdown" && card.planLimited; const linkValues = selected.dimension === "RequestPath" || selected.dimension === "Route"; const headingTabs = card.kind === "tabbed-breakdown" ? this.#tabsForCard(card) : undefined; - return html` - -
- ) => card.kind === "tabbed-breakdown" && this.#dispatch(card.id === "audience" ? "audience-change" : "utm-change", { dimension: event.detail.value })} .unavailable=${unavailable}> - - ${planLimited && unavailable ? html`

UTM reporting availability depends on your analytics plan and reporting window.

` : ""} -
- ${this.#renderBreakdownFooter(loading, unavailable, rows.length > 0, selected)} -
-
-
- `; + `, + hint: planLimited && unavailable ? html`

UTM reporting availability depends on your analytics plan and reporting window.

` : undefined, + footer: { + visible: true, + content: this.#renderBreakdownFooter(loading, unavailable, rows.length > 0, selected), + }, + }); } #renderAcquisitionCard(referrerCard: DashboardCard, utmCard?: Extract) { @@ -132,10 +161,10 @@ export class WebAnalyticsBreakdownGridElement extends UmbElementMixin(LitElement const rows = topBreakdownRows(allRows, 10); const total = breakdownMetricTotal(allRows, this.metric); const unavailable = report?.status === "error" ? report.message : undefined; - return html` - -
- ) => this.#dispatch("acquisition-change", { view: event.detail.value })} @subheading-tab-change=${(event: CustomEvent<{ value: UtmDimension }>) => this.#dispatch("utm-change", { dimension: event.detail.value })}> -
- ${this.#renderBreakdownFooter(loading, unavailable, rows.length > 0, selected)} -
-
-
- `; + `, + footer: { + visible: true, + content: this.#renderBreakdownFooter(loading, unavailable, rows.length > 0, selected), + }, + }); } #renderEvents() { const loading = isInitialLoading(this.events); - const rows = topEventRows(stateData(this.events)?.rows ?? [], 10); + const allRows = stateData(this.events)?.rows ?? []; + const rows = this.eventFilter + ? allRows.filter(({ eventName }) => eventName === this.eventFilter?.eventName) + : topEventRows(allRows, 10); const empty = !loading && rows.length === 0; - return html` - -
- - ${empty ? "" : html`
- ${!loading && rows.length ? html` this.#dispatch("view-events")}>View all` : ""} -
`} -
-
- `; + return this.#renderCardFrame({ + busy: this.events.status === "loading", + layout: empty ? "empty" : "report", + content: html``, + footer: { + visible: !empty, + content: !loading && rows.length + ? html` this.#dispatch("view-events")}>View all` + : undefined, + }, + }); + } + + #renderFlags() { + const loading = isInitialLoading(this.flags); + const allRows = stateData(this.flags)?.rows ?? []; + const rows = this.flagFilter + ? allRows.filter(({ value }) => value === this.flagFilter?.flagKey) + : allRows; + const empty = !loading && rows.length === 0; + return this.#renderCardFrame({ + busy: this.flags.status === "loading", + layout: empty ? "empty" : "report", + content: html` + + `, + footer: { + visible: !empty, + content: !loading && rows.length + ? html` this.#dispatch("view-flags")}>View all` + : undefined, + }, + }); } render() { @@ -190,9 +249,7 @@ export class WebAnalyticsBreakdownGridElement extends UmbElementMixin(LitElement ${this.supportsEvents || this.supportsFlags ? html`
${this.supportsEvents ? this.#renderEvents() : ""} - ${this.supportsFlags ? html` - - ` : ""} + ${this.supportsFlags ? this.#renderFlags() : ""}
` : ""} `; } @@ -204,7 +261,6 @@ export class WebAnalyticsBreakdownGridElement extends UmbElementMixin(LitElement .feature-grid { grid-template-columns: repeat(auto-fill, minmax(min(100%, 28rem), 1fr)); } .detail-grid { grid-template-columns: repeat(auto-fit, minmax(min(100%, 18rem), 1fr)); } .breakdown-card { --uui-box-default-padding: 0; min-inline-size: 0; overflow: hidden; position: relative; } - .flags-card { --uui-box-default-padding: 0; } .breakdown-card-layout { box-sizing: border-box; min-block-size: 100%; padding-bottom: var(--uui-size-layout-3); } .empty-card-layout { block-size: 100%; padding-bottom: 0; } .breakdown-footer { align-items: center; background: color-mix(in srgb, var(--uui-color-surface-alt) 9%, var(--uui-color-surface)); border-top: 1px solid var(--uui-color-border); bottom: 0; box-sizing: border-box; display: flex; justify-content: flex-end; left: 0; min-block-size: var(--uui-size-layout-3); padding: 0 var(--uui-size-space-4); position: absolute; right: 0; } diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-components.test.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-components.test.ts index dcbd477..18eacf8 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-components.test.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-components.test.ts @@ -31,7 +31,8 @@ import type { WebAnalyticsBreakdownTableElement } from "./breakdown-table.elemen import type { WebAnalyticsEventTableElement } from "./event-table.element.js"; import type { WebAnalyticsDashboardElement } from "./analytics-dashboard.element.js"; import type { WebAnalyticsDashboardHeaderElement } from "./analytics-dashboard-header.element.js"; -import type { WebAnalyticsFlagCardElement } from "./flag-card.element.js"; +import type { WebAnalyticsFlagTableElement } from "./flag-table.element.js"; +import type { WebAnalyticsFlagDialogElement } from "./flag-dialog.element.js"; import type { WebAnalyticsEventDetailsDialogElement } from "./event-details-dialog.element.js"; import "./analytics-summary.element.js"; import "./analytics-breakdown-grid.element.js"; @@ -75,7 +76,7 @@ describe("analytics presentation components", () => { element.route = { connection: "11111111-1111-1111-1111-111111111111", provider: "Vercel", - capabilities: { dimensions: ["RequestPath"], events: true, eventDetails: true, eventProperties: true, globalEventFiltering: false, flags: true, breakdownOrdering: false }, + capabilities: { dimensions: ["RequestPath"], events: true, eventDetails: true, eventProperties: true, globalEventFiltering: false, globalEventPropertyFiltering: true, flags: true, breakdownOrdering: false }, culture: "en-US", hostname: "example.com", path: "/products/example", @@ -200,17 +201,50 @@ describe("analytics presentation components", () => { const breakdown = element.shadowRoot?.querySelector("web-analytics-breakdown-table"); const events = element.shadowRoot?.querySelector("web-analytics-event-table"); - const flags = element.shadowRoot?.querySelector("web-analytics-flag-card"); + const flags = element.shadowRoot?.querySelector("web-analytics-flag-table"); await Promise.all([breakdown?.updateComplete, events?.updateComplete, flags?.updateComplete]); expect(breakdown?.loading).toBe(false); expect(breakdown?.shadowRoot?.querySelector(".row-label")?.textContent).toContain("Denmark"); expect(events?.loading).toBe(false); - expect(events?.shadowRoot?.querySelector(".details-action")?.textContent).toBe("Signup"); - expect(flags?.shadowRoot?.querySelector(".value")?.textContent).toBe("new-checkout"); + expect(events?.shadowRoot?.querySelector(".row-action")?.textContent).toBe("Signup"); + expect(flags?.shadowRoot?.querySelector(".row-action")?.textContent).toBe("new-checkout"); expect(element.shadowRoot?.querySelectorAll('[aria-busy="true"]')).toHaveLength(3); }); + it("shows drill-down filters on their event and flag card rows", async () => { + const element = document.createElement("web-analytics-breakdown-grid") as WebAnalyticsBreakdownGridElement; + element.cards = []; + element.events = successState({ rows: [ + { eventName: "Signup", visitors: 8, count: 9 }, + { eventName: "Demo requested", visitors: 5, count: 6 }, + ] }); + element.flags = successState({ rows: [ + { value: "new-pricing-page", visitors: 5, pageViews: 7 }, + { value: "checkout-redesign", visitors: 3, pageViews: 4 }, + ] }); + element.eventFilter = { + eventName: "Signup", + property: "plan", + value: "Pro", + }; + element.flagFilter = { flagKey: "new-pricing-page", value: "control" }; + document.body.append(element); + await element.updateComplete; + + const eventTable = element.shadowRoot?.querySelector("web-analytics-event-table"); + const flagTable = element.shadowRoot?.querySelector("web-analytics-flag-table"); + await Promise.all([eventTable?.updateComplete, flagTable?.updateComplete]); + + expect(eventTable?.shadowRoot?.querySelector(".drilldown-filter-label")?.textContent).toBe("plan: Pro"); + expect(flagTable?.shadowRoot?.querySelector(".drilldown-filter-label")?.textContent).toBe("control"); + expect(eventTable?.shadowRoot?.querySelector(".drilldown-filter")?.tagName).toBe("BUTTON"); + expect(flagTable?.shadowRoot?.querySelector(".drilldown-filter")?.tagName).toBe("BUTTON"); + expect(eventTable?.rows.map(({ eventName }) => eventName)).toEqual(["Signup"]); + expect(eventTable?.rows[0]).toMatchObject({ visitors: 8, count: 9 }); + expect(flagTable?.rows.map(({ value }) => value)).toEqual(["new-pricing-page"]); + }); + it("emits audience changes from the breakdown tabs", async () => { const element = document.createElement("web-analytics-breakdown-grid") as WebAnalyticsBreakdownGridElement; element.cards = dashboardCards(false, "unavailable").filter((card) => card.kind === "tabbed-breakdown" && card.id === "audience"); @@ -374,7 +408,7 @@ describe("analytics presentation components", () => { "Operating systems", ]); expect(cards[4]?.querySelector("web-analytics-event-table")).not.toBeNull(); - expect(cards[5]?.querySelector("web-analytics-flag-card")).not.toBeNull(); + expect(cards[5]?.querySelector("web-analytics-flag-table")).not.toBeNull(); }); it("keeps View all actions low priority while retaining emphasis for Retry", async () => { @@ -384,13 +418,18 @@ describe("analytics presentation components", () => { Country: successState({ dimension: "Country", rows: [{ value: "DK", visitors: 12, pageViews: 18 }] }), }; element.events = successState({ rows: [{ eventName: "Signup", visitors: 8, count: 9 }] }); - element.supportsFlags = false; + element.flags = successState({ rows: [{ value: "summer-sale", visitors: 5, pageViews: 7 }] }); document.body.append(element); await element.updateComplete; const viewAllActions = [...element.shadowRoot?.querySelectorAll("uui-button") ?? []] .filter((button) => button.textContent?.trim() === "View all"); - expect(viewAllActions).toHaveLength(2); + expect(viewAllActions).toHaveLength(3); + expect(viewAllActions.map((button) => button.getAttribute("label"))).toEqual([ + "View all Countries", + "View all events", + "View all flags", + ]); expect(viewAllActions.every((button) => button.getAttribute("look") === "default")).toBe(true); expect(viewAllActions.every((button) => button.classList.contains("view-all") && button.hasAttribute("compact"))).toBe(true); @@ -413,8 +452,8 @@ describe("analytics presentation components", () => { const featureGrid = element.shadowRoot?.querySelector(".feature-grid"); const cards = [...featureGrid?.querySelectorAll("uui-box") ?? []]; const flagsCard = cards[1]; - expect(flagsCard?.querySelector("web-analytics-flag-card")).not.toBeNull(); - expect(flagsCard?.classList.contains("flags-card")).toBe(true); + expect(flagsCard?.querySelector("web-analytics-flag-table")).not.toBeNull(); + expect(flagsCard?.querySelector('[label="View all flags"]')).toBeNull(); expect(cards[0]?.querySelector("web-analytics-event-table")).not.toBeNull(); }); @@ -430,7 +469,7 @@ describe("analytics presentation components", () => { const featureGrid = element.shadowRoot?.querySelector(".feature-grid"); const cards = [...featureGrid?.querySelectorAll("uui-box") ?? []]; expect(cards).toHaveLength(1); - expect(cards[0]?.querySelector("web-analytics-flag-card")).not.toBeNull(); + expect(cards[0]?.querySelector("web-analytics-flag-table")).not.toBeNull(); const styles = [...element.shadowRoot?.querySelectorAll("style") ?? []].map((style) => style.textContent).join("\n"); expect(styles).toContain(".feature-grid"); expect(styles).toContain("repeat(auto-fill"); @@ -572,33 +611,6 @@ describe("analytics presentation components", () => { expect(search?.getAttribute("placeholder")).toBe("Search title"); }); - it("drills from flag keys into their values and provides setup guidance when empty", async () => { - const element = document.createElement("web-analytics-flag-card") as WebAnalyticsFlagCardElement; - element.report = successState({ rows: [{ value: "summer-sale", visitors: 184, pageViews: 841 }] }); - const onSelect = vi.fn(); - element.addEventListener("select-flag", onSelect); - document.body.append(element); - await element.updateComplete; - - element.shadowRoot?.querySelector(".select")?.click(); - expect((onSelect.mock.calls[0][0] as CustomEvent).detail).toEqual({ flagKey: "summer-sale" }); - - element.selected = successState({ flagKey: "summer-sale", rows: [{ value: "true", visitors: 53, pageViews: 200 }] }); - await element.updateComplete; - expect(element.shadowRoot?.querySelector(".flag-back uui-icon")?.getAttribute("name")).toBe("icon-navigation-left"); - expect(element.shadowRoot?.querySelector(".selected-label")?.textContent).toBe("summer-sale"); - expect(element.shadowRoot?.querySelector(".row .value")?.textContent).toBe("true"); - expect(element.shadowRoot?.querySelector(".row .value")?.tagName).toBe("SPAN"); - - element.selected = undefined; - element.report = successState({ rows: [] }); - await element.updateComplete; - const setupLink = element.shadowRoot?.querySelector(".empty a"); - expect(element.shadowRoot?.querySelector(".empty-icon uui-icon")?.getAttribute("name")).toBe("icon-flag"); - expect(setupLink?.href).toBe("https://vercel.com/docs/flags/observability/web-analytics"); - expect(setupLink?.rel).toBe("noopener noreferrer"); - }); - it("wires a summary interaction through the mounted dashboard controller", async () => { const dashboard = document.createElement("web-analytics-dashboard") as WebAnalyticsDashboardElement; document.body.append(dashboard); @@ -644,16 +656,54 @@ describe("analytics presentation components", () => { expect(dashboard.shadowRoot?.querySelector("web-analytics-event-details-dialog")).toBeNull(); }); + it("keeps Flags in a dialog while drilling from keys into values", async () => { + Object.defineProperty(HTMLDialogElement.prototype, "showModal", { configurable: true, value: vi.fn() }); + sdk.flags + .mockResolvedValueOnce(apiOk({ rows: [{ value: "summer-sale", visitors: 184, pageViews: 841 }] })) + .mockResolvedValueOnce(apiOk({ rows: [ + { value: "summer-sale", visitors: 184, pageViews: 841 }, + { value: "new-checkout", visitors: 92, pageViews: 410 }, + ] })) + .mockResolvedValueOnce(apiOk({ flagKey: "summer-sale", rows: [{ value: "true", visitors: 53, pageViews: 200 }] })); + const dashboard = document.createElement("web-analytics-dashboard") as WebAnalyticsDashboardElement; + document.body.append(dashboard); + await vi.waitFor(() => expect(dashboard.shadowRoot?.querySelector("web-analytics-breakdown-grid")?.flags.status).toBe("success")); + + dashboard.shadowRoot?.querySelector("web-analytics-breakdown-grid")?.dispatchEvent(new CustomEvent("view-flags", { bubbles: true, composed: true })); + await vi.waitFor(() => expect(dashboard.shadowRoot?.querySelector("web-analytics-flag-dialog")).not.toBeNull()); + const flagsDialog = dashboard.shadowRoot?.querySelector("web-analytics-flag-dialog"); + flagsDialog?.dispatchEvent(new CustomEvent("select-flag", { + bubbles: true, + composed: true, + detail: { flagKey: "summer-sale" }, + })); + + await vi.waitFor(() => { + const dialog = dashboard.shadowRoot?.querySelector("web-analytics-flag-dialog"); + expect(dialog?.selected?.flagKey).toBe("summer-sale"); + }); + expect(dashboard.shadowRoot?.querySelectorAll("web-analytics-flag-dialog")).toHaveLength(1); + + dashboard.shadowRoot?.querySelector("web-analytics-flag-dialog")?.dispatchEvent(new CustomEvent("clear-selected-flag", { bubbles: true, composed: true })); + await vi.waitFor(() => { + const dialog = dashboard.shadowRoot?.querySelector("web-analytics-flag-dialog"); + expect(dialog?.selected).toBeUndefined(); + }); + expect(dashboard.shadowRoot?.querySelectorAll("web-analytics-flag-dialog")).toHaveLength(1); + }); + it("clears every active filter from the mounted dashboard and URL", async () => { - window.history.replaceState({}, "", "/umbraco/section/analytics?filter=RequestPath%3A%2F&filter=Country%3ADK"); + window.history.replaceState({}, "", "/umbraco/section/analytics?filter=RequestPath%3A%2F&filter=Country%3ADK&filterFlagKey=new-pricing-page&filterFlagValue=control"); const dashboard = document.createElement("web-analytics-dashboard") as WebAnalyticsDashboardElement; document.body.append(dashboard); - await vi.waitFor(() => expect(dashboard.shadowRoot?.querySelectorAll(".filter-badge")).toHaveLength(2)); + await vi.waitFor(() => expect(dashboard.shadowRoot?.querySelectorAll(".filter-badge")).toHaveLength(3)); dashboard.shadowRoot?.querySelector('[label="Clear all analytics filters"]')?.click(); await vi.waitFor(() => expect(dashboard.shadowRoot?.querySelector(".active-filters")).toBeNull()); expect(new URL(window.location.href).searchParams.has("filter")).toBe(false); + expect(new URL(window.location.href).searchParams.has("filterFlagKey")).toBe(false); + expect(new URL(window.location.href).searchParams.has("filterFlagValue")).toBe(false); }); it("renders relevant identity icons for active filters", async () => { diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dashboard.controller.test.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dashboard.controller.test.ts index 1b2c42c..fb77400 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dashboard.controller.test.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dashboard.controller.test.ts @@ -11,6 +11,7 @@ const fullCapabilities: AnalyticsCapabilities = { eventDetails: true, eventProperties: true, globalEventFiltering: false, + globalEventPropertyFiltering: true, flags: true, breakdownOrdering: false, }; @@ -26,7 +27,8 @@ describe("AnalyticsDashboardController", () => { { key: "22222222-2222-2222-2222-222222222222", displayName: "Second", provider: "Vercel", capabilities: fullCapabilities, isDefault: false, isConfigured: true, baseUrl: "https://second.example.com", warnings: [] }, ], })); - const controller = new AnalyticsDashboardController(vi.fn(), api, environment()); + const dashboardEnvironment = environment(); + const controller = new AnalyticsDashboardController(vi.fn(), api, dashboardEnvironment); controller.connect(); @@ -228,13 +230,13 @@ describe("AnalyticsDashboardController", () => { const pending = deferred>>(); api.eventDetails.mockReturnValueOnce(pending.promise); - const selecting = controller.selectEvent("Signup"); - expect(controller.state.selectedEvent?.details.status).toBe("loading"); - controller.closeEventDetails(); + const selecting = controller.features.selectEvent("Signup"); + expect(controller.features.state.selectedEvent?.details.status).toBe("loading"); + controller.features.closeEventDetails(); pending.resolve(ok({ eventName: "Signup", totals: { count: 1, visitors: 1 }, properties: [] })); await selecting; - expect(controller.state.selectedEvent).toBeUndefined(); + expect(controller.features.state.selectedEvent).toBeUndefined(); }); it("keeps the Events list available while viewing event details", async () => { @@ -242,15 +244,15 @@ describe("AnalyticsDashboardController", () => { const controller = new AnalyticsDashboardController(vi.fn(), api, environment()); controller.connect(); await vi.waitFor(() => expect(controller.state.summary.status).toBe("success")); - await controller.openEvents(); + await controller.features.openEvents(); - await controller.selectEvent("Signup"); + await controller.features.selectEvent("Signup"); - expect(controller.state.expandedEvents?.status).toBe("success"); - expect(controller.state.selectedEvent?.eventName).toBe("Signup"); - controller.closeEventDetails(); - expect(controller.state.expandedEvents?.status).toBe("success"); - expect(controller.state.selectedEvent).toBeUndefined(); + expect(controller.features.state.expandedEvents?.status).toBe("success"); + expect(controller.features.state.selectedEvent?.eventName).toBe("Signup"); + controller.features.closeEventDetails(); + expect(controller.features.state.expandedEvents?.status).toBe("success"); + expect(controller.features.state.selectedEvent).toBeUndefined(); }); it("opens all events when backing out of details opened from the dashboard", async () => { @@ -258,13 +260,54 @@ describe("AnalyticsDashboardController", () => { const controller = new AnalyticsDashboardController(vi.fn(), api, environment()); controller.connect(); await vi.waitFor(() => expect(controller.state.summary.status).toBe("success")); - await controller.selectEvent("Signup"); + await controller.features.selectEvent("Signup"); + + expect(controller.features.state.expandedEvents).toBeUndefined(); + await controller.features.backToEvents(); + + expect(controller.features.state.selectedEvent).toBeUndefined(); + expect(controller.features.state.expandedEvents?.status).toBe("success"); + }); + + it("keeps the Flags list available while viewing a flag and restores it on Back", async () => { + const api = dashboardApi(); + api.flags + .mockResolvedValueOnce(ok({ rows: [] })) + .mockResolvedValueOnce(ok({ rows: [{ value: "summer-sale", visitors: 184, pageViews: 841 }] })) + .mockResolvedValueOnce(ok({ flagKey: "summer-sale", rows: [{ value: "true", visitors: 53, pageViews: 200 }] })); + const controller = new AnalyticsDashboardController(vi.fn(), api, environment()); + controller.connect(); + await vi.waitFor(() => expect(controller.state.summary.status).toBe("success")); - expect(controller.state.expandedEvents).toBeUndefined(); - await controller.backToEvents(); + await controller.features.openFlags(); + await controller.features.selectFlag("summer-sale"); - expect(controller.state.selectedEvent).toBeUndefined(); - expect(controller.state.expandedEvents?.status).toBe("success"); + expect(controller.features.state.expandedFlags?.status).toBe("success"); + expect(controller.features.state.selectedFlag?.flagKey).toBe("summer-sale"); + expect(stateData(controller.features.state.selectedFlag!.report)?.flagKey).toBe("summer-sale"); + await controller.features.backToFlags(); + expect(controller.features.state.expandedFlags?.status).toBe("success"); + expect(controller.features.state.selectedFlag).toBeUndefined(); + }); + + it("keeps the Flags list request alive while flag details load", async () => { + const api = dashboardApi(); + const controller = new AnalyticsDashboardController(vi.fn(), api, environment()); + controller.connect(); + await vi.waitFor(() => expect(controller.state.summary.status).toBe("success")); + const list = deferred }>>>(); + api.flags + .mockReturnValueOnce(list.promise) + .mockResolvedValueOnce(ok({ flagKey: "summer-sale", rows: [{ value: "true", visitors: 53, pageViews: 200 }] })); + + const opening = controller.features.openFlags(); + await vi.waitFor(() => expect(controller.features.state.expandedFlags?.status).toBe("loading")); + await controller.features.selectFlag("summer-sale"); + list.resolve(ok({ rows: [{ value: "summer-sale", visitors: 184, pageViews: 841 }] })); + await opening; + + expect(controller.features.state.expandedFlags?.status).toBe("success"); + expect(controller.features.state.selectedFlag?.report.status).toBe("success"); }); it("reuses property values returned with event details", async () => { @@ -278,10 +321,136 @@ describe("AnalyticsDashboardController", () => { controller.connect(); await vi.waitFor(() => expect(controller.state.summary.status).toBe("success")); - await controller.selectEvent("Signup"); + await controller.features.selectEvent("Signup"); expect(api.eventPropertyValues).not.toHaveBeenCalled(); - expect(controller.state.selectedEvent?.details.status).toBe("success"); + expect(controller.features.state.selectedEvent?.details.status).toBe("success"); + }); + + it("keeps an event property drill-down on its card after the dialog closes", async () => { + const api = dashboardApi(); + api.eventDetails.mockResolvedValue(ok({ + eventName: "Signup", + totals: { count: 20, visitors: 10 }, + properties: [{ name: "plan", values: [{ value: "Pro", count: 20, visitors: 10 }] }], + })); + const dashboardEnvironment = environment(); + const controller = new AnalyticsDashboardController(vi.fn(), api, dashboardEnvironment); + controller.connect(); + await vi.waitFor(() => expect(controller.state.summary.status).toBe("success")); + await controller.features.selectEvent("Signup"); + + controller.features.applyEventFilter("plan", "Pro"); + + expect(controller.features.state.selectedEvent).toBeUndefined(); + expect(controller.state.eventFilter).toEqual({ + eventName: "Signup", + property: "plan", + value: "Pro", + }); + expect(api.summary.mock.calls[api.summary.mock.calls.length - 1]?.[0]?.query).toMatchObject({ + filterEventName: "Signup", + filterEventProperty: "plan", + filterEventValue: "Pro", + }); + expect(dashboardEnvironment.currentUrl().searchParams.get("filterEventName")).toBe("Signup"); + expect(dashboardEnvironment.currentUrl().searchParams.get("filterEventProperty")).toBe("plan"); + expect(dashboardEnvironment.currentUrl().searchParams.get("filterEventValue")).toBe("Pro"); + + await controller.features.selectEvent("Signup"); + expect(api.eventDetails.mock.calls[api.eventDetails.mock.calls.length - 1]?.[0]?.query).toMatchObject({ + eventName: "Signup", + eventProperty: "plan", + eventValue: "Pro", + }); + + controller.clearEventFilter(); + expect(controller.state.eventFilter).toBeUndefined(); + expect(dashboardEnvironment.currentUrl().searchParams.has("filterEventName")).toBe(false); + }); + + it("restores a URL-backed event filter into the first report request", async () => { + const api = dashboardApi(); + const dashboardEnvironment = environment( + "https://cms.example.com/umbraco/section/analytics?filterEventName=Signup&filterEventProperty=plan&filterEventValue=Pro", + ); + const controller = new AnalyticsDashboardController(vi.fn(), api, dashboardEnvironment); + + controller.connect(); + + await vi.waitFor(() => expect(controller.state.summary.status).toBe("success")); + expect(controller.state.eventFilter).toEqual({ eventName: "Signup", property: "plan", value: "Pro" }); + expect(api.summary.mock.calls[0]?.[0]?.query).toMatchObject({ + filterEventName: "Signup", + filterEventProperty: "plan", + filterEventValue: "Pro", + }); + expect(dashboardEnvironment.currentUrl().searchParams.get("filterEventValue")).toBe("Pro"); + }); + + it("removes a restored event filter when the selected provider cannot apply it globally", async () => { + const api = dashboardApi(); + api.connections.mockResolvedValue(ok({ + enabled: true, + defaultRangeDays: 30, + connections: [{ + key: "11111111-1111-1111-1111-111111111111", + displayName: "Vercel", + provider: "Vercel", + capabilities: { ...fullCapabilities, globalEventPropertyFiltering: false }, + isDefault: true, + isConfigured: true, + baseUrl: "https://example.com", + warnings: [], + }], + })); + const dashboardEnvironment = environment( + "https://cms.example.com/umbraco/section/analytics?filterEventName=Signup&filterEventProperty=plan&filterEventValue=Pro", + ); + const controller = new AnalyticsDashboardController(vi.fn(), api, dashboardEnvironment); + + controller.connect(); + + await vi.waitFor(() => expect(controller.state.summary.status).toBe("success")); + expect(controller.state.eventFilter).toBeUndefined(); + expect(api.summary.mock.calls[0]?.[0]?.query?.filterEventName).toBeUndefined(); + expect(dashboardEnvironment.currentUrl().searchParams.has("filterEventName")).toBe(false); + }); + + it("preserves the URL-backed event filter when the report scope changes", async () => { + const api = dashboardApi(); + api.eventDetails.mockResolvedValue(ok({ + eventName: "Signup", + totals: { count: 20, visitors: 10 }, + properties: [{ name: "plan", values: [{ value: "Pro", count: 20, visitors: 10 }] }], + })); + const controller = new AnalyticsDashboardController(vi.fn(), api, environment()); + controller.connect(); + await vi.waitFor(() => expect(controller.state.summary.status).toBe("success")); + await controller.features.selectEvent("Signup"); + controller.features.applyEventFilter("plan", "Pro"); + + controller.setDateRange(7, dateRangeForPreset(7)); + + expect(controller.state.eventFilter).toEqual({ eventName: "Signup", property: "plan", value: "Pro" }); + }); + + it("preserves the URL-backed event filter when reports are retried", async () => { + const api = dashboardApi(); + api.eventDetails.mockResolvedValue(ok({ + eventName: "Signup", + totals: { count: 20, visitors: 10 }, + properties: [{ name: "plan", values: [{ value: "Pro", count: 20, visitors: 10 }] }], + })); + const controller = new AnalyticsDashboardController(vi.fn(), api, environment()); + controller.connect(); + await vi.waitFor(() => expect(controller.state.summary.status).toBe("success")); + await controller.features.selectEvent("Signup"); + controller.features.applyEventFilter("plan", "Pro"); + + controller.retryReports(); + + expect(controller.state.eventFilter).toEqual({ eventName: "Signup", property: "plan", value: "Pro" }); }); it("keeps cached property values visible while refreshing a previously viewed tab", async () => { @@ -300,17 +469,17 @@ describe("AnalyticsDashboardController", () => { controller.connect(); await vi.waitFor(() => expect(controller.state.summary.status).toBe("success")); - await controller.selectEvent("Signup"); - await vi.waitFor(() => expect(stateData(controller.state.selectedEvent!.property)?.name).toBe("plan")); + await controller.features.selectEvent("Signup"); + await vi.waitFor(() => expect(stateData(controller.features.state.selectedEvent!.property)?.name).toBe("plan")); - controller.searchEventProperty("source", ""); - expect(isInitialLoading(controller.state.selectedEvent?.property)).toBe(true); + controller.features.searchEventProperty("source", ""); + expect(isInitialLoading(controller.features.state.selectedEvent?.property)).toBe(true); sourceValues.resolve(ok({ name: "source", values: [{ value: "Newsletter", count: 9, visitors: 8 }] })); - await vi.waitFor(() => expect(stateData(controller.state.selectedEvent!.property)?.name).toBe("source")); + await vi.waitFor(() => expect(stateData(controller.features.state.selectedEvent!.property)?.name).toBe("source")); - controller.searchEventProperty("plan", ""); - expect(isInitialLoading(controller.state.selectedEvent?.property)).toBe(false); - expect(stateData(controller.state.selectedEvent!.property)?.values).toEqual([{ value: "Pro", count: 20, visitors: 10 }]); + controller.features.searchEventProperty("plan", ""); + expect(isInitialLoading(controller.features.state.selectedEvent?.property)).toBe(false); + expect(stateData(controller.features.state.selectedEvent!.property)?.values).toEqual([{ value: "Pro", count: 20, visitors: 10 }]); }); it("loads event details without fetching property values when the capability is unavailable", async () => { @@ -338,12 +507,12 @@ describe("AnalyticsDashboardController", () => { controller.connect(); await vi.waitFor(() => expect(controller.state.summary.status).toBe("success")); - await controller.selectEvent("Signup"); - controller.searchEventProperty("plan", "Pro"); + await controller.features.selectEvent("Signup"); + controller.features.searchEventProperty("plan", "Pro"); expect(api.eventDetails).toHaveBeenCalledOnce(); expect(api.eventPropertyValues).not.toHaveBeenCalled(); - expect(controller.state.selectedEvent?.details.status).toBe("success"); + expect(controller.features.state.selectedEvent?.details.status).toBe("success"); }); it("atomically clears document and dialog state when scope changes", async () => { @@ -586,6 +755,7 @@ describe("AnalyticsDashboardController", () => { eventDetails: true, eventProperties: true, globalEventFiltering: true, + globalEventPropertyFiltering: true, flags: false, breakdownOrdering: true, }, @@ -603,7 +773,7 @@ describe("AnalyticsDashboardController", () => { controller.toggleFilter("EventName", "Signup"); await vi.waitFor(() => expect(api.summary).toHaveBeenCalled()); expect(api.summary.mock.calls[0]?.[0]?.query?.filter).toEqual(["EventName:Signup"]); - await controller.selectEvent("Signup"); + await controller.features.selectEvent("Signup"); expect(api.events).toHaveBeenCalled(); expect(api.flags).not.toHaveBeenCalled(); @@ -660,6 +830,68 @@ describe("AnalyticsDashboardController", () => { expect(api.summary).not.toHaveBeenCalled(); }); + it("applies and removes a Vercel flag-value filter across dashboard reports", async () => { + const api = dashboardApi(); + const dashboardEnvironment = environment(); + const controller = new AnalyticsDashboardController(vi.fn(), api, dashboardEnvironment); + controller.connect(); + await vi.waitFor(() => expect(controller.state.summary.status).toBe("success")); + api.summary.mockClear(); + + controller.toggleFlagFilter("new-pricing-page", "control"); + + await vi.waitFor(() => expect(api.summary).toHaveBeenCalled()); + expect(controller.state.flagFilter).toEqual({ flagKey: "new-pricing-page", value: "control" }); + expect(api.summary.mock.calls[0]?.[0]?.query).toMatchObject({ + filterFlagKey: "new-pricing-page", + filterFlagValue: "control", + }); + expect(dashboardEnvironment.currentUrl().searchParams.get("filterFlagKey")).toBe("new-pricing-page"); + expect(dashboardEnvironment.currentUrl().searchParams.get("filterFlagValue")).toBe("control"); + + controller.toggleFlagFilter("new-pricing-page", "control"); + expect(controller.state.flagFilter).toBeUndefined(); + expect(dashboardEnvironment.currentUrl().searchParams.has("filterFlagKey")).toBe(false); + expect(dashboardEnvironment.currentUrl().searchParams.has("filterFlagValue")).toBe(false); + }); + + it("applies an empty Vercel flag value", async () => { + const api = dashboardApi(); + const dashboardEnvironment = environment(); + const controller = new AnalyticsDashboardController(vi.fn(), api, dashboardEnvironment); + controller.connect(); + await vi.waitFor(() => expect(controller.state.summary.status).toBe("success")); + api.summary.mockClear(); + + controller.toggleFlagFilter("new-pricing-page", ""); + + await vi.waitFor(() => expect(api.summary).toHaveBeenCalled()); + expect(controller.state.flagFilter).toEqual({ flagKey: "new-pricing-page", value: "" }); + expect(api.summary.mock.calls[0]?.[0]?.query).toMatchObject({ + filterFlagKey: "new-pricing-page", + filterFlagValue: "", + }); + expect(dashboardEnvironment.currentUrl().searchParams.has("filterFlagValue")).toBe(true); + }); + + it("closes the selected flag dialog when applying its filter", 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.features.openFlags(); + await controller.features.selectFlag("new-pricing-page"); + + controller.toggleFlagFilter("new-pricing-page", "control"); + + expect(controller.features.state.expandedFlags).toBeUndefined(); + expect(controller.features.state.selectedFlag).toBeUndefined(); + expect(controller.state.flagFilter).toEqual({ + flagKey: "new-pricing-page", + value: "control", + }); + }); + it("removes unsupported filters when switching providers", async () => { const api = dashboardApi(); const plausibleCapabilities: AnalyticsCapabilities = { @@ -670,6 +902,7 @@ describe("AnalyticsDashboardController", () => { eventDetails: true, eventProperties: true, globalEventFiltering: true, + globalEventPropertyFiltering: true, flags: false, breakdownOrdering: true, }; diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dashboard.controller.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dashboard.controller.ts index 51f9a9c..4c73b65 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dashboard.controller.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dashboard.controller.ts @@ -4,9 +4,6 @@ import type { AnalyticsConnectionSummary, AnalyticsDimension, AnalyticsDocumentRoute, - AnalyticsEventDetails, - AnalyticsEventProperty, - AnalyticsEventRow, AnalyticsEventsReport, AnalyticsFlagsReport, AnalyticsProvider, @@ -21,13 +18,15 @@ import { parseDashboardUrlState, serializeFilter, writeDashboardUrlState, + type AnalyticsEventFilter, type AnalyticsFilter, + type AnalyticsFlagFilter, type AudienceDimension, type DashboardMetric, type UtmDimension, } from "./dashboard-url-state.js"; import { loadDashboardBreakdown, loadDashboardBreakdowns, loadDashboardReports, type DashboardReportQuery, type DashboardReportUpdate } from "./dashboard-report-loader.js"; -import { visibleEventRows } from "./event-rows.js"; +import { AnalyticsFeatureDrilldownController } from "./analytics-feature-drilldown.controller.js"; import { reportErrorMessage } from "./report-error.js"; import { DebouncedRequest, RequestCoordinator, type RequestResult } from "./request-coordinator.js"; import { detectUtmCapability, type UtmCapability } from "./utm-capability.js"; @@ -35,6 +34,7 @@ import { errorState, idleState, loadingState, successState, type AsyncState } fr import { normalizeDashboardSelection, supportsDimension, unavailableCapabilities } from "./dashboard-capabilities.js"; type ReportScope = { documentId?: string; culture?: string; path?: string }; +type ReportFilterQuery = Pick; export type ExpandedBreakdown = { dimension: AnalyticsDimension; headline: string; @@ -42,16 +42,6 @@ export type ExpandedBreakdown = { report: AsyncState; cache: Readonly>; }; -export type SelectedEvent = { - eventName: string; - details: AsyncState; - eventProperty?: string; - eventValue?: string; - propertyName?: string; - propertySearch?: string; - property: AsyncState; - propertyCache: Readonly>; -}; export type DashboardState = { connections: AnalyticsConnectionSummary[]; connection?: string; @@ -64,18 +54,17 @@ export type DashboardState = { breakdowns: Partial>>; events: AsyncState; flags: AsyncState; - selectedFlag?: AsyncState; metric: DashboardMetric; audienceDimension: AudienceDimension; acquisitionView: AcquisitionView; utmDimension: UtmDimension; filters: AnalyticsFilter[]; + flagFilter?: AnalyticsFlagFilter; + eventFilter?: AnalyticsEventFilter; configurationError?: string; setupRequired?: boolean; utmCapability: UtmCapability; expandedBreakdown?: ExpandedBreakdown; - expandedEvents?: AsyncState; - selectedEvent?: SelectedEvent; }; export type DashboardEnvironment = { @@ -95,6 +84,7 @@ const defaultEnvironment = (): DashboardEnvironment => ({ }); export class AnalyticsDashboardController { + readonly features: AnalyticsFeatureDrilldownController; state: DashboardState = { connections: [], range: dateRangeForPreset(30), @@ -118,10 +108,6 @@ export class AnalyticsDashboardController { readonly #reportRequest = new RequestCoordinator(); readonly #utmRequest = new RequestCoordinator(); readonly #expandedRequest = new DebouncedRequest(); - readonly #eventSearchRequest = new DebouncedRequest(); - readonly #eventDetailsRequest = new RequestCoordinator(); - readonly #flagRequest = new RequestCoordinator(); - readonly #eventPropertyRequest = new DebouncedRequest(); readonly #utmCapabilityByConnection = new Map(); #documentId?: string; #culture?: string; @@ -133,6 +119,15 @@ export class AnalyticsDashboardController { this.#notify = notify; this.#api = api; this.#environment = environment; + this.features = new AnalyticsFeatureDrilldownController(notify, api, () => { + const connection = this.state.connection; + return { + capabilities: this.#capabilities(), + visitQuery: connection ? this.#reportQuery(connection, this.#visitFilterQuery()) : undefined, + eventListQuery: connection ? this.#reportQuery(connection, this.#eventListFilterQuery()) : undefined, + eventFilter: this.state.eventFilter, + }; + }, (eventFilter) => this.#changeReportScope({ eventFilter })); } connect(documentId?: string, culture?: string): void { @@ -149,14 +144,7 @@ export class AnalyticsDashboardController { this.#scopeKey = key; this.#documentId = documentId; this.#culture = culture; - this.#initializationRequest.cancel(); - this.#reportRequest.cancel(); - this.#utmRequest.cancel(); - this.#expandedRequest.cancel(); - this.#eventSearchRequest.cancel(); - this.#eventDetailsRequest.cancel(); - this.#flagRequest.cancel(); - this.#eventPropertyRequest.cancel(); + this.#cancelRequests(); this.#set({ route: undefined, provider: undefined, @@ -165,25 +153,24 @@ export class AnalyticsDashboardController { breakdowns: {}, events: loadingState(), flags: loadingState(), - selectedFlag: undefined, acquisitionView: "referrers", utmCapability: "unknown", expandedBreakdown: undefined, - expandedEvents: undefined, - selectedEvent: undefined, }); + this.features.closeAll(); void this.#initialize(); } disconnect(): void { + this.#cancelRequests(); + this.features.disconnect(); + } + + #cancelRequests(): void { this.#initializationRequest.cancel(); this.#reportRequest.cancel(); this.#utmRequest.cancel(); this.#expandedRequest.cancel(); - this.#eventSearchRequest.cancel(); - this.#eventDetailsRequest.cancel(); - this.#flagRequest.cancel(); - this.#eventPropertyRequest.cancel(); } cards(): ReadonlyArray { @@ -205,7 +192,6 @@ export class AnalyticsDashboardController { 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(), @@ -216,7 +202,6 @@ export class AnalyticsDashboardController { }); return; } - this.#closeDialogs(); this.#utmRequest.cancel(); const capabilities = this.#capabilities(); const supportsUtm = capabilities.dimensions.includes("UtmSource"); @@ -265,7 +250,7 @@ export class AnalyticsDashboardController { const selectedConnection = this.state.connections.find(({ key }) => key === connection); const capabilities = selectedConnection?.capabilities ?? unavailableCapabilities; const selection = normalizeDashboardSelection(this.state, capabilities); - this.#set({ + this.#changeReportScope({ connection, provider: selectedConnection?.provider, capabilities, @@ -276,14 +261,10 @@ export class AnalyticsDashboardController { events: loadingState(), flags: loadingState(), }); - this.#syncUrlState(); - void this.loadReports(); } setDateRange(preset: DatePreset, range: AnalyticsDateRange): void { - this.#set({ preset, range }); - this.#syncUrlState(); - void this.loadReports(); + this.#changeReportScope({ preset, range }); } setMetric(metric: DashboardMetric): void { @@ -321,18 +302,34 @@ export class AnalyticsDashboardController { const filters = active ? this.state.filters.filter((filter) => filter.dimension !== dimension) : [...this.state.filters.filter((filter) => filter.dimension !== dimension), { dimension, value }]; - this.#set({ filters }); - this.#syncUrlState(); - void this.loadReports(); + this.#changeReportScope({ filters }); + } + + toggleFlagFilter(flagKey: string, value: string): void { + if (!this.#capabilities().flags || !flagKey) return; + const active = this.state.flagFilter?.flagKey === flagKey && this.state.flagFilter.value === value; + this.#changeReportScope({ flagFilter: active ? undefined : { flagKey, value } }); + } + + clearEventFilter(): void { + this.#changeReportScope({ eventFilter: undefined }); } removeFilter(dimension: AnalyticsDimension): void { - this.#set({ filters: this.state.filters.filter((filter) => filter.dimension !== dimension) }); - this.#syncUrlState(); - void this.loadReports(); + this.#changeReportScope({ filters: this.state.filters.filter((filter) => filter.dimension !== dimension) }); + } + + removeFlagFilter(): void { + this.#changeReportScope({ flagFilter: undefined }); } - clearFilters(): void { this.#set({ filters: [] }); this.#syncUrlState(); void this.loadReports(); } + clearFilters(): void { + this.#changeReportScope({ filters: [], flagFilter: undefined, eventFilter: undefined }); + } + + retryReports(): void { + this.#changeReportScope({}); + } async openBreakdown( dimension: AnalyticsDimension, @@ -383,84 +380,19 @@ export class AnalyticsDashboardController { closeBreakdown(): void { this.#expandedRequest.cancel(); this.#set({ expandedBreakdown: undefined }); } - async openEvents(search = "", debounce = false): Promise { - if (!this.#capabilities().events) return; - const connection = this.state.connection; - if (!connection) return; - const previous = this.state.expandedEvents; - this.#set({ expandedEvents: loadingState(previous) }); - const run = (signal: AbortSignal) => this.#api.events({ - query: { ...this.#reportQuery(connection, this.#eventListFilterQuery()), limit: 100, search: search || undefined }, signal, - }); - const result = await (debounce ? this.#eventSearchRequest.schedule(run) : this.#eventSearchRequest.run(run)); - if (result.status === "cancelled" || result.status === "stale" || !this.state.expandedEvents) return; - if (result.status === "error") { this.#set({ expandedEvents: errorState(reportErrorMessage(result.error), previous) }); return; } - const { data, error, response } = result.value; - this.#set({ expandedEvents: error - ? errorState(apiErrorMessage(error, response?.status ?? 0), previous) - : successState(visibleEventRows(data?.rows ?? [])) }); - } - - closeEvents(): void { this.#eventSearchRequest.cancel(); this.#set({ expandedEvents: undefined }); } - - async selectFlag(flagKey: string): Promise { - if (!this.#capabilities().flags) return; - const connection = this.state.connection; - if (!connection) return; - const previous = this.state.selectedFlag; - this.#set({ selectedFlag: loadingState(previous) }); - const result = await this.#flagRequest.run((signal) => this.#api.flags({ - query: { ...this.#reportQuery(connection, this.#visitFilterQuery()), flagKey, limit: 100 }, signal, - })); - if (result.status === "cancelled" || result.status === "stale") return; - if (result.status === "error") { this.#set({ selectedFlag: errorState(reportErrorMessage(result.error), previous) }); return; } - const { data, error, response } = result.value; - this.#set({ selectedFlag: error || !data - ? errorState(apiErrorMessage(error, response?.status ?? 0), previous) - : successState(data) }); - } - - clearSelectedFlag(): void { this.#flagRequest.cancel(); this.#set({ selectedFlag: undefined }); } - - async selectEvent(eventName: string): Promise { - if (!this.#capabilities().eventDetails) return; - await this.#loadEventDetails(eventName); - } - - toggleEventPropertyFilter(property: string, value: string): void { - const selected = this.state.selectedEvent; - if (!selected || !this.#capabilities().eventProperties) return; - const active = selected.eventProperty === property && selected.eventValue === value; - void this.#loadEventDetails(selected.eventName, active ? undefined : property, active ? undefined : value); - } - - searchEventProperty(propertyName: string, search: string): void { - if (!this.state.selectedEvent || !this.#capabilities().eventProperties) return; - const normalizedSearch = search.trim(); - void this.#loadEventPropertyValues(propertyName, normalizedSearch, normalizedSearch.length > 0); - } - - closeEventDetails(): void { - this.#eventPropertyRequest.cancel(); - this.#eventDetailsRequest.cancel(); - this.#set({ selectedEvent: undefined }); - } - - async backToEvents(): Promise { - const eventsAreOpen = this.state.expandedEvents !== undefined; - this.closeEventDetails(); - if (!eventsAreOpen) await this.openEvents(); - } + #set(patch: Partial): void { this.state = { ...this.state, ...patch }; this.#notify(); } - closeEventFlow(): void { - this.#eventPropertyRequest.cancel(); - this.#eventDetailsRequest.cancel(); - this.#eventSearchRequest.cancel(); - this.#set({ selectedEvent: undefined, expandedEvents: undefined }); + #changeReportScope(patch: Partial): void { + this.#expandedRequest.cancel(); + this.features.closeDialogs(); + this.#set({ + ...patch, + expandedBreakdown: undefined, + }); + this.#syncUrlState(); + void this.loadReports(); } - #set(patch: Partial): void { this.state = { ...this.state, ...patch }; this.#notify(); } - async #initialize(): Promise { this.#set({ configurationError: undefined, setupRequired: false }); const initialized = this.#documentId @@ -548,7 +480,9 @@ export class AnalyticsDashboardController { async #loadBreakdowns(): Promise { const connection = this.state.connection; if (!connection) return; - this.#closeDialogs(); + this.#expandedRequest.cancel(); + this.features.closeDialogs(); + this.#set({ expandedBreakdown: undefined }); this.#utmRequest.cancel(); const { dimensions } = this.#dashboardReportPlan(); this.#set({ @@ -638,71 +572,9 @@ export class AnalyticsDashboardController { this.#applyReportUpdate(result.value.update); } - async #loadEventDetails(eventName: string, eventProperty?: string, eventValue?: string): Promise { - const connection = this.state.connection; - if (!connection) return; - this.#eventPropertyRequest.cancel(); - const previous = this.state.selectedEvent?.eventName === eventName ? this.state.selectedEvent.details : undefined; - this.#set({ selectedEvent: { eventName, eventProperty, eventValue, details: loadingState(previous), property: idleState(), propertyCache: {} } }); - const result = await this.#eventDetailsRequest.run((signal) => this.#api.eventDetails({ - query: { ...this.#reportQuery(connection, this.#visitFilterQuery()), eventName, eventProperty, eventValue }, signal, - })); - if (result.status === "cancelled" || result.status === "stale" || this.state.selectedEvent?.eventName !== eventName) return; - if (result.status === "error") { - this.#set({ selectedEvent: { ...this.state.selectedEvent, details: errorState(reportErrorMessage(result.error), previous) } }); - return; - } - const { data, error, response } = result.value; - if (error || !data) { - this.#set({ selectedEvent: { ...this.state.selectedEvent, details: errorState(apiErrorMessage(error, response?.status ?? 0), previous) } }); - return; - } - const propertyCache = Object.fromEntries(data.properties - .filter((property) => property.values.length > 0) - .map((property) => [eventPropertyCacheKey(property.name, ""), property])); - this.#set({ selectedEvent: { ...this.state.selectedEvent, details: successState(data), propertyCache } }); - const firstProperty = data.properties[0]; - if (this.#capabilities().eventProperties && firstProperty && !firstProperty.values.length) { - void this.#loadEventPropertyValues(firstProperty.name, ""); - } - } - - async #loadEventPropertyValues(propertyName: string, search: string, debounce = false): Promise { - const connection = this.state.connection; - const selected = this.state.selectedEvent; - if (!connection || !selected || !this.#capabilities().eventProperties) return; - const cacheKey = eventPropertyCacheKey(propertyName, search); - const cached = selected.propertyCache[cacheKey]; - const property = cached ? loadingState(successState(cached)) : loadingState(); - this.#set({ selectedEvent: { ...selected, propertyName, propertySearch: search, property } }); - const run = (signal: AbortSignal) => this.#api.eventPropertyValues({ - query: { - ...this.#reportQuery(connection, this.#visitFilterQuery()), - eventName: selected.eventName, - propertyName, - limit: 100, - search, - eventProperty: selected.eventProperty, - eventValue: selected.eventValue, - }, - signal, - }); - const result = await (debounce ? this.#eventPropertyRequest.schedule(run) : this.#eventPropertyRequest.run(run)); - const current = this.state.selectedEvent; - if (result.status === "cancelled" || result.status === "stale" || current?.eventName !== selected.eventName || current.propertyName !== propertyName) return; - if (result.status === "error") { - this.#set({ selectedEvent: { ...current, property: errorState(reportErrorMessage(result.error), property) } }); - return; - } - const { data, error, response } = result.value; - this.#set({ selectedEvent: { ...current, property: error || !data - ? errorState(apiErrorMessage(error, response?.status ?? 0), property) - : successState(data), propertyCache: !error && data ? { ...current.propertyCache, [cacheKey]: data } : current.propertyCache } }); - } - - #reportQuery(connection: string, filter: { filter?: string[] }): DashboardReportQuery { + #reportQuery(connection: string, filter: ReportFilterQuery): DashboardReportQuery { const { from, to, interval } = this.state.range; - return { connection, from, to, interval, ...this.#scope(), ...filter } as DashboardReportQuery; + return { connection, from, to, interval, ...this.#scope(), ...filter }; } #scope(): ReportScope { @@ -715,13 +587,36 @@ export class AnalyticsDashboardController { return filters.length ? { filter: filters.map(serializeFilter) } : {}; } - #visitFilterQuery(): { filter?: string[] } { - return this.#serializedFilters(this.#capabilities().globalEventFiltering - ? this.state.filters - : this.state.filters.filter(({ dimension }) => dimension !== "EventName")); + #flagFilterQuery(): { filterFlagKey?: string; filterFlagValue?: string } { + return this.state.flagFilter + ? { filterFlagKey: this.state.flagFilter.flagKey, filterFlagValue: this.state.flagFilter.value } + : {}; } - #eventListFilterQuery(): { filter?: string[] } { return this.#serializedFilters(this.state.filters); } + #eventFilterQuery(): Pick { + const filter = this.state.eventFilter; + return filter + ? { + filterEventName: filter.eventName, + filterEventProperty: filter.property, + filterEventValue: filter.value, + } + : {}; + } + + #visitFilterQuery(): ReportFilterQuery { + return { + ...this.#serializedFilters(this.#capabilities().globalEventFiltering + ? this.state.filters + : this.state.filters.filter(({ dimension }) => dimension !== "EventName")), + ...this.#flagFilterQuery(), + ...this.#eventFilterQuery(), + }; + } + + #eventListFilterQuery(): ReportFilterQuery { + return { ...this.#serializedFilters(this.state.filters), ...this.#flagFilterQuery(), ...this.#eventFilterQuery() }; + } #restoreUrlState(): void { const parsed = parseDashboardUrlState(this.#environment.currentUrl().searchParams); @@ -731,6 +626,8 @@ export class AnalyticsDashboardController { audienceDimension: parsed.audience, utmDimension: parsed.utm, filters: parsed.filters, + flagFilter: parsed.flagFilter, + eventFilter: parsed.eventFilter, }; if (parsed.range) { patch.range = parsed.range; @@ -753,21 +650,11 @@ export class AnalyticsDashboardController { audience: this.state.audienceDimension, utm: this.state.utmDimension, filters: this.state.filters, + flagFilter: this.state.flagFilter, + eventFilter: this.state.eventFilter, })); } - #closeDialogs(): void { - this.#expandedRequest.cancel(); - this.#eventSearchRequest.cancel(); - this.#eventDetailsRequest.cancel(); - this.#flagRequest.cancel(); - this.#eventPropertyRequest.cancel(); - this.#set({ expandedBreakdown: undefined, expandedEvents: undefined, selectedEvent: undefined, selectedFlag: undefined }); - } -} - -function eventPropertyCacheKey(propertyName: string, search: string): string { - return JSON.stringify([propertyName, search]); } function breakdownCacheKey(dimension: AnalyticsDimension, search: string): string { diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dashboard.element.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dashboard.element.ts index a66ad09..0d62b6e 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dashboard.element.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dashboard.element.ts @@ -4,9 +4,10 @@ import type { AnalyticsDimension } from "../api/types.gen.js"; import { countryDisplayName, countryFlagUrl, normalizeCountryCode } from "./country-display.js"; import { breakdownValueIcon } from "./breakdown-value-icon.js"; import type { AnalyticsDateRangeChangeDetail } from "./date-range-picker.element.js"; -import type { AnalyticsFilter, AudienceDimension, DashboardMetric, UtmDimension } from "./dashboard-url-state.js"; +import type { AnalyticsEventFilter, AnalyticsFilter, AnalyticsFlagFilter, AudienceDimension, DashboardMetric, UtmDimension } from "./dashboard-url-state.js"; import type { AcquisitionView } from "./dashboard-cards.js"; import { AnalyticsDashboardController, type DashboardState } from "./analytics-dashboard.controller.js"; +import type { FeatureDrilldownState } from "./analytics-feature-drilldown.controller.js"; import { isInitialLoading, stateData, type AsyncState } from "./async-state.js"; import { analyticsDashboardStyles } from "./analytics-dashboard.styles.js"; import "./analytics-dashboard-header.element.js"; @@ -14,6 +15,7 @@ import "./analytics-summary.element.js"; import "./analytics-breakdown-grid.element.js"; import "./breakdown-dialog.element.js"; import "./event-dialog.element.js"; +import "./flag-dialog.element.js"; import "./event-details-dialog.element.js"; @customElement("web-analytics-dashboard") @@ -65,8 +67,8 @@ export class WebAnalyticsDashboardElement extends UmbElementMixin(LitElement) { return nothing; } - #renderFilters(filters: AnalyticsFilter[]) { - if (!filters.length) return ""; + #renderFilters(filters: AnalyticsFilter[], flagFilter?: AnalyticsFlagFilter, eventFilter?: AnalyticsEventFilter) { + if (!filters.length && !flagFilter && !eventFilter) return ""; return html`
Filters
@@ -81,6 +83,26 @@ export class WebAnalyticsDashboardElement extends UmbElementMixin(LitElement) { ${this.#filterLabel(filter)} `)} + ${flagFilter ? html` + + ` : ""} + ${eventFilter ? html` + + ` : ""} this.#controller.clearFilters()}>Clear all
@@ -106,9 +128,92 @@ export class WebAnalyticsDashboardElement extends UmbElementMixin(LitElement) { `; } + #renderBreakdownDialog(state: DashboardState) { + const expanded = state.expandedBreakdown; + if (!expanded) return nothing; + return html` + ) => this.#controller.searchBreakdown(event.detail.search)} + @breakdown-dimension-change=${(event: CustomEvent<{ dimension: AnalyticsDimension; headline: string }>) => this.#controller.openBreakdown(event.detail.dimension, event.detail.headline)} + @analytics-dialog-close=${() => this.#controller.closeBreakdown()}> + `; + } + + #renderEventListDialog(state: DashboardState, features: FeatureDrilldownState) { + const expanded = features.expandedEvents; + if (!expanded || features.selectedEvent) return nothing; + return html` + ) => this.#controller.features.openEvents(event.detail.search, true)} + @select-event=${(event: CustomEvent<{ eventName: string }>) => this.#controller.features.selectEvent(event.detail.eventName)} + @analytics-dialog-close=${() => this.#controller.features.closeEvents()}> + `; + } + + #renderFlagDialog(state: DashboardState, features: FeatureDrilldownState) { + if (!features.expandedFlags && !features.selectedFlag) return nothing; + return html` + ) => this.#controller.features.selectFlag(event.detail.flagKey)} + @clear-selected-flag=${() => this.#controller.features.backToFlags()} + @analytics-dialog-close=${() => this.#controller.features.closeFlagFlow()}> + `; + } + + #renderEventDetailsDialog(state: DashboardState, features: FeatureDrilldownState) { + const selected = features.selectedEvent; + if (!selected || !state.capabilities?.eventDetails) return nothing; + return html` + ) => this.#controller.features.searchEventProperty(event.detail.propertyName, event.detail.search)} + @apply-event-property-filter=${(event: CustomEvent<{ property: string; value: string; visitors: number; count: number }>) => { + const { property, value } = event.detail; + this.#controller.features.applyEventFilter(property, value); + }} + @back-to-events=${() => this.#controller.features.backToEvents()} + @analytics-dialog-close=${() => this.#controller.features.closeEventFlow()}> + `; + } + render() { void this._revision; const state = this.#controller.state; + const features = this.#controller.features.state; if (state.configurationError) return html`

${state.configurationError}

`; @@ -146,27 +251,27 @@ export class WebAnalyticsDashboardElement extends UmbElementMixin(LitElement) { `; - const expanded = state.expandedBreakdown; - const expandedEvents = state.expandedEvents; - const selected = state.selectedEvent; const capabilities = state.capabilities; return html` -
) => this.#controller.toggleFilter(event.detail.dimension, event.detail.value)}> +
) => this.#controller.toggleFilter(event.detail.dimension, event.detail.value)} + @toggle-flag-filter=${(event: CustomEvent) => this.#controller.toggleFlagFilter(event.detail.flagKey, event.detail.value)}> ${this.#renderHeader(state)} - ${this.#renderFilters(state.filters)} + ${this.#renderFilters(state.filters, state.flagFilter, state.eventFilter)} ) => this.#controller.setMetric(event.detail.metric)} - @retry-summary=${() => this.#controller.loadReports()}> + @retry-summary=${() => this.#controller.retryReports()}> ) => this.#controller.openBreakdown(event.detail.dimension, event.detail.headline)} - @view-events=${() => this.#controller.openEvents()} - @select-event=${(event: CustomEvent<{ eventName: string }>) => this.#controller.selectEvent(event.detail.eventName)} - @select-flag=${(event: CustomEvent<{ flagKey: string }>) => this.#controller.selectFlag(event.detail.flagKey)} - @clear-selected-flag=${() => this.#controller.clearSelectedFlag()} - @retry-reports=${() => this.#controller.loadReports()} + @view-events=${() => this.#controller.features.openEvents()} + @view-flags=${() => this.#controller.features.openFlags()} + @select-event=${(event: CustomEvent<{ eventName: string }>) => this.#controller.features.selectEvent(event.detail.eventName)} + @select-flag=${(event: CustomEvent<{ flagKey: string }>) => this.#controller.features.selectFlag(event.detail.flagKey)} + @clear-event-filter=${() => this.#controller.clearEventFilter()} + @retry-reports=${() => this.#controller.retryReports()} @audience-change=${(event: CustomEvent<{ dimension: AudienceDimension }>) => this.#controller.setAudienceDimension(event.detail.dimension)} @acquisition-change=${(event: CustomEvent<{ view: AcquisitionView }>) => this.#controller.setAcquisitionView(event.detail.view)} @utm-change=${(event: CustomEvent<{ dimension: UtmDimension }>) => this.#controller.setUtmDimension(event.detail.dimension)}> - ${expanded ? html` - ) => this.#controller.searchBreakdown(event.detail.search)} - @breakdown-dimension-change=${(event: CustomEvent<{ dimension: AnalyticsDimension; headline: string }>) => this.#controller.openBreakdown(event.detail.dimension, event.detail.headline)} - @close-breakdown=${() => this.#controller.closeBreakdown()}> - ` : ""} - ${expandedEvents && !selected ? html` - ) => this.#controller.openEvents(event.detail.search, true)} - @select-event=${(event: CustomEvent<{ eventName: string }>) => this.#controller.selectEvent(event.detail.eventName)} - @close-events=${() => this.#controller.closeEvents()}> - ` : ""} - ${selected && capabilities?.eventDetails ? html` - ) => this.#controller.searchEventProperty(event.detail.propertyName, event.detail.search)} - @toggle-event-property-filter=${(event: CustomEvent<{ property: string; value: string }>) => this.#controller.toggleEventPropertyFilter(event.detail.property, event.detail.value)} - @back-to-events=${() => this.#controller.backToEvents()} - @close-event-details=${() => this.#controller.closeEventFlow()}> - ` : ""} + ${this.#renderBreakdownDialog(state)} + ${this.#renderEventListDialog(state, features)} + ${this.#renderFlagDialog(state, features)} + ${this.#renderEventDetailsDialog(state, features)}
`; } diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dialog-frame.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dialog-frame.ts new file mode 100644 index 0000000..fb94fdf --- /dev/null +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dialog-frame.ts @@ -0,0 +1,35 @@ +import { html, type TemplateResult } from "@umbraco-cms/backoffice/external/lit"; +import { renderAnalyticsDialogHeadline } from "./analytics-dialog-headline.js"; +import { cancelDialog, closeDialog, notifyDialogClosed } from "./dialog-lifecycle.js"; + +export type AnalyticsDialogFrame = { + host: HTMLElement; + ariaLabel: string; + closeLabel: string; + headline: string | TemplateResult; + body: TemplateResult; + controls?: TemplateResult; + showHeadline?: boolean; +}; + +export function renderAnalyticsDialogFrame({ + host, + ariaLabel, + closeLabel, + headline, + body, + controls, + showHeadline = true, +}: AnalyticsDialogFrame): TemplateResult { + return html` + cancelDialog(event, host)} + @close=${() => notifyDialogClosed(host, "analytics-dialog-close")}> +
+ ${renderAnalyticsDialogHeadline(headline, closeLabel, () => closeDialog(host), controls, showHeadline)} + ${body} +
+
+ `; +} diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-drilldown-filter.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-drilldown-filter.ts new file mode 100644 index 0000000..529fdf6 --- /dev/null +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-drilldown-filter.ts @@ -0,0 +1,63 @@ +import { css, html, type TemplateResult } from "@umbraco-cms/backoffice/external/lit"; + +export function renderAnalyticsDrilldownFilter({ + label, + removeLabel, + remove, +}: { + label: string; + removeLabel: string; + remove: () => void; +}): TemplateResult { + return html` + + `; +} + +export const analyticsDrilldownFilterStyles = css` + .drilldown-filter { + align-items: center; + appearance: none; + background: color-mix(in srgb, var(--uui-color-interactive) 6%, var(--uui-color-surface)); + border: 1px solid color-mix(in srgb, var(--uui-color-interactive) 18%, var(--uui-color-border)); + border-radius: var(--uui-border-radius); + color: var(--uui-color-interactive-emphasis); + cursor: pointer; + display: inline-flex; + flex: 0 1 auto; + font-size: var(--uui-type-small-size); + font-weight: 400; + gap: var(--uui-size-space-1); + max-inline-size: 50%; + min-inline-size: 0; + padding: var(--uui-size-space-1) var(--uui-size-space-2); + position: relative; + z-index: 2; + } + .drilldown-filter:hover { + background: color-mix(in srgb, var(--uui-color-interactive) 13%, var(--uui-color-surface)); + border-color: color-mix(in srgb, var(--uui-color-interactive) 34%, var(--uui-color-border)); + } + .drilldown-filter:active { + background: color-mix(in srgb, var(--uui-color-interactive) 18%, var(--uui-color-surface)); + } + .drilldown-filter:focus-visible { + outline: 2px solid var(--uui-color-selected); + outline-offset: 1px; + } + .drilldown-filter-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .drilldown-filter-remove { flex: 0 0 auto; } +`; diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-feature-drilldown.controller.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-feature-drilldown.controller.ts new file mode 100644 index 0000000..3ac1330 --- /dev/null +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-feature-drilldown.controller.ts @@ -0,0 +1,338 @@ +import type { + AnalyticsCapabilities, + AnalyticsEventProperty, + AnalyticsEventRow, + AnalyticsFlagsReport, +} from "../api/types.gen.js"; +import { errorState, idleState, loadingState, successState, type AsyncState } from "./async-state.js"; +import type { DashboardApi } from "./dashboard-api.js"; +import type { SelectedEvent, SelectedFlag } from "./dashboard-drilldown-state.js"; +import type { AnalyticsEventFilter } from "./dashboard-url-state.js"; +import type { DashboardReportQuery } from "./dashboard-report-loader.js"; +import { visibleEventRows } from "./event-rows.js"; +import { reportErrorMessage } from "./report-error.js"; +import { DebouncedRequest, RequestCoordinator } from "./request-coordinator.js"; + +export type FeatureDrilldownState = { + expandedEvents?: AsyncState; + selectedEvent?: SelectedEvent; + expandedFlags?: AsyncState; + selectedFlag?: SelectedFlag; +}; + +type FeatureDrilldownContext = { + capabilities: AnalyticsCapabilities; + visitQuery?: DashboardReportQuery; + eventListQuery?: DashboardReportQuery; + eventFilter?: AnalyticsEventFilter; +}; + +export class AnalyticsFeatureDrilldownController { + state: FeatureDrilldownState = {}; + + readonly #eventSearchRequest = new DebouncedRequest(); + readonly #eventDetailsRequest = new RequestCoordinator(); + readonly #eventPropertyRequest = new DebouncedRequest(); + readonly #flagListRequest = new RequestCoordinator(); + readonly #flagDetailsRequest = new RequestCoordinator(); + readonly #notify: () => void; + readonly #api: DashboardApi; + readonly #getContext: () => FeatureDrilldownContext; + readonly #applyEventFilter: (filter: AnalyticsEventFilter | undefined) => void; + + constructor( + notify: () => void, + api: DashboardApi, + getContext: () => FeatureDrilldownContext, + applyEventFilter: (filter: AnalyticsEventFilter | undefined) => void, + ) { + this.#notify = notify; + this.#api = api; + this.#getContext = getContext; + this.#applyEventFilter = applyEventFilter; + } + + async openEvents(search = "", debounce = false): Promise { + const { capabilities, eventListQuery } = this.#getContext(); + if (!capabilities.events || !eventListQuery) return; + const previous = this.state.expandedEvents; + this.#set({ expandedEvents: loadingState(previous) }); + const run = (signal: AbortSignal) => this.#api.events({ + query: { ...eventListQuery, limit: 100, search: search || undefined }, + signal, + }); + const result = await (debounce ? this.#eventSearchRequest.schedule(run) : this.#eventSearchRequest.run(run)); + if (result.status === "cancelled" || result.status === "stale" || !this.state.expandedEvents) return; + if (result.status === "error") { + this.#set({ expandedEvents: errorState(reportErrorMessage(result.error), previous) }); + return; + } + const { data, error, response } = result.value; + this.#set({ + expandedEvents: error + ? errorState(apiErrorMessage(error, response?.status ?? 0), previous) + : successState(visibleEventRows(data?.rows ?? [])), + }); + } + + closeEvents(): void { + this.#eventSearchRequest.cancel(); + this.#set({ expandedEvents: undefined }); + } + + async selectEvent(eventName: string): Promise { + const { capabilities, eventFilter } = this.#getContext(); + if (!capabilities.eventDetails) return; + const drilldown = eventFilter?.eventName === eventName + ? eventFilter + : undefined; + await this.#loadEventDetails(eventName, drilldown?.property, drilldown?.value); + } + + applyEventFilter(property: string, value: string): void { + const { capabilities, eventFilter } = this.#getContext(); + const selected = this.state.selectedEvent; + if (!selected || !capabilities.globalEventPropertyFiltering) return; + const active = eventFilter?.eventName === selected.eventName + && eventFilter.property === property + && eventFilter.value === value; + this.#applyEventFilter(active ? undefined : { eventName: selected.eventName, property, value }); + } + + searchEventProperty(propertyName: string, search: string): void { + const { capabilities } = this.#getContext(); + if (!this.state.selectedEvent || !capabilities.eventProperties) return; + const normalizedSearch = search.trim(); + void this.#loadEventPropertyValues(propertyName, normalizedSearch, normalizedSearch.length > 0); + } + + closeEventDetails(): void { + this.#eventPropertyRequest.cancel(); + this.#eventDetailsRequest.cancel(); + this.#set({ selectedEvent: undefined }); + } + + async backToEvents(): Promise { + const eventsAreOpen = this.state.expandedEvents !== undefined; + this.closeEventDetails(); + if (!eventsAreOpen) await this.openEvents(); + } + + closeEventFlow(): void { + this.#eventPropertyRequest.cancel(); + this.#eventDetailsRequest.cancel(); + this.#eventSearchRequest.cancel(); + this.#set({ selectedEvent: undefined, expandedEvents: undefined }); + } + + async openFlags(): Promise { + const { capabilities, visitQuery } = this.#getContext(); + if (!capabilities.flags || !visitQuery) return; + const previous = this.state.expandedFlags; + this.#set({ expandedFlags: loadingState(previous) }); + const result = await this.#flagListRequest.run((signal) => this.#api.flags({ + query: { ...visitQuery, limit: 100 }, + signal, + })); + if (result.status === "cancelled" || result.status === "stale" || !this.state.expandedFlags) return; + if (result.status === "error") { + this.#set({ expandedFlags: errorState(reportErrorMessage(result.error), previous) }); + return; + } + const { data, error, response } = result.value; + this.#set({ + expandedFlags: error || !data + ? errorState(apiErrorMessage(error, response?.status ?? 0), previous) + : successState(data), + }); + } + + async selectFlag(flagKey: string): Promise { + const { capabilities, visitQuery } = this.#getContext(); + if (!capabilities.flags || !visitQuery) return; + const previous = this.state.selectedFlag?.flagKey === flagKey + ? this.state.selectedFlag.report + : undefined; + this.#set({ selectedFlag: { flagKey, report: loadingState(previous) } }); + const result = await this.#flagDetailsRequest.run((signal) => this.#api.flags({ + query: { ...visitQuery, flagKey, limit: 100 }, + signal, + })); + if (result.status === "cancelled" || result.status === "stale" || this.state.selectedFlag?.flagKey !== flagKey) return; + if (result.status === "error") { + this.#set({ selectedFlag: { flagKey, report: errorState(reportErrorMessage(result.error), previous) } }); + return; + } + const { data, error, response } = result.value; + this.#set({ + selectedFlag: { + flagKey, + report: error || !data + ? errorState(apiErrorMessage(error, response?.status ?? 0), previous) + : successState(data), + }, + }); + } + + clearSelectedFlag(): void { + this.#flagDetailsRequest.cancel(); + this.#set({ selectedFlag: undefined }); + } + + async backToFlags(): Promise { + const flagsAreOpen = this.state.expandedFlags !== undefined; + this.clearSelectedFlag(); + if (!flagsAreOpen) await this.openFlags(); + } + + closeFlagFlow(): void { + this.#flagListRequest.cancel(); + this.#flagDetailsRequest.cancel(); + this.#set({ expandedFlags: undefined, selectedFlag: undefined }); + } + + closeDialogs(): void { + this.#cancelRequests(); + this.#set({ + expandedEvents: undefined, + selectedEvent: undefined, + expandedFlags: undefined, + selectedFlag: undefined, + }); + } + + closeAll(): void { + this.#cancelRequests(); + this.#set({ + expandedEvents: undefined, + selectedEvent: undefined, + expandedFlags: undefined, + selectedFlag: undefined, + }); + } + + disconnect(): void { + this.#cancelRequests(); + } + + async #loadEventDetails(eventName: string, eventProperty?: string, eventValue?: string): Promise { + const { visitQuery } = this.#getContext(); + if (!visitQuery) return; + this.#eventPropertyRequest.cancel(); + const previous = this.state.selectedEvent?.eventName === eventName + ? this.state.selectedEvent.details + : undefined; + this.#set({ + selectedEvent: { + eventName, + eventProperty, + eventValue, + details: loadingState(previous), + property: idleState(), + propertyCache: {}, + }, + }); + const result = await this.#eventDetailsRequest.run((signal) => this.#api.eventDetails({ + query: { ...visitQuery, eventName, eventProperty, eventValue }, + signal, + })); + if (result.status === "cancelled" || result.status === "stale" || this.state.selectedEvent?.eventName !== eventName) return; + if (result.status === "error") { + this.#set({ + selectedEvent: { + ...this.state.selectedEvent, + details: errorState(reportErrorMessage(result.error), previous), + }, + }); + return; + } + const { data, error, response } = result.value; + if (error || !data) { + this.#set({ + selectedEvent: { + ...this.state.selectedEvent, + details: errorState(apiErrorMessage(error, response?.status ?? 0), previous), + }, + }); + return; + } + const propertyCache = Object.fromEntries(data.properties + .filter((property) => property.values.length > 0) + .map((property) => [eventPropertyCacheKey(property.name, ""), property])); + this.#set({ + selectedEvent: { + ...this.state.selectedEvent, + details: successState(data), + propertyCache, + }, + }); + const firstProperty = data.properties[0]; + if (this.#getContext().capabilities.eventProperties && firstProperty && !firstProperty.values.length) { + void this.#loadEventPropertyValues(firstProperty.name, ""); + } + } + + async #loadEventPropertyValues(propertyName: string, search: string, debounce = false): Promise { + const { capabilities, visitQuery } = this.#getContext(); + const selected = this.state.selectedEvent; + if (!visitQuery || !selected || !capabilities.eventProperties) return; + const cacheKey = eventPropertyCacheKey(propertyName, search); + const cached = selected.propertyCache[cacheKey]; + const property = cached ? loadingState(successState(cached)) : loadingState(); + this.#set({ selectedEvent: { ...selected, propertyName, propertySearch: search, property } }); + const run = (signal: AbortSignal) => this.#api.eventPropertyValues({ + query: { + ...visitQuery, + eventName: selected.eventName, + propertyName, + limit: 100, + search, + eventProperty: selected.eventProperty, + eventValue: selected.eventValue, + }, + signal, + }); + const result = await (debounce ? this.#eventPropertyRequest.schedule(run) : this.#eventPropertyRequest.run(run)); + const current = this.state.selectedEvent; + if (result.status === "cancelled" || result.status === "stale" + || current?.eventName !== selected.eventName + || current.propertyName !== propertyName) return; + if (result.status === "error") { + this.#set({ selectedEvent: { ...current, property: errorState(reportErrorMessage(result.error), property) } }); + return; + } + const { data, error, response } = result.value; + this.#set({ + selectedEvent: { + ...current, + property: error || !data + ? errorState(apiErrorMessage(error, response?.status ?? 0), property) + : successState(data), + propertyCache: !error && data + ? { ...current.propertyCache, [cacheKey]: data } + : current.propertyCache, + }, + }); + } + + #cancelRequests(): void { + this.#eventSearchRequest.cancel(); + this.#eventDetailsRequest.cancel(); + this.#eventPropertyRequest.cancel(); + this.#flagListRequest.cancel(); + this.#flagDetailsRequest.cancel(); + } + + #set(patch: Partial): void { + this.state = { ...this.state, ...patch }; + this.#notify(); + } +} + +function eventPropertyCacheKey(propertyName: string, search: string): string { + return JSON.stringify([propertyName, search]); +} + +function apiErrorMessage(error: unknown, status: number): string { + return reportErrorMessage(typeof error === "object" && error !== null ? { ...error, status } : { status }); +} diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-filter-action.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-filter-action.ts new file mode 100644 index 0000000..323045e --- /dev/null +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-filter-action.ts @@ -0,0 +1,33 @@ +import { css, html } from "@umbraco-cms/backoffice/external/lit"; + +export const analyticsTableFilterActionStyles = css` + .metric-cell { align-items: center; display: flex; gap: var(--uui-size-space-2); justify-content: flex-end; } + .filter-action { align-items: center; appearance: none; background: transparent; border: 0; border-radius: var(--uui-border-radius); color: var(--uui-color-text-alt); cursor: pointer; display: inline-flex; font: inherit; justify-content: center; opacity: 0; padding: var(--uui-size-space-2); } + tbody tr:hover .filter-action, .filter-action:focus-visible, .filter-action[aria-pressed="true"] { opacity: 1; } + .filter-action:hover { background: color-mix(in srgb, var(--uui-color-interactive) 10%, var(--uui-color-surface)); color: var(--uui-color-interactive-emphasis); } + .filter-action[aria-pressed="true"] { background: color-mix(in srgb, var(--uui-color-interactive) 15%, var(--uui-color-surface)); color: var(--uui-color-interactive-emphasis); } + .filter-action:focus-visible { outline: 2px solid var(--uui-color-selected); outline-offset: 1px; } + @media (hover: none) { .filter-action { opacity: 1; } } +`; + +export function renderAnalyticsFilterAction({ + active, + label, + toggle, +}: { + active: boolean; + label: string; + toggle: () => void; +}) { + return html` + + `; +} diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-metric-table.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-metric-table.ts new file mode 100644 index 0000000..8b249ee --- /dev/null +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-metric-table.ts @@ -0,0 +1,52 @@ +import { css, html, type TemplateResult } from "@umbraco-cms/backoffice/external/lit"; +import { renderAnalyticsTableSkeletonRows } from "./analytics-table-skeleton.js"; + +export function renderAnalyticsMetricTable({ + caption, + rowHeading, + totalHeading, + loading, + skeletonRows, + rows, +}: { + caption: string; + rowHeading: string; + totalHeading: string; + loading: boolean; + skeletonRows: number; + rows: TemplateResult | TemplateResult[]; +}): TemplateResult { + return html` + ${loading ? html`Loading ${caption.toLocaleLowerCase()}` : ""} + + + + ${loading ? renderAnalyticsTableSkeletonRows(skeletonRows) : rows} +
${caption}
${rowHeading}Visitors${totalHeading}
+ `; +} + +export const analyticsMetricTableStyles = css` + :host { block-size: 100%; display: flex; flex-direction: column; overflow-x: auto; } + table { --bar-inset: var(--uui-size-space-3); border-collapse: collapse; min-inline-size: 30rem; table-layout: fixed; width: 100%; } + caption { clip: rect(0 0 0 0); height: 1px; overflow: hidden; position: absolute; width: 1px; } + th, td { box-sizing: border-box; padding: var(--uui-size-space-3) var(--uui-size-space-5); text-align: left; } + thead th { border-bottom: 1px solid var(--uui-color-border); font-weight: 700; } + thead th:not(:first-child), td { text-align: right; width: 8rem; } + tbody tr { height: 2.5rem; } + tbody th, tbody td { padding-block: 0; } + tbody th { font-weight: 500; min-width: 12rem; position: relative; } + tbody td { font-variant-numeric: tabular-nums; position: relative; z-index: 1; } + tbody tr:hover, tbody tr:focus-within { position: relative; z-index: 2; } + .row-heading-content { align-items: center; display: flex; gap: var(--uui-size-space-2); min-inline-size: 0; position: relative; z-index: 1; } + .row-action { appearance: none; background: transparent; border: 0; color: var(--uui-color-text); cursor: pointer; flex: 0 1 auto; font: inherit; min-inline-size: 0; overflow: hidden; padding: 0; position: relative; text-align: left; text-overflow: ellipsis; white-space: nowrap; z-index: 1; } + .row-action:hover { text-decoration: underline; text-underline-offset: 0.18em; } + .row-action:focus-visible, .empty a:focus-visible { outline: 2px solid var(--uui-color-selected); outline-offset: 2px; } + .row-value { display: block; flex: 0 1 auto; min-inline-size: 0; overflow: hidden; position: relative; text-overflow: ellipsis; white-space: nowrap; z-index: 1; } + .bar { inset-block: var(--uui-size-space-1); inset-inline-start: var(--bar-inset); inline-size: calc(100% + 16rem - 2 * var(--bar-inset)); position: absolute; } + .bar::before { background: color-mix(in srgb, var(--uui-color-interactive) 4%, var(--uui-color-surface)); block-size: 100%; border-radius: var(--uui-border-radius); content: ""; display: block; inline-size: max(var(--bar-minimum), var(--bar-width)); } + .empty { align-items: center; display: flex; flex: 1; flex-direction: column; gap: var(--uui-size-space-3); justify-content: center; min-block-size: 16rem; padding: var(--uui-size-layout-1); text-align: center; } + .empty-icon { align-items: center; border: 1px solid var(--uui-color-border); border-radius: 50%; color: var(--uui-color-text-alt); display: inline-flex; font-size: var(--uui-type-h4-size); height: 3rem; justify-content: center; width: 3rem; } + .empty p { color: var(--uui-color-text-alt); margin: 0; max-width: 34rem; } + .empty a { align-items: center; color: var(--uui-color-interactive-emphasis); display: inline-flex; gap: var(--uui-size-space-1); } +`; diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-table-skeleton.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-table-skeleton.ts index 4474743..515ef25 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-table-skeleton.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-table-skeleton.ts @@ -9,6 +9,16 @@ export const renderAnalyticsTableSkeletonRows = (count: number) => Array.from({ `); export const analyticsTableSkeletonStyles = css` + .visually-hidden { + clip: rect(0 0 0 0); + clip-path: inset(50%); + height: 1px; + overflow: hidden; + position: absolute; + white-space: nowrap; + width: 1px; + } + .skeleton-line, .skeleton-number { background: var(--uui-color-surface-alt); block-size: 1lh; diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/breakdown-dialog.element.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/breakdown-dialog.element.ts index ada4fbf..1034999 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/breakdown-dialog.element.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/breakdown-dialog.element.ts @@ -9,12 +9,12 @@ import { import { UmbElementMixin } from "@umbraco-cms/backoffice/element-api"; import { UmbTextStyles } from "@umbraco-cms/backoffice/style"; import type { AnalyticsBreakdownRow, AnalyticsDimension } from "../api/types.gen.js"; -import { renderAnalyticsDialogHeadline } from "./analytics-dialog-headline.js"; +import { renderAnalyticsDialogFrame } from "./analytics-dialog-frame.js"; import { analyticsDialogStyles } from "./analytics-dialog.styles.js"; import { breakdownDimensionLabel, type TrafficMetric } from "./breakdown-rows.js"; import { AUDIENCE_OPTIONS, breakdownDialogGroup, referrerDimensionOption, UTM_OPTIONS, type DimensionOption } from "./dashboard-cards.js"; import type { AnalyticsFilter, UtmDimension } from "./dashboard-url-state.js"; -import { cancelDialog, closeDialog, notifyDialogClosed, notifyDialogSearch, openDialog, searchInputValue } from "./dialog-lifecycle.js"; +import { notifyDialogSearch, openDialog, searchInputValue } from "./dialog-lifecycle.js"; import { isUtmDimension } from "./utm-capability.js"; import type { ReportTabGroup } from "./report-tabs.js"; import "./breakdown-table.element.js"; @@ -39,18 +39,6 @@ export class WebAnalyticsBreakdownDialogElement extends UmbElementMixin(LitEleme openDialog(this); } - #close(): void { - closeDialog(this); - } - - #notifyClosed(): void { - notifyDialogClosed(this, "close-breakdown"); - } - - #onCancel(event: Event): void { - cancelDialog(event, this); - } - #onSearch(event: Event): void { this._search = searchInputValue(event); notifyDialogSearch(this, "search-breakdown", this._search); @@ -153,41 +141,44 @@ export class WebAnalyticsBreakdownDialogElement extends UmbElementMixin(LitEleme const dialogHeadline = group === "audience" ? "Audience" : group === "acquisition" ? "Traffic sources" : this.headline; const headingTabs = this.#headingTabs(); const subheadingTabs = this.#subheadingTabs(); - return html` - -
- ${renderAnalyticsDialogHeadline(dialogHeadline, `Close ${dialogHeadline}`, () => this.#close(), html` - - - - `, false)} -
- ) => this.#selectHeading(event.detail.value)} - @subheading-tab-change=${(event: CustomEvent<{ value: string }>) => this.#selectSubheading(event.detail.value)}> -
+ return renderAnalyticsDialogFrame({ + host: this, + ariaLabel: dialogHeadline, + closeLabel: `Close ${dialogHeadline}`, + headline: dialogHeadline, + showHeadline: false, + controls: html` + + + + `, + body: html` +
+ ) => this.#selectHeading(event.detail.value)} + @subheading-tab-change=${(event: CustomEvent<{ value: string }>) => this.#selectSubheading(event.detail.value)}>
-
- `; + `, + }); } static styles = [UmbTextStyles, analyticsDialogStyles, css` diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/breakdown-table.element.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/breakdown-table.element.ts index cbfedb3..7c1e4a5 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/breakdown-table.element.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/breakdown-table.element.ts @@ -16,6 +16,7 @@ import { import { countryDisplayName, countryFlagUrl, normalizeCountryCode } from "./country-display.js"; import { breakdownValueIcon } from "./breakdown-value-icon.js"; import type { AnalyticsFilter } from "./dashboard-url-state.js"; +import { renderAnalyticsFilterAction } from "./analytics-filter-action.js"; import { googleFaviconUrl } from "./favicon.js"; import { renderReportTabs, reportTabsStyles, selectedReportTabId, type ReportTabGroup } from "./report-tabs.js"; @@ -103,20 +104,15 @@ export class WebAnalyticsBreakdownTableElement extends UmbElementMixin(LitElemen const tooltipId = `breakdown-value-${index}`; const activeFilter = this.filters.some((filter) => filter.dimension === this.dimension && filter.value === row.value); const filterLabel = activeFilter ? `Remove ${displayValue} filter` : `Filter analytics by ${displayValue}`; - const filterAction = html` - `; + })), + }); const expandedPercentageMode = !this.compact && isPercentageDimension(this.dimension); const expandedPercentage = expandedPercentageMode ? html`` diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/dashboard-capabilities.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/dashboard-capabilities.ts index 47050ab..7145e7d 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/dashboard-capabilities.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/dashboard-capabilities.ts @@ -1,5 +1,5 @@ import type { AnalyticsCapabilities, AnalyticsDimension } from "../api/types.gen.js"; -import type { AnalyticsFilter, AudienceDimension, UtmDimension } from "./dashboard-url-state.js"; +import type { AnalyticsEventFilter, AnalyticsFilter, AnalyticsFlagFilter, AudienceDimension, UtmDimension } from "./dashboard-url-state.js"; import type { AcquisitionView } from "./dashboard-cards.js"; export const unavailableCapabilities: AnalyticsCapabilities = { @@ -8,6 +8,7 @@ export const unavailableCapabilities: AnalyticsCapabilities = { eventDetails: false, eventProperties: false, globalEventFiltering: false, + globalEventPropertyFiltering: false, flags: false, breakdownOrdering: false, }; @@ -17,6 +18,8 @@ type DashboardSelection = { acquisitionView: AcquisitionView; utmDimension: UtmDimension; filters: AnalyticsFilter[]; + flagFilter?: AnalyticsFlagFilter; + eventFilter?: AnalyticsEventFilter; }; const audienceDimensions: ReadonlyArray = ["DeviceType", "BrowserName"]; @@ -40,6 +43,8 @@ export function normalizeDashboardSelection( utmDimension, filters: selection.filters.filter(({ dimension }) => supported.has(dimension) && (dimension !== "EventName" || capabilities.globalEventFiltering)), + flagFilter: capabilities.flags ? selection.flagFilter : undefined, + eventFilter: capabilities.globalEventPropertyFiltering ? selection.eventFilter : undefined, }; } diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/dashboard-drilldown-state.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/dashboard-drilldown-state.ts new file mode 100644 index 0000000..26e8de5 --- /dev/null +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/dashboard-drilldown-state.ts @@ -0,0 +1,18 @@ +import type { AnalyticsEventDetails, AnalyticsEventProperty, AnalyticsFlagsReport } from "../api/types.gen.js"; +import type { AsyncState } from "./async-state.js"; + +export type SelectedEvent = { + eventName: string; + details: AsyncState; + eventProperty?: string; + eventValue?: string; + propertyName?: string; + propertySearch?: string; + property: AsyncState; + propertyCache: Readonly>; +}; + +export type SelectedFlag = { + flagKey: string; + report: AsyncState; +}; diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/dashboard-url-state.test.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/dashboard-url-state.test.ts index 24081a0..073c61c 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/dashboard-url-state.test.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/dashboard-url-state.test.ts @@ -4,7 +4,7 @@ import { parseDashboardUrlState, writeDashboardUrlState } from "./dashboard-url- describe("analytics dashboard URL state", () => { it("parses shareable report state and ignores malformed filters", () => { const state = parseDashboardUrlState(new URLSearchParams( - "connection=main&range=30&from=2026-06-17T00%3A00%3A00Z&to=2026-07-16T00%3A00%3A00Z&tz=UTC&metric=pageViews&audience=BrowserName&utm=UtmCampaign&filter=Country%3ADK&filter=RequestPath%3A%2Fnews%3Aarchive&filter=EventName%3ASignup&filter=Nope%3Ax&filter=Country%3AUS", + "connection=main&range=30&from=2026-06-17T00%3A00%3A00Z&to=2026-07-16T00%3A00%3A00Z&tz=UTC&metric=pageViews&audience=BrowserName&utm=UtmCampaign&filter=Country%3ADK&filter=RequestPath%3A%2Fnews%3Aarchive&filter=EventName%3ASignup&filter=Nope%3Ax&filter=Country%3AUS&filterFlagKey=new-pricing-page&filterFlagValue=editorial%3Acompact", )); expect(state.connection).toBe("main"); @@ -18,6 +18,7 @@ describe("analytics dashboard URL state", () => { { dimension: "RequestPath", value: "/news:archive" }, { dimension: "EventName", value: "Signup" }, ]); + expect(state.flagFilter).toEqual({ flagKey: "new-pricing-page", value: "editorial:compact" }); }); it("writes analytics state while preserving unrelated Umbraco parameters", () => { @@ -29,6 +30,8 @@ describe("analytics dashboard URL state", () => { audience: "DeviceType", utm: "UtmMedium", filters: [{ dimension: "Country", value: "DK" }], + flagFilter: { flagKey: "new-pricing-page", value: "control" }, + eventFilter: { eventName: "Signup completed", property: "plan", value: "Pro" }, }); expect(url.searchParams.get("umbDebug")).toBe("true"); @@ -36,6 +39,37 @@ describe("analytics dashboard URL state", () => { expect(url.searchParams.get("utm")).toBe("UtmMedium"); expect(url.searchParams.get("tz")).toBe("Europe/Copenhagen"); expect(url.searchParams.getAll("filter")).toEqual(["Country:DK"]); + expect(url.searchParams.get("filterFlagKey")).toBe("new-pricing-page"); + expect(url.searchParams.get("filterFlagValue")).toBe("control"); + expect(url.searchParams.get("filterEventName")).toBe("Signup completed"); + expect(url.searchParams.get("filterEventProperty")).toBe("plan"); + expect(url.searchParams.get("filterEventValue")).toBe("Pro"); + expect(parseDashboardUrlState(url.searchParams).eventFilter).toEqual({ + eventName: "Signup completed", + property: "plan", + value: "Pro", + }); + }); + + it("ignores incomplete flag filters", () => { + expect(parseDashboardUrlState(new URLSearchParams("filterFlagKey=new-pricing-page")).flagFilter).toBeUndefined(); + expect(parseDashboardUrlState(new URLSearchParams("filterFlagValue=control")).flagFilter).toBeUndefined(); + }); + + it("round-trips an empty flag value", () => { + const url = writeDashboardUrlState(new URL("https://example.com/analytics"), { + connection: "main", + preset: 30, + range: { from: "2026-06-01T00:00:00.000Z", to: "2026-07-01T00:00:00.000Z", interval: "Day", timeZone: "UTC" }, + metric: "visitors", + audience: "DeviceType", + utm: "UtmSource", + filters: [], + flagFilter: { flagKey: "experiment", value: "" }, + }); + + expect(url.searchParams.has("filterFlagValue")).toBe(true); + expect(parseDashboardUrlState(url.searchParams).flagFilter).toEqual({ flagKey: "experiment", value: "" }); }); it("restores the hourly last 24 hours preset", () => { diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/dashboard-url-state.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/dashboard-url-state.ts index 18a7ec7..db771f0 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/dashboard-url-state.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/dashboard-url-state.ts @@ -2,6 +2,8 @@ import type { AnalyticsDimension } from "../api/types.gen.js"; import { normalizeCustomRange, normalizePresetRange, type AnalyticsDateRange, type DatePreset } from "./date-range.js"; export type AnalyticsFilter = { dimension: AnalyticsDimension; value: string }; +export type AnalyticsFlagFilter = { flagKey: string; value: string }; +export type AnalyticsEventFilter = { eventName: string; property: string; value: string }; export type DashboardMetric = "visitors" | "pageViews"; export type AudienceDimension = "DeviceType" | "BrowserName"; export type UtmDimension = "UtmSource" | "UtmMedium" | "UtmCampaign" | "UtmTerm" | "UtmContent"; @@ -14,6 +16,8 @@ export type DashboardUrlState = { audience: AudienceDimension; utm: UtmDimension; filters: AnalyticsFilter[]; + flagFilter?: AnalyticsFlagFilter; + eventFilter?: AnalyticsEventFilter; }; const DIMENSIONS = new Set([ @@ -48,6 +52,17 @@ export function parseDashboardUrlState(params: URLSearchParams): DashboardUrlSta seen.add(dimension); filters.push({ dimension, value }); } + const filterFlagKey = validFilterValue(params.get("filterFlagKey"), 255, false); + const filterFlagValue = validFilterValue(params.get("filterFlagValue"), 500, true); + const flagFilter = filterFlagKey && filterFlagValue !== undefined + ? { flagKey: filterFlagKey, value: filterFlagValue } + : undefined; + const filterEventName = validFilterValue(params.get("filterEventName"), 255, false); + const filterEventProperty = validFilterValue(params.get("filterEventProperty"), 255, false); + const filterEventValue = validFilterValue(params.get("filterEventValue"), 500, true); + const eventFilter = filterEventName && filterEventProperty && filterEventValue !== undefined + ? { eventName: filterEventName, property: filterEventProperty, value: filterEventValue } + : undefined; return { connection: params.get("connection") || undefined, @@ -57,18 +72,29 @@ export function parseDashboardUrlState(params: URLSearchParams): DashboardUrlSta audience: params.get("audience") === "BrowserName" ? "BrowserName" : "DeviceType", utm: parseUtmDimension(params.get("utm")), filters, + flagFilter, + eventFilter, }; } +function validFilterValue(value: string | null, maximumLength: number, allowEmpty: boolean): string | undefined { + return value !== null + && (allowEmpty || value.length > 0) + && value.length <= maximumLength + && !/[\u0000-\u001f\u007f]/.test(value) + ? value + : undefined; +} + function parseUtmDimension(value: string | null): UtmDimension { return value === "UtmMedium" || value === "UtmCampaign" || value === "UtmTerm" || value === "UtmContent" ? value : "UtmSource"; } -export function writeDashboardUrlState(url: URL, state: Required> & { connection?: string }): URL { +export function writeDashboardUrlState(url: URL, state: Required> & Pick): URL { const params = url.searchParams; - for (const name of ["connection", "range", "from", "to", "tz", "metric", "audience", "utm", "filter"]) params.delete(name); + for (const name of ["connection", "range", "from", "to", "tz", "metric", "audience", "utm", "filter", "filterFlagKey", "filterFlagValue", "filterEventName", "filterEventProperty", "filterEventValue"]) params.delete(name); if (state.connection) params.set("connection", state.connection); params.set("range", String(state.preset)); params.set("from", state.range.from); @@ -78,5 +104,14 @@ export function writeDashboardUrlState(url: URL, state: Required params.append("filter", serializeFilter(filter))); + if (state.flagFilter) { + params.set("filterFlagKey", state.flagFilter.flagKey); + params.set("filterFlagValue", state.flagFilter.value); + } + if (state.eventFilter) { + params.set("filterEventName", state.eventFilter.eventName); + params.set("filterEventProperty", state.eventFilter.property); + params.set("filterEventValue", state.eventFilter.value); + } return url; } diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/document-route.test.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/document-route.test.ts index 74e7bb4..34a5a26 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/document-route.test.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/document-route.test.ts @@ -5,7 +5,7 @@ import { activeDocumentRoute, workspaceAnalyticsCulture } from "./document-route const route = (culture: string, isCurrent = false): AnalyticsDocumentRoute => ({ connection: "main", provider: "Vercel", - capabilities: { dimensions: ["RequestPath"], events: true, eventDetails: true, eventProperties: true, globalEventFiltering: false, flags: true, breakdownOrdering: false }, + capabilities: { dimensions: ["RequestPath"], events: true, eventDetails: true, eventProperties: true, globalEventFiltering: false, globalEventPropertyFiltering: true, flags: true, breakdownOrdering: false }, culture, hostname: "www.example.com", path: `/${culture}`, diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/event-details-dialog.element.test.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/event-details-dialog.element.test.ts index d0b4960..d247ca9 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/event-details-dialog.element.test.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/event-details-dialog.element.test.ts @@ -81,6 +81,7 @@ describe("event details dialog layout", () => { const dialog = document.createElement("web-analytics-event-details-dialog") as WebAnalyticsEventDetailsDialogElement; dialog.eventName = "Signup completed"; dialog.propertiesEnabled = true; + dialog.filteringEnabled = true; dialog.details = { eventName: "Signup completed", totals: { count: 15, visitors: 12 }, @@ -143,5 +144,31 @@ describe("event details dialog layout", () => { expect(dialog.shadowRoot?.querySelector("tbody th strong")).toBeNull(); expect([...dialog.shadowRoot?.querySelectorAll("tbody td strong") ?? []].map((metric) => metric.textContent)).toEqual(["12", "15"]); + expect(dialog.shadowRoot?.querySelector(".filter-button")).toBeNull(); + }); + + it("delegates event property filter application with its metrics", async () => { + const dialog = document.createElement("web-analytics-event-details-dialog") as WebAnalyticsEventDetailsDialogElement; + dialog.eventName = "Signup completed"; + dialog.propertiesEnabled = true; + dialog.filteringEnabled = true; + dialog.details = { + eventName: "Signup completed", + totals: { count: 15, visitors: 12 }, + properties: [{ name: "plan", values: [{ value: "Pro", count: 15, visitors: 12 }] }], + }; + const onFilter = vi.fn(); + dialog.addEventListener("apply-event-property-filter", onFilter); + document.body.append(dialog); + await dialog.updateComplete; + + dialog.shadowRoot?.querySelector(".filter-button")?.click(); + + expect((onFilter.mock.calls[0][0] as CustomEvent).detail).toEqual({ + property: "plan", + value: "Pro", + visitors: 12, + count: 15, + }); }); }); diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/event-details-dialog.element.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/event-details-dialog.element.ts index 773e2b4..1835fed 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/event-details-dialog.element.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/event-details-dialog.element.ts @@ -3,16 +3,17 @@ import { UmbElementMixin } from "@umbraco-cms/backoffice/element-api"; import { UmbTextStyles } from "@umbraco-cms/backoffice/style"; import type { UUIInputElement } from "@umbraco-cms/backoffice/external/uui"; import type { AnalyticsEventDetails, AnalyticsEventProperty } from "../api/types.gen.js"; -import { renderAnalyticsDialogHeadline } from "./analytics-dialog-headline.js"; +import { renderAnalyticsDialogFrame } from "./analytics-dialog-frame.js"; import { analyticsDialogStyles, analyticsEventDialogStyles } from "./analytics-dialog.styles.js"; import { analyticsTableSkeletonStyles, renderAnalyticsTableSkeletonRows } from "./analytics-table-skeleton.js"; -import { cancelDialog, closeDialog, notifyDialogClosed, openDialog } from "./dialog-lifecycle.js"; +import { openDialog } from "./dialog-lifecycle.js"; import { renderReportTabs, reportTabsStyles } from "./report-tabs.js"; @customElement("web-analytics-event-details-dialog") export class WebAnalyticsEventDetailsDialogElement extends UmbElementMixin(LitElement) { @property() eventName = "Event"; @property({ type: Boolean }) propertiesEnabled = false; + @property({ type: Boolean }) filteringEnabled = false; @property({ type: Boolean }) loading = false; @property() unavailable?: string; @property({ attribute: false }) details?: AnalyticsEventDetails; @@ -26,9 +27,6 @@ export class WebAnalyticsEventDetailsDialogElement extends UmbElementMixin(LitEl @state() private _search = ""; protected firstUpdated(): void { openDialog(this); } - #close(): void { closeDialog(this); } - #notifyClosed(): void { notifyDialogClosed(this, "close-event-details"); } - #onCancel(event: Event): void { cancelDialog(event, this); } #backToEvents(): void { this.dispatchEvent(new CustomEvent("back-to-events", { bubbles: true, composed: true })); } #activeProperty(): AnalyticsEventProperty | undefined { @@ -59,12 +57,17 @@ export class WebAnalyticsEventDetailsDialogElement extends UmbElementMixin(LitEl this.#notifySearch(propertyName, ""); } - #toggleFilter(property: string, value: string): void { + #applyFilter(property: string, value: AnalyticsEventProperty["values"][number]): void { this.#clearSearch(property); - this.dispatchEvent(new CustomEvent("toggle-event-property-filter", { + this.dispatchEvent(new CustomEvent("apply-event-property-filter", { bubbles: true, composed: true, - detail: { property, value }, + detail: { + property, + value: value.value, + visitors: value.visitors, + count: value.count, + }, })); } @@ -85,9 +88,8 @@ export class WebAnalyticsEventDetailsDialogElement extends UmbElementMixin(LitEl const maximum = Math.max(...values.map((value) => value.count), 1); return html`
- ${property.values.length || (this.filterProperty !== undefined && this.filterValue !== undefined) ? html` + ${property.values.length ? html`
- ${property.values.length ? html` - ` : ""} - ${this.filterProperty !== undefined && this.filterValue !== undefined ? html` - - ` : ""}
` : ""}
@@ -132,15 +126,15 @@ export class WebAnalyticsEventDetailsDialogElement extends UmbElementMixin(LitEl - + ` : ""} ${this.localize.number(value.visitors)} @@ -166,28 +160,29 @@ export class WebAnalyticsEventDetailsDialogElement extends UmbElementMixin(LitEl render() { const activeProperty = this.#activeProperty(); - return html` - -
- ${renderAnalyticsDialogHeadline(html` + return renderAnalyticsDialogFrame({ + host: this, + ariaLabel: `${this.eventName} event details`, + closeLabel: "Close event details", + headline: html` - `, "Close event details", () => this.#close())} -
- ${this.details ? html` - ${this.propertiesEnabled ? activeProperty ? html` - ${this.#renderProperty(activeProperty)} - ` : this.#renderNoProperties() - : ""} - ${this.loading ? html`
Updating event details…
` : ""} - ${this.unavailable ? html`` : ""} - ` : this.loading ? html`
Loading event details…
` : this.unavailable ? html`

${this.unavailable}

` : ""} -
+ `, + body: html` +
+ ${this.details ? html` + ${this.propertiesEnabled ? activeProperty ? html` + ${this.#renderProperty(activeProperty)} + ` : this.#renderNoProperties() + : ""} + ${this.loading ? html`
Updating event details…
` : ""} + ${this.unavailable ? html`` : ""} + ` : this.loading ? html`
Loading event details…
` : this.unavailable ? html`

${this.unavailable}

` : ""}
-
- `; + `, + }); } static styles = [UmbTextStyles, analyticsDialogStyles, analyticsEventDialogStyles, analyticsTableSkeletonStyles, reportTabsStyles, css` @@ -203,8 +198,6 @@ export class WebAnalyticsEventDetailsDialogElement extends UmbElementMixin(LitEl thead { background: var(--uui-color-surface); box-shadow: 0 1px 0 var(--uui-color-border); position: sticky; top: 0; z-index: 3; } thead th { background: var(--uui-color-surface); font-weight: 700; } .property-heading { --analytics-report-tabs-margin: calc(-1 * var(--uui-size-space-3)) calc(-1 * var(--uui-size-space-5)); overflow: hidden; padding-block: var(--uui-size-space-3); } - .active-filter { align-items: center; background: var(--uui-color-surface-alt); border: 1px solid var(--uui-color-border); border-radius: var(--uui-border-radius); color: var(--uui-color-text); cursor: pointer; display: inline-flex; gap: var(--uui-size-space-2); max-inline-size: 100%; padding: var(--uui-size-space-2) var(--uui-size-space-3); } - .active-filter span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .metric-headings th { box-shadow: 0 1px 0 var(--uui-color-border); } thead th:not(:first-child), td { text-align: right; width: 8rem; } tbody th { font-weight: 500; min-width: 12rem; position: relative; } @@ -223,7 +216,6 @@ export class WebAnalyticsEventDetailsDialogElement extends UmbElementMixin(LitEl .loading-overlay { background: color-mix(in srgb, var(--uui-color-surface) 82%, transparent); inset: 0; padding: var(--uui-size-space-5); position: absolute; z-index: 4; } .error-overlay { background: color-mix(in srgb, var(--uui-color-warning) 8%, var(--uui-color-surface)); border: 1px solid color-mix(in srgb, var(--uui-color-warning) 28%, var(--uui-color-border)); border-radius: var(--uui-border-radius); inset-block-start: var(--uui-size-space-3); inset-inline: var(--uui-size-space-3); padding: var(--uui-size-space-4); position: absolute; z-index: 5; } .empty-row td { padding: var(--uui-size-space-5); text-align: left; } - .visually-hidden { clip: rect(0 0 0 0); clip-path: inset(50%); height: 1px; overflow: hidden; position: absolute; white-space: nowrap; width: 1px; } @media (hover: none) { .filter-button { opacity: 1; } } `]; } diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/event-dialog.element.test.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/event-dialog.element.test.ts index ce9afcb..018cf4b 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/event-dialog.element.test.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/event-dialog.element.test.ts @@ -23,4 +23,16 @@ describe("events dialog", () => { expect(dialog.shadowRoot?.querySelector("uui-input")?.getAttribute("label")).toBe("Search events"); expect(dialog.shadowRoot?.querySelector(".analytics-dialog-close")?.getAttribute("aria-label")).toBe("Close events"); }); + + it("uses the shared dialog close contract", async () => { + const dialog = document.createElement("web-analytics-event-dialog") as WebAnalyticsEventDialogElement; + const onClose = vi.fn(); + dialog.addEventListener("analytics-dialog-close", onClose); + document.body.append(dialog); + await dialog.updateComplete; + + dialog.shadowRoot?.querySelector("dialog")?.dispatchEvent(new Event("close")); + + expect(onClose).toHaveBeenCalledOnce(); + }); }); diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/event-dialog.element.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/event-dialog.element.ts index 20a7fd6..2ff0905 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/event-dialog.element.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/event-dialog.element.ts @@ -2,10 +2,10 @@ import { LitElement, css, customElement, html, property, state } from "@umbraco- import { UmbElementMixin } from "@umbraco-cms/backoffice/element-api"; import { UmbTextStyles } from "@umbraco-cms/backoffice/style"; import type { AnalyticsEventRow } from "../api/types.gen.js"; -import { renderAnalyticsDialogHeadline } from "./analytics-dialog-headline.js"; +import { renderAnalyticsDialogFrame } from "./analytics-dialog-frame.js"; import { analyticsDialogStyles, analyticsEventDialogStyles } from "./analytics-dialog.styles.js"; import type { AnalyticsFilter } from "./dashboard-url-state.js"; -import { cancelDialog, closeDialog, notifyDialogClosed, notifyDialogSearch, openDialog, searchInputValue } from "./dialog-lifecycle.js"; +import { notifyDialogSearch, openDialog, searchInputValue } from "./dialog-lifecycle.js"; import "./event-table.element.js"; @customElement("web-analytics-event-dialog") @@ -19,33 +19,33 @@ export class WebAnalyticsEventDialogElement extends UmbElementMixin(LitElement) @state() private _search = ""; protected firstUpdated(): void { openDialog(this); } - #close(): void { closeDialog(this); } - #notifyClosed(): void { notifyDialogClosed(this, "close-events"); } - #onCancel(event: Event): void { cancelDialog(event, this); } #onSearch(event: Event): void { this._search = searchInputValue(event); notifyDialogSearch(this, "search-events", this._search); } render() { - return html` - -
- ${renderAnalyticsDialogHeadline("Events", "Close events", () => this.#close(), html` - - - - `, false)} -
- ${!this.loading && this.unavailable ? html`

${this.unavailable}

` : ""} - ${!this.loading && !this.unavailable && this._search && this.rows.length === 0 ? html`

Try a different search.

` : ""} - ${this.loading || (!this.unavailable && (!this._search || this.rows.length > 0)) ? html` - - ` : ""} -
+ return renderAnalyticsDialogFrame({ + host: this, + ariaLabel: "Events", + closeLabel: "Close events", + headline: "Events", + showHeadline: false, + controls: html` + + + + `, + body: html` +
+ ${!this.loading && this.unavailable ? html`

${this.unavailable}

` : ""} + ${!this.loading && !this.unavailable && this._search && this.rows.length === 0 ? html`

Try a different search.

` : ""} + ${this.loading || (!this.unavailable && (!this._search || this.rows.length > 0)) ? html` + + ` : ""}
-
- `; + `, + }); } static styles = [UmbTextStyles, analyticsDialogStyles, analyticsEventDialogStyles, css` diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/event-table.element.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/event-table.element.ts index 5b4ff64..7d5debc 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/event-table.element.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/event-table.element.ts @@ -1,9 +1,15 @@ -import { LitElement, css, customElement, html, property } from "@umbraco-cms/backoffice/external/lit"; +import { LitElement, customElement, html, property } from "@umbraco-cms/backoffice/external/lit"; import { UmbElementMixin } from "@umbraco-cms/backoffice/element-api"; import type { AnalyticsEventRow } from "../api/types.gen.js"; -import type { AnalyticsFilter } from "./dashboard-url-state.js"; +import type { AnalyticsEventFilter, AnalyticsFilter } from "./dashboard-url-state.js"; import { visibleEventRows } from "./event-rows.js"; -import { analyticsTableSkeletonStyles, renderAnalyticsTableSkeletonRows } from "./analytics-table-skeleton.js"; +import { analyticsTableSkeletonStyles } from "./analytics-table-skeleton.js"; +import { analyticsTableFilterActionStyles, renderAnalyticsFilterAction } from "./analytics-filter-action.js"; +import { analyticsMetricTableStyles, renderAnalyticsMetricTable } from "./analytics-metric-table.js"; +import { + analyticsDrilldownFilterStyles, + renderAnalyticsDrilldownFilter, +} from "./analytics-drilldown-filter.js"; @customElement("web-analytics-event-table") export class WebAnalyticsEventTableElement extends UmbElementMixin(LitElement) { @@ -11,6 +17,7 @@ export class WebAnalyticsEventTableElement extends UmbElementMixin(LitElement) { @property({ type: Number }) skeletonRows = 10; @property({ attribute: false }) rows: AnalyticsEventRow[] = []; @property({ attribute: false }) filters: AnalyticsFilter[] = []; + @property({ attribute: false }) eventFilter?: AnalyticsEventFilter; @property({ type: Boolean }) detailsEnabled = true; @property({ type: Boolean }) filteringEnabled = false; @@ -26,44 +33,54 @@ export class WebAnalyticsEventTableElement extends UmbElementMixin(LitElement) { const rows = visibleEventRows(this.rows); const empty = !this.loading && rows.length === 0; const maximum = Math.max(...rows.map((row) => row.count), 1); - return html` - ${this.loading ? html`Loading events` : ""} - - - - ${this.loading - ? renderAnalyticsTableSkeletonRows(this.skeletonRows) - : rows.map((row) => { + const body = rows.map((row) => { const activeFilter = this.filters.some((filter) => filter.dimension === "EventName" && filter.value === row.eventName); + const eventFilter = this.eventFilter?.eventName === row.eventName + ? this.eventFilter + : undefined; const filterLabel = activeFilter ? `Remove ${row.eventName} event filter` : `Filter analytics by ${row.eventName} event`; return html` - `;})} -
Events and goals
EventVisitorsTotal events
0 ? "4px" : "0px"}`}> - ${this.detailsEnabled - ? html`` - : html`${row.eventName}`} + + ${this.detailsEnabled + ? html`` + : html`${row.eventName}`} + ${eventFilter ? renderAnalyticsDrilldownFilter({ + label: `${eventFilter.property}: ${eventFilter.value || "(empty)"}`, + removeLabel: `Remove ${row.eventName} ${eventFilter.property} filter ${eventFilter.value || "empty"}`, + remove: () => this.dispatchEvent(new CustomEvent("clear-event-filter", { + bubbles: true, + composed: true, + })), + }) : ""} + - ${this.filteringEnabled ? html`` : ""} + })), + }) : ""} ${this.localize.number(row.visitors)} ${this.localize.number(row.count)}
+ `;}); + return html` + ${renderAnalyticsMetricTable({ + caption: "Events", + rowHeading: "Event", + totalHeading: "Total events", + loading: this.loading, + skeletonRows: this.skeletonRows, + rows: body, + })} ${empty ? html`
@@ -74,39 +91,7 @@ export class WebAnalyticsEventTableElement extends UmbElementMixin(LitElement) { `; } - static styles = [analyticsTableSkeletonStyles, css` - :host { block-size: 100%; display: flex; flex-direction: column; overflow-x: auto; } - table { --bar-inset: var(--uui-size-space-3); border-collapse: collapse; min-inline-size: 30rem; table-layout: fixed; width: 100%; } - caption { clip: rect(0 0 0 0); height: 1px; overflow: hidden; position: absolute; width: 1px; } - th, td { box-sizing: border-box; padding: var(--uui-size-space-3) var(--uui-size-space-5); text-align: left; } - thead th { border-bottom: 1px solid var(--uui-color-border); font-weight: 700; } - thead th:not(:first-child), td { text-align: right; width: 8rem; } - tbody tr { height: 2.5rem; } - tbody th, tbody td { padding-block: 0; } - tbody th { font-weight: 500; min-width: 12rem; position: relative; } - td { font-variant-numeric: tabular-nums; position: relative; z-index: 1; } - tbody tr:hover, tbody tr:focus-within { position: relative; z-index: 2; } - .details-action { appearance: none; background: transparent; border: 0; color: var(--uui-color-text); cursor: pointer; font: inherit; max-width: 100%; overflow: hidden; padding: 0; position: relative; text-align: left; text-overflow: ellipsis; white-space: nowrap; z-index: 1; } - .details-action:hover { text-decoration: underline; text-underline-offset: 0.18em; } - .details-action:focus-visible { outline: 2px solid var(--uui-color-selected); outline-offset: 2px; } - .event-name { position: relative; z-index: 1; } - .metric-value { font-weight: 700; } - .metric-cell { align-items: center; display: flex; gap: var(--uui-size-space-2); justify-content: flex-end; } - .filter-action { align-items: center; appearance: none; background: transparent; border: 0; border-radius: var(--uui-border-radius); color: var(--uui-color-text-alt); cursor: pointer; display: inline-flex; font: inherit; justify-content: center; opacity: 0; padding: var(--uui-size-space-2); } - tbody tr:hover .filter-action, .filter-action:focus-visible, .filter-action[aria-pressed="true"] { opacity: 1; } - .filter-action:hover { background: color-mix(in srgb, var(--uui-color-interactive) 10%, var(--uui-color-surface)); color: var(--uui-color-interactive-emphasis); } - .filter-action[aria-pressed="true"] { background: color-mix(in srgb, var(--uui-color-interactive) 15%, var(--uui-color-surface)); color: var(--uui-color-interactive-emphasis); } - .filter-action:focus-visible { outline: 2px solid var(--uui-color-selected); outline-offset: 1px; } - .bar { inset-block: var(--uui-size-space-1); inset-inline-start: var(--bar-inset); inline-size: calc(100% + 16rem - 2 * var(--bar-inset)); position: absolute; } - .bar::before { background: color-mix(in srgb, var(--uui-color-interactive) 4%, var(--uui-color-surface)); block-size: 100%; border-radius: var(--uui-border-radius); content: ""; display: block; inline-size: max(var(--bar-minimum), var(--bar-width)); } - .empty { align-items: center; display: flex; flex: 1; flex-direction: column; gap: var(--uui-size-space-3); justify-content: center; min-block-size: 16rem; padding: var(--uui-size-layout-1); text-align: center; } - .empty-icon { align-items: center; border: 1px solid var(--uui-color-border); border-radius: 50%; color: var(--uui-color-text-alt); display: inline-flex; font-size: var(--uui-type-h4-size); height: 3rem; justify-content: center; width: 3rem; } - .empty p { color: var(--uui-color-text-alt); margin: 0; max-width: 34rem; } - .empty a { align-items: center; color: var(--uui-color-interactive-emphasis); display: inline-flex; gap: var(--uui-size-space-1); } - .empty a:focus-visible { outline: 2px solid var(--uui-color-selected); outline-offset: 2px; } - .visually-hidden { clip: rect(0 0 0 0); clip-path: inset(50%); height: 1px; overflow: hidden; position: absolute; white-space: nowrap; width: 1px; } - @media (hover: none) { .filter-action { opacity: 1; } } - `]; + static styles = [analyticsTableSkeletonStyles, analyticsMetricTableStyles, analyticsTableFilterActionStyles, analyticsDrilldownFilterStyles]; } declare global { diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/flag-card.element.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/flag-card.element.ts deleted file mode 100644 index 131cf50..0000000 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/flag-card.element.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { LitElement, css, customElement, html, property } from "@umbraco-cms/backoffice/external/lit"; -import { UmbElementMixin } from "@umbraco-cms/backoffice/element-api"; -import { UmbTextStyles } from "@umbraco-cms/backoffice/style"; -import type { AnalyticsFlagRow, AnalyticsFlagsReport } from "../api/types.gen.js"; -import { isInitialLoading, stateData, type AsyncState } from "./async-state.js"; - -const FLAGS_SETUP_URL = "https://vercel.com/docs/flags/observability/web-analytics"; - -@customElement("web-analytics-flag-card") -export class WebAnalyticsFlagCardElement extends UmbElementMixin(LitElement) { - @property({ attribute: false }) report: AsyncState = { status: "loading" }; - @property({ attribute: false }) selected?: AsyncState; - - #select(value: string): void { - this.dispatchEvent(new CustomEvent("select-flag", { bubbles: true, composed: true, detail: { flagKey: value } })); - } - - #clear(): void { - this.dispatchEvent(new CustomEvent("clear-selected-flag", { bubbles: true, composed: true })); - } - - #rows(report: AnalyticsFlagsReport | undefined): AnalyticsFlagRow[] { - return (report?.rows ?? []).filter(({ value }) => value !== "Others"); - } - - render() { - const activeState = this.selected ?? this.report; - const data = stateData(activeState); - const rows = this.#rows(data); - const loading = isInitialLoading(activeState); - const unavailable = activeState.status === "error" ? activeState.message : undefined; - const selectedKey = data?.flagKey; - const maximum = Math.max(...rows.map(({ pageViews }) => pageViews), 1); - - return html` -
-
- ${selectedKey ? html` - - ` : html`Flags`} -
- VisitorsTotal -
- ${loading ? html` -
Loading feature flags - ${Array.from({ length: 3 }, () => html`
`)} -
- ` : unavailable ? html` -
Flags could not be loaded

${unavailable}

- ` : rows.length === 0 ? html` -
- - No flags -

Track feature flags to understand how they affect visitor behaviour.

- Set up flag tracking -
- ` : html` -
- ${rows.map((row) => html` -
- - ${selectedKey - ? html`${row.value}` - : html``} - ${this.localize.number(row.visitors)} - ${this.localize.number(row.pageViews)} -
- `)} -
- `} - `; - } - - static styles = [UmbTextStyles, css` - :host { block-size: 100%; display: flex; flex-direction: column; min-block-size: 22rem; } - .header { align-items: center; block-size: 2.5rem; border-bottom: 1px solid var(--uui-color-border); box-sizing: border-box; display: grid; gap: var(--uui-size-space-4); grid-template-columns: minmax(0, 1fr) 8rem 8rem; padding-inline: var(--uui-size-space-5); } - .header > span { font-weight: 700; text-align: right; } - .title { align-items: center; display: flex; gap: var(--uui-size-space-2); min-width: 0; } - .flag-back { align-items: center; appearance: none; background: transparent; block-size: 2rem; border: 0; color: var(--uui-color-text); cursor: pointer; display: inline-flex; font: inherit; font-weight: 700; gap: var(--uui-size-space-2); margin-inline-start: calc(-1 * var(--uui-size-space-2)); max-inline-size: 100%; min-inline-size: 0; padding: 0; } - .flag-back uui-icon { flex: 0 0 auto; font-size: 1.25rem; } - .flag-back:hover .selected-label { text-decoration: underline; text-underline-offset: .18em; } - .selected-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } - .flag-back:focus-visible, .select:focus-visible, a:focus-visible { outline: 2px solid var(--uui-color-selected); outline-offset: 2px; } - .rows { padding: 0; } - .row { align-items: center; display: grid; gap: var(--uui-size-space-4); grid-template-columns: minmax(0, 1fr) 8rem 8rem; min-block-size: 2.5rem; padding: 0 var(--uui-size-space-5); position: relative; } - .row > :not(.bar) { position: relative; z-index: 1; } - .row > strong:not(.value) { font-variant-numeric: tabular-nums; text-align: right; } - .bar { inset-block: var(--uui-size-space-1); inset-inline: var(--uui-size-space-3); position: absolute; } - .bar::before { background: color-mix(in srgb, var(--uui-color-interactive) 4%, var(--uui-color-surface)); border-radius: var(--uui-border-radius); block-size: 100%; content: ""; display: block; inline-size: max(4px, var(--bar-width)); } - .value { overflow: hidden; text-align: left; text-overflow: ellipsis; white-space: nowrap; } - .select { appearance: none; background: transparent; border: 0; color: var(--uui-color-text); cursor: pointer; font: inherit; font-weight: 500; padding: 0; } - .select:hover { text-decoration: underline; text-underline-offset: .18em; } - .empty { align-items: center; display: flex; flex: 1; flex-direction: column; gap: var(--uui-size-space-3); justify-content: center; padding: var(--uui-size-layout-1); text-align: center; } - .empty-icon { align-items: center; border: 1px solid var(--uui-color-border); border-radius: 50%; color: var(--uui-color-text-alt); display: inline-flex; font-size: 1.5rem; height: 3rem; justify-content: center; width: 3rem; } - .empty p { color: var(--uui-color-text-alt); margin: 0; max-width: 34rem; } - .empty a { align-items: center; color: var(--uui-color-interactive-emphasis); display: inline-flex; gap: var(--uui-size-space-1); } - .error { color: var(--uui-color-danger); } - .skeleton span { background: var(--uui-color-surface-alt); border-radius: var(--uui-border-radius); block-size: 1rem; } - .skeleton span:first-child { inline-size: 55%; } - .visually-hidden { clip: rect(0 0 0 0); clip-path: inset(50%); height: 1px; overflow: hidden; position: absolute; white-space: nowrap; width: 1px; } - @media (max-width: 36rem) { - .header, .row { grid-template-columns: minmax(0, 1fr) 5rem 5rem; } - .header, .row { padding-inline: var(--uui-size-space-4); } - } - `]; -} - -declare global { interface HTMLElementTagNameMap { "web-analytics-flag-card": WebAnalyticsFlagCardElement; } } diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/flag-dialog.element.test.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/flag-dialog.element.test.ts new file mode 100644 index 0000000..1298ff8 --- /dev/null +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/flag-dialog.element.test.ts @@ -0,0 +1,57 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@umbraco-cms/backoffice/element-api", () => ({ + UmbElementMixin: (base: T) => class extends base { + readonly localize = { + number: (value: string | number) => new Intl.NumberFormat("en-US").format(Number(value)), + }; + }, +})); +vi.mock("@umbraco-cms/backoffice/style", () => ({ UmbTextStyles: [] })); + +import { successState } from "./async-state.js"; +import type { WebAnalyticsFlagDialogElement } from "./flag-dialog.element.js"; +import type { WebAnalyticsFlagTableElement } from "./flag-table.element.js"; +import "./flag-dialog.element.js"; + +beforeEach(() => { HTMLDialogElement.prototype.showModal = vi.fn(); }); +afterEach(() => document.body.replaceChildren()); + +describe("flag dialog", () => { + it("renders the complete flag list as selectable rows", async () => { + const dialog = document.createElement("web-analytics-flag-dialog") as WebAnalyticsFlagDialogElement; + dialog.report = successState({ rows: [{ value: "summer-sale", visitors: 184, pageViews: 841 }] }); + document.body.append(dialog); + await dialog.updateComplete; + + const table = dialog.shadowRoot?.querySelector("web-analytics-flag-table"); + expect(dialog.shadowRoot?.querySelector("dialog")?.getAttribute("aria-label")).toBe("Flags"); + expect(table?.mode).toBe("list"); + }); + + it("renders selected flag values and returns to the list", async () => { + const dialog = document.createElement("web-analytics-flag-dialog") as WebAnalyticsFlagDialogElement; + dialog.report = successState({ rows: [{ value: "summer-sale", visitors: 184, pageViews: 841 }] }); + dialog.selected = { + flagKey: "summer-sale", + report: successState({ flagKey: "summer-sale", rows: [{ value: "true", visitors: 53, pageViews: 200 }] }), + }; + dialog.flagFilter = { flagKey: "summer-sale", value: "true" }; + const onBack = vi.fn(); + dialog.addEventListener("clear-selected-flag", onBack); + document.body.append(dialog); + await dialog.updateComplete; + + const table = dialog.shadowRoot?.querySelector("web-analytics-flag-table"); + const back = dialog.shadowRoot?.querySelector(".analytics-dialog-back"); + expect(dialog.shadowRoot?.querySelector("dialog")?.getAttribute("aria-label")).toBe("summer-sale flag details"); + expect(back?.textContent?.trim()).toBe("summer-sale"); + expect(table?.mode).toBe("detail"); + expect(table?.flagKey).toBe("summer-sale"); + expect(table?.flagFilter).toEqual({ flagKey: "summer-sale", value: "true" }); + + back?.click(); + expect(onBack).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/flag-dialog.element.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/flag-dialog.element.ts new file mode 100644 index 0000000..c048a73 --- /dev/null +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/flag-dialog.element.ts @@ -0,0 +1,59 @@ +import { LitElement, customElement, html, property } from "@umbraco-cms/backoffice/external/lit"; +import { UmbElementMixin } from "@umbraco-cms/backoffice/element-api"; +import { UmbTextStyles } from "@umbraco-cms/backoffice/style"; +import type { AnalyticsFlagsReport } from "../api/types.gen.js"; +import { renderAnalyticsDialogFrame } from "./analytics-dialog-frame.js"; +import { analyticsDialogStyles } from "./analytics-dialog.styles.js"; +import { isInitialLoading, stateData, type AsyncState } from "./async-state.js"; +import { openDialog } from "./dialog-lifecycle.js"; +import type { AnalyticsFlagFilter } from "./dashboard-url-state.js"; +import type { SelectedFlag } from "./dashboard-drilldown-state.js"; +import "./flag-table.element.js"; + +@customElement("web-analytics-flag-dialog") +export class WebAnalyticsFlagDialogElement extends UmbElementMixin(LitElement) { + @property({ attribute: false }) report: AsyncState = { status: "loading" }; + @property({ attribute: false }) selected?: SelectedFlag; + @property({ attribute: false }) flagFilter?: AnalyticsFlagFilter; + + protected firstUpdated(): void { openDialog(this); } + + #back(selectedKey: string) { + return html` + + `; + } + + render() { + const activeState = this.selected?.report ?? this.report; + const data = stateData(activeState); + const selectedKey = this.selected?.flagKey; + return renderAnalyticsDialogFrame({ + host: this, + ariaLabel: selectedKey ? `${selectedKey} flag details` : "Flags", + closeLabel: "Close flags", + headline: selectedKey ? this.#back(selectedKey) : "Flags", + body: html` +
+ ${activeState.status === "error" ? html`

${activeState.message}

` : html` + + `} +
+ `, + }); + } + + static styles = [UmbTextStyles, analyticsDialogStyles]; +} + +declare global { interface HTMLElementTagNameMap { "web-analytics-flag-dialog": WebAnalyticsFlagDialogElement; } } diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/flag-table.element.test.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/flag-table.element.test.ts new file mode 100644 index 0000000..8e3d6d8 --- /dev/null +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/flag-table.element.test.ts @@ -0,0 +1,120 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@umbraco-cms/backoffice/element-api", () => ({ + UmbElementMixin: (base: T) => class extends base { + readonly localize = { + number: (value: string | number) => new Intl.NumberFormat("en-US").format(Number(value)), + }; + }, +})); + +import type { WebAnalyticsFlagTableElement } from "./flag-table.element.js"; +import { analyticsTableSkeletonStyles } from "./analytics-table-skeleton.js"; +import "./flag-table.element.js"; + +afterEach(() => document.body.replaceChildren()); + +describe("flag table", () => { + it("keeps the loading announcement visually hidden", async () => { + const table = document.createElement("web-analytics-flag-table") as WebAnalyticsFlagTableElement; + table.loading = true; + document.body.append(table); + await table.updateComplete; + + const status = table.shadowRoot?.querySelector('[role="status"]'); + expect(status?.textContent).toBe("Loading feature flags"); + expect(status?.classList.contains("visually-hidden")).toBe(true); + expect(analyticsTableSkeletonStyles.cssText).toContain(".visually-hidden"); + }); + + it("selects flag keys in list mode", async () => { + const table = document.createElement("web-analytics-flag-table") as WebAnalyticsFlagTableElement; + table.mode = "list"; + table.rows = [{ value: "summer-sale", visitors: 184, pageViews: 841 }]; + const onSelect = vi.fn(); + table.addEventListener("select-flag", onSelect); + document.body.append(table); + await table.updateComplete; + + table.shadowRoot?.querySelector(".row-action")?.click(); + + expect((onSelect.mock.calls[0][0] as CustomEvent).detail).toEqual({ flagKey: "summer-sale" }); + expect(table.shadowRoot?.querySelector(".row-value")).toBeNull(); + }); + + it("renders flag values as text in detail mode", async () => { + const table = document.createElement("web-analytics-flag-table") as WebAnalyticsFlagTableElement; + table.mode = "detail"; + table.rows = [{ value: "true", visitors: 53, pageViews: 200 }]; + document.body.append(table); + await table.updateComplete; + + expect(table.shadowRoot?.querySelector("tbody .row-value")?.textContent).toBe("true"); + expect(table.shadowRoot?.querySelector(".row-action")).toBeNull(); + }); + + it("filters analytics by a drilled-in flag value", async () => { + const table = document.createElement("web-analytics-flag-table") as WebAnalyticsFlagTableElement; + table.mode = "detail"; + table.flagKey = "new-pricing-page"; + table.flagFilter = { flagKey: "new-pricing-page", value: "control" }; + table.rows = [{ value: "control", visitors: 53, pageViews: 200 }]; + const onFilter = vi.fn(); + table.addEventListener("toggle-flag-filter", onFilter); + document.body.append(table); + await table.updateComplete; + + const action = table.shadowRoot?.querySelector(".filter-action"); + expect(action?.getAttribute("aria-pressed")).toBe("true"); + expect(action?.getAttribute("aria-label")).toContain("Remove new-pricing-page flag value filter control"); + action?.click(); + + expect((onFilter.mock.calls[0][0] as CustomEvent).detail).toEqual({ + flagKey: "new-pricing-page", + value: "control", + }); + + table.mode = "list"; + await table.updateComplete; + expect(table.shadowRoot?.querySelector(".filter-action")).toBeNull(); + }); + + it("shows and removes the active flag value on the overview row", async () => { + const table = document.createElement("web-analytics-flag-table") as WebAnalyticsFlagTableElement; + table.mode = "overview"; + table.flagFilter = { flagKey: "new-pricing-page", value: "control" }; + table.rows = [{ value: "new-pricing-page", visitors: 53, pageViews: 200 }]; + const onFilter = vi.fn(); + table.addEventListener("toggle-flag-filter", onFilter); + document.body.append(table); + await table.updateComplete; + + const filter = table.shadowRoot?.querySelector(".drilldown-filter"); + expect(filter?.textContent?.trim()).toBe("control"); + expect(filter?.getAttribute("aria-label")).toBe("Remove new-pricing-page flag value filter control"); + expect(filter?.parentElement?.classList.contains("row-heading-content")).toBe(true); + filter?.click(); + + expect((onFilter.mock.calls[0][0] as CustomEvent).detail).toEqual({ + flagKey: "new-pricing-page", + value: "control", + }); + }); + + it("shows setup guidance only in overview mode", async () => { + const table = document.createElement("web-analytics-flag-table") as WebAnalyticsFlagTableElement; + table.mode = "overview"; + document.body.append(table); + await table.updateComplete; + + const setupLink = table.shadowRoot?.querySelector(".empty a"); + expect(table.shadowRoot?.querySelector(".empty-icon uui-icon")?.getAttribute("name")).toBe("icon-flag"); + expect(setupLink?.href).toBe("https://vercel.com/docs/flags/observability/web-analytics"); + expect(setupLink?.rel).toBe("noopener noreferrer"); + + table.mode = "list"; + await table.updateComplete; + expect(table.shadowRoot?.querySelector(".empty a")).toBeNull(); + }); +}); diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/flag-table.element.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/flag-table.element.ts new file mode 100644 index 0000000..ee1cd75 --- /dev/null +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/flag-table.element.ts @@ -0,0 +1,99 @@ +import { LitElement, customElement, html, property } from "@umbraco-cms/backoffice/external/lit"; +import { UmbElementMixin } from "@umbraco-cms/backoffice/element-api"; +import type { AnalyticsFlagRow } from "../api/types.gen.js"; +import type { AnalyticsFlagFilter } from "./dashboard-url-state.js"; +import { analyticsTableFilterActionStyles, renderAnalyticsFilterAction } from "./analytics-filter-action.js"; +import { analyticsTableSkeletonStyles } from "./analytics-table-skeleton.js"; +import { analyticsDrilldownFilterStyles, renderAnalyticsDrilldownFilter } from "./analytics-drilldown-filter.js"; +import { analyticsMetricTableStyles, renderAnalyticsMetricTable } from "./analytics-metric-table.js"; + +const FLAGS_SETUP_URL = "https://vercel.com/docs/flags/observability/web-analytics"; +export type FlagTableMode = "detail" | "list" | "overview"; + +@customElement("web-analytics-flag-table") +export class WebAnalyticsFlagTableElement extends UmbElementMixin(LitElement) { + @property({ type: Boolean }) loading = false; + @property() mode: FlagTableMode = "overview"; + @property({ attribute: false }) rows: AnalyticsFlagRow[] = []; + @property() flagKey?: string; + @property({ attribute: false }) flagFilter?: AnalyticsFlagFilter; + + #select(flagKey: string): void { + this.dispatchEvent(new CustomEvent("select-flag", { + bubbles: true, + composed: true, + detail: { flagKey }, + })); + } + + render() { + const rows = this.rows.filter(({ value }) => value !== "Others"); + const empty = !this.loading && rows.length === 0; + const maximum = Math.max(...rows.map(({ pageViews }) => pageViews), 1); + const body = rows.map((row) => { + const activeFilter = this.flagKey === this.flagFilter?.flagKey && row.value === this.flagFilter?.value; + const filterLabel = activeFilter + ? `Remove ${this.flagKey} flag value filter ${row.value}` + : `Filter analytics by ${this.flagKey} flag value ${row.value}`; + return html` + + + 0 ? "4px" : "0px"}`}> + + ${this.mode !== "detail" + ? html`` + : html`${row.value}`} + ${this.mode === "overview" && this.flagFilter?.flagKey === row.value + ? renderAnalyticsDrilldownFilter({ + label: this.flagFilter.value || "(empty)", + removeLabel: `Remove ${row.value} flag value filter ${this.flagFilter.value || "empty"}`, + remove: () => this.dispatchEvent(new CustomEvent("toggle-flag-filter", { + bubbles: true, + composed: true, + detail: this.flagFilter, + })), + }) + : ""} + + + + ${this.mode === "detail" && this.flagKey ? renderAnalyticsFilterAction({ + active: activeFilter, + label: filterLabel, + toggle: () => this.dispatchEvent(new CustomEvent("toggle-flag-filter", { + bubbles: true, + composed: true, + detail: { flagKey: this.flagKey, value: row.value }, + })), + }) : ""} + ${this.localize.number(row.visitors)} + + ${this.localize.number(row.pageViews)} + + `;}); + return html` + ${renderAnalyticsMetricTable({ + caption: "Feature flags", + rowHeading: "Flags", + totalHeading: "Total", + loading: this.loading, + skeletonRows: 3, + rows: body, + })} + ${empty ? html` +
+ + No flags +

Track feature flags to understand how they affect visitor behaviour.

+ ${this.mode === "overview" ? html` + Set up flag tracking + ` : ""} +
+ ` : ""} + `; + } + + static styles = [analyticsTableSkeletonStyles, analyticsMetricTableStyles, analyticsTableFilterActionStyles, analyticsDrilldownFilterStyles]; +} + +declare global { interface HTMLElementTagNameMap { "web-analytics-flag-table": WebAnalyticsFlagTableElement; } } diff --git a/src/TheBuilder.WebAnalytics/Client/src/api/types.gen.ts b/src/TheBuilder.WebAnalytics/Client/src/api/types.gen.ts index 56d1a1d..17cb298 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/api/types.gen.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/api/types.gen.ts @@ -21,6 +21,7 @@ export type AnalyticsCapabilities = { eventDetails: boolean; eventProperties: boolean; globalEventFiltering: boolean; + globalEventPropertyFiltering: boolean; flags: boolean; breakdownOrdering: boolean; }; @@ -294,6 +295,11 @@ export type BreakdownData = { culture?: string; path?: string; filter?: Array; + filterFlagKey?: string; + filterFlagValue?: string; + filterEventName?: string; + filterEventProperty?: string; + filterEventValue?: string; }; url: '/umbraco/management/api/v1/web-analytics/reports/breakdown/{dimension}'; }; @@ -328,6 +334,11 @@ export type EventsData = { culture?: string; path?: string; filter?: Array; + filterFlagKey?: string; + filterFlagValue?: string; + filterEventName?: string; + filterEventProperty?: string; + filterEventValue?: string; }; url: '/umbraco/management/api/v1/web-analytics/reports/events'; }; @@ -363,6 +374,11 @@ export type EventDetailsData = { culture?: string; path?: string; filter?: Array; + filterFlagKey?: string; + filterFlagValue?: string; + filterEventName?: string; + filterEventProperty?: string; + filterEventValue?: string; }; url: '/umbraco/management/api/v1/web-analytics/reports/events/details'; }; @@ -401,6 +417,11 @@ export type EventPropertyValuesData = { culture?: string; path?: string; filter?: Array; + filterFlagKey?: string; + filterFlagValue?: string; + filterEventName?: string; + filterEventProperty?: string; + filterEventValue?: string; }; url: '/umbraco/management/api/v1/web-analytics/reports/events/property-values'; }; @@ -435,6 +456,11 @@ export type FlagsData = { culture?: string; path?: string; filter?: Array; + filterFlagKey?: string; + filterFlagValue?: string; + filterEventName?: string; + filterEventProperty?: string; + filterEventValue?: string; }; url: '/umbraco/management/api/v1/web-analytics/reports/flags'; }; @@ -467,6 +493,11 @@ export type SummaryData = { culture?: string; path?: string; filter?: Array; + filterFlagKey?: string; + filterFlagValue?: string; + filterEventName?: string; + filterEventProperty?: string; + filterEventValue?: string; }; url: '/umbraco/management/api/v1/web-analytics/reports/summary'; }; diff --git a/src/TheBuilder.WebAnalytics/Client/src/settings/settings-dashboard.test.ts b/src/TheBuilder.WebAnalytics/Client/src/settings/settings-dashboard.test.ts index e53b3f1..8282837 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/settings/settings-dashboard.test.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/settings/settings-dashboard.test.ts @@ -229,7 +229,7 @@ const PROVIDERS: AnalyticsSettingsResponse["providers"] = [ provider: "Vercel", description: "Projects using Vercel Web Analytics", logoSlug: "vercel", - capabilities: { dimensions: ["RequestPath", "EventName"], events: true, eventDetails: true, eventProperties: false, globalEventFiltering: false, flags: true, breakdownOrdering: false }, + capabilities: { dimensions: ["RequestPath", "EventName"], events: true, eventDetails: true, eventProperties: false, globalEventFiltering: false, globalEventPropertyFiltering: false, flags: true, breakdownOrdering: false }, identifier: { key: "projectId", label: "Vercel project ID", description: "Use the project ID from your Vercel project settings.", requiredMessage: "a Vercel project ID" }, team: { key: "team", label: "Team ID or slug", description: "Optional team slug or ID for projects owned by a Vercel team." }, credential: { label: "access token", description: "Configure a Vercel access token in the server settings.", documentationUrl: "https://vercel.com/docs/rest-api" }, @@ -239,7 +239,7 @@ const PROVIDERS: AnalyticsSettingsResponse["providers"] = [ provider: "Plausible", description: "Sites using Plausible Analytics", logoSlug: "plausible", - capabilities: { dimensions: ["RequestPath", "EventName"], events: true, eventDetails: true, eventProperties: true, globalEventFiltering: true, flags: false, breakdownOrdering: true }, + capabilities: { dimensions: ["RequestPath", "EventName"], events: true, eventDetails: true, eventProperties: true, globalEventFiltering: true, globalEventPropertyFiltering: true, flags: false, breakdownOrdering: true }, identifier: { key: "siteId", label: "Plausible site ID", description: "Use the domain configured in your Plausible site settings.", requiredMessage: "a Plausible site ID" }, team: null, credential: { label: "Stats API key", description: "Configure a Plausible Stats API key in the server settings.", documentationUrl: "https://plausible.io/docs/stats-api" }, diff --git a/src/TheBuilder.WebAnalytics/Client/src/settings/settings-model.test.ts b/src/TheBuilder.WebAnalytics/Client/src/settings/settings-model.test.ts index 511b527..64c6659 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/settings/settings-model.test.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/settings/settings-model.test.ts @@ -17,7 +17,7 @@ const settings = (): AnalyticsSettingsResponse => ({ provider: "Vercel", description: "Projects using Vercel Web Analytics", logoSlug: "vercel", - capabilities: { dimensions: ["RequestPath", "EventName"], events: true, eventDetails: true, eventProperties: false, globalEventFiltering: false, flags: true, breakdownOrdering: false }, + capabilities: { dimensions: ["RequestPath", "EventName"], events: true, eventDetails: true, eventProperties: false, globalEventFiltering: false, globalEventPropertyFiltering: false, flags: true, breakdownOrdering: false }, identifier: { key: "projectId", label: "Vercel project ID", description: "Use the project ID from your Vercel project settings.", requiredMessage: "a Vercel project ID" }, team: { key: "team", label: "Team ID or slug", description: "Optional team slug or ID for projects owned by a Vercel team." }, credential: { label: "access token", description: "Configure a Vercel access token in the server settings.", documentationUrl: "https://vercel.com/docs/rest-api" }, @@ -27,7 +27,7 @@ const settings = (): AnalyticsSettingsResponse => ({ provider: "Plausible", description: "Sites using Plausible Analytics", logoSlug: "plausible", - capabilities: { dimensions: ["RequestPath", "EventName"], events: true, eventDetails: true, eventProperties: true, globalEventFiltering: true, flags: false, breakdownOrdering: true }, + capabilities: { dimensions: ["RequestPath", "EventName"], events: true, eventDetails: true, eventProperties: true, globalEventFiltering: true, globalEventPropertyFiltering: true, flags: false, breakdownOrdering: true }, identifier: { key: "siteId", label: "Plausible site ID", description: "Use the domain configured in your Plausible site settings.", requiredMessage: "a Plausible site ID" }, team: null, credential: { label: "Stats API key", description: "Configure a Plausible Stats API key in the server settings.", documentationUrl: "https://plausible.io/docs/stats-api" }, diff --git a/src/TheBuilder.WebAnalytics/Configuration/AnalyticsConnectionRegistry.cs b/src/TheBuilder.WebAnalytics/Configuration/AnalyticsConnectionRegistry.cs index 8dc6cd0..314e0e3 100644 --- a/src/TheBuilder.WebAnalytics/Configuration/AnalyticsConnectionRegistry.cs +++ b/src/TheBuilder.WebAnalytics/Configuration/AnalyticsConnectionRegistry.cs @@ -143,6 +143,9 @@ public AnalyticsCapabilities Capabilities EventDetails = supported.EventDetails && EnableEvents, EventProperties = supported.EventProperties && EnableEvents, GlobalEventFiltering = supported.GlobalEventFiltering && EnableEvents, + GlobalEventPropertyFiltering = IsMock + ? supported.EventProperties && EnableEvents + : supported.GlobalEventPropertyFiltering && EnableEvents, Flags = supported.Flags && EnableFlags }; } diff --git a/src/TheBuilder.WebAnalytics/Configuration/AnalyticsProviderCapabilities.cs b/src/TheBuilder.WebAnalytics/Configuration/AnalyticsProviderCapabilities.cs index 7ee390c..6f09b21 100644 --- a/src/TheBuilder.WebAnalytics/Configuration/AnalyticsProviderCapabilities.cs +++ b/src/TheBuilder.WebAnalytics/Configuration/AnalyticsProviderCapabilities.cs @@ -8,6 +8,7 @@ internal static class AnalyticsProviderCapabilities internal static AnalyticsCapabilities FromClient( IReadOnlyList dimensions, bool globalEventFiltering, + bool globalEventPropertyFiltering, bool breakdownOrdering) where TClient : class, IAnalyticsProviderClient { @@ -20,6 +21,9 @@ internal static AnalyticsCapabilities FromClient( if (globalEventFiltering && !events) throw new InvalidOperationException( $"{clientType.Name} cannot enable global event filtering without implementing {nameof(IAnalyticsEventsProviderClient)}."); + if (globalEventPropertyFiltering && !eventProperties) + throw new InvalidOperationException( + $"{clientType.Name} cannot enable global event property filtering without implementing {nameof(IAnalyticsEventPropertiesProviderClient)}."); return new( dimensions, @@ -27,6 +31,7 @@ internal static AnalyticsCapabilities FromClient( eventDetails, eventProperties, globalEventFiltering, + globalEventPropertyFiltering, flags, breakdownOrdering); } diff --git a/src/TheBuilder.WebAnalytics/Controllers/AnalyticsReportFilters.cs b/src/TheBuilder.WebAnalytics/Controllers/AnalyticsReportFilters.cs new file mode 100644 index 0000000..deb89c0 --- /dev/null +++ b/src/TheBuilder.WebAnalytics/Controllers/AnalyticsReportFilters.cs @@ -0,0 +1,70 @@ +using Microsoft.AspNetCore.Mvc; +using TheBuilder.WebAnalytics.Models; +using TheBuilder.WebAnalytics.Services; + +namespace TheBuilder.WebAnalytics.Controllers; + +public sealed class AnalyticsReportFilters +{ + [FromQuery(Name = "filter")] + public string[]? Filter { get; init; } + + [FromQuery(Name = "filterFlagKey")] + public string? FilterFlagKey { get; init; } + + [FromQuery(Name = "filterFlagValue")] + public string? FilterFlagValue { get; init; } + + [FromQuery(Name = "filterEventName")] + public string? FilterEventName { get; init; } + + [FromQuery(Name = "filterEventProperty")] + public string? FilterEventProperty { get; init; } + + [FromQuery(Name = "filterEventValue")] + public string? FilterEventValue { get; init; } + + internal bool TryParse(out ParsedAnalyticsReportFilters parsed, out string? error) + { + parsed = new([], null, null); + if (!AnalyticsFilterParser.TryParse(Filter, out var filters, out error)) return false; + + var hasFlagKey = !string.IsNullOrWhiteSpace(FilterFlagKey); + if (hasFlagKey != (FilterFlagValue is not null)) + { + error = "Flag filter key and value must be supplied together."; + return false; + } + if (FilterFlagKey?.Length > 255 || FilterFlagValue?.Length > 500) + { + error = "Flag filter key must be 255 characters or fewer and value must be 500 characters or fewer."; + return false; + } + + var hasEventName = !string.IsNullOrWhiteSpace(FilterEventName); + var hasEventProperty = !string.IsNullOrWhiteSpace(FilterEventProperty); + var hasEventValue = FilterEventValue is not null; + if (hasEventName != hasEventProperty || hasEventName != hasEventValue) + { + error = "Event filter name, property, and value must be supplied together."; + return false; + } + if (FilterEventName?.Length > 255 || FilterEventProperty?.Length > 255 || FilterEventValue?.Length > 500) + { + error = "Event filter name and property must be 255 characters or fewer and value must be 500 characters or fewer."; + return false; + } + + parsed = new( + filters, + hasFlagKey ? new AnalyticsFlagFilter(FilterFlagKey!.Trim(), FilterFlagValue!) : null, + hasEventName ? new AnalyticsEventFilter(FilterEventName!.Trim(), FilterEventProperty!.Trim(), FilterEventValue!) : null); + error = null; + return true; + } +} + +internal sealed record ParsedAnalyticsReportFilters( + IReadOnlyList Filters, + AnalyticsFlagFilter? FlagFilter, + AnalyticsEventFilter? EventFilter); diff --git a/src/TheBuilder.WebAnalytics/Controllers/WebAnalyticsApiController.cs b/src/TheBuilder.WebAnalytics/Controllers/WebAnalyticsApiController.cs index 91ef2f8..d5a278f 100644 --- a/src/TheBuilder.WebAnalytics/Controllers/WebAnalyticsApiController.cs +++ b/src/TheBuilder.WebAnalytics/Controllers/WebAnalyticsApiController.cs @@ -97,11 +97,11 @@ public async Task> Summary( [FromQuery] Guid? documentId, [FromQuery] string? culture, [FromQuery] string? path, - [FromQuery] string[]? filter, + [FromQuery] AnalyticsReportFilters? reportFilters, CancellationToken cancellationToken) { var scope = await AuthorizeAndBuildQueryAsync( - connection, from, to, interval, documentId, culture, path, filter, ReportScope.Visits, + connection, from, to, interval, documentId, culture, path, reportFilters, ReportScope.Visits, new(ReportCapability.Core), cancellationToken); if (scope.Error is not null) return scope.Error; var report = await reportService.GetSummaryAsync(scope.Query!, cancellationToken); @@ -123,7 +123,7 @@ public async Task> Breakdown( [FromQuery] Guid? documentId = null, [FromQuery] string? culture = null, [FromQuery] string? path = null, - [FromQuery] string[]? filter = null, + [FromQuery] AnalyticsReportFilters? reportFilters = null, CancellationToken cancellationToken = default) { if (!Enum.IsDefined(dimension) || dimension == AnalyticsDimension.EventName) @@ -135,7 +135,7 @@ public async Task> Breakdown( if (orderBy is not null && !Enum.IsDefined(orderBy.Value)) return ValidationProblem("The requested breakdown metric is not supported."); var scope = await AuthorizeAndBuildQueryAsync( - connection, from, to, interval, documentId, culture, path, filter, ReportScope.Visits, + connection, from, to, interval, documentId, culture, path, reportFilters, ReportScope.Visits, new(ReportCapability.Breakdown, dimension), cancellationToken); if (scope.Error is not null) return scope.Error; if (orderBy is not null && registry.Get(connection)?.Capabilities.BreakdownOrdering is not true) @@ -157,13 +157,13 @@ public async Task> Events( [FromQuery] Guid? documentId = null, [FromQuery] string? culture = null, [FromQuery] string? path = null, - [FromQuery] string[]? filter = null, + [FromQuery] AnalyticsReportFilters? reportFilters = null, CancellationToken cancellationToken = default) { if (limit is < 1 or > 100) return ValidationProblem("Limit must be between 1 and 100."); if (search?.Length > 200) return ValidationProblem("Search must be 200 characters or fewer."); var scope = await AuthorizeAndBuildQueryAsync( - connection, from, to, interval, documentId, culture, path, filter, ReportScope.EventList, + connection, from, to, interval, documentId, culture, path, reportFilters, ReportScope.EventList, new(ReportCapability.Events), cancellationToken); if (scope.Error is not null) return scope.Error; var report = await reportService.GetEventsAsync(scope.Query!, limit, search, cancellationToken); @@ -183,13 +183,13 @@ public async Task> Flags( [FromQuery] Guid? documentId = null, [FromQuery] string? culture = null, [FromQuery] string? path = null, - [FromQuery] string[]? filter = null, + [FromQuery] AnalyticsReportFilters? reportFilters = null, CancellationToken cancellationToken = default) { if (flagKey?.Length > 255) return ValidationProblem("Flag key must be 255 characters or fewer."); if (limit is < 1 or > 100) return ValidationProblem("Limit must be between 1 and 100."); var scope = await AuthorizeAndBuildQueryAsync( - connection, from, to, interval, documentId, culture, path, filter, ReportScope.Visits, + connection, from, to, interval, documentId, culture, path, reportFilters, ReportScope.Visits, new(ReportCapability.Flags), cancellationToken); if (scope.Error is not null) return scope.Error; var report = await reportService.GetFlagsAsync(scope.Query!, flagKey, limit, cancellationToken); @@ -210,7 +210,7 @@ public async Task> EventDetails( [FromQuery] Guid? documentId = null, [FromQuery] string? culture = null, [FromQuery] string? path = null, - [FromQuery] string[]? filter = null, + [FromQuery] AnalyticsReportFilters? reportFilters = null, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(eventName) || eventName.Length > 255) @@ -227,7 +227,7 @@ public async Task> EventDetails( } var scope = await AuthorizeAndBuildQueryAsync( - connection, from, to, interval, documentId, culture, path, filter, ReportScope.EventSelection, + connection, from, to, interval, documentId, culture, path, reportFilters, ReportScope.EventSelection, new(string.IsNullOrWhiteSpace(eventProperty) ? ReportCapability.EventDetails : ReportCapability.EventProperties), cancellationToken); if (scope.Error is not null) return scope.Error; var eventDataFilter = string.IsNullOrWhiteSpace(eventProperty) @@ -254,7 +254,7 @@ public async Task> EventPropertyValues( [FromQuery] Guid? documentId = null, [FromQuery] string? culture = null, [FromQuery] string? path = null, - [FromQuery] string[]? filter = null, + [FromQuery] AnalyticsReportFilters? reportFilters = null, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(eventName) || eventName.Length > 255) @@ -277,7 +277,7 @@ public async Task> EventPropertyValues( } var scope = await AuthorizeAndBuildQueryAsync( - connection, from, to, interval, documentId, culture, path, filter, ReportScope.EventSelection, + connection, from, to, interval, documentId, culture, path, reportFilters, ReportScope.EventSelection, new(ReportCapability.EventProperties), cancellationToken); if (scope.Error is not null) return scope.Error; var eventDataFilter = string.IsNullOrWhiteSpace(eventProperty) @@ -302,7 +302,7 @@ public async Task> EventPropertyValues( Guid? documentId, string? culture, string? path, - IReadOnlyList? filterValues, + AnalyticsReportFilters? reportFilters, ReportScope reportScope, ReportRequirement reportRequirement, CancellationToken cancellationToken) @@ -315,10 +315,12 @@ public async Task> EventPropertyValues( { return (null, ValidationProblem("The date range must be ordered and no longer than 730 days.")); } - if (!AnalyticsFilterParser.TryParse(filterValues, out var filters, out var filterError)) + reportFilters ??= new(); + if (!reportFilters.TryParse(out var parsedFilters, out var filterError)) { return (null, ValidationProblem(filterError!)); } + var (filters, flagFilter, eventFilter) = parsedFilters; if (!registry.Settings.Enabled) { return (null, WebAnalyticsProblemFactory.CreateResult( @@ -333,9 +335,9 @@ public async Task> EventPropertyValues( var selectedConnection = registry.Get(connection); if (selectedConnection is null) return (null, NotFoundProblem("The selected analytics connection does not exist.")); - var capabilityError = ValidateCapabilities(filters, reportScope, reportRequirement, selectedConnection.Capabilities); + var capabilityError = ValidateCapabilities(filters, flagFilter, eventFilter, reportScope, reportRequirement, selectedConnection.Capabilities); return capabilityError is null - ? (new AnalyticsQuery(connection, from, to, interval, Filters: filters), null) + ? (new AnalyticsQuery(connection, from, to, interval, Filters: filters, FlagFilter: flagFilter, EventFilter: eventFilter), null) : (null, capabilityError); } @@ -350,14 +352,16 @@ public async Task> EventPropertyValues( string.Equals(route.Path, path, StringComparison.Ordinal)); if (selectedRoute is null) return (null, ValidationProblem("The selected path is not a published route for this document and connection.")); - var documentCapabilityError = ValidateCapabilities(filters, reportScope, reportRequirement, selectedRoute.Capabilities); + var documentCapabilityError = ValidateCapabilities(filters, flagFilter, eventFilter, reportScope, reportRequirement, selectedRoute.Capabilities); return documentCapabilityError is null - ? (new AnalyticsQuery(connection, from, to, interval, selectedRoute.Path, filters), null) + ? (new AnalyticsQuery(connection, from, to, interval, selectedRoute.Path, filters, flagFilter, eventFilter), null) : (null, documentCapabilityError); } private ActionResult? ValidateCapabilities( IReadOnlyList filters, + AnalyticsFlagFilter? flagFilter, + AnalyticsEventFilter? eventFilter, ReportScope reportScope, ReportRequirement requirement, AnalyticsCapabilities capabilities) @@ -369,6 +373,10 @@ public async Task> EventPropertyValues( !capabilities.GlobalEventFiltering && filters.Any(filter => filter.Dimension == AnalyticsDimension.EventName)) return ValidationProblem("The selected analytics provider does not support event filters for this report."); + if (flagFilter is not null && !capabilities.Flags) + return ValidationProblem("The selected analytics provider does not support flag filters."); + if (eventFilter is not null && !capabilities.GlobalEventPropertyFiltering) + return ValidationProblem("The selected analytics provider does not support event property filters."); var supported = requirement.Capability switch { diff --git a/src/TheBuilder.WebAnalytics/Models/AnalyticsModels.cs b/src/TheBuilder.WebAnalytics/Models/AnalyticsModels.cs index 8db5cc1..3d87f4d 100644 --- a/src/TheBuilder.WebAnalytics/Models/AnalyticsModels.cs +++ b/src/TheBuilder.WebAnalytics/Models/AnalyticsModels.cs @@ -67,6 +67,7 @@ public sealed record AnalyticsCapabilities( bool EventDetails, bool EventProperties, bool GlobalEventFiltering, + bool GlobalEventPropertyFiltering, bool Flags, bool BreakdownOrdering); @@ -76,7 +77,9 @@ public sealed record AnalyticsQuery( DateTimeOffset To, AnalyticsInterval Interval, string? RequestPath = null, - IReadOnlyList? Filters = null) + IReadOnlyList? Filters = null, + AnalyticsFlagFilter? FlagFilter = null, + AnalyticsEventFilter? EventFilter = null) { internal AnalyticsQuery( Guid Connection, @@ -98,6 +101,10 @@ internal AnalyticsQuery( public sealed record AnalyticsFilter(AnalyticsDimension Dimension, string Value); +public sealed record AnalyticsFlagFilter(string Key, string Value); + +public sealed record AnalyticsEventFilter(string EventName, string Property, string Value); + public sealed record AnalyticsEventDataFilter(string Property, string Value); public sealed record AnalyticsTotals(long PageViews, long Visitors); diff --git a/src/TheBuilder.WebAnalytics/Providers/PlausibleProvider.cs b/src/TheBuilder.WebAnalytics/Providers/PlausibleProvider.cs index 4c2fd57..61dab90 100644 --- a/src/TheBuilder.WebAnalytics/Providers/PlausibleProvider.cs +++ b/src/TheBuilder.WebAnalytics/Providers/PlausibleProvider.cs @@ -25,6 +25,7 @@ internal static class PlausibleProvider AnalyticsDimension.EventName ], globalEventFiltering: true, + globalEventPropertyFiltering: true, breakdownOrdering: true), new( AnalyticsConnectionIdentifier.SiteId, diff --git a/src/TheBuilder.WebAnalytics/Providers/VercelProvider.cs b/src/TheBuilder.WebAnalytics/Providers/VercelProvider.cs index f45d0b6..1d7ce3a 100644 --- a/src/TheBuilder.WebAnalytics/Providers/VercelProvider.cs +++ b/src/TheBuilder.WebAnalytics/Providers/VercelProvider.cs @@ -25,6 +25,7 @@ internal static class VercelProvider AnalyticsDimension.EventName ], globalEventFiltering: false, + globalEventPropertyFiltering: false, breakdownOrdering: false), new( AnalyticsConnectionIdentifier.ProjectId, diff --git a/src/TheBuilder.WebAnalytics/Services/AnalyticsReportService.cs b/src/TheBuilder.WebAnalytics/Services/AnalyticsReportService.cs index 45d9855..554cece 100644 --- a/src/TheBuilder.WebAnalytics/Services/AnalyticsReportService.cs +++ b/src/TheBuilder.WebAnalytics/Services/AnalyticsReportService.cs @@ -214,7 +214,13 @@ private static string Normalize(AnalyticsQuery query) var filters = string.Join(",", (query.Filters ?? []) .OrderBy(filter => filter.Dimension) .Select(filter => $"{filter.Dimension}:{EncodeCachePart(filter.Value)}")); - return $"{query.Connection:N}:{query.From.UtcTicks}:{query.To.UtcTicks}:{query.Interval}:{query.RequestPath}:{filters}"; + var flagFilter = query.FlagFilter is null + ? string.Empty + : $"{EncodeCachePart(query.FlagFilter.Key)}:{EncodeCachePart(query.FlagFilter.Value)}"; + var eventFilter = query.EventFilter is null + ? string.Empty + : $"{EncodeCachePart(query.EventFilter.EventName)}:{EncodeCachePart(query.EventFilter.Property)}:{EncodeCachePart(query.EventFilter.Value)}"; + return $"{query.Connection:N}:{query.From.UtcTicks}:{query.To.UtcTicks}:{query.Interval}:{query.RequestPath}:{filters}:{flagFilter}:{eventFilter}"; } private async Task TryGetPreviousTotalsAsync( diff --git a/src/TheBuilder.WebAnalytics/Services/MockAnalyticsClient.cs b/src/TheBuilder.WebAnalytics/Services/MockAnalyticsClient.cs index 2f22cc9..ccb4153 100644 --- a/src/TheBuilder.WebAnalytics/Services/MockAnalyticsClient.cs +++ b/src/TheBuilder.WebAnalytics/Services/MockAnalyticsClient.cs @@ -155,6 +155,12 @@ public Task> GetEventsAsync( { cancellationToken.ThrowIfCancellationRequested(); IReadOnlyList rows = HasEvents(connection) ? Events : []; + if (query.EventFilter is not null) + { + rows = rows + .Where(row => string.Equals(row.EventName, query.EventFilter.EventName, StringComparison.OrdinalIgnoreCase)) + .ToArray(); + } var filtered = ApplyDimensionFilter(rows, query, AnalyticsDimension.EventName, row => row.EventName); var scale = QueryScale(connection, query, AnalyticsDimension.EventName); var scaled = filtered.Select(row => new AnalyticsEventRow( @@ -180,6 +186,15 @@ public Task> GetFlagsAsync( "personalised-homepage" => [new("default", 2410, 1530), new("industry", 1390, 870), new("returning-visitor", 960, 610)], _ => FlagKeys }; + var flagFilter = query.FlagFilter; + if (flagKey is not null && + flagFilter is not null && + string.Equals(flagFilter.Key, flagKey, StringComparison.OrdinalIgnoreCase)) + { + rows = rows + .Where(row => string.Equals(row.Value, flagFilter.Value, StringComparison.OrdinalIgnoreCase)) + .ToArray(); + } var scale = QueryScale(connection, query); return Task.FromResult>(rows .Take(Math.Max(0, limit)) @@ -267,7 +282,9 @@ private static double QueryScale( var growthScale = Scenario(connection) is MockAnalyticsScenario.Complete ? DemoGrowthScale(query.To) : 1d; - var filterCount = query.Filters?.Count(filter => filter.Dimension != excludedDimension) ?? 0; + var filterCount = (query.Filters?.Count(filter => filter.Dimension != excludedDimension) ?? 0) + + (query.FlagFilter is null ? 0 : 1) + + (query.EventFilter is null ? 0 : 1); return scale * rangeScale * Math.Pow(0.62d, filterCount) * growthScale; } diff --git a/src/TheBuilder.WebAnalytics/Services/PlausibleAnalyticsClient.cs b/src/TheBuilder.WebAnalytics/Services/PlausibleAnalyticsClient.cs index 2b4291f..ac99f01 100644 --- a/src/TheBuilder.WebAnalytics/Services/PlausibleAnalyticsClient.cs +++ b/src/TheBuilder.WebAnalytics/Services/PlausibleAnalyticsClient.cs @@ -333,6 +333,19 @@ private static List BuildFilters( { filters.Add(new PlausibleFilter("is", PropertyDimension(eventDataFilter.Property), [eventDataFilter.Value])); } + if (query.EventFilter is not null) + { + if (string.IsNullOrWhiteSpace(eventName)) + { + filters.Add(new PlausibleFilter("is", "event:goal", [query.EventFilter.EventName])); + } + if (eventDataFilter is null || + !string.Equals(eventDataFilter.Property, query.EventFilter.Property, StringComparison.Ordinal) || + !string.Equals(eventDataFilter.Value, query.EventFilter.Value, StringComparison.Ordinal)) + { + filters.Add(new PlausibleFilter("is", PropertyDimension(query.EventFilter.Property), [query.EventFilter.Value])); + } + } return filters; } diff --git a/src/TheBuilder.WebAnalytics/Services/VercelAnalyticsClient.cs b/src/TheBuilder.WebAnalytics/Services/VercelAnalyticsClient.cs index 3d1922a..6e2be7a 100644 --- a/src/TheBuilder.WebAnalytics/Services/VercelAnalyticsClient.cs +++ b/src/TheBuilder.WebAnalytics/Services/VercelAnalyticsClient.cs @@ -293,6 +293,10 @@ private async Task SendAsync( AnalyticsConnection connection, AnalyticsQuery query) { + if (query.EventFilter is not null) + { + throw new ArgumentException("Global event property filters are not supported by Vercel.", nameof(query)); + } var parameters = new Dictionary { ["projectId"] = connection.ProjectId, @@ -310,7 +314,10 @@ private async Task SendAsync( { AddFilter(parameters, $"{ToApiValue(filter.Dimension)} eq '{EscapeODataString(filter.Value)}'"); } - + if (query.FlagFilter is not null) + { + AddFilter(parameters, $"{ToFlagDimension(query.FlagFilter.Key)} eq '{EscapeODataString(query.FlagFilter.Value)}'"); + } return parameters; } diff --git a/tests/TheBuilder.WebAnalytics.Tests/Configuration/WebAnalyticsOptionsValidatorTests.cs b/tests/TheBuilder.WebAnalytics.Tests/Configuration/WebAnalyticsOptionsValidatorTests.cs index 54777e0..31eb805 100644 --- a/tests/TheBuilder.WebAnalytics.Tests/Configuration/WebAnalyticsOptionsValidatorTests.cs +++ b/tests/TheBuilder.WebAnalytics.Tests/Configuration/WebAnalyticsOptionsValidatorTests.cs @@ -149,6 +149,7 @@ public void Registry_resolves_plausible_provider_token() Assert.True(connection.Capabilities.EventDetails); Assert.True(connection.Capabilities.Events); Assert.True(connection.Capabilities.GlobalEventFiltering); + Assert.True(connection.Capabilities.GlobalEventPropertyFiltering); Assert.Equal(["locale", "title"], connection.EventPropertyNames); } @@ -168,6 +169,7 @@ public void Registry_intersects_provider_capabilities_with_enabled_connection_fe Assert.False(connection.Capabilities.EventDetails); Assert.False(connection.Capabilities.EventProperties); Assert.False(connection.Capabilities.GlobalEventFiltering); + Assert.False(connection.Capabilities.GlobalEventPropertyFiltering); Assert.False(connection.Capabilities.Flags); } diff --git a/tests/TheBuilder.WebAnalytics.Tests/Controllers/WebAnalyticsApiControllerTests.cs b/tests/TheBuilder.WebAnalytics.Tests/Controllers/WebAnalyticsApiControllerTests.cs index b206115..f0ba74f 100644 --- a/tests/TheBuilder.WebAnalytics.Tests/Controllers/WebAnalyticsApiControllerTests.cs +++ b/tests/TheBuilder.WebAnalytics.Tests/Controllers/WebAnalyticsApiControllerTests.cs @@ -295,7 +295,7 @@ public async Task Summary_rejects_event_name_filter_in_visit_scope_before_dispat null, null, null, - ["EventName:Signup"], + new() { Filter = ["EventName:Signup"] }, CancellationToken.None); AssertInvalidQuery(response.Result); @@ -303,6 +303,87 @@ public async Task Summary_rejects_event_name_filter_in_visit_scope_before_dispat Assert.Contains("does not support event filters for this report", problem.Detail); } + [Fact] + public async Task Summary_rejects_incomplete_flag_filters_before_dispatch() + { + var response = await CreateBoundaryOnlyController().Summary( + MainKey, + UtcDate(2026, 7, 1), + UtcDate(2026, 7, 3), + AnalyticsInterval.Day, + null, + null, + null, + new() { FilterFlagKey = "new-pricing-page" }, + CancellationToken.None); + + AssertInvalidQuery(response.Result); + var problem = Assert.IsType(Assert.IsType(response.Result).Value); + Assert.Contains("key and value must be supplied together", problem.Detail); + } + + [Fact] + public async Task Summary_rejects_incomplete_event_property_filters_before_dispatch() + { + var response = await CreateBoundaryOnlyController().Summary( + MainKey, + UtcDate(2026, 7, 1), + UtcDate(2026, 7, 3), + AnalyticsInterval.Day, + null, + null, + null, + new() { FilterEventName = "Signup", FilterEventProperty = "plan" }, + CancellationToken.None); + + AssertInvalidQuery(response.Result); + var problem = Assert.IsType(Assert.IsType(response.Result).Value); + Assert.Contains("name, property, and value must be supplied together", problem.Detail); + } + + [Fact] + public async Task Summary_rejects_global_event_property_filters_for_vercel() + { + var response = await CreateVercelBoundaryController().Summary( + MainKey, + UtcDate(2026, 7, 1), + UtcDate(2026, 7, 3), + AnalyticsInterval.Day, + null, + null, + null, + new() + { + FilterEventName = "Signup", + FilterEventProperty = "plan", + FilterEventValue = "Pro" + }, + CancellationToken.None); + + AssertInvalidQuery(response.Result); + var problem = Assert.IsType(Assert.IsType(response.Result).Value); + Assert.Contains("does not support event property filters", problem.Detail); + } + + [Fact] + public async Task Summary_rejects_flag_filters_for_providers_without_flags() + { + var response = await CreatePlausibleBoundaryController().Summary( + MainKey, + UtcDate(2026, 7, 1), + UtcDate(2026, 7, 3), + AnalyticsInterval.Day, + null, + null, + null, + new() { FilterFlagKey = "new-pricing-page", FilterFlagValue = "control" }, + CancellationToken.None); + + AssertInvalidQuery(response.Result); + var problem = Assert.IsType(Assert.IsType(response.Result).Value); + Assert.Contains("does not support flag filters", problem.Detail); + } + [Fact] public async Task Summary_rejects_filters_not_supported_by_the_selected_provider() { @@ -323,7 +404,7 @@ public async Task Summary_rejects_filters_not_supported_by_the_selected_provider null, null, null, - ["Route:/articles/[slug]"], + new() { Filter = ["Route:/articles/[slug]"] }, CancellationToken.None); AssertInvalidQuery(response.Result); @@ -367,7 +448,7 @@ public async Task Event_details_rejects_event_name_filter_from_the_shared_query( UtcDate(2026, 7, 3), AnalyticsInterval.Day, "Signup", - filter: ["EventName:AnotherEvent"]); + reportFilters: new() { Filter = ["EventName:AnotherEvent"] }); AssertInvalidQuery(response.Result); } @@ -384,7 +465,7 @@ public async Task Event_property_values_reject_event_name_filter_from_the_shared AnalyticsInterval.Day, "Signup", "plan", - filter: ["EventName:AnotherEvent"]); + reportFilters: new() { Filter = ["EventName:AnotherEvent"] }); AssertInvalidQuery(response.Result); } diff --git a/tests/TheBuilder.WebAnalytics.Tests/Services/AnalyticsReportServiceTests.cs b/tests/TheBuilder.WebAnalytics.Tests/Services/AnalyticsReportServiceTests.cs index 33fa6e8..9c04934 100644 --- a/tests/TheBuilder.WebAnalytics.Tests/Services/AnalyticsReportServiceTests.cs +++ b/tests/TheBuilder.WebAnalytics.Tests/Services/AnalyticsReportServiceTests.cs @@ -26,6 +26,28 @@ public async Task Summary_is_cached_by_normalized_query() Assert.Equal(1, client.TrendCalls); } + [Fact] + public async Task Summary_cache_distinguishes_flag_and_event_property_filters() + { + var client = new CountingClient(); + using var cache = new AnalyticsReportCache(); + var service = CreateService(CreateRegistry(), client, cache); + var query = CreateQuery(); + + await service.GetSummaryAsync(query, CancellationToken.None); + await service.GetSummaryAsync(query with + { + FlagFilter = new AnalyticsFlagFilter("new-pricing-page", "control") + }, CancellationToken.None); + await service.GetSummaryAsync(query with + { + EventFilter = new AnalyticsEventFilter("Signup", "plan", "Pro") + }, CancellationToken.None); + + Assert.Equal(6, client.CountCalls); + Assert.Equal(3, client.TrendCalls); + } + [Fact] public async Task Zero_cache_duration_disables_caching() { diff --git a/tests/TheBuilder.WebAnalytics.Tests/Services/MockAnalyticsClientTests.cs b/tests/TheBuilder.WebAnalytics.Tests/Services/MockAnalyticsClientTests.cs index 4db2226..3603656 100644 --- a/tests/TheBuilder.WebAnalytics.Tests/Services/MockAnalyticsClientTests.cs +++ b/tests/TheBuilder.WebAnalytics.Tests/Services/MockAnalyticsClientTests.cs @@ -148,6 +148,31 @@ public async Task Demo_event_filter_returns_only_the_selected_event() Assert.Equal("Demo requested", Assert.Single(events).EventName); } + [Fact] + public async Task Demo_flag_and_event_property_filters_change_report_data() + { + var client = new MockAnalyticsClient(); + var connection = CreateRegistry(MockAnalyticsScenario.Complete, true).Get(MockKey)!; + var baseline = CreateQuery(); + var flagFiltered = baseline with + { + FlagFilter = new AnalyticsFlagFilter("new-pricing-page", "control") + }; + var eventFiltered = baseline with + { + EventFilter = new AnalyticsEventFilter("Signup completed", "plan", "Pro") + }; + + var baselineTotals = await client.CountAsync(connection, baseline, CancellationToken.None); + var flagTotals = await client.CountAsync(connection, flagFiltered, CancellationToken.None); + var eventTotals = await client.CountAsync(connection, eventFiltered, CancellationToken.None); + var events = await client.GetEventsAsync(connection, eventFiltered, 10, null, CancellationToken.None); + + Assert.True(flagTotals.Visitors < baselineTotals.Visitors); + Assert.True(eventTotals.Visitors < baselineTotals.Visitors); + Assert.Equal("Signup completed", Assert.Single(events).EventName); + } + [Fact] public async Task Resolver_serves_mock_reports_without_contacting_providers() { @@ -206,6 +231,7 @@ public void Capability_factory_rejects_global_event_filtering_without_event_oper AnalyticsProviderCapabilities.FromClient( [], globalEventFiltering: true, + globalEventPropertyFiltering: false, breakdownOrdering: false)); Assert.Contains(nameof(IAnalyticsEventsProviderClient), exception.Message); @@ -273,6 +299,7 @@ public void Resolver_reports_unsupported_typed_operations() AnalyticsProviderCapabilities.FromClient( [], globalEventFiltering: false, + globalEventPropertyFiltering: false, breakdownOrdering: false), new( AnalyticsConnectionIdentifier.ProjectId, diff --git a/tests/TheBuilder.WebAnalytics.Tests/Services/VercelAnalyticsClientTests.cs b/tests/TheBuilder.WebAnalytics.Tests/Services/VercelAnalyticsClientTests.cs index 8c7525b..20ae574 100644 --- a/tests/TheBuilder.WebAnalytics.Tests/Services/VercelAnalyticsClientTests.cs +++ b/tests/TheBuilder.WebAnalytics.Tests/Services/VercelAnalyticsClientTests.cs @@ -312,6 +312,46 @@ public async Task Flag_values_quote_dynamic_keys_and_parse_the_returned_value() Assert.Contains("limit=100", handler.Request.RequestUri.Query); } + [Theory] + [InlineData("editor's choice", "filter=flags/'new-pricing-page' eq 'editor''s choice'")] + [InlineData("", "filter=flags/'new-pricing-page' eq ''")] + public async Task Visit_reports_filter_by_a_specific_flag_value(string value, string expectedFilter) + { + var handler = new RecordingHandler("""{"data":{"pageviews":12,"visitors":8}}"""); + var client = CreateClient(handler); + var connection = CreateConnection(); + var query = new AnalyticsQuery( + connection.Key, + new DateTimeOffset(2026, 7, 1, 0, 0, 0, TimeSpan.Zero), + new DateTimeOffset(2026, 7, 3, 0, 0, 0, TimeSpan.Zero), + AnalyticsInterval.Day, + FlagFilter: new("new-pricing-page", value)); + + await client.CountAsync(connection, query, CancellationToken.None); + + var requestQuery = Uri.UnescapeDataString(handler.Request!.RequestUri!.Query); + Assert.Contains(expectedFilter, requestQuery); + } + + [Fact] + public async Task Visit_reports_reject_global_event_property_filters() + { + var handler = new RecordingHandler("""{"data":{"pageviews":12,"visitors":8}}"""); + var client = CreateClient(handler); + var connection = CreateConnection(); + var query = new AnalyticsQuery( + connection.Key, + new DateTimeOffset(2026, 7, 1, 0, 0, 0, TimeSpan.Zero), + new DateTimeOffset(2026, 7, 3, 0, 0, 0, TimeSpan.Zero), + AnalyticsInterval.Day, + EventFilter: new("Signup", "account-tier", "Editor's choice")); + + var error = await Assert.ThrowsAsync( + () => client.CountAsync(connection, query, CancellationToken.None)); + + Assert.Contains("not supported by Vercel", error.Message); + } + [Theory] [InlineData("beta_banner", "flags/beta_banner")] [InlineData("my-flag", "flags/'my-flag'")]