Merge branch 'dev' of https://github.com/umami-software/umami into dev
This commit is contained in:
commit
2de1c9bcc0
4 files changed
+182
-25
No files matched your search
@@ -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 }) => <div>{children}</div>,
|
||||
Grid: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
ListItem: ({ id, children }: { id: string; children: ReactNode }) => (
|
||||
<option value={id}>{children}</option>
|
||||
),
|
||||
Select: ({
|
||||
label,
|
||||
value,
|
||||
defaultValue,
|
||||
placeholder,
|
||||
onChange,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
value?: string;
|
||||
defaultValue?: string;
|
||||
placeholder?: string;
|
||||
onChange?: (value: string) => void;
|
||||
children: ReactNode;
|
||||
}) => (
|
||||
<label>
|
||||
{label}
|
||||
<select
|
||||
aria-label={label}
|
||||
value={value ?? defaultValue ?? ''}
|
||||
onChange={event => onChange?.(event.currentTarget.value)}
|
||||
>
|
||||
{placeholder && <option value="">{placeholder}</option>}
|
||||
{children}
|
||||
</select>
|
||||
</label>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/common/Empty', () => ({
|
||||
Empty: () => <div>Empty</div>,
|
||||
}));
|
||||
|
||||
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: () => <div>WebsiteControls</div>,
|
||||
}));
|
||||
|
||||
vi.mock('./Attribution', () => ({
|
||||
Attribution: ({ step, type }: { step: string; type: string }) => (
|
||||
<div data-testid="attribution-result">{`${type}:${step}`}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
test('keeps the selected conversion step when the date range refreshes the value list', async () => {
|
||||
const { rerender, user } = render(<AttributionPage websiteId="website-1" />);
|
||||
|
||||
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(<AttributionPage websiteId="website-1" />);
|
||||
|
||||
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(<AttributionPage websiteId="website-1" />);
|
||||
|
||||
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();
|
||||
});
|
||||
@@ -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 }) => (
|
||||
<ListItem key={value} id={value}>
|
||||
{value}
|
||||
</ListItem>
|
||||
|
||||
@@ -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(<UserButton showText={false} />);
|
||||
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(<UserButton />);
|
||||
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();
|
||||
});
|
||||
@@ -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) {
|
||||
<Button
|
||||
variant="zero"
|
||||
aria-label={showText ? undefined : user.username}
|
||||
style={{ padding: 0, width: '100%', justifyContent: 'flex-start' }}
|
||||
style={{
|
||||
display: 'flex',
|
||||
padding: 0,
|
||||
width: '100%',
|
||||
flexGrow: 1,
|
||||
justifyContent: 'flex-start',
|
||||
}}
|
||||
>
|
||||
<Row
|
||||
alignItems="center"
|
||||
@@ -126,7 +143,7 @@ export function UserButton({ showText = true, onClose }: UserButtonProps) {
|
||||
);
|
||||
|
||||
const menu = (
|
||||
<DialogTrigger>
|
||||
<MenuTrigger>
|
||||
{trigger}
|
||||
<Popover side="top" align="start">
|
||||
<Column minWidth="200px">
|
||||
@@ -159,7 +176,7 @@ export function UserButton({ showText = true, onClose }: UserButtonProps) {
|
||||
style={{ maxHeight: 300, overflow: 'auto' }}
|
||||
>
|
||||
{languageItems.map(({ value, label }) => (
|
||||
<MenuItem key={value} id={value} onAction={key => saveLocale(key as string)}>
|
||||
<MenuItem key={value} id={value} onAction={() => handleSelectLocale(value)}>
|
||||
<Text weight={value === locale ? 'bold' : undefined}>{label}</Text>
|
||||
</MenuItem>
|
||||
))}
|
||||
@@ -181,21 +198,17 @@ export function UserButton({ showText = true, onClose }: UserButtonProps) {
|
||||
isNonModal
|
||||
>
|
||||
<Menu selectionMode="single" selectedKeys={new Set([theme])}>
|
||||
<MenuItem id="light" onAction={key => setTheme(key as 'light' | 'dark')}>
|
||||
<Row alignItems="center" gap>
|
||||
<Icon>
|
||||
<Sun />
|
||||
</Icon>
|
||||
<Text>Light</Text>
|
||||
</Row>
|
||||
<MenuItem id="light" onAction={() => handleSelectTheme('light')}>
|
||||
<Icon>
|
||||
<Sun />
|
||||
</Icon>
|
||||
<Text weight={theme === 'light' ? 'bold' : undefined}>Light</Text>
|
||||
</MenuItem>
|
||||
<MenuItem id="dark" onAction={key => setTheme(key as 'light' | 'dark')}>
|
||||
<Row alignItems="center" gap>
|
||||
<Icon>
|
||||
<Moon />
|
||||
</Icon>
|
||||
<Text>Dark</Text>
|
||||
</Row>
|
||||
<MenuItem id="dark" onAction={() => handleSelectTheme('dark')}>
|
||||
<Icon>
|
||||
<Moon />
|
||||
</Icon>
|
||||
<Text weight={theme === 'dark' ? 'bold' : undefined}>Dark</Text>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</Popover>
|
||||
@@ -204,6 +217,7 @@ export function UserButton({ showText = true, onClose }: UserButtonProps) {
|
||||
if (separator) {
|
||||
return <MenuSeparator key={id} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<MenuItem key={id} id={id} onAction={() => handleNavigate(path, target)}>
|
||||
<Row alignItems="center" gap>
|
||||
@@ -221,16 +235,16 @@ export function UserButton({ showText = true, onClose }: UserButtonProps) {
|
||||
</Menu>
|
||||
</Column>
|
||||
</Popover>
|
||||
</DialogTrigger>
|
||||
</MenuTrigger>
|
||||
);
|
||||
|
||||
if (showText) {
|
||||
return menu;
|
||||
return <div style={{ width: '100%' }}>{menu}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipTrigger delay={0}>
|
||||
<Row width="100%">{menu}</Row>
|
||||
<div style={{ width: '100%' }}>{menu}</div>
|
||||
<Tooltip placement="right">{user.username}</Tooltip>
|
||||
</TooltipTrigger>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user