From fbc4e86bb94c61dd10259ce5fe05c35b0d4105c6 Mon Sep 17 00:00:00 2001 From: Mike Cao Date: Mon, 3 Aug 2026 13:03:58 -0700 Subject: [PATCH] Add reusable website value combobox to attribution and journey filters --- .../(reports)/attribution/AttributionPage.tsx | 48 ++-------- .../(reports)/journeys/JourneysPage.tsx | 25 +++-- .../input/WebsiteValueComboBox.test.tsx | 84 ++++++++++++++++ src/components/input/WebsiteValueComboBox.tsx | 95 +++++++++++++++++++ 4 files changed, 207 insertions(+), 45 deletions(-) create mode 100644 src/components/input/WebsiteValueComboBox.test.tsx create mode 100644 src/components/input/WebsiteValueComboBox.tsx diff --git a/src/app/(main)/websites/[websiteId]/(reports)/attribution/AttributionPage.tsx b/src/app/(main)/websites/[websiteId]/(reports)/attribution/AttributionPage.tsx index 96a7d1a74..d201f2c34 100644 --- a/src/app/(main)/websites/[websiteId]/(reports)/attribution/AttributionPage.tsx +++ b/src/app/(main)/websites/[websiteId]/(reports)/attribution/AttributionPage.tsx @@ -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 }) { - + onChange={setStep} + /> {step ? ( diff --git a/src/app/(main)/websites/[websiteId]/(reports)/journeys/JourneysPage.tsx b/src/app/(main)/websites/[websiteId]/(reports)/journeys/JourneysPage.tsx index 06939e068..452b776be 100644 --- a/src/app/(main)/websites/[websiteId]/(reports)/journeys/JourneysPage.tsx +++ b/src/app/(main)/websites/[websiteId]/(reports)/journeys/JourneysPage.tsx @@ -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 }) { ))} - - diff --git a/src/components/input/WebsiteValueComboBox.test.tsx b/src/components/input/WebsiteValueComboBox.test.tsx new file mode 100644 index 000000000..0dd6cf51d --- /dev/null +++ b/src/components/input/WebsiteValueComboBox.test.tsx @@ -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 }) => , + ListItem: ({ children }: { children: ReactNode }) =>
{children}
, + Loading: () =>
Loading
, + 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; + }) => ( +
+ onInputValueChange(event.currentTarget.value)} + /> + {children} +
+ ), +})); + +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( + , + ); + + fireEvent.change(screen.getByLabelText('Start step'), { target: { value: '' } }); + + expect(onChange).toHaveBeenCalledWith(''); +}); + +test('combines values from both Journey step types', () => { + render( + {}} + />, + ); + + expect(screen.getByText('/home')).toBeInTheDocument(); + expect(screen.getByText('signup')).toBeInTheDocument(); +}); diff --git a/src/components/input/WebsiteValueComboBox.tsx b/src/components/input/WebsiteValueComboBox.tsx new file mode 100644 index 000000000..fc9b5120a --- /dev/null +++ b/src/components/input/WebsiteValueComboBox.tsx @@ -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(values)]; + }, [primaryQuery.data, additionalQuery.data]); + + return ( +
+ {label && } + { + setSearch(value); + onChange(value); + }} + renderEmptyState={() => + primaryQuery.isLoading || additionalQuery.isLoading ? ( + + ) : ( + + ) + } + > + {items.map(item => ( + + {item} + + ))} + +
+ ); +}