diff --git a/src/app/(main)/websites/[websiteId]/WebsiteControls.tsx b/src/app/(main)/websites/[websiteId]/WebsiteControls.tsx
index 655e8ea0..f0471ed6 100644
--- a/src/app/(main)/websites/[websiteId]/WebsiteControls.tsx
+++ b/src/app/(main)/websites/[websiteId]/WebsiteControls.tsx
@@ -3,6 +3,7 @@ import { WebsiteFilterButton } from '@/app/(main)/websites/[websiteId]/WebsiteFi
import { WebsiteDateFilter } from '@/components/input/WebsiteDateFilter';
import { FilterBar } from '@/components/input/FilterBar';
import { WebsiteMonthSelect } from '@/components/input/WebsiteMonthSelect';
+import { ExportButton } from '@/components/input/ExportButton';
export function WebsiteControls({
websiteId,
@@ -10,21 +11,24 @@ export function WebsiteControls({
allowDateFilter = true,
allowMonthFilter,
allowCompare,
+ allowDownload = false,
}: {
websiteId: string;
allowFilter?: boolean;
allowCompare?: boolean;
allowDateFilter?: boolean;
allowMonthFilter?: boolean;
+ allowDownload?: boolean;
}) {
return (
{allowFilter ? : }
{allowDateFilter && }
+ {allowDownload && }
{allowMonthFilter && }
- {allowFilter && }
+ {allowFilter && }
);
}
diff --git a/src/app/(main)/websites/[websiteId]/WebsiteFilterButton.tsx b/src/app/(main)/websites/[websiteId]/WebsiteFilterButton.tsx
index 94022e87..a3442d1c 100644
--- a/src/app/(main)/websites/[websiteId]/WebsiteFilterButton.tsx
+++ b/src/app/(main)/websites/[websiteId]/WebsiteFilterButton.tsx
@@ -2,6 +2,7 @@ import { Button, Icon, DialogTrigger, Dialog, Modal, Text } from '@umami/react-z
import { ListFilter } from '@/components/icons';
import { FilterEditForm } from '@/components/common/FilterEditForm';
import { useMessages, useNavigation, useFilters } from '@/components/hooks';
+import { filtersArrayToObject } from '@/lib/params';
export function WebsiteFilterButton({
websiteId,
@@ -21,16 +22,7 @@ export function WebsiteFilterButton({
const { filters } = useFilters();
const handleChange = ({ filters, segment }) => {
- const params = filters.reduce(
- (obj: { [x: string]: string }, filter: { name: any; operator: any; value: any }) => {
- const { name, operator, value } = filter;
-
- obj[name] = `${operator}.${value}`;
-
- return obj;
- },
- {},
- );
+ const params = filtersArrayToObject(filters);
const url = replaceParams({ ...params, segment });
diff --git a/src/app/(main)/websites/[websiteId]/segments/SegmentAddButton.tsx b/src/app/(main)/websites/[websiteId]/segments/SegmentAddButton.tsx
new file mode 100644
index 00000000..3c198fd4
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/segments/SegmentAddButton.tsx
@@ -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 (
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/segments/SegmentEditForm.tsx b/src/app/(main)/websites/[websiteId]/segments/SegmentEditForm.tsx
new file mode 100644
index 00000000..15133e8e
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/segments/SegmentEditForm.tsx
@@ -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 (
+
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/segments/SegmentsDataTable.tsx b/src/app/(main)/websites/[websiteId]/segments/SegmentsDataTable.tsx
new file mode 100644
index 00000000..d98bad02
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/segments/SegmentsDataTable.tsx
@@ -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 ;
+ };
+
+ return (
+
+ {({ data }) => }
+
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/segments/SegmentsPage.tsx b/src/app/(main)/websites/[websiteId]/segments/SegmentsPage.tsx
new file mode 100644
index 00000000..9f2d8097
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/segments/SegmentsPage.tsx
@@ -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 (
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/segments/SegmentsTable.tsx b/src/app/(main)/websites/[websiteId]/segments/SegmentsTable.tsx
new file mode 100644
index 00000000..0231db87
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/segments/SegmentsTable.tsx
@@ -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 ;
+ }
+
+ return (
+
+
+
+ {(row: any) => }
+
+
+ {(row: any) => {
+ const { id } = row;
+
+ return (
+
+
+
+
+ );
+ }}
+
+
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/segments/page.tsx b/src/app/(main)/websites/[websiteId]/segments/page.tsx
new file mode 100644
index 00000000..9f406b16
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/segments/page.tsx
@@ -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 ;
+}
+
+export const metadata: Metadata = {
+ title: 'Segments',
+};
diff --git a/src/app/api/websites/[websiteId]/segments/route.ts b/src/app/api/websites/[websiteId]/segments/route.ts
index 2cf8388f..5bbcceed 100644
--- a/src/app/api/websites/[websiteId]/segments/route.ts
+++ b/src/app/api/websites/[websiteId]/segments/route.ts
@@ -2,7 +2,7 @@ import { canUpdateWebsite, canViewWebsite } from '@/lib/auth';
import { uuid } from '@/lib/crypto';
import { parseRequest } from '@/lib/request';
import { json, unauthorized } from '@/lib/response';
-import { segmentTypeParam } from '@/lib/schema';
+import { segmentTypeParam, searchParams } from '@/lib/schema';
import { createSegment, getWebsiteSegments } from '@/queries';
import { z } from 'zod';
@@ -12,6 +12,7 @@ export async function GET(
) {
const schema = z.object({
type: segmentTypeParam,
+ ...searchParams,
});
const { auth, query, error } = await parseRequest(request, schema);
diff --git a/src/components/common/FilterEditForm.tsx b/src/components/common/FilterEditForm.tsx
index 45c402d2..b253b780 100644
--- a/src/components/common/FilterEditForm.tsx
+++ b/src/components/common/FilterEditForm.tsx
@@ -1,6 +1,6 @@
import { useState } from 'react';
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 { SegmentFilters } from '@/components/input/SegmentFilters';
@@ -23,6 +23,10 @@ export function FilterEditForm({
const [currentFilters, setCurrentFilters] = useState(filters);
const [currentSegment, setCurrentSegment] = useState(segmentId);
+ const {
+ dateRange: { startDate, endDate },
+ } = useDateRange(websiteId);
+
const handleReset = () => {
setCurrentFilters([]);
setCurrentSegment(null);
@@ -45,7 +49,13 @@ export function FilterEditForm({
{formatMessage(labels.segments)}
-
+
,
+) {
+ 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,
+ });
+}
diff --git a/src/components/hooks/queries/useWebsiteSegementsQuery.ts b/src/components/hooks/queries/useWebsiteSegmentsQuery.ts
similarity index 58%
rename from src/components/hooks/queries/useWebsiteSegementsQuery.ts
rename to src/components/hooks/queries/useWebsiteSegmentsQuery.ts
index aecf1d52..f64b6c2f 100644
--- a/src/components/hooks/queries/useWebsiteSegementsQuery.ts
+++ b/src/components/hooks/queries/useWebsiteSegmentsQuery.ts
@@ -2,6 +2,7 @@ import { useApi } from '../useApi';
import { useModified } from '@/components/hooks';
import { keepPreviousData } from '@tanstack/react-query';
import { ReactQueryOptions } from '@/lib/types';
+import { useFilterParameters } from '@/components/hooks/useFilterParameters';
export function useWebsiteSegmentsQuery(
websiteId: string,
@@ -9,11 +10,12 @@ export function useWebsiteSegmentsQuery(
options?: ReactQueryOptions,
) {
const { get, useQuery } = useApi();
- const { modified } = useModified(`website:${websiteId}`);
+ const { modified } = useModified(`segments`);
+ const filters = useFilterParameters();
return useQuery({
- queryKey: ['website:segments', { websiteId, modified, ...params }],
- queryFn: () => get(`/websites/${websiteId}/segments`, { ...params }),
+ queryKey: ['website:segments', { websiteId, modified, ...filters, ...params }],
+ queryFn: () => get(`/websites/${websiteId}/segments`, { ...filters, ...params }),
enabled: !!websiteId,
placeholderData: keepPreviousData,
...options,
diff --git a/src/components/input/FieldFilters.tsx b/src/components/input/FieldFilters.tsx
index d37f8850..88d7b822 100644
--- a/src/components/input/FieldFilters.tsx
+++ b/src/components/input/FieldFilters.tsx
@@ -1,21 +1,26 @@
import { Key } from 'react';
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 { Empty } from '@/components/common/Empty';
export interface FieldFiltersProps {
websiteId: string;
filters: { name: string; operator: string; value: string }[];
+ startDate: Date;
+ endDate: Date;
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 { fields } = useFields();
- const {
- dateRange: { startDate, endDate },
- } = useDateRange(websiteId);
const updateFilter = (name: string, props: Record) => {
onSave(filters.map(filter => (filter.name === name ? { ...filter, ...props } : filter)));
diff --git a/src/components/input/FilterBar.tsx b/src/components/input/FilterBar.tsx
index 51409b26..445db323 100644
--- a/src/components/input/FilterBar.tsx
+++ b/src/components/input/FilterBar.tsx
@@ -1,9 +1,15 @@
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 { isSearchOperator } from '@/lib/params';
-export function FilterBar() {
+export function FilterBar({ websiteId }: { websiteId: string }) {
const { formatMessage, labels } = useMessages();
const { formatValue } = useFormat();
const {
@@ -13,6 +19,7 @@ export function FilterBar() {
query: { segment },
} = useNavigation();
const { filters, operatorLabels } = useFilters();
+ const { data, isLoading } = useWebsiteSegmentQuery(websiteId, segment);
const handleCloseFilter = (param: string) => {
router.push(updateParams({ [param]: undefined }));
@@ -33,11 +40,11 @@ export function FilterBar() {
return (
- {segment && (
+ {segment && !isLoading && (
diff --git a/src/components/input/SegmentFilters.tsx b/src/components/input/SegmentFilters.tsx
index 6d1936ee..3cf8ce46 100644
--- a/src/components/input/SegmentFilters.tsx
+++ b/src/components/input/SegmentFilters.tsx
@@ -19,7 +19,7 @@ export function SegmentFilters({ websiteId, segmentId, onSave }: SegmentFiltersP
handleChange(id[0])}>
- {data?.map(item => {
+ {data?.data?.map(item => {
return (
{item.name}
diff --git a/src/components/input/WebsiteDateFilter.tsx b/src/components/input/WebsiteDateFilter.tsx
index 0bf74846..e78bcd8d 100644
--- a/src/components/input/WebsiteDateFilter.tsx
+++ b/src/components/input/WebsiteDateFilter.tsx
@@ -12,7 +12,6 @@ import { isAfter } from 'date-fns';
import { Chevron, Close, Compare } from '@/components/icons';
import { useDateRange, useMessages, useNavigation } from '@/components/hooks';
import { DateFilter } from './DateFilter';
-import { ExportButton } from '@/components/input/ExportButton';
export function WebsiteDateFilter({
websiteId,
@@ -102,7 +101,6 @@ export function WebsiteDateFilter({
{formatMessage(compare ? labels.cancel : labels.compareDates)}
)}
-
);
}
diff --git a/src/lib/clickhouse.ts b/src/lib/clickhouse.ts
index bb184d6b..233a78bf 100644
--- a/src/lib/clickhouse.ts
+++ b/src/lib/clickhouse.ts
@@ -3,7 +3,7 @@ import { formatInTimeZone } from 'date-fns-tz';
import debug from 'debug';
import { CLICKHOUSE } from '@/lib/db';
import { DEFAULT_PAGE_SIZE, OPERATORS } from './constants';
-import { filtersToArray } from './params';
+import { filtersObjectToArray } from './params';
import { QueryFilters, QueryOptions } from './types';
export const CLICKHOUSE_DATE_FORMATS = {
@@ -88,7 +88,7 @@ function mapFilter(column: string, operator: string, name: string, type: string
}
function getFilterQuery(filters: Record, options: QueryOptions = {}) {
- const query = filtersToArray(filters, options).reduce((arr, { name, column, operator }) => {
+ const query = filtersObjectToArray(filters, options).reduce((arr, { name, column, operator }) => {
if (column) {
arr.push(`and ${mapFilter(column, operator, name)}`);
@@ -144,7 +144,7 @@ function getDateQuery(filters: Record) {
function getQueryParams(filters: Record) {
return {
...filters,
- ...filtersToArray(filters).reduce((obj, { name, value }) => {
+ ...filtersObjectToArray(filters).reduce((obj, { name, value }) => {
if (name && value !== undefined) {
obj[name] = value;
}
diff --git a/src/lib/params.ts b/src/lib/params.ts
index 9b3abf58..e136542b 100644
--- a/src/lib/params.ts
+++ b/src/lib/params.ts
@@ -1,7 +1,7 @@
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') {
const [, operator, value] = param.match(/^([a-z]+)\.(.*)/) || [];
@@ -18,7 +18,7 @@ export function isSearchOperator(operator: any) {
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) => {
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] });
}
- const { operator, value } = parseParameterValue(filter);
+ const { operator, value } = parseFilterValue(filter);
return arr.concat({
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;
+ }, {});
+}
diff --git a/src/lib/prisma.ts b/src/lib/prisma.ts
index ab97b7d6..560361f8 100644
--- a/src/lib/prisma.ts
+++ b/src/lib/prisma.ts
@@ -4,7 +4,7 @@ import { PrismaPg } from '@prisma/adapter-pg';
import { readReplicas } from '@prisma/extension-read-replicas';
import { SESSION_COLUMNS, OPERATORS, DEFAULT_PAGE_SIZE } from './constants';
import { QueryOptions, QueryFilters } from './types';
-import { filtersToArray } from './params';
+import { filtersObjectToArray } from './params';
const log = debug('umami:prisma');
@@ -95,7 +95,7 @@ function mapCohortFilter(column: string, operator: string, value: string) {
}
function getFilterQuery(filters: Record, options: QueryOptions = {}): string {
- const query = filtersToArray(filters, options).reduce(
+ const query = filtersObjectToArray(filters, options).reduce(
(arr, { name, column, operator, prefix = '' }) => {
if (column) {
arr.push(`and ${mapFilter(`${prefix}${column}`, operator, name)}`);
@@ -116,7 +116,7 @@ function getFilterQuery(filters: Record, 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 }) => {
if (column) {
arr.push(
@@ -169,7 +169,7 @@ function getDateQuery(filters: Record) {
function getQueryParams(filters: Record) {
return {
...filters,
- ...filtersToArray(filters).reduce((obj, { name, operator, value }) => {
+ ...filtersObjectToArray(filters).reduce((obj, { name, operator, value }) => {
obj[name] = [OPERATORS.contains, OPERATORS.doesNotContain].includes(operator)
? `%${value}%`
: value;
diff --git a/src/lib/types.ts b/src/lib/types.ts
index 81627458..53bdaea5 100644
--- a/src/lib/types.ts
+++ b/src/lib/types.ts
@@ -24,6 +24,13 @@ export interface Auth {
};
}
+export interface Filter {
+ name: string;
+ operator: string;
+ value: string;
+ type?: string;
+}
+
export interface DateRange {
startDate: Date;
endDate: Date;
diff --git a/src/queries/prisma/segment.ts b/src/queries/prisma/segment.ts
index 01b82e9b..311eccd4 100644
--- a/src/queries/prisma/segment.ts
+++ b/src/queries/prisma/segment.ts
@@ -20,7 +20,7 @@ export async function getWebsiteSegment(websiteId: string, segmentId: string): P
}
export async function getWebsiteSegments(websiteId: string, type: string): Promise {
- return prisma.client.Segment.findMany({
+ return prisma.pagedQuery('segment', {
where: { websiteId, type },
});
}