diff --git a/.codex/setup.sh b/.codex/setup.sh index 0e71001..88f7551 100755 --- a/.codex/setup.sh +++ b/.codex/setup.sh @@ -4,7 +4,7 @@ set -euo pipefail worktree_root="$(git rev-parse --show-toplevel)" source_worktree="$(git worktree list --porcelain | awk '/^worktree / { print substr($0, 10); exit }')" -database_relative_path="samples/Umbraco.VercelAnalytics.Example/umbraco/Data/Umbraco.sqlite.db" +database_relative_path="samples/TheBuilder.WebAnalytics.Example/umbraco/Data/Umbraco.sqlite.db" source_database="$source_worktree/$database_relative_path" worktree_database="$worktree_root/$database_relative_path" @@ -15,7 +15,8 @@ if [[ "$source_database" != "$worktree_database" ]]; then fi mkdir -p "$(dirname "$worktree_database")" - cp "$source_database" "$worktree_database" + sqlite3 "$source_database" ".backup '$worktree_database'" + [[ "$(sqlite3 "$worktree_database" 'PRAGMA integrity_check;')" == "ok" ]] fi pushd "$worktree_root/src/TheBuilder.WebAnalytics/Client" >/dev/null diff --git a/src/TheBuilder.WebAnalytics/Client/public/icons/browsers/README.md b/src/TheBuilder.WebAnalytics/Client/public/icons/browsers/README.md new file mode 100644 index 0000000..fa0adc6 --- /dev/null +++ b/src/TheBuilder.WebAnalytics/Client/public/icons/browsers/README.md @@ -0,0 +1,3 @@ +# Browser icon sources + +The coloured browser marks in this directory are local copies from [theSVG](https://thesvg.org/). Refer to each source icon's page for its licence and trademark notice. diff --git a/src/TheBuilder.WebAnalytics/Client/public/icons/browsers/brave.svg b/src/TheBuilder.WebAnalytics/Client/public/icons/browsers/brave.svg new file mode 100644 index 0000000..e3749a6 --- /dev/null +++ b/src/TheBuilder.WebAnalytics/Client/public/icons/browsers/brave.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/TheBuilder.WebAnalytics/Client/public/icons/browsers/chrome.svg b/src/TheBuilder.WebAnalytics/Client/public/icons/browsers/chrome.svg new file mode 100644 index 0000000..d83e7bf --- /dev/null +++ b/src/TheBuilder.WebAnalytics/Client/public/icons/browsers/chrome.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/TheBuilder.WebAnalytics/Client/public/icons/browsers/duckduckgo.svg b/src/TheBuilder.WebAnalytics/Client/public/icons/browsers/duckduckgo.svg new file mode 100644 index 0000000..e528df9 --- /dev/null +++ b/src/TheBuilder.WebAnalytics/Client/public/icons/browsers/duckduckgo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/TheBuilder.WebAnalytics/Client/public/icons/browsers/ecosia.svg b/src/TheBuilder.WebAnalytics/Client/public/icons/browsers/ecosia.svg new file mode 100644 index 0000000..ca0f820 --- /dev/null +++ b/src/TheBuilder.WebAnalytics/Client/public/icons/browsers/ecosia.svg @@ -0,0 +1 @@ +Ecosia \ No newline at end of file diff --git a/src/TheBuilder.WebAnalytics/Client/public/icons/browsers/edge.svg b/src/TheBuilder.WebAnalytics/Client/public/icons/browsers/edge.svg new file mode 100644 index 0000000..56caadd --- /dev/null +++ b/src/TheBuilder.WebAnalytics/Client/public/icons/browsers/edge.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/TheBuilder.WebAnalytics/Client/public/icons/browsers/firefox.svg b/src/TheBuilder.WebAnalytics/Client/public/icons/browsers/firefox.svg new file mode 100644 index 0000000..b7ae49f --- /dev/null +++ b/src/TheBuilder.WebAnalytics/Client/public/icons/browsers/firefox.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/TheBuilder.WebAnalytics/Client/public/icons/browsers/jio.svg b/src/TheBuilder.WebAnalytics/Client/public/icons/browsers/jio.svg new file mode 100644 index 0000000..b7a0f65 --- /dev/null +++ b/src/TheBuilder.WebAnalytics/Client/public/icons/browsers/jio.svg @@ -0,0 +1 @@ +Jio \ No newline at end of file diff --git a/src/TheBuilder.WebAnalytics/Client/public/icons/browsers/opera-gx.svg b/src/TheBuilder.WebAnalytics/Client/public/icons/browsers/opera-gx.svg new file mode 100644 index 0000000..191ae6c --- /dev/null +++ b/src/TheBuilder.WebAnalytics/Client/public/icons/browsers/opera-gx.svg @@ -0,0 +1 @@ +Opera GX \ No newline at end of file diff --git a/src/TheBuilder.WebAnalytics/Client/public/icons/browsers/opera.svg b/src/TheBuilder.WebAnalytics/Client/public/icons/browsers/opera.svg new file mode 100644 index 0000000..0ebd501 --- /dev/null +++ b/src/TheBuilder.WebAnalytics/Client/public/icons/browsers/opera.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/TheBuilder.WebAnalytics/Client/public/icons/browsers/qwant.svg b/src/TheBuilder.WebAnalytics/Client/public/icons/browsers/qwant.svg new file mode 100644 index 0000000..0198c98 --- /dev/null +++ b/src/TheBuilder.WebAnalytics/Client/public/icons/browsers/qwant.svg @@ -0,0 +1 @@ +Qwant \ No newline at end of file diff --git a/src/TheBuilder.WebAnalytics/Client/public/icons/browsers/safari.svg b/src/TheBuilder.WebAnalytics/Client/public/icons/browsers/safari.svg new file mode 100644 index 0000000..ef0dff8 --- /dev/null +++ b/src/TheBuilder.WebAnalytics/Client/public/icons/browsers/safari.svg @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/TheBuilder.WebAnalytics/Client/public/icons/browsers/samsung-browser.svg b/src/TheBuilder.WebAnalytics/Client/public/icons/browsers/samsung-browser.svg new file mode 100644 index 0000000..cdf3470 --- /dev/null +++ b/src/TheBuilder.WebAnalytics/Client/public/icons/browsers/samsung-browser.svg @@ -0,0 +1 @@ +Samsung Browser diff --git a/src/TheBuilder.WebAnalytics/Client/public/icons/browsers/sberbank.svg b/src/TheBuilder.WebAnalytics/Client/public/icons/browsers/sberbank.svg new file mode 100644 index 0000000..eedb374 --- /dev/null +++ b/src/TheBuilder.WebAnalytics/Client/public/icons/browsers/sberbank.svg @@ -0,0 +1 @@ + diff --git a/src/TheBuilder.WebAnalytics/Client/public/icons/browsers/vivo.svg b/src/TheBuilder.WebAnalytics/Client/public/icons/browsers/vivo.svg new file mode 100644 index 0000000..55badd3 --- /dev/null +++ b/src/TheBuilder.WebAnalytics/Client/public/icons/browsers/vivo.svg @@ -0,0 +1 @@ +vivo \ No newline at end of file diff --git a/src/TheBuilder.WebAnalytics/Client/public/icons/browsers/yandex.svg b/src/TheBuilder.WebAnalytics/Client/public/icons/browsers/yandex.svg new file mode 100644 index 0000000..a59434d --- /dev/null +++ b/src/TheBuilder.WebAnalytics/Client/public/icons/browsers/yandex.svg @@ -0,0 +1,4 @@ + + Yandex + + \ No newline at end of file diff --git a/src/TheBuilder.WebAnalytics/Client/public/icons/operating-systems/README.md b/src/TheBuilder.WebAnalytics/Client/public/icons/operating-systems/README.md new file mode 100644 index 0000000..85e760e --- /dev/null +++ b/src/TheBuilder.WebAnalytics/Client/public/icons/operating-systems/README.md @@ -0,0 +1,3 @@ +# Operating system icon sources + +The coloured operating system marks in this directory are local copies from [theSVG](https://thesvg.org/). Refer to each source icon's page for its licence and trademark notice. The iOS mark (`ios.svg`) was supplied by the project owner from Icons8. diff --git a/src/TheBuilder.WebAnalytics/Client/public/icons/operating-systems/android.svg b/src/TheBuilder.WebAnalytics/Client/public/icons/operating-systems/android.svg new file mode 100644 index 0000000..6339e02 --- /dev/null +++ b/src/TheBuilder.WebAnalytics/Client/public/icons/operating-systems/android.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/TheBuilder.WebAnalytics/Client/public/icons/operating-systems/apple.svg b/src/TheBuilder.WebAnalytics/Client/public/icons/operating-systems/apple.svg new file mode 100644 index 0000000..f86c318 --- /dev/null +++ b/src/TheBuilder.WebAnalytics/Client/public/icons/operating-systems/apple.svg @@ -0,0 +1 @@ + diff --git a/src/TheBuilder.WebAnalytics/Client/public/icons/operating-systems/ios.svg b/src/TheBuilder.WebAnalytics/Client/public/icons/operating-systems/ios.svg new file mode 100644 index 0000000..0b781e8 --- /dev/null +++ b/src/TheBuilder.WebAnalytics/Client/public/icons/operating-systems/ios.svg @@ -0,0 +1 @@ + diff --git a/src/TheBuilder.WebAnalytics/Client/public/icons/operating-systems/linux.svg b/src/TheBuilder.WebAnalytics/Client/public/icons/operating-systems/linux.svg new file mode 100644 index 0000000..1c05d82 --- /dev/null +++ b/src/TheBuilder.WebAnalytics/Client/public/icons/operating-systems/linux.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/TheBuilder.WebAnalytics/Client/public/icons/operating-systems/ubuntu.svg b/src/TheBuilder.WebAnalytics/Client/public/icons/operating-systems/ubuntu.svg new file mode 100644 index 0000000..c348cc7 --- /dev/null +++ b/src/TheBuilder.WebAnalytics/Client/public/icons/operating-systems/ubuntu.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/TheBuilder.WebAnalytics/Client/public/icons/operating-systems/windows.svg b/src/TheBuilder.WebAnalytics/Client/public/icons/operating-systems/windows.svg new file mode 100644 index 0000000..853c7f4 --- /dev/null +++ b/src/TheBuilder.WebAnalytics/Client/public/icons/operating-systems/windows.svg @@ -0,0 +1 @@ + \ No newline at end of file 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 fe0ca9e..dcbd477 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-components.test.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-components.test.ts @@ -308,6 +308,57 @@ describe("analytics presentation components", () => { expect([...element.shadowRoot?.querySelectorAll(".metric-number") ?? []].map((value) => value.textContent)).toEqual(["22,304", "30,000", "1", "1"]); }); + it("renders local browser marks and a globe for unrecognised browser values", async () => { + const element = document.createElement("web-analytics-breakdown-table") as WebAnalyticsBreakdownTableElement; + element.dimension = "BrowserName"; + element.rows = [ + { value: "Chrome", visitors: 22_304, pageViews: 30_000 }, + { value: "Mobile App", visitors: 1, pageViews: 1 }, + ]; + document.body.append(element); + await element.updateComplete; + + const icons = element.shadowRoot?.querySelectorAll(".breakdown-value-icon"); + expect(icons).toHaveLength(2); + expect(icons?.[0]?.tagName).toBe("IMG"); + expect((icons?.[0] as HTMLImageElement | undefined)?.getAttribute("src")).toBe("/App_Plugins/TheBuilder.WebAnalytics/icons/browsers/chrome.svg"); + expect(icons?.[1]?.tagName).toBe("UUI-ICON"); + expect(icons?.[1]?.getAttribute("name")).toBe("icon-globe"); + }); + + it("renders local operating system marks and a globe for unrecognised values", async () => { + const element = document.createElement("web-analytics-breakdown-table") as WebAnalyticsBreakdownTableElement; + element.dimension = "OsName"; + element.rows = [ + { value: "Windows", visitors: 22_304, pageViews: 30_000 }, + { value: "iOS", visitors: 2_304, pageViews: 3_000 }, + { value: "(not set)", visitors: 1, pageViews: 1 }, + ]; + document.body.append(element); + await element.updateComplete; + + const icons = element.shadowRoot?.querySelectorAll(".breakdown-value-icon"); + expect(icons).toHaveLength(3); + expect((icons?.[0] as HTMLImageElement | undefined)?.getAttribute("src")).toBe("/App_Plugins/TheBuilder.WebAnalytics/icons/operating-systems/windows.svg"); + expect((icons?.[1] as HTMLImageElement | undefined)?.getAttribute("src")).toBe("/App_Plugins/TheBuilder.WebAnalytics/icons/operating-systems/ios.svg"); + expect(icons?.[2]?.getAttribute("name")).toBe("icon-globe"); + }); + + it("renders native Umbraco icons for device categories", async () => { + const element = document.createElement("web-analytics-breakdown-table") as WebAnalyticsBreakdownTableElement; + element.dimension = "DeviceType"; + element.rows = [ + { value: "Desktop", visitors: 22_304, pageViews: 30_000 }, + { value: "Mobile", visitors: 1_204, pageViews: 2_000 }, + { value: "Tablet", visitors: 304, pageViews: 500 }, + ]; + document.body.append(element); + await element.updateComplete; + + const icons = [...element.shadowRoot?.querySelectorAll(".breakdown-value-icon") ?? []]; + expect(icons.map((icon) => icon.getAttribute("name"))).toEqual(["icon-desktop", "icon-mobile", "icon-ipad"]); + }); + it("keeps document traffic breakdowns ahead of optional reports", async () => { const element = document.createElement("web-analytics-breakdown-grid") as WebAnalyticsBreakdownGridElement; element.cards = dashboardCards(true, "unavailable"); @@ -495,7 +546,7 @@ describe("analytics presentation components", () => { document.body.append(element); await element.updateComplete; - expect(element.shadowRoot?.querySelector(".analytics-dialog-headline h2")?.textContent).toBe("Read article event"); + expect(element.shadowRoot?.querySelector(".analytics-dialog-headline h2 .analytics-dialog-back")?.textContent?.trim()).toBe("Read article"); expect(element.shadowRoot?.querySelector(".analytics-dialog-close")?.getAttribute("aria-label")).toBe("Close event details"); expect(element.shadowRoot?.querySelector(".event-totals")).toBeNull(); expect(element.shadowRoot?.querySelector(".dialog-content")?.classList.contains("no-properties")).toBe(false); @@ -564,6 +615,35 @@ describe("analytics presentation components", () => { expect(summary?.metric).toBe("pageViews"); }); + it("replaces the Events dialog with event details while preserving back navigation", async () => { + Object.defineProperty(HTMLDialogElement.prototype, "showModal", { configurable: true, value: vi.fn() }); + sdk.events.mockResolvedValue(apiOk({ rows: [{ eventName: "Signup completed", visitors: 12, count: 18 }] })); + sdk.eventDetails.mockResolvedValue(apiOk({ + eventName: "Signup completed", + totals: { visitors: 12, count: 18 }, + properties: [], + })); + const dashboard = document.createElement("web-analytics-dashboard") as WebAnalyticsDashboardElement; + document.body.append(dashboard); + await vi.waitFor(() => expect(dashboard.shadowRoot?.querySelector("web-analytics-breakdown-grid")?.events.status).toBe("success")); + + dashboard.shadowRoot?.querySelector("web-analytics-breakdown-grid")?.dispatchEvent(new CustomEvent("view-events", { bubbles: true, composed: true })); + await vi.waitFor(() => expect(dashboard.shadowRoot?.querySelector("web-analytics-event-dialog")).not.toBeNull()); + const eventsDialog = dashboard.shadowRoot?.querySelector("web-analytics-event-dialog"); + eventsDialog?.dispatchEvent(new CustomEvent("select-event", { + bubbles: true, + composed: true, + detail: { eventName: "Signup completed" }, + })); + + await vi.waitFor(() => expect(dashboard.shadowRoot?.querySelector("web-analytics-event-details-dialog")).not.toBeNull()); + expect(dashboard.shadowRoot?.querySelector("web-analytics-event-dialog")).toBeNull(); + + dashboard.shadowRoot?.querySelector("web-analytics-event-details-dialog")?.dispatchEvent(new CustomEvent("back-to-events", { bubbles: true, composed: true })); + await vi.waitFor(() => expect(dashboard.shadowRoot?.querySelector("web-analytics-event-dialog")).not.toBeNull()); + expect(dashboard.shadowRoot?.querySelector("web-analytics-event-details-dialog")).toBeNull(); + }); + 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"); const dashboard = document.createElement("web-analytics-dashboard") as WebAnalyticsDashboardElement; @@ -575,6 +655,18 @@ describe("analytics presentation components", () => { await vi.waitFor(() => expect(dashboard.shadowRoot?.querySelector(".active-filters")).toBeNull()); expect(new URL(window.location.href).searchParams.has("filter")).toBe(false); }); + + it("renders relevant identity icons for active filters", async () => { + window.history.replaceState({}, "", "/umbraco/section/analytics?filter=Country%3ADK&filter=OsName%3AmacOS&filter=DeviceType%3ADesktop"); + const dashboard = document.createElement("web-analytics-dashboard") as WebAnalyticsDashboardElement; + document.body.append(dashboard); + await vi.waitFor(() => expect(dashboard.shadowRoot?.querySelectorAll(".filter-badge")).toHaveLength(3)); + + const badges = [...dashboard.shadowRoot?.querySelectorAll(".filter-badge") ?? []]; + expect((badges[0]?.querySelector(".filter-icon") as HTMLImageElement | null)?.getAttribute("src")).toBe("https://flag.vercel.app/s/DK.svg"); + expect((badges[1]?.querySelector(".filter-icon") as HTMLImageElement | null)?.getAttribute("src")).toBe("/App_Plugins/TheBuilder.WebAnalytics/icons/operating-systems/apple.svg"); + expect(badges[2]?.querySelector(".filter-icon")?.getAttribute("name")).toBe("icon-desktop"); + }); }); function apiOk(data: T) { 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 341d865..1b2c42c 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 @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import type { AnalyticsCapabilities, AnalyticsDocumentRoute } from "../api/types.gen.js"; import { AnalyticsDashboardController, type DashboardEnvironment } from "./analytics-dashboard.controller.js"; +import { isInitialLoading, stateData } from "./async-state.js"; import type { DashboardApi } from "./dashboard-api.js"; import { dateRangeForPreset } from "./date-range.js"; @@ -97,7 +98,8 @@ describe("AnalyticsDashboardController", () => { const api = dashboardApi(); const controller = new AnalyticsDashboardController(vi.fn(), api, environment()); controller.connect(); - await vi.waitFor(() => expect(controller.state.summary.status).toBe("success")); + await vi.waitFor(() => expect(controller.state.utmCapability).toBe("available")); + api.breakdown.mockClear(); const pending = deferred>>(); api.breakdown.mockReturnValueOnce(pending.promise); @@ -114,7 +116,8 @@ describe("AnalyticsDashboardController", () => { const api = dashboardApi(); const controller = new AnalyticsDashboardController(vi.fn(), api, environment()); controller.connect(); - await vi.waitFor(() => expect(controller.state.summary.status).toBe("success")); + await vi.waitFor(() => expect(controller.state.utmCapability).toBe("available")); + api.breakdown.mockClear(); await controller.openBreakdown("UtmCampaign", "UTM campaigns"); expect(controller.state.expandedBreakdown).toMatchObject({ @@ -129,7 +132,8 @@ describe("AnalyticsDashboardController", () => { const api = dashboardApi(); const controller = new AnalyticsDashboardController(vi.fn(), api, environment()); controller.connect(); - await vi.waitFor(() => expect(controller.state.summary.status).toBe("success")); + await vi.waitFor(() => expect(controller.state.utmCapability).toBe("available")); + api.breakdown.mockClear(); api.breakdown.mockResolvedValueOnce(ok({ dimension: "ReferrerHostname", rows: [{ value: "example.com", visitors: 12, pageViews: 18 }], @@ -145,16 +149,43 @@ describe("AnalyticsDashboardController", () => { await switching; }); + it("retains cached rows when returning to a breakdown tab", async () => { + const api = dashboardApi(); + const controller = new AnalyticsDashboardController(vi.fn(), api, environment()); + controller.connect(); + await vi.waitFor(() => expect(controller.state.utmCapability).toBe("available")); + api.breakdown.mockClear(); + const referrerRows = [{ value: "example.com", visitors: 12, pageViews: 18 }]; + api.breakdown.mockResolvedValueOnce(ok({ dimension: "ReferrerHostname", rows: referrerRows })); + await controller.openBreakdown("ReferrerHostname", "Referrers"); + expect(controller.state.expandedBreakdown?.report).toEqual({ status: "success", data: referrerRows }); + api.breakdown.mockResolvedValueOnce(ok({ dimension: "UtmSource", rows: [] })); + await controller.openBreakdown("UtmSource", "UTM sources"); + const pending = deferred>>(); + api.breakdown.mockReturnValueOnce(pending.promise); + + const returning = controller.openBreakdown("ReferrerHostname", "Referrers"); + + expect(controller.state.expandedBreakdown?.report).toEqual({ status: "loading", previous: referrerRows }); + pending.resolve(ok({ dimension: "ReferrerHostname", rows: [] })); + await returning; + }); + it("retains rows while the same breakdown query refreshes", async () => { const api = dashboardApi(); const controller = new AnalyticsDashboardController(vi.fn(), api, environment()); controller.connect(); - await vi.waitFor(() => expect(controller.state.summary.status).toBe("success")); + await vi.waitFor(() => expect(controller.state.utmCapability).toBe("available")); + api.breakdown.mockClear(); api.breakdown.mockResolvedValueOnce(ok({ dimension: "Country", rows: [{ value: "DK", visitors: 12, pageViews: 18 }], })); await controller.openBreakdown("Country", "Countries"); + expect(controller.state.expandedBreakdown?.report).toEqual({ + status: "success", + data: [{ value: "DK", visitors: 12, pageViews: 18 }], + }); const pending = deferred>>(); api.breakdown.mockReturnValueOnce(pending.promise); @@ -168,11 +199,12 @@ describe("AnalyticsDashboardController", () => { await refreshing; }); - it("does not reuse unfiltered rows for a searched breakdown", async () => { + it("does not reuse rows from a different breakdown search", 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 vi.waitFor(() => expect(controller.state.utmCapability).toBe("available")); + api.breakdown.mockClear(); api.breakdown.mockResolvedValueOnce(ok({ dimension: "Country", rows: [{ value: "DK", visitors: 12, pageViews: 18 }], @@ -205,6 +237,36 @@ describe("AnalyticsDashboardController", () => { expect(controller.state.selectedEvent).toBeUndefined(); }); + it("keeps the Events list available while viewing event details", 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.openEvents(); + + await controller.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(); + }); + + it("opens all events when backing out of details opened from the dashboard", 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.selectEvent("Signup"); + + expect(controller.state.expandedEvents).toBeUndefined(); + await controller.backToEvents(); + + expect(controller.state.selectedEvent).toBeUndefined(); + expect(controller.state.expandedEvents?.status).toBe("success"); + }); + it("reuses property values returned with event details", async () => { const api = dashboardApi(); api.eventDetails.mockResolvedValue(ok({ @@ -222,6 +284,35 @@ describe("AnalyticsDashboardController", () => { expect(controller.state.selectedEvent?.details.status).toBe("success"); }); + it("keeps cached property values visible while refreshing a previously viewed tab", async () => { + const api = dashboardApi(); + const sourceValues = deferred>>(); + api.eventDetails.mockResolvedValue(ok({ + eventName: "Signup", + totals: { count: 20, visitors: 10 }, + properties: [{ name: "plan", values: [] }, { name: "source", values: [] }], + })); + api.eventPropertyValues + .mockResolvedValueOnce(ok({ name: "plan", values: [{ value: "Pro", count: 20, visitors: 10 }] })) + .mockReturnValueOnce(sourceValues.promise) + .mockResolvedValueOnce(ok({ 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.selectEvent("Signup"); + await vi.waitFor(() => expect(stateData(controller.state.selectedEvent!.property)?.name).toBe("plan")); + + controller.searchEventProperty("source", ""); + expect(isInitialLoading(controller.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")); + + 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 }]); + }); + it("loads event details without fetching property values when the capability is unavailable", async () => { const api = dashboardApi(); api.connections.mockResolvedValue(ok({ 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 b72804b..a714d3f 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dashboard.controller.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dashboard.controller.ts @@ -40,6 +40,7 @@ export type ExpandedBreakdown = { headline: string; search: string; report: AsyncState; + cache: Readonly>; }; export type SelectedEvent = { eventName: string; @@ -49,6 +50,7 @@ export type SelectedEvent = { propertyName?: string; propertySearch?: string; property: AsyncState; + propertyCache: Readonly>; }; export type DashboardState = { connections: AnalyticsConnectionSummary[]; @@ -341,26 +343,32 @@ export class AnalyticsDashboardController { const connection = this.state.connection; if (!connection) return; const search = options.search ?? ""; - const current = this.state.expandedBreakdown; - const previous = current?.dimension === dimension && current.search === search ? current.report : undefined; - this.#set({ expandedBreakdown: { dimension, headline, search, report: loadingState(previous) } }); + const query = { ...this.#reportQuery(connection, this.#visitFilterQuery()), limit: 100, search: search || undefined }; + const cacheKey = breakdownCacheKey(dimension, search); + const cache = this.state.expandedBreakdown?.cache ?? {}; + const previousRows = cache[cacheKey]; + const previous = previousRows === undefined ? undefined : successState(previousRows); + this.#set({ expandedBreakdown: { dimension, headline, search, cache, report: loadingState(previous) } }); const run = (signal: AbortSignal) => this.#api.breakdown({ path: { dimension }, - query: { ...this.#reportQuery(connection, this.#visitFilterQuery()), limit: 100, search: search || undefined }, + query, signal, }); const result = await (options.debounce ? this.#expandedRequest.schedule(run) : this.#expandedRequest.run(run)); + const active = this.state.expandedBreakdown; if (result.status === "cancelled" || result.status === "stale" - || this.state.expandedBreakdown?.dimension !== dimension - || this.state.expandedBreakdown.search !== search) return; + || active?.dimension !== dimension + || active.search !== search) return; if (result.status === "error") { - this.#set({ expandedBreakdown: { dimension, headline, search, report: errorState(reportErrorMessage(result.error), previous) } }); + this.#set({ expandedBreakdown: { dimension, headline, search, cache: active.cache, report: errorState(reportErrorMessage(result.error), previous) } }); return; } const { data, error, response } = result.value; - this.#set({ expandedBreakdown: { dimension, headline, search, report: error + const rows = data?.rows ?? []; + const nextCache = error ? active.cache : { ...active.cache, [cacheKey]: rows }; + this.#set({ expandedBreakdown: { dimension, headline, search, cache: nextCache, report: error ? errorState(apiErrorMessage(error, response?.status ?? 0), previous) - : successState(data?.rows ?? []) } }); + : successState(rows) } }); } searchBreakdown(search: string): void { @@ -416,7 +424,6 @@ export class AnalyticsDashboardController { async selectEvent(eventName: string): Promise { if (!this.#capabilities().eventDetails) return; - this.closeEvents(); await this.#loadEventDetails(eventName); } @@ -428,7 +435,9 @@ export class AnalyticsDashboardController { } searchEventProperty(propertyName: string, search: string): void { - if (this.state.selectedEvent && this.#capabilities().eventProperties) void this.#loadEventPropertyValues(propertyName, search.trim(), true); + if (!this.state.selectedEvent || !this.#capabilities().eventProperties) return; + const normalizedSearch = search.trim(); + void this.#loadEventPropertyValues(propertyName, normalizedSearch, normalizedSearch.length > 0); } closeEventDetails(): void { @@ -437,6 +446,19 @@ export class AnalyticsDashboardController { 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 }); + } + #set(patch: Partial): void { this.state = { ...this.state, ...patch }; this.#notify(); } async #initialize(): Promise { @@ -608,7 +630,7 @@ export class AnalyticsDashboardController { 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() } }); + 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, })); @@ -622,7 +644,10 @@ export class AnalyticsDashboardController { this.#set({ selectedEvent: { ...this.state.selectedEvent, details: errorState(apiErrorMessage(error, response?.status ?? 0), previous) } }); return; } - this.#set({ selectedEvent: { ...this.state.selectedEvent, details: successState(data) } }); + 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, ""); @@ -633,8 +658,10 @@ export class AnalyticsDashboardController { const connection = this.state.connection; const selected = this.state.selectedEvent; if (!connection || !selected || !this.#capabilities().eventProperties) return; - const previous = selected.property; - this.#set({ selectedEvent: { ...selected, propertyName, propertySearch: search, property: loadingState(previous) } }); + 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()), @@ -651,13 +678,13 @@ export class AnalyticsDashboardController { 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), previous) } }); + 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), previous) - : successState(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 { @@ -726,6 +753,14 @@ export class AnalyticsDashboardController { } } +function eventPropertyCacheKey(propertyName: string, search: string): string { + return JSON.stringify([propertyName, search]); +} + +function breakdownCacheKey(dimension: AnalyticsDimension, search: string): string { + return JSON.stringify([dimension, 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-dashboard.element.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dashboard.element.ts index 875d3b2..a66ad09 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dashboard.element.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dashboard.element.ts @@ -1,7 +1,8 @@ -import { LitElement, customElement, html, property, state } from "@umbraco-cms/backoffice/external/lit"; +import { LitElement, customElement, html, nothing, property, state } from "@umbraco-cms/backoffice/external/lit"; import { UmbElementMixin } from "@umbraco-cms/backoffice/element-api"; import type { AnalyticsDimension } from "../api/types.gen.js"; -import { countryDisplayName, normalizeCountryCode } from "./country-display.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 { AcquisitionView } from "./dashboard-cards.js"; @@ -48,6 +49,22 @@ export class WebAnalyticsDashboardElement extends UmbElementMixin(LitElement) { return filter.value; } + #renderFilterIcon(filter: AnalyticsFilter) { + const countryCode = filter.dimension === "Country" ? normalizeCountryCode(filter.value) : undefined; + if (countryCode) { + return html``; + } + + const valueIcon = breakdownValueIcon(filter.dimension, filter.value); + if (valueIcon?.kind === "asset") { + return html``; + } + if (valueIcon?.kind === "native") { + return html``; + } + return nothing; + } + #renderFilters(filters: AnalyticsFilter[]) { if (!filters.length) return ""; return html` @@ -60,6 +77,7 @@ export class WebAnalyticsDashboardElement extends UmbElementMixin(LitElement) { class="filter-badge" aria-label=${`Remove filter ${this.#filterLabel(filter)}`} @click=${() => this.#controller.removeFilter(filter.dimension)}> + ${this.#renderFilterIcon(filter)} ${this.#filterLabel(filter)} `)} @@ -185,7 +203,7 @@ export class WebAnalyticsDashboardElement extends UmbElementMixin(LitElement) { @breakdown-dimension-change=${(event: CustomEvent<{ dimension: AnalyticsDimension; headline: string }>) => this.#controller.openBreakdown(event.detail.dimension, event.detail.headline)} @close-breakdown=${() => this.#controller.closeBreakdown()}> ` : ""} - ${expandedEvents ? html` + ${expandedEvents && !selected ? 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)} - @close-event-details=${() => this.#controller.closeEventDetails()}> + @back-to-events=${() => this.#controller.backToEvents()} + @close-event-details=${() => this.#controller.closeEventFlow()}> ` : ""} `; diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dashboard.styles.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dashboard.styles.ts index 9a6b56f..85afb77 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dashboard.styles.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dashboard.styles.ts @@ -17,6 +17,10 @@ export const analyticsDashboardStyles = [UmbTextStyles, css` .filter-badge { align-items: center; appearance: none; background: var(--uui-color-surface); border: 1px solid var(--uui-color-border); border-radius: var(--uui-border-radius); color: var(--uui-color-text); cursor: pointer; display: inline-flex; font: inherit; gap: var(--uui-size-space-2); max-inline-size: min(32rem, 100%); min-block-size: 2rem; min-inline-size: 0; padding: var(--uui-size-space-1) var(--uui-size-space-2); } .filter-badge:hover { background: color-mix(in srgb, var(--uui-color-interactive) 6%, var(--uui-color-surface)); border-color: var(--uui-color-interactive); } .filter-badge:focus-visible { outline: 2px solid var(--uui-color-selected); outline-offset: 2px; } + .filter-icon { flex: 0 0 auto; } + img.filter-icon { object-fit: contain; } + img.filter-flag { border-radius: var(--uui-border-radius); object-fit: cover; } + uui-icon.filter-icon { font-size: var(--uui-type-default-size); } .filter-value { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .filter-remove { color: var(--uui-color-text-alt); flex: 0 0 auto; font-size: 1.1em; line-height: 1; } .clear-filters { flex: 0 0 auto; } diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dialog-headline.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dialog-headline.ts index f5c5bc8..5ec4534 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dialog-headline.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dialog-headline.ts @@ -1,14 +1,15 @@ import { html, nothing } from "@umbraco-cms/backoffice/external/lit"; export function renderAnalyticsDialogHeadline( - headline: string, + headline: unknown, closeLabel: string, onClose: () => void, controls?: unknown, + showHeadline = true, ) { return html` -
-

