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 ( + + + + + {({ close }) => { + 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 ( +
+ + + + + + + + + {formatMessage(labels.save)} + + + + ); +} 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 ( + + + + + + + {formatMessage(labels.edit)} + + + + + + + + {formatMessage(labels.delete)} + + + + ); + }} + + + ); +} 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 }, }); }