From 2ad624ccc8befcaad256f2981eb136dd916d2354 Mon Sep 17 00:00:00 2001 From: Mike Cao Date: Sun, 27 Jul 2025 02:08:19 -0700 Subject: [PATCH] Added segment filtering to filter form. --- .../[websiteId]/WebsiteFilterButton.tsx | 28 ++-- .../[websiteId]/events/EventsPage.tsx | 8 +- .../[websiteId]/events/EventsTable.tsx | 2 +- .../[websiteId]/sessions/SessionsPage.tsx | 14 +- src/components/common/FilterEditForm.tsx | 135 +++++++----------- src/components/hooks/index.ts | 1 + .../hooks/queries/useWebsiteSegementsQuery.ts | 21 +++ src/components/hooks/useFields.ts | 1 - src/components/hooks/useNavigation.ts | 7 +- src/components/input/FieldFilters.tsx | 74 ++++++++++ src/components/input/FilterBar.tsx | 93 +++++++----- src/components/input/SegmentFilters.tsx | 36 +++++ src/components/input/WebsiteDateFilter.tsx | 2 +- src/lib/prisma.ts | 66 ++------- src/lib/storage.ts | 6 +- 15 files changed, 301 insertions(+), 193 deletions(-) create mode 100644 src/components/hooks/queries/useWebsiteSegementsQuery.ts create mode 100644 src/components/input/FieldFilters.tsx create mode 100644 src/components/input/SegmentFilters.tsx diff --git a/src/app/(main)/websites/[websiteId]/WebsiteFilterButton.tsx b/src/app/(main)/websites/[websiteId]/WebsiteFilterButton.tsx index f745c7e0..cf1cae24 100644 --- a/src/app/(main)/websites/[websiteId]/WebsiteFilterButton.tsx +++ b/src/app/(main)/websites/[websiteId]/WebsiteFilterButton.tsx @@ -13,19 +13,26 @@ export function WebsiteFilterButton({ showText?: boolean; }) { const { formatMessage, labels } = useMessages(); - const { updateParams, router } = useNavigation(); + const { + replaceParams, + router, + query: { segment }, + } = useNavigation(); const { filters } = useFilters(); - const handleChange = (filters: any[]) => { - const params = filters.reduce((obj, filter) => { - const { name, operator, value } = filter; + const handleChange = ({ filters, segment }) => { + const params = filters.reduce( + (obj: { [x: string]: string }, filter: { name: any; operator: any; value: any }) => { + const { name, operator, value } = filter; - obj[name] = `${operator}.${value}`; + obj[name] = `${operator}.${value}`; - return obj; - }, {}); + return obj; + }, + {}, + ); - const url = updateParams(params); + const url = replaceParams({ ...params, segment: segment?.id }); router.push(url); }; @@ -39,12 +46,13 @@ export function WebsiteFilterButton({ {showText && {formatMessage(labels.filter)}} - + {({ close }) => { return ( diff --git a/src/app/(main)/websites/[websiteId]/events/EventsPage.tsx b/src/app/(main)/websites/[websiteId]/events/EventsPage.tsx index 6af76a91..14a90c9f 100644 --- a/src/app/(main)/websites/[websiteId]/events/EventsPage.tsx +++ b/src/app/(main)/websites/[websiteId]/events/EventsPage.tsx @@ -14,7 +14,7 @@ const KEY_NAME = 'umami.events.tab'; export function EventsPage({ websiteId }) { const [label, setLabel] = useState(null); - const [tab, setTab] = useState(getItem(KEY_NAME) || 'activity'); + const [tab, setTab] = useState(getItem(KEY_NAME) || 'chart'); const { formatMessage, labels } = useMessages(); const handleLabelClick = (value: string) => { @@ -32,8 +32,8 @@ export function EventsPage({ websiteId }) { handleSelect(key)}> - {formatMessage(labels.activity)} {formatMessage(labels.chart)} + {formatMessage(labels.activity)} {formatMessage(labels.properties)} @@ -41,7 +41,9 @@ export function EventsPage({ websiteId }) { - + + + )} - + {(row: any) => ( {formatValue(row.browser, 'browser')} diff --git a/src/app/(main)/websites/[websiteId]/sessions/SessionsPage.tsx b/src/app/(main)/websites/[websiteId]/sessions/SessionsPage.tsx index 7e18bbed..68eef29c 100644 --- a/src/app/(main)/websites/[websiteId]/sessions/SessionsPage.tsx +++ b/src/app/(main)/websites/[websiteId]/sessions/SessionsPage.tsx @@ -1,21 +1,29 @@ 'use client'; -import { useState } from 'react'; +import { Key, useState } from 'react'; import { TabList, Tab, Tabs, TabPanel, Column } from '@umami/react-zen'; import { SessionsDataTable } from './SessionsDataTable'; import { SessionProperties } from './SessionProperties'; import { useMessages } from '@/components/hooks'; import { Panel } from '@/components/common/Panel'; import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls'; +import { getItem, setItem } from '@/lib/storage'; + +const KEY_NAME = 'umami.sessions.tab'; export function SessionsPage({ websiteId }) { - const [tab, setTab] = useState('activity'); + const [tab, setTab] = useState(getItem(KEY_NAME) || 'activity'); const { formatMessage, labels } = useMessages(); + const handleSelect = (value: Key) => { + setItem(KEY_NAME, value); + setTab(value); + }; + return ( - setTab(value)}> + {formatMessage(labels.activity)} {formatMessage(labels.properties)} diff --git a/src/components/common/FilterEditForm.tsx b/src/components/common/FilterEditForm.tsx index 850bd91d..80574c5e 100644 --- a/src/components/common/FilterEditForm.tsx +++ b/src/components/common/FilterEditForm.tsx @@ -1,99 +1,68 @@ -import { useState, Key } from 'react'; -import { Grid, Row, Column, Label, List, ListItem, Button, Heading } from '@umami/react-zen'; -import { useDateRange, useFilters, useMessages } from '@/components/hooks'; -import { FilterRecord } from '@/components/common/FilterRecord'; -import { Empty } from '@/components/common/Empty'; +import { useState } from 'react'; +import { Column, Tabs, TabList, Tab, TabPanel, Row, Button } from '@umami/react-zen'; +import { useMessages } from '@/components/hooks'; +import { FieldFilters } from '@/components/input/FieldFilters'; +import { SegmentFilters } from '@/components/input/SegmentFilters'; export interface FilterEditFormProps { websiteId?: string; - data: any[]; - onChange?: (filters: { name: string; type: string; operator: string; value: string }[]) => void; + filters: any[]; + segmentId?: string; + onChange?: (params: { filters: any[]; segment: any }) => void; onClose?: () => void; } -export function FilterEditForm({ websiteId, data = [], onChange, onClose }: FilterEditFormProps) { - const { formatMessage, labels, messages } = useMessages(); - const [filters, setFilters] = useState(data); - const { fields } = useFilters(); - const { - dateRange: { startDate, endDate }, - } = useDateRange(websiteId); +export function FilterEditForm({ + websiteId, + filters = [], + segmentId, + onChange, + onClose, +}: FilterEditFormProps) { + const { formatMessage, labels } = useMessages(); + const [currentFilters, setCurrentFilters] = useState(filters); + const [currentSegment, setCurrentSegment] = useState(null); - const updateFilter = (name: string, props: Record) => { - setFilters(filters => - filters.map(filter => (filter.name === name ? { ...filter, ...props } : filter)), - ); + const handleReset = () => { + setCurrentFilters([]); }; - const handleAdd = (name: Key) => { - setFilters(filters.concat({ name, operator: 'eq', value: '' })); - }; - - const handleChange = (name: string, value: Key) => { - updateFilter(name, { value }); - }; - - const handleSelect = (name: string, operator: Key) => { - updateFilter(name, { operator }); - }; - - const handleRemove = (name: string) => { - setFilters(filters.filter(filter => filter.name !== name)); - }; - - const handleApply = () => { - onChange?.(filters.filter(f => f.value)); + const handleSave = () => { + onChange?.({ filters: currentFilters.filter(f => f.value), segment: currentSegment }); onClose?.(); }; + const handleSegmentChange = (segment?: { id: string }) => { + setCurrentSegment(segment); + }; + return ( - - - {formatMessage(labels.filters)} + + + + {formatMessage(labels.fields)} + {formatMessage(labels.segments)} + + + + + + + + + + + + + + - - - - {fields.map((field: any) => { - const isDisabled = filters.find(({ name }) => name === field.name); - return ( - - {field.label} - - ); - })} - - - - {filters.map(filter => { - return ( - - ); - })} - {!filters.length && } - - - - - - + ); } diff --git a/src/components/hooks/index.ts b/src/components/hooks/index.ts index d385196c..5c0d8d6b 100644 --- a/src/components/hooks/index.ts +++ b/src/components/hooks/index.ts @@ -26,6 +26,7 @@ export * from './queries/useTeamMembersQuery'; export * from './queries/useUserQuery'; export * from './queries/useUsersQuery'; export * from './queries/useWebsiteQuery'; +export * from './queries/useWebsiteSegementsQuery'; export * from './queries/useWebsitesQuery'; export * from './queries/useWebsiteEventsQuery'; export * from './queries/useWebsiteEventsSeriesQuery'; diff --git a/src/components/hooks/queries/useWebsiteSegementsQuery.ts b/src/components/hooks/queries/useWebsiteSegementsQuery.ts new file mode 100644 index 00000000..aecf1d52 --- /dev/null +++ b/src/components/hooks/queries/useWebsiteSegementsQuery.ts @@ -0,0 +1,21 @@ +import { useApi } from '../useApi'; +import { useModified } from '@/components/hooks'; +import { keepPreviousData } from '@tanstack/react-query'; +import { ReactQueryOptions } from '@/lib/types'; + +export function useWebsiteSegmentsQuery( + websiteId: string, + params?: Record, + options?: ReactQueryOptions, +) { + const { get, useQuery } = useApi(); + const { modified } = useModified(`website:${websiteId}`); + + return useQuery({ + queryKey: ['website:segments', { websiteId, modified, ...params }], + queryFn: () => get(`/websites/${websiteId}/segments`, { ...params }), + enabled: !!websiteId, + placeholderData: keepPreviousData, + ...options, + }); +} diff --git a/src/components/hooks/useFields.ts b/src/components/hooks/useFields.ts index d1f1c14f..3b78d94f 100644 --- a/src/components/hooks/useFields.ts +++ b/src/components/hooks/useFields.ts @@ -6,7 +6,6 @@ export function useFields() { const fields = [ { name: 'path', type: 'string', label: formatMessage(labels.path) }, // { name: 'cohort', type: 'string', label: formatMessage(labels.cohort) }, - // { name: 'segment', type: 'string', label: formatMessage(labels.segment) }, { name: 'title', type: 'string', label: formatMessage(labels.pageTitle) }, { name: 'referrer', type: 'string', label: formatMessage(labels.referrer) }, //{ name: 'query', type: 'string', label: formatMessage(labels.query) }, diff --git a/src/components/hooks/useNavigation.ts b/src/components/hooks/useNavigation.ts index 174f9879..4db2dfe9 100644 --- a/src/components/hooks/useNavigation.ts +++ b/src/components/hooks/useNavigation.ts @@ -11,7 +11,11 @@ export function useNavigation() { const [queryParams, setQueryParams] = useState(Object.fromEntries(searchParams)); const updateParams = (params?: Record) => { - return !params ? pathname : buildUrl(pathname, { ...queryParams, ...params }); + return buildUrl(pathname, { ...queryParams, ...params }); + }; + + const replaceParams = (params?: Record) => { + return buildUrl(pathname, params); }; useEffect(() => { @@ -33,6 +37,7 @@ export function useNavigation() { teamId, websiteId, updateParams, + replaceParams, renderUrl, }; } diff --git a/src/components/input/FieldFilters.tsx b/src/components/input/FieldFilters.tsx new file mode 100644 index 00000000..d37f8850 --- /dev/null +++ b/src/components/input/FieldFilters.tsx @@ -0,0 +1,74 @@ +import { Key } from 'react'; +import { Grid, Column, List, ListItem } from '@umami/react-zen'; +import { useDateRange, useFields, useMessages } from '@/components/hooks'; +import { FilterRecord } from '@/components/common/FilterRecord'; +import { Empty } from '@/components/common/Empty'; + +export interface FieldFiltersProps { + websiteId: string; + filters: { name: string; operator: string; value: string }[]; + onSave?: (data: any) => void; +} + +export function FieldFilters({ websiteId, filters, onSave }: FieldFiltersProps) { + const { formatMessage, messages } = useMessages(); + const { fields } = useFields(); + const { + dateRange: { startDate, endDate }, + } = useDateRange(websiteId); + + const updateFilter = (name: string, props: Record) => { + onSave(filters.map(filter => (filter.name === name ? { ...filter, ...props } : filter))); + }; + + const handleAdd = (name: Key) => { + onSave(filters.concat({ name: name.toString(), operator: 'eq', value: '' })); + }; + + const handleChange = (name: string, value: Key) => { + updateFilter(name, { value }); + }; + + const handleSelect = (name: string, operator: Key) => { + updateFilter(name, { operator }); + }; + + const handleRemove = (name: string) => { + onSave(filters.filter(filter => filter.name !== name)); + }; + + return ( + + + + {fields.map((field: any) => { + const isDisabled = !!filters.find(({ name }) => name === field.name); + return ( + + {field.label} + + ); + })} + + + + {filters.map(filter => { + return ( + + ); + })} + {!filters.length && } + + + ); +} diff --git a/src/components/input/FilterBar.tsx b/src/components/input/FilterBar.tsx index 224b4c77..51409b26 100644 --- a/src/components/input/FilterBar.tsx +++ b/src/components/input/FilterBar.tsx @@ -1,4 +1,3 @@ -import { MouseEvent } from 'react'; import { Button, Icon, Text, Row, TooltipTrigger, Tooltip } from '@umami/react-zen'; import { useNavigation, useMessages, useFormat, useFilters } from '@/components/hooks'; import { Close } from '@/components/icons'; @@ -7,57 +6,55 @@ import { isSearchOperator } from '@/lib/params'; export function FilterBar() { const { formatMessage, labels } = useMessages(); const { formatValue } = useFormat(); - const { router, updateParams } = useNavigation(); + const { + router, + updateParams, + replaceParams, + query: { segment }, + } = useNavigation(); const { filters, operatorLabels } = useFilters(); - const handleCloseFilter = (param: string, e: MouseEvent) => { - e.stopPropagation(); + const handleCloseFilter = (param: string) => { router.push(updateParams({ [param]: undefined })); }; const handleResetFilter = () => { - router.push(updateParams()); + router.push(replaceParams()); }; - if (!filters.length) { + const handleSegmentRemove = () => { + router.push(updateParams({ segment: undefined })); + }; + + if (!filters.length && !segment) { return null; } return ( - {Object.keys(filters).map(key => { - const filter = filters[key]; + {segment && ( + + )} + {filters.map(filter => { const { name, label, operator, value } = filter; const paramValue = isSearchOperator(operator) ? value : formatValue(value, name); return ( - - - - - {label} - - {operatorLabels[operator]} - - {paramValue} - - - handleCloseFilter(name, e)} size="xs"> - - - - + name={name} + label={label} + operator={operatorLabels[operator]} + value={paramValue} + onRemove={name => handleCloseFilter(name)} + /> ); })} @@ -74,3 +71,33 @@ export function FilterBar() { ); } + +const FilterItem = ({ name, label, operator, value, onRemove }) => { + return ( + + + + + {label} + + {operator} + + {value} + + + onRemove(name)} size="xs"> + + + + + ); +}; diff --git a/src/components/input/SegmentFilters.tsx b/src/components/input/SegmentFilters.tsx new file mode 100644 index 00000000..51690a01 --- /dev/null +++ b/src/components/input/SegmentFilters.tsx @@ -0,0 +1,36 @@ +import { useState } from 'react'; +import { List, Column, ListItem } from '@umami/react-zen'; +import { useWebsiteSegmentsQuery } from '@/components/hooks'; +import { LoadingPanel } from '@/components/common/LoadingPanel'; + +export interface SegmentFiltersProps { + websiteId: string; + segmentId: string; + onSave?: (data: any) => void; +} + +export function SegmentFilters({ websiteId, segmentId, onSave }: SegmentFiltersProps) { + const { data, isLoading } = useWebsiteSegmentsQuery(websiteId, { type: 'segment' }); + const [currentSegment, setCurrentSegment] = useState(segmentId); + + const handleSave = (id: string) => { + setCurrentSegment(id); + onSave?.(data.find(item => item.id === id)); + }; + + return ( + + + handleSave(id[0])}> + {data?.map(item => { + return ( + + {item.name} + + ); + })} + + + + ); +} diff --git a/src/components/input/WebsiteDateFilter.tsx b/src/components/input/WebsiteDateFilter.tsx index 4737517a..0bf74846 100644 --- a/src/components/input/WebsiteDateFilter.tsx +++ b/src/components/input/WebsiteDateFilter.tsx @@ -100,9 +100,9 @@ export function WebsiteDateFilter({ {compare ? : } {formatMessage(compare ? labels.cancel : labels.compareDates)} - )} + ); } diff --git a/src/lib/prisma.ts b/src/lib/prisma.ts index 813c844b..b8ad4984 100644 --- a/src/lib/prisma.ts +++ b/src/lib/prisma.ts @@ -2,7 +2,6 @@ import debug from 'debug'; import { PrismaClient } from '@prisma/client'; import { PrismaPg } from '@prisma/adapter-pg'; import { readReplicas } from '@prisma/extension-read-replicas'; -import { MYSQL, POSTGRESQL, getDatabaseType } from '@/lib/db'; import { SESSION_COLUMNS, OPERATORS, DEFAULT_PAGE_SIZE } from './constants'; import { QueryOptions, QueryFilters } from './types'; import { filtersToArray } from './params'; @@ -19,7 +18,7 @@ const PRISMA_LOG_OPTIONS = { ], }; -const POSTGRESQL_DATE_FORMATS = { +const DATE_FORMATS = { minute: 'YYYY-MM-DD HH24:MI:00', hour: 'YYYY-MM-DD HH24:00:00', day: 'YYYY-MM-DD HH24:00:00', @@ -28,47 +27,23 @@ const POSTGRESQL_DATE_FORMATS = { }; function getAddIntervalQuery(field: string, interval: string): string { - const db = getDatabaseType(); - - if (db === POSTGRESQL) { - return `${field} + interval '${interval}'`; - } - - if (db === MYSQL) { - return `DATE_ADD(${field}, interval ${interval})`; - } + return `${field} + interval '${interval}'`; } function getDayDiffQuery(field1: string, field2: string): string { - const db = getDatabaseType(); - - if (db === POSTGRESQL) { - return `${field1}::date - ${field2}::date`; - } - - if (db === MYSQL) { - return `DATEDIFF(${field1}, ${field2})`; - } + return `${field1}::date - ${field2}::date`; } function getCastColumnQuery(field: string, type: string): string { - const db = getDatabaseType(); - - if (db === POSTGRESQL) { - return `${field}::${type}`; - } - - if (db === MYSQL) { - return `${field}`; - } + return `${field}::${type}`; } function getDateSQL(field: string, unit: string, timezone?: string): string { if (timezone) { - return `to_char(date_trunc('${unit}', ${field} at time zone '${timezone}'), '${POSTGRESQL_DATE_FORMATS[unit]}')`; + return `to_char(date_trunc('${unit}', ${field} at time zone '${timezone}'), '${DATE_FORMATS[unit]}')`; } - return `to_char(date_trunc('${unit}', ${field}), '${POSTGRESQL_DATE_FORMATS[unit]}')`; + return `to_char(date_trunc('${unit}', ${field}), '${DATE_FORMATS[unit]}')`; } function getDateWeeklySQL(field: string, timezone?: string) { @@ -105,18 +80,15 @@ function mapFilter(column: string, operator: string, name: string, type: string } function mapCohortFilter(column: string, operator: string, value: string) { - const db = getDatabaseType(); - const like = db === POSTGRESQL ? 'ilike' : 'like'; - switch (operator) { case OPERATORS.equals: return `${column} = '${value}'`; case OPERATORS.notEquals: return `${column} != '${value}'`; case OPERATORS.contains: - return `${column} ${like} '${value}'`; + return `${column} ilike '${value}'`; case OPERATORS.doesNotContain: - return `${column} not ${like} '${value}'`; + return `${column} not ilike '${value}'`; default: return ''; } @@ -229,14 +201,8 @@ async function rawQuery(sql: string, data: object): Promise { log('QUERY:\n', sql); log('PARAMETERS:\n', data); } - - const db = getDatabaseType(); const params = []; - if (db !== POSTGRESQL && db !== MYSQL) { - return Promise.reject(new Error('Unknown database.')); - } - const query = sql?.replaceAll(/\{\{\s*(\w+)(::\w+)?\s*}}/g, (...args) => { const [, name, type] = args; @@ -244,7 +210,7 @@ async function rawQuery(sql: string, data: object): Promise { params.push(value); - return db === MYSQL ? '?' : `$${params.length}${type ?? ''}`; + return `$${params.length}${type ?? ''}`; }); return process.env.DATABASE_REPLICA_URL @@ -301,20 +267,9 @@ async function pagedRawQuery( return { data, count, page: +page, pageSize: size, orderBy }; } -function getQueryMode(): { mode?: 'default' | 'insensitive' } { - const db = getDatabaseType(); - - if (db === POSTGRESQL) { - return { mode: 'insensitive' }; - } - - return {}; -} - function getSearchParameters(query: string, filters: Record[]) { if (!query) return; - const mode = getQueryMode(); const parseFilter = (filter: Record) => { const [[key, value]] = Object.entries(filter); @@ -323,7 +278,7 @@ function getSearchParameters(query: string, filters: Record[]) { typeof value === 'string' ? { [value]: query, - ...mode, + mode: 'insensitive', } : parseFilter(value), }; @@ -404,7 +359,6 @@ export default { getSearchParameters, getTimestampDiffSQL, getSearchSQL, - getQueryMode, pagedQuery, pagedRawQuery, parseFilters, diff --git a/src/lib/storage.ts b/src/lib/storage.ts index f08a7f7a..fd19b3b6 100644 --- a/src/lib/storage.ts +++ b/src/lib/storage.ts @@ -9,7 +9,11 @@ export function getItem(key: string, session?: boolean): any { const value = (session ? sessionStorage : localStorage).getItem(key); if (value !== 'undefined' && value !== null) { - return JSON.parse(value); + try { + return JSON.parse(value); + } catch { + return null; + } } } }