Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 4 additions & 8 deletions src/TheBuilder.WebAnalytics/Client/src/analytics/report-error.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,11 @@
import { apiFailure } from "../api/api-failure.js";

export function reportApiErrorMessage(error: unknown, status: number): string {
return reportErrorMessage(typeof error === "object" && error !== null
? { ...error, status }
: { status });
return reportErrorMessage(apiFailure(error, status));
}

export function reportErrorMessage(error: unknown): string {
const problem = typeof error === "object" && error !== null
? error as { code?: unknown; status?: unknown }
: undefined;
const code = typeof problem?.code === "string" ? problem.code : undefined;
const status = problem?.status === undefined ? undefined : Number(problem.status);
const { code, status } = apiFailure(error);

switch (code) {
case "invalid_credentials":
Expand Down
24 changes: 24 additions & 0 deletions src/TheBuilder.WebAnalytics/Client/src/api/api-failure.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { apiFailure } from "./api-failure.js";

describe("apiFailure", () => {
it("normalizes supported problem fields", () => {
expect(apiFailure({ code: "invalid_query", detail: "Unsupported dimension", status: "400" })).toEqual({
code: "invalid_query",
detail: "Unsupported dimension",
status: 400,
});
});

it("prefers the HTTP response status", () => {
expect(apiFailure({ status: 400 }, 404).status).toBe(404);
});

it("ignores malformed problem fields", () => {
expect(apiFailure({ code: 42, detail: "", status: "unknown" })).toEqual({
code: undefined,
detail: undefined,
status: undefined,
});
});
});
24 changes: 24 additions & 0 deletions src/TheBuilder.WebAnalytics/Client/src/api/api-failure.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
export interface ApiFailure {
code?: string;
detail?: string;
status?: number;
}

export function apiFailure(error: unknown, responseStatus?: number): ApiFailure {
const problem = typeof error === "object" && error !== null ? error as Record<string, unknown> : undefined;
return {
code: stringProperty(problem, "code"),
detail: stringProperty(problem, "detail"),
status: responseStatus ?? numberProperty(problem, "status"),
};
}

function stringProperty(value: Record<string, unknown> | undefined, property: string): string | undefined {
const candidate = value?.[property];
return typeof candidate === "string" && candidate.trim() ? candidate : undefined;
}

function numberProperty(value: Record<string, unknown> | undefined, property: string): number | undefined {
const candidate = Number(value?.[property]);
return Number.isFinite(candidate) ? candidate : undefined;
}
1 change: 1 addition & 0 deletions src/TheBuilder.WebAnalytics/Client/src/api/types.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@ export type AnalyticsProviderTokenStatus = {
};

export type AnalyticsSettingsResponse = {
packageVersion: string;
enabled: boolean;
providers: Array<AnalyticsProviderDescriptor>;
providerTokens: Array<AnalyticsProviderTokenStatus>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { createSettingsUpdate, validateConnection, validateEditableSettings } fr
import { announceAnalyticsAvailability } from "../section/analytics-availability.js";
import { MOCK_SCENARIOS, type MockScenarioDefinition } from "./mock-scenarios.js";
import { identifierField, providerDescriptor, providerLogo } from "./provider-identity.js";
import { settingsError, type SettingsError } from "./settings-error.js";

type NewConnection =
| { kind: "provider"; descriptor: AnalyticsProviderDescriptor; hasAccessToken: boolean }
Expand All @@ -35,6 +36,7 @@ export class WebAnalyticsSettingsDashboardElement extends UmbElementMixin(LitEle
@state() private _showValidation = false;
@state() private _testingKey?: string;
@state() private _status?: { type: "success" | "error"; message: string };
@state() private _loadError?: SettingsError;
@state() private _connectionStatuses: Record<string, ConnectionActionStatus> = {};
@state() private _showProviderPicker = false;

Expand All @@ -46,17 +48,18 @@ export class WebAnalyticsSettingsDashboardElement extends UmbElementMixin(LitEle
async #load(): Promise<void> {
this._loading = true;
try {
const { data, error } = await WebAnalyticsService.settings();
const { data, error, response } = await WebAnalyticsService.settings();
if (error || !data) {
this._status = { type: "error", message: "Analytics settings could not be loaded. Administrator access is required." };
this._loadError = settingsError("load", error, response?.status);
return;
}
this._settings = data;
this._dirty = false;
this._showValidation = false;
this._status = undefined;
} catch {
this._status = { type: "error", message: "Analytics settings could not be loaded. Administrator access is required." };
this._loadError = undefined;
} catch (error) {
this._loadError = settingsError("load", error);
} finally {
this._loading = false;
}
Expand Down Expand Up @@ -155,17 +158,17 @@ export class WebAnalyticsSettingsDashboardElement extends UmbElementMixin(LitEle
this._testingKey = key;
this._connectionStatuses = { ...this._connectionStatuses, [key]: { type: "info", message: "Testing the saved connection…" } };
try {
const { data, error } = await WebAnalyticsService.testConnection({ path: { key } });
const { data, error, response } = await WebAnalyticsService.testConnection({ path: { key } });
this._connectionStatuses = {
...this._connectionStatuses,
[key]: error || !data
? { type: "error", message: "The connection test could not be completed." }
? { type: "error", message: settingsError("test", error, response?.status).message }
: { type: data.success ? "success" : "error", message: data.message },
};
} catch {
} catch (error) {
this._connectionStatuses = {
...this._connectionStatuses,
[key]: { type: "error", message: "The connection test could not be completed." },
[key]: { type: "error", message: settingsError("test", error).message },
};
} finally {
this._testingKey = undefined;
Expand All @@ -192,9 +195,9 @@ export class WebAnalyticsSettingsDashboardElement extends UmbElementMixin(LitEle
this._status = undefined;
const body: UpdateAnalyticsSettingsRequest = createSettingsUpdate(this._settings);
try {
const { data, error } = await WebAnalyticsService.saveSettings({ body });
const { data, error, response } = await WebAnalyticsService.saveSettings({ body });
if (error || !data) {
this._status = { type: "error", message: "Settings were not saved. Check the connection fields and mapping values." };
this._status = { type: "error", message: settingsError("save", error, response?.status).message };
return false;
}
this._settings = data;
Expand All @@ -203,8 +206,8 @@ export class WebAnalyticsSettingsDashboardElement extends UmbElementMixin(LitEle
announceAnalyticsAvailability(data.enabled);
if (successMessage) this._status = { type: "success", message: successMessage };
return true;
} catch {
this._status = { type: "error", message: "Settings were not saved. Check the connection fields and mapping values." };
} catch (error) {
this._status = { type: "error", message: settingsError("save", error).message };
return false;
} finally {
this._saving = false;
Expand Down Expand Up @@ -325,7 +328,15 @@ export class WebAnalyticsSettingsDashboardElement extends UmbElementMixin(LitEle
render() {
if (this._loading) return html`<uui-loader-bar aria-label="Loading analytics settings"></uui-loader-bar>`;
if (!this._settings) return html`
<umb-empty-state headline="Settings unavailable"><p>${this._status?.message}</p><uui-button look="secondary" label="Retry loading settings" @click=${this.#load}>Retry</uui-button></umb-empty-state>
<div class="load-error" role="alert" aria-live="assertive">
<uui-box headline=${this._loadError?.headline ?? "Settings unavailable"}>
<div class="load-error-content">
<uui-icon name="icon-alert" aria-hidden="true"></uui-icon>
<p>${this._loadError?.message ?? "Web Analytics settings could not be loaded."}</p>
</div>
<uui-button look="secondary" label="Retry loading settings" @click=${this.#load}>Retry</uui-button>
</uui-box>
</div>
`;

const settings = this._settings;
Expand Down Expand Up @@ -382,6 +393,8 @@ export class WebAnalyticsSettingsDashboardElement extends UmbElementMixin(LitEle
${this.#renderGeneralSettings()}
${this.#renderDevelopmentData()}

<footer class="package-version">Current installed version of Web Analytics: ${settings.packageVersion}</footer>

</form>
`;
}
Expand All @@ -396,6 +409,10 @@ export class WebAnalyticsSettingsDashboardElement extends UmbElementMixin(LitEle
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)); }
.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); }
.load-error-content p { margin: 0; overflow-wrap: anywhere; text-wrap: pretty; }
.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 {
Expand Down Expand Up @@ -453,6 +470,7 @@ export class WebAnalyticsSettingsDashboardElement extends UmbElementMixin(LitEle
.field-with-help { display: grid; gap: var(--uui-size-space-1); }
.field-help { color: var(--uui-color-text-alt); font-size: var(--uui-type-small-size); }
.package-status { min-inline-size: 0; }
.package-version { color: var(--uui-color-text-alt); font-size: var(--uui-type-small-size); margin-block-start: var(--uui-size-layout-2); overflow-wrap: anywhere; }
.visually-hidden { block-size: 1px; clip: rect(0 0 0 0); clip-path: inset(50%); inline-size: 1px; overflow: hidden; position: absolute; white-space: nowrap; }
.section-heading { margin-bottom: var(--uui-size-space-4); }
.provider-picker { background: var(--uui-color-surface-alt); border-block: 1px solid var(--uui-color-border); margin-block-end: var(--uui-size-space-5); padding: var(--uui-size-space-4) var(--uui-size-space-5) var(--uui-size-space-5); }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ import type { AnalyticsConnectionEditorElement } from "./connection-editor.eleme
import "./settings-dashboard.element.js";

beforeEach(() => {
sdk.settings.mockReset();
sdk.saveSettings.mockReset();
sdk.testConnection.mockReset();
Element.prototype.scrollIntoView = vi.fn();
Object.defineProperty(navigator, "clipboard", {
configurable: true,
Expand All @@ -41,8 +44,9 @@ describe("analytics settings network recovery", () => {
const dashboard = document.createElement("web-analytics-settings-dashboard") as WebAnalyticsSettingsDashboardElement;
document.body.append(dashboard);

await vi.waitFor(() => expect(dashboard.shadowRoot?.querySelector("umb-empty-state")).not.toBeNull());
expect(dashboard.shadowRoot?.textContent).toContain("Analytics settings could not be loaded.");
await vi.waitFor(() => expect(dashboard.shadowRoot?.querySelector(".load-error")).not.toBeNull());
expect(dashboard.shadowRoot?.querySelector("uui-box")?.getAttribute("headline")).toBe("Could not reach the settings service");
expect(dashboard.shadowRoot?.textContent).toContain("Check your connection and that Umbraco is running");

dashboard.shadowRoot?.querySelector<HTMLElement>('[label="Retry loading settings"]')?.click();

Expand All @@ -51,9 +55,33 @@ describe("analytics settings network recovery", () => {
});

it.each([
["an SDK error result", () => sdk.testConnection.mockResolvedValueOnce(apiError())],
["a rejected request", () => sdk.testConnection.mockRejectedValueOnce(new Error("Network unavailable"))],
])("shows a connection-local error and re-enables testing after %s", async (_description, arrangeResponse) => {
[401, "Sign in again", "session has expired"],
[403, "Administrator access required", "Only administrators"],
[404, "Package files are out of sync", "Restart Umbraco"],
[503, "Settings service unavailable", "Umbraco logs"],
])("shows a specific recovery for HTTP %s", async (status, headline, message) => {
sdk.settings.mockResolvedValueOnce(apiError(status));

const dashboard = document.createElement("web-analytics-settings-dashboard") as WebAnalyticsSettingsDashboardElement;
document.body.append(dashboard);

await vi.waitFor(() => expect(dashboard.shadowRoot?.querySelector(".load-error")).not.toBeNull());
expect(dashboard.shadowRoot?.querySelector("uui-box")?.getAttribute("headline")).toBe(headline);
expect(dashboard.shadowRoot?.textContent).toContain(message);
});

it("shows the installed package version below the settings", async () => {
const dashboard = document.createElement("web-analytics-settings-dashboard") as WebAnalyticsSettingsDashboardElement;
document.body.append(dashboard);

await vi.waitFor(() => expect(dashboard.shadowRoot?.querySelector(".package-version")).not.toBeNull());
expect(dashboard.shadowRoot?.querySelector(".package-version")?.textContent).toContain("Current installed version of Web Analytics: 0.3.0");
});

it.each([
["an SDK error result", () => sdk.testConnection.mockResolvedValueOnce(apiError()), "Umbraco logs"],
["a rejected request", () => sdk.testConnection.mockRejectedValueOnce(new Error("Network unavailable")), "could not reach Umbraco"],
])("shows a connection-local error and re-enables testing after %s", async (_description, arrangeResponse, message) => {
arrangeResponse();
sdk.settings.mockResolvedValueOnce(apiOk(settings({ connections: [connection()] })));

Expand All @@ -64,7 +92,7 @@ describe("analytics settings network recovery", () => {
const editor = dashboard.shadowRoot?.querySelector("web-analytics-connection-editor") as AnalyticsConnectionEditorElement;
editor.dispatchEvent(new CustomEvent("test-connection", { bubbles: true, composed: true }));

await vi.waitFor(() => expect(editor.shadowRoot?.querySelector(".action-status")?.textContent).toContain("The connection test could not be completed."));
await vi.waitFor(() => expect(editor.shadowRoot?.querySelector(".action-status")?.textContent).toContain(message));
expect(editor.testing).toBe(false);
expect(editor.shadowRoot?.querySelector<HTMLElement>('[label="Test the saved connection."]')?.hasAttribute("disabled")).toBe(false);
});
Expand Down Expand Up @@ -98,7 +126,7 @@ describe("analytics settings network recovery", () => {
const form = dashboard.shadowRoot?.querySelector("form");
form?.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true, composed: true }));

await vi.waitFor(() => expect(dashboard.shadowRoot?.textContent).toContain("Settings were not saved."));
await vi.waitFor(() => expect(dashboard.shadowRoot?.textContent).toContain("could not reach Umbraco"));
expect(dashboard.shadowRoot?.querySelector(".save-status")?.textContent).toContain("Unsaved changes");
expect(input!.value).toBe("31");
expect(dashboard.shadowRoot?.querySelector<HTMLElement>('[label="Save Web Analytics settings"]')?.hasAttribute("disabled")).toBe(false);
Expand All @@ -124,14 +152,53 @@ describe("analytics settings network recovery", () => {
await dashboard.updateComplete;
dashboard.shadowRoot?.querySelector("form")?.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true, composed: true }));

await vi.waitFor(() => expect(dashboard.shadowRoot?.textContent).toContain("Settings were not saved."));
await vi.waitFor(() => expect(dashboard.shadowRoot?.textContent).toContain("could not be completed"));
expect(dashboard.shadowRoot?.querySelector(".save-status")?.textContent).toContain("Unsaved changes");
expect(input!.value).toBe("31");
});

it.each([
[401, "session has expired"],
[404, "server and package files use the same version"],
[503, "Umbraco logs"],
])("preserves edits and explains save HTTP %s failures", async (status, message) => {
sdk.saveSettings.mockResolvedValueOnce(apiError(status));

const dashboard = document.createElement("web-analytics-settings-dashboard") as WebAnalyticsSettingsDashboardElement;
document.body.append(dashboard);
await vi.waitFor(() => expect(dashboard.shadowRoot?.querySelector("#default-range")).not.toBeNull());
const input = dashboard.shadowRoot?.querySelector<HTMLInputElement>("#default-range");
input!.value = "31";
input!.dispatchEvent(new Event("input", { bubbles: true, composed: true }));
dashboard.shadowRoot?.querySelector("form")?.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true, composed: true }));

await vi.waitFor(() => expect(dashboard.shadowRoot?.querySelector(".status")?.textContent).toContain(message));
expect(dashboard.shadowRoot?.querySelector(".save-status")?.textContent).toContain("Unsaved changes");
expect(input!.value).toBe("31");
});

it.each([
[401, "session has expired"],
[404, "connection test API was not found"],
[503, "Umbraco logs"],
])("explains connection-test HTTP %s failures", async (status, message) => {
sdk.testConnection.mockResolvedValueOnce(apiError(status));
sdk.settings.mockResolvedValueOnce(apiOk(settings({ connections: [connection()] })));

const dashboard = document.createElement("web-analytics-settings-dashboard") as WebAnalyticsSettingsDashboardElement;
document.body.append(dashboard);
await vi.waitFor(() => expect(dashboard.shadowRoot?.querySelector("web-analytics-connection-editor")).not.toBeNull());
const editor = dashboard.shadowRoot?.querySelector("web-analytics-connection-editor") as AnalyticsConnectionEditorElement;
editor.dispatchEvent(new CustomEvent("test-connection", { bubbles: true, composed: true }));

await vi.waitFor(() => expect(editor.shadowRoot?.querySelector(".action-status")?.textContent).toContain(message));
expect(editor.testing).toBe(false);
});
});

function settings(overrides: Partial<AnalyticsSettingsResponse> = {}): AnalyticsSettingsResponse {
return {
packageVersion: "0.3.0",
enabled: true,
providerTokens: [{ provider: "Vercel", hasAccessToken: false }, { provider: "Plausible", hasAccessToken: false }],
canCreateMockConnections: false,
Expand Down Expand Up @@ -266,6 +333,7 @@ describe("analytics settings onboarding", () => {

it("marks new connections as using the configured shared token", async () => {
sdk.settings.mockResolvedValue(apiOk({
packageVersion: "0.3.0",
enabled: true,
providers: PROVIDERS,
providerTokens: [{ provider: "Vercel", hasAccessToken: true }, { provider: "Plausible", hasAccessToken: false }],
Expand Down Expand Up @@ -323,6 +391,7 @@ describe("analytics settings onboarding", () => {

it("adds development mock scenarios as deterministic connections", async () => {
sdk.settings.mockResolvedValue(apiOk({
packageVersion: "0.3.0",
enabled: true,
providers: PROVIDERS,
providerTokens: [{ provider: "Vercel", hasAccessToken: false }, { provider: "Plausible", hasAccessToken: false }],
Expand Down Expand Up @@ -359,6 +428,6 @@ function apiOk<T>(data: T) {
return { data, error: undefined, request: new Request("https://example.com"), response: new Response(null, { status: 200 }) };
}

function apiError() {
return { data: undefined, error: { message: "Request failed" }, request: new Request("https://example.com"), response: new Response(null, { status: 500 }) };
function apiError(status = 500) {
return { data: undefined, error: { message: "Request failed" }, request: new Request("https://example.com"), response: new Response(null, { status }) };
}
Loading