diff --git a/src/app/(main)/websites/[websiteId]/(reports)/attribution/AttributionPage.test.tsx b/src/app/(main)/websites/[websiteId]/(reports)/attribution/AttributionPage.test.tsx
new file mode 100644
index 000000000..f1df189ef
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/(reports)/attribution/AttributionPage.test.tsx
@@ -0,0 +1,123 @@
+import type { ReactNode } from 'react';
+import { beforeEach, expect, test, vi } from 'vitest';
+import { render, screen } from '@/test/render';
+import { AttributionPage } from './AttributionPage';
+
+let mockDateRange = {
+ startDate: new Date('2026-07-01T00:00:00.000Z'),
+ endDate: new Date('2026-07-31T23:59:59.999Z'),
+};
+
+let mockValues = [{ value: 'Sign up' }, { value: 'Purchase' }];
+
+beforeEach(() => {
+ mockDateRange = {
+ startDate: new Date('2026-07-01T00:00:00.000Z'),
+ endDate: new Date('2026-07-31T23:59:59.999Z'),
+ };
+ mockValues = [{ value: 'Sign up' }, { value: 'Purchase' }];
+});
+
+vi.mock('@umami/react-zen', () => ({
+ Column: ({ children }: { children: ReactNode }) =>
{children}
,
+ Grid: ({ children }: { children: ReactNode }) => {children}
,
+ ListItem: ({ id, children }: { id: string; children: ReactNode }) => (
+
+ ),
+ Select: ({
+ label,
+ value,
+ defaultValue,
+ placeholder,
+ onChange,
+ children,
+ }: {
+ label: string;
+ value?: string;
+ defaultValue?: string;
+ placeholder?: string;
+ onChange?: (value: string) => void;
+ children: ReactNode;
+ }) => (
+
+ ),
+}));
+
+vi.mock('@/components/common/Empty', () => ({
+ Empty: () => Empty
,
+}));
+
+vi.mock('@/components/hooks', () => ({
+ useDateRange: () => ({
+ dateRange: mockDateRange,
+ }),
+ useMessages: () => ({
+ t: (value: string) => value,
+ labels: {
+ model: 'Model',
+ type: 'Type',
+ conversionStep: 'Conversion Step',
+ firstClick: 'First Click',
+ lastClick: 'Last Click',
+ viewedPage: 'Viewed Page',
+ triggeredEvent: 'Triggered Event',
+ },
+ }),
+ useWebsiteValuesQuery: () => ({
+ data: mockValues,
+ isLoading: false,
+ }),
+}));
+
+vi.mock('@/app/(main)/websites/[websiteId]/WebsiteControls', () => ({
+ WebsiteControls: () => WebsiteControls
,
+}));
+
+vi.mock('./Attribution', () => ({
+ Attribution: ({ step, type }: { step: string; type: string }) => (
+ {`${type}:${step}`}
+ ),
+}));
+
+test('keeps the selected conversion step when the date range refreshes the value list', async () => {
+ const { rerender, user } = render();
+
+ await user.selectOptions(screen.getByLabelText('Conversion Step'), 'Purchase');
+
+ expect(screen.getByLabelText('Conversion Step')).toHaveValue('Purchase');
+ expect(screen.getByTestId('attribution-result')).toHaveTextContent('path:Purchase');
+
+ mockDateRange = {
+ startDate: new Date('2026-08-01T00:00:00.000Z'),
+ endDate: new Date('2026-08-31T23:59:59.999Z'),
+ };
+ mockValues = [{ value: 'Sign up' }];
+
+ rerender();
+
+ expect(screen.getByLabelText('Conversion Step')).toHaveValue('Purchase');
+ expect(screen.getByTestId('attribution-result')).toHaveTextContent('path:Purchase');
+});
+
+test('clears the selected conversion step when the attribution type changes', async () => {
+ const { user } = render();
+
+ await user.selectOptions(screen.getByLabelText('Conversion Step'), 'Purchase');
+
+ expect(screen.getByTestId('attribution-result')).toHaveTextContent('path:Purchase');
+
+ await user.selectOptions(screen.getByLabelText('Type'), 'event');
+
+ expect(screen.getByLabelText('Conversion Step')).toHaveValue('');
+ expect(screen.queryByTestId('attribution-result')).not.toBeInTheDocument();
+});
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/attribution/AttributionPage.tsx b/src/app/(main)/websites/[websiteId]/(reports)/attribution/AttributionPage.tsx
index 358bb1879..96a7d1a74 100644
--- a/src/app/(main)/websites/[websiteId]/(reports)/attribution/AttributionPage.tsx
+++ b/src/app/(main)/websites/[websiteId]/(reports)/attribution/AttributionPage.tsx
@@ -1,6 +1,6 @@
'use client';
import { Column, Grid, ListItem, Select } from '@umami/react-zen';
-import { useState } from 'react';
+import { useMemo, useState } from 'react';
import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
import { Empty } from '@/components/common/Empty';
import { useDateRange, useMessages, useWebsiteValuesQuery } from '@/components/hooks';
@@ -25,6 +25,13 @@ export function AttributionPage({ websiteId }: { websiteId: string }) {
endDate,
});
const items = data?.filter(({ value }) => value) || [];
+ const stepItems = useMemo(() => {
+ if (!step || items.some(({ value }) => value === step)) {
+ return items;
+ }
+
+ return [{ value: step }, ...items];
+ }, [items, step]);
const handleTypeChange = (value: any) => {
setType(value as string);
@@ -71,7 +78,7 @@ export function AttributionPage({ websiteId }: { websiteId: string }) {
maxHeight={320}
allowSearch
>
- {items.map(({ value }) => (
+ {stepItems.map(({ value }) => (
{value}
diff --git a/src/components/input/UserButton.test.tsx b/src/components/input/UserButton.test.tsx
index d800d9ad7..fdf9d6d83 100644
--- a/src/components/input/UserButton.test.tsx
+++ b/src/components/input/UserButton.test.tsx
@@ -31,7 +31,8 @@ test('renders the expanded sidebar control at full width and opens its menu', as
expect(button).toHaveStyle({ width: '100%' });
- await user.click(button);
+ button.focus();
+ await user.keyboard('{Enter}');
expect(screen.getByRole('menuitem', { name: 'Settings' })).toBeInTheDocument();
});
@@ -40,7 +41,19 @@ test('keeps the collapsed sidebar control accessible and interactive', async ()
const { user } = render();
const button = screen.getByRole('button', { name: 'admin' });
- await user.click(button);
+ button.focus();
+ await user.keyboard('{Enter}');
expect(screen.getByRole('menuitem', { name: 'Settings' })).toBeInTheDocument();
});
+
+test('closes the menu after selecting settings', async () => {
+ const { user } = render();
+ const button = screen.getByRole('button', { name: 'admin' });
+
+ button.focus();
+ await user.keyboard('{Enter}');
+ await user.click(screen.getByRole('menuitem', { name: 'Settings' }));
+
+ expect(screen.queryByRole('menuitem', { name: 'Settings' })).not.toBeInTheDocument();
+});
diff --git a/src/components/input/UserButton.tsx b/src/components/input/UserButton.tsx
index 15a99c52f..0632157fe 100644
--- a/src/components/input/UserButton.tsx
+++ b/src/components/input/UserButton.tsx
@@ -1,11 +1,11 @@
import {
Button,
Column,
- DialogTrigger,
Icon,
Menu,
MenuItem,
MenuSeparator,
+ MenuTrigger,
Popover,
Row,
SubmenuTrigger,
@@ -52,6 +52,7 @@ export function UserButton({ showText = true, onClose }: UserButtonProps) {
const handleNavigate = (url: string, target?: string) => {
onClose?.();
+
if (target) {
window.open(url, target);
} else if (url.startsWith('http')) {
@@ -61,6 +62,16 @@ export function UserButton({ showText = true, onClose }: UserButtonProps) {
}
};
+ const handleSelectLocale = (key: string) => {
+ saveLocale(key);
+ onClose?.();
+ };
+
+ const handleSelectTheme = (key: 'light' | 'dark') => {
+ setTheme(key);
+ onClose?.();
+ };
+
const languageItems = Object.keys(languages).map(key => ({
value: key,
label: languages[key].label,
@@ -104,7 +115,13 @@ export function UserButton({ showText = true, onClose }: UserButtonProps) {