Add reusable website value combobox to attribution and journey filters
This commit is contained in:
1 parent
928c0c7ea1
commit
fbc4e86bb9
4 files changed
+207
-45
No files matched your search
@@ -0,0 +1,84 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { expect, test, vi } from 'vitest';
|
||||
import { WebsiteValueComboBox } from './WebsiteValueComboBox';
|
||||
|
||||
vi.mock('@umami/react-zen', () => ({
|
||||
cn: (...values: string[]) => values.filter(Boolean).join(' '),
|
||||
Label: ({ children }: { children: ReactNode }) => <label>{children}</label>,
|
||||
ListItem: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
Loading: () => <div>Loading</div>,
|
||||
useDebounce: (value: string) => value,
|
||||
}));
|
||||
|
||||
vi.mock('@/components/common/ComboBox', () => ({
|
||||
ComboBox: ({
|
||||
'aria-label': ariaLabel,
|
||||
inputValue,
|
||||
onInputValueChange,
|
||||
children,
|
||||
}: {
|
||||
'aria-label'?: string;
|
||||
inputValue: string;
|
||||
onInputValueChange: (value: string) => void;
|
||||
children: ReactNode;
|
||||
}) => (
|
||||
<div>
|
||||
<input
|
||||
aria-label={ariaLabel}
|
||||
value={inputValue}
|
||||
onChange={event => onInputValueChange(event.currentTarget.value)}
|
||||
/>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/hooks', () => ({
|
||||
useMessages: () => ({
|
||||
t: (value: string) => value,
|
||||
messages: { noResultsFound: 'No results found' },
|
||||
}),
|
||||
useWebsiteValuesQuery: ({ type }: { type: string }) => ({
|
||||
data: type === 'path' ? [{ value: '/home' }] : type === 'event' ? [{ value: 'signup' }] : [],
|
||||
isLoading: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
test('clears the selected website value when the input is cleared', () => {
|
||||
const onChange = vi.fn();
|
||||
|
||||
render(
|
||||
<WebsiteValueComboBox
|
||||
label="Start step"
|
||||
websiteId="website-1"
|
||||
type="path"
|
||||
startDate={new Date('2026-08-01')}
|
||||
endDate={new Date('2026-08-03')}
|
||||
value="/home"
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Start step'), { target: { value: '' } });
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith('');
|
||||
});
|
||||
|
||||
test('combines values from both Journey step types', () => {
|
||||
render(
|
||||
<WebsiteValueComboBox
|
||||
label="Start step"
|
||||
websiteId="website-1"
|
||||
type="path"
|
||||
additionalType="event"
|
||||
startDate={new Date('2026-08-01')}
|
||||
endDate={new Date('2026-08-03')}
|
||||
value=""
|
||||
onChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('/home')).toBeInTheDocument();
|
||||
expect(screen.getByText('signup')).toBeInTheDocument();
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import { type ComboBoxProps, cn, Label, ListItem, Loading, useDebounce } from '@umami/react-zen';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { ComboBox } from '@/components/common/ComboBox';
|
||||
import { Empty } from '@/components/common/Empty';
|
||||
import { useMessages, useWebsiteValuesQuery } from '@/components/hooks';
|
||||
|
||||
const DEFAULT_PLACEHOLDER = 'Select an item';
|
||||
|
||||
export interface WebsiteValueComboBoxProps
|
||||
extends Omit<
|
||||
ComboBoxProps,
|
||||
'children' | 'inputValue' | 'items' | 'onChange' | 'onInputValueChange'
|
||||
> {
|
||||
websiteId: string;
|
||||
type: string;
|
||||
additionalType?: string;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
export function WebsiteValueComboBox({
|
||||
websiteId,
|
||||
type,
|
||||
additionalType = '',
|
||||
startDate,
|
||||
endDate,
|
||||
value,
|
||||
onChange,
|
||||
label,
|
||||
className,
|
||||
placeholder = DEFAULT_PLACEHOLDER,
|
||||
...props
|
||||
}: WebsiteValueComboBoxProps) {
|
||||
const { t, messages } = useMessages();
|
||||
const [search, setSearch] = useState(value);
|
||||
const searchValue = useDebounce(search, 300);
|
||||
const primaryQuery = useWebsiteValuesQuery({
|
||||
websiteId,
|
||||
type,
|
||||
search: searchValue,
|
||||
startDate,
|
||||
endDate,
|
||||
});
|
||||
const additionalQuery = useWebsiteValuesQuery({
|
||||
websiteId,
|
||||
type: additionalType,
|
||||
search: searchValue,
|
||||
startDate,
|
||||
endDate,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setSearch('');
|
||||
}, [type, additionalType]);
|
||||
|
||||
const items = useMemo(() => {
|
||||
const values = [...(primaryQuery.data || []), ...(additionalQuery.data || [])]
|
||||
.map(({ value }) => value)
|
||||
.filter(Boolean);
|
||||
|
||||
return [...new Set<string>(values)];
|
||||
}, [primaryQuery.data, additionalQuery.data]);
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-1', className)}>
|
||||
{label && <Label>{label}</Label>}
|
||||
<ComboBox
|
||||
{...props}
|
||||
aria-label={props['aria-label'] ?? label}
|
||||
items={items}
|
||||
inputValue={value}
|
||||
placeholder={placeholder}
|
||||
onInputValueChange={value => {
|
||||
setSearch(value);
|
||||
onChange(value);
|
||||
}}
|
||||
renderEmptyState={() =>
|
||||
primaryQuery.isLoading || additionalQuery.isLoading ? (
|
||||
<Loading placement="center" icon="dots" />
|
||||
) : (
|
||||
<Empty message={t(messages.noResultsFound)} />
|
||||
)
|
||||
}
|
||||
>
|
||||
{items.map(item => (
|
||||
<ListItem key={item} id={item}>
|
||||
{item}
|
||||
</ListItem>
|
||||
))}
|
||||
</ComboBox>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user