implement UTM filters and fields

This commit is contained in:
Francis Cao 2026-02-05 16:30:46 -08:00
parent 7514af4236
commit 49adaa32d0
11 changed files with 336 additions and 55 deletions

View file

@ -1,6 +1,6 @@
import { Button, Column, Grid, List, ListItem } from '@umami/react-zen'; import { Button, Column, Grid, List, ListItem, ListSection } from '@umami/react-zen';
import { useState } from 'react'; import { useState } from 'react';
import { useFields, useMessages } from '@/components/hooks'; import { type FieldGroup, useFields, useMessages } from '@/components/hooks';
export function FieldSelectForm({ export function FieldSelectForm({
selectedFields = [], selectedFields = [],
@ -13,7 +13,7 @@ export function FieldSelectForm({
}) { }) {
const [selected, setSelected] = useState(selectedFields); const [selected, setSelected] = useState(selectedFields);
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
const { fields } = useFields(); const { fields, groupLabels } = useFields();
const handleChange = (value: string[]) => { const handleChange = (value: string[]) => {
setSelected(value); setSelected(value);
@ -24,17 +24,38 @@ export function FieldSelectForm({
onClose(); onClose();
}; };
const groupedFields = fields
.filter(field => field.name !== 'event')
.reduce(
(acc, field) => {
const group = field.group;
if (!acc[group]) {
acc[group] = [];
}
acc[group].push(field);
return acc;
},
{} as Record<FieldGroup, typeof fields>,
);
return ( return (
<Column gap="6"> <Column gap="6">
<List value={selected} onChange={handleChange} selectionMode="multiple"> <Column gap="3" overflowY="auto" maxHeight="400px">
{fields.map(({ name, label }) => { <List value={selected} onChange={handleChange} selectionMode="multiple">
return ( {groupLabels.map(({ key: groupKey, label }) => {
<ListItem key={name} id={name}> const groupFields = groupedFields[groupKey];
{label} return (
</ListItem> <ListSection key={groupKey} title={label}>
); {groupFields.map(field => (
})} <ListItem key={field.name} id={field.name}>
</List> {field.filterLabel}
</ListItem>
))}
</ListSection>
);
})}
</List>
</Column>
<Grid columns="1fr 1fr" gap> <Grid columns="1fr 1fr" gap>
<Button onPress={onClose}>{formatMessage(labels.cancel)}</Button> <Button onPress={onClose}>{formatMessage(labels.cancel)}</Button>
<Button onPress={handleApply} variant="primary"> <Button onPress={handleApply} variant="primary">

View file

@ -4,10 +4,14 @@ import {
AppWindow, AppWindow,
Cpu, Cpu,
Earth, Earth,
Fingerprint,
Globe, Globe,
KeyRound,
Landmark, Landmark,
Languages, Languages,
Laptop, Laptop,
Layers,
Link2,
LogIn, LogIn,
LogOut, LogOut,
MapPin, MapPin,
@ -15,9 +19,11 @@ import {
Monitor, Monitor,
Network, Network,
Search, Search,
Send,
Share2, Share2,
SquareSlash, SquareSlash,
Tag, Tag,
Target,
Type, Type,
} from '@/components/icons'; } from '@/components/icons';
import { Lightning } from '@/components/svg'; import { Lightning } from '@/components/svg';
@ -154,6 +160,41 @@ export function WebsiteExpandedMenu({
}, },
].filter(filterExcluded), ].filter(filterExcluded),
}, },
{
label: formatMessage(labels.utm),
items: [
{
id: 'utmSource',
label: formatMessage(labels.source),
path: updateParams({ view: 'utmSource' }),
icon: <Link2 />,
},
{
id: 'utmMedium',
label: formatMessage(labels.medium),
path: updateParams({ view: 'utmMedium' }),
icon: <Send />,
},
{
id: 'utmCampaign',
label: formatMessage(labels.campaign),
path: updateParams({ view: 'utmCampaign' }),
icon: <Target />,
},
{
id: 'utmContent',
label: formatMessage(labels.content),
path: updateParams({ view: 'utmContent' }),
icon: <Layers />,
},
{
id: 'utmTerm',
label: formatMessage(labels.term),
path: updateParams({ view: 'utmTerm' }),
icon: <KeyRound />,
},
].filter(filterExcluded),
},
{ {
label: formatMessage(labels.other), label: formatMessage(labels.other),
items: [ items: [
@ -173,7 +214,7 @@ export function WebsiteExpandedMenu({
id: 'distinctId', id: 'distinctId',
label: formatMessage(labels.distinctId), label: formatMessage(labels.distinctId),
path: updateParams({ view: 'distinctId' }), path: updateParams({ view: 'distinctId' }),
icon: <Tag />, icon: <Fingerprint />,
}, },
{ {
id: 'tag', id: 'tag',

View file

@ -88,6 +88,31 @@ export function CompareTables({ websiteId }: { websiteId: string }) {
label: formatMessage(labels.events), label: formatMessage(labels.events),
path: renderPath('event'), path: renderPath('event'),
}, },
{
id: 'utmSource',
label: formatMessage(labels.utmSource),
path: renderPath('utmSource'),
},
{
id: 'utmMedium',
label: formatMessage(labels.utmMedium),
path: renderPath('utmMedium'),
},
{
id: 'utmCampaign',
label: formatMessage(labels.utmCampaign),
path: renderPath('utmCampaign'),
},
{
id: 'utmContent',
label: formatMessage(labels.utmContent),
path: renderPath('utmContent'),
},
{
id: 'utmTerm',
label: formatMessage(labels.utmTerm),
path: renderPath('utmTerm'),
},
{ {
id: 'hostname', id: 'hostname',
label: formatMessage(labels.hostname), label: formatMessage(labels.hostname),

View file

@ -1,24 +1,142 @@
import { useMessages } from './useMessages'; import { useMessages } from './useMessages';
export type FieldGroup = 'url' | 'sources' | 'location' | 'environment' | 'utm' | 'other';
export interface Field {
name: string;
filterLabel: string;
label: string;
group: FieldGroup;
}
export function useFields() { export function useFields() {
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
const fields = [ const fields: Field[] = [
{ name: 'path', type: 'string', label: formatMessage(labels.path) }, {
{ name: 'query', type: 'string', label: formatMessage(labels.query) }, name: 'path',
{ name: 'title', type: 'string', label: formatMessage(labels.pageTitle) }, filterLabel: formatMessage(labels.path),
{ name: 'referrer', type: 'string', label: formatMessage(labels.referrer) }, label: formatMessage(labels.path),
{ name: 'browser', type: 'string', label: formatMessage(labels.browser) }, group: 'url',
{ name: 'os', type: 'string', label: formatMessage(labels.os) }, },
{ name: 'device', type: 'string', label: formatMessage(labels.device) }, {
{ name: 'country', type: 'string', label: formatMessage(labels.country) }, name: 'query',
{ name: 'region', type: 'string', label: formatMessage(labels.region) }, filterLabel: formatMessage(labels.query),
{ name: 'city', type: 'string', label: formatMessage(labels.city) }, label: formatMessage(labels.query),
{ name: 'hostname', type: 'string', label: formatMessage(labels.hostname) }, group: 'url',
{ name: 'distinctId', type: 'string', label: formatMessage(labels.distinctId) }, },
{ name: 'tag', type: 'string', label: formatMessage(labels.tag) }, {
{ name: 'event', type: 'string', label: formatMessage(labels.event) }, name: 'title',
filterLabel: formatMessage(labels.pageTitle),
label: formatMessage(labels.pageTitle),
group: 'url',
},
{
name: 'referrer',
filterLabel: formatMessage(labels.referrer),
label: formatMessage(labels.referrer),
group: 'sources',
},
{
name: 'country',
filterLabel: formatMessage(labels.country),
label: formatMessage(labels.country),
group: 'location',
},
{
name: 'region',
filterLabel: formatMessage(labels.region),
label: formatMessage(labels.region),
group: 'location',
},
{
name: 'city',
filterLabel: formatMessage(labels.city),
label: formatMessage(labels.city),
group: 'location',
},
{
name: 'browser',
filterLabel: formatMessage(labels.browser),
label: formatMessage(labels.browser),
group: 'environment',
},
{
name: 'os',
filterLabel: formatMessage(labels.os),
label: formatMessage(labels.os),
group: 'environment',
},
{
name: 'device',
filterLabel: formatMessage(labels.device),
label: formatMessage(labels.device),
group: 'environment',
},
{
name: 'utmSource',
filterLabel: formatMessage(labels.source),
label: formatMessage(labels.utmSource),
group: 'utm',
},
{
name: 'utmMedium',
filterLabel: formatMessage(labels.medium),
label: formatMessage(labels.utmMedium),
group: 'utm',
},
{
name: 'utmCampaign',
filterLabel: formatMessage(labels.campaign),
label: formatMessage(labels.utmCampaign),
group: 'utm',
},
{
name: 'utmContent',
filterLabel: formatMessage(labels.content),
label: formatMessage(labels.utmContent),
group: 'utm',
},
{
name: 'utmTerm',
filterLabel: formatMessage(labels.term),
label: formatMessage(labels.utmTerm),
group: 'utm',
},
{
name: 'hostname',
filterLabel: formatMessage(labels.hostname),
label: formatMessage(labels.hostname),
group: 'other',
},
{
name: 'distinctId',
filterLabel: formatMessage(labels.distinctId),
label: formatMessage(labels.distinctId),
group: 'other',
},
{
name: 'tag',
filterLabel: formatMessage(labels.tag),
label: formatMessage(labels.tag),
group: 'other',
},
{
name: 'event',
filterLabel: formatMessage(labels.event),
label: formatMessage(labels.event),
group: 'other',
},
]; ];
return { fields }; const groupLabels: { key: FieldGroup; label: string }[] = [
{ key: 'url', label: formatMessage(labels.url) },
{ key: 'sources', label: formatMessage(labels.sources) },
{ key: 'location', label: formatMessage(labels.location) },
{ key: 'environment', label: formatMessage(labels.environment) },
{ key: 'utm', label: formatMessage(labels.utm) },
{ key: 'other', label: formatMessage(labels.other) },
];
return { fields, groupLabels };
} }

View file

@ -19,6 +19,11 @@ export function useFilterParameters() {
tag, tag,
hostname, hostname,
distinctId, distinctId,
utmSource,
utmMedium,
utmCampaign,
utmContent,
utmTerm,
page, page,
pageSize, pageSize,
search, search,
@ -45,6 +50,11 @@ export function useFilterParameters() {
tag, tag,
hostname, hostname,
distinctId, distinctId,
utmSource,
utmMedium,
utmCampaign,
utmContent,
utmTerm,
search, search,
segment, segment,
cohort, cohort,
@ -66,6 +76,11 @@ export function useFilterParameters() {
tag, tag,
hostname, hostname,
distinctId, distinctId,
utmSource,
utmMedium,
utmCampaign,
utmContent,
utmTerm,
page, page,
pageSize, pageSize,
search, search,

View file

@ -5,8 +5,10 @@ import {
Icon, Icon,
List, List,
ListItem, ListItem,
ListSection,
Menu, Menu,
MenuItem, MenuItem,
MenuSection,
MenuTrigger, MenuTrigger,
Popover, Popover,
Row, Row,
@ -15,7 +17,7 @@ import { endOfDay, subMonths } from 'date-fns';
import type { Key } from 'react'; import type { Key } from 'react';
import { Empty } from '@/components/common/Empty'; import { Empty } from '@/components/common/Empty';
import { FilterRecord } from '@/components/common/FilterRecord'; import { FilterRecord } from '@/components/common/FilterRecord';
import { useFields, useMessages, useMobile } from '@/components/hooks'; import { type FieldGroup, useFields, useMessages, useMobile } from '@/components/hooks';
import { Plus } from '@/components/icons'; import { Plus } from '@/components/icons';
export interface FieldFiltersProps { export interface FieldFiltersProps {
@ -27,11 +29,25 @@ export interface FieldFiltersProps {
export function FieldFilters({ websiteId, value, exclude = [], onChange }: FieldFiltersProps) { export function FieldFilters({ websiteId, value, exclude = [], onChange }: FieldFiltersProps) {
const { formatMessage, messages } = useMessages(); const { formatMessage, messages } = useMessages();
const { fields } = useFields(); const { fields, groupLabels } = useFields();
const startDate = subMonths(endOfDay(new Date()), 6); const startDate = subMonths(endOfDay(new Date()), 6);
const endDate = endOfDay(new Date()); const endDate = endOfDay(new Date());
const { isMobile } = useMobile(); const { isMobile } = useMobile();
const groupedFields = fields
.filter(({ name }) => !exclude.includes(name))
.reduce(
(acc, field) => {
const group = field.group;
if (!acc[group]) {
acc[group] = [];
}
acc[group].push(field);
return acc;
},
{} as Record<FieldGroup, typeof fields>,
);
const updateFilter = (name: string, props: Record<string, any>) => { const updateFilter = (name: string, props: Record<string, any>) => {
onChange(value.map(filter => (filter.name === name ? { ...filter, ...props } : filter))); onChange(value.map(filter => (filter.name === name ? { ...filter, ...props } : filter)));
}; };
@ -66,32 +82,44 @@ export function FieldFilters({ websiteId, value, exclude = [], onChange }: Field
onAction={handleAdd} onAction={handleAdd}
style={{ maxHeight: 'calc(100vh - 2rem)', overflowY: 'auto' }} style={{ maxHeight: 'calc(100vh - 2rem)', overflowY: 'auto' }}
> >
{fields {groupLabels.map(({ key: groupKey, label }) => {
.filter(({ name }) => !exclude.includes(name)) const groupFields = groupedFields[groupKey];
.map(field => { return (
const isDisabled = !!value.find(({ name }) => name === field.name); <MenuSection key={groupKey} title={label}>
return ( {groupFields.map(field => {
<MenuItem key={field.name} id={field.name} isDisabled={isDisabled}> const isDisabled = !!value.find(({ name }) => name === field.name);
{field.label} return (
</MenuItem> <MenuItem key={field.name} id={field.name} isDisabled={isDisabled}>
); {field.filterLabel}
})} </MenuItem>
);
})}
</MenuSection>
);
})}
</Menu> </Menu>
</Popover> </Popover>
</MenuTrigger> </MenuTrigger>
</Row> </Row>
<Column display={{ xs: 'none', md: 'flex' }} border="right" paddingRight="3" marginRight="6"> <Column display={{ xs: 'none', md: 'flex' }} border="right" paddingRight="3" marginRight="6">
<List onAction={handleAdd}> <List onAction={handleAdd}>
{fields {groupLabels.map(({ key: groupKey, label }) => {
.filter(({ name }) => !exclude.includes(name)) const groupFields = groupedFields[groupKey];
.map(field => { if (!groupFields || groupFields.length === 0) return null;
const isDisabled = !!value.find(({ name }) => name === field.name);
return ( return (
<ListItem key={field.name} id={field.name} isDisabled={isDisabled}> <ListSection key={groupKey} title={label}>
{field.label} {groupFields.map(field => {
</ListItem> const isDisabled = !!value.find(({ name }) => name === field.name);
); return (
})} <ListItem key={field.name} id={field.name} isDisabled={isDisabled}>
{field.filterLabel}
</ListItem>
);
})}
</ListSection>
);
})}
</List> </List>
</Column> </Column>
<Column overflow="auto" gapY="4" style={{ contain: 'layout' }}> <Column overflow="auto" gapY="4" style={{ contain: 'layout' }}>

View file

@ -43,6 +43,7 @@ export const labels = defineMessages({
joinTeam: { id: 'label.join-team', defaultMessage: 'Join team' }, joinTeam: { id: 'label.join-team', defaultMessage: 'Join team' },
settings: { id: 'label.settings', defaultMessage: 'Settings' }, settings: { id: 'label.settings', defaultMessage: 'Settings' },
owner: { id: 'label.owner', defaultMessage: 'Owner' }, owner: { id: 'label.owner', defaultMessage: 'Owner' },
url: { id: 'label.url', defaultMessage: 'URL' },
teamOwner: { id: 'label.team-owner', defaultMessage: 'Team owner' }, teamOwner: { id: 'label.team-owner', defaultMessage: 'Team owner' },
teamManager: { id: 'label.team-manager', defaultMessage: 'Team manager' }, teamManager: { id: 'label.team-manager', defaultMessage: 'Team manager' },
teamMember: { id: 'label.team-member', defaultMessage: 'Team member' }, teamMember: { id: 'label.team-member', defaultMessage: 'Team member' },
@ -245,6 +246,16 @@ export const labels = defineMessages({
device: { id: 'label.device', defaultMessage: 'Device' }, device: { id: 'label.device', defaultMessage: 'Device' },
pageTitle: { id: 'label.pageTitle', defaultMessage: 'Page title' }, pageTitle: { id: 'label.pageTitle', defaultMessage: 'Page title' },
tag: { id: 'label.tag', defaultMessage: 'Tag' }, tag: { id: 'label.tag', defaultMessage: 'Tag' },
source: { id: 'label.source', defaultMessage: 'Source' },
medium: { id: 'label.medium', defaultMessage: 'Medium' },
campaign: { id: 'label.campaign', defaultMessage: 'Campaign' },
content: { id: 'label.content', defaultMessage: 'Content' },
term: { id: 'label.term', defaultMessage: 'Term' },
utmSource: { id: 'label.utm-source', defaultMessage: 'UTM source' },
utmMedium: { id: 'label.utm-medium', defaultMessage: 'UTM medium' },
utmCampaign: { id: 'label.utm-campaign', defaultMessage: 'UTM campaign' },
utmContent: { id: 'label.utm-content', defaultMessage: 'UTM content' },
utmTerm: { id: 'label.utm-term', defaultMessage: 'UTM term' },
segment: { id: 'label.segment', defaultMessage: 'Segment' }, segment: { id: 'label.segment', defaultMessage: 'Segment' },
cohort: { id: 'label.cohort', defaultMessage: 'Cohort' }, cohort: { id: 'label.cohort', defaultMessage: 'Cohort' },
minute: { id: 'label.minute', defaultMessage: 'Minute' }, minute: { id: 'label.minute', defaultMessage: 'Minute' },
@ -311,9 +322,7 @@ export const labels = defineMessages({
channel: { id: 'label.channel', defaultMessage: 'Channel' }, channel: { id: 'label.channel', defaultMessage: 'Channel' },
channels: { id: 'label.channels', defaultMessage: 'Channels' }, channels: { id: 'label.channels', defaultMessage: 'Channels' },
sources: { id: 'label.sources', defaultMessage: 'Sources' }, sources: { id: 'label.sources', defaultMessage: 'Sources' },
medium: { id: 'label.medium', defaultMessage: 'Medium' },
campaigns: { id: 'label.campaigns', defaultMessage: 'Campaigns' }, campaigns: { id: 'label.campaigns', defaultMessage: 'Campaigns' },
content: { id: 'label.content', defaultMessage: 'Content' },
terms: { id: 'label.terms', defaultMessage: 'Terms' }, terms: { id: 'label.terms', defaultMessage: 'Terms' },
direct: { id: 'label.direct', defaultMessage: 'Direct' }, direct: { id: 'label.direct', defaultMessage: 'Direct' },
referral: { id: 'label.referral', defaultMessage: 'Referral' }, referral: { id: 'label.referral', defaultMessage: 'Referral' },

View file

@ -44,6 +44,11 @@ export const EVENT_COLUMNS = [
'event', 'event',
'tag', 'tag',
'hostname', 'hostname',
'utmSource',
'utmMedium',
'utmCampaign',
'utmContent',
'utmTerm',
]; ];
export const SESSION_COLUMNS = [ export const SESSION_COLUMNS = [
@ -83,6 +88,11 @@ export const FILTER_COLUMNS = {
event: 'event_name', event: 'event_name',
tag: 'tag', tag: 'tag',
eventType: 'event_type', eventType: 'event_type',
utmSource: 'utm_source',
utmMedium: 'utm_medium',
utmCampaign: 'utm_campaign',
utmContent: 'utm_content',
utmTerm: 'utm_term',
}; };
export const COLLECTION_TYPE = { export const COLLECTION_TYPE = {

View file

@ -39,6 +39,11 @@ export const filterParams = {
distinctId: z.string().optional(), distinctId: z.string().optional(),
language: z.string().optional(), language: z.string().optional(),
event: z.string().optional(), event: z.string().optional(),
utmSource: z.string().optional(),
utmMedium: z.string().optional(),
utmCampaign: z.string().optional(),
utmContent: z.string().optional(),
utmTerm: z.string().optional(),
segment: z.uuid().optional(), segment: z.uuid().optional(),
cohort: z.uuid().optional(), cohort: z.uuid().optional(),
eventType: z.coerce.number().int().positive().optional(), eventType: z.coerce.number().int().positive().optional(),
@ -94,6 +99,11 @@ export const fieldsParam = z.enum([
'distinctId', 'distinctId',
'language', 'language',
'event', 'event',
'utmSource',
'utmMedium',
'utmCampaign',
'utmContent',
'utmTerm',
]); ]);
export const reportTypeParam = z.enum([ export const reportTypeParam = z.enum([

View file

@ -33,6 +33,7 @@ async function relationalQuery(websiteId: string, filters: QueryFilters) {
${joinSessionQuery} ${joinSessionQuery}
where website_event.website_id = {{websiteId::uuid}} where website_event.website_id = {{websiteId::uuid}}
and website_event.created_at between {{startDate}} and {{endDate}} and website_event.created_at between {{startDate}} and {{endDate}}
and website_event.event_type != 2
${filterQuery} ${filterQuery}
group by time group by time
order by 1 order by 1
@ -59,8 +60,10 @@ async function clickhouseQuery(websiteId: string, filters: QueryFilters) {
count(distinct session_id) as value count(distinct session_id) as value
from website_event from website_event
${cohortQuery} ${cohortQuery}
${excludeBounceQuery}
where website_id = {websiteId:UUID} where website_id = {websiteId:UUID}
and created_at between {startDate:DateTime64} and {endDate:DateTime64} and created_at between {startDate:DateTime64} and {endDate:DateTime64}
and event_type != 2
${filterQuery} ${filterQuery}
group by time group by time
order by time order by time
@ -75,6 +78,7 @@ async function clickhouseQuery(websiteId: string, filters: QueryFilters) {
${excludeBounceQuery} ${excludeBounceQuery}
where website_id = {websiteId:UUID} where website_id = {websiteId:UUID}
and created_at between {startDate:DateTime64} and {endDate:DateTime64} and created_at between {startDate:DateTime64} and {endDate:DateTime64}
and event_type != 2
${filterQuery} ${filterQuery}
group by time group by time
order by time order by time

View file

@ -71,7 +71,7 @@ async function relationalQuery(
website_event.session_id, website_event.visit_id website_event.session_id, website_event.visit_id
) as t ) as t
group by ${parseFieldsByName(fields)} group by ${parseFieldsByName(fields)}
order by 1 desc, 2 desc order by 2 desc, 1 desc
limit 500 limit 500
`, `,
queryParams, queryParams,
@ -119,7 +119,7 @@ async function clickhouseQuery(
session_id, visit_id session_id, visit_id
) as t ) as t
group by ${parseFieldsByName(fields)} group by ${parseFieldsByName(fields)}
order by 1 desc, 2 desc order by 2 desc, 1 desc
limit 500 limit 500
`, `,
queryParams, queryParams,