${headline}

+
+ ${showHeadline ? html`

${headline}

` : nothing} ${controls ? html`
${controls}
` : nothing} - ` : ""} - + ` : ""} + ${this.filterProperty !== undefined && this.filterValue !== undefined ? html` + + ` : ""} + + ` : ""}
- + ${this.searchLoading ? html`Loading ${property.name} values` : ""} +
@@ -113,7 +118,7 @@ export class WebAnalyticsEventDetailsDialogElement extends UmbElementMixin(LitEl ${this.searchLoading ? html` - + ${renderAnalyticsTableSkeletonRows(6)} ` : this.searchUnavailable ? html` ` : values.length ? values.map((value) => { @@ -135,10 +140,10 @@ export class WebAnalyticsEventDetailsDialogElement extends UmbElementMixin(LitEl @click=${() => this.#toggleFilter(property.name, value.value)}> - ${this.localize.number(value.visitors)} + ${this.localize.number(value.visitors)} - + `; }) : html``} @@ -163,7 +168,12 @@ export class WebAnalyticsEventDetailsDialogElement extends UmbElementMixin(LitEl return html`
- ${renderAnalyticsDialogHeadline(`${this.eventName} event`, "Close event details", () => this.#close())} + ${renderAnalyticsDialogHeadline(html` + + `, "Close event details", () => this.#close())}
${this.details ? html` ${this.propertiesEnabled ? activeProperty ? html` @@ -179,8 +189,8 @@ export class WebAnalyticsEventDetailsDialogElement extends UmbElementMixin(LitEl `; } - static styles = [UmbTextStyles, analyticsDialogStyles, reportTabsStyles, css` - .dialog-content { --analytics-dialog-body-height: min(28rem, 52dvh); display: flex; flex-direction: column; position: relative; } + static styles = [UmbTextStyles, analyticsDialogStyles, analyticsEventDialogStyles, analyticsTableSkeletonStyles, reportTabsStyles, css` + .dialog-content { display: flex; flex-direction: column; position: relative; } .property-controls { border-bottom: 1px solid var(--uui-color-border); display: grid; flex: 0 0 auto; gap: var(--uui-size-space-3); padding: 0 var(--analytics-dialog-inline-padding) var(--uui-size-space-4); } .property-controls uui-input { box-sizing: border-box; width: 100%; } .property-controls uui-input [slot="prepend"] { align-items: center; display: flex; margin-inline: var(--uui-size-space-3) var(--uui-size-space-2); } 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 new file mode 100644 index 0000000..ce9afcb --- /dev/null +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/event-dialog.element.test.ts @@ -0,0 +1,26 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@umbraco-cms/backoffice/element-api", () => ({ + UmbElementMixin: (base: T) => base, +})); +vi.mock("@umbraco-cms/backoffice/style", () => ({ UmbTextStyles: [] })); + +import type { WebAnalyticsEventDialogElement } from "./event-dialog.element.js"; +import "./event-dialog.element.js"; + +beforeEach(() => { HTMLDialogElement.prototype.showModal = vi.fn(); }); +afterEach(() => document.body.replaceChildren()); + +describe("events dialog", () => { + it("keeps its accessible name without repeating a visible title", async () => { + const dialog = document.createElement("web-analytics-event-dialog") as WebAnalyticsEventDialogElement; + document.body.append(dialog); + await dialog.updateComplete; + + expect(dialog.shadowRoot?.querySelector("dialog")?.getAttribute("aria-label")).toBe("Events"); + expect(dialog.shadowRoot?.querySelector(".analytics-dialog-headline h2")).toBeNull(); + expect(dialog.shadowRoot?.querySelector("uui-input")?.getAttribute("label")).toBe("Search events"); + expect(dialog.shadowRoot?.querySelector(".analytics-dialog-close")?.getAttribute("aria-label")).toBe("Close events"); + }); +}); 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 624a371..c2a0080 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/event-dialog.element.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/event-dialog.element.ts @@ -4,7 +4,7 @@ import { UmbTextStyles } from "@umbraco-cms/backoffice/style"; import type { UUIInputElement } from "@umbraco-cms/backoffice/external/uui"; import type { AnalyticsEventRow } from "../api/types.gen.js"; import { renderAnalyticsDialogHeadline } from "./analytics-dialog-headline.js"; -import { analyticsDialogStyles } from "./analytics-dialog.styles.js"; +import { analyticsDialogStyles, analyticsEventDialogStyles } from "./analytics-dialog.styles.js"; import type { AnalyticsFilter } from "./dashboard-url-state.js"; import "./event-table.element.js"; @@ -35,7 +35,7 @@ export class WebAnalyticsEventDialogElement extends UmbElementMixin(LitElement) - `)} + `, false)}
${!this.loading && this.unavailable ? html`

${this.unavailable}

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

Try a different search.

` : ""} @@ -48,8 +48,7 @@ export class WebAnalyticsEventDialogElement extends UmbElementMixin(LitElement) `; } - static styles = [UmbTextStyles, analyticsDialogStyles, css` - dialog { --analytics-dialog-max-width: 58rem; } + static styles = [UmbTextStyles, analyticsDialogStyles, analyticsEventDialogStyles, css` uui-input { box-sizing: border-box; width: 100%; } uui-input [slot="prepend"] { align-items: center; display: flex; margin-inline: var(--uui-size-space-3) var(--uui-size-space-2); } .results { overflow: auto; scrollbar-gutter: stable; } 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 3e99df7..5b4ff64 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/event-table.element.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/event-table.element.ts @@ -3,6 +3,7 @@ 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 { visibleEventRows } from "./event-rows.js"; +import { analyticsTableSkeletonStyles, renderAnalyticsTableSkeletonRows } from "./analytics-table-skeleton.js"; @customElement("web-analytics-event-table") export class WebAnalyticsEventTableElement extends UmbElementMixin(LitElement) { @@ -31,9 +32,7 @@ export class WebAnalyticsEventTableElement extends UmbElementMixin(LitElement) {
${this.loading - ? Array.from({ length: this.skeletonRows }, () => html` - - `) + ? renderAnalyticsTableSkeletonRows(this.skeletonRows) : rows.map((row) => { const activeFilter = this.filters.some((filter) => filter.dimension === "EventName" && filter.value === row.eventName); const filterLabel = activeFilter ? `Remove ${row.eventName} event filter` : `Filter analytics by ${row.eventName} event`; @@ -75,7 +74,7 @@ export class WebAnalyticsEventTableElement extends UmbElementMixin(LitElement) { `; } - static styles = css` + 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; } @@ -100,17 +99,14 @@ export class WebAnalyticsEventTableElement extends UmbElementMixin(LitElement) { .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)); } - .skeleton-line, .skeleton-number { background: var(--uui-color-surface-alt); block-size: 1lh; border-radius: var(--uui-border-radius); display: block; } - .skeleton-line { width: 70%; } - .skeleton-number { margin-inline-start: auto; width: 3.5rem; } .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: 1.5rem; height: 3rem; justify-content: center; width: 3rem; } + .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; } } - `; + `]; } declare global {
${property.name} values for ${this.eventName}

Looking up matching values…

${this.searchUnavailable}

${this.localize.number(value.count)}${this.localize.number(value.count)}

${search ? "Try a different search." : "No values were recorded for this property in the selected period."}

Events and goals
EventVisitorsTotal events