New filter bar and filter edit form.

This commit is contained in:
Mike Cao 2025-04-09 21:15:12 -07:00
parent 47e89afcb4
commit bfdd3f9525
19 changed files with 300 additions and 150 deletions

View file

@ -28,7 +28,7 @@ export function LanguageSetting() {
return (
<Row gap="3">
<Select
value={locale}
selectedKey={locale}
onChange={val => saveLocale(val as string)}
allowSearch
onSearch={setSearch}

View file

@ -26,7 +26,7 @@ export function TimezoneSetting() {
<Row gap="3">
<Select
className={styles.dropdown}
value={timezone}
selectedKey={timezone}
onChange={(value: any) => saveTimezone(value)}
allowSearch={true}
onSearch={setSearch}

View file

@ -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 (
<Column>
<Row className={styles.filter}>
<Label>{label}</Label>
<Flexbox gap="3">
<Row gap="3">
{allowFilterSelect && (
<Select
className={styles.dropdown}
items={filterDropdownItems(name)}
items={items}
value={operator}
onChange={handleOperatorChange}
>
@ -183,7 +184,7 @@ export function FieldFilterEditForm({
onChange={e => setValue(e.target.value)}
/>
)}
</Flexbox>
</Row>
<Button variant="primary" onPress={handleAdd} isDisabled={isDisabled}>
{formatMessage(isNew ? labels.add : labels.update)}
</Button>

View file

@ -11,11 +11,11 @@ export function FieldSelectForm({ fields = [], onSelect, showType = true }: Fiel
const { formatMessage, labels } = useMessages();
return (
<Menu>
<MenuSection title={formatMessage(labels.fields)}>
<Menu onAction={value => onSelect?.(value)}>
<MenuSection title={formatMessage(labels.fields)} selectionMode="multiple">
{fields.map(({ name, label, type }) => {
return (
<MenuItem key={name} id={name} onAction={() => onSelect(name)}>
<MenuItem key={name} id={name}>
<Row alignItems="center" justifyContent="space-between">
<Text>{label || name}</Text>
{showType && type && <Text color="muted">{type}</Text>}

View file

@ -30,7 +30,7 @@ export function FilterSelectForm({
return (
<FieldFilterEditForm
websiteId={websiteId}
name={name}
name={name || 'url'}
label={label}
type={type}
startDate={startDate}

View file

@ -1,7 +1,7 @@
import { Button, Icon, Icons, MenuTrigger, Popover, Text } from '@umami/react-zen';
import { FilterSelectForm } from '@/app/(main)/reports/[reportId]/FilterSelectForm';
import { useFields, useMessages, useNavigation, useDateRange } from '@/components/hooks';
import { OPERATOR_PREFIXES } from '@/lib/constants';
import { Button, Icon, Icons, DialogTrigger, Dialog, Modal, Text } from '@umami/react-zen';
import { FilterEditForm } from '@/components/common/FilterEditForm';
import { useMessages, useNavigation, useFilters } from '@/components/hooks';
import { OPERATORS } from '@/lib/constants';
export function WebsiteFilterButton({
websiteId,
@ -14,41 +14,44 @@ export function WebsiteFilterButton({
}) {
const { formatMessage, labels } = useMessages();
const { renderUrl, router } = useNavigation();
const { fields } = useFields();
const {
dateRange: { startDate, endDate },
} = useDateRange(websiteId);
const { filters } = useFilters();
const handleAddFilter = ({ name, operator, value }) => {
const prefix = OPERATOR_PREFIXES[operator];
const handleChange = (filters: any[]) => {
const params = filters.reduce((obj, filter) => {
const { name, operator, value } = filter;
router.push(renderUrl({ [name]: prefix + value }));
obj[name] = operator === OPERATORS.equals ? value : `${operator}~${value}`;
return obj;
}, {});
const url = renderUrl(params);
router.push(url);
};
return (
<MenuTrigger>
<DialogTrigger>
<Button variant="quiet">
<Icon>
<Icons.Plus />
</Icon>
{showText && <Text>{formatMessage(labels.filter)}</Text>}
</Button>
<Popover placement="bottom start">
{({ close }: any) => {
return (
<FilterSelectForm
websiteId={websiteId}
fields={fields}
startDate={startDate}
endDate={endDate}
onChange={value => {
handleAddFilter(value);
close();
}}
/>
);
}}
</Popover>
</MenuTrigger>
<Modal>
<Dialog>
{({ close }) => {
return (
<FilterEditForm
websiteId={websiteId}
data={filters}
onChange={handleChange}
onClose={close}
/>
);
}}
</Dialog>
</Modal>
</DialogTrigger>
);
}

View file

@ -22,7 +22,7 @@ export function EventsPage({ websiteId }) {
<EventsMetricsBar websiteId={websiteId} />
</Panel>
<GridRow layout="two-one">
<Panel>
<Panel gridColumn="span 2">
<EventsChart websiteId={websiteId} />
</Panel>
<Panel>

View file

@ -22,7 +22,7 @@ export function SessionsPage({ websiteId }) {
<SessionsMetricsBar websiteId={websiteId} />
</Panel>
<GridRow layout="two-one">
<Panel padding="0">
<Panel padding="0" gridColumn="span 2">
<WorldMap websiteId={websiteId} />
</Panel>
<Panel>

View file

@ -1,5 +1,5 @@
import { ReactNode } from 'react';
import { Icon, Text, Flexbox } from '@umami/react-zen';
import { Icon, Text, Column } from '@umami/react-zen';
import { Icons } from '@/components/icons';
export interface EmptyPlaceholderProps {
@ -9,12 +9,12 @@ export interface EmptyPlaceholderProps {
export function EmptyPlaceholder({ message, children }: EmptyPlaceholderProps) {
return (
<Flexbox direction="column" alignItems="center" justifyContent="center" gap={60} height={600}>
<Column alignItems="center" justifyContent="center" gap="5" height="100%" width="100%">
<Icon size="xl">
<Icons.Logo />
</Icon>
<Text size="lg">{message}</Text>
<div>{children}</div>
</Flexbox>
</Column>
);
}

View file

@ -0,0 +1,86 @@
import { useState, Key } from 'react';
import { Grid, Row, Column, Label, List, ListItem, Button, Heading } from '@umami/react-zen';
import { useFilters, useMessages } from '@/components/hooks';
import { EmptyPlaceholder } from '@/components/common/EmptyPlaceholder';
import { FilterRecord } from '@/components/common/FilterRecord';
export interface FilterEditFormProps {
websiteId?: string;
data: any[];
onChange?: (filters: { name: string; type: string; operator: string; value: string }[]) => void;
onClose?: () => void;
}
export function FilterEditForm({ data = [], onChange, onClose }: FilterEditFormProps) {
const { formatMessage, labels } = useMessages();
const [filters, setFilters] = useState(data);
const { fields } = useFilters();
const updateFilter = (name: string, props: { [key: string]: any }) => {
setFilters(filters =>
filters.map(filter => (filter.name === name ? { ...filter, ...props } : filter)),
);
};
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));
onClose?.();
};
return (
<Grid columns="160px 1fr" width="760px" gapY="6">
<Row gridColumn="span 2">
<Heading>{formatMessage(labels.filters)}</Heading>
</Row>
<Column border="right" paddingRight="3">
<Label>Fields</Label>
<List onAction={handleAdd}>
{fields.map((field: any) => {
const isDisabled = filters.find(({ name }) => name === field.name);
return (
<ListItem key={field.name} id={field.name} isDisabled={isDisabled}>
{field.label}
</ListItem>
);
})}
</List>
</Column>
<Column paddingLeft="6" overflow="auto" gapY="4">
{filters.map(filter => {
return (
<FilterRecord
key={filter.name}
{...filter}
onSelect={handleSelect}
onRemove={handleRemove}
onChange={handleChange}
/>
);
})}
{!filters.length && <EmptyPlaceholder message="No filters selected." />}
</Column>
<Row alignItems="center" justifyContent="flex-end" gridColumn="span 2" gap>
<Button onPress={onClose}>{formatMessage(labels.cancel)}</Button>
<Button variant="primary" onPress={handleApply}>
{formatMessage(labels.apply)}
</Button>
</Row>
</Grid>
);
}

View file

@ -0,0 +1,64 @@
import {
Grid,
Row,
Column,
TextField,
Label,
ListItem,
Select,
Icon,
Icons,
Button,
} from '@umami/react-zen';
import { useFilters } from '@/components/hooks';
export interface FilterRecordProps {
name: string;
operator: string;
value: string;
onSelect?: (name: string, value: any) => void;
onRemove?: (name: string) => void;
onChange?: (name: string, value: string) => void;
}
export function FilterRecord({
name,
operator,
value,
onSelect,
onRemove,
onChange,
}: FilterRecordProps) {
const { fields, operators } = useFilters();
return (
<Grid columns="1fr auto">
<Column>
<Label>{fields.find(f => f.name === name)?.label}</Label>
<Row gap alignItems="center">
<Select
items={operators.filter(({ type }) => type === 'string')}
selectedKey={operator}
onSelectionChange={value => onSelect?.(name, value)}
>
{({ name, label }: any) => {
return (
<ListItem key={name} id={name}>
{label}
</ListItem>
);
}}
</Select>
<TextField value={value} onChange={e => onChange?.(name, e.target.value)} />
</Row>
</Column>
<Column justifyContent="flex-end">
<Button variant="quiet" onPress={() => onRemove?.(name)}>
<Icon>
<Icons.Close />
</Icon>
</Button>
</Column>
</Grid>
);
}

View file

@ -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 };
}

View file

@ -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) {

View file

@ -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({

View file

@ -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 (
<Row
className="dark-theme"
gap="3"
backgroundColor="3"
backgroundColor="2"
alignItems="center"
justifyContent="space-between"
paddingY="3"
@ -85,46 +39,26 @@ export function FilterBar({ websiteId }: { websiteId: string }) {
<Text color="11" weight="bold">
{formatMessage(labels.filters)}
</Text>
{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 (
<MenuTrigger key={key}>
<Button variant="outline">
<Button key={name} variant="outline">
<Row alignItems="center" gap="6">
<Row alignItems="center" gap="2">
<Text weight="bold">{label}</Text>
<Text transform="uppercase" color="muted">
<Text transform="uppercase" color="11" size="1">
{operatorLabels[operator]}
</Text>
<Text weight="bold">{paramValue}</Text>
<Icon onClick={e => handleCloseFilter(key, e)}>
<Icons.Close />
</Icon>
</Row>
</Button>
<Popover placement="start">
{({ close }: any) => {
return (
<FieldFilterEditForm
label={label}
type="string"
websiteId={websiteId}
name={key}
operator={operator}
defaultValue={value}
startDate={startDate}
endDate={endDate}
onChange={values => handleChangeFilter(values, close)}
/>
);
}}
</Popover>
</MenuTrigger>
<Icon onClick={e => handleCloseFilter(name, e)}>
<Icons.Close />
</Icon>
</Row>
</Button>
);
})}
<WebsiteFilterButton websiteId={websiteId} alignment="center" showText={false} />

View file

@ -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 = [

View file

@ -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 = [