From 7ab5b3a39bb6582167a0a65971f8df556d6150f3 Mon Sep 17 00:00:00 2001 From: Daniel Schmidt Date: Wed, 22 Jul 2026 11:36:08 +0200 Subject: [PATCH 01/12] feat: configure optional reports per connection --- .../Client/src/api/types.gen.ts | 5 +++ .../src/settings/connection-editor.element.ts | 40 ++++++++++++++++++- .../settings/settings-dashboard.element.ts | 2 + .../src/settings/settings-dashboard.test.ts | 40 +++++++++++++++++++ .../src/settings/settings-model.test.ts | 14 +++++++ .../Client/src/settings/settings-model.ts | 2 + .../AnalyticsConnectionRegistry.cs | 31 +++++++++++--- .../Configuration/AnalyticsProviderCatalog.cs | 1 + .../Configuration/WebAnalyticsOptions.cs | 4 ++ .../Configuration/WebAnalyticsSettings.cs | 8 ++++ .../WebAnalyticsSettingsApiController.cs | 4 ++ .../Models/AnalyticsModels.cs | 5 +++ .../WebAnalyticsOptionsValidatorTests.cs | 19 +++++++++ .../WebAnalyticsSettingsApiControllerTests.cs | 6 +++ 14 files changed, 175 insertions(+), 6 deletions(-) diff --git a/src/TheBuilder.WebAnalytics/Client/src/api/types.gen.ts b/src/TheBuilder.WebAnalytics/Client/src/api/types.gen.ts index 357e1b0..56d1a1d 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/api/types.gen.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/api/types.gen.ts @@ -33,6 +33,8 @@ export type AnalyticsConnectionSettingsResponse = { team?: string | null; siteId: string; eventPropertyNames: Array; + enableEvents: boolean; + enableFlags: boolean; documentRootKeys: Array; enableAllDocumentTypes: boolean; enabledDocumentTypeKeys: Array; @@ -160,6 +162,7 @@ export type AnalyticsProviderDescriptor = { provider: AnalyticsProvider; description: string; logoSlug: string; + capabilities: AnalyticsCapabilities; identifier: AnalyticsIdentifierFieldDescriptor; team?: AnalyticsOptionalFieldDescriptor | null; credential: AnalyticsCredentialDescriptor; @@ -205,6 +208,8 @@ export type UpdateAnalyticsConnectionRequest = { team?: string | null; siteId: string; eventPropertyNames: Array; + enableEvents: boolean; + enableFlags: boolean; mockScenario?: MockAnalyticsScenario | null; documentRootKeys: Array; enableAllDocumentTypes: boolean; diff --git a/src/TheBuilder.WebAnalytics/Client/src/settings/connection-editor.element.ts b/src/TheBuilder.WebAnalytics/Client/src/settings/connection-editor.element.ts index 6dcb2a1..0bbc92a 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/settings/connection-editor.element.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/settings/connection-editor.element.ts @@ -72,6 +72,10 @@ export class AnalyticsConnectionEditorElement extends UmbElementMixin(LitElement this.#update({ enableAllDocumentTypes: (event.target as UUIToggleElement).checked }); } + #feature(field: "enableEvents" | "enableFlags", event: Event): void { + this.#update({ [field]: (event.target as UUIToggleElement).checked }); + } + #teamReference(event: Event): void { this.#update(parseTeamReference(String((event.target as UUIInputElement).value ?? ""))); } @@ -89,6 +93,15 @@ export class AnalyticsConnectionEditorElement extends UmbElementMixin(LitElement return count ? `${count} custom propert${count === 1 ? "y" : "ies"}` : "Built-in properties only"; } + #dashboardReportsSummary(descriptor: AnalyticsProviderDescriptor): string { + const reports = [ + descriptor.capabilities.events && this.connection.enableEvents, + descriptor.capabilities.flags && this.connection.enableFlags, + ].filter((enabled) => enabled).length; + const supported = Number(descriptor.capabilities.events) + Number(descriptor.capabilities.flags); + return `${reports} of ${supported} enabled`; + } + #mappingSummary(): string { const roots = this.connection.documentRootKeys.length; return roots ? `${roots} document root${roots === 1 ? "" : "s"}` : "Global analytics only"; @@ -225,7 +238,28 @@ export class AnalyticsConnectionEditorElement extends UmbElementMixin(LitElement `} - ${!isMock && descriptor.eventProperties ? html` + ${descriptor.capabilities.events || descriptor.capabilities.flags ? html` +
+ Dashboard reports${this.#dashboardReportsSummary(descriptor)} +
+

Choose the optional reports shown for this connection.

+ ${descriptor.capabilities.events ? html` +
+ this.#feature("enableEvents", event)}>Custom events +

Show event totals, filters, and event details in Analytics.

+
+ ` : ""} + ${descriptor.capabilities.flags ? html` +
+ this.#feature("enableFlags", event)}>Feature flags +

Show feature flag usage and value breakdowns in Analytics.

+
+ ` : ""} +
+
+ ` : ""} + + ${!isMock && descriptor.eventProperties && connection.enableEvents ? html`
Event properties${this.#eventPropertySummary()}
@@ -391,6 +425,10 @@ export class AnalyticsConnectionEditorElement extends UmbElementMixin(LitElement code { font-family: var(--uui-font-monospace); overflow-wrap: anywhere; } .mapping-content .fields { max-inline-size: 32rem; } .event-properties-content > uui-form-layout-item { margin-top: 0; max-inline-size: 32rem; } + .report-options { display: grid; gap: var(--uui-size-space-4); } + .report-options > .section-intro { margin-bottom: 0; } + .report-option { display: grid; gap: var(--uui-size-space-1); max-inline-size: 40rem; } + .report-option p { color: var(--uui-color-text-alt); margin: 0 0 0 var(--uui-size-7); } .document-types { margin-top: var(--uui-size-space-4); } .toggle-help { margin-bottom: 0; } .field-control { display: grid; gap: var(--uui-size-space-2); } diff --git a/src/TheBuilder.WebAnalytics/Client/src/settings/settings-dashboard.element.ts b/src/TheBuilder.WebAnalytics/Client/src/settings/settings-dashboard.element.ts index 3d83289..7e18797 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/settings/settings-dashboard.element.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/settings/settings-dashboard.element.ts @@ -117,6 +117,8 @@ export class WebAnalyticsSettingsDashboardElement extends UmbElementMixin(LitEle team: null, siteId: "", eventPropertyNames: [], + enableEvents: true, + enableFlags: true, documentRootKeys: [], enableAllDocumentTypes: false, enabledDocumentTypeKeys: [], diff --git a/src/TheBuilder.WebAnalytics/Client/src/settings/settings-dashboard.test.ts b/src/TheBuilder.WebAnalytics/Client/src/settings/settings-dashboard.test.ts index 057ffae..f3ffb16 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/settings/settings-dashboard.test.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/settings/settings-dashboard.test.ts @@ -215,6 +215,7 @@ const PROVIDERS: AnalyticsSettingsResponse["providers"] = [ provider: "Vercel", description: "Projects using Vercel Web Analytics", logoSlug: "vercel", + capabilities: { dimensions: ["RequestPath", "EventName"], events: true, eventDetails: true, eventProperties: false, globalEventFiltering: false, flags: true, breakdownOrdering: false }, identifier: { key: "projectId", label: "Vercel project ID", description: "Use the project ID from your Vercel project settings.", requiredMessage: "a Vercel project ID" }, team: { key: "team", label: "Team ID or slug", description: "Optional team slug or ID for projects owned by a Vercel team." }, credential: { label: "access token", description: "Configure a Vercel access token in the server settings.", documentationUrl: "https://vercel.com/docs/rest-api" }, @@ -224,6 +225,7 @@ const PROVIDERS: AnalyticsSettingsResponse["providers"] = [ provider: "Plausible", description: "Sites using Plausible Analytics", logoSlug: "plausible", + capabilities: { dimensions: ["RequestPath", "EventName"], events: true, eventDetails: true, eventProperties: true, globalEventFiltering: true, flags: false, breakdownOrdering: true }, identifier: { key: "siteId", label: "Plausible site ID", description: "Use the domain configured in your Plausible site settings.", requiredMessage: "a Plausible site ID" }, team: null, credential: { label: "Stats API key", description: "Configure a Plausible Stats API key in the server settings.", documentationUrl: "https://plausible.io/docs/stats-api" }, @@ -240,6 +242,8 @@ function connection(): AnalyticsConnectionSettingsResponse { team: null, siteId: "", eventPropertyNames: [], + enableEvents: true, + enableFlags: true, documentRootKeys: [], enableAllDocumentTypes: false, enabledDocumentTypeKeys: [], @@ -250,6 +254,42 @@ function connection(): AnalyticsConnectionSettingsResponse { } describe("analytics settings onboarding", () => { + it("renders only the dashboard reports supported by each connection provider", async () => { + const plausible = { + ...connection(), + key: "connection-2", + provider: "Plausible" as const, + projectId: "", + siteId: "example.com", + }; + sdk.settings.mockResolvedValueOnce(apiOk(settings({ connections: [connection(), plausible] }))); + const dashboard = document.createElement("web-analytics-settings-dashboard") as WebAnalyticsSettingsDashboardElement; + document.body.append(dashboard); + + await vi.waitFor(() => expect(dashboard.shadowRoot?.querySelectorAll("web-analytics-connection-editor")).toHaveLength(2)); + const editors = [...dashboard.shadowRoot?.querySelectorAll("web-analytics-connection-editor") ?? []] as AnalyticsConnectionEditorElement[]; + expect(editors[0].shadowRoot?.querySelector('[label="Show custom events"]')).not.toBeNull(); + expect(editors[0].shadowRoot?.querySelector('[label="Show feature flags"]')).not.toBeNull(); + expect(editors[1].shadowRoot?.querySelector('[label="Show custom events"]')).not.toBeNull(); + expect(editors[1].shadowRoot?.querySelector('[label="Show feature flags"]')).toBeNull(); + }); + + it("emits connection changes when a dashboard report is disabled", async () => { + const editor = document.createElement("web-analytics-connection-editor") as AnalyticsConnectionEditorElement; + editor.connection = connection(); + editor.descriptor = PROVIDERS[0]; + const changed = vi.fn(); + editor.addEventListener("connection-change", changed); + document.body.append(editor); + await editor.updateComplete; + + const events = editor.shadowRoot?.querySelector('[label="Show custom events"]'); + events!.checked = false; + events!.dispatchEvent(new Event("change", { bubbles: true, composed: true })); + + expect((changed.mock.calls[0][0] as CustomEvent).detail.enableEvents).toBe(false); + }); + it("shows an unsupported connection and blocks saving when its descriptor is missing", async () => { sdk.settings.mockResolvedValueOnce(apiOk(settings({ providers: [], connections: [connection()] }))); const dashboard = document.createElement("web-analytics-settings-dashboard") as WebAnalyticsSettingsDashboardElement; diff --git a/src/TheBuilder.WebAnalytics/Client/src/settings/settings-model.test.ts b/src/TheBuilder.WebAnalytics/Client/src/settings/settings-model.test.ts index b5862b5..9b4202d 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/settings/settings-model.test.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/settings/settings-model.test.ts @@ -17,6 +17,7 @@ const settings = (): AnalyticsSettingsResponse => ({ provider: "Vercel", description: "Projects using Vercel Web Analytics", logoSlug: "vercel", + capabilities: { dimensions: ["RequestPath", "EventName"], events: true, eventDetails: true, eventProperties: false, globalEventFiltering: false, flags: true, breakdownOrdering: false }, identifier: { key: "projectId", label: "Vercel project ID", description: "Use the project ID from your Vercel project settings.", requiredMessage: "a Vercel project ID" }, team: { key: "team", label: "Team ID or slug", description: "Optional team slug or ID for projects owned by a Vercel team." }, credential: { label: "access token", description: "Configure a Vercel access token in the server settings.", documentationUrl: "https://vercel.com/docs/rest-api" }, @@ -26,6 +27,7 @@ const settings = (): AnalyticsSettingsResponse => ({ provider: "Plausible", description: "Sites using Plausible Analytics", logoSlug: "plausible", + capabilities: { dimensions: ["RequestPath", "EventName"], events: true, eventDetails: true, eventProperties: true, globalEventFiltering: true, flags: false, breakdownOrdering: true }, identifier: { key: "siteId", label: "Plausible site ID", description: "Use the domain configured in your Plausible site settings.", requiredMessage: "a Plausible site ID" }, team: null, credential: { label: "Stats API key", description: "Configure a Plausible Stats API key in the server settings.", documentationUrl: "https://plausible.io/docs/stats-api" }, @@ -44,6 +46,8 @@ const settings = (): AnalyticsSettingsResponse => ({ team: null, siteId: "", eventPropertyNames: [], + enableEvents: true, + enableFlags: true, documentRootKeys: [], enableAllDocumentTypes: false, enabledDocumentTypeKeys: [], @@ -120,6 +124,16 @@ describe("analytics settings model", () => { expect(createSettingsUpdate(model).connections[0].key).toBe(model.connections[0].key); }); + it("preserves enabled dashboard reports in updates", () => { + const model = settings(); + model.connections[0].enableEvents = false; + + expect(createSettingsUpdate(model).connections[0]).toMatchObject({ + enableEvents: false, + enableFlags: true, + }); + }); + it("validates and serializes Plausible event property names", () => { const model = settings(); const connection = model.connections[0]; diff --git a/src/TheBuilder.WebAnalytics/Client/src/settings/settings-model.ts b/src/TheBuilder.WebAnalytics/Client/src/settings/settings-model.ts index 9b08084..1928ebd 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/settings/settings-model.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/settings/settings-model.ts @@ -59,6 +59,8 @@ export function createSettingsUpdate(settings: AnalyticsSettingsResponse): Updat team: connection.team, siteId: connection.siteId, eventPropertyNames: connection.eventPropertyNames, + enableEvents: connection.enableEvents, + enableFlags: connection.enableFlags, mockScenario: connection.mockScenario, documentRootKeys: connection.documentRootKeys, enableAllDocumentTypes: connection.enableAllDocumentTypes, diff --git a/src/TheBuilder.WebAnalytics/Configuration/AnalyticsConnectionRegistry.cs b/src/TheBuilder.WebAnalytics/Configuration/AnalyticsConnectionRegistry.cs index 049052a..8dc6cd0 100644 --- a/src/TheBuilder.WebAnalytics/Configuration/AnalyticsConnectionRegistry.cs +++ b/src/TheBuilder.WebAnalytics/Configuration/AnalyticsConnectionRegistry.cs @@ -116,7 +116,9 @@ public sealed record AnalyticsConnection( bool EnableAllDocumentTypes, IReadOnlySet EnabledDocumentTypeKeys, IReadOnlySet EnabledDocumentTypes, - MockAnalyticsScenario? MockScenario = null) + MockAnalyticsScenario? MockScenario = null, + bool EnableEvents = true, + bool EnableFlags = true) { public bool HasAccessToken => !string.IsNullOrWhiteSpace(AccessToken); @@ -125,9 +127,26 @@ public sealed record AnalyticsConnection( public bool IsConfigured => IsMock || HasAccessToken && !string.IsNullOrWhiteSpace(AnalyticsProviderCatalog.Default.Get(Provider).GetIdentifier(this)); - public AnalyticsCapabilities Capabilities => IsMock - ? AnalyticsProviderCatalog.Default.Get(AnalyticsProvider.Vercel).Capabilities - : AnalyticsProviderCatalog.Default.Get(Provider).Capabilities; + public AnalyticsCapabilities Capabilities + { + get + { + var supported = IsMock + ? AnalyticsProviderCatalog.Default.Get(AnalyticsProvider.Vercel).Capabilities + : AnalyticsProviderCatalog.Default.Get(Provider).Capabilities; + return supported with + { + Dimensions = EnableEvents + ? supported.Dimensions + : supported.Dimensions.Where(dimension => dimension != AnalyticsDimension.EventName).ToArray(), + Events = supported.Events && EnableEvents, + EventDetails = supported.EventDetails && EnableEvents, + EventProperties = supported.EventProperties && EnableEvents, + GlobalEventFiltering = supported.GlobalEventFiltering && EnableEvents, + Flags = supported.Flags && EnableFlags + }; + } + } public override string ToString() => $"{nameof(AnalyticsConnection)} {{ Key = {Key}, DisplayName = {DisplayName}, Provider = {Provider}, ProjectId = {ProjectId}, Team = {Team}, SiteId = {SiteId}, AccessToken = [REDACTED] }}"; @@ -153,7 +172,9 @@ internal static AnalyticsConnection Create( ParseGuidValues(settings.EnabledDocumentTypeKeys).ToHashSet(), settings.EnabledDocumentTypes.Select(value => value.Trim()).Where(value => value.Length > 0) .ToHashSet(StringComparer.OrdinalIgnoreCase), - settings.MockScenario); + settings.MockScenario, + settings.EnableEvents, + settings.EnableFlags); private static string? NullIfWhiteSpace(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); diff --git a/src/TheBuilder.WebAnalytics/Configuration/AnalyticsProviderCatalog.cs b/src/TheBuilder.WebAnalytics/Configuration/AnalyticsProviderCatalog.cs index 2cf52f7..fde9e22 100644 --- a/src/TheBuilder.WebAnalytics/Configuration/AnalyticsProviderCatalog.cs +++ b/src/TheBuilder.WebAnalytics/Configuration/AnalyticsProviderCatalog.cs @@ -62,6 +62,7 @@ public sealed class AnalyticsProviderDefinition( Provider, Settings.Description, Settings.LogoSlug, + Capabilities, Identifier.ToDescriptor(), Settings.Team, Settings.Credential, diff --git a/src/TheBuilder.WebAnalytics/Configuration/WebAnalyticsOptions.cs b/src/TheBuilder.WebAnalytics/Configuration/WebAnalyticsOptions.cs index a41f3ff..4e4c35a 100644 --- a/src/TheBuilder.WebAnalytics/Configuration/WebAnalyticsOptions.cs +++ b/src/TheBuilder.WebAnalytics/Configuration/WebAnalyticsOptions.cs @@ -55,6 +55,10 @@ public sealed class AnalyticsConnectionOptions public string[] EventPropertyNames { get; set; } = []; + public bool EnableEvents { get; set; } = true; + + public bool EnableFlags { get; set; } = true; + public MockAnalyticsScenario? MockScenario { get; set; } public string[] DocumentRootKeys { get; set; } = []; diff --git a/src/TheBuilder.WebAnalytics/Configuration/WebAnalyticsSettings.cs b/src/TheBuilder.WebAnalytics/Configuration/WebAnalyticsSettings.cs index fcdf443..13cceca 100644 --- a/src/TheBuilder.WebAnalytics/Configuration/WebAnalyticsSettings.cs +++ b/src/TheBuilder.WebAnalytics/Configuration/WebAnalyticsSettings.cs @@ -35,6 +35,10 @@ public sealed class AnalyticsConnectionSettings public string[] EventPropertyNames { get; set; } = []; + public bool EnableEvents { get; set; } = true; + + public bool EnableFlags { get; set; } = true; + public MockAnalyticsScenario? MockScenario { get; set; } [JsonIgnore] @@ -134,6 +138,8 @@ private static AnalyticsConnectionSettings NormalizeConnection(AnalyticsConnecti Team = fields.Team, SiteId = fields.SiteId, EventPropertyNames = fields.EventPropertyNames, + EnableEvents = connection.EnableEvents, + EnableFlags = connection.EnableFlags, MockScenario = connection.MockScenario, DocumentRootKeys = NormalizeGuidValues(connection.DocumentRootKeys), EnableAllDocumentTypes = connection.EnableAllDocumentTypes, @@ -174,6 +180,8 @@ public static WebAnalyticsSettings FromServerOptions(WebAnalyticsOptions options Team = connection.Team, SiteId = connection.SiteId, EventPropertyNames = connection.EventPropertyNames, + EnableEvents = connection.EnableEvents, + EnableFlags = connection.EnableFlags, MockScenario = connection.MockScenario, DocumentRootKeys = connection.DocumentRootKeys, EnableAllDocumentTypes = connection.EnableAllDocumentTypes, diff --git a/src/TheBuilder.WebAnalytics/Controllers/WebAnalyticsSettingsApiController.cs b/src/TheBuilder.WebAnalytics/Controllers/WebAnalyticsSettingsApiController.cs index 67525b6..0e6e5e9 100644 --- a/src/TheBuilder.WebAnalytics/Controllers/WebAnalyticsSettingsApiController.cs +++ b/src/TheBuilder.WebAnalytics/Controllers/WebAnalyticsSettingsApiController.cs @@ -66,6 +66,8 @@ public async Task> SaveSettings( Team = connection.Team, SiteId = connection.SiteId, EventPropertyNames = connection.EventPropertyNames.ToArray(), + EnableEvents = connection.EnableEvents, + EnableFlags = connection.EnableFlags, MockScenario = connection.MockScenario, DocumentRootKeys = connection.DocumentRootKeys.ToArray(), EnableAllDocumentTypes = connection.EnableAllDocumentTypes, @@ -141,6 +143,8 @@ private async Task CreateResponseAsync(CancellationTo Team = connection.Team, SiteId = connection.SiteId, EventPropertyNames = connection.EventPropertyNames, + EnableEvents = connection.EnableEvents, + EnableFlags = connection.EnableFlags, DocumentRootKeys = connection.DocumentRootKeys, EnableAllDocumentTypes = connection.EnableAllDocumentTypes, EnabledDocumentTypeKeys = connection.EnabledDocumentTypeKeys, diff --git a/src/TheBuilder.WebAnalytics/Models/AnalyticsModels.cs b/src/TheBuilder.WebAnalytics/Models/AnalyticsModels.cs index cd00137..8db5cc1 100644 --- a/src/TheBuilder.WebAnalytics/Models/AnalyticsModels.cs +++ b/src/TheBuilder.WebAnalytics/Models/AnalyticsModels.cs @@ -182,6 +182,7 @@ public sealed record AnalyticsProviderDescriptor( AnalyticsProvider Provider, string Description, string LogoSlug, + AnalyticsCapabilities Capabilities, AnalyticsIdentifierFieldDescriptor Identifier, AnalyticsOptionalFieldDescriptor? Team, AnalyticsCredentialDescriptor Credential, @@ -196,6 +197,8 @@ public sealed class AnalyticsConnectionSettingsResponse public string? Team { get; init; } public required string SiteId { get; init; } public IReadOnlyList EventPropertyNames { get; init; } = []; + public bool EnableEvents { get; init; } = true; + public bool EnableFlags { get; init; } = true; public required IReadOnlyList DocumentRootKeys { get; init; } public required bool EnableAllDocumentTypes { get; init; } public required IReadOnlyList EnabledDocumentTypeKeys { get; init; } @@ -219,6 +222,8 @@ public sealed class UpdateAnalyticsConnectionRequest public string? Team { get; init; } public required string SiteId { get; init; } public IReadOnlyList EventPropertyNames { get; init; } = []; + public bool EnableEvents { get; init; } = true; + public bool EnableFlags { get; init; } = true; public MockAnalyticsScenario? MockScenario { get; init; } public required IReadOnlyList DocumentRootKeys { get; init; } public required bool EnableAllDocumentTypes { get; init; } diff --git a/tests/TheBuilder.WebAnalytics.Tests/Configuration/WebAnalyticsOptionsValidatorTests.cs b/tests/TheBuilder.WebAnalytics.Tests/Configuration/WebAnalyticsOptionsValidatorTests.cs index d8bae45..54777e0 100644 --- a/tests/TheBuilder.WebAnalytics.Tests/Configuration/WebAnalyticsOptionsValidatorTests.cs +++ b/tests/TheBuilder.WebAnalytics.Tests/Configuration/WebAnalyticsOptionsValidatorTests.cs @@ -152,6 +152,25 @@ public void Registry_resolves_plausible_provider_token() Assert.Equal(["locale", "title"], connection.EventPropertyNames); } + [Fact] + public void Registry_intersects_provider_capabilities_with_enabled_connection_features() + { + var options = CreateOptions(); + var configured = Assert.Single(options.Connections); + configured.EnableEvents = false; + configured.EnableFlags = false; + + var connection = CreateRegistry(options).Get(MainKey); + + Assert.NotNull(connection); + Assert.DoesNotContain(AnalyticsDimension.EventName, connection.Capabilities.Dimensions); + Assert.False(connection.Capabilities.Events); + Assert.False(connection.Capabilities.EventDetails); + Assert.False(connection.Capabilities.EventProperties); + Assert.False(connection.Capabilities.GlobalEventFiltering); + Assert.False(connection.Capabilities.Flags); + } + [Fact] public void Connection_string_representation_redacts_access_token() { diff --git a/tests/TheBuilder.WebAnalytics.Tests/Controllers/WebAnalyticsSettingsApiControllerTests.cs b/tests/TheBuilder.WebAnalytics.Tests/Controllers/WebAnalyticsSettingsApiControllerTests.cs index 55b198c..691d64e 100644 --- a/tests/TheBuilder.WebAnalytics.Tests/Controllers/WebAnalyticsSettingsApiControllerTests.cs +++ b/tests/TheBuilder.WebAnalytics.Tests/Controllers/WebAnalyticsSettingsApiControllerTests.cs @@ -59,10 +59,16 @@ public async Task Settings_preserve_mock_identity_and_report_runtime_availabilit Assert.Equal("projectId", vercel.Identifier.Key); Assert.NotNull(vercel.Team); Assert.Null(vercel.EventProperties); + Assert.True(vercel.Capabilities.Events); + Assert.True(vercel.Capabilities.Flags); var plausible = Assert.Single(response.Providers, provider => provider.Provider == AnalyticsProvider.Plausible); Assert.Equal("siteId", plausible.Identifier.Key); Assert.Null(plausible.Team); Assert.Equal(20, plausible.EventProperties?.MaximumNames); + Assert.True(plausible.Capabilities.Events); + Assert.False(plausible.Capabilities.Flags); + Assert.True(connection.EnableEvents); + Assert.True(connection.EnableFlags); Assert.DoesNotContain("server-secret", JsonSerializer.Serialize(response), StringComparison.Ordinal); } From e7963aa2f69d56583c93381dfc9ae4eff1ab1c9f Mon Sep 17 00:00:00 2001 From: Daniel Schmidt Date: Wed, 22 Jul 2026 11:41:50 +0200 Subject: [PATCH 02/12] feat: adapt dashboard card hierarchy --- .../analytics-breakdown-grid.element.ts | 39 ++++++++----------- .../analytics/analytics-components.test.ts | 33 +++++++++------- .../analytics-dashboard.controller.test.ts | 35 +++++++++++++++++ 3 files changed, 72 insertions(+), 35 deletions(-) diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-breakdown-grid.element.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-breakdown-grid.element.ts index 3a3cf9c..37e83dc 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-breakdown-grid.element.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-breakdown-grid.element.ts @@ -189,7 +189,7 @@ export class WebAnalyticsBreakdownGridElement extends UmbElementMixin(LitElement const rows = topEventRows(stateData(this.events)?.rows ?? [], 10); const empty = !loading && rows.length === 0; return html` - +
${empty ? "" : html`
@@ -205,27 +205,31 @@ export class WebAnalyticsBreakdownGridElement extends UmbElementMixin(LitElement const utmCard = this.cards.find((card): card is Extract => card.kind === "tabbed-breakdown" && card.id === "utm"); const referrerCard = standardCards.find((card) => card.kind === "breakdown" && (card.dimension === "ReferrerHostname" || card.dimension === "Referrer")); const renderCard = (card: DashboardCard) => card === referrerCard ? this.#renderAcquisitionCard(card, utmCard) : this.#renderCard(card); - const documentScoped = !standardCards.some((card) => card.kind === "breakdown" && card.dimension === "RequestPath"); - const cardsBeforeEvents = documentScoped ? standardCards.slice(0, 1) : standardCards; - const cardsAfterEvents = documentScoped ? standardCards.slice(1) : []; + const primaryCards = standardCards.filter((card) => card.span === "wide"); + const detailCards = standardCards.filter((card) => card.span === "normal"); return html` -
- ${cardsBeforeEvents.map(renderCard)} +
+ ${primaryCards.map(renderCard)} +
+
+ ${detailCards.map(renderCard)} +
+ ${this.supportsEvents || this.supportsFlags ? html`
${this.supportsEvents ? this.#renderEvents() : ""} - ${cardsAfterEvents.map(renderCard)} - ${this.supportsFlags ? html` + ${this.supportsFlags ? html` ` : ""} -
+
` : ""} `; } static styles = [UmbTextStyles, css` - .grid { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: var(--uui-size-layout-1); } - .breakdown-card { --uui-box-default-padding: 0; grid-column: span 2; overflow: hidden; position: relative; } - .wide { grid-column: span 3; } + .grid { display: grid; gap: var(--uui-size-layout-1); } + .grid + .grid { margin-block-start: var(--uui-size-layout-1); } + .primary-grid, .feature-grid { grid-template-columns: repeat(auto-fit, minmax(min(100%, 28rem), 1fr)); } + .detail-grid { grid-template-columns: repeat(auto-fit, minmax(min(100%, 18rem), 1fr)); } + .breakdown-card { --uui-box-default-padding: 0; min-inline-size: 0; overflow: hidden; position: relative; } .flags-card { --uui-box-default-padding: 0; } - .document-flags-card { grid-column: 1 / -1; inline-size: 50%; justify-self: center; } .breakdown-card-layout { box-sizing: border-box; min-block-size: 100%; padding-bottom: var(--uui-size-layout-3); } .empty-card-layout { block-size: 100%; padding-bottom: 0; } .breakdown-footer { align-items: center; background: color-mix(in srgb, var(--uui-color-surface-alt) 9%, var(--uui-color-surface)); border-top: 1px solid var(--uui-color-border); bottom: 0; box-sizing: border-box; display: flex; justify-content: flex-end; left: 0; min-block-size: var(--uui-size-layout-3); padding: 0 var(--uui-size-space-4); position: absolute; right: 0; } @@ -241,15 +245,6 @@ export class WebAnalyticsBreakdownGridElement extends UmbElementMixin(LitElement .utm-tabs button[aria-selected="true"] { background: var(--uui-color-surface-alt); color: var(--uui-color-text); font-weight: 600; } .utm-tabs button:hover { background: color-mix(in srgb, var(--uui-color-selected) 8%, transparent); color: var(--uui-color-text); } .utm-tabs button:focus-visible { outline: 2px solid var(--uui-color-selected); outline-offset: -2px; } - @media (max-width: 62rem) { - .grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } - .breakdown-card, .wide { grid-column: auto; } - .document-flags-card { inline-size: 100%; } - } - @media (max-width: 56rem) { - .grid { grid-template-columns: 1fr; } - .document-flags-card { grid-column: 1 / -1; } - } `]; } 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 619db14..29b8016 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-components.test.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-components.test.ts @@ -211,7 +211,7 @@ describe("analytics presentation components", () => { expect(element.shadowRoot?.querySelector(".metric-number")?.textContent).toBe("22,304"); }); - it("places events beside referrers in document analytics", async () => { + 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"); element.events = successState({ rows: [{ eventName: "Signup", visitors: 12, count: 18 }] }); @@ -219,36 +219,43 @@ describe("analytics presentation components", () => { await element.updateComplete; const cards = [...element.shadowRoot?.querySelectorAll("uui-box") ?? []]; - expect(cards[0]?.querySelector("web-analytics-breakdown-table")?.headline).toBe("Referrers"); - expect(cards[1]?.querySelector("web-analytics-event-table")).not.toBeNull(); + expect(cards.slice(0, 4).map((card) => card.querySelector("web-analytics-breakdown-table")?.headline)).toEqual([ + "Referrers", + "Countries", + "Devices", + "Operating systems", + ]); + expect(cards[4]?.querySelector("web-analytics-event-table")).not.toBeNull(); + expect(cards[5]?.querySelector("web-analytics-flag-card")).not.toBeNull(); }); - it("gives Flags the same wide span as Events in the overview", async () => { + it("groups Events and Flags as optional reports in the overview", async () => { const element = document.createElement("web-analytics-breakdown-grid") as WebAnalyticsBreakdownGridElement; element.cards = dashboardCards(false, "unavailable"); element.events = successState({ rows: [] }); document.body.append(element); await element.updateComplete; - const cards = [...element.shadowRoot?.querySelectorAll("uui-box") ?? []]; - const flagsCard = cards[cards.length - 1]; + const featureGrid = element.shadowRoot?.querySelector(".feature-grid"); + const cards = [...featureGrid?.querySelectorAll("uui-box") ?? []]; + const flagsCard = cards[1]; expect(flagsCard?.querySelector("web-analytics-flag-card")).not.toBeNull(); expect(flagsCard?.classList.contains("flags-card")).toBe(true); - expect(flagsCard?.classList.contains("wide")).toBe(true); - expect(cards[cards.length - 2]?.querySelector("web-analytics-event-table")).not.toBeNull(); + expect(cards[0]?.querySelector("web-analytics-event-table")).not.toBeNull(); }); - it("keeps Flags on its own row in document analytics", async () => { + it("lets a lone optional report occupy its feature grid", async () => { const element = document.createElement("web-analytics-breakdown-grid") as WebAnalyticsBreakdownGridElement; element.cards = dashboardCards(true, "unavailable"); + element.supportsEvents = false; + element.supportsFlags = true; element.events = successState({ rows: [] }); document.body.append(element); await element.updateComplete; - const cards = [...element.shadowRoot?.querySelectorAll("uui-box") ?? []]; - const flagsCard = cards[cards.length - 1]; - expect(flagsCard?.classList.contains("document-flags-card")).toBe(true); - expect(flagsCard?.classList.contains("wide")).toBe(false); + const cards = [...element.shadowRoot?.querySelectorAll(".feature-grid > uui-box") ?? []]; + expect(cards).toHaveLength(1); + expect(cards[0]?.querySelector("web-analytics-flag-card")).not.toBeNull(); }); it("merges valid UTM reports into the referrers card with five parameter tabs", async () => { diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dashboard.controller.test.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dashboard.controller.test.ts index 1cb3ea7..fbb8a8e 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 @@ -448,6 +448,41 @@ describe("AnalyticsDashboardController", () => { expect(controller.cards().some((card) => card.kind === "tabbed-breakdown" && card.id === "utm")).toBe(true); }); + it("does not request optional reports disabled for the connection", async () => { + const api = dashboardApi(); + api.connections.mockResolvedValue(ok({ + enabled: true, + defaultRangeDays: 30, + connections: [{ + key: "11111111-1111-1111-1111-111111111111", + displayName: "Traffic only", + provider: "Vercel", + capabilities: { + ...fullCapabilities, + dimensions: fullCapabilities.dimensions.filter((dimension) => dimension !== "EventName"), + events: false, + eventDetails: false, + eventProperties: false, + globalEventFiltering: false, + flags: false, + }, + isDefault: true, + isConfigured: true, + baseUrl: "https://example.com", + warnings: [], + }], + })); + const controller = new AnalyticsDashboardController(vi.fn(), api, environment()); + + controller.connect(); + + await vi.waitFor(() => expect(controller.state.summary.status).toBe("success")); + expect(api.events).not.toHaveBeenCalled(); + expect(api.flags).not.toHaveBeenCalled(); + expect(controller.state.events.status).toBe("idle"); + expect(controller.state.flags.status).toBe("idle"); + }); + it("does not create a global event filter for providers that do not support it", async () => { const api = dashboardApi(); const controller = new AnalyticsDashboardController(vi.fn(), api, environment()); From 8a8f359107add573aff301abb9ac45462433b59f Mon Sep 17 00:00:00 2001 From: Daniel Schmidt Date: Wed, 22 Jul 2026 11:56:56 +0200 Subject: [PATCH 03/12] fix: keep lone optional report compact --- .../src/analytics/analytics-breakdown-grid.element.ts | 3 ++- .../Client/src/analytics/analytics-components.test.ts | 8 ++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-breakdown-grid.element.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-breakdown-grid.element.ts index 37e83dc..f1545ff 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-breakdown-grid.element.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-breakdown-grid.element.ts @@ -226,7 +226,8 @@ export class WebAnalyticsBreakdownGridElement extends UmbElementMixin(LitElement static styles = [UmbTextStyles, css` .grid { display: grid; gap: var(--uui-size-layout-1); } .grid + .grid { margin-block-start: var(--uui-size-layout-1); } - .primary-grid, .feature-grid { grid-template-columns: repeat(auto-fit, minmax(min(100%, 28rem), 1fr)); } + .primary-grid { grid-template-columns: repeat(auto-fit, minmax(min(100%, 28rem), 1fr)); } + .feature-grid { grid-template-columns: repeat(auto-fill, minmax(min(100%, 28rem), 1fr)); } .detail-grid { grid-template-columns: repeat(auto-fit, minmax(min(100%, 18rem), 1fr)); } .breakdown-card { --uui-box-default-padding: 0; min-inline-size: 0; overflow: hidden; position: relative; } .flags-card { --uui-box-default-padding: 0; } 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 29b8016..1a35b9b 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-components.test.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-components.test.ts @@ -244,7 +244,7 @@ describe("analytics presentation components", () => { expect(cards[0]?.querySelector("web-analytics-event-table")).not.toBeNull(); }); - it("lets a lone optional report occupy its feature grid", async () => { + it("keeps a lone optional report in one feature-grid track", async () => { const element = document.createElement("web-analytics-breakdown-grid") as WebAnalyticsBreakdownGridElement; element.cards = dashboardCards(true, "unavailable"); element.supportsEvents = false; @@ -253,9 +253,13 @@ describe("analytics presentation components", () => { document.body.append(element); await element.updateComplete; - const cards = [...element.shadowRoot?.querySelectorAll(".feature-grid > uui-box") ?? []]; + const featureGrid = element.shadowRoot?.querySelector(".feature-grid"); + const cards = [...featureGrid?.querySelectorAll("uui-box") ?? []]; expect(cards).toHaveLength(1); expect(cards[0]?.querySelector("web-analytics-flag-card")).not.toBeNull(); + const styles = [...element.shadowRoot?.querySelectorAll("style") ?? []].map((style) => style.textContent).join("\n"); + expect(styles).toContain(".feature-grid"); + expect(styles).toContain("repeat(auto-fill"); }); it("merges valid UTM reports into the referrers card with five parameter tabs", async () => { From adfef2bbb23b42122d0dedcd538875c2ca0970c7 Mon Sep 17 00:00:00 2001 From: Daniel Schmidt Date: Wed, 22 Jul 2026 12:08:42 +0200 Subject: [PATCH 04/12] fix: clarify connection settings hierarchy --- .../src/settings/connection-editor.element.ts | 104 +++++++++--------- .../src/settings/settings-dashboard.test.ts | 40 +++++++ 2 files changed, 95 insertions(+), 49 deletions(-) diff --git a/src/TheBuilder.WebAnalytics/Client/src/settings/connection-editor.element.ts b/src/TheBuilder.WebAnalytics/Client/src/settings/connection-editor.element.ts index 0bbc92a..8ee74c5 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/settings/connection-editor.element.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/settings/connection-editor.element.ts @@ -128,6 +128,30 @@ export class AnalyticsConnectionEditorElement extends UmbElementMixin(LitElement this.dispatchEvent(new CustomEvent(name, { bubbles: true, composed: true })); } + #renderCredentialSection(connection: EditableAnalyticsConnection, descriptor: AnalyticsProviderDescriptor) { + const required = !connection.hasAccessToken; + return html`
+ ${required ? "Connection credential" : "Credential override"}${required ? "Required before testing" : connection.hasAccessTokenOverride ? "Configured on the server" : "Using shared credential"} +
+

+ ${required + ? `No shared ${connection.provider} ${descriptor.credential.label} was detected. Add a connection-specific credential before testing this connection.` + : `Set a connection-specific credential only when this connection cannot use the shared ${connection.provider} ${descriptor.credential.label}.`} + + Create a ${connection.provider} ${descriptor.credential.label} + +

+
+ WebAnalytics__ConnectionAccessTokens__${connection.key} + ${this._tokenCopyStatus === "copied" ? "Copied" : "Copy"} +
+ + ${this._tokenCopyStatus === "failed" ? "Could not copy the setting name. Select and copy it manually." : this._tokenCopyStatus === "copied" ? "Setting name copied." : ""} + +
+
`; + } + render() { const connection = this.connection; const descriptor = this.descriptor; @@ -193,9 +217,9 @@ export class AnalyticsConnectionEditorElement extends UmbElementMixin(LitElement

${isMock ? "Mock data" : `${connection.provider} connection`}

-
- ${this.status ? html`${this.status.message}` : ""} -
+ ${this.status ? html`
+ ${this.status.message} +
` : ""}
- ${isMock ? "" : html`
- Credential override${connection.hasAccessTokenOverride ? "Configured on the server" : connection.hasAccessToken ? "Using shared credential" : "Required without a shared credential"} -
-

- ${connection.hasAccessToken - ? `Set a connection-specific credential only when this connection cannot use the shared ${connection.provider} ${descriptor.credential.label}.` - : `No shared ${connection.provider} ${descriptor.credential.label} was detected. Add a connection-specific credential before testing this connection.`} - - Create a ${connection.provider} ${descriptor.credential.label} - -

-
- WebAnalytics__ConnectionAccessTokens__${connection.key} - ${this._tokenCopyStatus === "copied" ? "Copied" : "Copy"} + ${!isMock && !connection.hasAccessToken ? this.#renderCredentialSection(connection, descriptor) : ""} + +
+ Page analytics${this.#mappingSummary()} +
+

Optional. Select the Umbraco site roots that use this analytics connection. Leave empty for global analytics only.

+
+ + Document roots + + Documents below a selected root use this connection for page analytics. +
- - ${this._tokenCopyStatus === "failed" ? "Could not copy the setting name. Select and copy it manually." : this._tokenCopyStatus === "copied" ? "Setting name copied." : ""} -
-
`} +
+ +
+ Document workspace${this.#documentTypeSummary()} +
+

Choose which document types show an Analytics workspace tab. This does not affect the global dashboard.

+ Show analytics on all document types + ${connection.enableAllDocumentTypes ? html`

New document types are included automatically.

` : html` + + Selected document types + + + `} +
+
${descriptor.capabilities.events || descriptor.capabilities.flags ? html`
@@ -284,33 +317,7 @@ export class AnalyticsConnectionEditorElement extends UmbElementMixin(LitElement
` : ""} -
- Page analytics${this.#mappingSummary()} -
-

Optional. Select the Umbraco site roots that use this analytics connection. Leave empty for global analytics only.

-
- - Document roots - - Documents below a selected root use this connection for page analytics. - -
-
-
- -
- Document workspace${this.#documentTypeSummary()} -
-

Choose which document types show an Analytics workspace tab. This does not affect the global dashboard.

- Show analytics on all document types - ${connection.enableAllDocumentTypes ? html`

New document types are included automatically.

` : html` - - Selected document types - - - `} -
-
+ ${!isMock && connection.hasAccessToken ? this.#renderCredentialSection(connection, descriptor) : ""}
@@ -395,7 +402,6 @@ export class AnalyticsConnectionEditorElement extends UmbElementMixin(LitElement .essentials-heading h3 { font-size: var(--uui-type-h5-size); margin: 0; } .connection-actions { align-items: center; display: flex; flex: 0 1 auto; flex-wrap: wrap; gap: var(--uui-size-space-3); grid-area: actions; justify-content: flex-end; } .action-status { grid-area: status; justify-self: start; min-inline-size: 0; } - .action-status:empty { display: none; } .action-status span { align-items: center; display: flex; gap: var(--uui-size-space-2); max-inline-size: 70ch; overflow-wrap: anywhere; text-align: start; } .action-status .success { color: var(--uui-color-positive-standalone); } .action-status .error { color: var(--uui-color-danger-standalone); } diff --git a/src/TheBuilder.WebAnalytics/Client/src/settings/settings-dashboard.test.ts b/src/TheBuilder.WebAnalytics/Client/src/settings/settings-dashboard.test.ts index f3ffb16..27de7fc 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/settings/settings-dashboard.test.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/settings/settings-dashboard.test.ts @@ -254,6 +254,39 @@ function connection(): AnalyticsConnectionSettingsResponse { } describe("analytics settings onboarding", () => { + it("orders connection settings by the editor workflow without reserving an empty status row", async () => { + const plausible = { + ...connection(), + key: "connection-2", + provider: "Plausible" as const, + projectId: "", + siteId: "example.com", + }; + sdk.settings.mockResolvedValueOnce(apiOk(settings({ connections: [connection(), plausible] }))); + const dashboard = document.createElement("web-analytics-settings-dashboard") as WebAnalyticsSettingsDashboardElement; + document.body.append(dashboard); + + await vi.waitFor(() => expect(dashboard.shadowRoot?.querySelectorAll("web-analytics-connection-editor")).toHaveLength(2)); + const editors = [...dashboard.shadowRoot?.querySelectorAll("web-analytics-connection-editor") ?? []] as AnalyticsConnectionEditorElement[]; + const sectionLabels = (editor: AnalyticsConnectionEditorElement) => + [...editor.shadowRoot?.querySelectorAll(".config-section > summary > span") ?? []].map((label) => label.textContent?.trim()); + + expect(editors[0].shadowRoot?.querySelector(".action-status")).toBeNull(); + expect(sectionLabels(editors[0])).toEqual([ + "Page analytics", + "Document workspace", + "Dashboard reports", + "Credential override", + ]); + expect(sectionLabels(editors[1])).toEqual([ + "Page analytics", + "Document workspace", + "Dashboard reports", + "Event properties", + "Credential override", + ]); + }); + it("renders only the dashboard reports supported by each connection provider", async () => { const plausible = { ...connection(), @@ -411,6 +444,13 @@ describe("analytics settings onboarding", () => { const testButton = editor.shadowRoot?.querySelector('[label="Add a server-side credential before testing this connection."]'); expect(testButton?.hasAttribute("disabled")).toBe(true); expect(editor.shadowRoot?.querySelector(".summary-health uui-tag")?.textContent?.trim()).toBe("Setup required"); + expect([...editor.shadowRoot?.querySelectorAll(".config-section > summary > span") ?? []].map((label) => label.textContent?.trim())).toEqual([ + "Connection credential", + "Page analytics", + "Document workspace", + "Dashboard reports", + ]); + expect(editor.shadowRoot?.querySelector(".token-section small")?.textContent?.trim()).toBe("Required before testing"); }); it("shows a recoverable message when the override setting name cannot be copied", async () => { From 926413b319b90d0c796c1b31e8496c693ceecc1e Mon Sep 17 00:00:00 2001 From: Daniel Schmidt Date: Wed, 22 Jul 2026 12:12:54 +0200 Subject: [PATCH 05/12] fix: show save success without layout shift --- .../src/settings/settings-dashboard.element.ts | 17 ++++++++++++----- .../src/settings/settings-dashboard.test.ts | 16 ++++++++++++++-- 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/src/TheBuilder.WebAnalytics/Client/src/settings/settings-dashboard.element.ts b/src/TheBuilder.WebAnalytics/Client/src/settings/settings-dashboard.element.ts index 7e18797..a32ff8f 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/settings/settings-dashboard.element.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/settings/settings-dashboard.element.ts @@ -6,6 +6,7 @@ import { state, } from "@umbraco-cms/backoffice/external/lit"; import { UmbElementMixin } from "@umbraco-cms/backoffice/element-api"; +import { UMB_NOTIFICATION_CONTEXT } from "@umbraco-cms/backoffice/notification"; import { UmbTextStyles } from "@umbraco-cms/backoffice/style"; import type { UUIInputElement, UUIToggleElement } from "@umbraco-cms/backoffice/external/uui"; import { WebAnalyticsService } from "../api/sdk.gen.js"; @@ -35,10 +36,18 @@ export class WebAnalyticsSettingsDashboardElement extends UmbElementMixin(LitEle @state() private _dirty = false; @state() private _showValidation = false; @state() private _testingKey?: string; - @state() private _status?: { type: "success" | "error"; message: string }; + @state() private _status?: { type: "error"; message: string }; @state() private _loadError?: SettingsError; @state() private _connectionStatuses: Record = {}; @state() private _showProviderPicker = false; + #notificationContext?: typeof UMB_NOTIFICATION_CONTEXT.TYPE; + + constructor() { + super(); + this.consumeContext(UMB_NOTIFICATION_CONTEXT, (context) => { + this.#notificationContext = context; + }); + } connectedCallback(): void { super.connectedCallback(); @@ -70,7 +79,6 @@ export class WebAnalyticsSettingsDashboardElement extends UmbElementMixin(LitEle this._settings = { ...this._settings, ...patch }; if (markDirty) { this._dirty = true; - if (this._status?.type === "success") this._status = undefined; } } @@ -206,7 +214,7 @@ export class WebAnalyticsSettingsDashboardElement extends UmbElementMixin(LitEle this._dirty = false; this._showValidation = false; announceAnalyticsAvailability(data.enabled); - if (successMessage) this._status = { type: "success", message: successMessage }; + if (successMessage) this.#notificationContext?.peek("positive", { data: { message: successMessage } }); return true; } catch (error) { this._status = { type: "error", message: settingsError("save", error).message }; @@ -357,7 +365,7 @@ export class WebAnalyticsSettingsDashboardElement extends UmbElementMixin(LitEle ` : ""} - ${this._status ? html`
${this._status.message}
` : ""} + ${this._status ? html`` : ""}
@@ -456,7 +464,6 @@ export class WebAnalyticsSettingsDashboardElement extends UmbElementMixin(LitEle h2 { margin: 0; } .section-heading p { color: var(--uui-color-text-alt); margin-block: var(--uui-size-space-2) 0; text-wrap: pretty; } .status { align-items: flex-start; border: 1px solid var(--uui-color-border); display: flex; gap: var(--uui-size-space-2); margin-block: var(--uui-size-space-5); overflow-wrap: anywhere; padding: var(--uui-size-space-3) var(--uui-size-space-4); } - .status.success { background: color-mix(in srgb, var(--uui-color-positive) 8%, var(--uui-color-surface)); border-color: color-mix(in srgb, var(--uui-color-positive) 35%, var(--uui-color-border)); } .status.error { background: color-mix(in srgb, var(--uui-color-danger) 7%, var(--uui-color-surface)); border-color: color-mix(in srgb, var(--uui-color-danger) 35%, var(--uui-color-border)); } .connections-section { margin-block-start: 0; } .general, .mock-settings { margin-block-start: var(--uui-size-layout-2); } diff --git a/src/TheBuilder.WebAnalytics/Client/src/settings/settings-dashboard.test.ts b/src/TheBuilder.WebAnalytics/Client/src/settings/settings-dashboard.test.ts index 27de7fc..67758b0 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/settings/settings-dashboard.test.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/settings/settings-dashboard.test.ts @@ -6,10 +6,18 @@ const sdk = vi.hoisted(() => ({ saveSettings: vi.fn(), testConnection: vi.fn(), })); +const notifications = vi.hoisted(() => ({ + peek: vi.fn(), +})); vi.mock("../api/sdk.gen.js", () => ({ WebAnalyticsService: sdk })); +vi.mock("@umbraco-cms/backoffice/notification", () => ({ UMB_NOTIFICATION_CONTEXT: { TYPE: {} } })); vi.mock("@umbraco-cms/backoffice/element-api", () => ({ - UmbElementMixin: (base: T) => base, + UmbElementMixin: (base: T) => class extends base { + consumeContext(_token: unknown, callback: (context: typeof notifications) => void) { + callback(notifications); + } + }, })); vi.mock("@umbraco-cms/backoffice/style", () => ({ UmbTextStyles: [] })); vi.mock("@umbraco-cms/backoffice/document", () => ({})); @@ -23,6 +31,7 @@ beforeEach(() => { sdk.settings.mockReset(); sdk.saveSettings.mockReset(); sdk.testConnection.mockReset(); + notifications.peek.mockReset(); Element.prototype.scrollIntoView = vi.fn(); Object.defineProperty(navigator, "clipboard", { configurable: true, @@ -134,8 +143,11 @@ describe("analytics settings network recovery", () => { sdk.saveSettings.mockResolvedValueOnce(apiOk(settings({ defaultRangeDays: 31 }))); form?.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true, composed: true })); - await vi.waitFor(() => expect(dashboard.shadowRoot?.textContent).toContain("Web Analytics settings saved.")); + await vi.waitFor(() => expect(notifications.peek).toHaveBeenCalledWith("positive", { + data: { message: "Web Analytics settings saved." }, + })); expect(dashboard.shadowRoot?.querySelector(".settings-actions")).toBeNull(); + expect(dashboard.shadowRoot?.querySelector(".status")).toBeNull(); expect(sdk.saveSettings).toHaveBeenCalledTimes(2); }); From 45aa755b0e7416d9c75d6b008cce115539680513 Mon Sep 17 00:00:00 2001 From: Daniel Schmidt Date: Wed, 22 Jul 2026 12:14:30 +0200 Subject: [PATCH 06/12] fix: simplify page analytics guidance --- .../Client/src/settings/connection-editor.element.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/TheBuilder.WebAnalytics/Client/src/settings/connection-editor.element.ts b/src/TheBuilder.WebAnalytics/Client/src/settings/connection-editor.element.ts index 8ee74c5..a9e0d66 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/settings/connection-editor.element.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/settings/connection-editor.element.ts @@ -246,7 +246,7 @@ export class AnalyticsConnectionEditorElement extends UmbElementMixin(LitElement
Page analytics${this.#mappingSummary()}
-

Optional. Select the Umbraco site roots that use this analytics connection. Leave empty for global analytics only.

+

Select the Umbraco site roots that use this analytics connection. Leave empty for global analytics only.

Document roots From 0ba424b9d09aaf21813928adf1587d21d3350f8f Mon Sep 17 00:00:00 2001 From: Daniel Schmidt Date: Wed, 22 Jul 2026 12:29:53 +0200 Subject: [PATCH 07/12] fix: align settings save footer with Umbraco --- .../settings/settings-dashboard.element.ts | 55 ++++++------------- .../src/settings/settings-dashboard.test.ts | 8 ++- 2 files changed, 24 insertions(+), 39 deletions(-) diff --git a/src/TheBuilder.WebAnalytics/Client/src/settings/settings-dashboard.element.ts b/src/TheBuilder.WebAnalytics/Client/src/settings/settings-dashboard.element.ts index a32ff8f..79906f2 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/settings/settings-dashboard.element.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/settings/settings-dashboard.element.ts @@ -355,15 +355,22 @@ export class WebAnalyticsSettingsDashboardElement extends UmbElementMixin(LitEle return html`
- ${this._dirty ? html` -
+ + ${this._dirty ? html` Unsaved changes - Save settings -
- ` : ""} + ` : ""} + Save settings + ${this._status ? html`` : ""} @@ -412,13 +419,12 @@ export class WebAnalyticsSettingsDashboardElement extends UmbElementMixin(LitEle static styles = [UmbTextStyles, css` :host { --analytics-z-sticky-action: 10; - --settings-actions-inline-inset: var(--uui-size-space-4); --settings-column-max: 76rem; --settings-inline-gutter: var(--uui-size-layout-1); container-type: inline-size; display: block; } - form { max-width: var(--settings-column-max); margin-inline: auto; padding: var(--uui-size-layout-1) var(--settings-inline-gutter) calc(var(--uui-size-layout-1) + var(--uui-size-14) + var(--uui-size-space-4)); } + form { max-width: var(--settings-column-max); margin-inline: auto; padding: var(--uui-size-layout-1) var(--settings-inline-gutter) calc(var(--uui-size-layout-1) + var(--umb-footer-layout-height)); } .load-error { box-sizing: border-box; margin-inline: auto; max-width: var(--settings-column-max); padding: var(--uui-size-layout-1) var(--settings-inline-gutter); } .load-error-content { align-items: flex-start; display: flex; gap: var(--uui-size-space-3); margin-block-end: var(--uui-size-space-5); max-inline-size: 70ch; } .load-error-content uui-icon { color: var(--uui-color-danger-standalone); flex: 0 0 auto; font-size: var(--uui-size-6); } @@ -426,40 +432,16 @@ export class WebAnalyticsSettingsDashboardElement extends UmbElementMixin(LitEle .section-heading { display: flex; align-items: center; justify-content: space-between; gap: var(--uui-size-layout-1); } .section-heading > div { min-inline-size: 0; } .settings-actions { - align-items: center; - background: var(--uui-color-surface-alt); - border-radius: var(--uui-border-radius); - box-shadow: var(--uui-shadow-depth-3); - box-sizing: border-box; - display: flex; - flex-wrap: wrap; - gap: var(--uui-size-space-4); - justify-content: flex-end; - inset-block-end: var(--uui-size-layout-1); - inset-inline-end: calc(var(--settings-inline-gutter) + var(--settings-actions-inline-inset)); - max-inline-size: calc(100vw - 2 * var(--settings-inline-gutter) - 2 * var(--settings-actions-inline-inset)); - min-block-size: var(--uui-size-14); - padding: var(--uui-size-space-3) var(--uui-size-space-4); + inset-block-end: 0; + inset-inline-end: 0; position: fixed; - width: min( - calc(var(--settings-column-max) - 2 * var(--settings-actions-inline-inset)), - calc(100vw - 2 * var(--settings-inline-gutter) - 2 * var(--settings-actions-inline-inset)) - ); + width: 100%; z-index: var(--analytics-z-sticky-action); } @supports (width: 1cqi) { - .settings-actions { - inset-inline-end: max( - calc(var(--settings-inline-gutter) + var(--settings-actions-inline-inset)), - calc((100cqi - var(--settings-column-max)) / 2 + var(--settings-actions-inline-inset)) - ); - width: min( - calc(var(--settings-column-max) - 2 * var(--settings-actions-inline-inset)), - calc(100cqi - 2 * var(--settings-inline-gutter) - 2 * var(--settings-actions-inline-inset)) - ); - } + .settings-actions { width: 100cqi; } } - .save-status { align-items: center; color: var(--uui-color-text-alt); display: inline-flex; font-size: var(--uui-type-small-size); gap: var(--uui-size-space-2); white-space: nowrap; } + .save-status { align-items: center; color: var(--uui-color-text-alt); display: inline-flex; font-size: var(--uui-type-small-size); gap: var(--uui-size-space-2); margin-inline-start: var(--uui-size-layout-1); white-space: nowrap; } .save-status uui-icon { color: var(--uui-color-warning-standalone); } h2 { margin: 0; } .section-heading p { color: var(--uui-color-text-alt); margin-block: var(--uui-size-space-2) 0; text-wrap: pretty; } @@ -524,7 +506,6 @@ export class WebAnalyticsSettingsDashboardElement extends UmbElementMixin(LitEle @media (max-width: 800px) { .section-heading { align-items: stretch; flex-direction: column; } .mock-scenarios { grid-template-columns: 1fr; } - .settings-actions { inset-block-end: var(--uui-size-space-4); } .connection-empty-state { align-items: start; grid-template-columns: auto minmax(0, 1fr); } .connection-empty-state uui-button { grid-column: 1 / -1; justify-self: start; } .provider-choices { grid-template-columns: 1fr; } diff --git a/src/TheBuilder.WebAnalytics/Client/src/settings/settings-dashboard.test.ts b/src/TheBuilder.WebAnalytics/Client/src/settings/settings-dashboard.test.ts index 67758b0..e53b3f1 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/settings/settings-dashboard.test.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/settings/settings-dashboard.test.ts @@ -146,7 +146,9 @@ describe("analytics settings network recovery", () => { await vi.waitFor(() => expect(notifications.peek).toHaveBeenCalledWith("positive", { data: { message: "Web Analytics settings saved." }, })); - expect(dashboard.shadowRoot?.querySelector(".settings-actions")).toBeNull(); + expect(dashboard.shadowRoot?.querySelector(".settings-actions")).not.toBeNull(); + expect(dashboard.shadowRoot?.querySelector(".save-status")).toBeNull(); + expect(dashboard.shadowRoot?.querySelector('[label="Save Web Analytics settings"]')?.hasAttribute("disabled")).toBe(true); expect(dashboard.shadowRoot?.querySelector(".status")).toBeNull(); expect(sdk.saveSettings).toHaveBeenCalledTimes(2); }); @@ -356,7 +358,9 @@ describe("analytics settings onboarding", () => { expect(dashboard.shadowRoot?.querySelector("#default-connection")).toBeNull(); expect(dashboard.shadowRoot?.querySelector("h1")).toBeNull(); expect(dashboard.shadowRoot?.textContent).not.toContain("Connect analytics providers and choose where page analytics appears."); - expect(dashboard.shadowRoot?.querySelector(".settings-actions")).toBeNull(); + expect(dashboard.shadowRoot?.querySelector(".settings-actions")?.tagName).toBe("UMB-FOOTER-LAYOUT"); + expect(dashboard.shadowRoot?.querySelector('[label="Save Web Analytics settings"]')?.getAttribute("color")).toBe("positive"); + expect(dashboard.shadowRoot?.querySelector('[label="Save Web Analytics settings"]')?.hasAttribute("disabled")).toBe(true); expect(dashboard.shadowRoot?.querySelector(".connection-empty-state h3")?.textContent).toBe("Connect your first analytics provider"); dashboard.shadowRoot?.querySelector('.connection-empty-state [label="Choose analytics provider"]')?.click(); From a119ca2870beae8653e94ab76f44a4bcc0ce8ea3 Mon Sep 17 00:00:00 2001 From: Daniel Schmidt Date: Wed, 22 Jul 2026 12:33:38 +0200 Subject: [PATCH 08/12] fix: keep UTM tabs in expanded report --- .../analytics/analytics-dashboard.element.ts | 1 + .../breakdown-dialog.element.test.ts | 57 +++++++++++++++++++ .../src/analytics/breakdown-dialog.element.ts | 55 ++++++++++++++++-- 3 files changed, 109 insertions(+), 4 deletions(-) create mode 100644 src/TheBuilder.WebAnalytics/Client/src/analytics/breakdown-dialog.element.test.ts 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 a300dca..65f7f37 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dashboard.element.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dashboard.element.ts @@ -152,6 +152,7 @@ export class WebAnalyticsDashboardElement extends UmbElementMixin(LitElement) { .baseUrl=${this.#controller.linkBaseUrl()} .linkValues=${expanded.dimension === "RequestPath" || expanded.dimension === "Route"} @search-breakdown=${(event: CustomEvent<{ search: string }>) => this.#controller.searchBreakdown(event.detail.search)} + @breakdown-dimension-change=${(event: CustomEvent<{ dimension: AnalyticsDimension; headline: string }>) => this.#controller.openBreakdown(event.detail.dimension, event.detail.headline)} @close-breakdown=${() => this.#controller.closeBreakdown()}> ` : ""} ${expandedEvents ? html` diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/breakdown-dialog.element.test.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/breakdown-dialog.element.test.ts new file mode 100644 index 0000000..961f9fd --- /dev/null +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/breakdown-dialog.element.test.ts @@ -0,0 +1,57 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@umbraco-cms/backoffice/element-api", () => ({ + UmbElementMixin: (base: T) => class extends base { + readonly localize = { + number: (value: string | number, options?: Intl.NumberFormatOptions) => new Intl.NumberFormat("en-US", options).format(Number(value)), + }; + }, +})); +vi.mock("@umbraco-cms/backoffice/style", () => ({ UmbTextStyles: [] })); + +import type { WebAnalyticsBreakdownDialogElement } from "./breakdown-dialog.element.js"; +import type { WebAnalyticsBreakdownTableElement } from "./breakdown-table.element.js"; +import "./breakdown-dialog.element.js"; + +beforeEach(() => { HTMLDialogElement.prototype.showModal = vi.fn(); }); +afterEach(() => { document.body.replaceChildren(); }); + +describe("breakdown dialog", () => { + it("keeps every UTM parameter available while showing the selected report", async () => { + const dialog = document.createElement("web-analytics-breakdown-dialog") as WebAnalyticsBreakdownDialogElement; + dialog.headline = "UTM media"; + dialog.dimension = "UtmMedium"; + dialog.rows = [{ value: "email", visitors: 8, pageViews: 11 }]; + const onDimensionChange = vi.fn(); + dialog.addEventListener("breakdown-dimension-change", onDimensionChange); + document.body.append(dialog); + await dialog.updateComplete; + + expect(dialog.shadowRoot?.querySelector("dialog")?.getAttribute("aria-label")).toBe("UTM"); + expect(dialog.shadowRoot?.querySelector("uui-dialog-layout")?.getAttribute("headline")).toBe("UTM"); + const tabs = [...dialog.shadowRoot?.querySelectorAll('.utm-tabs [role="tab"]') ?? []]; + expect(tabs.map((tab) => tab.textContent?.trim())).toEqual(["Source", "Medium", "Campaign", "Term", "Content"]); + expect(tabs[1]?.getAttribute("aria-selected")).toBe("true"); + expect(dialog.shadowRoot?.querySelector("uui-input")?.getAttribute("label")).toBe("Search UTM media"); + expect(dialog.shadowRoot?.querySelector("web-analytics-breakdown-table")?.headline).toBe("UTM media"); + + tabs[2]?.click(); + + expect((onDimensionChange.mock.calls[0]?.[0] as CustomEvent).detail).toEqual({ + dimension: "UtmCampaign", + headline: "UTM campaigns", + }); + }); + + it("does not add UTM navigation to other breakdowns", async () => { + const dialog = document.createElement("web-analytics-breakdown-dialog") as WebAnalyticsBreakdownDialogElement; + dialog.headline = "Countries"; + dialog.dimension = "Country"; + document.body.append(dialog); + await dialog.updateComplete; + + expect(dialog.shadowRoot?.querySelector("uui-dialog-layout")?.getAttribute("headline")).toBe("Countries"); + expect(dialog.shadowRoot?.querySelector(".utm-tabs")).toBeNull(); + }); +}); diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/breakdown-dialog.element.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/breakdown-dialog.element.ts index a7b6c3f..d9b7682 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/breakdown-dialog.element.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/breakdown-dialog.element.ts @@ -11,7 +11,9 @@ import { UmbTextStyles } from "@umbraco-cms/backoffice/style"; import type { UUIInputElement } from "@umbraco-cms/backoffice/external/uui"; import type { AnalyticsBreakdownRow, AnalyticsDimension } from "../api/types.gen.js"; import { breakdownMetricTotal, type TrafficMetric } from "./breakdown-rows.js"; +import { UTM_OPTIONS } from "./dashboard-cards.js"; import type { AnalyticsFilter } from "./dashboard-url-state.js"; +import { isUtmDimension } from "./utm-capability.js"; import "./breakdown-table.element.js"; @customElement("web-analytics-breakdown-dialog") @@ -53,13 +55,53 @@ export class WebAnalyticsBreakdownDialogElement extends UmbElementMixin(LitEleme })); } + #onTabKeydown(event: KeyboardEvent): void { + if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return; + event.preventDefault(); + const tabs = Array.from((event.currentTarget as HTMLElement).parentElement?.querySelectorAll("[role=tab]") ?? []); + const currentIndex = tabs.indexOf(event.currentTarget as HTMLButtonElement); + const targetIndex = event.key === "Home" ? 0 : event.key === "End" ? tabs.length - 1 + : event.key === "ArrowLeft" ? (currentIndex - 1 + tabs.length) % tabs.length + : (currentIndex + 1) % tabs.length; + tabs[targetIndex]?.click(); + tabs[targetIndex]?.focus(); + } + + #selectUtmDimension(dimension: AnalyticsDimension, headline: string): void { + if (dimension === this.dimension) return; + this._search = ""; + this.dispatchEvent(new CustomEvent("breakdown-dimension-change", { + bubbles: true, + composed: true, + detail: { dimension, headline }, + })); + } + render() { + const utmOption = this.dimension && isUtmDimension(this.dimension) + ? UTM_OPTIONS.find(({ dimension }) => dimension === this.dimension) + : undefined; + const dialogHeadline = utmOption ? "UTM" : this.headline; + const tableHeadline = utmOption?.headline ?? this.headline; return html` - - + + + ${utmOption ? html` +
+ ${UTM_OPTIONS.map(({ dimension, label, headline }) => html` + + `)} +
+ ` : ""} 0))) ? html` Date: Wed, 22 Jul 2026 13:09:34 +0200 Subject: [PATCH 09/12] fix: unify expanded analytics dialogs --- .../analytics-breakdown-grid.element.ts | 124 +++++-------- .../analytics/analytics-components.test.ts | 30 ++-- .../analytics-dashboard.controller.test.ts | 23 +++ .../analytics-dashboard.controller.ts | 29 +++- .../analytics/analytics-dashboard.element.ts | 7 +- .../src/analytics/analytics-dialog.styles.ts | 23 +++ .../breakdown-dialog.element.test.ts | 62 ++++++- .../src/analytics/breakdown-dialog.element.ts | 163 +++++++++--------- .../Client/src/analytics/breakdown-rows.ts | 20 +++ .../src/analytics/breakdown-table.element.ts | 57 ++++-- .../Client/src/analytics/dashboard-cards.ts | 16 +- .../analytics/event-details-dialog.element.ts | 61 ++----- .../src/analytics/event-dialog.element.ts | 9 +- .../src/analytics/event-table.element.ts | 2 +- .../Client/src/analytics/report-tabs.ts | 107 ++++++++++++ .../Client/src/analytics/utm-capability.ts | 3 +- 16 files changed, 482 insertions(+), 254 deletions(-) create mode 100644 src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dialog.styles.ts create mode 100644 src/TheBuilder.WebAnalytics/Client/src/analytics/report-tabs.ts diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-breakdown-grid.element.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-breakdown-grid.element.ts index f1545ff..8ff04a7 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-breakdown-grid.element.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-breakdown-grid.element.ts @@ -10,6 +10,7 @@ import "./breakdown-table.element.js"; import "./event-table.element.js"; import "./flag-card.element.js"; import { stateData, type AsyncState } from "./async-state.js"; +import type { ReportTabGroup } from "./report-tabs.js"; @customElement("web-analytics-breakdown-grid") export class WebAnalyticsBreakdownGridElement extends UmbElementMixin(LitElement) { @@ -33,74 +34,38 @@ export class WebAnalyticsBreakdownGridElement extends UmbElementMixin(LitElement this.dispatchEvent(new CustomEvent(name, { bubbles: true, composed: true, detail })); } - #onTabKeydown(event: KeyboardEvent): void { - if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return; - event.preventDefault(); - const tabs = Array.from((event.currentTarget as HTMLElement).parentElement?.querySelectorAll("[role=tab]") ?? []); - const currentIndex = tabs.indexOf(event.currentTarget as HTMLButtonElement); - const targetIndex = event.key === "Home" ? 0 : event.key === "End" ? tabs.length - 1 - : event.key === "ArrowLeft" ? (currentIndex - 1 + tabs.length) % tabs.length - : (currentIndex + 1) % tabs.length; - tabs[targetIndex]?.click(); - tabs[targetIndex]?.focus(); - } - - #renderTabs(card: Extract) { + #tabsForCard(card: Extract): ReportTabGroup { const selected = card.id === "audience" ? this.audienceDimension : this.utmDimension; const options = card.id === "utm" ? UTM_OPTIONS : card.options; - return html` -
- ${options.map(({ dimension, label }) => html` - - `)} -
- `; + return { + ariaLabel: card.id === "audience" ? "Audience technology" : "UTM parameter", + idPrefix: `${card.id}-card-tab`, + options: options.map(({ dimension, label }) => ({ value: dimension, label })), + selected, + }; } - #renderAcquisitionTabs(utmAvailable: boolean) { + #acquisitionTabs(utmAvailable: boolean): ReportTabGroup { const selected = utmAvailable ? this.acquisitionView : "referrers"; - return html` -
- - ${utmAvailable ? html` - - ` : ""} -
- `; + return { + ariaLabel: "Traffic source", + idPrefix: "acquisition-card-tab", + options: [ + { value: "referrers", label: "Referrers" }, + ...(utmAvailable ? [{ value: "utm", label: "UTM" }] : []), + ], + selected, + }; } - #renderUtmTabs(card: Extract) { - return html` -
- ${card.options.map(({ dimension, label }) => html` - - `)} -
- `; + #utmTabs(card: Extract): ReportTabGroup { + return { + appearance: "secondary", + ariaLabel: "UTM parameter", + idPrefix: "utm-card-tab", + options: card.options.map(({ dimension, label }) => ({ value: dimension, label })), + selected: this.utmDimension, + }; } #renderCard(card: DashboardCard) { @@ -113,6 +78,10 @@ export class WebAnalyticsBreakdownGridElement extends UmbElementMixin(LitElement const unavailable = state?.status === "error" ? state.message : undefined; const planLimited = card.kind === "tabbed-breakdown" && card.planLimited; const linkValues = selected.dimension === "RequestPath" || selected.dimension === "Route"; + const headingTabs = card.kind === "tabbed-breakdown" ? this.#tabsForCard(card) : undefined; + const context = card.kind === "tabbed-breakdown" && card.id === "audience" + ? { kind: "audience" as const, title: "Audience" as const, options: card.options } + : undefined; return html`
@@ -126,13 +95,14 @@ export class WebAnalyticsBreakdownGridElement extends UmbElementMixin(LitElement .filters=${this.filters} .baseUrl=${this.baseUrl} .linkValues=${linkValues} + .headingTabs=${headingTabs} + @heading-tab-change=${(event: CustomEvent<{ value: AnalyticsDimension }>) => card.kind === "tabbed-breakdown" && this.#dispatch(card.id === "audience" ? "audience-change" : "utm-change", { dimension: event.detail.value })} .unavailable=${unavailable}> - ${card.kind === "tabbed-breakdown" ? this.#renderTabs(card) : ""} ${planLimited && unavailable ? html`

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

` : ""}
${!loading && !unavailable && rows.length ? html` - this.#dispatch("view-breakdown", selected)}>View all + this.#dispatch("view-breakdown", { ...selected, context })}>View all ` : !loading && unavailable ? html` this.#dispatch("retry-reports")}>Retry ` : ""} @@ -154,6 +124,13 @@ export class WebAnalyticsBreakdownGridElement extends UmbElementMixin(LitElement const rows = topBreakdownRows(allRows, 10); const total = breakdownMetricTotal(allRows, this.metric); const unavailable = report?.status === "error" ? report.message : undefined; + const context = utmCard ? { + kind: "acquisition" as const, + title: "Traffic sources" as const, + referrer: selectedCardDimension(referrerCard, this.audienceDimension, this.utmDimension), + utmDimension: this.utmDimension, + utmOptions: utmCard.options, + } : undefined; return html` @@ -167,14 +144,15 @@ export class WebAnalyticsBreakdownGridElement extends UmbElementMixin(LitElement .loading=${loading} .filters=${this.filters} .baseUrl=${this.baseUrl} + .headingTabs=${this.#acquisitionTabs(utmAvailable)} + .subheadingTabs=${showingUtm ? this.#utmTabs(utmCard) : undefined} .hasSubheading=${Boolean(showingUtm)} - .unavailable=${unavailable}> - ${this.#renderAcquisitionTabs(utmAvailable)} - ${showingUtm ? this.#renderUtmTabs(utmCard) : ""} - + .unavailable=${unavailable} + @heading-tab-change=${(event: CustomEvent<{ value: AcquisitionView }>) => this.#dispatch("acquisition-change", { view: event.detail.value })} + @subheading-tab-change=${(event: CustomEvent<{ value: UtmDimension }>) => this.#dispatch("utm-change", { dimension: event.detail.value })}>
${!loading && !unavailable && rows.length ? html` - this.#dispatch("view-breakdown", selected)}>View all + this.#dispatch("view-breakdown", { ...selected, context })}>View all ` : !loading && unavailable ? html` this.#dispatch("retry-reports")}>Retry ` : ""} @@ -236,16 +214,6 @@ export class WebAnalyticsBreakdownGridElement extends UmbElementMixin(LitElement .breakdown-footer { align-items: center; background: color-mix(in srgb, var(--uui-color-surface-alt) 9%, var(--uui-color-surface)); border-top: 1px solid var(--uui-color-border); bottom: 0; box-sizing: border-box; display: flex; justify-content: flex-end; left: 0; min-block-size: var(--uui-size-layout-3); padding: 0 var(--uui-size-space-4); position: absolute; right: 0; } .hint { color: var(--uui-color-text-alt); } .breakdown-hint { margin: 0; padding: var(--uui-size-space-3) var(--uui-size-space-5); } - .breakdown-tabs { align-items: stretch; display: flex; margin: calc(-1 * var(--uui-size-space-3)); min-inline-size: 0; overflow-x: auto; overscroll-behavior-inline: contain; scrollbar-width: thin; } - .breakdown-tabs button { appearance: none; background: transparent; border: 0; border-bottom: 2px solid transparent; color: var(--uui-color-text-alt); cursor: pointer; flex: 0 0 auto; font: inherit; font-weight: 500; padding: calc(var(--uui-size-space-3) - 1px) var(--uui-size-space-3); white-space: nowrap; } - .breakdown-tabs button[aria-selected="true"] { border-bottom-color: var(--uui-color-selected); color: var(--uui-color-text); font-weight: 700; } - .breakdown-tabs button:hover { background: var(--uui-color-surface-alt); } - .breakdown-tabs button:focus-visible { outline: 2px solid var(--uui-color-selected); outline-offset: -2px; } - .utm-tabs { align-items: center; display: flex; gap: var(--uui-size-space-1); margin-inline: calc(-1 * var(--uui-size-space-3)); min-inline-size: 0; overflow-x: auto; padding-block: var(--uui-size-space-2); scrollbar-width: thin; } - .utm-tabs button { appearance: none; background: transparent; border: 0; border-radius: var(--uui-border-radius); color: var(--uui-color-text-alt); cursor: pointer; flex: 0 0 auto; font: inherit; padding: var(--uui-size-space-2) var(--uui-size-space-3); } - .utm-tabs button[aria-selected="true"] { background: var(--uui-color-surface-alt); color: var(--uui-color-text); font-weight: 600; } - .utm-tabs button:hover { background: color-mix(in srgb, var(--uui-color-selected) 8%, transparent); color: var(--uui-color-text); } - .utm-tabs button:focus-visible { outline: 2px solid var(--uui-color-selected); outline-offset: -2px; } `]; } 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 1a35b9b..15cd178 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-components.test.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-components.test.ts @@ -157,7 +157,10 @@ describe("analytics presentation components", () => { document.body.append(element); await element.updateComplete; - const browserTab = [...element.shadowRoot?.querySelectorAll("[role=tab]") ?? []] + const table = [...element.shadowRoot?.querySelectorAll("web-analytics-breakdown-table") ?? []] + .find((candidate) => (candidate as WebAnalyticsBreakdownTableElement).headingTabs?.ariaLabel === "Audience technology") as WebAnalyticsBreakdownTableElement; + await table.updateComplete; + const browserTab = [...table.shadowRoot?.querySelectorAll("[role=tab]") ?? []] .find((button) => button.textContent?.trim() === "Browsers"); browserTab?.click(); @@ -281,19 +284,22 @@ describe("analytics presentation components", () => { document.body.append(element); await element.updateComplete; - const topTabs = [...element.shadowRoot?.querySelectorAll(".acquisition-tabs [role=tab]") ?? []]; - expect(topTabs.map((tab) => tab.textContent?.trim())).toEqual(["Referrers", "UTM Parameters"]); + const acquisitionTable = [...element.shadowRoot?.querySelectorAll("web-analytics-breakdown-table") ?? []] + .find((table) => (table as WebAnalyticsBreakdownTableElement).headingTabs?.ariaLabel === "Traffic source") as WebAnalyticsBreakdownTableElement; + await acquisitionTable.updateComplete; + const topTabs = [...acquisitionTable.shadowRoot?.querySelectorAll(".report-tabs.primary [role=tab]") ?? []]; + expect(topTabs.map((tab) => tab.textContent?.trim())).toEqual(["Referrers", "UTM"]); topTabs[1]?.click(); expect((onAcquisitionChange.mock.calls[0][0] as CustomEvent).detail).toEqual({ view: "utm" }); element.acquisitionView = "utm"; await element.updateComplete; - const parameterTabs = [...element.shadowRoot?.querySelectorAll(".utm-tabs [role=tab]") ?? []]; + const updatedAcquisitionTable = [...element.shadowRoot?.querySelectorAll("web-analytics-breakdown-table") ?? []] + .find((table) => (table as WebAnalyticsBreakdownTableElement).headingTabs?.ariaLabel === "Traffic source") as WebAnalyticsBreakdownTableElement; + await updatedAcquisitionTable.updateComplete; + const parameterTabs = [...updatedAcquisitionTable.shadowRoot?.querySelectorAll(".report-tabs.secondary [role=tab]") ?? []]; expect(parameterTabs.map((tab) => tab.textContent?.trim())).toEqual(["Source", "Medium", "Campaign", "Term", "Content"]); - const acquisitionTable = [...element.shadowRoot?.querySelectorAll("web-analytics-breakdown-table") ?? []] - .find((table) => table.querySelector(".utm-tabs")) as WebAnalyticsBreakdownTableElement; - await acquisitionTable.updateComplete; - const headerRows = acquisitionTable.shadowRoot?.querySelectorAll("thead tr"); + const headerRows = updatedAcquisitionTable.shadowRoot?.querySelectorAll("thead tr"); expect(headerRows?.[0].lastElementChild?.textContent?.trim()).toBe("Visitors"); expect(headerRows?.[0].lastElementChild?.hasAttribute("rowspan")).toBe(false); expect(headerRows?.[1].firstElementChild?.getAttribute("colspan")).toBe("2"); @@ -312,8 +318,12 @@ describe("analytics presentation components", () => { document.body.append(element); await element.updateComplete; - expect(element.shadowRoot?.querySelector(".acquisition-tabs")?.textContent?.trim()).toBe("Referrers"); - expect(element.shadowRoot?.querySelector(".utm-tabs")).toBeNull(); + const table = [...element.shadowRoot?.querySelectorAll("web-analytics-breakdown-table") ?? []] + .find((candidate) => (candidate as WebAnalyticsBreakdownTableElement).headingTabs?.ariaLabel === "Traffic source") as WebAnalyticsBreakdownTableElement; + await table.updateComplete; + expect([...table.shadowRoot?.querySelectorAll(".report-tabs.primary [role=tab]") ?? []] + .map((tab) => tab.textContent?.trim())).toEqual(["Referrers"]); + expect(table.shadowRoot?.querySelector(".report-tabs.secondary")).toBeNull(); }); it("shows event setup guidance when no custom events have been tracked", async () => { diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dashboard.controller.test.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dashboard.controller.test.ts index fbb8a8e..08a1005 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 @@ -2,6 +2,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 type { DashboardApi } from "./dashboard-api.js"; +import { UTM_OPTIONS } from "./dashboard-cards.js"; import { dateRangeForPreset } from "./date-range.js"; const fullCapabilities: AnalyticsCapabilities = { @@ -115,6 +116,28 @@ describe("AnalyticsDashboardController", () => { expect(controller.state.expandedBreakdown).toBeUndefined(); }); + it("preserves grouped dialog context and updates its selected UTM dimension", async () => { + const api = dashboardApi(); + const controller = new AnalyticsDashboardController(vi.fn(), api, environment()); + controller.connect(); + await vi.waitFor(() => expect(controller.state.summary.status).toBe("success")); + const context = { + kind: "acquisition" as const, + title: "Traffic sources" as const, + referrer: { dimension: "ReferrerHostname" as const, headline: "Referrers", label: "Referrers" }, + utmDimension: "UtmMedium" as const, + utmOptions: UTM_OPTIONS, + }; + + await controller.openBreakdown("UtmCampaign", "UTM campaigns", { context }); + + expect(controller.state.expandedBreakdown?.context).toMatchObject({ + kind: "acquisition", + title: "Traffic sources", + utmDimension: "UtmCampaign", + }); + }); + it("does not restore event details after the dialog closes during a request", async () => { const api = dashboardApi(); const controller = new AnalyticsDashboardController(vi.fn(), api, environment()); 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 61efa52..d9a91b7 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dashboard.controller.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dashboard.controller.ts @@ -15,7 +15,7 @@ import type { import { dashboardApi, type DashboardApi } from "./dashboard-api.js"; import { activeDocumentRoute } from "./document-route.js"; import { countrySearchValue } from "./country-display.js"; -import { dashboardReportPlan, type AcquisitionView, type DashboardCard, type DashboardReportPlan } from "./dashboard-cards.js"; +import { dashboardReportPlan, type AcquisitionView, type BreakdownDialogContext, type DashboardCard, type DashboardReportPlan } from "./dashboard-cards.js"; import { dateRangeForPreset, type AnalyticsDateRange, type DatePreset } from "./date-range.js"; import { parseDashboardUrlState, @@ -30,12 +30,13 @@ import { loadDashboardBreakdown, loadDashboardBreakdowns, loadDashboardReports, import { visibleEventRows } from "./event-rows.js"; import { reportErrorMessage } from "./report-error.js"; import { DebouncedRequest, RequestCoordinator } from "./request-coordinator.js"; -import { detectUtmCapability, type UtmCapability } from "./utm-capability.js"; +import { detectUtmCapability, isUtmDimension, type UtmCapability } from "./utm-capability.js"; import { errorState, idleState, loadingState, successState, type AsyncState } from "./async-state.js"; import { normalizeDashboardSelection, supportsDimension, unavailableCapabilities } from "./dashboard-capabilities.js"; type ReportScope = { documentId?: string; culture?: string; path?: string }; export type ExpandedBreakdown = { + context?: BreakdownDialogContext; dimension: AnalyticsDimension; headline: string; report: AsyncState; @@ -317,25 +318,33 @@ export class AnalyticsDashboardController { clearFilters(): void { this.#set({ filters: [] }); this.#syncUrlState(); void this.loadReports(); } - async openBreakdown(dimension: AnalyticsDimension, headline: string, search = "", debounce = false): Promise { + async openBreakdown( + dimension: AnalyticsDimension, + headline: string, + options: { context?: BreakdownDialogContext; search?: string; debounce?: boolean } = {}, + ): Promise { if (!supportsDimension(this.#capabilities(), dimension)) return; const connection = this.state.connection; if (!connection) return; + const search = options.search ?? ""; + const context = options.context?.kind === "acquisition" && isUtmDimension(dimension) + ? { ...options.context, utmDimension: dimension } + : options.context; const previous = this.state.expandedBreakdown?.dimension === dimension ? this.state.expandedBreakdown.report : undefined; - this.#set({ expandedBreakdown: { dimension, headline, report: loadingState(previous) } }); + this.#set({ expandedBreakdown: { context, dimension, headline, report: loadingState(previous) } }); const run = (signal: AbortSignal) => this.#api.breakdown({ path: { dimension }, query: { ...this.#reportQuery(connection, this.#visitFilterQuery()), limit: 100, search: search || undefined }, signal, }); - const result = await (debounce ? this.#expandedRequest.schedule(run) : this.#expandedRequest.run(run)); + const result = await (options.debounce ? this.#expandedRequest.schedule(run) : this.#expandedRequest.run(run)); if (result.status === "cancelled" || result.status === "stale" || this.state.expandedBreakdown?.dimension !== dimension) return; if (result.status === "error") { - this.#set({ expandedBreakdown: { dimension, headline, report: errorState(reportErrorMessage(result.error), previous) } }); + this.#set({ expandedBreakdown: { context, dimension, headline, report: errorState(reportErrorMessage(result.error), previous) } }); return; } const { data, error, response } = result.value; - this.#set({ expandedBreakdown: { dimension, headline, report: error + this.#set({ expandedBreakdown: { context, dimension, headline, report: error ? errorState(apiErrorMessage(error, response?.status ?? 0), previous) : successState(data?.rows ?? []) } }); } @@ -344,7 +353,11 @@ export class AnalyticsDashboardController { const expanded = this.state.expandedBreakdown; if (!expanded) return; const value = expanded.dimension === "Country" ? countrySearchValue(search, this.#environment.languages) : search; - void this.openBreakdown(expanded.dimension, expanded.headline, value, true); + void this.openBreakdown(expanded.dimension, expanded.headline, { + context: expanded.context, + search: value, + debounce: true, + }); } closeBreakdown(): void { this.#expandedRequest.cancel(); this.#set({ expandedBreakdown: undefined }); } 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 65f7f37..f52465f 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dashboard.element.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dashboard.element.ts @@ -4,7 +4,7 @@ import type { AnalyticsDimension } from "../api/types.gen.js"; import { countryDisplayName, normalizeCountryCode } from "./country-display.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"; +import type { AcquisitionView, BreakdownDialogContext } from "./dashboard-cards.js"; import { AnalyticsDashboardController } from "./analytics-dashboard.controller.js"; import { stateData, type AsyncState } from "./async-state.js"; import { analyticsDashboardStyles } from "./analytics-dashboard.styles.js"; @@ -131,7 +131,7 @@ export class WebAnalyticsDashboardElement extends UmbElementMixin(LitElement) { .supportsEventDetails=${capabilities?.eventDetails ?? false} .supportsGlobalEventFiltering=${capabilities?.globalEventFiltering ?? false} .supportsFlags=${capabilities?.flags ?? false} - @view-breakdown=${(event: CustomEvent<{ dimension: AnalyticsDimension; headline: string }>) => this.#controller.openBreakdown(event.detail.dimension, event.detail.headline)} + @view-breakdown=${(event: CustomEvent<{ context?: BreakdownDialogContext; dimension: AnalyticsDimension; headline: string }>) => this.#controller.openBreakdown(event.detail.dimension, event.detail.headline, { context: event.detail.context })} @view-events=${() => this.#controller.openEvents()} @select-event=${(event: CustomEvent<{ eventName: string }>) => this.#controller.selectEvent(event.detail.eventName)} @select-flag=${(event: CustomEvent<{ flagKey: string }>) => this.#controller.selectFlag(event.detail.flagKey)} @@ -143,6 +143,7 @@ export class WebAnalyticsDashboardElement extends UmbElementMixin(LitElement) { ${expanded ? html` ) => this.#controller.searchBreakdown(event.detail.search)} - @breakdown-dimension-change=${(event: CustomEvent<{ dimension: AnalyticsDimension; headline: string }>) => this.#controller.openBreakdown(event.detail.dimension, event.detail.headline)} + @breakdown-dimension-change=${(event: CustomEvent<{ dimension: AnalyticsDimension; headline: string }>) => this.#controller.openBreakdown(event.detail.dimension, event.detail.headline, { context: expanded.context })} @close-breakdown=${() => this.#controller.closeBreakdown()}> ` : ""} ${expandedEvents ? html` diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dialog.styles.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dialog.styles.ts new file mode 100644 index 0000000..8376f3d --- /dev/null +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dialog.styles.ts @@ -0,0 +1,23 @@ +import { css } from "@umbraco-cms/backoffice/external/lit"; + +export const analyticsDialogStyles = css` + dialog { + border: 0; + border-radius: var(--uui-border-radius); + box-shadow: var(--uui-shadow-depth-5); + box-sizing: border-box; + margin: auto; + max-height: min(52rem, calc(100dvh - 2 * var(--uui-size-layout-1))); + max-width: min(var(--analytics-dialog-max-width, 50rem), calc(100vw - 2 * var(--uui-size-layout-1))); + padding: 0; + width: 100%; + } + dialog::backdrop { background: color-mix(in srgb, var(--uui-color-text) 45%, transparent); } + uui-dialog-layout { + --uui-size-10: var(--uui-size-space-5); + --uui-size-14: var(--uui-size-space-6); + } + @media (max-width: 600px) { + dialog { max-height: 100dvh; max-width: 100vw; } + } +`; diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/breakdown-dialog.element.test.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/breakdown-dialog.element.test.ts index 961f9fd..76a0f45 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/breakdown-dialog.element.test.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/breakdown-dialog.element.test.ts @@ -12,29 +12,41 @@ vi.mock("@umbraco-cms/backoffice/style", () => ({ UmbTextStyles: [] })); import type { WebAnalyticsBreakdownDialogElement } from "./breakdown-dialog.element.js"; import type { WebAnalyticsBreakdownTableElement } from "./breakdown-table.element.js"; +import { UTM_OPTIONS } from "./dashboard-cards.js"; import "./breakdown-dialog.element.js"; beforeEach(() => { HTMLDialogElement.prototype.showModal = vi.fn(); }); afterEach(() => { document.body.replaceChildren(); }); describe("breakdown dialog", () => { - it("keeps every UTM parameter available while showing the selected report", async () => { + it("keeps traffic-source and UTM tabs inside the expanded table", async () => { const dialog = document.createElement("web-analytics-breakdown-dialog") as WebAnalyticsBreakdownDialogElement; dialog.headline = "UTM media"; dialog.dimension = "UtmMedium"; + dialog.context = { + kind: "acquisition", + title: "Traffic sources", + referrer: { dimension: "ReferrerHostname", headline: "Referrers", label: "Referrers" }, + utmDimension: "UtmMedium", + utmOptions: UTM_OPTIONS, + }; dialog.rows = [{ value: "email", visitors: 8, pageViews: 11 }]; const onDimensionChange = vi.fn(); dialog.addEventListener("breakdown-dimension-change", onDimensionChange); document.body.append(dialog); await dialog.updateComplete; - expect(dialog.shadowRoot?.querySelector("dialog")?.getAttribute("aria-label")).toBe("UTM"); - expect(dialog.shadowRoot?.querySelector("uui-dialog-layout")?.getAttribute("headline")).toBe("UTM"); - const tabs = [...dialog.shadowRoot?.querySelectorAll('.utm-tabs [role="tab"]') ?? []]; + expect(dialog.shadowRoot?.querySelector("dialog")?.getAttribute("aria-label")).toBe("Traffic sources"); + expect(dialog.shadowRoot?.querySelector("uui-dialog-layout")?.getAttribute("headline")).toBe("Traffic sources"); + const table = dialog.shadowRoot?.querySelector("web-analytics-breakdown-table")!; + await table.updateComplete; + expect([...table.shadowRoot?.querySelectorAll('.report-tabs.primary [role="tab"]') ?? []] + .map((tab) => tab.textContent?.trim())).toEqual(["Referrers", "UTM"]); + const tabs = [...table.shadowRoot?.querySelectorAll('.report-tabs.secondary [role="tab"]') ?? []]; expect(tabs.map((tab) => tab.textContent?.trim())).toEqual(["Source", "Medium", "Campaign", "Term", "Content"]); expect(tabs[1]?.getAttribute("aria-selected")).toBe("true"); expect(dialog.shadowRoot?.querySelector("uui-input")?.getAttribute("label")).toBe("Search UTM media"); - expect(dialog.shadowRoot?.querySelector("web-analytics-breakdown-table")?.headline).toBe("UTM media"); + expect(table.rowLabel).toBe("Medium"); tabs[2]?.click(); @@ -44,7 +56,7 @@ describe("breakdown dialog", () => { }); }); - it("does not add UTM navigation to other breakdowns", async () => { + it("uses a singular column label without adding tabs to a plain breakdown", async () => { const dialog = document.createElement("web-analytics-breakdown-dialog") as WebAnalyticsBreakdownDialogElement; dialog.headline = "Countries"; dialog.dimension = "Country"; @@ -52,6 +64,42 @@ describe("breakdown dialog", () => { await dialog.updateComplete; expect(dialog.shadowRoot?.querySelector("uui-dialog-layout")?.getAttribute("headline")).toBe("Countries"); - expect(dialog.shadowRoot?.querySelector(".utm-tabs")).toBeNull(); + const table = dialog.shadowRoot?.querySelector("web-analytics-breakdown-table")!; + await table.updateComplete; + expect(table.rowLabel).toBe("Country"); + expect(table.shadowRoot?.querySelector(".report-tabs")).toBeNull(); + }); + + it("keeps audience tabs inside the table header", async () => { + const dialog = document.createElement("web-analytics-breakdown-dialog") as WebAnalyticsBreakdownDialogElement; + dialog.headline = "Devices"; + dialog.dimension = "DeviceType"; + dialog.context = { + kind: "audience", + title: "Audience", + options: [ + { dimension: "DeviceType", headline: "Devices", label: "Devices" }, + { dimension: "BrowserName", headline: "Browsers", label: "Browsers" }, + ], + }; + const onDimensionChange = vi.fn(); + dialog.addEventListener("breakdown-dimension-change", onDimensionChange); + document.body.append(dialog); + await dialog.updateComplete; + const table = dialog.shadowRoot?.querySelector("web-analytics-breakdown-table")!; + await table.updateComplete; + + expect(dialog.shadowRoot?.querySelector("uui-dialog-layout")?.getAttribute("headline")).toBe("Audience"); + const tabs = [...table.shadowRoot?.querySelectorAll('.report-tabs.primary [role="tab"]') ?? []]; + expect(tabs.map((tab) => tab.textContent?.trim())).toEqual(["Devices", "Browsers"]); + expect(tabs[0]?.getAttribute("aria-controls")).toBe("breakdown-report-panel"); + expect(table.shadowRoot?.querySelector("table")?.getAttribute("aria-labelledby")).toBe("expanded-audience-tab-0"); + tabs[0]?.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true })); + await Promise.resolve(); + expect((onDimensionChange.mock.calls[0]?.[0] as CustomEvent).detail).toEqual({ + dimension: "BrowserName", + headline: "Browsers", + }); + expect(table.shadowRoot?.activeElement).toBe(tabs[1]); }); }); diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/breakdown-dialog.element.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/breakdown-dialog.element.ts index d9b7682..1c028ec 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/breakdown-dialog.element.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/breakdown-dialog.element.ts @@ -10,10 +10,12 @@ import { UmbElementMixin } from "@umbraco-cms/backoffice/element-api"; import { UmbTextStyles } from "@umbraco-cms/backoffice/style"; import type { UUIInputElement } from "@umbraco-cms/backoffice/external/uui"; import type { AnalyticsBreakdownRow, AnalyticsDimension } from "../api/types.gen.js"; -import { breakdownMetricTotal, type TrafficMetric } from "./breakdown-rows.js"; -import { UTM_OPTIONS } from "./dashboard-cards.js"; +import { analyticsDialogStyles } from "./analytics-dialog.styles.js"; +import { breakdownDimensionLabel, breakdownMetricTotal, type TrafficMetric } from "./breakdown-rows.js"; +import type { BreakdownDialogContext, DimensionOption } from "./dashboard-cards.js"; import type { AnalyticsFilter } from "./dashboard-url-state.js"; import { isUtmDimension } from "./utm-capability.js"; +import type { ReportTabGroup } from "./report-tabs.js"; import "./breakdown-table.element.js"; @customElement("web-analytics-breakdown-dialog") @@ -27,6 +29,7 @@ export class WebAnalyticsBreakdownDialogElement extends UmbElementMixin(LitEleme @property({ type: Boolean }) linkValues = false; @property({ attribute: false }) rows: AnalyticsBreakdownRow[] = []; @property({ attribute: false }) filters: AnalyticsFilter[] = []; + @property({ attribute: false }) context?: BreakdownDialogContext; @state() private _search = ""; protected firstUpdated(): void { @@ -55,53 +58,79 @@ export class WebAnalyticsBreakdownDialogElement extends UmbElementMixin(LitEleme })); } - #onTabKeydown(event: KeyboardEvent): void { - if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return; - event.preventDefault(); - const tabs = Array.from((event.currentTarget as HTMLElement).parentElement?.querySelectorAll("[role=tab]") ?? []); - const currentIndex = tabs.indexOf(event.currentTarget as HTMLButtonElement); - const targetIndex = event.key === "Home" ? 0 : event.key === "End" ? tabs.length - 1 - : event.key === "ArrowLeft" ? (currentIndex - 1 + tabs.length) % tabs.length - : (currentIndex + 1) % tabs.length; - tabs[targetIndex]?.click(); - tabs[targetIndex]?.focus(); - } - - #selectUtmDimension(dimension: AnalyticsDimension, headline: string): void { - if (dimension === this.dimension) return; + #selectDimension(option?: DimensionOption): void { + if (!option || option.dimension === this.dimension) return; this._search = ""; this.dispatchEvent(new CustomEvent("breakdown-dimension-change", { bubbles: true, composed: true, - detail: { dimension, headline }, + detail: { dimension: option.dimension, headline: option.headline }, })); } + #headingTabs(): ReportTabGroup | undefined { + if (this.context?.kind === "audience") { + return { + ariaLabel: "Audience technology", + idPrefix: "expanded-audience-tab", + options: this.context.options.map(({ dimension, label }) => ({ value: dimension, label })), + selected: this.dimension ?? this.context.options[0]?.dimension ?? "DeviceType", + }; + } + if (this.context?.kind === "acquisition") { + return { + ariaLabel: "Traffic source", + idPrefix: "expanded-acquisition-tab", + options: [ + { value: "referrers", label: "Referrers" }, + { value: "utm", label: "UTM" }, + ], + selected: this.dimension && isUtmDimension(this.dimension) ? "utm" : "referrers", + }; + } + return undefined; + } + + #subheadingTabs(): ReportTabGroup | undefined { + if (this.context?.kind !== "acquisition" || !this.dimension || !isUtmDimension(this.dimension)) return undefined; + return { + appearance: "secondary", + ariaLabel: "UTM parameter", + idPrefix: "expanded-utm-tab", + options: this.context.utmOptions.map(({ dimension, label }) => ({ value: dimension, label })), + selected: this.dimension, + }; + } + + #selectHeading(value: string): void { + if (this.context?.kind === "audience") { + this.#selectDimension(this.context.options.find(({ dimension }) => dimension === value)); + return; + } + if (this.context?.kind === "acquisition") { + const context = this.context; + this.#selectDimension(value === "referrers" + ? context.referrer + : context.utmOptions.find(({ dimension }) => dimension === context.utmDimension)); + } + } + + #selectSubheading(value: string): void { + if (this.context?.kind === "acquisition") { + this.#selectDimension(this.context.utmOptions.find(({ dimension }) => dimension === value)); + } + } + render() { - const utmOption = this.dimension && isUtmDimension(this.dimension) - ? UTM_OPTIONS.find(({ dimension }) => dimension === this.dimension) - : undefined; - const dialogHeadline = utmOption ? "UTM" : this.headline; - const tableHeadline = utmOption?.headline ?? this.headline; + const dialogHeadline = this.context?.title ?? this.headline; + const headingTabs = this.#headingTabs(); + const subheadingTabs = this.#subheadingTabs(); return html` - ${utmOption ? html` -
- ${UTM_OPTIONS.map(({ dimension, label, headline }) => html` - - `)} -
- ` : ""}
- ${!this.loading && this.unavailable ? html`

${this.unavailable}

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

Try a different search.

` - : ""} - ${(this.loading || (!this.unavailable && (!this._search || this.rows.length > 0))) ? html` - - ` : ""} + ) => this.#selectHeading(event.detail.value)} + @subheading-tab-change=${(event: CustomEvent<{ value: string }>) => this.#selectSubheading(event.detail.value)}>
Close
@@ -132,34 +163,12 @@ export class WebAnalyticsBreakdownDialogElement extends UmbElementMixin(LitEleme `; } - static styles = [UmbTextStyles, css` - dialog { - border: 0; - border-radius: var(--uui-border-radius); - box-shadow: var(--uui-shadow-depth-5); - box-sizing: border-box; - margin: auto; - max-height: min(52rem, calc(100dvh - 2 * var(--uui-size-layout-1))); - max-width: min(50rem, calc(100vw - 2 * var(--uui-size-layout-1))); - padding: 0; - width: 100%; - } - dialog::backdrop { background: rgb(0 0 0 / 45%); } - uui-dialog-layout { - --uui-size-10: var(--uui-size-space-5); - --uui-size-14: var(--uui-size-space-6); - } + static styles = [UmbTextStyles, analyticsDialogStyles, 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); } - .utm-tabs { align-items: center; display: flex; gap: var(--uui-size-space-1); margin-block-end: var(--uui-size-space-3); margin-inline: calc(-1 * var(--uui-size-space-3)); min-inline-size: 0; overflow-x: auto; padding-block: var(--uui-size-space-2); scrollbar-width: thin; } - .utm-tabs button { appearance: none; background: transparent; border: 0; border-radius: var(--uui-border-radius); color: var(--uui-color-text-alt); cursor: pointer; flex: 0 0 auto; font: inherit; padding: var(--uui-size-space-2) var(--uui-size-space-3); } - .utm-tabs button[aria-selected="true"] { background: var(--uui-color-surface-alt); color: var(--uui-color-text); font-weight: 600; } - .utm-tabs button:hover { background: color-mix(in srgb, var(--uui-color-selected) 8%, transparent); color: var(--uui-color-text); } - .utm-tabs button:focus-visible { outline: 2px solid var(--uui-color-selected); outline-offset: -2px; } - .results { block-size: min(30rem, 52dvh); margin-top: var(--uui-size-space-4); overflow: auto; scrollbar-gutter: stable; } + .results { max-block-size: min(30rem, 52dvh); margin-top: var(--uui-size-space-4); overflow: auto; scrollbar-gutter: stable; } @media (max-width: 600px) { - dialog { max-height: 100dvh; max-width: 100vw; } - .results { block-size: 48dvh; } + .results { max-block-size: 48dvh; } } `]; } diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/breakdown-rows.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/breakdown-rows.ts index 084f051..fb242e7 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/breakdown-rows.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/breakdown-rows.ts @@ -3,6 +3,22 @@ import type { AnalyticsBreakdownRow, AnalyticsDimension } from "../api/types.gen const OTHERS_LABEL = "others"; const UNKNOWN_LABEL = "unknown"; +const DIMENSION_LABELS: Partial> = { + RequestPath: "Page", + Route: "Page", + ReferrerHostname: "Referrer", + Referrer: "Referrer", + Country: "Country", + DeviceType: "Device", + BrowserName: "Browser", + OsName: "Operating system", + UtmSource: "Source", + UtmMedium: "Medium", + UtmCampaign: "Campaign", + UtmTerm: "Term", + UtmContent: "Content", +}; + export type TrafficMetric = "visitors" | "pageViews"; const PERCENTAGE_DIMENSIONS = new Set([ @@ -57,6 +73,10 @@ export function breakdownDisplayValue(value: string, dimension?: AnalyticsDimens : trimmed; } +export function breakdownDimensionLabel(dimension?: AnalyticsDimension): string { + return dimension ? DIMENSION_LABELS[dimension] ?? "Value" : "Value"; +} + export function topBreakdownRows( rows: AnalyticsBreakdownRow[], limit = 10, 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 a5e1d69..eb75990 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/breakdown-table.element.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/breakdown-table.element.ts @@ -1,4 +1,4 @@ -import { LitElement, css, customElement, html, property } from "@umbraco-cms/backoffice/external/lit"; +import { LitElement, css, customElement, html, nothing, property } from "@umbraco-cms/backoffice/external/lit"; import { UmbElementMixin } from "@umbraco-cms/backoffice/element-api"; import type { AnalyticsBreakdownRow, AnalyticsDimension } from "../api/types.gen.js"; import { @@ -15,10 +15,15 @@ import { import { countryDisplayName, countryFlagUrl, normalizeCountryCode } from "./country-display.js"; import type { AnalyticsFilter } from "./dashboard-url-state.js"; import { googleFaviconUrl } from "./favicon.js"; +import { renderReportTabs, reportTabsStyles, selectedReportTabId, type ReportTabGroup } from "./report-tabs.js"; + +const BREAKDOWN_PANEL_ID = "breakdown-report-panel"; @customElement("web-analytics-breakdown-table") export class WebAnalyticsBreakdownTableElement extends UmbElementMixin(LitElement) { @property() headline = "Breakdown"; + @property() rowLabel?: string; + @property() emptyMessage = "No traffic was recorded for this breakdown."; @property() unavailable?: string; @property() baseUrl?: string; @property() dimension?: AnalyticsDimension; @@ -30,12 +35,14 @@ export class WebAnalyticsBreakdownTableElement extends UmbElementMixin(LitElemen @property({ type: Number }) total = 0; @property({ attribute: false }) rows: AnalyticsBreakdownRow[] = []; @property({ attribute: false }) filters: AnalyticsFilter[] = []; + @property({ attribute: false }) headingTabs?: ReportTabGroup; + @property({ attribute: false }) subheadingTabs?: ReportTabGroup; render() { if (this.loading) { return html` Loading ${this.headline} - + ${this.#renderHeading()} ${Array.from({ length: this.skeletonRows }, () => html` @@ -47,17 +54,22 @@ export class WebAnalyticsBreakdownTableElement extends UmbElementMixin(LitElemen `; } - if (this.unavailable) return html`

${this.unavailable}

`; const rows = visibleBreakdownRows(this.rows); - if (rows.length === 0) return html`

No traffic was recorded for this breakdown.

`; const maximum = Math.max(...rows.map((row) => breakdownMetricValue(row, this.metric)), 1); const percentageDimension = isPercentageDimension(this.dimension); + const message = this.unavailable ?? (rows.length === 0 ? this.emptyMessage : undefined); + const labelledBy = [this.headingTabs, this.subheadingTabs] + .map((group) => group ? selectedReportTabId(group) : undefined) + .filter(Boolean) + .join(" "); return html` - +
${this.#renderHeading()} - ${rows.map((row, index) => { + ${message ? html` + + ` : rows.map((row, index) => { const isReferrer = this.dimension === "ReferrerHostname" || this.dimension === "Referrer"; const href = isReferrer ? referrerExternalHref(row.value) @@ -130,16 +142,30 @@ export class WebAnalyticsBreakdownTableElement extends UmbElementMixin(LitElemen return html` - + - ${this.hasSubheading ? html`` : ""} + ${this.hasSubheading || this.subheadingTabs ? html` + + ` : ""} `; } - static styles = css` - :host { display: block; overflow-x: clip; overflow-y: visible; } + static styles = [reportTabsStyles, css` + :host { display: block; overflow-x: auto; overflow-y: visible; scrollbar-width: thin; } table { --bar-inset: var(--uui-size-space-3); --metric-column-width: 8.5rem; @@ -170,9 +196,9 @@ export class WebAnalyticsBreakdownTableElement extends UmbElementMixin(LitElemen .row-label a { align-items: center; color: inherit; display: flex; gap: var(--uui-size-space-1); min-inline-size: 0; text-decoration: none; } .link-label { min-inline-size: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .row-label a:hover .link-label, .row-label a:focus-visible .link-label { text-decoration: underline; text-underline-offset: 0.12em; } - .external-indicator { color: var(--uui-color-text-alt); flex: 0 0 auto; font-size: 0.75rem; opacity: 0; transition: opacity 150ms ease-out; } + .external-indicator { color: var(--uui-color-text-alt); flex: 0 0 auto; font-size: var(--uui-type-small-size); opacity: 0; transition: opacity 150ms ease-out; } .row-label a:hover .external-indicator, .row-label a:focus-visible .external-indicator { opacity: 1; } - .country-flag { border-radius: 2px; flex: 0 0 auto; object-fit: cover; } + .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; } .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; } @@ -217,7 +243,7 @@ export class WebAnalyticsBreakdownTableElement extends UmbElementMixin(LitElemen bottom: 100%; top: auto; } - .percentage-tooltip strong { font-size: 1rem; } + .percentage-tooltip strong { font-size: var(--uui-type-default-size); } .percentage-tooltip span { color: color-mix(in srgb, var(--uui-color-surface) 70%, transparent); } .percentage-value:hover .percentage-tooltip, .percentage-value:focus .percentage-tooltip { opacity: 1; transform: translateY(0); visibility: visible; } @@ -249,10 +275,11 @@ export class WebAnalyticsBreakdownTableElement extends UmbElementMixin(LitElemen .skeleton-table tbody tr:nth-child(3n + 2) .skeleton-line { width: 56%; } .skeleton-table tbody tr:nth-child(3n) .skeleton-line { width: 84%; } .visually-hidden { clip: rect(0 0 0 0); clip-path: inset(50%); height: 1px; overflow: hidden; position: absolute; white-space: nowrap; width: 1px; } - .message { color: var(--uui-color-text-alt); padding: var(--uui-size-space-5); } + .message-row td { color: var(--uui-color-text-alt); padding: var(--uui-size-space-5); text-align: left; } + .message-row p { margin: 0; } @media (hover: none) { .filter-action { opacity: 1; } .external-indicator { opacity: 0.65; } } @media (prefers-reduced-motion: reduce) { .external-indicator, .percentage-tooltip { transition: none; } } - `; + `]; } declare global { diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/dashboard-cards.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/dashboard-cards.ts index 5ded80d..80e65ed 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/dashboard-cards.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/dashboard-cards.ts @@ -2,12 +2,26 @@ import type { AnalyticsDimension } from "../api/types.gen.js"; import type { AudienceDimension, UtmDimension } from "./dashboard-url-state.js"; import type { UtmCapability } from "./utm-capability.js"; -type DimensionOption = { +export type DimensionOption = { dimension: TDimension; headline: string; label: string; }; +export type BreakdownDialogContext = + | { + kind: "audience"; + title: "Audience"; + options: ReadonlyArray; + } + | { + kind: "acquisition"; + title: "Traffic sources"; + referrer: DimensionOption; + utmDimension: UtmDimension; + utmOptions: ReadonlyArray>; + }; + export type DashboardCard = | { kind: "breakdown"; 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 8b394b3..82bf0cc 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/event-details-dialog.element.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/event-details-dialog.element.ts @@ -3,6 +3,8 @@ import { UmbElementMixin } from "@umbraco-cms/backoffice/element-api"; import { UmbTextStyles } from "@umbraco-cms/backoffice/style"; import type { UUIInputElement } from "@umbraco-cms/backoffice/external/uui"; import type { AnalyticsEventDetails, AnalyticsEventProperty } from "../api/types.gen.js"; +import { analyticsDialogStyles } from "./analytics-dialog.styles.js"; +import { renderReportTabs, reportTabsStyles } from "./report-tabs.js"; @customElement("web-analytics-event-details-dialog") export class WebAnalyticsEventDetailsDialogElement extends UmbElementMixin(LitElement) { @@ -53,23 +55,6 @@ export class WebAnalyticsEventDetailsDialogElement extends UmbElementMixin(LitEl this.#notifySearch(propertyName, ""); } - #onTabKeydown(event: KeyboardEvent): void { - const properties = this.details?.properties ?? []; - if (!properties.length || !["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return; - event.preventDefault(); - const activeIndex = Math.max(0, properties.findIndex((property) => property.name === this.#activeProperty()?.name)); - const nextIndex = event.key === "Home" - ? 0 - : event.key === "End" - ? properties.length - 1 - : (activeIndex + (event.key === "ArrowLeft" ? -1 : 1) + properties.length) % properties.length; - this._propertyName = properties[nextIndex].name; - this.#clearSearch(properties[nextIndex].name); - this.updateComplete.then(() => this.shadowRoot - ?.querySelector(`[data-property-index="${nextIndex}"]`) - ?.focus()); - } - #toggleFilter(property: string, value: string): void { this.#clearSearch(property); this.dispatchEvent(new CustomEvent("toggle-event-property-filter", { @@ -80,22 +65,12 @@ export class WebAnalyticsEventDetailsDialogElement extends UmbElementMixin(LitEl } #renderPropertyTabs(activeProperty: AnalyticsEventProperty) { - return html` -
- ${this.details?.properties.map((property, index) => html` - - `)} -
- `; + return renderReportTabs({ + ariaLabel: "Event properties", + idPrefix: "event-property-tab", + options: this.details?.properties.map(({ name }) => ({ value: name, label: name })) ?? [], + selected: activeProperty.name, + }, (propertyName) => this.#selectProperty(propertyName), "event-property-panel"); } #renderProperty(property: AnalyticsEventProperty) { @@ -105,7 +80,7 @@ export class WebAnalyticsEventDetailsDialogElement extends UmbElementMixin(LitEl const values = searchIsCurrent ? this.searchedProperty?.values ?? [] : search ? [] : property.values; const maximum = Math.max(...values.map((value) => value.count), 1); return html` -
+
${property.values.length ? html` Loading events` : ""}
${this.headline}

${message}

${this.headline}${this.headingTabs + ? renderReportTabs(this.headingTabs, (value) => this.dispatchEvent(new CustomEvent("heading-tab-change", { + bubbles: true, + composed: true, + detail: { value }, + })), BREAKDOWN_PANEL_ID) + : this.rowLabel ?? this.headline} ${this.#metricLabel()}
${this.subheadingTabs + ? renderReportTabs(this.subheadingTabs, (value) => this.dispatchEvent(new CustomEvent("subheading-tab-change", { + bubbles: true, + composed: true, + detail: { value }, + })), BREAKDOWN_PANEL_ID) + : ""}
- + ${this.loading ? Array.from({ length: this.skeletonRows }, () => html` diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/report-tabs.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/report-tabs.ts new file mode 100644 index 0000000..0328c4f --- /dev/null +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/report-tabs.ts @@ -0,0 +1,107 @@ +import { css, html, nothing } from "@umbraco-cms/backoffice/external/lit"; + +export type ReportTabOption = { + value: TValue; + label: string; +}; + +export type ReportTabGroup = { + ariaLabel: string; + idPrefix: string; + options: ReadonlyArray>; + selected: TValue; + appearance?: "primary" | "secondary"; +}; + +export function selectedReportTabId(group: ReportTabGroup): string | undefined { + const index = group.options.findIndex(({ value }) => value === group.selected); + return index >= 0 ? `${group.idPrefix}-${index}` : undefined; +} + +export function renderReportTabs( + group: ReportTabGroup, + onSelect: (value: TValue) => void, + panelId?: string, +) { + const select = (value: TValue): void => { + if (value !== group.selected) onSelect(value); + }; + const onKeydown = (event: KeyboardEvent): void => { + if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return; + event.preventDefault(); + const tabs = Array.from((event.currentTarget as HTMLElement).parentElement?.querySelectorAll("[role=tab]") ?? []); + const currentIndex = tabs.indexOf(event.currentTarget as HTMLButtonElement); + const targetIndex = event.key === "Home" ? 0 : event.key === "End" ? tabs.length - 1 + : event.key === "ArrowLeft" ? (currentIndex - 1 + tabs.length) % tabs.length + : (currentIndex + 1) % tabs.length; + const option = group.options[targetIndex]; + if (!option) return; + select(option.value); + queueMicrotask(() => tabs[targetIndex]?.focus()); + }; + + return html` +
+ ${group.options.map(({ value, label }, index) => html` + + `)} +
+ `; +} + +export const reportTabsStyles = css` + .report-tabs { + align-items: stretch; + display: flex; + margin: var(--analytics-report-tabs-margin, calc(-1 * var(--uui-size-space-3))); + min-inline-size: 0; + overflow-x: auto; + overscroll-behavior-inline: contain; + scrollbar-width: thin; + } + .report-tabs button { + appearance: none; + background: transparent; + border: 0; + border-bottom: 3px solid transparent; + color: var(--uui-color-text-alt); + cursor: pointer; + flex: 0 0 auto; + font: inherit; + min-block-size: 2.75rem; + padding: var(--uui-size-space-3) var(--uui-size-space-4); + white-space: nowrap; + } + .report-tabs button:hover { background: color-mix(in srgb, var(--uui-color-selected) 7%, transparent); color: var(--uui-color-text); } + .report-tabs button[aria-selected="true"] { border-bottom-color: var(--uui-color-selected); color: var(--uui-color-text); font-weight: 700; } + .report-tabs button:focus-visible { outline: 2px solid var(--uui-color-selected); outline-offset: -3px; } + .report-tabs.secondary { + align-items: center; + gap: var(--uui-size-space-1); + margin: var(--analytics-report-tabs-secondary-margin, 0 calc(-1 * var(--uui-size-space-3))); + padding-block: var(--uui-size-space-2); + } + .report-tabs.secondary button { + border-bottom: 0; + border-radius: var(--uui-border-radius); + min-block-size: 2.5rem; + padding: var(--uui-size-space-2) var(--uui-size-space-3); + } + .report-tabs.secondary button[aria-selected="true"] { background: var(--uui-color-surface-alt); } + @media (pointer: coarse) { + .report-tabs.secondary button { min-block-size: 2.75rem; } + } + @media (forced-colors: active) { + .report-tabs button[aria-selected="true"] { border-bottom-color: Highlight; } + .report-tabs.secondary button[aria-selected="true"] { outline: 2px solid Highlight; } + } +`; diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/utm-capability.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/utm-capability.ts index e5e418a..38880ef 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/utm-capability.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/utm-capability.ts @@ -1,4 +1,5 @@ import type { AnalyticsDimension } from "../api/types.gen.js"; +import type { UtmDimension } from "./dashboard-url-state.js"; export type UtmCapability = "unknown" | "available" | "unavailable"; @@ -10,7 +11,7 @@ const UTM_DIMENSIONS: ReadonlySet = new Set([ "UtmContent", ]); -export function isUtmDimension(dimension: AnalyticsDimension): boolean { +export function isUtmDimension(dimension: AnalyticsDimension): dimension is UtmDimension { return UTM_DIMENSIONS.has(dimension); } From 9e9a6c9f130b41032bab6306fcfef37d44fa1e99 Mon Sep 17 00:00:00 2001 From: Daniel Schmidt Date: Wed, 22 Jul 2026 13:11:46 +0200 Subject: [PATCH 10/12] fix: stabilize analytics dialog height --- .../Client/src/analytics/analytics-dialog.styles.ts | 5 +++++ .../Client/src/analytics/breakdown-dialog.element.ts | 7 ++----- .../Client/src/analytics/event-details-dialog.element.ts | 7 ++----- .../Client/src/analytics/event-dialog.element.ts | 4 ++-- 4 files changed, 11 insertions(+), 12 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 8376f3d..1e1d95c 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dialog.styles.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dialog.styles.ts @@ -17,7 +17,12 @@ export const analyticsDialogStyles = css` --uui-size-10: var(--uui-size-space-5); --uui-size-14: var(--uui-size-space-6); } + .analytics-dialog-body { + block-size: var(--analytics-dialog-body-height, min(30rem, 52dvh)); + min-block-size: 0; + } @media (max-width: 600px) { dialog { max-height: 100dvh; max-width: 100vw; } + .analytics-dialog-body { block-size: 48dvh; } } `; diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/breakdown-dialog.element.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/breakdown-dialog.element.ts index 1c028ec..030801a 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/breakdown-dialog.element.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/breakdown-dialog.element.ts @@ -137,7 +137,7 @@ export class WebAnalyticsBreakdownDialogElement extends UmbElementMixin(LitEleme @input=${this.#onSearch}> -
+
-
+
${this.details ? html` ${this.propertiesEnabled ? activeProperty ? html` ${this.#renderProperty(activeProperty)} @@ -179,7 +179,7 @@ export class WebAnalyticsEventDetailsDialogElement extends UmbElementMixin(LitEl } static styles = [UmbTextStyles, analyticsDialogStyles, reportTabsStyles, css` - .dialog-content { display: flex; flex-direction: column; max-block-size: min(28rem, 52dvh); min-block-size: 0; position: relative; } + .dialog-content { --analytics-dialog-body-height: min(28rem, 52dvh); display: flex; flex-direction: column; position: relative; } .property-controls { display: grid; flex: 0 0 auto; gap: var(--uui-size-space-3); padding-block-end: 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); } @@ -212,9 +212,6 @@ export class WebAnalyticsEventDetailsDialogElement extends UmbElementMixin(LitEl .error-overlay { background: color-mix(in srgb, var(--uui-color-warning) 8%, var(--uui-color-surface)); border: 1px solid color-mix(in srgb, var(--uui-color-warning) 28%, var(--uui-color-border)); border-radius: var(--uui-border-radius); inset-block-start: var(--uui-size-space-3); inset-inline: var(--uui-size-space-3); padding: var(--uui-size-space-4); position: absolute; z-index: 5; } .empty-row td { padding: var(--uui-size-space-5); text-align: left; } .visually-hidden { clip: rect(0 0 0 0); clip-path: inset(50%); height: 1px; overflow: hidden; position: absolute; white-space: nowrap; width: 1px; } - @media (max-width: 600px) { - .dialog-content { max-block-size: 48dvh; } - } @media (hover: none) { .filter-button { opacity: 1; } } `]; } 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 93eb2c6..a8fe9ac 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/event-dialog.element.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/event-dialog.element.ts @@ -33,7 +33,7 @@ export class WebAnalyticsEventDialogElement extends UmbElementMixin(LitElement) -
+
${!this.loading && this.unavailable ? html`

${this.unavailable}

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

Try a different search.

` : ""} ${this.loading || (!this.unavailable && (!this._search || this.rows.length > 0)) ? html` @@ -50,7 +50,7 @@ export class WebAnalyticsEventDialogElement extends UmbElementMixin(LitElement) dialog { --analytics-dialog-max-width: 58rem; } 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 { max-block-size: min(30rem, 52dvh); margin-top: var(--uui-size-space-4); overflow: auto; scrollbar-gutter: stable; } + .results { margin-top: var(--uui-size-space-4); overflow: auto; scrollbar-gutter: stable; } `]; } From f3f699698b715e85625e1658b85ac8d798b72573 Mon Sep 17 00:00:00 2001 From: Daniel Schmidt Date: Wed, 22 Jul 2026 13:19:15 +0200 Subject: [PATCH 11/12] style: polish analytics summary chart --- .../src/analytics/analytics-summary.element.ts | 14 ++++++-------- .../Client/src/analytics/history-chart.element.ts | 8 ++++---- 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-summary.element.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-summary.element.ts index 373c5b7..9e863e5 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-summary.element.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-summary.element.ts @@ -126,16 +126,16 @@ export class WebAnalyticsSummaryElement extends UmbElementMixin(LitElement) { .history, .summary-error { --uui-box-default-padding: 0; margin-bottom: var(--uui-size-layout-1); overflow: hidden; } .history { --web-analytics-chart-color: oklch(51.51% .2399 257.85); } .metric-tabs { background: var(--uui-color-surface-alt); border-bottom: 1px solid var(--uui-color-border); display: flex; flex-wrap: nowrap; overflow-x: auto; overscroll-behavior-inline: contain; scrollbar-width: thin; } - .metric-tab { --metric-font-size: clamp(2rem, 3cqi, 3rem); appearance: none; background: transparent; border: 0; border-bottom: 3px solid transparent; color: var(--uui-color-text-alt); cursor: pointer; flex: 0 0 auto; font: inherit; inline-size: max-content; min-block-size: clamp(6.5rem, 10cqi, 7.75rem); min-inline-size: 18rem; padding: clamp(var(--uui-size-space-4), 2cqi, var(--uui-size-space-5)) var(--uui-size-space-5); text-align: left; transition: background-color 160ms ease-out, color 160ms ease-out; } + .metric-tab { --metric-font-size: clamp(2rem, 3cqi, 3rem); appearance: none; background: transparent; border: 0; border-bottom: 3px solid transparent; box-sizing: border-box; color: var(--uui-color-text-alt); cursor: pointer; flex: 0 0 auto; font: inherit; inline-size: max-content; min-block-size: clamp(6.5rem, 10cqi, 7.75rem); min-inline-size: 18rem; padding: clamp(var(--uui-size-space-4), 2cqi, var(--uui-size-space-5)) var(--uui-size-space-5); text-align: left; transition: background-color 160ms ease-out, color 160ms ease-out; } .metric-tab:last-child { border-inline-end: 1px solid var(--uui-color-border); } .metric-tab[aria-selected="true"] { background: var(--uui-color-surface); border-bottom-color: var(--web-analytics-chart-color); color: var(--uui-color-text); } .metric-tab[aria-selected="false"]:hover { background: color-mix(in srgb, var(--uui-color-interactive) 7%, var(--uui-color-surface)); } .metric-tab[aria-selected="false"]:active { background: color-mix(in srgb, var(--uui-color-interactive) 11%, var(--uui-color-surface)); } .metric-tab:focus-visible { outline: 2px solid var(--uui-color-selected); outline-offset: -2px; } - .metric-value { align-items: center; display: flex; flex-wrap: nowrap; gap: var(--uui-size-space-4); margin-top: var(--uui-size-space-3); } - .metric-tab strong { font-size: var(--metric-font-size); font-variant-numeric: tabular-nums; line-height: 1.1; white-space: nowrap; } + .metric-value { align-items: baseline; display: flex; flex-wrap: wrap; gap: var(--uui-size-space-2) var(--uui-size-space-4); margin-top: var(--uui-size-space-3); } + .metric-tab strong { font-size: var(--metric-font-size); font-variant-numeric: tabular-nums; font-weight: 400; letter-spacing: -0.02em; line-height: 1.05; white-space: nowrap; } .eyebrow { color: currentColor; font-weight: 700; } - .comparison { border-radius: var(--uui-border-radius); flex: 0 0 auto; font-weight: 700; padding: var(--uui-size-space-2) var(--uui-size-space-3); white-space: nowrap; } + .comparison { border-radius: var(--uui-border-radius); flex: 0 0 auto; font-size: var(--uui-type-small-size); font-weight: 700; line-height: var(--uui-type-default-line-height); padding: var(--uui-size-space-1) var(--uui-size-space-2); white-space: nowrap; } .comparison.increase { background: color-mix(in srgb, var(--uui-color-positive-standalone) 14%, var(--uui-color-surface)); color: var(--uui-color-positive-standalone); } .comparison.decrease { background: color-mix(in srgb, var(--uui-color-danger-standalone) 14%, var(--uui-color-surface)); color: var(--uui-color-danger-standalone); } .comparison.unchanged { background: var(--uui-color-surface-alt); color: var(--uui-color-text-alt); } @@ -150,14 +150,12 @@ export class WebAnalyticsSummaryElement extends UmbElementMixin(LitElement) { .summary-error-copy p { color: var(--uui-color-text-alt); margin: var(--uui-size-space-1) 0 0; } .visually-hidden { clip: rect(0 0 0 0); clip-path: inset(50%); height: 1px; overflow: hidden; position: absolute; white-space: nowrap; width: 1px; } @media (max-width: 48rem) { - .metric-tab { --metric-font-size: clamp(1.5rem, 4cqi, 2rem); box-sizing: border-box; flex: 0 0 65cqi; min-block-size: 6.5rem; min-inline-size: 14rem; padding: var(--uui-size-space-4); } + .metric-tab { --metric-font-size: clamp(1.5rem, 4cqi, 2rem); flex: 0 0 65cqi; min-block-size: 6.5rem; min-inline-size: 14rem; padding: var(--uui-size-space-4); } .metric-value { gap: var(--uui-size-space-2); } - .comparison { font-size: 0.875rem; padding: var(--uui-size-space-1) var(--uui-size-space-2); } } @media (max-width: 40rem) { .metric-tab { --metric-font-size: clamp(1.25rem, 5cqi, 1.75rem); min-block-size: 5.5rem; padding: var(--uui-size-space-3); } - .eyebrow { font-size: 0.875rem; } - .comparison { font-size: 0.75rem; } + .eyebrow, .comparison { font-size: var(--uui-type-small-size); } } @media (prefers-reduced-motion: reduce) { .metric-tab { transition: none; } } `]; diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/history-chart.element.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/history-chart.element.ts index 6f69a06..f3b0c32 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/history-chart.element.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/history-chart.element.ts @@ -47,13 +47,13 @@ export class WebAnalyticsHistoryChartElement extends UmbElementMixin(LitElement) const style = getComputedStyle(this); const color = style.getPropertyValue("--web-analytics-chart-color").trim() || "oklch(51.51% .2399 257.85)"; - const lineColor = `color-mix(in srgb, ${color} 72%, transparent)`; - const fillColor = style.getPropertyValue("--web-analytics-chart-fill").trim() || "oklch(51.51% .2399 257.85 / 0.12)"; + const lineColor = `color-mix(in srgb, ${color} 82%, transparent)`; + const fillColor = style.getPropertyValue("--web-analytics-chart-fill").trim() || "oklch(51.51% .2399 257.85 / 0.1)"; const guideColor = style.getPropertyValue("--uui-color-text").trim() || "#1b264f"; const mutedColor = style.getPropertyValue("--uui-color-text-alt").trim() || "#5c5c5c"; const surfaceColor = style.getPropertyValue("--uui-color-surface").trim() || "#ffffff"; const borderColor = style.getPropertyValue("--uui-color-border").trim() || "#d8d7d9"; - const gridColor = `color-mix(in srgb, ${borderColor} 55%, transparent)`; + const gridColor = `color-mix(in srgb, ${borderColor} 42%, transparent)`; const label = this.#metricLabel(); const latestPoint = this.points[this.points.length - 1]; const latestPeriodInProgress = latestPoint @@ -89,7 +89,7 @@ export class WebAnalyticsHistoryChartElement extends UmbElementMixin(LitElement) label, data: this.points.map((point) => point[this.metric] ?? 0), borderColor: lineColor, - borderWidth: 2, + borderWidth: 2.25, backgroundColor: fillColor, fill: true, pointRadius: 0, From 69b3cffdde59a68a55ce9ed510116e71ff48fc51 Mon Sep 17 00:00:00 2001 From: Daniel Schmidt Date: Wed, 22 Jul 2026 15:35:58 +0200 Subject: [PATCH 12/12] feat: refine configurable analytics dashboard --- .../analytics-breakdown-grid.element.ts | 47 +++--- .../analytics/analytics-components.test.ts | 139 +++++++++++++++++- .../analytics-dashboard-header.element.ts | 3 +- .../analytics-dashboard.controller.test.ts | 106 +++++++++---- .../analytics-dashboard.controller.ts | 39 +++-- .../analytics/analytics-dashboard.element.ts | 71 ++++++--- .../analytics/analytics-dashboard.styles.ts | 6 + .../analytics/analytics-dialog-headline.ts | 18 +++ .../src/analytics/analytics-dialog.styles.ts | 61 +++++++- .../analytics/analytics-summary.element.ts | 6 +- .../Client/src/analytics/async-state.test.ts | 10 ++ .../Client/src/analytics/async-state.ts | 4 + .../breakdown-dialog.element.test.ts | 73 ++++++--- .../src/analytics/breakdown-dialog.element.ts | 106 ++++++++----- .../src/analytics/breakdown-table.element.ts | 117 +++++++++------ .../Client/src/analytics/dashboard-cards.ts | 30 ++-- .../analytics/event-details-dialog.element.ts | 11 +- .../src/analytics/event-dialog.element.ts | 16 +- .../src/analytics/event-table.element.ts | 5 +- .../Client/src/analytics/flag-card.element.ts | 10 +- .../Client/src/analytics/report-tabs.ts | 27 ++-- .../src/settings/settings-model.test.ts | 3 + .../Client/src/settings/settings-model.ts | 2 +- .../Configuration/AnalyticsProviderCatalog.cs | 4 +- .../WebAnalyticsSettingsValidatorTests.cs | 16 ++ 25 files changed, 671 insertions(+), 259 deletions(-) create mode 100644 src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dialog-headline.ts create mode 100644 src/TheBuilder.WebAnalytics/Client/src/analytics/async-state.test.ts diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-breakdown-grid.element.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-breakdown-grid.element.ts index 8ff04a7..3812f5e 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-breakdown-grid.element.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-breakdown-grid.element.ts @@ -3,13 +3,13 @@ import { UmbElementMixin } from "@umbraco-cms/backoffice/element-api"; import { UmbTextStyles } from "@umbraco-cms/backoffice/style"; import type { AnalyticsBreakdown, AnalyticsDimension, AnalyticsEventsReport, AnalyticsFlagsReport } from "../api/types.gen.js"; import { breakdownMetricTotal, topBreakdownRows } from "./breakdown-rows.js"; -import { selectedCardDimension, type AcquisitionView, type DashboardCard, UTM_OPTIONS } from "./dashboard-cards.js"; +import { selectedCardDimension, type AcquisitionView, type DashboardCard } from "./dashboard-cards.js"; import type { AnalyticsFilter, AudienceDimension, DashboardMetric, UtmDimension } from "./dashboard-url-state.js"; import { topEventRows } from "./event-rows.js"; import "./breakdown-table.element.js"; import "./event-table.element.js"; import "./flag-card.element.js"; -import { stateData, type AsyncState } from "./async-state.js"; +import { isInitialLoading, stateData, type AsyncState } from "./async-state.js"; import type { ReportTabGroup } from "./report-tabs.js"; @customElement("web-analytics-breakdown-grid") @@ -35,13 +35,11 @@ export class WebAnalyticsBreakdownGridElement extends UmbElementMixin(LitElement } #tabsForCard(card: Extract): ReportTabGroup { - const selected = card.id === "audience" ? this.audienceDimension : this.utmDimension; - const options = card.id === "utm" ? UTM_OPTIONS : card.options; return { - ariaLabel: card.id === "audience" ? "Audience technology" : "UTM parameter", + ariaLabel: "Audience technology", idPrefix: `${card.id}-card-tab`, - options: options.map(({ dimension, label }) => ({ value: dimension, label })), - selected, + options: card.options.map(({ dimension, label }) => ({ value: dimension, label })), + selected: this.audienceDimension, }; } @@ -71,7 +69,7 @@ export class WebAnalyticsBreakdownGridElement extends UmbElementMixin(LitElement #renderCard(card: DashboardCard) { const selected = selectedCardDimension(card, this.audienceDimension, this.utmDimension); const state = this.breakdowns[selected.dimension]; - const loading = !state || state.status === "idle" || state.status === "loading"; + const loading = isInitialLoading(state); const allRows = state ? stateData(state)?.rows ?? [] : []; const rows = topBreakdownRows(allRows, 10); const total = breakdownMetricTotal(allRows, this.metric); @@ -79,16 +77,14 @@ export class WebAnalyticsBreakdownGridElement extends UmbElementMixin(LitElement const planLimited = card.kind === "tabbed-breakdown" && card.planLimited; const linkValues = selected.dimension === "RequestPath" || selected.dimension === "Route"; const headingTabs = card.kind === "tabbed-breakdown" ? this.#tabsForCard(card) : undefined; - const context = card.kind === "tabbed-breakdown" && card.id === "audience" - ? { kind: "audience" as const, title: "Audience" as const, options: card.options } - : undefined; return html` - +
UTM reporting availability depends on your analytics plan and reporting window.

` : ""}
${!loading && !unavailable && rows.length ? html` - this.#dispatch("view-breakdown", { ...selected, context })}>View all + this.#dispatch("view-breakdown", selected)}>View all ` : !loading && unavailable ? html` this.#dispatch("retry-reports")}>Retry ` : ""} @@ -119,26 +115,19 @@ export class WebAnalyticsBreakdownGridElement extends UmbElementMixin(LitElement ? selectedCardDimension(utmCard, this.audienceDimension, this.utmDimension) : selectedCardDimension(referrerCard, this.audienceDimension, this.utmDimension); const report = this.breakdowns[selected.dimension]; - const loading = !report || report.status === "idle" || report.status === "loading"; + const loading = isInitialLoading(report); const allRows = report ? stateData(report)?.rows ?? [] : []; const rows = topBreakdownRows(allRows, 10); const total = breakdownMetricTotal(allRows, this.metric); const unavailable = report?.status === "error" ? report.message : undefined; - const context = utmCard ? { - kind: "acquisition" as const, - title: "Traffic sources" as const, - referrer: selectedCardDimension(referrerCard, this.audienceDimension, this.utmDimension), - utmDimension: this.utmDimension, - utmOptions: utmCard.options, - } : undefined; - return html` - +
) => this.#dispatch("acquisition-change", { view: event.detail.value })} @subheading-tab-change=${(event: CustomEvent<{ value: UtmDimension }>) => this.#dispatch("utm-change", { dimension: event.detail.value })}>
${!loading && !unavailable && rows.length ? html` - this.#dispatch("view-breakdown", { ...selected, context })}>View all + this.#dispatch("view-breakdown", selected)}>View all ` : !loading && unavailable ? html` this.#dispatch("retry-reports")}>Retry ` : ""} @@ -163,15 +151,15 @@ export class WebAnalyticsBreakdownGridElement extends UmbElementMixin(LitElement } #renderEvents() { - const loading = this.events.status === "idle" || this.events.status === "loading"; + const loading = isInitialLoading(this.events); const rows = topEventRows(stateData(this.events)?.rows ?? [], 10); const empty = !loading && rows.length === 0; return html` - +
${empty ? "" : html`
- ${!loading && rows.length ? html` this.#dispatch("view-events")}>View all` : ""} + ${!loading && rows.length ? html` this.#dispatch("view-events")}>View all` : ""}
`}
@@ -194,7 +182,7 @@ export class WebAnalyticsBreakdownGridElement extends UmbElementMixin(LitElement ${this.supportsEvents || this.supportsFlags ? html`
${this.supportsEvents ? this.#renderEvents() : ""} - ${this.supportsFlags ? html` + ${this.supportsFlags ? html` ` : ""}
` : ""} @@ -212,6 +200,7 @@ export class WebAnalyticsBreakdownGridElement extends UmbElementMixin(LitElement .breakdown-card-layout { box-sizing: border-box; min-block-size: 100%; padding-bottom: var(--uui-size-layout-3); } .empty-card-layout { block-size: 100%; padding-bottom: 0; } .breakdown-footer { align-items: center; background: color-mix(in srgb, var(--uui-color-surface-alt) 9%, var(--uui-color-surface)); border-top: 1px solid var(--uui-color-border); bottom: 0; box-sizing: border-box; display: flex; justify-content: flex-end; left: 0; min-block-size: var(--uui-size-layout-3); padding: 0 var(--uui-size-space-4); position: absolute; right: 0; } + .view-all { --uui-button-border-width: 0; --uui-button-content-align: right; } .hint { color: var(--uui-color-text-alt); } .breakdown-hint { margin: 0; padding: var(--uui-size-space-3) var(--uui-size-space-5); } `]; 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 15cd178..fe0ca9e 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-components.test.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-components.test.ts @@ -23,11 +23,12 @@ vi.mock("@umbraco-cms/backoffice/element-api", () => ({ vi.mock("@umbraco-cms/backoffice/style", () => ({ UmbTextStyles: [] })); import { dateRangeForPreset } from "./date-range.js"; -import { successState } from "./async-state.js"; +import { loadingState, successState } from "./async-state.js"; import { dashboardCards } from "./dashboard-cards.js"; import type { WebAnalyticsSummaryElement } from "./analytics-summary.element.js"; import type { WebAnalyticsBreakdownGridElement } from "./analytics-breakdown-grid.element.js"; import type { WebAnalyticsBreakdownTableElement } from "./breakdown-table.element.js"; +import type { WebAnalyticsEventTableElement } from "./event-table.element.js"; import type { WebAnalyticsDashboardElement } from "./analytics-dashboard.element.js"; import type { WebAnalyticsDashboardHeaderElement } from "./analytics-dashboard-header.element.js"; import type { WebAnalyticsFlagCardElement } from "./flag-card.element.js"; @@ -115,6 +116,35 @@ describe("analytics presentation components", () => { expect(action?.getAttribute("label")).toBe("Open Web Analytics settings"); }); + it("replaces reports with connection setup guidance when credentials are missing", async () => { + sdk.connections.mockResolvedValue(apiOk({ + enabled: true, + defaultRangeDays: 30, + connections: [{ + key: "11111111-1111-1111-1111-111111111111", + displayName: "Production", + provider: "Vercel", + capabilities: { dimensions: ["RequestPath", "Country"], events: true, eventDetails: true, eventProperties: true, globalEventFiltering: false, flags: true, breakdownOrdering: false }, + isDefault: true, + isConfigured: false, + baseUrl: "https://example.com", + warnings: ["No server-side credential is configured for this connection."], + }], + })); + const element = document.createElement("web-analytics-dashboard") as WebAnalyticsDashboardElement; + document.body.append(element); + + await vi.waitFor(() => expect(element.shadowRoot?.querySelector("#connection-setup-title")?.textContent).toBe("Connection credentials required")); + + const header = element.shadowRoot?.querySelector("web-analytics-dashboard-header"); + await header?.updateComplete; + expect(element.shadowRoot?.querySelector("web-analytics-summary")).toBeNull(); + expect(element.shadowRoot?.querySelector("web-analytics-breakdown-grid")).toBeNull(); + expect(header?.shadowRoot?.querySelector("web-analytics-date-range-picker")).toBeNull(); + expect(element.shadowRoot?.querySelector(".connection-setup p")?.textContent).toContain("server-side credentials"); + expect(element.shadowRoot?.querySelector(".connection-setup uui-button")?.getAttribute("href")).toBe("/umbraco/section/settings/dashboard/web-analytics"); + }); + it("emits metric changes from the summary tabs", async () => { const element = document.createElement("web-analytics-summary") as WebAnalyticsSummaryElement; element.range = dateRangeForPreset(30); @@ -143,6 +173,44 @@ describe("analytics presentation components", () => { expect(element.shadowRoot?.querySelector("#metric-visitors-tab strong")?.textContent).toBe("185.508"); }); + it("keeps the previous summary visible while a filtered report refreshes", async () => { + const element = document.createElement("web-analytics-summary") as WebAnalyticsSummaryElement; + element.range = dateRangeForPreset(30); + element.metric = "visitors"; + element.report = loadingState(successState({ totals: { visitors: 12, pageViews: 34 }, points: [] })); + document.body.append(element); + await element.updateComplete; + + expect(element.shadowRoot?.querySelector(".history")?.getAttribute("aria-busy")).toBe("true"); + expect(element.shadowRoot?.querySelector("#metric-visitors-tab strong")?.textContent).toBe("12"); + expect(element.shadowRoot?.querySelector(".metric-skeleton")).toBeNull(); + expect(element.shadowRoot?.querySelector(".chart-skeleton")).toBeNull(); + }); + + it("keeps previous breakdown, event, and flag rows visible while filters refresh", async () => { + const element = document.createElement("web-analytics-breakdown-grid") as WebAnalyticsBreakdownGridElement; + element.cards = dashboardCards(false, "unavailable").filter((card) => card.kind === "breakdown" && card.dimension === "Country"); + element.breakdowns = { + Country: loadingState(successState({ dimension: "Country", rows: [{ value: "DK", visitors: 12, pageViews: 18 }] })), + }; + element.events = loadingState(successState({ rows: [{ eventName: "Signup", visitors: 8, count: 9 }] })); + element.flags = loadingState(successState({ rows: [{ value: "new-checkout", visitors: 5, pageViews: 7 }] })); + document.body.append(element); + await element.updateComplete; + + const breakdown = element.shadowRoot?.querySelector("web-analytics-breakdown-table"); + const events = element.shadowRoot?.querySelector("web-analytics-event-table"); + const flags = element.shadowRoot?.querySelector("web-analytics-flag-card"); + await Promise.all([breakdown?.updateComplete, events?.updateComplete, flags?.updateComplete]); + + expect(breakdown?.loading).toBe(false); + expect(breakdown?.shadowRoot?.querySelector(".row-label")?.textContent).toContain("Denmark"); + expect(events?.loading).toBe(false); + expect(events?.shadowRoot?.querySelector(".details-action")?.textContent).toBe("Signup"); + expect(flags?.shadowRoot?.querySelector(".value")?.textContent).toBe("new-checkout"); + expect(element.shadowRoot?.querySelectorAll('[aria-busy="true"]')).toHaveLength(3); + }); + it("emits audience changes from the breakdown tabs", async () => { const element = document.createElement("web-analytics-breakdown-grid") as WebAnalyticsBreakdownGridElement; element.cards = dashboardCards(false, "unavailable").filter((card) => card.kind === "tabbed-breakdown" && card.id === "audience"); @@ -168,7 +236,7 @@ describe("analytics presentation components", () => { expect((onChange.mock.calls[0][0] as CustomEvent).detail).toEqual({ dimension: "BrowserName" }); }); - it("normalizes percentage cards against all visible grouped rows", async () => { + it("keeps audience cards as percentages of the selected metric", async () => { const element = document.createElement("web-analytics-breakdown-grid") as WebAnalyticsBreakdownGridElement; element.cards = dashboardCards(false, "unavailable").filter((card) => card.kind === "tabbed-breakdown" && card.id === "audience"); element.audienceDimension = "DeviceType"; @@ -187,8 +255,34 @@ describe("analytics presentation components", () => { document.body.append(element); await element.updateComplete; - const table = element.shadowRoot?.querySelector("web-analytics-breakdown-table"); - expect(table?.total).toBe(11_339); + const table = element.shadowRoot?.querySelector("web-analytics-breakdown-table")!; + await table.updateComplete; + expect(table.total).toBe(11_339); + const headers = [...table.shadowRoot?.querySelectorAll("thead th") ?? []] + .map((header) => header.textContent?.replace(/\s+/g, "").trim()); + expect(headers).toEqual(["DevicesBrowsers", "Visitors"]); + const values = [...table.shadowRoot?.querySelectorAll("tbody .percentage-value > span:first-child") ?? []] + .map((value) => value.textContent?.trim()); + expect(values).toEqual(["99%", "1%"]); + }); + + it("keeps standard cards focused on the selected metric", async () => { + const element = document.createElement("web-analytics-breakdown-grid") as WebAnalyticsBreakdownGridElement; + element.cards = dashboardCards(false, "unavailable").filter((card) => card.kind === "breakdown" && card.dimension === "RequestPath"); + element.metric = "pageViews"; + element.breakdowns = { + RequestPath: successState({ dimension: "RequestPath", rows: [{ value: "/", visitors: 8_525, pageViews: 15_119 }] }), + }; + element.events = successState({ rows: [] }); + document.body.append(element); + await element.updateComplete; + + const table = element.shadowRoot?.querySelector("web-analytics-breakdown-table")!; + await table.updateComplete; + expect([...table.shadowRoot?.querySelectorAll("thead th") ?? []].map((header) => header.textContent?.trim())).toEqual(["Pages", "Page views"]); + expect([...table.shadowRoot?.querySelectorAll(".metric-number") ?? []].map((value) => value.textContent)).toEqual(["15,119"]); + expect(table.shadowRoot?.querySelector(".metric-cell .filter-action")).not.toBeNull(); + expect(table.shadowRoot?.querySelector(".row-value .filter-action")).toBeNull(); }); it.each(["ReferrerHostname", "Referrer"] as const)("renders %s rows as secure external links with favicons for attributed hosts", async (dimension) => { @@ -211,7 +305,7 @@ describe("analytics presentation components", () => { expect(favicons?.[0]?.src).toBe("https://www.google.com/s2/favicons?domain=google.com&sz=32"); expect(favicons?.[0]?.getAttribute("referrerpolicy")).toBe("no-referrer"); expect([...element.shadowRoot?.querySelectorAll(".row-label") ?? []].map((label) => label.textContent?.trim())).toEqual(["google.com (opens in a new tab)", "Unknown"]); - expect(element.shadowRoot?.querySelector(".metric-number")?.textContent).toBe("22,304"); + expect([...element.shadowRoot?.querySelectorAll(".metric-number") ?? []].map((value) => value.textContent)).toEqual(["22,304", "30,000", "1", "1"]); }); it("keeps document traffic breakdowns ahead of optional reports", async () => { @@ -232,6 +326,32 @@ describe("analytics presentation components", () => { expect(cards[5]?.querySelector("web-analytics-flag-card")).not.toBeNull(); }); + it("keeps View all actions low priority while retaining emphasis for Retry", async () => { + const element = document.createElement("web-analytics-breakdown-grid") as WebAnalyticsBreakdownGridElement; + element.cards = dashboardCards(false, "unavailable").filter((card) => card.kind === "breakdown" && card.dimension === "Country"); + element.breakdowns = { + Country: successState({ dimension: "Country", rows: [{ value: "DK", visitors: 12, pageViews: 18 }] }), + }; + element.events = successState({ rows: [{ eventName: "Signup", visitors: 8, count: 9 }] }); + element.supportsFlags = false; + document.body.append(element); + await element.updateComplete; + + const viewAllActions = [...element.shadowRoot?.querySelectorAll("uui-button") ?? []] + .filter((button) => button.textContent?.trim() === "View all"); + expect(viewAllActions).toHaveLength(2); + expect(viewAllActions.every((button) => button.getAttribute("look") === "default")).toBe(true); + expect(viewAllActions.every((button) => button.classList.contains("view-all") && button.hasAttribute("compact"))).toBe(true); + + element.breakdowns = { Country: { status: "error", message: "Unavailable" } }; + element.supportsEvents = false; + await element.updateComplete; + + const retry = element.shadowRoot?.querySelector("uui-button"); + expect(retry?.textContent?.trim()).toBe("Retry"); + expect(retry?.getAttribute("look")).toBe("secondary"); + }); + it("groups Events and Flags as optional reports in the overview", async () => { const element = document.createElement("web-analytics-breakdown-grid") as WebAnalyticsBreakdownGridElement; element.cards = dashboardCards(false, "unavailable"); @@ -300,7 +420,7 @@ describe("analytics presentation components", () => { const parameterTabs = [...updatedAcquisitionTable.shadowRoot?.querySelectorAll(".report-tabs.secondary [role=tab]") ?? []]; expect(parameterTabs.map((tab) => tab.textContent?.trim())).toEqual(["Source", "Medium", "Campaign", "Term", "Content"]); const headerRows = updatedAcquisitionTable.shadowRoot?.querySelectorAll("thead tr"); - expect(headerRows?.[0].lastElementChild?.textContent?.trim()).toBe("Visitors"); + expect([...headerRows?.[0].children ?? []].map((header) => header.textContent?.replace(/\s+/g, "").trim())).toEqual(["ReferrersUTM", "Visitors"]); expect(headerRows?.[0].lastElementChild?.hasAttribute("rowspan")).toBe(false); expect(headerRows?.[1].firstElementChild?.getAttribute("colspan")).toBe("2"); parameterTabs[4]?.click(); @@ -350,6 +470,9 @@ describe("analytics presentation components", () => { document.body.append(element); await element.updateComplete; expect(element.shadowRoot?.querySelector(".filter-action")).toBeNull(); + const metrics = [...element.shadowRoot?.querySelectorAll(".metric-value") ?? []]; + expect(metrics.map((metric) => metric.textContent)).toEqual(["12", "15"]); + expect(metrics.every((metric) => metric.tagName === "STRONG")).toBe(true); element.filteringEnabled = true; await element.updateComplete; @@ -372,7 +495,8 @@ describe("analytics presentation components", () => { document.body.append(element); await element.updateComplete; - expect(element.shadowRoot?.querySelector("uui-dialog-layout")?.getAttribute("headline")).toBe("Read article event"); + expect(element.shadowRoot?.querySelector(".analytics-dialog-headline h2")?.textContent).toBe("Read article event"); + 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); expect(element.shadowRoot?.querySelector("umb-empty-state")?.getAttribute("headline")).toBe("No property data"); @@ -413,6 +537,7 @@ describe("analytics presentation components", () => { expect(element.shadowRoot?.querySelector(".flag-back uui-icon")?.getAttribute("name")).toBe("icon-navigation-left"); expect(element.shadowRoot?.querySelector(".selected-label")?.textContent).toBe("summer-sale"); expect(element.shadowRoot?.querySelector(".row .value")?.textContent).toBe("true"); + expect(element.shadowRoot?.querySelector(".row .value")?.tagName).toBe("SPAN"); element.selected = undefined; element.report = successState({ rows: [] }); diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dashboard-header.element.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dashboard-header.element.ts index 90d1a18..00fd634 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dashboard-header.element.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dashboard-header.element.ts @@ -51,6 +51,7 @@ export class WebAnalyticsDashboardHeaderElement extends UmbElementMixin(LitEleme render() { const connection = this.#connection(); + const showDateRange = connection?.isConfigured !== false; const hostname = this.#hostname(); const siteLabel = hostname ?? connection?.displayName; const linkUrl = this.documentScoped ? this.route?.url ?? this.siteUrl : this.siteUrl; @@ -85,7 +86,7 @@ export class WebAnalyticsDashboardHeaderElement extends UmbElementMixin(LitEleme ${!this.documentScoped && this.connections.length > 1 ? html` ` : ""} - + ${showDateRange ? html`` : ""}
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 08a1005..341d865 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 @@ -2,7 +2,6 @@ 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 type { DashboardApi } from "./dashboard-api.js"; -import { UTM_OPTIONS } from "./dashboard-cards.js"; import { dateRangeForPreset } from "./date-range.js"; const fullCapabilities: AnalyticsCapabilities = { @@ -34,7 +33,7 @@ describe("AnalyticsDashboardController", () => { expect(controller.state.connection).toBe("11111111-1111-1111-1111-111111111111"); }); - it("does not retain reports from the previous connection when the next one fails", async () => { + it("does not request or retain reports for an unconfigured connection", async () => { const api = dashboardApi(); api.connections.mockResolvedValue(ok({ enabled: true, @@ -44,25 +43,20 @@ describe("AnalyticsDashboardController", () => { { key: "22222222-2222-2222-2222-222222222222", displayName: "Unconfigured", provider: "Vercel", capabilities: fullCapabilities, isDefault: false, isConfigured: false, baseUrl: undefined, warnings: ["No server-side access token is configured for this connection."] }, ], })); - const nextEvents = deferred>>(); - api.events - .mockResolvedValueOnce(ok({ rows: [{ eventName: "Demo event", visitors: 10, count: 12 }] })) - .mockReturnValueOnce(nextEvents.promise); + api.events.mockResolvedValueOnce(ok({ rows: [{ eventName: "Demo event", visitors: 10, count: 12 }] })); const controller = new AnalyticsDashboardController(vi.fn(), api, environment()); controller.connect(); await vi.waitFor(() => expect(controller.state.events.status).toBe("success")); controller.setConnection("22222222-2222-2222-2222-222222222222"); - expect(controller.state.events).toEqual({ status: "loading" }); - - nextEvents.resolve({ - data: undefined, - error: new Error("No access token"), - response: new Response(null, { status: 401 }), - }); - await vi.waitFor(() => expect(controller.state.events.status).toBe("error")); - expect("previous" in controller.state.events).toBe(false); + expect(controller.state.summary).toEqual({ status: "idle" }); + expect(controller.state.breakdowns).toEqual({}); + expect(controller.state.events).toEqual({ status: "idle" }); + expect(controller.state.flags).toEqual({ status: "idle" }); + expect(api.summary).toHaveBeenCalledTimes(1); + expect(api.events).toHaveBeenCalledTimes(1); + expect(api.flags).toHaveBeenCalledTimes(1); }); it("keeps the newest document scope when an older route request finishes last", async () => { @@ -116,26 +110,82 @@ describe("AnalyticsDashboardController", () => { expect(controller.state.expandedBreakdown).toBeUndefined(); }); - it("preserves grouped dialog context and updates its selected UTM dimension", async () => { + it("stores only canonical breakdown query state for a grouped dialog", async () => { const api = dashboardApi(); const controller = new AnalyticsDashboardController(vi.fn(), api, environment()); controller.connect(); await vi.waitFor(() => expect(controller.state.summary.status).toBe("success")); - const context = { - kind: "acquisition" as const, - title: "Traffic sources" as const, - referrer: { dimension: "ReferrerHostname" as const, headline: "Referrers", label: "Referrers" }, - utmDimension: "UtmMedium" as const, - utmOptions: UTM_OPTIONS, - }; + await controller.openBreakdown("UtmCampaign", "UTM campaigns"); - await controller.openBreakdown("UtmCampaign", "UTM campaigns", { context }); + expect(controller.state.expandedBreakdown).toMatchObject({ + dimension: "UtmCampaign", + headline: "UTM campaigns", + search: "", + report: { status: "success" }, + }); + }); - expect(controller.state.expandedBreakdown?.context).toMatchObject({ - kind: "acquisition", - title: "Traffic sources", - utmDimension: "UtmCampaign", + it("does not reuse rows from a different breakdown query", async () => { + const api = dashboardApi(); + const controller = new AnalyticsDashboardController(vi.fn(), api, environment()); + controller.connect(); + await vi.waitFor(() => expect(controller.state.summary.status).toBe("success")); + api.breakdown.mockResolvedValueOnce(ok({ + dimension: "ReferrerHostname", + rows: [{ value: "example.com", visitors: 12, pageViews: 18 }], + })); + await controller.openBreakdown("ReferrerHostname", "Referrers"); + const pending = deferred>>(); + api.breakdown.mockReturnValueOnce(pending.promise); + + const switching = controller.openBreakdown("UtmSource", "UTM sources"); + + expect(controller.state.expandedBreakdown?.report).toEqual({ status: "loading" }); + pending.resolve(ok({ dimension: "UtmSource", rows: [] })); + await switching; + }); + + it("retains rows while the same breakdown query refreshes", async () => { + const api = dashboardApi(); + const controller = new AnalyticsDashboardController(vi.fn(), api, environment()); + controller.connect(); + await vi.waitFor(() => expect(controller.state.summary.status).toBe("success")); + api.breakdown.mockResolvedValueOnce(ok({ + dimension: "Country", + rows: [{ value: "DK", visitors: 12, pageViews: 18 }], + })); + await controller.openBreakdown("Country", "Countries"); + const pending = deferred>>(); + api.breakdown.mockReturnValueOnce(pending.promise); + + const refreshing = controller.openBreakdown("Country", "Countries"); + + expect(controller.state.expandedBreakdown?.report).toEqual({ + status: "loading", + previous: [{ value: "DK", visitors: 12, pageViews: 18 }], }); + pending.resolve(ok({ dimension: "Country", rows: [] })); + await refreshing; + }); + + it("does not reuse unfiltered rows for a searched breakdown", async () => { + const api = dashboardApi(); + const controller = new AnalyticsDashboardController(vi.fn(), api, environment()); + controller.connect(); + await vi.waitFor(() => expect(controller.state.summary.status).toBe("success")); + api.breakdown.mockResolvedValueOnce(ok({ + dimension: "Country", + rows: [{ value: "DK", visitors: 12, pageViews: 18 }], + })); + await controller.openBreakdown("Country", "Countries"); + const pending = deferred>>(); + api.breakdown.mockReturnValueOnce(pending.promise); + + const searching = controller.openBreakdown("Country", "Countries", { search: "denmark" }); + + expect(controller.state.expandedBreakdown?.report).toEqual({ status: "loading" }); + pending.resolve(ok({ dimension: "Country", rows: [] })); + await searching; }); it("does not restore event details after the dialog closes during a request", async () => { 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 d9a91b7..b72804b 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dashboard.controller.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dashboard.controller.ts @@ -15,7 +15,7 @@ import type { import { dashboardApi, type DashboardApi } from "./dashboard-api.js"; import { activeDocumentRoute } from "./document-route.js"; import { countrySearchValue } from "./country-display.js"; -import { dashboardReportPlan, type AcquisitionView, type BreakdownDialogContext, type DashboardCard, type DashboardReportPlan } from "./dashboard-cards.js"; +import { dashboardReportPlan, type AcquisitionView, type DashboardCard, type DashboardReportPlan } from "./dashboard-cards.js"; import { dateRangeForPreset, type AnalyticsDateRange, type DatePreset } from "./date-range.js"; import { parseDashboardUrlState, @@ -30,15 +30,15 @@ import { loadDashboardBreakdown, loadDashboardBreakdowns, loadDashboardReports, import { visibleEventRows } from "./event-rows.js"; import { reportErrorMessage } from "./report-error.js"; import { DebouncedRequest, RequestCoordinator } from "./request-coordinator.js"; -import { detectUtmCapability, isUtmDimension, type UtmCapability } from "./utm-capability.js"; +import { detectUtmCapability, type UtmCapability } from "./utm-capability.js"; import { errorState, idleState, loadingState, successState, type AsyncState } from "./async-state.js"; import { normalizeDashboardSelection, supportsDimension, unavailableCapabilities } from "./dashboard-capabilities.js"; type ReportScope = { documentId?: string; culture?: string; path?: string }; export type ExpandedBreakdown = { - context?: BreakdownDialogContext; dimension: AnalyticsDimension; headline: string; + search: string; report: AsyncState; }; export type SelectedEvent = { @@ -200,6 +200,20 @@ export class AnalyticsDashboardController { async loadReports(): Promise { const connection = this.state.connection; if (!connection) return; + const selectedConnection = this.state.connections.find(({ key }) => key === connection); + if (selectedConnection?.isConfigured === false) { + this.#reportRequest.cancel(); + this.#closeDialogs(); + this.#utmRequest.cancel(); + this.#set({ + summary: idleState(), + breakdowns: {}, + events: idleState(), + flags: idleState(), + utmCapability: "unknown", + }); + return; + } this.#closeDialogs(); this.#utmRequest.cancel(); const capabilities = this.#capabilities(); @@ -321,30 +335,30 @@ export class AnalyticsDashboardController { async openBreakdown( dimension: AnalyticsDimension, headline: string, - options: { context?: BreakdownDialogContext; search?: string; debounce?: boolean } = {}, + options: { search?: string; debounce?: boolean } = {}, ): Promise { if (!supportsDimension(this.#capabilities(), dimension)) return; const connection = this.state.connection; if (!connection) return; const search = options.search ?? ""; - const context = options.context?.kind === "acquisition" && isUtmDimension(dimension) - ? { ...options.context, utmDimension: dimension } - : options.context; - const previous = this.state.expandedBreakdown?.dimension === dimension ? this.state.expandedBreakdown.report : undefined; - this.#set({ expandedBreakdown: { context, dimension, headline, report: loadingState(previous) } }); + const current = this.state.expandedBreakdown; + const previous = current?.dimension === dimension && current.search === search ? current.report : undefined; + this.#set({ expandedBreakdown: { dimension, headline, search, report: loadingState(previous) } }); const run = (signal: AbortSignal) => this.#api.breakdown({ path: { dimension }, query: { ...this.#reportQuery(connection, this.#visitFilterQuery()), limit: 100, search: search || undefined }, signal, }); const result = await (options.debounce ? this.#expandedRequest.schedule(run) : this.#expandedRequest.run(run)); - if (result.status === "cancelled" || result.status === "stale" || this.state.expandedBreakdown?.dimension !== dimension) return; + if (result.status === "cancelled" || result.status === "stale" + || this.state.expandedBreakdown?.dimension !== dimension + || this.state.expandedBreakdown.search !== search) return; if (result.status === "error") { - this.#set({ expandedBreakdown: { context, dimension, headline, report: errorState(reportErrorMessage(result.error), previous) } }); + this.#set({ expandedBreakdown: { dimension, headline, search, report: errorState(reportErrorMessage(result.error), previous) } }); return; } const { data, error, response } = result.value; - this.#set({ expandedBreakdown: { context, dimension, headline, report: error + this.#set({ expandedBreakdown: { dimension, headline, search, report: error ? errorState(apiErrorMessage(error, response?.status ?? 0), previous) : successState(data?.rows ?? []) } }); } @@ -354,7 +368,6 @@ export class AnalyticsDashboardController { if (!expanded) return; const value = expanded.dimension === "Country" ? countrySearchValue(search, this.#environment.languages) : search; void this.openBreakdown(expanded.dimension, expanded.headline, { - context: expanded.context, search: value, debounce: true, }); 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 f52465f..875d3b2 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dashboard.element.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dashboard.element.ts @@ -4,9 +4,9 @@ import type { AnalyticsDimension } from "../api/types.gen.js"; import { countryDisplayName, normalizeCountryCode } from "./country-display.js"; import type { AnalyticsDateRangeChangeDetail } from "./date-range-picker.element.js"; import type { AnalyticsFilter, AudienceDimension, DashboardMetric, UtmDimension } from "./dashboard-url-state.js"; -import type { AcquisitionView, BreakdownDialogContext } from "./dashboard-cards.js"; -import { AnalyticsDashboardController } from "./analytics-dashboard.controller.js"; -import { stateData, type AsyncState } from "./async-state.js"; +import type { AcquisitionView } from "./dashboard-cards.js"; +import { AnalyticsDashboardController, type DashboardState } from "./analytics-dashboard.controller.js"; +import { isInitialLoading, stateData, type AsyncState } from "./async-state.js"; import { analyticsDashboardStyles } from "./analytics-dashboard.styles.js"; import "./analytics-dashboard-header.element.js"; import "./analytics-summary.element.js"; @@ -73,6 +73,21 @@ export class WebAnalyticsDashboardElement extends UmbElementMixin(LitElement) { return state.status === "error" ? state.message : undefined; } + #renderHeader(state: DashboardState) { + return html` + ) => this.#controller.setConnection(event.detail.connection)} + @analytics-date-range-change=${(event: CustomEvent) => this.#controller.setDateRange(event.detail.preset, event.detail.range)}> + `; + } + render() { void this._revision; const state = this.#controller.state; @@ -92,22 +107,34 @@ export class WebAnalyticsDashboardElement extends UmbElementMixin(LitElement) { `; + const activeConnection = state.connections.find(({ key }) => key === state.connection); + if (activeConnection?.isConfigured === false) return html` +
+ ${this.#renderHeader(state)} +
+
+ +
+

Connection credentials required

+

Add server-side credentials for this connection to load analytics reports.

+ + Open settings + +
+
+
+
+ `; const expanded = state.expandedBreakdown; const expandedEvents = state.expandedEvents; const selected = state.selectedEvent; const capabilities = state.capabilities; return html`
) => this.#controller.toggleFilter(event.detail.dimension, event.detail.value)}> - ) => this.#controller.setConnection(event.detail.connection)} - @analytics-date-range-change=${(event: CustomEvent) => this.#controller.setDateRange(event.detail.preset, event.detail.range)}> + ${this.#renderHeader(state)} ${this.#renderFilters(state.filters)} ) => this.#controller.openBreakdown(event.detail.dimension, event.detail.headline, { context: event.detail.context })} + @view-breakdown=${(event: CustomEvent<{ dimension: AnalyticsDimension; headline: string }>) => this.#controller.openBreakdown(event.detail.dimension, event.detail.headline)} @view-events=${() => this.#controller.openEvents()} @select-event=${(event: CustomEvent<{ eventName: string }>) => this.#controller.selectEvent(event.detail.eventName)} @select-flag=${(event: CustomEvent<{ flagKey: string }>) => this.#controller.selectFlag(event.detail.flagKey)} @@ -143,17 +170,19 @@ export class WebAnalyticsDashboardElement extends UmbElementMixin(LitElement) { ${expanded ? html` ) => this.#controller.searchBreakdown(event.detail.search)} - @breakdown-dimension-change=${(event: CustomEvent<{ dimension: AnalyticsDimension; headline: string }>) => this.#controller.openBreakdown(event.detail.dimension, event.detail.headline, { context: expanded.context })} + @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` @@ -162,7 +191,8 @@ export class WebAnalyticsDashboardElement extends UmbElementMixin(LitElement) { .filters=${state.filters} .detailsEnabled=${capabilities?.eventDetails ?? false} .filteringEnabled=${capabilities?.globalEventFiltering ?? false} - .loading=${expandedEvents.status === "loading"} + .loading=${isInitialLoading(expandedEvents)} + aria-busy=${expandedEvents.status === "loading" ? "true" : "false"} .unavailable=${this.#error(expandedEvents)} @search-events=${(event: CustomEvent<{ search: string }>) => this.#controller.openEvents(event.detail.search, true)} @select-event=${(event: CustomEvent<{ eventName: string }>) => this.#controller.selectEvent(event.detail.eventName)} @@ -173,13 +203,14 @@ export class WebAnalyticsDashboardElement extends UmbElementMixin(LitElement) { .eventName=${selected.eventName} .propertiesEnabled=${capabilities?.eventProperties ?? false} .details=${stateData(selected.details)} - .loading=${selected.details.status === "loading"} + .loading=${isInitialLoading(selected.details)} .unavailable=${this.#error(selected.details)} .filterProperty=${selected.eventProperty} .filterValue=${selected.eventValue} .searchedProperty=${stateData(selected.property)} .searchedTerm=${selected.propertySearch} - .searchLoading=${selected.property.status === "loading"} + .searchLoading=${isInitialLoading(selected.property)} + aria-busy=${selected.details.status === "loading" || selected.property.status === "loading" ? "true" : "false"} .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)} 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 b1e8a69..9a6b56f 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dashboard.styles.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dashboard.styles.ts @@ -4,6 +4,12 @@ import { UmbTextStyles } from "@umbraco-cms/backoffice/style"; export const analyticsDashboardStyles = [UmbTextStyles, css` :host { display: block; } main { container-type: inline-size; margin-inline: auto; max-width: 110rem; padding: var(--uui-size-layout-1); } + .connection-setup-region { display: grid; min-block-size: min(28rem, 55vh); padding: var(--uui-size-layout-1); place-items: center; } + .connection-setup { align-items: start; background: color-mix(in srgb, var(--uui-color-warning) 7%, var(--uui-color-surface)); border: 1px solid var(--uui-color-border); border-radius: var(--uui-border-radius); box-sizing: border-box; display: grid; gap: var(--uui-size-space-4); grid-template-columns: auto minmax(0, 1fr); inline-size: 100%; max-inline-size: 46rem; padding: var(--uui-size-layout-1); } + .connection-setup > uui-icon { color: var(--uui-color-warning-standalone); font-size: var(--uui-type-h4-size); margin-block-start: var(--uui-size-space-1); } + .connection-setup-content { align-items: flex-start; display: flex; flex-direction: column; gap: var(--uui-size-space-3); min-inline-size: 0; } + .connection-setup h2 { font-size: var(--uui-type-h4-size); font-weight: 400; line-height: var(--uui-type-h4-line-height); margin: 0; } + .connection-setup p { color: var(--uui-color-text-alt); margin: 0 0 var(--uui-size-space-2); max-inline-size: 65ch; } .active-filters { align-items: center; background: color-mix(in srgb, var(--uui-color-interactive) 3%, var(--uui-color-surface)); border: 1px solid var(--uui-color-border); border-radius: var(--uui-border-radius); display: flex; gap: var(--uui-size-space-3); margin-bottom: var(--uui-size-space-5); min-inline-size: 0; padding: var(--uui-size-space-2); } .filter-heading { align-items: center; color: var(--uui-color-text-alt); display: flex; flex: 0 0 auto; gap: var(--uui-size-space-2); padding-inline: var(--uui-size-space-2); } .filter-heading uui-icon { color: var(--uui-color-interactive); } diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dialog-headline.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dialog-headline.ts new file mode 100644 index 0000000..f5c5bc8 --- /dev/null +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dialog-headline.ts @@ -0,0 +1,18 @@ +import { html, nothing } from "@umbraco-cms/backoffice/external/lit"; + +export function renderAnalyticsDialogHeadline( + headline: string, + closeLabel: string, + onClose: () => void, + controls?: unknown, +) { + return html` +
+

${headline}

+ ${controls ? html`
${controls}
` : nothing} + +
+ `; +} 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 1e1d95c..d19390a 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dialog.styles.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dialog.styles.ts @@ -13,9 +13,61 @@ export const analyticsDialogStyles = css` width: 100%; } dialog::backdrop { background: color-mix(in srgb, var(--uui-color-text) 45%, transparent); } - uui-dialog-layout { - --uui-size-10: var(--uui-size-space-5); - --uui-size-14: var(--uui-size-space-6); + .analytics-dialog-layout { + --analytics-dialog-inline-padding: var(--uui-size-space-5); + color: var(--uui-color-text); + } + .analytics-dialog-headline { + align-items: center; + border-bottom: 1px solid var(--uui-color-border); + box-sizing: border-box; + display: grid; + gap: var(--uui-size-space-5); + grid-template-columns: minmax(0, 1fr) 2.75rem; + min-block-size: 3.5rem; + padding-block: var(--uui-size-space-3); + padding-inline: var(--analytics-dialog-inline-padding); + } + .analytics-dialog-headline.has-controls { grid-template-columns: max-content minmax(11rem, 16rem) minmax(0, 1fr) 2.75rem; } + .analytics-dialog-headline.has-controls .analytics-dialog-close { grid-column: 4; } + .analytics-dialog-headline h2 { + font-size: var(--uui-type-default-size); + font-weight: 700; + line-height: var(--uui-type-default-line-height); + margin: 0; + min-width: 0; + overflow-wrap: anywhere; + } + .analytics-dialog-headline-controls { min-width: 0; } + .analytics-dialog-close { + align-items: center; + appearance: none; + background: transparent; + block-size: 2.75rem; + border: 0; + border-radius: var(--uui-border-radius); + color: var(--uui-color-text-alt); + cursor: pointer; + display: inline-flex; + flex: 0 0 auto; + font: inherit; + inline-size: 2.75rem; + justify-content: center; + line-height: 1; + padding: 0; + } + .analytics-dialog-close span { + font-size: var(--uui-type-h3-size); + line-height: 1; + transform: translateY(-0.03em); + } + .analytics-dialog-close:hover { + background: var(--uui-color-surface-alt); + color: var(--uui-color-text); + } + .analytics-dialog-close:focus-visible { + outline: 2px solid var(--uui-color-selected); + outline-offset: 2px; } .analytics-dialog-body { block-size: var(--analytics-dialog-body-height, min(30rem, 52dvh)); @@ -23,6 +75,9 @@ export const analyticsDialogStyles = css` } @media (max-width: 600px) { dialog { max-height: 100dvh; max-width: 100vw; } + .analytics-dialog-headline.has-controls { grid-template-columns: minmax(0, 1fr) 2.75rem; } + .analytics-dialog-headline-controls { grid-column: 1 / -1; grid-row: 2; } + .analytics-dialog-headline.has-controls .analytics-dialog-close { grid-column: 2; grid-row: 1; } .analytics-dialog-body { block-size: 48dvh; } } `; diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-summary.element.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-summary.element.ts index 9e863e5..c863806 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-summary.element.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-summary.element.ts @@ -5,7 +5,7 @@ import type { AnalyticsSummary } from "../api/types.gen.js"; import { inclusiveRangeDays, type AnalyticsDateRange } from "./date-range.js"; import { metricComparison } from "./metric-comparison.js"; import type { DashboardMetric } from "./dashboard-url-state.js"; -import { stateData, type AsyncState } from "./async-state.js"; +import { isInitialLoading, stateData, type AsyncState } from "./async-state.js"; import "./history-chart.element.js"; @customElement("web-analytics-summary") @@ -76,7 +76,7 @@ export class WebAnalyticsSummaryElement extends UmbElementMixin(LitElement) { @click=${() => this.#selectMetric(metric)} @keydown=${this.#onTabKeydown}> ${label} - ${this.report.status === "loading" + ${isInitialLoading(this.report) ? html`` : html` ${this.localize.number(stateData(this.report)?.totals[metric] ?? 0)} @@ -110,7 +110,7 @@ export class WebAnalyticsSummaryElement extends UmbElementMixin(LitElement) { class="history-panel" role="tabpanel" aria-labelledby=${`metric-${this.metric}-tab`}> - ${this.report.status === "loading" ? html` + ${isInitialLoading(this.report) ? html` Loading traffic summary and history ` : stateData(this.report)?.points.length diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/async-state.test.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/async-state.test.ts new file mode 100644 index 0000000..d43d768 --- /dev/null +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/async-state.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from "vitest"; +import { isInitialLoading, loadingState, successState } from "./async-state.js"; + +describe("async state", () => { + it("distinguishes the initial load from a refresh with previous data", () => { + expect(isInitialLoading(undefined)).toBe(true); + expect(isInitialLoading(loadingState())).toBe(true); + expect(isInitialLoading(loadingState(successState({ rows: ["previous"] })))).toBe(false); + }); +}); diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/async-state.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/async-state.ts index bf1a33b..c6f6723 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/async-state.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/async-state.ts @@ -21,3 +21,7 @@ export function errorState(message: string, state?: AsyncState): AsyncStat export function stateData(state: AsyncState): T | undefined { return "data" in state ? state.data : "previous" in state ? state.previous : undefined; } + +export function isInitialLoading(state: AsyncState | undefined): boolean { + return !state || state.status === "idle" || (state.status === "loading" && state.previous === undefined); +} diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/breakdown-dialog.element.test.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/breakdown-dialog.element.test.ts index 76a0f45..aab6660 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/breakdown-dialog.element.test.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/breakdown-dialog.element.test.ts @@ -12,7 +12,6 @@ vi.mock("@umbraco-cms/backoffice/style", () => ({ UmbTextStyles: [] })); import type { WebAnalyticsBreakdownDialogElement } from "./breakdown-dialog.element.js"; import type { WebAnalyticsBreakdownTableElement } from "./breakdown-table.element.js"; -import { UTM_OPTIONS } from "./dashboard-cards.js"; import "./breakdown-dialog.element.js"; beforeEach(() => { HTMLDialogElement.prototype.showModal = vi.fn(); }); @@ -23,13 +22,8 @@ describe("breakdown dialog", () => { const dialog = document.createElement("web-analytics-breakdown-dialog") as WebAnalyticsBreakdownDialogElement; dialog.headline = "UTM media"; dialog.dimension = "UtmMedium"; - dialog.context = { - kind: "acquisition", - title: "Traffic sources", - referrer: { dimension: "ReferrerHostname", headline: "Referrers", label: "Referrers" }, - utmDimension: "UtmMedium", - utmOptions: UTM_OPTIONS, - }; + dialog.availableDimensions = ["ReferrerHostname", "UtmSource", "UtmMedium", "UtmCampaign", "UtmTerm", "UtmContent"]; + dialog.preferredUtmDimension = "UtmMedium"; dialog.rows = [{ value: "email", visitors: 8, pageViews: 11 }]; const onDimensionChange = vi.fn(); dialog.addEventListener("breakdown-dimension-change", onDimensionChange); @@ -37,7 +31,11 @@ describe("breakdown dialog", () => { await dialog.updateComplete; expect(dialog.shadowRoot?.querySelector("dialog")?.getAttribute("aria-label")).toBe("Traffic sources"); - expect(dialog.shadowRoot?.querySelector("uui-dialog-layout")?.getAttribute("headline")).toBe("Traffic sources"); + expect(dialog.shadowRoot?.querySelector(".analytics-dialog-headline h2")?.textContent).toBe("Traffic sources"); + expect(dialog.shadowRoot?.querySelector(".analytics-dialog-close")?.getAttribute("aria-label")).toBe("Close Traffic sources"); + expect(dialog.shadowRoot?.querySelector(".analytics-dialog-headline-controls uui-input")?.getAttribute("label")).toBe("Search UTM media"); + expect(dialog.shadowRoot?.querySelector(".analytics-dialog-controls")).toBeNull(); + expect(dialog.shadowRoot?.querySelector('[slot="actions"]')).toBeNull(); const table = dialog.shadowRoot?.querySelector("web-analytics-breakdown-table")!; await table.updateComplete; expect([...table.shadowRoot?.querySelectorAll('.report-tabs.primary [role="tab"]') ?? []] @@ -63,25 +61,47 @@ describe("breakdown dialog", () => { document.body.append(dialog); await dialog.updateComplete; - expect(dialog.shadowRoot?.querySelector("uui-dialog-layout")?.getAttribute("headline")).toBe("Countries"); + expect(dialog.shadowRoot?.querySelector(".analytics-dialog-headline h2")?.textContent).toBe("Countries"); const table = dialog.shadowRoot?.querySelector("web-analytics-breakdown-table")!; await table.updateComplete; expect(table.rowLabel).toBe("Country"); expect(table.shadowRoot?.querySelector(".report-tabs")).toBeNull(); + expect([...table.shadowRoot?.querySelectorAll("thead th") ?? []].map((header) => header.textContent?.trim())).toEqual([ + "Country", + "Visitors", + "Page views", + ]); + }); + + it("keeps percentage-dimension visitor share beside the visitor count", async () => { + const dialog = document.createElement("web-analytics-breakdown-dialog") as WebAnalyticsBreakdownDialogElement; + dialog.headline = "Countries"; + dialog.dimension = "Country"; + dialog.metric = "pageViews"; + dialog.rows = [ + { value: "DK", visitors: 10, pageViews: 30 }, + { value: "US", visitors: 10, pageViews: 10 }, + ]; + document.body.append(dialog); + await dialog.updateComplete; + const table = dialog.shadowRoot?.querySelector("web-analytics-breakdown-table")!; + await table.updateComplete; + + const shares = [...table.shadowRoot?.querySelectorAll(".metric-share") ?? []]; + expect(shares.map((share) => share.textContent)).toEqual(["50%", "50%"]); + expect(shares.every((share) => share.closest("td")?.cellIndex === 1)).toBe(true); + expect([...shares[0].parentElement?.children ?? []].map((element) => element.className)).toEqual([ + "filter-action", + "metric-share", + "metric-number", + ]); }); it("keeps audience tabs inside the table header", async () => { const dialog = document.createElement("web-analytics-breakdown-dialog") as WebAnalyticsBreakdownDialogElement; dialog.headline = "Devices"; dialog.dimension = "DeviceType"; - dialog.context = { - kind: "audience", - title: "Audience", - options: [ - { dimension: "DeviceType", headline: "Devices", label: "Devices" }, - { dimension: "BrowserName", headline: "Browsers", label: "Browsers" }, - ], - }; + dialog.availableDimensions = ["DeviceType", "BrowserName"]; const onDimensionChange = vi.fn(); dialog.addEventListener("breakdown-dimension-change", onDimensionChange); document.body.append(dialog); @@ -89,7 +109,7 @@ describe("breakdown dialog", () => { const table = dialog.shadowRoot?.querySelector("web-analytics-breakdown-table")!; await table.updateComplete; - expect(dialog.shadowRoot?.querySelector("uui-dialog-layout")?.getAttribute("headline")).toBe("Audience"); + expect(dialog.shadowRoot?.querySelector(".analytics-dialog-headline h2")?.textContent).toBe("Audience"); const tabs = [...table.shadowRoot?.querySelectorAll('.report-tabs.primary [role="tab"]') ?? []]; expect(tabs.map((tab) => tab.textContent?.trim())).toEqual(["Devices", "Browsers"]); expect(tabs[0]?.getAttribute("aria-controls")).toBe("breakdown-report-panel"); @@ -102,4 +122,19 @@ describe("breakdown dialog", () => { }); expect(table.shadowRoot?.activeElement).toBe(tabs[1]); }); + + it("does not render unsupported grouped dimensions", async () => { + const dialog = document.createElement("web-analytics-breakdown-dialog") as WebAnalyticsBreakdownDialogElement; + dialog.headline = "Referrers"; + dialog.dimension = "ReferrerHostname"; + dialog.availableDimensions = ["ReferrerHostname"]; + document.body.append(dialog); + await dialog.updateComplete; + const table = dialog.shadowRoot?.querySelector("web-analytics-breakdown-table")!; + await table.updateComplete; + + expect([...table.shadowRoot?.querySelectorAll('.report-tabs.primary [role="tab"]') ?? []] + .map((tab) => tab.textContent?.trim())).toEqual(["Referrers"]); + expect(table.shadowRoot?.querySelector('.report-tabs.secondary')).toBeNull(); + }); }); diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/breakdown-dialog.element.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/breakdown-dialog.element.ts index 030801a..103143e 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/breakdown-dialog.element.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/breakdown-dialog.element.ts @@ -10,10 +10,11 @@ import { UmbElementMixin } from "@umbraco-cms/backoffice/element-api"; import { UmbTextStyles } from "@umbraco-cms/backoffice/style"; import type { UUIInputElement } from "@umbraco-cms/backoffice/external/uui"; import type { AnalyticsBreakdownRow, AnalyticsDimension } from "../api/types.gen.js"; +import { renderAnalyticsDialogHeadline } from "./analytics-dialog-headline.js"; import { analyticsDialogStyles } from "./analytics-dialog.styles.js"; -import { breakdownDimensionLabel, breakdownMetricTotal, type TrafficMetric } from "./breakdown-rows.js"; -import type { BreakdownDialogContext, DimensionOption } from "./dashboard-cards.js"; -import type { AnalyticsFilter } from "./dashboard-url-state.js"; +import { breakdownDimensionLabel, type TrafficMetric } from "./breakdown-rows.js"; +import { AUDIENCE_OPTIONS, breakdownDialogGroup, referrerDimensionOption, UTM_OPTIONS, type DimensionOption } from "./dashboard-cards.js"; +import type { AnalyticsFilter, UtmDimension } from "./dashboard-url-state.js"; import { isUtmDimension } from "./utm-capability.js"; import type { ReportTabGroup } from "./report-tabs.js"; import "./breakdown-table.element.js"; @@ -29,8 +30,10 @@ export class WebAnalyticsBreakdownDialogElement extends UmbElementMixin(LitEleme @property({ type: Boolean }) linkValues = false; @property({ attribute: false }) rows: AnalyticsBreakdownRow[] = []; @property({ attribute: false }) filters: AnalyticsFilter[] = []; - @property({ attribute: false }) context?: BreakdownDialogContext; + @property({ attribute: false }) availableDimensions: AnalyticsDimension[] = []; + @property() preferredUtmDimension: UtmDimension = "UtmSource"; @state() private _search = ""; + @state() private _utmDimension?: UtmDimension; protected firstUpdated(): void { this.shadowRoot?.querySelector("dialog")?.showModal(); @@ -60,6 +63,8 @@ export class WebAnalyticsBreakdownDialogElement extends UmbElementMixin(LitEleme #selectDimension(option?: DimensionOption): void { if (!option || option.dimension === this.dimension) return; + if (this.dimension && isUtmDimension(this.dimension)) this._utmDimension = this.dimension; + if (isUtmDimension(option.dimension)) this._utmDimension = option.dimension; this._search = ""; this.dispatchEvent(new CustomEvent("breakdown-dimension-change", { bubbles: true, @@ -68,22 +73,51 @@ export class WebAnalyticsBreakdownDialogElement extends UmbElementMixin(LitEleme })); } + #supports(dimension: AnalyticsDimension): boolean { + return this.availableDimensions.length === 0 || this.availableDimensions.includes(dimension); + } + + #audienceOptions(): ReadonlyArray { + return AUDIENCE_OPTIONS.filter(({ dimension }) => this.#supports(dimension)); + } + + #utmOptions(): ReadonlyArray> { + return UTM_OPTIONS.filter(({ dimension }) => this.#supports(dimension)); + } + + #referrerOption(): DimensionOption | undefined { + if (this.#supports("ReferrerHostname")) return referrerDimensionOption("ReferrerHostname"); + if (this.#supports("Referrer")) return referrerDimensionOption("Referrer"); + return undefined; + } + + #selectedUtmOption(): DimensionOption | undefined { + const options = this.#utmOptions(); + const selected = this._utmDimension + ?? (this.dimension && isUtmDimension(this.dimension) ? this.dimension : this.preferredUtmDimension); + return options.find(({ dimension }) => dimension === selected) ?? options[0]; + } + #headingTabs(): ReportTabGroup | undefined { - if (this.context?.kind === "audience") { + const group = breakdownDialogGroup(this.dimension); + if (group === "audience") { + const options = this.#audienceOptions(); return { ariaLabel: "Audience technology", idPrefix: "expanded-audience-tab", - options: this.context.options.map(({ dimension, label }) => ({ value: dimension, label })), - selected: this.dimension ?? this.context.options[0]?.dimension ?? "DeviceType", + options: options.map(({ dimension, label }) => ({ value: dimension, label })), + selected: this.dimension ?? options[0]?.dimension ?? "DeviceType", }; } - if (this.context?.kind === "acquisition") { + if (group === "acquisition") { + const referrer = this.#referrerOption(); + const utm = this.#selectedUtmOption(); return { ariaLabel: "Traffic source", idPrefix: "expanded-acquisition-tab", options: [ - { value: "referrers", label: "Referrers" }, - { value: "utm", label: "UTM" }, + ...(referrer ? [{ value: "referrers", label: "Referrers" }] : []), + ...(utm ? [{ value: "utm", label: "UTM" }] : []), ], selected: this.dimension && isUtmDimension(this.dimension) ? "utm" : "referrers", }; @@ -92,58 +126,58 @@ export class WebAnalyticsBreakdownDialogElement extends UmbElementMixin(LitEleme } #subheadingTabs(): ReportTabGroup | undefined { - if (this.context?.kind !== "acquisition" || !this.dimension || !isUtmDimension(this.dimension)) return undefined; + if (breakdownDialogGroup(this.dimension) !== "acquisition" || !this.dimension || !isUtmDimension(this.dimension)) return undefined; return { appearance: "secondary", ariaLabel: "UTM parameter", idPrefix: "expanded-utm-tab", - options: this.context.utmOptions.map(({ dimension, label }) => ({ value: dimension, label })), + options: this.#utmOptions().map(({ dimension, label }) => ({ value: dimension, label })), selected: this.dimension, }; } #selectHeading(value: string): void { - if (this.context?.kind === "audience") { - this.#selectDimension(this.context.options.find(({ dimension }) => dimension === value)); + const group = breakdownDialogGroup(this.dimension); + if (group === "audience") { + this.#selectDimension(this.#audienceOptions().find(({ dimension }) => dimension === value)); return; } - if (this.context?.kind === "acquisition") { - const context = this.context; + if (group === "acquisition") { this.#selectDimension(value === "referrers" - ? context.referrer - : context.utmOptions.find(({ dimension }) => dimension === context.utmDimension)); + ? this.#referrerOption() + : this.#selectedUtmOption()); } } #selectSubheading(value: string): void { - if (this.context?.kind === "acquisition") { - this.#selectDimension(this.context.utmOptions.find(({ dimension }) => dimension === value)); - } + if (breakdownDialogGroup(this.dimension) === "acquisition") this.#selectDimension(this.#utmOptions().find(({ dimension }) => dimension === value)); } render() { - const dialogHeadline = this.context?.title ?? this.headline; + const group = breakdownDialogGroup(this.dimension); + const dialogHeadline = group === "audience" ? "Audience" : group === "acquisition" ? "Traffic sources" : this.headline; const headingTabs = this.#headingTabs(); const subheadingTabs = this.#subheadingTabs(); return html` - - - - +
+ ${renderAnalyticsDialogHeadline(dialogHeadline, `Close ${dialogHeadline}`, () => this.#close(), html` + + + + `)}
) => this.#selectHeading(event.detail.value)} @subheading-tab-change=${(event: CustomEvent<{ value: string }>) => this.#selectSubheading(event.detail.value)}>
- Close - +
`; } @@ -166,7 +198,7 @@ export class WebAnalyticsBreakdownDialogElement extends UmbElementMixin(LitEleme static styles = [UmbTextStyles, analyticsDialogStyles, 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 { margin-top: var(--uui-size-space-4); overflow: auto; scrollbar-gutter: stable; } + .results { overflow: auto; scrollbar-gutter: stable; } `]; } 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 eb75990..a32d79f 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/breakdown-table.element.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/breakdown-table.element.ts @@ -5,6 +5,7 @@ import { analyticsRowHref, breakdownBarRatio, breakdownDisplayValue, + breakdownMetricTotal, breakdownMetricValue, breakdownPercentage, isPercentageDimension, @@ -30,7 +31,7 @@ export class WebAnalyticsBreakdownTableElement extends UmbElementMixin(LitElemen @property() metric: TrafficMetric = "visitors"; @property({ type: Boolean }) loading = false; @property({ type: Boolean }) linkValues = false; - @property({ type: Boolean }) hasSubheading = false; + @property({ type: Boolean }) compact = false; @property({ type: Number }) skeletonRows = 10; @property({ type: Number }) total = 0; @property({ attribute: false }) rows: AnalyticsBreakdownRow[] = []; @@ -39,6 +40,7 @@ export class WebAnalyticsBreakdownTableElement extends UmbElementMixin(LitElemen @property({ attribute: false }) subheadingTabs?: ReportTabGroup; render() { + const percentageMode = this.compact && isPercentageDimension(this.dimension); if (this.loading) { return html` Loading ${this.headline} @@ -49,6 +51,7 @@ export class WebAnalyticsBreakdownTableElement extends UmbElementMixin(LitElemen
+ ${this.compact ? nothing : html``} `)}
Events and goals
Events and goalsVisitorsTotal
EventVisitorsTotal events
@@ -56,7 +59,8 @@ export class WebAnalyticsBreakdownTableElement extends UmbElementMixin(LitElemen } const rows = visibleBreakdownRows(this.rows); const maximum = Math.max(...rows.map((row) => breakdownMetricValue(row, this.metric)), 1); - const percentageDimension = isPercentageDimension(this.dimension); + const percentageTotal = this.total > 0 ? this.total : breakdownMetricTotal(rows, this.metric); + const visitorTotal = breakdownMetricTotal(rows, "visitors"); const message = this.unavailable ?? (rows.length === 0 ? this.emptyMessage : undefined); const labelledBy = [this.headingTabs, this.subheadingTabs] @@ -64,11 +68,11 @@ export class WebAnalyticsBreakdownTableElement extends UmbElementMixin(LitElemen .filter(Boolean) .join(" "); return html` - +
${this.#renderHeading()} ${message ? html` - + ` : rows.map((row, index) => { const isReferrer = this.dimension === "ReferrerHostname" || this.dimension === "Referrer"; const href = isReferrer @@ -82,15 +86,38 @@ export class WebAnalyticsBreakdownTableElement extends UmbElementMixin(LitElemen ? countryDisplayName(countryCode, navigator.languages) : breakdownDisplayValue(row.value, this.dimension); const metricValue = breakdownMetricValue(row, this.metric); + const barRatio = breakdownBarRatio(metricValue, maximum); const percentage = breakdownPercentage( metricValue, - this.total, + percentageTotal, + (value, options) => this.localize.number(value, options), + ); + const visitorPercentage = breakdownPercentage( + row.visitors, + visitorTotal, (value, options) => this.localize.number(value, options), ); - const barRatio = breakdownBarRatio(metricValue, maximum); const tooltipId = `breakdown-value-${index}`; const activeFilter = this.filters.some((filter) => filter.dimension === this.dimension && filter.value === row.value); const filterLabel = activeFilter ? `Remove ${displayValue} filter` : `Filter analytics by ${displayValue}`; + const filterAction = html` + `; + const expandedPercentageMode = !this.compact && isPercentageDimension(this.dimension); + const expandedPercentage = expandedPercentageMode + ? html`` + : nothing; return html` - + + ` : this.compact ? html` + + ` : html` + + + `} `;})}
${this.headline}

${message}

${message}

@@ -103,31 +130,26 @@ export class WebAnalyticsBreakdownTableElement extends UmbElementMixin(LitElemen : displayValue} - - ${percentageDimension ? html` - - - ${this.localize.number(metricValue)} ${this.#metricLabel().toLocaleLowerCase()}, ${percentage.precise} of the total - - ${this.localize.number(metricValue)} - ${percentage.precise} + ${percentageMode ? html` + + + ${filterAction} + + + ${this.localize.number(metricValue)} ${this.#metricLabel().toLocaleLowerCase()}, ${percentage.precise} of the total + + ${this.localize.number(metricValue)} + ${percentage.precise} + - ` : html`${this.localize.number(metricValue)}`} - ${filterAction}${this.localize.number(metricValue)}${this.metric === "visitors" || expandedPercentageMode ? filterAction : nothing}${expandedPercentage}${this.localize.number(row.visitors)}${this.metric === "pageViews" && !expandedPercentageMode ? filterAction : nothing}${this.localize.number(row.pageViews)}
@@ -149,10 +171,12 @@ export class WebAnalyticsBreakdownTableElement extends UmbElementMixin(LitElemen detail: { value }, })), BREAKDOWN_PANEL_ID) : this.rowLabel ?? this.headline} - ${this.#metricLabel()} + ${this.compact + ? html`${this.#metricLabel()}` + : html`VisitorsPage views`} - ${this.hasSubheading || this.subheadingTabs ? html` - ${this.subheadingTabs + ${this.subheadingTabs ? html` + ${this.subheadingTabs ? renderReportTabs(this.subheadingTabs, (value) => this.dispatchEvent(new CustomEvent("subheading-tab-change", { bubbles: true, composed: true, @@ -165,18 +189,23 @@ export class WebAnalyticsBreakdownTableElement extends UmbElementMixin(LitElemen } static styles = [reportTabsStyles, css` - :host { display: block; overflow-x: auto; overflow-y: visible; scrollbar-width: thin; } + :host { display: block; overflow: visible; } table { --bar-inset: var(--uui-size-space-3); --metric-column-width: 8.5rem; + --metric-columns-width: var(--metric-column-width); border-collapse: collapse; - min-inline-size: 20rem; + min-inline-size: min(20rem, 100%); table-layout: fixed; width: 100%; } + table:not(.compact-table) { + --metric-column-width: 9.5rem; + --metric-columns-width: calc(var(--metric-column-width) * 2); + } caption { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); } - thead th { border-bottom: 1px solid var(--uui-color-border); font-weight: 700; } - thead th:nth-child(2) { color: var(--uui-color-text-alt); text-align: right; width: var(--metric-column-width); } + thead th { block-size: 2.5rem; border-bottom: 1px solid var(--uui-color-border); font-weight: 700; } + thead th:nth-child(n + 2) { color: var(--uui-color-text-alt); text-align: right; white-space: nowrap; width: var(--metric-column-width); } .subheading-row th { background: color-mix(in srgb, var(--uui-color-surface-alt) 35%, var(--uui-color-surface)); padding-block: 0; } th, td { box-sizing: border-box; padding: var(--uui-size-space-3) var(--uui-size-space-5); text-align: left; } tbody tr { height: 2.5rem; } @@ -185,6 +214,7 @@ export class WebAnalyticsBreakdownTableElement extends UmbElementMixin(LitElemen tbody tr:hover, tbody tr:focus-within { position: relative; z-index: 2; } .metric-cell { align-items: center; display: flex; gap: var(--uui-size-space-2); justify-content: flex-end; } .metric-number { font-weight: 700; min-inline-size: 0; } + .metric-share { color: color-mix(in srgb, var(--uui-color-text) 60%, transparent); font-weight: 700; margin-inline-end: var(--uui-size-space-3); } .filter-action { align-items: center; appearance: none; background: transparent; block-size: 1.75rem; border: 0; border-radius: var(--uui-border-radius); color: var(--uui-color-text-alt); cursor: pointer; display: inline-flex; font: inherit; inline-size: 1.75rem; justify-content: center; opacity: 0; padding: 0; } tbody tr:hover .filter-action, .filter-action:focus-visible, .filter-action[aria-pressed="true"] { opacity: 1; } .filter-action:hover { background: color-mix(in srgb, var(--uui-color-interactive) 10%, var(--uui-color-surface)); color: var(--uui-color-interactive-emphasis); } @@ -232,17 +262,8 @@ export class WebAnalyticsBreakdownTableElement extends UmbElementMixin(LitElemen right: var(--uui-size-space-4); top: 100%; } - .percentage-tooltip.below { - bottom: auto; - top: calc(100% + var(--uui-size-space-3)); - transform: translateY(calc(-1 * var(--uui-size-space-2))); - } - .percentage-tooltip.below::after { - border-bottom-color: var(--uui-color-text); - border-top-color: transparent; - bottom: 100%; - top: auto; - } + .percentage-tooltip.below { bottom: auto; top: calc(100% + var(--uui-size-space-3)); transform: translateY(calc(-1 * var(--uui-size-space-2))); } + .percentage-tooltip.below::after { border-bottom-color: var(--uui-color-text); border-top-color: transparent; bottom: 100%; top: auto; } .percentage-tooltip strong { font-size: var(--uui-type-default-size); } .percentage-tooltip span { color: color-mix(in srgb, var(--uui-color-surface) 70%, transparent); } .percentage-value:hover .percentage-tooltip, @@ -253,7 +274,7 @@ export class WebAnalyticsBreakdownTableElement extends UmbElementMixin(LitElemen .bar { inset-block: 0.25rem; inset-inline-start: var(--bar-inset); - inline-size: calc(100% + var(--metric-column-width) - var(--bar-inset) - var(--bar-inset)); + inline-size: calc(100% + var(--metric-columns-width) - var(--bar-inset) - var(--bar-inset)); position: absolute; } .bar::before { diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/dashboard-cards.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/dashboard-cards.ts index 80e65ed..54488c1 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/dashboard-cards.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/dashboard-cards.ts @@ -1,6 +1,6 @@ import type { AnalyticsDimension } from "../api/types.gen.js"; import type { AudienceDimension, UtmDimension } from "./dashboard-url-state.js"; -import type { UtmCapability } from "./utm-capability.js"; +import { isUtmDimension, type UtmCapability } from "./utm-capability.js"; export type DimensionOption = { dimension: TDimension; @@ -8,19 +8,7 @@ export type DimensionOption; - } - | { - kind: "acquisition"; - title: "Traffic sources"; - referrer: DimensionOption; - utmDimension: UtmDimension; - utmOptions: ReadonlyArray>; - }; +export type BreakdownDialogGroup = "audience" | "acquisition"; export type DashboardCard = | { @@ -44,7 +32,7 @@ export type DashboardReportPlan = { dimensions: ReadonlyArray; }; -const AUDIENCE_OPTIONS: ReadonlyArray> = [ +export const AUDIENCE_OPTIONS: ReadonlyArray> = [ { dimension: "DeviceType", headline: "Devices", label: "Devices" }, { dimension: "BrowserName", headline: "Browsers", label: "Browsers" }, ]; @@ -57,6 +45,16 @@ export const UTM_OPTIONS: ReadonlyArray> = [ { dimension: "UtmContent", headline: "UTM content", label: "Content" }, ]; +export function breakdownDialogGroup(dimension?: AnalyticsDimension): BreakdownDialogGroup | undefined { + if (dimension === "DeviceType" || dimension === "BrowserName") return "audience"; + if (dimension === "ReferrerHostname" || dimension === "Referrer" || (dimension && isUtmDimension(dimension))) return "acquisition"; + return undefined; +} + +export function referrerDimensionOption(dimension: "ReferrerHostname" | "Referrer"): DimensionOption { + return { dimension, headline: "Referrers", label: "Referrers" }; +} + const SHARED_CARDS: ReadonlyArray = [ { kind: "breakdown", dimension: "Country", headline: "Countries", span: "normal" }, { kind: "tabbed-breakdown", id: "audience", options: AUDIENCE_OPTIONS, reportLoading: "eager", span: "normal", planLimited: false }, @@ -79,7 +77,7 @@ export function dashboardCards( ): ReadonlyArray { const cards: DashboardCard[] = [ ...(documentScoped ? [] : [{ kind: "breakdown" as const, dimension: "RequestPath" as const, headline: "Pages", span: "wide" as const }]), - { kind: "breakdown", dimension: referrerDimension, headline: "Referrers", span: "wide" }, + { kind: "breakdown", ...referrerDimensionOption(referrerDimension), span: "wide" }, ...SHARED_CARDS, ]; if (utmCapability === "available") cards.push(UTM_CARD); 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 9a788e7..55bfd66 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/event-details-dialog.element.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/event-details-dialog.element.ts @@ -3,6 +3,7 @@ import { UmbElementMixin } from "@umbraco-cms/backoffice/element-api"; import { UmbTextStyles } from "@umbraco-cms/backoffice/style"; import type { UUIInputElement } from "@umbraco-cms/backoffice/external/uui"; import type { AnalyticsEventDetails, AnalyticsEventProperty } from "../api/types.gen.js"; +import { renderAnalyticsDialogHeadline } from "./analytics-dialog-headline.js"; import { analyticsDialogStyles } from "./analytics-dialog.styles.js"; import { renderReportTabs, reportTabsStyles } from "./report-tabs.js"; @@ -161,7 +162,8 @@ export class WebAnalyticsEventDetailsDialogElement extends UmbElementMixin(LitEl const activeProperty = this.#activeProperty(); return html` - +
+ ${renderAnalyticsDialogHeadline(`${this.eventName} event`, "Close event details", () => this.#close())}
${this.details ? html` ${this.propertiesEnabled ? activeProperty ? html` @@ -172,19 +174,18 @@ export class WebAnalyticsEventDetailsDialogElement extends UmbElementMixin(LitEl ${this.unavailable ? html`` : ""} ` : this.loading ? html`
Loading event details…
` : this.unavailable ? html`

${this.unavailable}

` : ""}
- Close - +
`; } static styles = [UmbTextStyles, analyticsDialogStyles, reportTabsStyles, css` .dialog-content { --analytics-dialog-body-height: min(28rem, 52dvh); display: flex; flex-direction: column; position: relative; } - .property-controls { display: grid; flex: 0 0 auto; gap: var(--uui-size-space-3); padding-block-end: var(--uui-size-space-4); } + .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); } #event-property-panel { display: flex; flex: 1; flex-direction: column; min-block-size: 0; } - .property-table { flex: 1; margin-inline: calc(-1 * var(--uui-size-space-5)); min-block-size: 0; overflow: auto; scrollbar-gutter: stable; } + .property-table { flex: 1; min-block-size: 0; overflow: auto; scrollbar-gutter: stable; } table { --bar-inset: var(--uui-size-space-3); border-collapse: separate; border-spacing: 0; min-inline-size: 34rem; table-layout: fixed; width: 100%; } caption { clip: rect(0 0 0 0); height: 1px; overflow: hidden; position: absolute; width: 1px; } th, td { box-sizing: border-box; padding: var(--uui-size-space-3) var(--uui-size-space-5); text-align: left; } 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 a8fe9ac..624a371 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/event-dialog.element.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/event-dialog.element.ts @@ -3,6 +3,7 @@ import { UmbElementMixin } from "@umbraco-cms/backoffice/element-api"; import { UmbTextStyles } from "@umbraco-cms/backoffice/style"; import type { UUIInputElement } from "@umbraco-cms/backoffice/external/uui"; import type { AnalyticsEventRow } from "../api/types.gen.js"; +import { renderAnalyticsDialogHeadline } from "./analytics-dialog-headline.js"; import { analyticsDialogStyles } from "./analytics-dialog.styles.js"; import type { AnalyticsFilter } from "./dashboard-url-state.js"; import "./event-table.element.js"; @@ -29,10 +30,12 @@ export class WebAnalyticsEventDialogElement extends UmbElementMixin(LitElement) render() { return html` - - - - +
+ ${renderAnalyticsDialogHeadline("Events", "Close events", () => this.#close(), html` + + + + `)}
${!this.loading && this.unavailable ? html`

${this.unavailable}

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

Try a different search.

` : ""} @@ -40,8 +43,7 @@ export class WebAnalyticsEventDialogElement extends UmbElementMixin(LitElement) ` : ""}
- Close - +
`; } @@ -50,7 +52,7 @@ export class WebAnalyticsEventDialogElement extends UmbElementMixin(LitElement) dialog { --analytics-dialog-max-width: 58rem; } 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 { margin-top: var(--uui-size-space-4); overflow: auto; scrollbar-gutter: stable; } + .results { overflow: auto; scrollbar-gutter: stable; } `]; } diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/event-table.element.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/event-table.element.ts index 29ad52b..3e99df7 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/event-table.element.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/event-table.element.ts @@ -59,9 +59,9 @@ export class WebAnalyticsEventTableElement extends UmbElementMixin(LitElement) { }))}> ` : ""} - ${this.localize.number(row.visitors)} + ${this.localize.number(row.visitors)} - ${this.localize.number(row.count)} + ${this.localize.number(row.count)} `;})} @@ -91,6 +91,7 @@ export class WebAnalyticsEventTableElement extends UmbElementMixin(LitElement) { .details-action:hover { text-decoration: underline; text-underline-offset: 0.18em; } .details-action:focus-visible { outline: 2px solid var(--uui-color-selected); outline-offset: 2px; } .event-name { position: relative; z-index: 1; } + .metric-value { font-weight: 700; } .metric-cell { align-items: center; display: flex; gap: var(--uui-size-space-2); justify-content: flex-end; } .filter-action { align-items: center; appearance: none; background: transparent; border: 0; border-radius: var(--uui-border-radius); color: var(--uui-color-text-alt); cursor: pointer; display: inline-flex; font: inherit; justify-content: center; opacity: 0; padding: var(--uui-size-space-2); } tbody tr:hover .filter-action, .filter-action:focus-visible, .filter-action[aria-pressed="true"] { opacity: 1; } diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/flag-card.element.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/flag-card.element.ts index 40e150c..131cf50 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/flag-card.element.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/flag-card.element.ts @@ -2,7 +2,7 @@ import { LitElement, css, customElement, html, property } from "@umbraco-cms/bac import { UmbElementMixin } from "@umbraco-cms/backoffice/element-api"; import { UmbTextStyles } from "@umbraco-cms/backoffice/style"; import type { AnalyticsFlagRow, AnalyticsFlagsReport } from "../api/types.gen.js"; -import { stateData, type AsyncState } from "./async-state.js"; +import { isInitialLoading, stateData, type AsyncState } from "./async-state.js"; const FLAGS_SETUP_URL = "https://vercel.com/docs/flags/observability/web-analytics"; @@ -27,7 +27,7 @@ export class WebAnalyticsFlagCardElement extends UmbElementMixin(LitElement) { const activeState = this.selected ?? this.report; const data = stateData(activeState); const rows = this.#rows(data); - const loading = activeState.status === "idle" || activeState.status === "loading"; + const loading = isInitialLoading(activeState); const unavailable = activeState.status === "error" ? activeState.message : undefined; const selectedKey = data?.flagKey; const maximum = Math.max(...rows.map(({ pageViews }) => pageViews), 1); @@ -57,12 +57,12 @@ export class WebAnalyticsFlagCardElement extends UmbElementMixin(LitElement) { Set up flag tracking
` : html` -
+
${rows.map((row) => html`
${selectedKey - ? html`${row.value}` + ? html`${row.value}` : html``} ${this.localize.number(row.visitors)} ${this.localize.number(row.pageViews)} @@ -90,7 +90,7 @@ export class WebAnalyticsFlagCardElement extends UmbElementMixin(LitElement) { .bar { inset-block: var(--uui-size-space-1); inset-inline: var(--uui-size-space-3); position: absolute; } .bar::before { background: color-mix(in srgb, var(--uui-color-interactive) 4%, var(--uui-color-surface)); border-radius: var(--uui-border-radius); block-size: 100%; content: ""; display: block; inline-size: max(4px, var(--bar-width)); } .value { overflow: hidden; text-align: left; text-overflow: ellipsis; white-space: nowrap; } - .select { appearance: none; background: transparent; border: 0; color: var(--uui-color-text); cursor: pointer; font: inherit; font-weight: 600; padding: 0; } + .select { appearance: none; background: transparent; border: 0; color: var(--uui-color-text); cursor: pointer; font: inherit; font-weight: 500; padding: 0; } .select:hover { text-decoration: underline; text-underline-offset: .18em; } .empty { align-items: center; display: flex; flex: 1; flex-direction: column; gap: var(--uui-size-space-3); justify-content: center; padding: var(--uui-size-layout-1); text-align: center; } .empty-icon { align-items: center; border: 1px solid var(--uui-color-border); border-radius: 50%; color: var(--uui-color-text-alt); display: inline-flex; font-size: 1.5rem; height: 3rem; justify-content: center; width: 3rem; } diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/report-tabs.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/report-tabs.ts index 0328c4f..bfb349e 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/report-tabs.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/report-tabs.ts @@ -62,7 +62,10 @@ export const reportTabsStyles = css` .report-tabs { align-items: stretch; display: flex; - margin: var(--analytics-report-tabs-margin, calc(-1 * var(--uui-size-space-3))); + margin: var( + --analytics-report-tabs-margin, + calc(-1 * var(--uui-size-space-3)) calc(-1 * var(--uui-size-space-5)) + ); min-inline-size: 0; overflow-x: auto; overscroll-behavior-inline: contain; @@ -72,36 +75,34 @@ export const reportTabsStyles = css` appearance: none; background: transparent; border: 0; - border-bottom: 3px solid transparent; + box-sizing: border-box; color: var(--uui-color-text-alt); cursor: pointer; flex: 0 0 auto; font: inherit; - min-block-size: 2.75rem; - padding: var(--uui-size-space-3) var(--uui-size-space-4); + min-block-size: calc(2.5rem - 0.5px); + padding: var(--uui-size-space-3) var(--uui-size-space-5); white-space: nowrap; } .report-tabs button:hover { background: color-mix(in srgb, var(--uui-color-selected) 7%, transparent); color: var(--uui-color-text); } - .report-tabs button[aria-selected="true"] { border-bottom-color: var(--uui-color-selected); color: var(--uui-color-text); font-weight: 700; } + .report-tabs button[aria-selected="true"] { box-shadow: inset 0 -3px var(--uui-color-selected); color: var(--uui-color-text); font-weight: 700; } .report-tabs button:focus-visible { outline: 2px solid var(--uui-color-selected); outline-offset: -3px; } .report-tabs.secondary { - align-items: center; - gap: var(--uui-size-space-1); + align-items: stretch; + gap: 0; margin: var(--analytics-report-tabs-secondary-margin, 0 calc(-1 * var(--uui-size-space-3))); - padding-block: var(--uui-size-space-2); + padding-block: 0; } .report-tabs.secondary button { - border-bottom: 0; - border-radius: var(--uui-border-radius); min-block-size: 2.5rem; - padding: var(--uui-size-space-2) var(--uui-size-space-3); + padding: var(--uui-size-space-3); } - .report-tabs.secondary button[aria-selected="true"] { background: var(--uui-color-surface-alt); } + .report-tabs.secondary button[aria-selected="true"] { background: transparent; box-shadow: inset 0 -2px var(--uui-color-selected); } @media (pointer: coarse) { .report-tabs.secondary button { min-block-size: 2.75rem; } } @media (forced-colors: active) { - .report-tabs button[aria-selected="true"] { border-bottom-color: Highlight; } + .report-tabs button[aria-selected="true"] { box-shadow: inset 0 -3px Highlight; } .report-tabs.secondary button[aria-selected="true"] { outline: 2px solid Highlight; } } `; diff --git a/src/TheBuilder.WebAnalytics/Client/src/settings/settings-model.test.ts b/src/TheBuilder.WebAnalytics/Client/src/settings/settings-model.test.ts index 9b4202d..511b527 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/settings/settings-model.test.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/settings/settings-model.test.ts @@ -147,5 +147,8 @@ describe("analytics settings model", () => { connection.eventPropertyNames = Array.from({ length: 21 }, (_, index) => `property-${index}`); expect(validateConnection(connection, providerDescriptor(model, connection.provider)).eventPropertyNames).toBe("Add no more than 20 event properties."); + + connection.enableEvents = false; + expect(validateConnection(connection, providerDescriptor(model, connection.provider))).toEqual({}); }); }); diff --git a/src/TheBuilder.WebAnalytics/Client/src/settings/settings-model.ts b/src/TheBuilder.WebAnalytics/Client/src/settings/settings-model.ts index 1928ebd..8de939e 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/settings/settings-model.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/settings/settings-model.ts @@ -26,7 +26,7 @@ export function validateConnection( const identifier = identifierValue(connection, descriptor); if (!field || identifier === undefined) return { provider: `Unsupported identifier field for ${connection.provider}.` }; if (connection.mockScenario == null && !identifier.trim()) errors[field] = `Enter ${descriptor.identifier.requiredMessage}.`; - const eventProperties = descriptor.eventProperties; + const eventProperties = connection.enableEvents ? descriptor.eventProperties : undefined; if (eventProperties && connection.eventPropertyNames.length > eventProperties.maximumNames) errors.eventPropertyNames = `Add no more than ${eventProperties.maximumNames} event properties.`; else if (eventProperties && connection.eventPropertyNames.some((name) => name.length > eventProperties.maximumNameLength)) diff --git a/src/TheBuilder.WebAnalytics/Configuration/AnalyticsProviderCatalog.cs b/src/TheBuilder.WebAnalytics/Configuration/AnalyticsProviderCatalog.cs index fde9e22..14173f8 100644 --- a/src/TheBuilder.WebAnalytics/Configuration/AnalyticsProviderCatalog.cs +++ b/src/TheBuilder.WebAnalytics/Configuration/AnalyticsProviderCatalog.cs @@ -103,7 +103,7 @@ public void ValidateConnection( failures.Add($"Connection '{label}' cannot define a {AnalyticsConnectionIdentifier.SiteId.ToLabel()}."); var eventPropertyNames = connection.EventPropertyNames ?? []; - if (Settings.EventProperties is { } eventProperties) + if (connection.EnableEvents && Settings.EventProperties is { } eventProperties) { if (eventPropertyNames.Count(value => !string.IsNullOrWhiteSpace(value)) > eventProperties.MaximumNames) failures.Add($"Connection '{label}' cannot define more than {eventProperties.MaximumNames} event properties."); @@ -111,7 +111,7 @@ public void ValidateConnection( failures.Add($"Connection '{label}' contains an invalid event property name."); } - if (eventPropertyNames.Any(value => !string.IsNullOrWhiteSpace(value)) && + if (connection.EnableEvents && eventPropertyNames.Any(value => !string.IsNullOrWhiteSpace(value)) && (Settings.EventProperties is null || isSupportedMockScenario)) failures.Add($"Connection '{label}' cannot define {Settings.EventProperties?.Label ?? "event properties"} for {Provider}."); } diff --git a/tests/TheBuilder.WebAnalytics.Tests/Configuration/WebAnalyticsSettingsValidatorTests.cs b/tests/TheBuilder.WebAnalytics.Tests/Configuration/WebAnalyticsSettingsValidatorTests.cs index 4c6b76d..b373830 100644 --- a/tests/TheBuilder.WebAnalytics.Tests/Configuration/WebAnalyticsSettingsValidatorTests.cs +++ b/tests/TheBuilder.WebAnalytics.Tests/Configuration/WebAnalyticsSettingsValidatorTests.cs @@ -228,6 +228,22 @@ public void Event_property_names_are_bounded_and_plausible_only() Assert.Contains(maximumFailures, failure => failure.Contains("cannot define more than 20 event properties")); } + [Fact] + public void Disabled_events_do_not_validate_dormant_event_property_names() + { + var settings = CreateSettings(); + var connection = settings.Connections[0]; + connection.Provider = AnalyticsProvider.Plausible; + connection.ProjectId = string.Empty; + connection.SiteId = "example.com"; + connection.EnableEvents = false; + connection.EventPropertyNames = Enumerable.Range(1, 21).Select(index => $"property-{index}").ToArray(); + + var failures = WebAnalyticsSettingsValidator.Validate(settings); + + Assert.DoesNotContain(failures, failure => failure.Contains("event propert", StringComparison.OrdinalIgnoreCase)); + } + [Fact] public void Null_event_property_names_from_external_json_are_normalized() {