From bfdd3f952504589638cbc78840e6bf3fbf65f69f Mon Sep 17 00:00:00 2001 From: Mike Cao Date: Wed, 9 Apr 2025 21:15:12 -0700 Subject: [PATCH] New filter bar and filter edit form. --- package.json | 2 +- pnpm-lock.yaml | 10 +- src/app/(main)/profile/LanguageSetting.tsx | 2 +- src/app/(main)/profile/TimezoneSetting.tsx | 2 +- .../[reportId]/FieldFilterEditForm.tsx | 9 +- .../reports/[reportId]/FieldSelectForm.tsx | 6 +- .../reports/[reportId]/FilterSelectForm.tsx | 2 +- .../[websiteId]/WebsiteFilterButton.tsx | 61 +++++----- .../[websiteId]/events/EventsPage.tsx | 2 +- .../[websiteId]/sessions/SessionsPage.tsx | 2 +- src/components/common/EmptyPlaceholder.tsx | 6 +- src/components/common/FilterEditForm.tsx | 86 ++++++++++++++ src/components/common/FilterRecord.tsx | 64 +++++++++++ src/components/hooks/useFilters.ts | 79 +++++++++++-- src/components/hooks/useNavigation.ts | 4 +- src/components/messages.ts | 1 + src/components/metrics/FilterBar.tsx | 108 ++++-------------- src/components/metrics/PagesTable.tsx | 2 +- src/components/metrics/ReferrersTable.tsx | 2 +- 19 files changed, 300 insertions(+), 150 deletions(-) create mode 100644 src/components/common/FilterEditForm.tsx create mode 100644 src/components/common/FilterRecord.tsx diff --git a/package.json b/package.json index 9cc9afd9..f5344f47 100644 --- a/package.json +++ b/package.json @@ -78,7 +78,7 @@ "@react-spring/web": "^9.7.5", "@tanstack/react-query": "^5.71.10", "@umami/prisma-client": "^0.16.0", - "@umami/react-zen": "^0.79.0", + "@umami/react-zen": "^0.81.0", "@umami/redis-client": "^0.27.0", "bcryptjs": "^2.4.3", "chalk": "^4.1.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f681d270..e82fa7ac 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -45,8 +45,8 @@ importers: specifier: ^0.16.0 version: 0.16.0(@prisma/client@6.5.0(prisma@6.5.0(typescript@5.8.3))(typescript@5.8.3))(@prisma/extension-read-replicas@0.4.1(@prisma/client@6.5.0(prisma@6.5.0(typescript@5.8.3))(typescript@5.8.3))) '@umami/react-zen': - specifier: ^0.79.0 - version: 0.79.0(@babel/core@7.26.9)(@types/react@19.1.0)(immer@9.0.21)(use-sync-external-store@1.5.0(react@19.1.0)) + specifier: ^0.81.0 + version: 0.81.0(@babel/core@7.26.9)(@types/react@19.1.0)(immer@9.0.21)(use-sync-external-store@1.5.0(react@19.1.0)) '@umami/redis-client': specifier: ^0.27.0 version: 0.27.0 @@ -2946,8 +2946,8 @@ packages: '@prisma/client': ^4.8.0 '@prisma/extension-read-replicas': ^0.3.0 - '@umami/react-zen@0.79.0': - resolution: {integrity: sha512-qumZSV/dWvtq7iR7QwxEO5emE/jzgI5uPP5Y1E9S+MMGnJRhD5gQ1TvURf+jYAlTuiPubLysu6U3nKYmPNJxUg==} + '@umami/react-zen@0.81.0': + resolution: {integrity: sha512-DmwttqG+rhllcyqdCusxZ0MLVPSjIv4BXPsz7M1CED8I6wrz2MzG7euZgkJY1GMLtLPscisTooCqSG/RzwhhjQ==} '@umami/redis-client@0.27.0': resolution: {integrity: sha512-SbHTpxhgeZyTBUSp2zdZM+XUtpsaSL4Tad8QXIEhEtjWhvvfoornyT5kLuyYCVtzSAT4daALeGmOO1z6EE1KcA==} @@ -10572,7 +10572,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@umami/react-zen@0.79.0(@babel/core@7.26.9)(@types/react@19.1.0)(immer@9.0.21)(use-sync-external-store@1.5.0(react@19.1.0))': + '@umami/react-zen@0.81.0(@babel/core@7.26.9)(@types/react@19.1.0)(immer@9.0.21)(use-sync-external-store@1.5.0(react@19.1.0))': dependencies: '@fontsource/jetbrains-mono': 5.2.5 '@react-aria/focus': 3.20.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0) diff --git a/src/app/(main)/profile/LanguageSetting.tsx b/src/app/(main)/profile/LanguageSetting.tsx index 020a846b..e71b88cb 100644 --- a/src/app/(main)/profile/LanguageSetting.tsx +++ b/src/app/(main)/profile/LanguageSetting.tsx @@ -28,7 +28,7 @@ export function LanguageSetting() { return ( saveTimezone(value)} allowSearch={true} onSearch={setSearch} diff --git a/src/app/(main)/reports/[reportId]/FieldFilterEditForm.tsx b/src/app/(main)/reports/[reportId]/FieldFilterEditForm.tsx index 9964ec6c..752629d5 100644 --- a/src/app/(main)/reports/[reportId]/FieldFilterEditForm.tsx +++ b/src/app/(main)/reports/[reportId]/FieldFilterEditForm.tsx @@ -7,7 +7,6 @@ import { Column, Row, Select, - Flexbox, Icon, Icons, Loading, @@ -130,15 +129,17 @@ export function FieldFilterEditForm({ window.setTimeout(() => setShowMenu(false), 500); }; + const items = filterDropdownItems(name); + return ( - + {allowFilterSelect && ( type === 'string')} + selectedKey={operator} + onSelectionChange={value => onSelect?.(name, value)} + > + {({ name, label }: any) => { + return ( + + {label} + + ); + }} + + onChange?.(name, e.target.value)} /> + + + + + + + ); +} diff --git a/src/components/hooks/useFilters.ts b/src/components/hooks/useFilters.ts index 94c45885..5986ba67 100644 --- a/src/components/hooks/useFilters.ts +++ b/src/components/hooks/useFilters.ts @@ -1,8 +1,46 @@ import { useMessages } from './useMessages'; -import { OPERATORS } from '@/lib/constants'; +import { useNavigation } from '@/components/hooks/useNavigation'; +import { FILTER_COLUMNS, OPERATORS } from '@/lib/constants'; +import { safeDecodeURIComponent } from '@/lib/url'; export function useFilters() { const { formatMessage, labels } = useMessages(); + const { query } = useNavigation(); + + const fields = [ + { name: 'url', type: 'string', label: formatMessage(labels.url) }, + { name: 'title', type: 'string', label: formatMessage(labels.pageTitle) }, + { name: 'referrer', type: 'string', label: formatMessage(labels.referrer) }, + { name: 'query', type: 'string', label: formatMessage(labels.query) }, + { 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: 'host', type: 'string', label: formatMessage(labels.host) }, + { name: 'tag', type: 'string', label: formatMessage(labels.tag) }, + ]; + + const operators = [ + { name: 'eq', type: 'string', label: 'Is' }, + { name: 'neq', type: 'string', label: 'Is not' }, + { name: 'c', type: 'string', label: 'Contains' }, + { name: 'dnc', type: 'string', label: 'Does not contain' }, + { name: 'c', type: 'array', label: 'Contains' }, + { name: 'dnc', type: 'array', label: 'Does not contain' }, + { name: 't', type: 'boolean', label: 'True' }, + { name: 'f', type: 'boolean', label: 'False' }, + { name: 'eq', type: 'number', label: 'Is' }, + { name: 'neq', type: 'number', label: 'Is not' }, + { name: 'gt', type: 'number', label: 'Greater than' }, + { name: 'lt', type: 'number', label: 'Less than' }, + { name: 'gte', type: 'number', label: 'Greater than or equals' }, + { name: 'lte', type: 'number', label: 'Less than or equals' }, + { name: 'bf', type: 'date', label: 'Before' }, + { name: 'af', type: 'date', label: 'After' }, + { name: 'eq', type: 'uuid', label: 'Is' }, + ]; const operatorLabels = { [OPERATORS.equals]: formatMessage(labels.is), @@ -37,15 +75,38 @@ export function useFilters() { uuid: [OPERATORS.equals], }; - const filters = Object.keys(typeFilters).flatMap(key => { - return ( - typeFilters[key]?.map(value => ({ type: key, value, label: operatorLabels[value] })) ?? [] - ); - }); + const filters = Object.keys(query).reduce((arr, key) => { + if (FILTER_COLUMNS[key]) { + let operator = 'eq'; + let value = safeDecodeURIComponent(query[key]); + const label = fields.find(({ name }) => name === key)?.label; - const getFilters = type => { - return typeFilters[type]?.map(key => ({ type, value: key, label: operatorLabels[key] })) ?? []; + const match = value.match(/^([a-z]+)~(.*)/); + + if (match) { + operator = match[1]; + value = match[2]; + } + + return arr.concat({ + name: key, + operator, + value, + label, + }); + } + return arr; + }, []); + + const getFilters = (type: string) => { + return ( + typeFilters[type]?.map((key: string | number) => ({ + type, + value: key, + label: operatorLabels[key], + })) ?? [] + ); }; - return { filters, operatorLabels, typeFilters, getFilters }; + return { fields, operators, filters, operatorLabels, typeFilters, getFilters }; } diff --git a/src/components/hooks/useNavigation.ts b/src/components/hooks/useNavigation.ts index 2cc65dab..194d7154 100644 --- a/src/components/hooks/useNavigation.ts +++ b/src/components/hooks/useNavigation.ts @@ -18,8 +18,8 @@ export function useNavigation() { return obj; }, [params]); - function renderUrl(params: any, reset?: boolean) { - return reset ? pathname : buildUrl(pathname, { ...query, ...params }); + function renderUrl(params: any) { + return !params ? pathname : buildUrl(pathname, { ...query, ...params }); } function renderTeamUrl(url: string) { diff --git a/src/components/messages.ts b/src/components/messages.ts index 3d877bdd..e049d67d 100644 --- a/src/components/messages.ts +++ b/src/components/messages.ts @@ -299,6 +299,7 @@ export const labels = defineMessages({ grouped: { id: 'label.grouped', defaultMessage: 'Grouped' }, other: { id: 'label.other', defaultMessage: 'Other' }, boards: { id: 'label.boards', defaultMessage: 'Boards' }, + apply: { id: 'label.apply', defaultMessage: 'Apply' }, }); export const messages = defineMessages({ diff --git a/src/components/metrics/FilterBar.tsx b/src/components/metrics/FilterBar.tsx index 72c62608..7f0367bb 100644 --- a/src/components/metrics/FilterBar.tsx +++ b/src/components/metrics/FilterBar.tsx @@ -1,52 +1,14 @@ import { MouseEvent } from 'react'; -import { - Button, - Icon, - Icons, - Popover, - MenuTrigger, - Text, - Row, - TooltipTrigger, - Tooltip, -} from '@umami/react-zen'; -import { - useDateRange, - useFields, - useNavigation, - useMessages, - useFormat, - useFilters, -} from '@/components/hooks'; -import { FieldFilterEditForm } from '@/app/(main)/reports/[reportId]/FieldFilterEditForm'; -import { FILTER_COLUMNS, OPERATOR_PREFIXES } from '@/lib/constants'; -import { isSearchOperator, parseParameterValue } from '@/lib/params'; +import { Button, Icon, Icons, Text, Row, TooltipTrigger, Tooltip } from '@umami/react-zen'; +import { useNavigation, useMessages, useFormat, useFilters } from '@/components/hooks'; +import { isSearchOperator } from '@/lib/params'; import { WebsiteFilterButton } from '@/app/(main)/websites/[websiteId]/WebsiteFilterButton'; export function FilterBar({ websiteId }: { websiteId: string }) { const { formatMessage, labels } = useMessages(); const { formatValue } = useFormat(); - const { dateRange } = useDateRange(websiteId); - const { - router, - renderUrl, - query: { view }, - } = useNavigation(); - const { fields } = useFields(); - const { operatorLabels } = useFilters(); - const { startDate, endDate } = dateRange; - const { query } = useNavigation(); - - const params = Object.keys(query).reduce((obj, key) => { - if (FILTER_COLUMNS[key]) { - obj[key] = query[key]; - } - return obj; - }, {}); - - if (Object.keys(params).filter(key => params[key]).length === 0) { - return null; - } + const { router, renderUrl } = useNavigation(); + const { filters, operatorLabels } = useFilters(); const handleCloseFilter = (param: string, e: MouseEvent) => { e.stopPropagation(); @@ -54,25 +16,17 @@ export function FilterBar({ websiteId }: { websiteId: string }) { }; const handleResetFilter = () => { - router.push(renderUrl({ view }, true)); + router.push(renderUrl(false)); }; - const handleChangeFilter = ( - values: { name: string; operator: string; value: string }, - close: () => void, - ) => { - const { name, operator, value } = values; - const prefix = OPERATOR_PREFIXES[operator]; - - router.push(renderUrl({ [name]: prefix + value })); - close(); - }; + if (!filters.length) { + return null; + } return ( {formatMessage(labels.filters)} - {Object.keys(params).map(key => { - if (!params[key]) { - return null; - } - const label = fields.find(f => f.name === key)?.label; - const { operator, value } = parseParameterValue(params[key]); + {Object.keys(filters).map(key => { + const filter = filters[key]; + const { name, label, operator, value } = filter; const paramValue = isSearchOperator(operator) ? value : formatValue(value, key); return ( - - - - {({ close }: any) => { - return ( - handleChangeFilter(values, close)} - /> - ); - }} - - + handleCloseFilter(name, e)}> + + + + ); })} diff --git a/src/components/metrics/PagesTable.tsx b/src/components/metrics/PagesTable.tsx index ee522e0b..ebd7bddb 100644 --- a/src/components/metrics/PagesTable.tsx +++ b/src/components/metrics/PagesTable.tsx @@ -20,7 +20,7 @@ export function PagesTable({ allowFilter, ...props }: PagesTableProps) { const { domain } = useContext(WebsiteContext); const handleSelect = (key: any) => { - router.push(renderUrl({ view: key }), { scroll: false }); + router.push(renderUrl({ view: key })); }; const buttons = [ diff --git a/src/components/metrics/ReferrersTable.tsx b/src/components/metrics/ReferrersTable.tsx index c7642ee2..2109228e 100644 --- a/src/components/metrics/ReferrersTable.tsx +++ b/src/components/metrics/ReferrersTable.tsx @@ -20,7 +20,7 @@ export function ReferrersTable({ allowFilter, ...props }: ReferrersTableProps) { const { formatMessage, labels } = useMessages(); const handleSelect = (key: any) => { - router.push(renderUrl({ view: key }), { scroll: false }); + router.push(renderUrl({ view: key })); }; const buttons = [