From 68f12f191055ba73eb7d2756c6da1f1a8226875d Mon Sep 17 00:00:00 2001 From: Daniel Schmidt Date: Thu, 23 Jul 2026 10:49:07 +0200 Subject: [PATCH 01/25] fix: remove empty event property controls --- .../event-details-dialog.element.test.ts | 16 +++++++++++++ .../analytics/event-details-dialog.element.ts | 24 ++++++++++--------- 2 files changed, 29 insertions(+), 11 deletions(-) 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 cb73ac0..6dd7257 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 @@ -58,6 +58,22 @@ describe("event details dialog layout", () => { expect(dialog.shadowRoot?.querySelector("thead")?.textContent?.match(/title/g)).toHaveLength(1); }); + it("does not leave an empty search area when the active property has no values", async () => { + const dialog = document.createElement("web-analytics-event-details-dialog") as WebAnalyticsEventDetailsDialogElement; + dialog.eventName = "Signup completed"; + dialog.propertiesEnabled = true; + dialog.details = { + eventName: "Signup completed", + totals: { count: 15, visitors: 12 }, + properties: [{ name: "plan", values: [] }], + }; + document.body.append(dialog); + await dialog.updateComplete; + + expect(dialog.shadowRoot?.querySelector(".property-controls")).toBeNull(); + expect(dialog.shadowRoot?.querySelector('uui-input[type="search"]')).toBeNull(); + }); + it("does not render property controls when properties are unavailable", async () => { const dialog = document.createElement("web-analytics-event-details-dialog") as WebAnalyticsEventDetailsDialogElement; dialog.eventName = "Read case"; 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 55bfd66..4fc47e8 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 @@ -82,8 +82,9 @@ export class WebAnalyticsEventDetailsDialogElement extends UmbElementMixin(LitEl const maximum = Math.max(...values.map((value) => value.count), 1); return html`
-
- ${property.values.length ? html` + ${property.values.length || (this.filterProperty !== undefined && this.filterValue !== undefined) ? html` +
+ ${property.values.length ? html` - ` : ""} - ${this.filterProperty !== undefined && this.filterValue !== undefined ? html` - - ` : ""} -
+ ` : ""} + ${this.filterProperty !== undefined && this.filterValue !== undefined ? html` + + ` : ""} +
+ ` : ""}
From 147d1c6dc65908830afb1d8d405af5215aa30704 Mon Sep 17 00:00:00 2001 From: Daniel Schmidt Date: Thu, 23 Jul 2026 10:50:46 +0200 Subject: [PATCH 02/25] style: emphasize event property values --- .../Client/src/analytics/event-details-dialog.element.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 4fc47e8..a525d92 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 @@ -205,7 +205,7 @@ export class WebAnalyticsEventDetailsDialogElement extends UmbElementMixin(LitEl tr:hover .filter-button, .filter-button:focus-visible, .filter-button[aria-pressed="true"] { opacity: 1; } .filter-button:hover { background: var(--uui-color-surface-emphasis); } .filter-button:focus-visible { outline: 2px solid var(--uui-color-selected); outline-offset: 1px; } - .value-label { overflow-wrap: anywhere; position: relative; z-index: 1; } + .value-label { font-weight: 700; overflow-wrap: anywhere; position: relative; 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)); } .loading, .state-message { box-sizing: border-box; flex: 1; padding: var(--uui-size-space-5); } From 3365a82877817d1b16b0a69c986f5729ee37b032 Mon Sep 17 00:00:00 2001 From: Daniel Schmidt Date: Thu, 23 Jul 2026 10:53:17 +0200 Subject: [PATCH 03/25] refactor: simplify breakdown dialog header --- .../Client/src/analytics/analytics-dialog-headline.ts | 5 +++-- .../Client/src/analytics/analytics-dialog.styles.ts | 2 ++ .../Client/src/analytics/breakdown-dialog.element.test.ts | 8 +++++--- .../Client/src/analytics/breakdown-dialog.element.ts | 2 +- 4 files changed, 11 insertions(+), 6 deletions(-) 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..d162522 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dialog-headline.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dialog-headline.ts @@ -5,10 +5,11 @@ export function renderAnalyticsDialogHeadline( closeLabel: string, onClose: () => void, controls?: unknown, + showHeadline = true, ) { return html` -
-

${headline}

+
+ ${showHeadline ? html`

${headline}

` : nothing} ${controls ? html`
${controls}
` : nothing} + `)}
${this.details ? html` ${this.propertiesEnabled ? activeProperty ? html` From ccc208c524b30ec8e93dc60e980bc0daa949f848 Mon Sep 17 00:00:00 2001 From: Daniel Schmidt Date: Thu, 23 Jul 2026 11:52:39 +0200 Subject: [PATCH 08/25] refactor: focus event details header --- .../Client/src/analytics/analytics-components.test.ts | 2 +- .../Client/src/analytics/analytics-dialog.styles.ts | 10 +++++----- .../src/analytics/event-details-dialog.element.test.ts | 5 +++-- .../src/analytics/event-details-dialog.element.ts | 7 +++---- 4 files changed, 12 insertions(+), 12 deletions(-) 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..add91ba 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-components.test.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-components.test.ts @@ -495,7 +495,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")?.textContent).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); diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dialog.styles.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dialog.styles.ts index 4e4c421..f0cbadc 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dialog.styles.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dialog.styles.ts @@ -32,7 +32,7 @@ export const analyticsDialogStyles = css` .analytics-dialog-headline.has-controls .analytics-dialog-close { grid-column: 4; } .analytics-dialog-headline.has-controls.no-headline { grid-template-columns: minmax(11rem, 16rem) minmax(0, 1fr) 2.75rem; } .analytics-dialog-headline.has-controls.no-headline .analytics-dialog-close { grid-column: 3; } - .analytics-dialog-headline.has-leading-action { gap: var(--uui-size-space-3); grid-template-columns: max-content minmax(0, 1fr) 2.75rem; } + .analytics-dialog-headline.has-leading-action { grid-template-columns: 2.75rem minmax(0, 1fr) 2.75rem; } .analytics-dialog-headline h2 { font-size: var(--uui-type-default-size); font-weight: 700; @@ -79,16 +79,16 @@ export const analyticsDialogStyles = css` block-size: 2.75rem; border: 0; border-radius: var(--uui-border-radius); - color: var(--uui-color-interactive); + color: var(--uui-color-text); cursor: pointer; display: inline-flex; font: inherit; - gap: var(--uui-size-space-1); - padding: 0 var(--uui-size-space-2); + inline-size: 2.75rem; + justify-content: center; + padding: 0; } .analytics-dialog-back:hover { background: var(--uui-color-surface-alt); color: var(--uui-color-text); } .analytics-dialog-back:focus-visible { outline: 2px solid var(--uui-color-selected); outline-offset: 2px; } - .analytics-dialog-back span { white-space: nowrap; } .analytics-dialog-body { block-size: var(--analytics-dialog-body-height, min(30rem, 52dvh)); min-block-size: 0; 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 82eb723..5c1ff76 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 @@ -21,8 +21,9 @@ describe("event details dialog layout", () => { document.body.append(dialog); await dialog.updateComplete; - expect(dialog.shadowRoot?.querySelector(".analytics-dialog-back")?.textContent).toContain("All events"); - expect(dialog.shadowRoot?.querySelector(".analytics-dialog-back uui-icon")?.getAttribute("name")).toBe("icon-arrow-left"); + expect(dialog.shadowRoot?.querySelector(".analytics-dialog-headline h2")?.textContent).toBe("Signup completed"); + expect(dialog.shadowRoot?.querySelector(".analytics-dialog-back")?.getAttribute("aria-label")).toBe("Back to all events"); + expect(dialog.shadowRoot?.querySelector(".analytics-dialog-back uui-icon")?.getAttribute("name")).toBe("icon-navigation-left"); (dialog.shadowRoot?.querySelector(".analytics-dialog-back") as HTMLButtonElement).click(); expect(onBack).toHaveBeenCalledOnce(); }); 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 17907cf..25a3403 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 @@ -166,10 +166,9 @@ export class WebAnalyticsEventDetailsDialogElement extends UmbElementMixin(LitEl return html`
- ${renderAnalyticsDialogHeadline(`${this.eventName} event`, "Close event details", () => this.#close(), undefined, true, html` - `)}
From de5464542827ee594f739ec51e10f436c45fba5e Mon Sep 17 00:00:00 2001 From: Daniel Schmidt Date: Thu, 23 Jul 2026 11:56:10 +0200 Subject: [PATCH 09/25] fix: open all events when leaving event details --- .../analytics-dashboard.controller.test.ts | 14 ++++++++++++++ .../analytics/analytics-dashboard.controller.ts | 6 ++++++ .../src/analytics/analytics-dashboard.element.ts | 2 +- 3 files changed, 21 insertions(+), 1 deletion(-) 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 5f2a07a..bbe78b8 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 @@ -227,6 +227,20 @@ describe("AnalyticsDashboardController", () => { 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({ 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 4e611d6..53bc8c8 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dashboard.controller.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dashboard.controller.ts @@ -436,6 +436,12 @@ 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(); 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 03540f3..ff8abe1 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dashboard.element.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dashboard.element.ts @@ -214,7 +214,7 @@ export class WebAnalyticsDashboardElement extends UmbElementMixin(LitElement) { .searchUnavailable=${this.#error(selected.property)} @search-event-property=${(event: CustomEvent<{ propertyName: string; search: string }>) => 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.closeEventDetails()} + @back-to-events=${() => this.#controller.backToEvents()} @close-event-details=${() => this.#controller.closeEventFlow()}> ` : ""} From 18f45e5f4e6a7533a8399dc7eec2c933d8f54185 Mon Sep 17 00:00:00 2001 From: Daniel Schmidt Date: Thu, 23 Jul 2026 11:59:06 +0200 Subject: [PATCH 10/25] refactor: combine event title with back navigation --- .../src/analytics/analytics-components.test.ts | 2 +- .../src/analytics/analytics-dialog-headline.ts | 6 ++---- .../src/analytics/analytics-dialog.styles.ts | 15 +++++++++------ .../event-details-dialog.element.test.ts | 8 +++++--- .../src/analytics/event-details-dialog.element.ts | 7 ++++--- 5 files changed, 21 insertions(+), 17 deletions(-) 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 add91ba..9b8d681 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-components.test.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-components.test.ts @@ -495,7 +495,7 @@ describe("analytics presentation components", () => { document.body.append(element); await element.updateComplete; - expect(element.shadowRoot?.querySelector(".analytics-dialog-headline h2")?.textContent).toBe("Read article"); + 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); 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 a6aa44e..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,16 +1,14 @@ import { html, nothing } from "@umbraco-cms/backoffice/external/lit"; export function renderAnalyticsDialogHeadline( - headline: string, + headline: unknown, closeLabel: string, onClose: () => void, controls?: unknown, showHeadline = true, - leadingAction?: unknown, ) { return html` -
- ${leadingAction ? html`
${leadingAction}
` : nothing} +
${showHeadline ? html`

${headline}

` : nothing} ${controls ? html`
${controls}
` : nothing} - `)} + `, "Close event details", () => this.#close())}
${this.details ? html` ${this.propertiesEnabled ? activeProperty ? html` From 9af74605dedcfe3628f11d9369dd098ab9c95182 Mon Sep 17 00:00:00 2001 From: Daniel Schmidt Date: Thu, 23 Jul 2026 12:04:41 +0200 Subject: [PATCH 11/25] fix: align event detail dialog metrics --- .../src/analytics/analytics-dialog.styles.ts | 4 ++++ .../event-details-dialog.element.test.ts | 19 ++++++++++++++++++- .../analytics/event-details-dialog.element.ts | 10 +++++----- .../src/analytics/event-dialog.element.ts | 5 ++--- 4 files changed, 29 insertions(+), 9 deletions(-) diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dialog.styles.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dialog.styles.ts index a8dce29..97c01b6 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dialog.styles.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dialog.styles.ts @@ -104,3 +104,7 @@ export const analyticsDialogStyles = css` .analytics-dialog-body { block-size: 48dvh; } } `; + +export const analyticsEventDialogStyles = css` + dialog { --analytics-dialog-max-width: 58rem; } +`; 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 74d71e8..87aa9dc 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 @@ -2,7 +2,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; vi.mock("@umbraco-cms/backoffice/element-api", () => ({ - UmbElementMixin: (base: T) => base, + UmbElementMixin: (base: T) => class extends base { + readonly localize = { number: (value: number) => value.toLocaleString() }; + }, })); vi.mock("@umbraco-cms/backoffice/style", () => ({ UmbTextStyles: [] })); @@ -109,4 +111,19 @@ describe("event details dialog layout", () => { expect(dialog.shadowRoot?.querySelector(".filter-button")).toBeNull(); expect(dialog.shadowRoot?.querySelector(".active-filter")).toBeNull(); }); + + it("emphasizes both metrics for each property value", async () => { + const dialog = document.createElement("web-analytics-event-details-dialog") as WebAnalyticsEventDetailsDialogElement; + dialog.eventName = "Signup completed"; + dialog.propertiesEnabled = true; + dialog.details = { + eventName: "Signup completed", + totals: { count: 15, visitors: 12 }, + properties: [{ name: "plan", values: [{ value: "Pro", count: 15, visitors: 12 }] }], + }; + document.body.append(dialog); + await dialog.updateComplete; + + expect([...dialog.shadowRoot?.querySelectorAll("tbody td strong") ?? []].map((metric) => metric.textContent)).toEqual(["12", "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 670890f..69b0ff2 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 @@ -4,7 +4,7 @@ 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 { analyticsDialogStyles } from "./analytics-dialog.styles.js"; +import { analyticsDialogStyles, analyticsEventDialogStyles } from "./analytics-dialog.styles.js"; import { renderReportTabs, reportTabsStyles } from "./report-tabs.js"; @customElement("web-analytics-event-details-dialog") @@ -138,10 +138,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``} @@ -187,8 +187,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, 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.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/event-dialog.element.ts index 4e285f9..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"; @@ -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; } From 13ecb9fb3ec8e3e5d2790a5f9dc3fb99ba705e62 Mon Sep 17 00:00:00 2001 From: Daniel Schmidt Date: Thu, 23 Jul 2026 12:11:15 +0200 Subject: [PATCH 12/25] fix: preserve event property value hierarchy --- .../Client/src/analytics/event-details-dialog.element.test.ts | 3 ++- .../Client/src/analytics/event-details-dialog.element.ts | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) 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 87aa9dc..8c1bbfb 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 @@ -112,7 +112,7 @@ describe("event details dialog layout", () => { expect(dialog.shadowRoot?.querySelector(".active-filter")).toBeNull(); }); - it("emphasizes both metrics for each property value", async () => { + it("emphasizes metrics without emphasizing the property value", async () => { const dialog = document.createElement("web-analytics-event-details-dialog") as WebAnalyticsEventDetailsDialogElement; dialog.eventName = "Signup completed"; dialog.propertiesEnabled = true; @@ -124,6 +124,7 @@ describe("event details dialog layout", () => { document.body.append(dialog); await dialog.updateComplete; + expect(dialog.shadowRoot?.querySelector("tbody th strong")).toBeNull(); expect([...dialog.shadowRoot?.querySelectorAll("tbody td strong") ?? []].map((metric) => metric.textContent)).toEqual(["12", "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 69b0ff2..63ba63c 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 @@ -211,7 +211,7 @@ export class WebAnalyticsEventDetailsDialogElement extends UmbElementMixin(LitEl tr:hover .filter-button, .filter-button:focus-visible, .filter-button[aria-pressed="true"] { opacity: 1; } .filter-button:hover { background: var(--uui-color-surface-emphasis); } .filter-button:focus-visible { outline: 2px solid var(--uui-color-selected); outline-offset: 1px; } - .value-label { font-weight: 700; overflow-wrap: anywhere; position: relative; z-index: 1; } + .value-label { overflow-wrap: anywhere; position: relative; 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)); } .loading, .state-message { box-sizing: border-box; flex: 1; padding: var(--uui-size-space-5); } From d961e959f7cbf1a2a00c5ea86104e72d8306bbbe Mon Sep 17 00:00:00 2001 From: Daniel Schmidt Date: Thu, 23 Jul 2026 12:18:04 +0200 Subject: [PATCH 13/25] fix: retain event property values while refreshing --- .../analytics-dashboard.controller.test.ts | 30 +++++++++++++++++++ .../analytics-dashboard.controller.ts | 28 ++++++++++++----- .../src/analytics/analytics-table-skeleton.ts | 20 +++++++++++++ .../event-details-dialog.element.test.ts | 17 +++++++++++ .../analytics/event-details-dialog.element.ts | 8 +++-- .../src/analytics/event-table.element.ts | 14 ++++----- 6 files changed, 97 insertions(+), 20 deletions(-) create mode 100644 src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-table-skeleton.ts 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 bbe78b8..6d69a52 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"; @@ -258,6 +259,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 53bc8c8..c9f74f0 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dashboard.controller.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dashboard.controller.ts @@ -49,6 +49,7 @@ export type SelectedEvent = { propertyName?: string; propertySearch?: string; property: AsyncState; + propertyCache: Readonly>; }; export type DashboardState = { connections: AnalyticsConnectionSummary[]; @@ -427,7 +428,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 { @@ -620,7 +623,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, })); @@ -634,7 +637,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, ""); @@ -645,8 +651,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()), @@ -663,13 +671,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 { @@ -738,6 +746,10 @@ export class AnalyticsDashboardController { } } +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-table-skeleton.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-table-skeleton.ts new file mode 100644 index 0000000..4474743 --- /dev/null +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-table-skeleton.ts @@ -0,0 +1,20 @@ +import { css, html } from "@umbraco-cms/backoffice/external/lit"; + +export const renderAnalyticsTableSkeletonRows = (count: number) => Array.from({ length: count }, () => html` + + + + + +`); + +export const analyticsTableSkeletonStyles = css` + .skeleton-line, .skeleton-number { + background: var(--uui-color-surface-alt); + block-size: 1lh; + border-radius: var(--uui-border-radius); + display: block; + } + .skeleton-line { inline-size: 70%; } + .skeleton-number { inline-size: 3.5rem; margin-inline-start: auto; } +`; 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 8c1bbfb..d0b4960 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 @@ -93,6 +93,23 @@ describe("event details dialog layout", () => { expect(dialog.shadowRoot?.querySelector('uui-input[type="search"]')).toBeNull(); }); + it("shows a table skeleton while an uncached property tab is loading", async () => { + const dialog = document.createElement("web-analytics-event-details-dialog") as WebAnalyticsEventDetailsDialogElement; + dialog.eventName = "Signup completed"; + dialog.propertiesEnabled = true; + dialog.searchLoading = true; + dialog.details = { + eventName: "Signup completed", + totals: { count: 15, visitors: 12 }, + properties: [{ name: "source", values: [] }], + }; + document.body.append(dialog); + await dialog.updateComplete; + + expect(dialog.shadowRoot?.querySelectorAll(".skeleton-line")).toHaveLength(6); + expect(dialog.shadowRoot?.textContent).not.toContain("No values were recorded"); + }); + it("does not render property controls when properties are unavailable", async () => { const dialog = document.createElement("web-analytics-event-details-dialog") as WebAnalyticsEventDetailsDialogElement; dialog.eventName = "Read case"; 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 63ba63c..b940402 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 @@ -5,6 +5,7 @@ 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 { analyticsDialogStyles, analyticsEventDialogStyles } from "./analytics-dialog.styles.js"; +import { analyticsTableSkeletonStyles, renderAnalyticsTableSkeletonRows } from "./analytics-table-skeleton.js"; import { renderReportTabs, reportTabsStyles } from "./report-tabs.js"; @customElement("web-analytics-event-details-dialog") @@ -106,7 +107,8 @@ export class WebAnalyticsEventDetailsDialogElement extends UmbElementMixin(LitEl ` : ""}
-
${property.name} values for ${this.eventName}
${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."}

+ ${this.searchLoading ? html`Loading ${property.name} values` : ""} +
@@ -116,7 +118,7 @@ export class WebAnalyticsEventDetailsDialogElement extends UmbElementMixin(LitEl ${this.searchLoading ? html` - + ${renderAnalyticsTableSkeletonRows(6)} ` : this.searchUnavailable ? html` ` : values.length ? values.map((value) => { @@ -187,7 +189,7 @@ export class WebAnalyticsEventDetailsDialogElement extends UmbElementMixin(LitEl `; } - static styles = [UmbTextStyles, analyticsDialogStyles, analyticsEventDialogStyles, reportTabsStyles, css` + 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%; } 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 { From 18527bacc9105b4caf95063826bf3c7b9c3656b1 Mon Sep 17 00:00:00 2001 From: Daniel Schmidt Date: Thu, 23 Jul 2026 12:28:25 +0200 Subject: [PATCH 14/25] feat: add browser icons to analytics --- .../Client/public/icons/browsers/README.md | 4 ++ .../Client/public/icons/browsers/brave.svg | 24 +++++++++++ .../Client/public/icons/browsers/chrome.svg | 1 + .../public/icons/browsers/duckduckgo.svg | 1 + .../Client/public/icons/browsers/ecosia.svg | 1 + .../Client/public/icons/browsers/edge.svg | 41 +++++++++++++++++++ .../Client/public/icons/browsers/firefox.svg | 1 + .../Client/public/icons/browsers/jio.svg | 1 + .../Client/public/icons/browsers/opera-gx.svg | 1 + .../Client/public/icons/browsers/opera.svg | 16 ++++++++ .../Client/public/icons/browsers/qwant.svg | 1 + .../Client/public/icons/browsers/safari.svg | 31 ++++++++++++++ .../public/icons/browsers/samsung-browser.svg | 1 + .../Client/public/icons/browsers/sberbank.svg | 1 + .../Client/public/icons/browsers/vivo.svg | 1 + .../Client/public/icons/browsers/yandex.svg | 4 ++ .../analytics/analytics-components.test.ts | 18 ++++++++ .../src/analytics/breakdown-table.element.ts | 9 ++++ .../Client/src/analytics/browser-icon.test.ts | 16 ++++++++ .../Client/src/analytics/browser-icon.ts | 35 ++++++++++++++++ 20 files changed, 208 insertions(+) create mode 100644 src/TheBuilder.WebAnalytics/Client/public/icons/browsers/README.md create mode 100644 src/TheBuilder.WebAnalytics/Client/public/icons/browsers/brave.svg create mode 100644 src/TheBuilder.WebAnalytics/Client/public/icons/browsers/chrome.svg create mode 100644 src/TheBuilder.WebAnalytics/Client/public/icons/browsers/duckduckgo.svg create mode 100644 src/TheBuilder.WebAnalytics/Client/public/icons/browsers/ecosia.svg create mode 100644 src/TheBuilder.WebAnalytics/Client/public/icons/browsers/edge.svg create mode 100644 src/TheBuilder.WebAnalytics/Client/public/icons/browsers/firefox.svg create mode 100644 src/TheBuilder.WebAnalytics/Client/public/icons/browsers/jio.svg create mode 100644 src/TheBuilder.WebAnalytics/Client/public/icons/browsers/opera-gx.svg create mode 100644 src/TheBuilder.WebAnalytics/Client/public/icons/browsers/opera.svg create mode 100644 src/TheBuilder.WebAnalytics/Client/public/icons/browsers/qwant.svg create mode 100644 src/TheBuilder.WebAnalytics/Client/public/icons/browsers/safari.svg create mode 100644 src/TheBuilder.WebAnalytics/Client/public/icons/browsers/samsung-browser.svg create mode 100644 src/TheBuilder.WebAnalytics/Client/public/icons/browsers/sberbank.svg create mode 100644 src/TheBuilder.WebAnalytics/Client/public/icons/browsers/vivo.svg create mode 100644 src/TheBuilder.WebAnalytics/Client/public/icons/browsers/yandex.svg create mode 100644 src/TheBuilder.WebAnalytics/Client/src/analytics/browser-icon.test.ts create mode 100644 src/TheBuilder.WebAnalytics/Client/src/analytics/browser-icon.ts 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..cd4c6ee --- /dev/null +++ b/src/TheBuilder.WebAnalytics/Client/public/icons/browsers/README.md @@ -0,0 +1,4 @@ +# Browser icon sources + +The coloured browser marks in this directory are local copies from [theSVG](https://thesvg.org/), which publishes these SVG assets under the MIT License. + 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/src/analytics/analytics-components.test.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-components.test.ts index 9b8d681..b0b0a09 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,24 @@ 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(".browser-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("keeps document traffic breakdowns ahead of optional reports", async () => { const element = document.createElement("web-analytics-breakdown-grid") as WebAnalyticsBreakdownGridElement; element.cards = dashboardCards(true, "unavailable"); 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 a32d79f..fb7a06c 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/breakdown-table.element.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/breakdown-table.element.ts @@ -14,6 +14,7 @@ import { type TrafficMetric, } from "./breakdown-rows.js"; import { countryDisplayName, countryFlagUrl, normalizeCountryCode } from "./country-display.js"; +import { browserIconPath } from "./browser-icon.js"; import type { AnalyticsFilter } from "./dashboard-url-state.js"; import { googleFaviconUrl } from "./favicon.js"; import { renderReportTabs, reportTabsStyles, selectedReportTabId, type ReportTabGroup } from "./report-tabs.js"; @@ -82,6 +83,7 @@ export class WebAnalyticsBreakdownTableElement extends UmbElementMixin(LitElemen : undefined; const countryCode = this.dimension === "Country" ? normalizeCountryCode(row.value) : undefined; const faviconUrl = isReferrer && href ? googleFaviconUrl(row.value) : undefined; + const browserIcon = this.dimension === "BrowserName" ? browserIconPath(row.value) : undefined; const displayValue = countryCode ? countryDisplayName(countryCode, navigator.languages) : breakdownDisplayValue(row.value, this.dimension); @@ -125,6 +127,11 @@ export class WebAnalyticsBreakdownTableElement extends UmbElementMixin(LitElemen ${countryCode ? html` ((event.currentTarget as HTMLImageElement).style.visibility = "hidden")}>` : ""} ${faviconUrl ? html` ((event.currentTarget as HTMLImageElement).hidden = true)}>` : ""} + ${browserIcon + ? html`` + : this.dimension === "BrowserName" + ? html`` + : nothing} ${href ? html`${displayValue} (opens in a new tab)` : displayValue} @@ -230,6 +237,8 @@ export class WebAnalyticsBreakdownTableElement extends UmbElementMixin(LitElemen .row-label a:hover .external-indicator, .row-label a:focus-visible .external-indicator { opacity: 1; } .country-flag { border-radius: var(--uui-border-radius); flex: 0 0 auto; object-fit: cover; } .referrer-favicon { border-radius: var(--uui-border-radius); flex: 0 0 auto; object-fit: contain; } + .browser-icon { block-size: 1.25rem; flex: 0 0 auto; inline-size: 1.25rem; object-fit: contain; } + .browser-icon-fallback { color: var(--uui-color-text-alt); } .percentage-value { display: inline-block; font-weight: 700; outline: none; position: relative; } .percentage-value:focus-visible { outline: 2px solid var(--uui-color-selected); outline-offset: 2px; } .percentage-tooltip { diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/browser-icon.test.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/browser-icon.test.ts new file mode 100644 index 0000000..abcfda1 --- /dev/null +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/browser-icon.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { browserIconPath } from "./browser-icon.js"; + +describe("browserIconPath", () => { + it("uses local coloured marks for recognised browser values", () => { + expect(browserIconPath("Chrome")).toBe("/App_Plugins/TheBuilder.WebAnalytics/icons/browsers/chrome.svg"); + expect(browserIconPath("Microsoft Edge")).toBe("/App_Plugins/TheBuilder.WebAnalytics/icons/browsers/edge.svg"); + expect(browserIconPath("Opera GX")).toBe("/App_Plugins/TheBuilder.WebAnalytics/icons/browsers/opera-gx.svg"); + expect(browserIconPath("SberBrowser")).toBe("/App_Plugins/TheBuilder.WebAnalytics/icons/browsers/sberbank.svg"); + }); + + it("leaves unrecognised values for the generic browser fallback", () => { + expect(browserIconPath("Mobile App")).toBeUndefined(); + expect(browserIconPath("(not set)")).toBeUndefined(); + }); +}); diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/browser-icon.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/browser-icon.ts new file mode 100644 index 0000000..d64ca6f --- /dev/null +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/browser-icon.ts @@ -0,0 +1,35 @@ +const BROWSER_ICON_PATHS = new Map([ + ["brave", "brave.svg"], + ["chrome", "chrome.svg"], + ["chrome mobile", "chrome.svg"], + ["duckduckgo", "duckduckgo.svg"], + ["duckduckgo privacy browser", "duckduckgo.svg"], + ["ecosia", "ecosia.svg"], + ["edge", "edge.svg"], + ["firefox", "firefox.svg"], + ["jio", "jio.svg"], + ["jiosphere", "jio.svg"], + ["microsoft edge", "edge.svg"], + ["mobile firefox", "firefox.svg"], + ["mobile safari", "safari.svg"], + ["opera", "opera.svg"], + ["opera gx", "opera-gx.svg"], + ["opera touch", "opera.svg"], + ["qwant", "qwant.svg"], + ["qwant mobile", "qwant.svg"], + ["safari", "safari.svg"], + ["samsung browser", "samsung-browser.svg"], + ["samsung internet", "samsung-browser.svg"], + ["sberbrowser", "sberbank.svg"], + ["vivo", "vivo.svg"], + ["vivo browser", "vivo.svg"], + ["yandex", "yandex.svg"], + ["yandex browser", "yandex.svg"], +]); + +const BROWSER_ICON_ROOT = "/App_Plugins/TheBuilder.WebAnalytics/icons/browsers/"; + +export function browserIconPath(value: string): string | undefined { + const icon = BROWSER_ICON_PATHS.get(value.trim().toLowerCase()); + return icon ? `${BROWSER_ICON_ROOT}${icon}` : undefined; +} From 119a809ba1a494085705a33531c821cc2858b4b7 Mon Sep 17 00:00:00 2001 From: Daniel Schmidt Date: Thu, 23 Jul 2026 12:39:36 +0200 Subject: [PATCH 15/25] feat: add operating system icons to analytics --- .../Client/public/icons/browsers/README.md | 3 +- .../public/icons/operating-systems/README.md | 4 ++ .../icons/operating-systems/android.svg | 1 + .../public/icons/operating-systems/apple.svg | 1 + .../public/icons/operating-systems/linux.svg | 1 + .../public/icons/operating-systems/ubuntu.svg | 1 + .../icons/operating-systems/windows.svg | 1 + .../analytics/analytics-components.test.ts | 18 +++++- .../src/analytics/breakdown-table.element.ts | 17 +++--- .../analytics/breakdown-value-icon.test.ts | 23 +++++++ .../src/analytics/breakdown-value-icon.ts | 60 +++++++++++++++++++ .../Client/src/analytics/browser-icon.test.ts | 16 ----- .../Client/src/analytics/browser-icon.ts | 35 ----------- 13 files changed, 119 insertions(+), 62 deletions(-) create mode 100644 src/TheBuilder.WebAnalytics/Client/public/icons/operating-systems/README.md create mode 100644 src/TheBuilder.WebAnalytics/Client/public/icons/operating-systems/android.svg create mode 100644 src/TheBuilder.WebAnalytics/Client/public/icons/operating-systems/apple.svg create mode 100644 src/TheBuilder.WebAnalytics/Client/public/icons/operating-systems/linux.svg create mode 100644 src/TheBuilder.WebAnalytics/Client/public/icons/operating-systems/ubuntu.svg create mode 100644 src/TheBuilder.WebAnalytics/Client/public/icons/operating-systems/windows.svg create mode 100644 src/TheBuilder.WebAnalytics/Client/src/analytics/breakdown-value-icon.test.ts create mode 100644 src/TheBuilder.WebAnalytics/Client/src/analytics/breakdown-value-icon.ts delete mode 100644 src/TheBuilder.WebAnalytics/Client/src/analytics/browser-icon.test.ts delete mode 100644 src/TheBuilder.WebAnalytics/Client/src/analytics/browser-icon.ts diff --git a/src/TheBuilder.WebAnalytics/Client/public/icons/browsers/README.md b/src/TheBuilder.WebAnalytics/Client/public/icons/browsers/README.md index cd4c6ee..fa0adc6 100644 --- a/src/TheBuilder.WebAnalytics/Client/public/icons/browsers/README.md +++ b/src/TheBuilder.WebAnalytics/Client/public/icons/browsers/README.md @@ -1,4 +1,3 @@ # Browser icon sources -The coloured browser marks in this directory are local copies from [theSVG](https://thesvg.org/), which publishes these SVG assets under the MIT License. - +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/operating-systems/README.md b/src/TheBuilder.WebAnalytics/Client/public/icons/operating-systems/README.md new file mode 100644 index 0000000..b3a95c1 --- /dev/null +++ b/src/TheBuilder.WebAnalytics/Client/public/icons/operating-systems/README.md @@ -0,0 +1,4 @@ +# 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. + 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..054d831 --- /dev/null +++ b/src/TheBuilder.WebAnalytics/Client/public/icons/operating-systems/apple.svg @@ -0,0 +1 @@ + \ No newline at end of file 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 b0b0a09..757d25c 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-components.test.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-components.test.ts @@ -318,7 +318,7 @@ describe("analytics presentation components", () => { document.body.append(element); await element.updateComplete; - const icons = element.shadowRoot?.querySelectorAll(".browser-icon"); + 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"); @@ -326,6 +326,22 @@ describe("analytics presentation components", () => { 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: "(not set)", 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] as HTMLImageElement | undefined)?.getAttribute("src")).toBe("/App_Plugins/TheBuilder.WebAnalytics/icons/operating-systems/windows.svg"); + expect(icons?.[1]?.getAttribute("name")).toBe("icon-globe"); + }); + 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"); 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 fb7a06c..5df0948 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/breakdown-table.element.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/breakdown-table.element.ts @@ -14,7 +14,7 @@ import { type TrafficMetric, } from "./breakdown-rows.js"; import { countryDisplayName, countryFlagUrl, normalizeCountryCode } from "./country-display.js"; -import { browserIconPath } from "./browser-icon.js"; +import { breakdownValueIconPath } from "./breakdown-value-icon.js"; import type { AnalyticsFilter } from "./dashboard-url-state.js"; import { googleFaviconUrl } from "./favicon.js"; import { renderReportTabs, reportTabsStyles, selectedReportTabId, type ReportTabGroup } from "./report-tabs.js"; @@ -83,7 +83,8 @@ export class WebAnalyticsBreakdownTableElement extends UmbElementMixin(LitElemen : undefined; const countryCode = this.dimension === "Country" ? normalizeCountryCode(row.value) : undefined; const faviconUrl = isReferrer && href ? googleFaviconUrl(row.value) : undefined; - const browserIcon = this.dimension === "BrowserName" ? browserIconPath(row.value) : undefined; + const valueIcon = breakdownValueIconPath(this.dimension, row.value); + const hasValueIconFallback = this.dimension === "BrowserName" || this.dimension === "OsName"; const displayValue = countryCode ? countryDisplayName(countryCode, navigator.languages) : breakdownDisplayValue(row.value, this.dimension); @@ -127,10 +128,10 @@ export class WebAnalyticsBreakdownTableElement extends UmbElementMixin(LitElemen ${countryCode ? html` ((event.currentTarget as HTMLImageElement).style.visibility = "hidden")}>` : ""} ${faviconUrl ? html` ((event.currentTarget as HTMLImageElement).hidden = true)}>` : ""} - ${browserIcon - ? html`` - : this.dimension === "BrowserName" - ? html`` + ${valueIcon + ? html`` + : hasValueIconFallback + ? html`` : nothing} ${href ? html`${displayValue} (opens in a new tab)` @@ -237,8 +238,8 @@ export class WebAnalyticsBreakdownTableElement extends UmbElementMixin(LitElemen .row-label a:hover .external-indicator, .row-label a:focus-visible .external-indicator { opacity: 1; } .country-flag { border-radius: var(--uui-border-radius); flex: 0 0 auto; object-fit: cover; } .referrer-favicon { border-radius: var(--uui-border-radius); flex: 0 0 auto; object-fit: contain; } - .browser-icon { block-size: 1.25rem; flex: 0 0 auto; inline-size: 1.25rem; object-fit: contain; } - .browser-icon-fallback { color: var(--uui-color-text-alt); } + .breakdown-value-icon { block-size: 1.25rem; flex: 0 0 auto; inline-size: 1.25rem; object-fit: contain; } + .breakdown-value-icon-fallback { color: var(--uui-color-text-alt); } .percentage-value { display: inline-block; font-weight: 700; outline: none; position: relative; } .percentage-value:focus-visible { outline: 2px solid var(--uui-color-selected); outline-offset: 2px; } .percentage-tooltip { diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/breakdown-value-icon.test.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/breakdown-value-icon.test.ts new file mode 100644 index 0000000..ad55bfb --- /dev/null +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/breakdown-value-icon.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { breakdownValueIconPath } from "./breakdown-value-icon.js"; + +describe("breakdownValueIconPath", () => { + it("uses local coloured marks for recognised browser values", () => { + expect(breakdownValueIconPath("BrowserName", "Chrome")).toBe("/App_Plugins/TheBuilder.WebAnalytics/icons/browsers/chrome.svg"); + expect(breakdownValueIconPath("BrowserName", "Microsoft Edge")).toBe("/App_Plugins/TheBuilder.WebAnalytics/icons/browsers/edge.svg"); + expect(breakdownValueIconPath("BrowserName", "Opera GX")).toBe("/App_Plugins/TheBuilder.WebAnalytics/icons/browsers/opera-gx.svg"); + expect(breakdownValueIconPath("BrowserName", "SberBrowser")).toBe("/App_Plugins/TheBuilder.WebAnalytics/icons/browsers/sberbank.svg"); + }); + + it("uses local platform marks for recognised operating systems", () => { + expect(breakdownValueIconPath("OsName", "Mac OS X")).toBe("/App_Plugins/TheBuilder.WebAnalytics/icons/operating-systems/apple.svg"); + expect(breakdownValueIconPath("OsName", "Windows")).toBe("/App_Plugins/TheBuilder.WebAnalytics/icons/operating-systems/windows.svg"); + expect(breakdownValueIconPath("OsName", "GNU/Linux")).toBe("/App_Plugins/TheBuilder.WebAnalytics/icons/operating-systems/linux.svg"); + expect(breakdownValueIconPath("OsName", "Chrome OS")).toBe("/App_Plugins/TheBuilder.WebAnalytics/icons/browsers/chrome.svg"); + }); + + it("leaves unrecognised values for the generic fallback", () => { + expect(breakdownValueIconPath("BrowserName", "Mobile App")).toBeUndefined(); + expect(breakdownValueIconPath("OsName", "(not set)")).toBeUndefined(); + }); +}); diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/breakdown-value-icon.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/breakdown-value-icon.ts new file mode 100644 index 0000000..04a31f0 --- /dev/null +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/breakdown-value-icon.ts @@ -0,0 +1,60 @@ +import type { AnalyticsDimension } from "../api/types.gen.js"; + +const BROWSER_ICON_PATHS = new Map([ + ["brave", "browsers/brave.svg"], + ["chrome", "browsers/chrome.svg"], + ["chrome mobile", "browsers/chrome.svg"], + ["duckduckgo", "browsers/duckduckgo.svg"], + ["duckduckgo privacy browser", "browsers/duckduckgo.svg"], + ["ecosia", "browsers/ecosia.svg"], + ["edge", "browsers/edge.svg"], + ["firefox", "browsers/firefox.svg"], + ["jio", "browsers/jio.svg"], + ["jiosphere", "browsers/jio.svg"], + ["microsoft edge", "browsers/edge.svg"], + ["mobile firefox", "browsers/firefox.svg"], + ["mobile safari", "browsers/safari.svg"], + ["opera", "browsers/opera.svg"], + ["opera gx", "browsers/opera-gx.svg"], + ["opera touch", "browsers/opera.svg"], + ["qwant", "browsers/qwant.svg"], + ["qwant mobile", "browsers/qwant.svg"], + ["safari", "browsers/safari.svg"], + ["samsung browser", "browsers/samsung-browser.svg"], + ["samsung internet", "browsers/samsung-browser.svg"], + ["sberbrowser", "browsers/sberbank.svg"], + ["vivo", "browsers/vivo.svg"], + ["vivo browser", "browsers/vivo.svg"], + ["yandex", "browsers/yandex.svg"], + ["yandex browser", "browsers/yandex.svg"], +]); + +const OPERATING_SYSTEM_ICON_PATHS = new Map([ + ["android", "operating-systems/android.svg"], + ["chrome os", "browsers/chrome.svg"], + ["chromeos", "browsers/chrome.svg"], + ["gnu/linux", "operating-systems/linux.svg"], + ["ios", "operating-systems/apple.svg"], + ["ipados", "operating-systems/apple.svg"], + ["linux", "operating-systems/linux.svg"], + ["mac", "operating-systems/apple.svg"], + ["mac os", "operating-systems/apple.svg"], + ["mac os x", "operating-systems/apple.svg"], + ["macos", "operating-systems/apple.svg"], + ["ubuntu", "operating-systems/ubuntu.svg"], + ["windows", "operating-systems/windows.svg"], + ["windows 10", "operating-systems/windows.svg"], + ["windows 11", "operating-systems/windows.svg"], +]); + +const ICON_PATHS_BY_DIMENSION: Partial>> = { + BrowserName: BROWSER_ICON_PATHS, + OsName: OPERATING_SYSTEM_ICON_PATHS, +}; + +const BREAKDOWN_ICON_ROOT = "/App_Plugins/TheBuilder.WebAnalytics/icons/"; + +export function breakdownValueIconPath(dimension: AnalyticsDimension | undefined, value: string): string | undefined { + const icon = dimension ? ICON_PATHS_BY_DIMENSION[dimension]?.get(value.trim().toLowerCase()) : undefined; + return icon ? `${BREAKDOWN_ICON_ROOT}${icon}` : undefined; +} diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/browser-icon.test.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/browser-icon.test.ts deleted file mode 100644 index abcfda1..0000000 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/browser-icon.test.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { browserIconPath } from "./browser-icon.js"; - -describe("browserIconPath", () => { - it("uses local coloured marks for recognised browser values", () => { - expect(browserIconPath("Chrome")).toBe("/App_Plugins/TheBuilder.WebAnalytics/icons/browsers/chrome.svg"); - expect(browserIconPath("Microsoft Edge")).toBe("/App_Plugins/TheBuilder.WebAnalytics/icons/browsers/edge.svg"); - expect(browserIconPath("Opera GX")).toBe("/App_Plugins/TheBuilder.WebAnalytics/icons/browsers/opera-gx.svg"); - expect(browserIconPath("SberBrowser")).toBe("/App_Plugins/TheBuilder.WebAnalytics/icons/browsers/sberbank.svg"); - }); - - it("leaves unrecognised values for the generic browser fallback", () => { - expect(browserIconPath("Mobile App")).toBeUndefined(); - expect(browserIconPath("(not set)")).toBeUndefined(); - }); -}); diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/browser-icon.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/browser-icon.ts deleted file mode 100644 index d64ca6f..0000000 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/browser-icon.ts +++ /dev/null @@ -1,35 +0,0 @@ -const BROWSER_ICON_PATHS = new Map([ - ["brave", "brave.svg"], - ["chrome", "chrome.svg"], - ["chrome mobile", "chrome.svg"], - ["duckduckgo", "duckduckgo.svg"], - ["duckduckgo privacy browser", "duckduckgo.svg"], - ["ecosia", "ecosia.svg"], - ["edge", "edge.svg"], - ["firefox", "firefox.svg"], - ["jio", "jio.svg"], - ["jiosphere", "jio.svg"], - ["microsoft edge", "edge.svg"], - ["mobile firefox", "firefox.svg"], - ["mobile safari", "safari.svg"], - ["opera", "opera.svg"], - ["opera gx", "opera-gx.svg"], - ["opera touch", "opera.svg"], - ["qwant", "qwant.svg"], - ["qwant mobile", "qwant.svg"], - ["safari", "safari.svg"], - ["samsung browser", "samsung-browser.svg"], - ["samsung internet", "samsung-browser.svg"], - ["sberbrowser", "sberbank.svg"], - ["vivo", "vivo.svg"], - ["vivo browser", "vivo.svg"], - ["yandex", "yandex.svg"], - ["yandex browser", "yandex.svg"], -]); - -const BROWSER_ICON_ROOT = "/App_Plugins/TheBuilder.WebAnalytics/icons/browsers/"; - -export function browserIconPath(value: string): string | undefined { - const icon = BROWSER_ICON_PATHS.get(value.trim().toLowerCase()); - return icon ? `${BROWSER_ICON_ROOT}${icon}` : undefined; -} From 1f27f4eaa8d851c2c7a751ae6e3913608cb2b6a3 Mon Sep 17 00:00:00 2001 From: Daniel Schmidt Date: Thu, 23 Jul 2026 12:43:36 +0200 Subject: [PATCH 16/25] fix: improve Apple icon contrast --- .../Client/public/icons/operating-systems/apple.svg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/TheBuilder.WebAnalytics/Client/public/icons/operating-systems/apple.svg b/src/TheBuilder.WebAnalytics/Client/public/icons/operating-systems/apple.svg index 054d831..f86c318 100644 --- a/src/TheBuilder.WebAnalytics/Client/public/icons/operating-systems/apple.svg +++ b/src/TheBuilder.WebAnalytics/Client/public/icons/operating-systems/apple.svg @@ -1 +1 @@ - \ No newline at end of file + From f4a3e175e9329e096e5368eaae5b16a78b2a639f Mon Sep 17 00:00:00 2001 From: Daniel Schmidt Date: Thu, 23 Jul 2026 12:45:45 +0200 Subject: [PATCH 17/25] feat: use supplied iOS analytics icon --- .../Client/public/icons/operating-systems/README.md | 3 +-- .../Client/public/icons/operating-systems/ios.svg | 1 + .../Client/src/analytics/breakdown-value-icon.test.ts | 1 + .../Client/src/analytics/breakdown-value-icon.ts | 2 +- 4 files changed, 4 insertions(+), 3 deletions(-) create mode 100644 src/TheBuilder.WebAnalytics/Client/public/icons/operating-systems/ios.svg diff --git a/src/TheBuilder.WebAnalytics/Client/public/icons/operating-systems/README.md b/src/TheBuilder.WebAnalytics/Client/public/icons/operating-systems/README.md index b3a95c1..85e760e 100644 --- a/src/TheBuilder.WebAnalytics/Client/public/icons/operating-systems/README.md +++ b/src/TheBuilder.WebAnalytics/Client/public/icons/operating-systems/README.md @@ -1,4 +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 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/ios.svg b/src/TheBuilder.WebAnalytics/Client/public/icons/operating-systems/ios.svg new file mode 100644 index 0000000..64516ff --- /dev/null +++ b/src/TheBuilder.WebAnalytics/Client/public/icons/operating-systems/ios.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/breakdown-value-icon.test.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/breakdown-value-icon.test.ts index ad55bfb..a38b594 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/breakdown-value-icon.test.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/breakdown-value-icon.test.ts @@ -11,6 +11,7 @@ describe("breakdownValueIconPath", () => { it("uses local platform marks for recognised operating systems", () => { expect(breakdownValueIconPath("OsName", "Mac OS X")).toBe("/App_Plugins/TheBuilder.WebAnalytics/icons/operating-systems/apple.svg"); + expect(breakdownValueIconPath("OsName", "iOS")).toBe("/App_Plugins/TheBuilder.WebAnalytics/icons/operating-systems/ios.svg"); expect(breakdownValueIconPath("OsName", "Windows")).toBe("/App_Plugins/TheBuilder.WebAnalytics/icons/operating-systems/windows.svg"); expect(breakdownValueIconPath("OsName", "GNU/Linux")).toBe("/App_Plugins/TheBuilder.WebAnalytics/icons/operating-systems/linux.svg"); expect(breakdownValueIconPath("OsName", "Chrome OS")).toBe("/App_Plugins/TheBuilder.WebAnalytics/icons/browsers/chrome.svg"); diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/breakdown-value-icon.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/breakdown-value-icon.ts index 04a31f0..42c83c7 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/breakdown-value-icon.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/breakdown-value-icon.ts @@ -34,7 +34,7 @@ const OPERATING_SYSTEM_ICON_PATHS = new Map([ ["chrome os", "browsers/chrome.svg"], ["chromeos", "browsers/chrome.svg"], ["gnu/linux", "operating-systems/linux.svg"], - ["ios", "operating-systems/apple.svg"], + ["ios", "operating-systems/ios.svg"], ["ipados", "operating-systems/apple.svg"], ["linux", "operating-systems/linux.svg"], ["mac", "operating-systems/apple.svg"], From 8172e7a30797bfc4662d93ead7fd0a12096e4a78 Mon Sep 17 00:00:00 2001 From: Daniel Schmidt Date: Thu, 23 Jul 2026 12:50:57 +0200 Subject: [PATCH 18/25] feat: add native device icons to analytics --- .../analytics/analytics-components.test.ts | 15 +++++++++ .../src/analytics/breakdown-table.element.ts | 14 ++++---- .../analytics/breakdown-value-icon.test.ts | 33 +++++++++++-------- .../src/analytics/breakdown-value-icon.ts | 20 +++++++++-- 4 files changed, 60 insertions(+), 22 deletions(-) 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 757d25c..096d1dc 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-components.test.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-components.test.ts @@ -342,6 +342,21 @@ describe("analytics presentation components", () => { expect(icons?.[1]?.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"); 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 5df0948..cbfedb3 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/breakdown-table.element.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/breakdown-table.element.ts @@ -14,7 +14,7 @@ import { type TrafficMetric, } from "./breakdown-rows.js"; import { countryDisplayName, countryFlagUrl, normalizeCountryCode } from "./country-display.js"; -import { breakdownValueIconPath } from "./breakdown-value-icon.js"; +import { breakdownValueIcon } from "./breakdown-value-icon.js"; import type { AnalyticsFilter } from "./dashboard-url-state.js"; import { googleFaviconUrl } from "./favicon.js"; import { renderReportTabs, reportTabsStyles, selectedReportTabId, type ReportTabGroup } from "./report-tabs.js"; @@ -83,8 +83,8 @@ export class WebAnalyticsBreakdownTableElement extends UmbElementMixin(LitElemen : undefined; const countryCode = this.dimension === "Country" ? normalizeCountryCode(row.value) : undefined; const faviconUrl = isReferrer && href ? googleFaviconUrl(row.value) : undefined; - const valueIcon = breakdownValueIconPath(this.dimension, row.value); - const hasValueIconFallback = this.dimension === "BrowserName" || this.dimension === "OsName"; + const valueIcon = breakdownValueIcon(this.dimension, row.value); + const hasValueIconFallback = this.dimension === "BrowserName" || this.dimension === "DeviceType" || this.dimension === "OsName"; const displayValue = countryCode ? countryDisplayName(countryCode, navigator.languages) : breakdownDisplayValue(row.value, this.dimension); @@ -128,8 +128,10 @@ export class WebAnalyticsBreakdownTableElement extends UmbElementMixin(LitElemen ${countryCode ? html` ((event.currentTarget as HTMLImageElement).style.visibility = "hidden")}>` : ""} ${faviconUrl ? html` ((event.currentTarget as HTMLImageElement).hidden = true)}>` : ""} - ${valueIcon - ? html`` + ${valueIcon?.kind === "asset" + ? html`` + : valueIcon?.kind === "native" + ? html`` : hasValueIconFallback ? html`` : nothing} @@ -238,7 +240,7 @@ export class WebAnalyticsBreakdownTableElement extends UmbElementMixin(LitElemen .row-label a:hover .external-indicator, .row-label a:focus-visible .external-indicator { opacity: 1; } .country-flag { border-radius: var(--uui-border-radius); flex: 0 0 auto; object-fit: cover; } .referrer-favicon { border-radius: var(--uui-border-radius); flex: 0 0 auto; object-fit: contain; } - .breakdown-value-icon { block-size: 1.25rem; flex: 0 0 auto; inline-size: 1.25rem; object-fit: contain; } + .breakdown-value-icon { block-size: 1.25rem; flex: 0 0 auto; font-size: var(--uui-size-5); inline-size: 1.25rem; object-fit: contain; } .breakdown-value-icon-fallback { color: var(--uui-color-text-alt); } .percentage-value { display: inline-block; font-weight: 700; outline: none; position: relative; } .percentage-value:focus-visible { outline: 2px solid var(--uui-color-selected); outline-offset: 2px; } diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/breakdown-value-icon.test.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/breakdown-value-icon.test.ts index a38b594..110acbf 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/breakdown-value-icon.test.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/breakdown-value-icon.test.ts @@ -1,24 +1,31 @@ import { describe, expect, it } from "vitest"; -import { breakdownValueIconPath } from "./breakdown-value-icon.js"; +import { breakdownValueIcon } from "./breakdown-value-icon.js"; -describe("breakdownValueIconPath", () => { +describe("breakdownValueIcon", () => { it("uses local coloured marks for recognised browser values", () => { - expect(breakdownValueIconPath("BrowserName", "Chrome")).toBe("/App_Plugins/TheBuilder.WebAnalytics/icons/browsers/chrome.svg"); - expect(breakdownValueIconPath("BrowserName", "Microsoft Edge")).toBe("/App_Plugins/TheBuilder.WebAnalytics/icons/browsers/edge.svg"); - expect(breakdownValueIconPath("BrowserName", "Opera GX")).toBe("/App_Plugins/TheBuilder.WebAnalytics/icons/browsers/opera-gx.svg"); - expect(breakdownValueIconPath("BrowserName", "SberBrowser")).toBe("/App_Plugins/TheBuilder.WebAnalytics/icons/browsers/sberbank.svg"); + expect(breakdownValueIcon("BrowserName", "Chrome")).toEqual({ kind: "asset", src: "/App_Plugins/TheBuilder.WebAnalytics/icons/browsers/chrome.svg" }); + expect(breakdownValueIcon("BrowserName", "Microsoft Edge")).toEqual({ kind: "asset", src: "/App_Plugins/TheBuilder.WebAnalytics/icons/browsers/edge.svg" }); + expect(breakdownValueIcon("BrowserName", "Opera GX")).toEqual({ kind: "asset", src: "/App_Plugins/TheBuilder.WebAnalytics/icons/browsers/opera-gx.svg" }); + expect(breakdownValueIcon("BrowserName", "SberBrowser")).toEqual({ kind: "asset", src: "/App_Plugins/TheBuilder.WebAnalytics/icons/browsers/sberbank.svg" }); }); it("uses local platform marks for recognised operating systems", () => { - expect(breakdownValueIconPath("OsName", "Mac OS X")).toBe("/App_Plugins/TheBuilder.WebAnalytics/icons/operating-systems/apple.svg"); - expect(breakdownValueIconPath("OsName", "iOS")).toBe("/App_Plugins/TheBuilder.WebAnalytics/icons/operating-systems/ios.svg"); - expect(breakdownValueIconPath("OsName", "Windows")).toBe("/App_Plugins/TheBuilder.WebAnalytics/icons/operating-systems/windows.svg"); - expect(breakdownValueIconPath("OsName", "GNU/Linux")).toBe("/App_Plugins/TheBuilder.WebAnalytics/icons/operating-systems/linux.svg"); - expect(breakdownValueIconPath("OsName", "Chrome OS")).toBe("/App_Plugins/TheBuilder.WebAnalytics/icons/browsers/chrome.svg"); + expect(breakdownValueIcon("OsName", "Mac OS X")).toEqual({ kind: "asset", src: "/App_Plugins/TheBuilder.WebAnalytics/icons/operating-systems/apple.svg" }); + expect(breakdownValueIcon("OsName", "iOS")).toEqual({ kind: "asset", src: "/App_Plugins/TheBuilder.WebAnalytics/icons/operating-systems/ios.svg" }); + expect(breakdownValueIcon("OsName", "Windows")).toEqual({ kind: "asset", src: "/App_Plugins/TheBuilder.WebAnalytics/icons/operating-systems/windows.svg" }); + expect(breakdownValueIcon("OsName", "GNU/Linux")).toEqual({ kind: "asset", src: "/App_Plugins/TheBuilder.WebAnalytics/icons/operating-systems/linux.svg" }); + expect(breakdownValueIcon("OsName", "Chrome OS")).toEqual({ kind: "asset", src: "/App_Plugins/TheBuilder.WebAnalytics/icons/browsers/chrome.svg" }); + }); + + it("uses native Umbraco marks for recognised device values", () => { + expect(breakdownValueIcon("DeviceType", "Desktop")).toEqual({ kind: "native", name: "icon-desktop" }); + expect(breakdownValueIcon("DeviceType", "Mobile")).toEqual({ kind: "native", name: "icon-mobile" }); + expect(breakdownValueIcon("DeviceType", "Tablet")).toEqual({ kind: "native", name: "icon-ipad" }); }); it("leaves unrecognised values for the generic fallback", () => { - expect(breakdownValueIconPath("BrowserName", "Mobile App")).toBeUndefined(); - expect(breakdownValueIconPath("OsName", "(not set)")).toBeUndefined(); + expect(breakdownValueIcon("BrowserName", "Mobile App")).toBeUndefined(); + expect(breakdownValueIcon("OsName", "(not set)")).toBeUndefined(); + expect(breakdownValueIcon("DeviceType", "Unknown")).toBeUndefined(); }); }); diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/breakdown-value-icon.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/breakdown-value-icon.ts index 42c83c7..32eef65 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/breakdown-value-icon.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/breakdown-value-icon.ts @@ -52,9 +52,23 @@ const ICON_PATHS_BY_DIMENSION: Partial([ + ["desktop", "icon-desktop"], + ["mobile", "icon-mobile"], + ["tablet", "icon-ipad"], +]); + const BREAKDOWN_ICON_ROOT = "/App_Plugins/TheBuilder.WebAnalytics/icons/"; -export function breakdownValueIconPath(dimension: AnalyticsDimension | undefined, value: string): string | undefined { - const icon = dimension ? ICON_PATHS_BY_DIMENSION[dimension]?.get(value.trim().toLowerCase()) : undefined; - return icon ? `${BREAKDOWN_ICON_ROOT}${icon}` : undefined; +export type BreakdownValueIcon = + | { kind: "asset"; src: string } + | { kind: "native"; name: string }; + +export function breakdownValueIcon(dimension: AnalyticsDimension | undefined, value: string): BreakdownValueIcon | undefined { + const normalizedValue = value.trim().toLowerCase(); + const asset = dimension ? ICON_PATHS_BY_DIMENSION[dimension]?.get(normalizedValue) : undefined; + if (asset) return { kind: "asset", src: `${BREAKDOWN_ICON_ROOT}${asset}` }; + + const nativeIcon = dimension === "DeviceType" ? NATIVE_ICON_NAMES.get(normalizedValue) : undefined; + return nativeIcon ? { kind: "native", name: nativeIcon } : undefined; } From d52acd1ea577a1931b69ed5d6dee478a5f448bfd Mon Sep 17 00:00:00 2001 From: Daniel Schmidt Date: Thu, 23 Jul 2026 12:52:07 +0200 Subject: [PATCH 19/25] fix: trim iOS analytics icon --- .../Client/public/icons/operating-systems/ios.svg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/TheBuilder.WebAnalytics/Client/public/icons/operating-systems/ios.svg b/src/TheBuilder.WebAnalytics/Client/public/icons/operating-systems/ios.svg index 64516ff..bb2af84 100644 --- a/src/TheBuilder.WebAnalytics/Client/public/icons/operating-systems/ios.svg +++ b/src/TheBuilder.WebAnalytics/Client/public/icons/operating-systems/ios.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file From eeda0c94e2d1f98e1835a2a8b8189360cd2540d0 Mon Sep 17 00:00:00 2001 From: Daniel Schmidt Date: Thu, 23 Jul 2026 12:54:03 +0200 Subject: [PATCH 20/25] fix: elevate iOS analytics icon --- .../Client/src/analytics/breakdown-table.element.ts | 1 + 1 file changed, 1 insertion(+) 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..d609007 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/breakdown-table.element.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/breakdown-table.element.ts @@ -241,6 +241,7 @@ export class WebAnalyticsBreakdownTableElement extends UmbElementMixin(LitElemen .country-flag { border-radius: var(--uui-border-radius); flex: 0 0 auto; object-fit: cover; } .referrer-favicon { border-radius: var(--uui-border-radius); flex: 0 0 auto; object-fit: contain; } .breakdown-value-icon { block-size: 1.25rem; flex: 0 0 auto; font-size: var(--uui-size-5); inline-size: 1.25rem; object-fit: contain; } + img.breakdown-value-icon[src$="/operating-systems/ios.svg"] { box-shadow: var(--uui-shadow-depth-1); } .breakdown-value-icon-fallback { color: var(--uui-color-text-alt); } .percentage-value { display: inline-block; font-weight: 700; outline: none; position: relative; } .percentage-value:focus-visible { outline: 2px solid var(--uui-color-selected); outline-offset: 2px; } From 28d3af66698b1bbde2344bf9f9f0c4260ff63cd2 Mon Sep 17 00:00:00 2001 From: Daniel Schmidt Date: Thu, 23 Jul 2026 12:56:29 +0200 Subject: [PATCH 21/25] feat: add icons to active analytics filters --- .../analytics/analytics-components.test.ts | 12 ++++++++++ .../analytics/analytics-dashboard.element.ts | 22 +++++++++++++++++-- .../analytics/analytics-dashboard.styles.ts | 5 +++++ 3 files changed, 37 insertions(+), 2 deletions(-) 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 096d1dc..83ae342 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-components.test.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-components.test.ts @@ -624,6 +624,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.element.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dashboard.element.ts index ff8abe1..01c981a 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)} `)} 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..5bf5627 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,11 @@ 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; } + img.filter-icon[src$="/operating-systems/ios.svg"] { box-shadow: var(--uui-shadow-depth-1); } + 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; } From 331762715e75bd93ebc821737e2374062ed14caa Mon Sep 17 00:00:00 2001 From: Daniel Schmidt Date: Thu, 23 Jul 2026 12:59:13 +0200 Subject: [PATCH 22/25] fix: refine iOS icon shadow --- .../Client/src/analytics/analytics-components.test.ts | 6 ++++-- .../Client/src/analytics/analytics-dashboard.element.ts | 2 +- .../Client/src/analytics/analytics-dashboard.styles.ts | 2 +- .../Client/src/analytics/breakdown-table.element.ts | 4 ++-- 4 files changed, 8 insertions(+), 6 deletions(-) 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 83ae342..dbb208e 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-components.test.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-components.test.ts @@ -331,15 +331,17 @@ describe("analytics presentation components", () => { 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(2); + 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]?.getAttribute("name")).toBe("icon-globe"); + expect(icons?.[1]?.classList.contains("ios-icon")).toBe(true); + expect(icons?.[2]?.getAttribute("name")).toBe("icon-globe"); }); it("renders native Umbraco icons for device categories", async () => { 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 01c981a..9a98b1a 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dashboard.element.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dashboard.element.ts @@ -57,7 +57,7 @@ export class WebAnalyticsDashboardElement extends UmbElementMixin(LitElement) { const valueIcon = breakdownValueIcon(filter.dimension, filter.value); if (valueIcon?.kind === "asset") { - return html``; + return html``; } if (valueIcon?.kind === "native") { return html``; 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 5bf5627..4e5388d 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dashboard.styles.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dashboard.styles.ts @@ -20,7 +20,7 @@ export const analyticsDashboardStyles = [UmbTextStyles, css` .filter-icon { flex: 0 0 auto; } img.filter-icon { object-fit: contain; } img.filter-flag { border-radius: var(--uui-border-radius); object-fit: cover; } - img.filter-icon[src$="/operating-systems/ios.svg"] { box-shadow: var(--uui-shadow-depth-1); } + .ios-icon { filter: drop-shadow(0 1px 1px color-mix(in srgb, var(--uui-color-text) 15%, transparent)); } 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; } 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 d609007..01c35d4 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/breakdown-table.element.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/breakdown-table.element.ts @@ -129,7 +129,7 @@ export class WebAnalyticsBreakdownTableElement extends UmbElementMixin(LitElemen ${countryCode ? html` ((event.currentTarget as HTMLImageElement).style.visibility = "hidden")}>` : ""} ${faviconUrl ? html` ((event.currentTarget as HTMLImageElement).hidden = true)}>` : ""} ${valueIcon?.kind === "asset" - ? html`` + ? html`` : valueIcon?.kind === "native" ? html`` : hasValueIconFallback @@ -241,7 +241,7 @@ export class WebAnalyticsBreakdownTableElement extends UmbElementMixin(LitElemen .country-flag { border-radius: var(--uui-border-radius); flex: 0 0 auto; object-fit: cover; } .referrer-favicon { border-radius: var(--uui-border-radius); flex: 0 0 auto; object-fit: contain; } .breakdown-value-icon { block-size: 1.25rem; flex: 0 0 auto; font-size: var(--uui-size-5); inline-size: 1.25rem; object-fit: contain; } - img.breakdown-value-icon[src$="/operating-systems/ios.svg"] { box-shadow: var(--uui-shadow-depth-1); } + .ios-icon { filter: drop-shadow(0 1px 1px color-mix(in srgb, var(--uui-color-text) 15%, transparent)); } .breakdown-value-icon-fallback { color: var(--uui-color-text-alt); } .percentage-value { display: inline-block; font-weight: 700; outline: none; position: relative; } .percentage-value:focus-visible { outline: 2px solid var(--uui-color-selected); outline-offset: 2px; } From 4180fd488c0fca4db8d6799c0924aa4d93adad2c Mon Sep 17 00:00:00 2001 From: Daniel Schmidt Date: Thu, 23 Jul 2026 13:31:22 +0200 Subject: [PATCH 23/25] fix: scope analytics dialog cache --- .../public/icons/operating-systems/ios.svg | 2 +- .../analytics/analytics-components.test.ts | 2 +- .../analytics-dashboard.controller.test.ts | 55 ++++++++++++++----- .../analytics-dashboard.controller.ts | 29 +++++++--- .../analytics/analytics-dashboard.element.ts | 2 +- .../analytics/analytics-dashboard.styles.ts | 1 - .../src/analytics/breakdown-table.element.ts | 3 +- 7 files changed, 64 insertions(+), 30 deletions(-) diff --git a/src/TheBuilder.WebAnalytics/Client/public/icons/operating-systems/ios.svg b/src/TheBuilder.WebAnalytics/Client/public/icons/operating-systems/ios.svg index bb2af84..f005775 100644 --- a/src/TheBuilder.WebAnalytics/Client/public/icons/operating-systems/ios.svg +++ b/src/TheBuilder.WebAnalytics/Client/public/icons/operating-systems/ios.svg @@ -1 +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 dbb208e..47f0e4b 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-components.test.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-components.test.ts @@ -340,7 +340,7 @@ describe("analytics presentation components", () => { 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]?.classList.contains("ios-icon")).toBe(true); + 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"); }); 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 6d69a52..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 @@ -98,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); @@ -115,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({ @@ -126,11 +128,12 @@ describe("AnalyticsDashboardController", () => { }); }); - it("retains rows while switching breakdown tabs", async () => { + it("does not reuse rows from a different breakdown query", async () => { const api = dashboardApi(); const controller = new AnalyticsDashboardController(vi.fn(), api, environment()); controller.connect(); - await vi.waitFor(() => expect(controller.state.summary.status).toBe("success")); + 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 }], @@ -141,24 +144,48 @@ describe("AnalyticsDashboardController", () => { const switching = controller.openBreakdown("UtmSource", "UTM sources"); - expect(controller.state.expandedBreakdown?.report).toEqual({ - status: "loading", - previous: [{ value: "example.com", visitors: 12, pageViews: 18 }], - }); + expect(controller.state.expandedBreakdown?.report).toEqual({ status: "loading" }); pending.resolve(ok({ dimension: "UtmSource", rows: [] })); 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); @@ -172,11 +199,12 @@ describe("AnalyticsDashboardController", () => { await refreshing; }); - it("retains rows while filtering a 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 }], @@ -187,10 +215,7 @@ describe("AnalyticsDashboardController", () => { const searching = controller.openBreakdown("Country", "Countries", { search: "denmark" }); - expect(controller.state.expandedBreakdown?.report).toEqual({ - status: "loading", - previous: [{ value: "DK", visitors: 12, pageViews: 18 }], - }); + expect(controller.state.expandedBreakdown?.report).toEqual({ status: "loading" }); pending.resolve(ok({ dimension: "Country", rows: [] })); await searching; }); 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 c9f74f0..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; @@ -342,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?.report; - 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 { @@ -750,6 +757,10 @@ 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 9a98b1a..01c981a 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dashboard.element.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dashboard.element.ts @@ -57,7 +57,7 @@ export class WebAnalyticsDashboardElement extends UmbElementMixin(LitElement) { const valueIcon = breakdownValueIcon(filter.dimension, filter.value); if (valueIcon?.kind === "asset") { - return html``; + return html``; } if (valueIcon?.kind === "native") { return html``; 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 4e5388d..85afb77 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dashboard.styles.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dashboard.styles.ts @@ -20,7 +20,6 @@ export const analyticsDashboardStyles = [UmbTextStyles, css` .filter-icon { flex: 0 0 auto; } img.filter-icon { object-fit: contain; } img.filter-flag { border-radius: var(--uui-border-radius); object-fit: cover; } - .ios-icon { filter: drop-shadow(0 1px 1px color-mix(in srgb, var(--uui-color-text) 15%, transparent)); } 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; } 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 01c35d4..cbfedb3 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/breakdown-table.element.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/breakdown-table.element.ts @@ -129,7 +129,7 @@ export class WebAnalyticsBreakdownTableElement extends UmbElementMixin(LitElemen ${countryCode ? html` ((event.currentTarget as HTMLImageElement).style.visibility = "hidden")}>` : ""} ${faviconUrl ? html` ((event.currentTarget as HTMLImageElement).hidden = true)}>` : ""} ${valueIcon?.kind === "asset" - ? html`` + ? html`` : valueIcon?.kind === "native" ? html`` : hasValueIconFallback @@ -241,7 +241,6 @@ export class WebAnalyticsBreakdownTableElement extends UmbElementMixin(LitElemen .country-flag { border-radius: var(--uui-border-radius); flex: 0 0 auto; object-fit: cover; } .referrer-favicon { border-radius: var(--uui-border-radius); flex: 0 0 auto; object-fit: contain; } .breakdown-value-icon { block-size: 1.25rem; flex: 0 0 auto; font-size: var(--uui-size-5); inline-size: 1.25rem; object-fit: contain; } - .ios-icon { filter: drop-shadow(0 1px 1px color-mix(in srgb, var(--uui-color-text) 15%, transparent)); } .breakdown-value-icon-fallback { color: var(--uui-color-text-alt); } .percentage-value { display: inline-block; font-weight: 700; outline: none; position: relative; } .percentage-value:focus-visible { outline: 2px solid var(--uui-color-selected); outline-offset: 2px; } From d4a0e878cc7d3c1293d1a71bddfc3ee0eed2771f Mon Sep 17 00:00:00 2001 From: Daniel Schmidt Date: Thu, 23 Jul 2026 13:59:17 +0200 Subject: [PATCH 24/25] fix: preserve iOS icon shadow --- .../Client/public/icons/operating-systems/ios.svg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/TheBuilder.WebAnalytics/Client/public/icons/operating-systems/ios.svg b/src/TheBuilder.WebAnalytics/Client/public/icons/operating-systems/ios.svg index f005775..0b781e8 100644 --- a/src/TheBuilder.WebAnalytics/Client/public/icons/operating-systems/ios.svg +++ b/src/TheBuilder.WebAnalytics/Client/public/icons/operating-systems/ios.svg @@ -1 +1 @@ - + From e607d76e2908892500df6edde5dc8d7621ef82d2 Mon Sep 17 00:00:00 2001 From: Daniel Schmidt Date: Thu, 23 Jul 2026 14:02:53 +0200 Subject: [PATCH 25/25] fix: use one event dialog at a time --- .../analytics/analytics-components.test.ts | 29 +++++++++++++++++++ .../analytics/analytics-dashboard.element.ts | 2 +- 2 files changed, 30 insertions(+), 1 deletion(-) 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 47f0e4b..dcbd477 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-components.test.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-components.test.ts @@ -615,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; 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 01c981a..a66ad09 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dashboard.element.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dashboard.element.ts @@ -203,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`
${property.name} values for ${this.eventName}

Looking up matching values…

${this.searchUnavailable}

Events and goals
EventVisitorsTotal events