mirror of
https://github.com/umami-software/umami.git
synced 2026-02-10 23:57:12 +01:00
Added segment filtering to filter form.
This commit is contained in:
parent
2e69e57445
commit
2ad624ccc8
15 changed files with 301 additions and 193 deletions
|
|
@ -13,19 +13,26 @@ export function WebsiteFilterButton({
|
||||||
showText?: boolean;
|
showText?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { formatMessage, labels } = useMessages();
|
const { formatMessage, labels } = useMessages();
|
||||||
const { updateParams, router } = useNavigation();
|
const {
|
||||||
|
replaceParams,
|
||||||
|
router,
|
||||||
|
query: { segment },
|
||||||
|
} = useNavigation();
|
||||||
const { filters } = useFilters();
|
const { filters } = useFilters();
|
||||||
|
|
||||||
const handleChange = (filters: any[]) => {
|
const handleChange = ({ filters, segment }) => {
|
||||||
const params = filters.reduce((obj, filter) => {
|
const params = filters.reduce(
|
||||||
const { name, operator, value } = filter;
|
(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);
|
router.push(url);
|
||||||
};
|
};
|
||||||
|
|
@ -39,12 +46,13 @@ export function WebsiteFilterButton({
|
||||||
{showText && <Text>{formatMessage(labels.filter)}</Text>}
|
{showText && <Text>{formatMessage(labels.filter)}</Text>}
|
||||||
</Button>
|
</Button>
|
||||||
<Modal>
|
<Modal>
|
||||||
<Dialog>
|
<Dialog title={formatMessage(labels.filters)} style={{ width: 800, minHeight: 600 }}>
|
||||||
{({ close }) => {
|
{({ close }) => {
|
||||||
return (
|
return (
|
||||||
<FilterEditForm
|
<FilterEditForm
|
||||||
websiteId={websiteId}
|
websiteId={websiteId}
|
||||||
data={filters}
|
filters={filters}
|
||||||
|
segmentId={segment}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
onClose={close}
|
onClose={close}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ const KEY_NAME = 'umami.events.tab';
|
||||||
|
|
||||||
export function EventsPage({ websiteId }) {
|
export function EventsPage({ websiteId }) {
|
||||||
const [label, setLabel] = useState(null);
|
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 { formatMessage, labels } = useMessages();
|
||||||
|
|
||||||
const handleLabelClick = (value: string) => {
|
const handleLabelClick = (value: string) => {
|
||||||
|
|
@ -32,8 +32,8 @@ export function EventsPage({ websiteId }) {
|
||||||
<Panel>
|
<Panel>
|
||||||
<Tabs selectedKey={tab} onSelectionChange={key => handleSelect(key)}>
|
<Tabs selectedKey={tab} onSelectionChange={key => handleSelect(key)}>
|
||||||
<TabList>
|
<TabList>
|
||||||
<Tab id="activity">{formatMessage(labels.activity)}</Tab>
|
|
||||||
<Tab id="chart">{formatMessage(labels.chart)}</Tab>
|
<Tab id="chart">{formatMessage(labels.chart)}</Tab>
|
||||||
|
<Tab id="activity">{formatMessage(labels.activity)}</Tab>
|
||||||
<Tab id="properties">{formatMessage(labels.properties)}</Tab>
|
<Tab id="properties">{formatMessage(labels.properties)}</Tab>
|
||||||
</TabList>
|
</TabList>
|
||||||
<TabPanel id="activity">
|
<TabPanel id="activity">
|
||||||
|
|
@ -41,7 +41,9 @@ export function EventsPage({ websiteId }) {
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
<TabPanel id="chart">
|
<TabPanel id="chart">
|
||||||
<Column gap="6">
|
<Column gap="6">
|
||||||
<EventsChart websiteId={websiteId} focusLabel={label} />
|
<Column border="bottom" paddingBottom="6">
|
||||||
|
<EventsChart websiteId={websiteId} focusLabel={label} />
|
||||||
|
</Column>
|
||||||
<EventsTable
|
<EventsTable
|
||||||
websiteId={websiteId}
|
websiteId={websiteId}
|
||||||
type="event"
|
type="event"
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@ export function EventsTable({ data = [] }) {
|
||||||
</TypeIcon>
|
</TypeIcon>
|
||||||
)}
|
)}
|
||||||
</DataColumn>
|
</DataColumn>
|
||||||
<DataColumn id="browser" label={formatMessage(labels.browser)} width="120px">
|
<DataColumn id="browser" label={formatMessage(labels.browser)} width="140px">
|
||||||
{(row: any) => (
|
{(row: any) => (
|
||||||
<TypeIcon type="browser" value={row.browser}>
|
<TypeIcon type="browser" value={row.browser}>
|
||||||
{formatValue(row.browser, 'browser')}
|
{formatValue(row.browser, 'browser')}
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,29 @@
|
||||||
'use client';
|
'use client';
|
||||||
import { useState } from 'react';
|
import { Key, useState } from 'react';
|
||||||
import { TabList, Tab, Tabs, TabPanel, Column } from '@umami/react-zen';
|
import { TabList, Tab, Tabs, TabPanel, Column } from '@umami/react-zen';
|
||||||
import { SessionsDataTable } from './SessionsDataTable';
|
import { SessionsDataTable } from './SessionsDataTable';
|
||||||
import { SessionProperties } from './SessionProperties';
|
import { SessionProperties } from './SessionProperties';
|
||||||
import { useMessages } from '@/components/hooks';
|
import { useMessages } from '@/components/hooks';
|
||||||
import { Panel } from '@/components/common/Panel';
|
import { Panel } from '@/components/common/Panel';
|
||||||
import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
|
import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
|
||||||
|
import { getItem, setItem } from '@/lib/storage';
|
||||||
|
|
||||||
|
const KEY_NAME = 'umami.sessions.tab';
|
||||||
|
|
||||||
export function SessionsPage({ websiteId }) {
|
export function SessionsPage({ websiteId }) {
|
||||||
const [tab, setTab] = useState('activity');
|
const [tab, setTab] = useState(getItem(KEY_NAME) || 'activity');
|
||||||
const { formatMessage, labels } = useMessages();
|
const { formatMessage, labels } = useMessages();
|
||||||
|
|
||||||
|
const handleSelect = (value: Key) => {
|
||||||
|
setItem(KEY_NAME, value);
|
||||||
|
setTab(value);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Column gap="3">
|
<Column gap="3">
|
||||||
<WebsiteControls websiteId={websiteId} />
|
<WebsiteControls websiteId={websiteId} />
|
||||||
<Panel>
|
<Panel>
|
||||||
<Tabs selectedKey={tab} onSelectionChange={(value: any) => setTab(value)}>
|
<Tabs selectedKey={tab} onSelectionChange={handleSelect}>
|
||||||
<TabList>
|
<TabList>
|
||||||
<Tab id="activity">{formatMessage(labels.activity)}</Tab>
|
<Tab id="activity">{formatMessage(labels.activity)}</Tab>
|
||||||
<Tab id="properties">{formatMessage(labels.properties)}</Tab>
|
<Tab id="properties">{formatMessage(labels.properties)}</Tab>
|
||||||
|
|
|
||||||
|
|
@ -1,99 +1,68 @@
|
||||||
import { useState, Key } from 'react';
|
import { useState } from 'react';
|
||||||
import { Grid, Row, Column, Label, List, ListItem, Button, Heading } from '@umami/react-zen';
|
import { Column, Tabs, TabList, Tab, TabPanel, Row, Button } from '@umami/react-zen';
|
||||||
import { useDateRange, useFilters, useMessages } from '@/components/hooks';
|
import { useMessages } from '@/components/hooks';
|
||||||
import { FilterRecord } from '@/components/common/FilterRecord';
|
import { FieldFilters } from '@/components/input/FieldFilters';
|
||||||
import { Empty } from '@/components/common/Empty';
|
import { SegmentFilters } from '@/components/input/SegmentFilters';
|
||||||
|
|
||||||
export interface FilterEditFormProps {
|
export interface FilterEditFormProps {
|
||||||
websiteId?: string;
|
websiteId?: string;
|
||||||
data: any[];
|
filters: any[];
|
||||||
onChange?: (filters: { name: string; type: string; operator: string; value: string }[]) => void;
|
segmentId?: string;
|
||||||
|
onChange?: (params: { filters: any[]; segment: any }) => void;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FilterEditForm({ websiteId, data = [], onChange, onClose }: FilterEditFormProps) {
|
export function FilterEditForm({
|
||||||
const { formatMessage, labels, messages } = useMessages();
|
websiteId,
|
||||||
const [filters, setFilters] = useState(data);
|
filters = [],
|
||||||
const { fields } = useFilters();
|
segmentId,
|
||||||
const {
|
onChange,
|
||||||
dateRange: { startDate, endDate },
|
onClose,
|
||||||
} = useDateRange(websiteId);
|
}: FilterEditFormProps) {
|
||||||
|
const { formatMessage, labels } = useMessages();
|
||||||
|
const [currentFilters, setCurrentFilters] = useState(filters);
|
||||||
|
const [currentSegment, setCurrentSegment] = useState(null);
|
||||||
|
|
||||||
const updateFilter = (name: string, props: Record<string, any>) => {
|
const handleReset = () => {
|
||||||
setFilters(filters =>
|
setCurrentFilters([]);
|
||||||
filters.map(filter => (filter.name === name ? { ...filter, ...props } : filter)),
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAdd = (name: Key) => {
|
const handleSave = () => {
|
||||||
setFilters(filters.concat({ name, operator: 'eq', value: '' }));
|
onChange?.({ filters: currentFilters.filter(f => f.value), segment: currentSegment });
|
||||||
};
|
|
||||||
|
|
||||||
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?.();
|
onClose?.();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSegmentChange = (segment?: { id: string }) => {
|
||||||
|
setCurrentSegment(segment);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Grid columns="160px 1fr" width="760px" overflow="hidden" gapY="6">
|
<Column>
|
||||||
<Row gridColumn="span 2">
|
<Tabs>
|
||||||
<Heading>{formatMessage(labels.filters)}</Heading>
|
<TabList>
|
||||||
|
<Tab id="fields">{formatMessage(labels.fields)}</Tab>
|
||||||
|
<Tab id="segments">{formatMessage(labels.segments)}</Tab>
|
||||||
|
</TabList>
|
||||||
|
<TabPanel id="fields">
|
||||||
|
<FieldFilters websiteId={websiteId} filters={currentFilters} onSave={setCurrentFilters} />
|
||||||
|
</TabPanel>
|
||||||
|
<TabPanel id="segments">
|
||||||
|
<SegmentFilters
|
||||||
|
websiteId={websiteId}
|
||||||
|
segmentId={segmentId}
|
||||||
|
onSave={handleSegmentChange}
|
||||||
|
/>
|
||||||
|
</TabPanel>
|
||||||
|
</Tabs>
|
||||||
|
<Row alignItems="center" justifyContent="space-between" gridColumn="span 2" gap>
|
||||||
|
<Button onPress={handleReset}>{formatMessage(labels.reset)}</Button>
|
||||||
|
<Row alignItems="center" justifyContent="flex-end" gridColumn="span 2" gap>
|
||||||
|
<Button onPress={onClose}>{formatMessage(labels.cancel)}</Button>
|
||||||
|
<Button variant="primary" onPress={handleSave}>
|
||||||
|
{formatMessage(labels.apply)}
|
||||||
|
</Button>
|
||||||
|
</Row>
|
||||||
</Row>
|
</Row>
|
||||||
<Column border="right" paddingRight="3">
|
</Column>
|
||||||
<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"
|
|
||||||
maxHeight="600px"
|
|
||||||
style={{ contain: 'layout' }}
|
|
||||||
>
|
|
||||||
{filters.map(filter => {
|
|
||||||
return (
|
|
||||||
<FilterRecord
|
|
||||||
key={filter.name}
|
|
||||||
websiteId={websiteId}
|
|
||||||
type={filter.name}
|
|
||||||
startDate={startDate}
|
|
||||||
endDate={endDate}
|
|
||||||
{...filter}
|
|
||||||
onSelect={handleSelect}
|
|
||||||
onRemove={handleRemove}
|
|
||||||
onChange={handleChange}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{!filters.length && <Empty message={formatMessage(messages.nothingSelected)} />}
|
|
||||||
</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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ export * from './queries/useTeamMembersQuery';
|
||||||
export * from './queries/useUserQuery';
|
export * from './queries/useUserQuery';
|
||||||
export * from './queries/useUsersQuery';
|
export * from './queries/useUsersQuery';
|
||||||
export * from './queries/useWebsiteQuery';
|
export * from './queries/useWebsiteQuery';
|
||||||
|
export * from './queries/useWebsiteSegementsQuery';
|
||||||
export * from './queries/useWebsitesQuery';
|
export * from './queries/useWebsitesQuery';
|
||||||
export * from './queries/useWebsiteEventsQuery';
|
export * from './queries/useWebsiteEventsQuery';
|
||||||
export * from './queries/useWebsiteEventsSeriesQuery';
|
export * from './queries/useWebsiteEventsSeriesQuery';
|
||||||
|
|
|
||||||
21
src/components/hooks/queries/useWebsiteSegementsQuery.ts
Normal file
21
src/components/hooks/queries/useWebsiteSegementsQuery.ts
Normal file
|
|
@ -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<string, string>,
|
||||||
|
options?: ReactQueryOptions<any>,
|
||||||
|
) {
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -6,7 +6,6 @@ export function useFields() {
|
||||||
const fields = [
|
const fields = [
|
||||||
{ name: 'path', type: 'string', label: formatMessage(labels.path) },
|
{ name: 'path', type: 'string', label: formatMessage(labels.path) },
|
||||||
// { name: 'cohort', type: 'string', label: formatMessage(labels.cohort) },
|
// { 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: 'title', type: 'string', label: formatMessage(labels.pageTitle) },
|
||||||
{ name: 'referrer', type: 'string', label: formatMessage(labels.referrer) },
|
{ name: 'referrer', type: 'string', label: formatMessage(labels.referrer) },
|
||||||
//{ name: 'query', type: 'string', label: formatMessage(labels.query) },
|
//{ name: 'query', type: 'string', label: formatMessage(labels.query) },
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,11 @@ export function useNavigation() {
|
||||||
const [queryParams, setQueryParams] = useState(Object.fromEntries(searchParams));
|
const [queryParams, setQueryParams] = useState(Object.fromEntries(searchParams));
|
||||||
|
|
||||||
const updateParams = (params?: Record<string, string | number>) => {
|
const updateParams = (params?: Record<string, string | number>) => {
|
||||||
return !params ? pathname : buildUrl(pathname, { ...queryParams, ...params });
|
return buildUrl(pathname, { ...queryParams, ...params });
|
||||||
|
};
|
||||||
|
|
||||||
|
const replaceParams = (params?: Record<string, string | number>) => {
|
||||||
|
return buildUrl(pathname, params);
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -33,6 +37,7 @@ export function useNavigation() {
|
||||||
teamId,
|
teamId,
|
||||||
websiteId,
|
websiteId,
|
||||||
updateParams,
|
updateParams,
|
||||||
|
replaceParams,
|
||||||
renderUrl,
|
renderUrl,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
74
src/components/input/FieldFilters.tsx
Normal file
74
src/components/input/FieldFilters.tsx
Normal file
|
|
@ -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<string, any>) => {
|
||||||
|
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 (
|
||||||
|
<Grid columns="160px 1fr" overflow="hidden" gapY="6">
|
||||||
|
<Column border="right" paddingRight="3">
|
||||||
|
<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" height="500px" style={{ contain: 'layout' }}>
|
||||||
|
{filters.map(filter => {
|
||||||
|
return (
|
||||||
|
<FilterRecord
|
||||||
|
key={filter.name}
|
||||||
|
websiteId={websiteId}
|
||||||
|
type={filter.name}
|
||||||
|
startDate={startDate}
|
||||||
|
endDate={endDate}
|
||||||
|
{...filter}
|
||||||
|
onSelect={handleSelect}
|
||||||
|
onRemove={handleRemove}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{!filters.length && <Empty message={formatMessage(messages.nothingSelected)} />}
|
||||||
|
</Column>
|
||||||
|
</Grid>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import { MouseEvent } from 'react';
|
|
||||||
import { Button, Icon, Text, Row, TooltipTrigger, Tooltip } from '@umami/react-zen';
|
import { Button, Icon, Text, Row, TooltipTrigger, Tooltip } from '@umami/react-zen';
|
||||||
import { useNavigation, useMessages, useFormat, useFilters } from '@/components/hooks';
|
import { useNavigation, useMessages, useFormat, useFilters } from '@/components/hooks';
|
||||||
import { Close } from '@/components/icons';
|
import { Close } from '@/components/icons';
|
||||||
|
|
@ -7,57 +6,55 @@ import { isSearchOperator } from '@/lib/params';
|
||||||
export function FilterBar() {
|
export function FilterBar() {
|
||||||
const { formatMessage, labels } = useMessages();
|
const { formatMessage, labels } = useMessages();
|
||||||
const { formatValue } = useFormat();
|
const { formatValue } = useFormat();
|
||||||
const { router, updateParams } = useNavigation();
|
const {
|
||||||
|
router,
|
||||||
|
updateParams,
|
||||||
|
replaceParams,
|
||||||
|
query: { segment },
|
||||||
|
} = useNavigation();
|
||||||
const { filters, operatorLabels } = useFilters();
|
const { filters, operatorLabels } = useFilters();
|
||||||
|
|
||||||
const handleCloseFilter = (param: string, e: MouseEvent) => {
|
const handleCloseFilter = (param: string) => {
|
||||||
e.stopPropagation();
|
|
||||||
router.push(updateParams({ [param]: undefined }));
|
router.push(updateParams({ [param]: undefined }));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleResetFilter = () => {
|
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 null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Row gap alignItems="center" justifyContent="space-between" padding="2" backgroundColor="3">
|
<Row gap alignItems="center" justifyContent="space-between" padding="2" backgroundColor="3">
|
||||||
<Row alignItems="center" gap="2" wrap="wrap">
|
<Row alignItems="center" gap="2" wrap="wrap">
|
||||||
{Object.keys(filters).map(key => {
|
{segment && (
|
||||||
const filter = filters[key];
|
<FilterItem
|
||||||
|
name="segment"
|
||||||
|
label={formatMessage(labels.segment)}
|
||||||
|
value={segment}
|
||||||
|
operator={operatorLabels.eq}
|
||||||
|
onRemove={handleSegmentRemove}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{filters.map(filter => {
|
||||||
const { name, label, operator, value } = filter;
|
const { name, label, operator, value } = filter;
|
||||||
const paramValue = isSearchOperator(operator) ? value : formatValue(value, name);
|
const paramValue = isSearchOperator(operator) ? value : formatValue(value, name);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Row
|
<FilterItem
|
||||||
key={name}
|
key={name}
|
||||||
border
|
name={name}
|
||||||
padding="2"
|
label={label}
|
||||||
color
|
operator={operatorLabels[operator]}
|
||||||
backgroundColor
|
value={paramValue}
|
||||||
borderRadius
|
onRemove={name => handleCloseFilter(name)}
|
||||||
alignItems="center"
|
/>
|
||||||
justifyContent="space-between"
|
|
||||||
theme="dark"
|
|
||||||
>
|
|
||||||
<Row alignItems="center" gap="4">
|
|
||||||
<Row alignItems="center" gap="2">
|
|
||||||
<Text color="12" weight="bold">
|
|
||||||
{label}
|
|
||||||
</Text>
|
|
||||||
<Text color="11">{operatorLabels[operator]}</Text>
|
|
||||||
<Text color="12" weight="bold">
|
|
||||||
{paramValue}
|
|
||||||
</Text>
|
|
||||||
</Row>
|
|
||||||
<Icon onClick={e => handleCloseFilter(name, e)} size="xs">
|
|
||||||
<Close />
|
|
||||||
</Icon>
|
|
||||||
</Row>
|
|
||||||
</Row>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</Row>
|
</Row>
|
||||||
|
|
@ -74,3 +71,33 @@ export function FilterBar() {
|
||||||
</Row>
|
</Row>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const FilterItem = ({ name, label, operator, value, onRemove }) => {
|
||||||
|
return (
|
||||||
|
<Row
|
||||||
|
border
|
||||||
|
padding="2"
|
||||||
|
color
|
||||||
|
backgroundColor
|
||||||
|
borderRadius
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="space-between"
|
||||||
|
theme="dark"
|
||||||
|
>
|
||||||
|
<Row alignItems="center" gap="4">
|
||||||
|
<Row alignItems="center" gap="2">
|
||||||
|
<Text color="12" weight="bold">
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<Text color="11">{operator}</Text>
|
||||||
|
<Text color="12" weight="bold">
|
||||||
|
{value}
|
||||||
|
</Text>
|
||||||
|
</Row>
|
||||||
|
<Icon onClick={() => onRemove(name)} size="xs">
|
||||||
|
<Close />
|
||||||
|
</Icon>
|
||||||
|
</Row>
|
||||||
|
</Row>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
|
||||||
36
src/components/input/SegmentFilters.tsx
Normal file
36
src/components/input/SegmentFilters.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<Column height="400px" gap>
|
||||||
|
<LoadingPanel data={data} isLoading={isLoading} overflowY="auto">
|
||||||
|
<List selectionMode="single" value={[currentSegment]} onChange={id => handleSave(id[0])}>
|
||||||
|
{data?.map(item => {
|
||||||
|
return (
|
||||||
|
<ListItem key={item.id} id={item.id}>
|
||||||
|
{item.name}
|
||||||
|
</ListItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</List>
|
||||||
|
</LoadingPanel>
|
||||||
|
</Column>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -100,9 +100,9 @@ export function WebsiteDateFilter({
|
||||||
<Icon fillColor>{compare ? <Close /> : <Compare />}</Icon>
|
<Icon fillColor>{compare ? <Close /> : <Compare />}</Icon>
|
||||||
</Button>
|
</Button>
|
||||||
<Tooltip>{formatMessage(compare ? labels.cancel : labels.compareDates)}</Tooltip>
|
<Tooltip>{formatMessage(compare ? labels.cancel : labels.compareDates)}</Tooltip>
|
||||||
<ExportButton websiteId={websiteId} />
|
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
)}
|
)}
|
||||||
|
<ExportButton websiteId={websiteId} />
|
||||||
</Row>
|
</Row>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ import debug from 'debug';
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
import { PrismaPg } from '@prisma/adapter-pg';
|
import { PrismaPg } from '@prisma/adapter-pg';
|
||||||
import { readReplicas } from '@prisma/extension-read-replicas';
|
import { readReplicas } from '@prisma/extension-read-replicas';
|
||||||
import { MYSQL, POSTGRESQL, getDatabaseType } from '@/lib/db';
|
|
||||||
import { SESSION_COLUMNS, OPERATORS, DEFAULT_PAGE_SIZE } from './constants';
|
import { SESSION_COLUMNS, OPERATORS, DEFAULT_PAGE_SIZE } from './constants';
|
||||||
import { QueryOptions, QueryFilters } from './types';
|
import { QueryOptions, QueryFilters } from './types';
|
||||||
import { filtersToArray } from './params';
|
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',
|
minute: 'YYYY-MM-DD HH24:MI:00',
|
||||||
hour: 'YYYY-MM-DD HH24:00:00',
|
hour: 'YYYY-MM-DD HH24:00:00',
|
||||||
day: '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 {
|
function getAddIntervalQuery(field: string, interval: string): string {
|
||||||
const db = getDatabaseType();
|
return `${field} + interval '${interval}'`;
|
||||||
|
|
||||||
if (db === POSTGRESQL) {
|
|
||||||
return `${field} + interval '${interval}'`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (db === MYSQL) {
|
|
||||||
return `DATE_ADD(${field}, interval ${interval})`;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getDayDiffQuery(field1: string, field2: string): string {
|
function getDayDiffQuery(field1: string, field2: string): string {
|
||||||
const db = getDatabaseType();
|
return `${field1}::date - ${field2}::date`;
|
||||||
|
|
||||||
if (db === POSTGRESQL) {
|
|
||||||
return `${field1}::date - ${field2}::date`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (db === MYSQL) {
|
|
||||||
return `DATEDIFF(${field1}, ${field2})`;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCastColumnQuery(field: string, type: string): string {
|
function getCastColumnQuery(field: string, type: string): string {
|
||||||
const db = getDatabaseType();
|
return `${field}::${type}`;
|
||||||
|
|
||||||
if (db === POSTGRESQL) {
|
|
||||||
return `${field}::${type}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (db === MYSQL) {
|
|
||||||
return `${field}`;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getDateSQL(field: string, unit: string, timezone?: string): string {
|
function getDateSQL(field: string, unit: string, timezone?: string): string {
|
||||||
if (timezone) {
|
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) {
|
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) {
|
function mapCohortFilter(column: string, operator: string, value: string) {
|
||||||
const db = getDatabaseType();
|
|
||||||
const like = db === POSTGRESQL ? 'ilike' : 'like';
|
|
||||||
|
|
||||||
switch (operator) {
|
switch (operator) {
|
||||||
case OPERATORS.equals:
|
case OPERATORS.equals:
|
||||||
return `${column} = '${value}'`;
|
return `${column} = '${value}'`;
|
||||||
case OPERATORS.notEquals:
|
case OPERATORS.notEquals:
|
||||||
return `${column} != '${value}'`;
|
return `${column} != '${value}'`;
|
||||||
case OPERATORS.contains:
|
case OPERATORS.contains:
|
||||||
return `${column} ${like} '${value}'`;
|
return `${column} ilike '${value}'`;
|
||||||
case OPERATORS.doesNotContain:
|
case OPERATORS.doesNotContain:
|
||||||
return `${column} not ${like} '${value}'`;
|
return `${column} not ilike '${value}'`;
|
||||||
default:
|
default:
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
@ -229,14 +201,8 @@ async function rawQuery(sql: string, data: object): Promise<any> {
|
||||||
log('QUERY:\n', sql);
|
log('QUERY:\n', sql);
|
||||||
log('PARAMETERS:\n', data);
|
log('PARAMETERS:\n', data);
|
||||||
}
|
}
|
||||||
|
|
||||||
const db = getDatabaseType();
|
|
||||||
const params = [];
|
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 query = sql?.replaceAll(/\{\{\s*(\w+)(::\w+)?\s*}}/g, (...args) => {
|
||||||
const [, name, type] = args;
|
const [, name, type] = args;
|
||||||
|
|
||||||
|
|
@ -244,7 +210,7 @@ async function rawQuery(sql: string, data: object): Promise<any> {
|
||||||
|
|
||||||
params.push(value);
|
params.push(value);
|
||||||
|
|
||||||
return db === MYSQL ? '?' : `$${params.length}${type ?? ''}`;
|
return `$${params.length}${type ?? ''}`;
|
||||||
});
|
});
|
||||||
|
|
||||||
return process.env.DATABASE_REPLICA_URL
|
return process.env.DATABASE_REPLICA_URL
|
||||||
|
|
@ -301,20 +267,9 @@ async function pagedRawQuery(
|
||||||
return { data, count, page: +page, pageSize: size, orderBy };
|
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<string, any>[]) {
|
function getSearchParameters(query: string, filters: Record<string, any>[]) {
|
||||||
if (!query) return;
|
if (!query) return;
|
||||||
|
|
||||||
const mode = getQueryMode();
|
|
||||||
const parseFilter = (filter: Record<string, any>) => {
|
const parseFilter = (filter: Record<string, any>) => {
|
||||||
const [[key, value]] = Object.entries(filter);
|
const [[key, value]] = Object.entries(filter);
|
||||||
|
|
||||||
|
|
@ -323,7 +278,7 @@ function getSearchParameters(query: string, filters: Record<string, any>[]) {
|
||||||
typeof value === 'string'
|
typeof value === 'string'
|
||||||
? {
|
? {
|
||||||
[value]: query,
|
[value]: query,
|
||||||
...mode,
|
mode: 'insensitive',
|
||||||
}
|
}
|
||||||
: parseFilter(value),
|
: parseFilter(value),
|
||||||
};
|
};
|
||||||
|
|
@ -404,7 +359,6 @@ export default {
|
||||||
getSearchParameters,
|
getSearchParameters,
|
||||||
getTimestampDiffSQL,
|
getTimestampDiffSQL,
|
||||||
getSearchSQL,
|
getSearchSQL,
|
||||||
getQueryMode,
|
|
||||||
pagedQuery,
|
pagedQuery,
|
||||||
pagedRawQuery,
|
pagedRawQuery,
|
||||||
parseFilters,
|
parseFilters,
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,11 @@ export function getItem(key: string, session?: boolean): any {
|
||||||
const value = (session ? sessionStorage : localStorage).getItem(key);
|
const value = (session ? sessionStorage : localStorage).getItem(key);
|
||||||
|
|
||||||
if (value !== 'undefined' && value !== null) {
|
if (value !== 'undefined' && value !== null) {
|
||||||
return JSON.parse(value);
|
try {
|
||||||
|
return JSON.parse(value);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue