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

@@ -1,42 +1,23 @@
'use client';
import { Column, Grid, ListItem, Select } from '@umami/react-zen';
import { useMemo, useState } from 'react';
import { useState } from 'react';
import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
import { Empty } from '@/components/common/Empty';
import { useDateRange, useMessages, useWebsiteValuesQuery } from '@/components/hooks';
import { useDateRange, useMessages } from '@/components/hooks';
import { WebsiteValueComboBox } from '@/components/input/WebsiteValueComboBox';
import { Attribution } from './Attribution';
const stepPlaceholder = 'Select an item';
export function AttributionPage({ websiteId }: { websiteId: string }) {
const [model, setModel] = useState('first-click');
const [type, setType] = useState('path');
const [step, setStep] = useState('');
const [search, setSearch] = useState('');
const { t, labels } = useMessages();
const {
dateRange: { startDate, endDate },
} = useDateRange();
const { data, isLoading } = useWebsiteValuesQuery({
websiteId,
type,
search,
startDate,
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);
setStep('');
setSearch('');
};
return (
@@ -66,24 +47,15 @@ export function AttributionPage({ websiteId }: { websiteId: string }) {
</Select>
</Column>
<Column>
<Select
<WebsiteValueComboBox
label={t(labels.conversionStep)}
websiteId={websiteId}
type={type}
startDate={startDate}
endDate={endDate}
value={step}
onChange={value => setStep(value?.toString() ?? '')}
placeholder={stepPlaceholder}
renderValue={({ defaultChildren }) => (step ? defaultChildren : stepPlaceholder)}
isLoading={isLoading}
searchValue={search}
onSearch={setSearch}
maxHeight={320}
allowSearch
>
{stepItems.map(({ value }) => (
<ListItem key={value} id={value}>
{value}
</ListItem>
))}
</Select>
onChange={setStep}
/>
</Column>
</Grid>
{step ? (
@@ -1,10 +1,11 @@
'use client';
import { Column, Grid, ListItem, Row, SearchField, Select } from '@umami/react-zen';
import { Column, Grid, ListItem, Row, Select } from '@umami/react-zen';
import { useState } from 'react';
import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
import { Panel } from '@/components/common/Panel';
import { useDateRange, useMessages } from '@/components/hooks';
import { FilterButtons } from '@/components/input/FilterButtons';
import { WebsiteValueComboBox } from '@/components/input/WebsiteValueComboBox';
import { Journey } from './Journey';
const JOURNEY_STEPS = [2, 3, 4, 5, 6, 7];
@@ -19,6 +20,8 @@ export function JourneysPage({ websiteId }: { websiteId: string }) {
const [steps, setSteps] = useState(DEFAULT_STEP);
const [startStep, setStartStep] = useState('');
const [endStep, setEndStep] = useState('');
const stepType = view === 'events' ? 'event' : 'path';
const additionalStepType = view === 'all' ? 'event' : undefined;
const buttons = [
{
@@ -52,19 +55,27 @@ export function JourneysPage({ websiteId }: { websiteId: string }) {
))}
</Select>
<Column>
<SearchField
<WebsiteValueComboBox
label={t(labels.startStep)}
websiteId={websiteId}
type={stepType}
additionalType={additionalStepType}
startDate={startDate}
endDate={endDate}
value={startStep}
onSearch={setStartStep}
delay={1000}
onChange={setStartStep}
/>
</Column>
<Column>
<SearchField
<WebsiteValueComboBox
label={t(labels.endStep)}
websiteId={websiteId}
type={stepType}
additionalType={additionalStepType}
startDate={startDate}
endDate={endDate}
value={endStep}
onSearch={setEndStep}
delay={1000}
onChange={setEndStep}
/>
</Column>
</Grid>
@@ -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>
);
}