mirror of
https://github.com/umami-software/umami.git
synced 2026-02-04 04:37:11 +01:00
Segment editing.
This commit is contained in:
parent
fba7e12c36
commit
2dbcf63eeb
22 changed files with 306 additions and 42 deletions
|
|
@ -3,6 +3,7 @@ import { WebsiteFilterButton } from '@/app/(main)/websites/[websiteId]/WebsiteFi
|
||||||
import { WebsiteDateFilter } from '@/components/input/WebsiteDateFilter';
|
import { WebsiteDateFilter } from '@/components/input/WebsiteDateFilter';
|
||||||
import { FilterBar } from '@/components/input/FilterBar';
|
import { FilterBar } from '@/components/input/FilterBar';
|
||||||
import { WebsiteMonthSelect } from '@/components/input/WebsiteMonthSelect';
|
import { WebsiteMonthSelect } from '@/components/input/WebsiteMonthSelect';
|
||||||
|
import { ExportButton } from '@/components/input/ExportButton';
|
||||||
|
|
||||||
export function WebsiteControls({
|
export function WebsiteControls({
|
||||||
websiteId,
|
websiteId,
|
||||||
|
|
@ -10,21 +11,24 @@ export function WebsiteControls({
|
||||||
allowDateFilter = true,
|
allowDateFilter = true,
|
||||||
allowMonthFilter,
|
allowMonthFilter,
|
||||||
allowCompare,
|
allowCompare,
|
||||||
|
allowDownload = false,
|
||||||
}: {
|
}: {
|
||||||
websiteId: string;
|
websiteId: string;
|
||||||
allowFilter?: boolean;
|
allowFilter?: boolean;
|
||||||
allowCompare?: boolean;
|
allowCompare?: boolean;
|
||||||
allowDateFilter?: boolean;
|
allowDateFilter?: boolean;
|
||||||
allowMonthFilter?: boolean;
|
allowMonthFilter?: boolean;
|
||||||
|
allowDownload?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Column gap>
|
<Column gap>
|
||||||
<Row alignItems="center" justifyContent="space-between" gap="3">
|
<Row alignItems="center" justifyContent="space-between" gap="3">
|
||||||
{allowFilter ? <WebsiteFilterButton websiteId={websiteId} /> : <div />}
|
{allowFilter ? <WebsiteFilterButton websiteId={websiteId} /> : <div />}
|
||||||
{allowDateFilter && <WebsiteDateFilter websiteId={websiteId} allowCompare={allowCompare} />}
|
{allowDateFilter && <WebsiteDateFilter websiteId={websiteId} allowCompare={allowCompare} />}
|
||||||
|
{allowDownload && <ExportButton websiteId={websiteId} />}
|
||||||
{allowMonthFilter && <WebsiteMonthSelect websiteId={websiteId} />}
|
{allowMonthFilter && <WebsiteMonthSelect websiteId={websiteId} />}
|
||||||
</Row>
|
</Row>
|
||||||
{allowFilter && <FilterBar />}
|
{allowFilter && <FilterBar websiteId={websiteId} />}
|
||||||
</Column>
|
</Column>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { Button, Icon, DialogTrigger, Dialog, Modal, Text } from '@umami/react-z
|
||||||
import { ListFilter } from '@/components/icons';
|
import { ListFilter } from '@/components/icons';
|
||||||
import { FilterEditForm } from '@/components/common/FilterEditForm';
|
import { FilterEditForm } from '@/components/common/FilterEditForm';
|
||||||
import { useMessages, useNavigation, useFilters } from '@/components/hooks';
|
import { useMessages, useNavigation, useFilters } from '@/components/hooks';
|
||||||
|
import { filtersArrayToObject } from '@/lib/params';
|
||||||
|
|
||||||
export function WebsiteFilterButton({
|
export function WebsiteFilterButton({
|
||||||
websiteId,
|
websiteId,
|
||||||
|
|
@ -21,16 +22,7 @@ export function WebsiteFilterButton({
|
||||||
const { filters } = useFilters();
|
const { filters } = useFilters();
|
||||||
|
|
||||||
const handleChange = ({ filters, segment }) => {
|
const handleChange = ({ filters, segment }) => {
|
||||||
const params = filters.reduce(
|
const params = filtersArrayToObject(filters);
|
||||||
(obj: { [x: string]: string }, filter: { name: any; operator: any; value: any }) => {
|
|
||||||
const { name, operator, value } = filter;
|
|
||||||
|
|
||||||
obj[name] = `${operator}.${value}`;
|
|
||||||
|
|
||||||
return obj;
|
|
||||||
},
|
|
||||||
{},
|
|
||||||
);
|
|
||||||
|
|
||||||
const url = replaceParams({ ...params, segment });
|
const url = replaceParams({ ...params, segment });
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
import { Button, DialogTrigger, Modal, Text, Icon, Dialog } from '@umami/react-zen';
|
||||||
|
import { useMessages } from '@/components/hooks';
|
||||||
|
import { Plus } from '@/components/icons';
|
||||||
|
import { SegmentEditForm } from '@/app/(main)/websites/[websiteId]/segments/SegmentEditForm';
|
||||||
|
|
||||||
|
export function SegmentAddButton({ websiteId }: { websiteId: string }) {
|
||||||
|
const { formatMessage, labels } = useMessages();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DialogTrigger>
|
||||||
|
<Button variant="primary">
|
||||||
|
<Icon>
|
||||||
|
<Plus />
|
||||||
|
</Icon>
|
||||||
|
<Text>{formatMessage(labels.segment)}</Text>
|
||||||
|
</Button>
|
||||||
|
<Modal>
|
||||||
|
<Dialog title={formatMessage(labels.segment)} style={{ width: 800 }}>
|
||||||
|
{({ close }) => {
|
||||||
|
return <SegmentEditForm websiteId={websiteId} onClose={close} />;
|
||||||
|
}}
|
||||||
|
</Dialog>
|
||||||
|
</Modal>
|
||||||
|
</DialogTrigger>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,79 @@
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Form,
|
||||||
|
FormButtons,
|
||||||
|
FormField,
|
||||||
|
FormSubmitButton,
|
||||||
|
TextField,
|
||||||
|
Label,
|
||||||
|
} from '@umami/react-zen';
|
||||||
|
import { subMonths, endOfDay } from 'date-fns';
|
||||||
|
import { FieldFilters } from '@/components/input/FieldFilters';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useApi, useMessages, useModified } from '@/components/hooks';
|
||||||
|
import { filtersArrayToObject } from '@/lib/params';
|
||||||
|
|
||||||
|
export function SegmentEditForm({
|
||||||
|
websiteId,
|
||||||
|
filters = [],
|
||||||
|
onSave,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
websiteId: string;
|
||||||
|
filters?: any[];
|
||||||
|
onSave?: () => void;
|
||||||
|
onClose?: () => void;
|
||||||
|
}) {
|
||||||
|
const { formatMessage, labels } = useMessages();
|
||||||
|
const [currentFilters, setCurrentFilters] = useState(filters);
|
||||||
|
const { touch } = useModified();
|
||||||
|
const startDate = subMonths(endOfDay(new Date()), 6);
|
||||||
|
const endDate = endOfDay(new Date());
|
||||||
|
|
||||||
|
const { post, useMutation } = useApi();
|
||||||
|
const { mutate, error, isPending } = useMutation({
|
||||||
|
mutationFn: (data: any) =>
|
||||||
|
post(`/websites/${websiteId}/segments`, { ...data, type: 'segment' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = async (data: any) => {
|
||||||
|
mutate(
|
||||||
|
{ ...data, parameters: filtersArrayToObject(currentFilters) },
|
||||||
|
{
|
||||||
|
onSuccess: async () => {
|
||||||
|
touch('segments');
|
||||||
|
onSave?.();
|
||||||
|
onClose?.();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form error={error} onSubmit={handleSubmit}>
|
||||||
|
<FormField
|
||||||
|
name="name"
|
||||||
|
label={formatMessage(labels.name)}
|
||||||
|
rules={{ required: formatMessage(labels.required) }}
|
||||||
|
>
|
||||||
|
<TextField />
|
||||||
|
</FormField>
|
||||||
|
<Label>{formatMessage(labels.filters)}</Label>
|
||||||
|
<FieldFilters
|
||||||
|
websiteId={websiteId}
|
||||||
|
filters={currentFilters}
|
||||||
|
startDate={startDate}
|
||||||
|
endDate={endDate}
|
||||||
|
onSave={setCurrentFilters}
|
||||||
|
/>
|
||||||
|
<FormButtons>
|
||||||
|
<Button isDisabled={isPending} onPress={onClose}>
|
||||||
|
{formatMessage(labels.cancel)}
|
||||||
|
</Button>
|
||||||
|
<FormSubmitButton variant="primary" data-test="button-submit" isDisabled={isPending}>
|
||||||
|
{formatMessage(labels.save)}
|
||||||
|
</FormSubmitButton>
|
||||||
|
</FormButtons>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
import { SegmentAddButton } from './SegmentAddButton';
|
||||||
|
import { useWebsiteSegmentsQuery } from '@/components/hooks';
|
||||||
|
import { SegmentsTable } from './SegmentsTable';
|
||||||
|
import { DataGrid } from '@/components/common/DataGrid';
|
||||||
|
|
||||||
|
export function SegmentsDataTable({ websiteId }: { websiteId?: string }) {
|
||||||
|
const query = useWebsiteSegmentsQuery(websiteId, { type: 'segment' });
|
||||||
|
|
||||||
|
const renderActions = () => {
|
||||||
|
return <SegmentAddButton websiteId={websiteId} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DataGrid
|
||||||
|
query={query}
|
||||||
|
allowSearch={true}
|
||||||
|
autoFocus={false}
|
||||||
|
allowPaging={true}
|
||||||
|
renderActions={renderActions}
|
||||||
|
>
|
||||||
|
{({ data }) => <SegmentsTable data={data} />}
|
||||||
|
</DataGrid>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
'use client';
|
||||||
|
import { Column } from '@umami/react-zen';
|
||||||
|
import { SegmentsDataTable } from './SegmentsDataTable';
|
||||||
|
import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
|
||||||
|
import { Panel } from '@/components/common/Panel';
|
||||||
|
|
||||||
|
export function SegmentsPage({ websiteId }) {
|
||||||
|
return (
|
||||||
|
<Column gap="3">
|
||||||
|
<WebsiteControls websiteId={websiteId} allowFilter={false} allowDateFilter={false} />
|
||||||
|
<Panel>
|
||||||
|
<SegmentsDataTable websiteId={websiteId} />
|
||||||
|
</Panel>
|
||||||
|
</Column>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
import { DataTable, DataColumn, Icon, Row, Text, MenuItem } from '@umami/react-zen';
|
||||||
|
import { useMessages } from '@/components/hooks';
|
||||||
|
import { Empty } from '@/components/common/Empty';
|
||||||
|
import { Edit, Trash } from '@/components/icons';
|
||||||
|
import { DateDistance } from '@/components/common/DateDistance';
|
||||||
|
import { MenuButton } from '@/components/input/MenuButton';
|
||||||
|
|
||||||
|
export function SegmentsTable({ data = [] }) {
|
||||||
|
const { formatMessage, labels } = useMessages();
|
||||||
|
|
||||||
|
if (data.length === 0) {
|
||||||
|
return <Empty />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DataTable data={data}>
|
||||||
|
<DataColumn id="name" label={formatMessage(labels.name)} />
|
||||||
|
<DataColumn id="created" label={formatMessage(labels.created)}>
|
||||||
|
{(row: any) => <DateDistance date={new Date(row.createdAt)} />}
|
||||||
|
</DataColumn>
|
||||||
|
<DataColumn id="action" align="end" width="100px">
|
||||||
|
{(row: any) => {
|
||||||
|
const { id } = row;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MenuButton>
|
||||||
|
<MenuItem href={`/admin/users/${id}`} data-test="link-button-edit">
|
||||||
|
<Row alignItems="center" gap>
|
||||||
|
<Icon>
|
||||||
|
<Edit />
|
||||||
|
</Icon>
|
||||||
|
<Text>{formatMessage(labels.edit)}</Text>
|
||||||
|
</Row>
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem id="delete" onAction={null} data-test="link-button-delete">
|
||||||
|
<Row alignItems="center" gap>
|
||||||
|
<Icon>
|
||||||
|
<Trash />
|
||||||
|
</Icon>
|
||||||
|
<Text>{formatMessage(labels.delete)}</Text>
|
||||||
|
</Row>
|
||||||
|
</MenuItem>
|
||||||
|
</MenuButton>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</DataColumn>
|
||||||
|
</DataTable>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
src/app/(main)/websites/[websiteId]/segments/page.tsx
Normal file
12
src/app/(main)/websites/[websiteId]/segments/page.tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { Metadata } from 'next';
|
||||||
|
import { SegmentsPage } from './SegmentsPage';
|
||||||
|
|
||||||
|
export default async function ({ params }: { params: Promise<{ websiteId: string }> }) {
|
||||||
|
const { websiteId } = await params;
|
||||||
|
|
||||||
|
return <SegmentsPage websiteId={websiteId} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Segments',
|
||||||
|
};
|
||||||
|
|
@ -2,7 +2,7 @@ import { canUpdateWebsite, canViewWebsite } from '@/lib/auth';
|
||||||
import { uuid } from '@/lib/crypto';
|
import { uuid } from '@/lib/crypto';
|
||||||
import { parseRequest } from '@/lib/request';
|
import { parseRequest } from '@/lib/request';
|
||||||
import { json, unauthorized } from '@/lib/response';
|
import { json, unauthorized } from '@/lib/response';
|
||||||
import { segmentTypeParam } from '@/lib/schema';
|
import { segmentTypeParam, searchParams } from '@/lib/schema';
|
||||||
import { createSegment, getWebsiteSegments } from '@/queries';
|
import { createSegment, getWebsiteSegments } from '@/queries';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
|
@ -12,6 +12,7 @@ export async function GET(
|
||||||
) {
|
) {
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
type: segmentTypeParam,
|
type: segmentTypeParam,
|
||||||
|
...searchParams,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { auth, query, error } = await parseRequest(request, schema);
|
const { auth, query, error } = await parseRequest(request, schema);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Column, Tabs, TabList, Tab, TabPanel, Row, Button } from '@umami/react-zen';
|
import { Column, Tabs, TabList, Tab, TabPanel, Row, Button } from '@umami/react-zen';
|
||||||
import { useMessages } from '@/components/hooks';
|
import { useDateRange, useMessages } from '@/components/hooks';
|
||||||
import { FieldFilters } from '@/components/input/FieldFilters';
|
import { FieldFilters } from '@/components/input/FieldFilters';
|
||||||
import { SegmentFilters } from '@/components/input/SegmentFilters';
|
import { SegmentFilters } from '@/components/input/SegmentFilters';
|
||||||
|
|
||||||
|
|
@ -23,6 +23,10 @@ export function FilterEditForm({
|
||||||
const [currentFilters, setCurrentFilters] = useState(filters);
|
const [currentFilters, setCurrentFilters] = useState(filters);
|
||||||
const [currentSegment, setCurrentSegment] = useState(segmentId);
|
const [currentSegment, setCurrentSegment] = useState(segmentId);
|
||||||
|
|
||||||
|
const {
|
||||||
|
dateRange: { startDate, endDate },
|
||||||
|
} = useDateRange(websiteId);
|
||||||
|
|
||||||
const handleReset = () => {
|
const handleReset = () => {
|
||||||
setCurrentFilters([]);
|
setCurrentFilters([]);
|
||||||
setCurrentSegment(null);
|
setCurrentSegment(null);
|
||||||
|
|
@ -45,7 +49,13 @@ export function FilterEditForm({
|
||||||
<Tab id="segments">{formatMessage(labels.segments)}</Tab>
|
<Tab id="segments">{formatMessage(labels.segments)}</Tab>
|
||||||
</TabList>
|
</TabList>
|
||||||
<TabPanel id="fields">
|
<TabPanel id="fields">
|
||||||
<FieldFilters websiteId={websiteId} filters={currentFilters} onSave={setCurrentFilters} />
|
<FieldFilters
|
||||||
|
websiteId={websiteId}
|
||||||
|
filters={currentFilters}
|
||||||
|
startDate={startDate}
|
||||||
|
endDate={endDate}
|
||||||
|
onSave={setCurrentFilters}
|
||||||
|
/>
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
<TabPanel id="segments">
|
<TabPanel id="segments">
|
||||||
<SegmentFilters
|
<SegmentFilters
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,8 @@ 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/useWebsiteSegmentQuery';
|
||||||
|
export * from './queries/useWebsiteSegmentsQuery';
|
||||||
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/useWebsiteSegmentQuery.ts
Normal file
21
src/components/hooks/queries/useWebsiteSegmentQuery.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 useWebsiteSegmentQuery(
|
||||||
|
websiteId: string,
|
||||||
|
segmentId: string,
|
||||||
|
options?: ReactQueryOptions<any>,
|
||||||
|
) {
|
||||||
|
const { get, useQuery } = useApi();
|
||||||
|
const { modified } = useModified(`segments`);
|
||||||
|
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['website:segments', { websiteId, segmentId, modified }],
|
||||||
|
queryFn: () => get(`/websites/${websiteId}/segments/${segmentId}`),
|
||||||
|
enabled: !!(websiteId && segmentId),
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@ import { useApi } from '../useApi';
|
||||||
import { useModified } from '@/components/hooks';
|
import { useModified } from '@/components/hooks';
|
||||||
import { keepPreviousData } from '@tanstack/react-query';
|
import { keepPreviousData } from '@tanstack/react-query';
|
||||||
import { ReactQueryOptions } from '@/lib/types';
|
import { ReactQueryOptions } from '@/lib/types';
|
||||||
|
import { useFilterParameters } from '@/components/hooks/useFilterParameters';
|
||||||
|
|
||||||
export function useWebsiteSegmentsQuery(
|
export function useWebsiteSegmentsQuery(
|
||||||
websiteId: string,
|
websiteId: string,
|
||||||
|
|
@ -9,11 +10,12 @@ export function useWebsiteSegmentsQuery(
|
||||||
options?: ReactQueryOptions<any>,
|
options?: ReactQueryOptions<any>,
|
||||||
) {
|
) {
|
||||||
const { get, useQuery } = useApi();
|
const { get, useQuery } = useApi();
|
||||||
const { modified } = useModified(`website:${websiteId}`);
|
const { modified } = useModified(`segments`);
|
||||||
|
const filters = useFilterParameters();
|
||||||
|
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['website:segments', { websiteId, modified, ...params }],
|
queryKey: ['website:segments', { websiteId, modified, ...filters, ...params }],
|
||||||
queryFn: () => get(`/websites/${websiteId}/segments`, { ...params }),
|
queryFn: () => get(`/websites/${websiteId}/segments`, { ...filters, ...params }),
|
||||||
enabled: !!websiteId,
|
enabled: !!websiteId,
|
||||||
placeholderData: keepPreviousData,
|
placeholderData: keepPreviousData,
|
||||||
...options,
|
...options,
|
||||||
|
|
@ -1,21 +1,26 @@
|
||||||
import { Key } from 'react';
|
import { Key } from 'react';
|
||||||
import { Grid, Column, List, ListItem } from '@umami/react-zen';
|
import { Grid, Column, List, ListItem } from '@umami/react-zen';
|
||||||
import { useDateRange, useFields, useMessages } from '@/components/hooks';
|
import { useFields, useMessages } from '@/components/hooks';
|
||||||
import { FilterRecord } from '@/components/common/FilterRecord';
|
import { FilterRecord } from '@/components/common/FilterRecord';
|
||||||
import { Empty } from '@/components/common/Empty';
|
import { Empty } from '@/components/common/Empty';
|
||||||
|
|
||||||
export interface FieldFiltersProps {
|
export interface FieldFiltersProps {
|
||||||
websiteId: string;
|
websiteId: string;
|
||||||
filters: { name: string; operator: string; value: string }[];
|
filters: { name: string; operator: string; value: string }[];
|
||||||
|
startDate: Date;
|
||||||
|
endDate: Date;
|
||||||
onSave?: (data: any) => void;
|
onSave?: (data: any) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FieldFilters({ websiteId, filters, onSave }: FieldFiltersProps) {
|
export function FieldFilters({
|
||||||
|
websiteId,
|
||||||
|
filters,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
onSave,
|
||||||
|
}: FieldFiltersProps) {
|
||||||
const { formatMessage, messages } = useMessages();
|
const { formatMessage, messages } = useMessages();
|
||||||
const { fields } = useFields();
|
const { fields } = useFields();
|
||||||
const {
|
|
||||||
dateRange: { startDate, endDate },
|
|
||||||
} = useDateRange(websiteId);
|
|
||||||
|
|
||||||
const updateFilter = (name: string, props: Record<string, any>) => {
|
const updateFilter = (name: string, props: Record<string, any>) => {
|
||||||
onSave(filters.map(filter => (filter.name === name ? { ...filter, ...props } : filter)));
|
onSave(filters.map(filter => (filter.name === name ? { ...filter, ...props } : filter)));
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,15 @@
|
||||||
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,
|
||||||
|
useWebsiteSegmentQuery,
|
||||||
|
} from '@/components/hooks';
|
||||||
import { Close } from '@/components/icons';
|
import { Close } from '@/components/icons';
|
||||||
import { isSearchOperator } from '@/lib/params';
|
import { isSearchOperator } from '@/lib/params';
|
||||||
|
|
||||||
export function FilterBar() {
|
export function FilterBar({ websiteId }: { websiteId: string }) {
|
||||||
const { formatMessage, labels } = useMessages();
|
const { formatMessage, labels } = useMessages();
|
||||||
const { formatValue } = useFormat();
|
const { formatValue } = useFormat();
|
||||||
const {
|
const {
|
||||||
|
|
@ -13,6 +19,7 @@ export function FilterBar() {
|
||||||
query: { segment },
|
query: { segment },
|
||||||
} = useNavigation();
|
} = useNavigation();
|
||||||
const { filters, operatorLabels } = useFilters();
|
const { filters, operatorLabels } = useFilters();
|
||||||
|
const { data, isLoading } = useWebsiteSegmentQuery(websiteId, segment);
|
||||||
|
|
||||||
const handleCloseFilter = (param: string) => {
|
const handleCloseFilter = (param: string) => {
|
||||||
router.push(updateParams({ [param]: undefined }));
|
router.push(updateParams({ [param]: undefined }));
|
||||||
|
|
@ -33,11 +40,11 @@ export function FilterBar() {
|
||||||
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">
|
||||||
{segment && (
|
{segment && !isLoading && (
|
||||||
<FilterItem
|
<FilterItem
|
||||||
name="segment"
|
name="segment"
|
||||||
label={formatMessage(labels.segment)}
|
label={formatMessage(labels.segment)}
|
||||||
value={segment}
|
value={data?.name || segment}
|
||||||
operator={operatorLabels.eq}
|
operator={operatorLabels.eq}
|
||||||
onRemove={handleSegmentRemove}
|
onRemove={handleSegmentRemove}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ export function SegmentFilters({ websiteId, segmentId, onSave }: SegmentFiltersP
|
||||||
<Column height="400px" gap>
|
<Column height="400px" gap>
|
||||||
<LoadingPanel data={data} isLoading={isLoading} overflowY="auto">
|
<LoadingPanel data={data} isLoading={isLoading} overflowY="auto">
|
||||||
<List selectionMode="single" value={[segmentId]} onChange={id => handleChange(id[0])}>
|
<List selectionMode="single" value={[segmentId]} onChange={id => handleChange(id[0])}>
|
||||||
{data?.map(item => {
|
{data?.data?.map(item => {
|
||||||
return (
|
return (
|
||||||
<ListItem key={item.id} id={item.id}>
|
<ListItem key={item.id} id={item.id}>
|
||||||
{item.name}
|
{item.name}
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,6 @@ import { isAfter } from 'date-fns';
|
||||||
import { Chevron, Close, Compare } from '@/components/icons';
|
import { Chevron, Close, Compare } from '@/components/icons';
|
||||||
import { useDateRange, useMessages, useNavigation } from '@/components/hooks';
|
import { useDateRange, useMessages, useNavigation } from '@/components/hooks';
|
||||||
import { DateFilter } from './DateFilter';
|
import { DateFilter } from './DateFilter';
|
||||||
import { ExportButton } from '@/components/input/ExportButton';
|
|
||||||
|
|
||||||
export function WebsiteDateFilter({
|
export function WebsiteDateFilter({
|
||||||
websiteId,
|
websiteId,
|
||||||
|
|
@ -102,7 +101,6 @@ export function WebsiteDateFilter({
|
||||||
<Tooltip>{formatMessage(compare ? labels.cancel : labels.compareDates)}</Tooltip>
|
<Tooltip>{formatMessage(compare ? labels.cancel : labels.compareDates)}</Tooltip>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
)}
|
)}
|
||||||
<ExportButton websiteId={websiteId} />
|
|
||||||
</Row>
|
</Row>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { formatInTimeZone } from 'date-fns-tz';
|
||||||
import debug from 'debug';
|
import debug from 'debug';
|
||||||
import { CLICKHOUSE } from '@/lib/db';
|
import { CLICKHOUSE } from '@/lib/db';
|
||||||
import { DEFAULT_PAGE_SIZE, OPERATORS } from './constants';
|
import { DEFAULT_PAGE_SIZE, OPERATORS } from './constants';
|
||||||
import { filtersToArray } from './params';
|
import { filtersObjectToArray } from './params';
|
||||||
import { QueryFilters, QueryOptions } from './types';
|
import { QueryFilters, QueryOptions } from './types';
|
||||||
|
|
||||||
export const CLICKHOUSE_DATE_FORMATS = {
|
export const CLICKHOUSE_DATE_FORMATS = {
|
||||||
|
|
@ -88,7 +88,7 @@ function mapFilter(column: string, operator: string, name: string, type: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function getFilterQuery(filters: Record<string, any>, options: QueryOptions = {}) {
|
function getFilterQuery(filters: Record<string, any>, options: QueryOptions = {}) {
|
||||||
const query = filtersToArray(filters, options).reduce((arr, { name, column, operator }) => {
|
const query = filtersObjectToArray(filters, options).reduce((arr, { name, column, operator }) => {
|
||||||
if (column) {
|
if (column) {
|
||||||
arr.push(`and ${mapFilter(column, operator, name)}`);
|
arr.push(`and ${mapFilter(column, operator, name)}`);
|
||||||
|
|
||||||
|
|
@ -144,7 +144,7 @@ function getDateQuery(filters: Record<string, any>) {
|
||||||
function getQueryParams(filters: Record<string, any>) {
|
function getQueryParams(filters: Record<string, any>) {
|
||||||
return {
|
return {
|
||||||
...filters,
|
...filters,
|
||||||
...filtersToArray(filters).reduce((obj, { name, value }) => {
|
...filtersObjectToArray(filters).reduce((obj, { name, value }) => {
|
||||||
if (name && value !== undefined) {
|
if (name && value !== undefined) {
|
||||||
obj[name] = value;
|
obj[name] = value;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { FILTER_COLUMNS, OPERATORS } from '@/lib/constants';
|
import { FILTER_COLUMNS, OPERATORS } from '@/lib/constants';
|
||||||
import { QueryFilters, QueryOptions } from '@/lib/types';
|
import { Filter, QueryFilters, QueryOptions } from '@/lib/types';
|
||||||
|
|
||||||
export function parseParameterValue(param: any) {
|
export function parseFilterValue(param: any) {
|
||||||
if (typeof param === 'string') {
|
if (typeof param === 'string') {
|
||||||
const [, operator, value] = param.match(/^([a-z]+)\.(.*)/) || [];
|
const [, operator, value] = param.match(/^([a-z]+)\.(.*)/) || [];
|
||||||
|
|
||||||
|
|
@ -18,7 +18,7 @@ export function isSearchOperator(operator: any) {
|
||||||
return [OPERATORS.contains, OPERATORS.doesNotContain].includes(operator);
|
return [OPERATORS.contains, OPERATORS.doesNotContain].includes(operator);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function filtersToArray(filters: QueryFilters, options: QueryOptions = {}) {
|
export function filtersObjectToArray(filters: QueryFilters, options: QueryOptions = {}) {
|
||||||
return Object.keys(filters).reduce((arr, key) => {
|
return Object.keys(filters).reduce((arr, key) => {
|
||||||
const filter = filters[key];
|
const filter = filters[key];
|
||||||
|
|
||||||
|
|
@ -30,7 +30,7 @@ export function filtersToArray(filters: QueryFilters, options: QueryOptions = {}
|
||||||
return arr.concat({ ...filter, column: options?.columns?.[key] ?? FILTER_COLUMNS[key] });
|
return arr.concat({ ...filter, column: options?.columns?.[key] ?? FILTER_COLUMNS[key] });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { operator, value } = parseParameterValue(filter);
|
const { operator, value } = parseFilterValue(filter);
|
||||||
|
|
||||||
return arr.concat({
|
return arr.concat({
|
||||||
name: key,
|
name: key,
|
||||||
|
|
@ -41,3 +41,13 @@ export function filtersToArray(filters: QueryFilters, options: QueryOptions = {}
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function filtersArrayToObject(filters: Filter[]) {
|
||||||
|
return filters.reduce((obj, filter: Filter) => {
|
||||||
|
const { name, operator, value } = filter;
|
||||||
|
|
||||||
|
obj[name] = `${operator}.${value}`;
|
||||||
|
|
||||||
|
return obj;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { PrismaPg } from '@prisma/adapter-pg';
|
||||||
import { readReplicas } from '@prisma/extension-read-replicas';
|
import { readReplicas } from '@prisma/extension-read-replicas';
|
||||||
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 { filtersObjectToArray } from './params';
|
||||||
|
|
||||||
const log = debug('umami:prisma');
|
const log = debug('umami:prisma');
|
||||||
|
|
||||||
|
|
@ -95,7 +95,7 @@ function mapCohortFilter(column: string, operator: string, value: string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function getFilterQuery(filters: Record<string, any>, options: QueryOptions = {}): string {
|
function getFilterQuery(filters: Record<string, any>, options: QueryOptions = {}): string {
|
||||||
const query = filtersToArray(filters, options).reduce(
|
const query = filtersObjectToArray(filters, options).reduce(
|
||||||
(arr, { name, column, operator, prefix = '' }) => {
|
(arr, { name, column, operator, prefix = '' }) => {
|
||||||
if (column) {
|
if (column) {
|
||||||
arr.push(`and ${mapFilter(`${prefix}${column}`, operator, name)}`);
|
arr.push(`and ${mapFilter(`${prefix}${column}`, operator, name)}`);
|
||||||
|
|
@ -116,7 +116,7 @@ function getFilterQuery(filters: Record<string, any>, options: QueryOptions = {}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCohortQuery(websiteId: string, filters: QueryFilters = {}, options: QueryOptions = {}) {
|
function getCohortQuery(websiteId: string, filters: QueryFilters = {}, options: QueryOptions = {}) {
|
||||||
const query = filtersToArray(filters, options).reduce(
|
const query = filtersObjectToArray(filters, options).reduce(
|
||||||
(arr, { name, column, operator, value }) => {
|
(arr, { name, column, operator, value }) => {
|
||||||
if (column) {
|
if (column) {
|
||||||
arr.push(
|
arr.push(
|
||||||
|
|
@ -169,7 +169,7 @@ function getDateQuery(filters: Record<string, any>) {
|
||||||
function getQueryParams(filters: Record<string, any>) {
|
function getQueryParams(filters: Record<string, any>) {
|
||||||
return {
|
return {
|
||||||
...filters,
|
...filters,
|
||||||
...filtersToArray(filters).reduce((obj, { name, operator, value }) => {
|
...filtersObjectToArray(filters).reduce((obj, { name, operator, value }) => {
|
||||||
obj[name] = [OPERATORS.contains, OPERATORS.doesNotContain].includes(operator)
|
obj[name] = [OPERATORS.contains, OPERATORS.doesNotContain].includes(operator)
|
||||||
? `%${value}%`
|
? `%${value}%`
|
||||||
: value;
|
: value;
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,13 @@ export interface Auth {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Filter {
|
||||||
|
name: string;
|
||||||
|
operator: string;
|
||||||
|
value: string;
|
||||||
|
type?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DateRange {
|
export interface DateRange {
|
||||||
startDate: Date;
|
startDate: Date;
|
||||||
endDate: Date;
|
endDate: Date;
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ export async function getWebsiteSegment(websiteId: string, segmentId: string): P
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getWebsiteSegments(websiteId: string, type: string): Promise<Segment[]> {
|
export async function getWebsiteSegments(websiteId: string, type: string): Promise<Segment[]> {
|
||||||
return prisma.client.Segment.findMany({
|
return prisma.pagedQuery('segment', {
|
||||||
where: { websiteId, type },
|
where: { websiteId, type },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue