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

View file

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

View file

@ -88,6 +88,31 @@ export function CompareTables({ websiteId }: { websiteId: string }) {
label: formatMessage(labels.events),
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',
label: formatMessage(labels.hostname),

View file

@ -1,24 +1,142 @@
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() {
const { formatMessage, labels } = useMessages();
const fields = [
{ name: 'path', type: 'string', label: formatMessage(labels.path) },
{ name: 'query', type: 'string', label: formatMessage(labels.query) },
{ name: 'title', type: 'string', label: formatMessage(labels.pageTitle) },
{ name: 'referrer', type: 'string', label: formatMessage(labels.referrer) },
{ name: 'browser', type: 'string', label: formatMessage(labels.browser) },
{ 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: 'region', type: 'string', label: formatMessage(labels.region) },
{ name: 'city', type: 'string', label: formatMessage(labels.city) },
{ name: 'hostname', type: 'string', label: formatMessage(labels.hostname) },
{ name: 'distinctId', type: 'string', label: formatMessage(labels.distinctId) },
{ name: 'tag', type: 'string', label: formatMessage(labels.tag) },
{ name: 'event', type: 'string', label: formatMessage(labels.event) },
const fields: Field[] = [
{
name: 'path',
filterLabel: formatMessage(labels.path),
label: formatMessage(labels.path),
group: 'url',
},
{
name: 'query',
filterLabel: formatMessage(labels.query),
label: formatMessage(labels.query),
group: 'url',
},
{
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,
hostname,
distinctId,
utmSource,
utmMedium,
utmCampaign,
utmContent,
utmTerm,
page,
pageSize,
search,
@ -45,6 +50,11 @@ export function useFilterParameters() {
tag,
hostname,
distinctId,
utmSource,
utmMedium,
utmCampaign,
utmContent,
utmTerm,
search,
segment,
cohort,
@ -66,6 +76,11 @@ export function useFilterParameters() {
tag,
hostname,
distinctId,
utmSource,
utmMedium,
utmCampaign,
utmContent,
utmTerm,
page,
pageSize,
search,

View file

@ -5,8 +5,10 @@ import {
Icon,
List,
ListItem,
ListSection,
Menu,
MenuItem,
MenuSection,
MenuTrigger,
Popover,
Row,
@ -15,7 +17,7 @@ import { endOfDay, subMonths } from 'date-fns';
import type { Key } from 'react';
import { Empty } from '@/components/common/Empty';
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';
export interface FieldFiltersProps {
@ -27,11 +29,25 @@ export interface FieldFiltersProps {
export function FieldFilters({ websiteId, value, exclude = [], onChange }: FieldFiltersProps) {
const { formatMessage, messages } = useMessages();
const { fields } = useFields();
const { fields, groupLabels } = useFields();
const startDate = subMonths(endOfDay(new Date()), 6);
const endDate = endOfDay(new Date());
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>) => {
onChange(value.map(filter => (filter.name === name ? { ...filter, ...props } : filter)));
};
@ -66,32 +82,44 @@ export function FieldFilters({ websiteId, value, exclude = [], onChange }: Field
onAction={handleAdd}
style={{ maxHeight: 'calc(100vh - 2rem)', overflowY: 'auto' }}
>
{fields
.filter(({ name }) => !exclude.includes(name))
.map(field => {
const isDisabled = !!value.find(({ name }) => name === field.name);
return (
<MenuItem key={field.name} id={field.name} isDisabled={isDisabled}>
{field.label}
</MenuItem>
);
})}
{groupLabels.map(({ key: groupKey, label }) => {
const groupFields = groupedFields[groupKey];
return (
<MenuSection key={groupKey} title={label}>
{groupFields.map(field => {
const isDisabled = !!value.find(({ name }) => name === field.name);
return (
<MenuItem key={field.name} id={field.name} isDisabled={isDisabled}>
{field.filterLabel}
</MenuItem>
);
})}
</MenuSection>
);
})}
</Menu>
</Popover>
</MenuTrigger>
</Row>
<Column display={{ xs: 'none', md: 'flex' }} border="right" paddingRight="3" marginRight="6">
<List onAction={handleAdd}>
{fields
.filter(({ name }) => !exclude.includes(name))
.map(field => {
const isDisabled = !!value.find(({ name }) => name === field.name);
return (
<ListItem key={field.name} id={field.name} isDisabled={isDisabled}>
{field.label}
</ListItem>
);
})}
{groupLabels.map(({ key: groupKey, label }) => {
const groupFields = groupedFields[groupKey];
if (!groupFields || groupFields.length === 0) return null;
return (
<ListSection key={groupKey} title={label}>
{groupFields.map(field => {
const isDisabled = !!value.find(({ name }) => name === field.name);
return (
<ListItem key={field.name} id={field.name} isDisabled={isDisabled}>
{field.filterLabel}
</ListItem>
);
})}
</ListSection>
);
})}
</List>
</Column>
<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' },
settings: { id: 'label.settings', defaultMessage: 'Settings' },
owner: { id: 'label.owner', defaultMessage: 'Owner' },
url: { id: 'label.url', defaultMessage: 'URL' },
teamOwner: { id: 'label.team-owner', defaultMessage: 'Team owner' },
teamManager: { id: 'label.team-manager', defaultMessage: 'Team manager' },
teamMember: { id: 'label.team-member', defaultMessage: 'Team member' },
@ -245,6 +246,16 @@ export const labels = defineMessages({
device: { id: 'label.device', defaultMessage: 'Device' },
pageTitle: { id: 'label.pageTitle', defaultMessage: 'Page title' },
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' },
cohort: { id: 'label.cohort', defaultMessage: 'Cohort' },
minute: { id: 'label.minute', defaultMessage: 'Minute' },
@ -311,9 +322,7 @@ export const labels = defineMessages({
channel: { id: 'label.channel', defaultMessage: 'Channel' },
channels: { id: 'label.channels', defaultMessage: 'Channels' },
sources: { id: 'label.sources', defaultMessage: 'Sources' },
medium: { id: 'label.medium', defaultMessage: 'Medium' },
campaigns: { id: 'label.campaigns', defaultMessage: 'Campaigns' },
content: { id: 'label.content', defaultMessage: 'Content' },
terms: { id: 'label.terms', defaultMessage: 'Terms' },
direct: { id: 'label.direct', defaultMessage: 'Direct' },
referral: { id: 'label.referral', defaultMessage: 'Referral' },

View file

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

View file

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

View file

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

View file

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