Add reusable website value combobox to attribution and journey filters

This commit is contained in:
Mike Cao committed 2026-08-03 13:03:58 -07:00
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>
);
}