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
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user