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) {