diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 3a60c51..657f8c1 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -19,6 +19,8 @@ jobs: steps: - name: Check out repository uses: actions/checkout@v7 + with: + fetch-depth: 0 - name: Set up Node.js uses: actions/setup-node@v6 @@ -29,13 +31,17 @@ jobs: run: corepack enable - name: Install dependencies - run: corepack pnpm install --frozen-lockfile + run: pnpm install --frozen-lockfile + + - name: Audit changed client code + if: github.event_name == 'pull_request' + run: pnpm run audit --base '${{ github.event.pull_request.base.sha }}' --format github-annotations - name: Test client - run: corepack pnpm test + run: pnpm test - name: Build client - run: corepack pnpm build + run: pnpm build dotnet: name: .NET (Umbraco ${{ matrix.umbraco-version }}) @@ -84,7 +90,7 @@ jobs: - name: Install backoffice dependencies working-directory: src/TheBuilder.WebAnalytics/Client - run: corepack pnpm install --frozen-lockfile + run: pnpm install --frozen-lockfile - name: Pack NuGet package run: >- diff --git a/src/TheBuilder.WebAnalytics/Client/.fallowrc.json b/src/TheBuilder.WebAnalytics/Client/.fallowrc.json new file mode 100644 index 0000000..74707c9 --- /dev/null +++ b/src/TheBuilder.WebAnalytics/Client/.fallowrc.json @@ -0,0 +1,80 @@ +{ + "$schema": "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json", + "entry": [ + "src/bundle.manifests.ts", + "scripts/**/*.js", + "vite.config.ts" + ], + "dynamicallyLoaded": [ + "src/entrypoints/entrypoint.ts", + "src/section/analytics-enabled.condition.ts", + "src/section/analytics-section.element.ts", + "src/settings/settings-dashboard.element.ts", + "src/workspace/analytics-workspace.element.ts", + "src/workspace/document-analytics.condition.ts" + ], + "ignorePatterns": [ + "src/api/**/*.gen.ts", + "src/api/index.ts", + "src/api/client/index.ts" + ], + "ignoreDependencies": [ + // Build-time generator dependencies and the Umbraco-provided runtime peer + // intentionally remain devDependencies; production code is bundled. + "@hey-api/openapi-ts", + "@umbraco-cms/backoffice", + "chalk", + "node-fetch" + ], + "usedClassMembers": [ + "connectedCallback", + "disconnectedCallback", + "focusFirstInvalid", + "styles" + ], + "duplicates": { + "minOccurrences": 2, + "ignore": [ + "src/api/**/*.gen.ts", + "src/api/index.ts", + "src/api/client/index.ts" + ] + }, + "health": { + // Lit templates count every conditional binding and optional chain as a + // branch. These ceilings still catch structural hotspots without treating + // declarative rendering as equivalent to imperative control flow. + "maxCyclomatic": 30, + "maxCognitive": 35, + // Until CI produces Istanbul coverage, Fallow's static CRAP estimate + // overstates tested Lit and controller code. Keep a finite ceiling while + // structural complexity remains the primary gate. + "maxCrap": 300, + "ignore": [ + "src/api/**/*.gen.ts", + "src/api/index.ts", + "src/api/client/index.ts", + "**/*.test.ts" + ], + "thresholdOverrides": [ + { + "files": ["src/analytics/analytics-dashboard.element.ts"], + "functions": ["render"], + "maxCyclomatic": 35, + "reason": "The dashboard render method composes independent optional panels and dialog states; its declarative Lit bindings are kept under a narrow render-only ceiling." + }, + { + "files": ["src/settings/connection-editor.element.ts"], + "functions": ["render"], + "maxCyclomatic": 65, + "maxCognitive": 95, + "maxCrap": 900, + "reason": "The connection editor is a single declarative Lit form whose conditional bindings mirror independent provider capabilities; extracting them would fragment the form contract without reducing runtime decision-making." + } + ] + }, + "rules": { + "unused-dev-dependencies": "error", + "dev-dependencies-in-production": "error" + } +} diff --git a/src/TheBuilder.WebAnalytics/Client/.gitignore b/src/TheBuilder.WebAnalytics/Client/.gitignore new file mode 100644 index 0000000..54b847d --- /dev/null +++ b/src/TheBuilder.WebAnalytics/Client/.gitignore @@ -0,0 +1 @@ +.fallow/ diff --git a/src/TheBuilder.WebAnalytics/Client/package.json b/src/TheBuilder.WebAnalytics/Client/package.json index 7068aaf..4e72219 100644 --- a/src/TheBuilder.WebAnalytics/Client/package.json +++ b/src/TheBuilder.WebAnalytics/Client/package.json @@ -9,6 +9,7 @@ "build": "tsc && vite build", "check": "tsc --noEmit", "test": "vitest run", + "audit": "fallow audit --gate new-only", "generate-client": "node scripts/generate-openapi.js" }, "dependencies": { @@ -19,6 +20,7 @@ "@umbraco-cms/backoffice": "^17.1.0", "chalk": "^5.6.2", "cross-env": "^10.1.0", + "fallow": "3.9.1", "jsdom": "^27.4.0", "node-fetch": "^3.3.2", "typescript": "^5.9.3", diff --git a/src/TheBuilder.WebAnalytics/Client/pnpm-lock.yaml b/src/TheBuilder.WebAnalytics/Client/pnpm-lock.yaml index 5771276..3a383ac 100644 --- a/src/TheBuilder.WebAnalytics/Client/pnpm-lock.yaml +++ b/src/TheBuilder.WebAnalytics/Client/pnpm-lock.yaml @@ -27,6 +27,9 @@ importers: cross-env: specifier: ^10.1.0 version: 10.1.0 + fallow: + specifier: 3.9.1 + version: 3.9.1 jsdom: specifier: ^27.4.0 version: 27.4.0 @@ -261,6 +264,46 @@ packages: '@noble/hashes': optional: true + '@fallow-cli/darwin-arm64@3.9.1': + resolution: {integrity: sha512-kkOoEd4t/4LrkSHcg8GjeydgKYme1k5YoBhW/fwm2nT/B/uFfK67wd28VrEEzng2seftF2imUBInJjsDdsK4jg==} + cpu: [arm64] + os: [darwin] + + '@fallow-cli/darwin-x64@3.9.1': + resolution: {integrity: sha512-ub9lo4H/tFgZsh22RpyTkNgxWWd/77+zuzHflEND8Hbegrz/WX1hLJtiGfdCBA2meszWg4glTs8pLUx56o2ksw==} + cpu: [x64] + os: [darwin] + + '@fallow-cli/linux-arm64-gnu@3.9.1': + resolution: {integrity: sha512-s/TipX7J+nvctluu/HuqKkztJoxs7dDmsH3LGRhRduq6IelfnFD7p8t/ZmVnA8iE5qQxdOOZi4iF290CcQoxkA==} + cpu: [arm64] + os: [linux] + + '@fallow-cli/linux-arm64-musl@3.9.1': + resolution: {integrity: sha512-UoE+E2gXN/mbvlwbVIioyVsoI/Odqy+8n9q91DkwDCixZ/lTHipNBdmbdRrAyixkL6Sb4WrA1tTrLss+7KQQrQ==} + cpu: [arm64] + os: [linux] + + '@fallow-cli/linux-x64-gnu@3.9.1': + resolution: {integrity: sha512-f4oXqLyJmdZy3cfkhkBJDzWiSHqbWPOERk+yyzUoD7OXsxIiZmXH3DquNoPNygA85CtFUi7W2H506xHtCtDkOg==} + cpu: [x64] + os: [linux] + + '@fallow-cli/linux-x64-musl@3.9.1': + resolution: {integrity: sha512-ofJ+1aoRv7+d5bOJlk043vCCckpFpW7H5MR9kRUU7YV9r1Iuhsq6SbiAwGwrqMODR3zrmVKzEr6cH9IL/f5icA==} + cpu: [x64] + os: [linux] + + '@fallow-cli/win32-arm64-msvc@3.9.1': + resolution: {integrity: sha512-b+AaGm+X0WvFIZ+tOHJr0N4JL4eFWkvMVgT42u0KU+AubkMg01ne2oyEguCJYLyNmxbo18CnJFyXZVx+s7u5Sg==} + cpu: [arm64] + os: [win32] + + '@fallow-cli/win32-x64-msvc@3.9.1': + resolution: {integrity: sha512-E4frwv5/03a2z9MumQOq06OI0JZGxCEyvSBFtONpBZVhUpIF6sbtvSXe3jyJa89lWeYms8g4p+FKtm2G0XF+PA==} + cpu: [x64] + os: [win32] + '@heximal/expressions@0.1.5': resolution: {integrity: sha512-QdWz9vNrdzi24so9KGEM9w4UYLg1yk+LVvYBEDbw9EY1BzKHITWdtYc55xJ3Zuio0/9Naz/D1YtYlCnfsycNDQ==} @@ -1070,6 +1113,10 @@ packages: destr@2.0.5: resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + diff@9.0.0: resolution: {integrity: sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==} engines: {node: '>=0.3.1'} @@ -1114,6 +1161,11 @@ packages: exsolve@1.1.0: resolution: {integrity: sha512-D+42+T12DdIlJM3uepa55qGiL3sYdLBOxIl2ifQCzCHz4c7eiolaHsi3BIqEr7JxBzxv2pYZQX9kw16ziMcEmw==} + fallow@3.9.1: + resolution: {integrity: sha512-OVbuoRAz5LDWLAjB64905VnlT1MwPUmuPBBKJ0KYnKejX24edJI0x5degiWf2By6UZQAJBRx2zqflSH2sC+Bcg==} + engines: {node: '>=22'} + hasBin: true + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -1769,6 +1821,30 @@ snapshots: '@exodus/bytes@1.15.1': {} + '@fallow-cli/darwin-arm64@3.9.1': + optional: true + + '@fallow-cli/darwin-x64@3.9.1': + optional: true + + '@fallow-cli/linux-arm64-gnu@3.9.1': + optional: true + + '@fallow-cli/linux-arm64-musl@3.9.1': + optional: true + + '@fallow-cli/linux-x64-gnu@3.9.1': + optional: true + + '@fallow-cli/linux-x64-musl@3.9.1': + optional: true + + '@fallow-cli/win32-arm64-msvc@3.9.1': + optional: true + + '@fallow-cli/win32-x64-msvc@3.9.1': + optional: true + '@heximal/expressions@0.1.5': dependencies: tslib: 2.8.1 @@ -2947,6 +3023,8 @@ snapshots: destr@2.0.5: {} + detect-libc@2.1.2: {} + diff@9.0.0: {} dompurify@3.4.12: @@ -3002,6 +3080,19 @@ snapshots: exsolve@1.1.0: {} + fallow@3.9.1: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + '@fallow-cli/darwin-arm64': 3.9.1 + '@fallow-cli/darwin-x64': 3.9.1 + '@fallow-cli/linux-arm64-gnu': 3.9.1 + '@fallow-cli/linux-arm64-musl': 3.9.1 + '@fallow-cli/linux-x64-gnu': 3.9.1 + '@fallow-cli/linux-x64-musl': 3.9.1 + '@fallow-cli/win32-arm64-msvc': 3.9.1 + '@fallow-cli/win32-x64-msvc': 3.9.1 + fdir@6.5.0(picomatch@4.0.5): optionalDependencies: picomatch: 4.0.5 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 3812f5e..ae2eb19 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 @@ -66,6 +66,22 @@ export class WebAnalyticsBreakdownGridElement extends UmbElementMixin(LitElement }; } + #renderBreakdownFooter( + loading: boolean, + unavailable: string | undefined, + hasRows: boolean, + selected: ReturnType, + ) { + if (!loading && hasRows && !unavailable) { + return html` + this.#dispatch("view-breakdown", selected)}>View all + `; + } + return !loading && unavailable + ? html` this.#dispatch("retry-reports")}>Retry` + : ""; + } + #renderCard(card: DashboardCard) { const selected = selectedCardDimension(card, this.audienceDimension, this.utmDimension); const state = this.breakdowns[selected.dimension]; @@ -97,11 +113,7 @@ export class WebAnalyticsBreakdownGridElement extends UmbElementMixin(LitElement ${planLimited && unavailable ? html`

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

` : ""} @@ -139,11 +151,7 @@ export class WebAnalyticsBreakdownGridElement extends UmbElementMixin(LitElement @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 })}> 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 a714d3f..51f9a9c 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dashboard.controller.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-dashboard.controller.ts @@ -29,7 +29,7 @@ import { import { loadDashboardBreakdown, loadDashboardBreakdowns, loadDashboardReports, type DashboardReportQuery, type DashboardReportUpdate } from "./dashboard-report-loader.js"; import { visibleEventRows } from "./event-rows.js"; import { reportErrorMessage } from "./report-error.js"; -import { DebouncedRequest, RequestCoordinator } from "./request-coordinator.js"; +import { DebouncedRequest, RequestCoordinator, type RequestResult } from "./request-coordinator.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"; @@ -463,60 +463,73 @@ export class AnalyticsDashboardController { async #initialize(): Promise { this.#set({ configurationError: undefined, setupRequired: false }); - if (this.#documentId) { - const documentId = this.#documentId; - const result = await this.#initializationRequest.run((signal) => this.#api.documentRoutes({ - path: { documentId }, query: { culture: this.#culture }, signal, - })); - if (result.status === "cancelled" || result.status === "stale") return; - if (result.status === "error") { this.#set({ configurationError: reportErrorMessage(result.error), summary: idleState() }); return; } - const { data, error } = result.value; - const route = !error && data?.length ? activeDocumentRoute(data, this.#culture) : undefined; - if (!route) { - this.#set({ configurationError: "This document is unpublished, unmapped, or its active culture is not configured for analytics.", summary: idleState() }); - return; - } - const selection = normalizeDashboardSelection(this.state, route.capabilities); - this.#set({ route, connection: route.connection, provider: route.provider, capabilities: route.capabilities, ...selection }); - } else { - const result = await this.#initializationRequest.run((signal) => this.#api.connections({ signal })); - if (result.status === "cancelled" || result.status === "stale") return; - if (result.status === "error") { this.#set({ configurationError: reportErrorMessage(result.error), summary: idleState() }); return; } - const { data, error } = result.value; - if (error || !data?.enabled) { - this.#set({ configurationError: "Web Analytics is disabled or unavailable. Ask an administrator to configure a connection.", summary: idleState() }); - return; - } - if (data.connections.length === 0) { - this.#set({ setupRequired: true, summary: idleState() }); - return; - } - let { preset, range } = this.state; - if (!this.#hasUrlDateState) { - preset = [1, 7, 30, 90, 365].includes(data.defaultRangeDays) ? data.defaultRangeDays as Exclude : "custom"; - range = dateRangeForPreset(data.defaultRangeDays); - } - const stored = this.#environment.getStoredConnection(); - const requested = data.connections.some(({ key }) => key === this.state.connection) ? this.state.connection : undefined; - const storedValid = data.connections.some(({ key }) => key === stored) ? stored ?? undefined : undefined; - const connection = requested ?? storedValid ?? data.connections[0]?.key; - const selectedConnection = data.connections.find(({ key }) => key === connection); - const capabilities = selectedConnection?.capabilities ?? unavailableCapabilities; - const selection = normalizeDashboardSelection(this.state, capabilities); - this.#set({ - connections: data.connections, - connection, - provider: selectedConnection?.provider, - capabilities, - ...selection, - preset, - range, - }); - } + const initialized = this.#documentId + ? await this.#initializeDocument(this.#documentId) + : await this.#initializeGlobal(); + if (!initialized) return; this.#syncUrlState(); await this.loadReports(); } + async #initializeDocument(documentId: string): Promise { + const result = await this.#initializationRequest.run((signal) => this.#api.documentRoutes({ + path: { documentId }, query: { culture: this.#culture }, signal, + })); + if (!this.#initializationSucceeded(result)) return false; + const { data, error } = result.value; + const route = !error && data?.length ? activeDocumentRoute(data, this.#culture) : undefined; + if (!route) { + this.#set({ configurationError: "This document is unpublished, unmapped, or its active culture is not configured for analytics.", summary: idleState() }); + return false; + } + const selection = normalizeDashboardSelection(this.state, route.capabilities); + this.#set({ route, connection: route.connection, provider: route.provider, capabilities: route.capabilities, ...selection }); + return true; + } + + async #initializeGlobal(): Promise { + const result = await this.#initializationRequest.run((signal) => this.#api.connections({ signal })); + if (!this.#initializationSucceeded(result)) return false; + const { data, error } = result.value; + if (error || !data?.enabled) { + this.#set({ configurationError: "Web Analytics is disabled or unavailable. Ask an administrator to configure a connection.", summary: idleState() }); + return false; + } + if (data.connections.length === 0) { + this.#set({ setupRequired: true, summary: idleState() }); + return false; + } + let { preset, range } = this.state; + if (!this.#hasUrlDateState) { + preset = [1, 7, 30, 90, 365].includes(data.defaultRangeDays) ? data.defaultRangeDays as Exclude : "custom"; + range = dateRangeForPreset(data.defaultRangeDays); + } + const stored = this.#environment.getStoredConnection(); + const requested = data.connections.some(({ key }) => key === this.state.connection) ? this.state.connection : undefined; + const storedValid = data.connections.some(({ key }) => key === stored) ? stored ?? undefined : undefined; + const connection = requested ?? storedValid ?? data.connections[0]?.key; + const selectedConnection = data.connections.find(({ key }) => key === connection); + const capabilities = selectedConnection?.capabilities ?? unavailableCapabilities; + const selection = normalizeDashboardSelection(this.state, capabilities); + this.#set({ + connections: data.connections, + connection, + provider: selectedConnection?.provider, + capabilities, + ...selection, + preset, + range, + }); + return true; + } + + #initializationSucceeded(result: RequestResult): result is Extract, { status: "success" }> { + if (result.status === "error") { + this.#set({ configurationError: reportErrorMessage(result.error), summary: idleState() }); + } + return result.status === "success"; + } + #applyReportUpdate(update: DashboardReportUpdate): void { if (update.panel === "summary") { this.#set({ summary: update.status === "error" ? errorState(update.error, this.state.summary) : successState(update.data) }); 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 c863806..dd61f66 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-summary.element.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/analytics-summary.element.ts @@ -6,6 +6,7 @@ import { inclusiveRangeDays, type AnalyticsDateRange } from "./date-range.js"; import { metricComparison } from "./metric-comparison.js"; import type { DashboardMetric } from "./dashboard-url-state.js"; import { isInitialLoading, stateData, type AsyncState } from "./async-state.js"; +import { targetTabIndex } from "./tab-keyboard.js"; import "./history-chart.element.js"; @customElement("web-analytics-summary") @@ -30,14 +31,7 @@ export class WebAnalyticsSummaryElement extends UmbElementMixin(LitElement) { if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return; event.preventDefault(); const tabs = Array.from(this.shadowRoot?.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 targetIndex = targetTabIndex(event, tabs); tabs[targetIndex]?.click(); tabs[targetIndex]?.focus(); } 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 c270472..ada4fbf 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/breakdown-dialog.element.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/breakdown-dialog.element.ts @@ -8,13 +8,13 @@ import { } from "@umbraco-cms/backoffice/external/lit"; 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, type TrafficMetric } from "./breakdown-rows.js"; import { AUDIENCE_OPTIONS, breakdownDialogGroup, referrerDimensionOption, UTM_OPTIONS, type DimensionOption } from "./dashboard-cards.js"; import type { AnalyticsFilter, UtmDimension } from "./dashboard-url-state.js"; +import { cancelDialog, closeDialog, notifyDialogClosed, notifyDialogSearch, openDialog, searchInputValue } from "./dialog-lifecycle.js"; import { isUtmDimension } from "./utm-capability.js"; import type { ReportTabGroup } from "./report-tabs.js"; import "./breakdown-table.element.js"; @@ -36,29 +36,24 @@ export class WebAnalyticsBreakdownDialogElement extends UmbElementMixin(LitEleme @state() private _utmDimension?: UtmDimension; protected firstUpdated(): void { - this.shadowRoot?.querySelector("dialog")?.showModal(); + openDialog(this); } #close(): void { - this.shadowRoot?.querySelector("dialog")?.close(); + closeDialog(this); } #notifyClosed(): void { - this.dispatchEvent(new CustomEvent("close-breakdown", { bubbles: true, composed: true })); + notifyDialogClosed(this, "close-breakdown"); } #onCancel(event: Event): void { - event.preventDefault(); - this.#close(); + cancelDialog(event, this); } #onSearch(event: Event): void { - this._search = String((event.target as UUIInputElement).value ?? ""); - this.dispatchEvent(new CustomEvent("search-breakdown", { - bubbles: true, - composed: true, - detail: { search: this._search.trim() }, - })); + this._search = searchInputValue(event); + notifyDialogSearch(this, "search-breakdown", this._search); } #selectDimension(option?: DimensionOption): void { diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/date-range.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/date-range.ts index 1d7cb9d..6388727 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/date-range.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/date-range.ts @@ -291,14 +291,11 @@ function validIso(value: string): string | undefined { } function startOfZonedHour(date: Date, timeZone: string): Date { - const parts = new Intl.DateTimeFormat("en", { + const value = zonedNumericPartValue(date, timeZone, { minute: "2-digit", second: "2-digit", hourCycle: "h23", - timeZone, - }).formatToParts(date); - const value = (type: Intl.DateTimeFormatPartTypes) => - Number(parts.find((part) => part.type === type)?.value ?? 0); + }); const elapsedInHour = value("minute") * 60_000 + value("second") * 1000 + date.getUTCMilliseconds(); @@ -336,12 +333,20 @@ function zonedMidnightToIso(dateOnly: string, timeZone: string): string | undefi } function timeZoneOffsetMilliseconds(date: Date, timeZone: string): number { - const parts = new Intl.DateTimeFormat("en", { + const value = zonedNumericPartValue(date, timeZone, { year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit", - hourCycle: "h23", timeZone, - }).formatToParts(date); - const value = (type: Intl.DateTimeFormatPartTypes) => Number(parts.find((part) => part.type === type)?.value ?? 0); + hourCycle: "h23", + }); const wallClockAsUtc = Date.UTC(value("year"), value("month") - 1, value("day"), value("hour"), value("minute"), value("second")); return wallClockAsUtc - Math.floor(date.valueOf() / 1000) * 1000; } + +function zonedNumericPartValue( + date: Date, + timeZone: string, + options: Intl.DateTimeFormatOptions, +): (type: Intl.DateTimeFormatPartTypes) => number { + const parts = new Intl.DateTimeFormat("en", { ...options, timeZone }).formatToParts(date); + return (type) => Number(parts.find((part) => part.type === type)?.value ?? 0); +} diff --git a/src/TheBuilder.WebAnalytics/Client/src/analytics/dialog-lifecycle.ts b/src/TheBuilder.WebAnalytics/Client/src/analytics/dialog-lifecycle.ts new file mode 100644 index 0000000..bfe993a --- /dev/null +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/dialog-lifecycle.ts @@ -0,0 +1,28 @@ +export function openDialog(host: HTMLElement): void { + host.shadowRoot?.querySelector("dialog")?.showModal(); +} + +export function closeDialog(host: HTMLElement): void { + host.shadowRoot?.querySelector("dialog")?.close(); +} + +export function notifyDialogClosed(host: HTMLElement, eventName: string): void { + host.dispatchEvent(new CustomEvent(eventName, { bubbles: true, composed: true })); +} + +export function cancelDialog(event: Event, host: HTMLElement): void { + event.preventDefault(); + closeDialog(host); +} + +export function searchInputValue(event: Event): string { + return String((event.target as HTMLElement & { value?: unknown }).value ?? ""); +} + +export function notifyDialogSearch(host: HTMLElement, eventName: string, search: string): void { + host.dispatchEvent(new CustomEvent(eventName, { + bubbles: true, + composed: true, + detail: { search: search.trim() }, + })); +} 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 b940402..773e2b4 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 @@ -6,6 +6,7 @@ import type { AnalyticsEventDetails, AnalyticsEventProperty } from "../api/types import { renderAnalyticsDialogHeadline } from "./analytics-dialog-headline.js"; import { analyticsDialogStyles, analyticsEventDialogStyles } from "./analytics-dialog.styles.js"; import { analyticsTableSkeletonStyles, renderAnalyticsTableSkeletonRows } from "./analytics-table-skeleton.js"; +import { cancelDialog, closeDialog, notifyDialogClosed, openDialog } from "./dialog-lifecycle.js"; import { renderReportTabs, reportTabsStyles } from "./report-tabs.js"; @customElement("web-analytics-event-details-dialog") @@ -24,10 +25,10 @@ export class WebAnalyticsEventDetailsDialogElement extends UmbElementMixin(LitEl @state() private _propertyName?: string; @state() private _search = ""; - protected firstUpdated(): void { this.shadowRoot?.querySelector("dialog")?.showModal(); } - #close(): void { this.shadowRoot?.querySelector("dialog")?.close(); } - #notifyClosed(): void { this.dispatchEvent(new CustomEvent("close-event-details", { bubbles: true, composed: true })); } - #onCancel(event: Event): void { event.preventDefault(); this.#close(); } + protected firstUpdated(): void { openDialog(this); } + #close(): void { closeDialog(this); } + #notifyClosed(): void { notifyDialogClosed(this, "close-event-details"); } + #onCancel(event: Event): void { cancelDialog(event, this); } #backToEvents(): void { this.dispatchEvent(new CustomEvent("back-to-events", { bubbles: true, composed: true })); } #activeProperty(): AnalyticsEventProperty | undefined { 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 c2a0080..20a7fd6 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/event-dialog.element.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/event-dialog.element.ts @@ -1,11 +1,11 @@ import { LitElement, css, customElement, html, property, state } from "@umbraco-cms/backoffice/external/lit"; 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, analyticsEventDialogStyles } from "./analytics-dialog.styles.js"; import type { AnalyticsFilter } from "./dashboard-url-state.js"; +import { cancelDialog, closeDialog, notifyDialogClosed, notifyDialogSearch, openDialog, searchInputValue } from "./dialog-lifecycle.js"; import "./event-table.element.js"; @customElement("web-analytics-event-dialog") @@ -18,13 +18,13 @@ export class WebAnalyticsEventDialogElement extends UmbElementMixin(LitElement) @property({ type: Boolean }) filteringEnabled = false; @state() private _search = ""; - protected firstUpdated(): void { this.shadowRoot?.querySelector("dialog")?.showModal(); } - #close(): void { this.shadowRoot?.querySelector("dialog")?.close(); } - #notifyClosed(): void { this.dispatchEvent(new CustomEvent("close-events", { bubbles: true, composed: true })); } - #onCancel(event: Event): void { event.preventDefault(); this.#close(); } + protected firstUpdated(): void { openDialog(this); } + #close(): void { closeDialog(this); } + #notifyClosed(): void { notifyDialogClosed(this, "close-events"); } + #onCancel(event: Event): void { cancelDialog(event, this); } #onSearch(event: Event): void { - this._search = String((event.target as UUIInputElement).value ?? ""); - this.dispatchEvent(new CustomEvent("search-events", { bubbles: true, composed: true, detail: { search: this._search.trim() } })); + this._search = searchInputValue(event); + notifyDialogSearch(this, "search-events", this._search); } render() { 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 f3b0c32..967e0f7 100644 --- a/src/TheBuilder.WebAnalytics/Client/src/analytics/history-chart.element.ts +++ b/src/TheBuilder.WebAnalytics/Client/src/analytics/history-chart.element.ts @@ -24,7 +24,7 @@ import { formatAnalyticsDate, formatAnalyticsTooltipDate, isAnalyticsPeriodInPro Chart.register(LineController, LineElement, PointElement, LinearScale, CategoryScale, Filler, Tooltip); @customElement("web-analytics-history-chart") -export class WebAnalyticsHistoryChartElement extends UmbElementMixin(LitElement) { +class WebAnalyticsHistoryChartElement extends UmbElementMixin(LitElement) { @property({ attribute: false }) points: Array<{ timestamp: string; visitors: number; pageViews?: number; count?: number }> = []; @property() metric: "visitors" | "pageViews" | "count" = "visitors"; @property() interval: AnalyticsInterval = "Day"; @@ -55,10 +55,7 @@ export class WebAnalyticsHistoryChartElement extends UmbElementMixin(LitElement) const borderColor = style.getPropertyValue("--uui-color-border").trim() || "#d8d7d9"; const gridColor = `color-mix(in srgb, ${borderColor} 42%, transparent)`; const label = this.#metricLabel(); - const latestPoint = this.points[this.points.length - 1]; - const latestPeriodInProgress = latestPoint - ? isAnalyticsPeriodInProgress(latestPoint.timestamp, this.interval, new Date(), this.timeZone) - : false; + const latestPeriodInProgress = this.#latestPeriodInProgress(); const hoverGuide: Plugin<"line"> = { id: "webAnalyticsHoverGuide", afterDatasetsDraw: (chart) => { @@ -162,10 +159,7 @@ export class WebAnalyticsHistoryChartElement extends UmbElementMixin(LitElement) render() { const label = this.#metricLabel(); - const latestPoint = this.points[this.points.length - 1]; - const latestPeriodInProgress = latestPoint - ? isAnalyticsPeriodInProgress(latestPoint.timestamp, this.interval, new Date(), this.timeZone) - : false; + const latestPeriodInProgress = this.#latestPeriodInProgress(); const progressDescription = latestPeriodInProgress ? ". The final period is still in progress" : ""; return html`