Cohorts editing.

This commit is contained in:
Mike Cao 2025-08-26 23:55:57 -07:00
parent 07665f4824
commit 8c8e36c63b
26 changed files with 1066 additions and 985 deletions

View file

@ -82,7 +82,7 @@
"@react-spring/web": "^9.7.3", "@react-spring/web": "^9.7.3",
"@svgr/cli": "^8.1.0", "@svgr/cli": "^8.1.0",
"@tanstack/react-query": "^5.85.5", "@tanstack/react-query": "^5.85.5",
"@umami/react-zen": "^0.164.0", "@umami/react-zen": "^0.168.0",
"@umami/redis-client": "^0.27.0", "@umami/redis-client": "^0.27.0",
"bcryptjs": "^3.0.2", "bcryptjs": "^3.0.2",
"chalk": "^5.6.0", "chalk": "^5.6.0",

1672
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -70,7 +70,11 @@ export function SideNav(props: SidebarProps) {
<Row height="100%" backgroundColor border="right"> <Row height="100%" backgroundColor border="right">
<Sidebar {...props} isCollapsed={isCollapsed || hasNav} muteItems={false} showBorder={false}> <Sidebar {...props} isCollapsed={isCollapsed || hasNav} muteItems={false} showBorder={false}>
<SidebarSection onClick={() => setIsCollapsed(false)}> <SidebarSection onClick={() => setIsCollapsed(false)}>
<SidebarHeader label="umami" icon={isCollapsed && !hasNav ? <PanelLeft /> : <Logo />}> <SidebarHeader
label="umami"
icon={isCollapsed && !hasNav ? <PanelLeft /> : <Logo />}
style={{ maxHeight: 40 }}
>
{!isCollapsed && !hasNav && <PanelButton />} {!isCollapsed && !hasNav && <PanelButton />}
</SidebarHeader> </SidebarHeader>
</SidebarSection> </SidebarSection>
@ -81,7 +85,12 @@ export function SideNav(props: SidebarProps) {
{links.map(({ id, path, label, icon }) => { {links.map(({ id, path, label, icon }) => {
return ( return (
<Link key={id} href={renderUrl(path, false)} role="button"> <Link key={id} href={renderUrl(path, false)} role="button">
<SidebarItem label={label} icon={icon} isSelected={pathname.endsWith(path)} /> <SidebarItem
label={label}
icon={icon}
isSelected={pathname.endsWith(path)}
role="button"
/>
</Link> </Link>
); );
})} })}
@ -90,7 +99,12 @@ export function SideNav(props: SidebarProps) {
{bottomLinks.map(({ id, path, label, icon }) => { {bottomLinks.map(({ id, path, label, icon }) => {
return ( return (
<Link key={id} href={path} role="button"> <Link key={id} href={path} role="button">
<SidebarItem label={label} icon={icon} isSelected={pathname.includes(path)} /> <SidebarItem
label={label}
icon={icon}
isSelected={pathname.includes(path)}
role="button"
/>
</Link> </Link>
); );
})} })}

View file

@ -8,17 +8,14 @@ export function DateRangeSetting() {
const { dateRange, saveDateRange } = useDateRange(); const { dateRange, saveDateRange } = useDateRange();
const { value } = dateRange; const { value } = dateRange;
const handleChange = (value: string) => saveDateRange(value); const handleChange = (value: string) => {
saveDateRange(value);
};
const handleReset = () => saveDateRange(DEFAULT_DATE_RANGE_VALUE); const handleReset = () => saveDateRange(DEFAULT_DATE_RANGE_VALUE);
return ( return (
<Row gap="3"> <Row gap="3">
<DateFilter <DateFilter value={value} onChange={handleChange} />
value={value}
startDate={dateRange.startDate}
endDate={dateRange.endDate}
onChange={handleChange}
/>
<Button onPress={handleReset}>{formatMessage(labels.reset)}</Button> <Button onPress={handleReset}>{formatMessage(labels.reset)}</Button>
</Row> </Row>
); );

View file

@ -15,7 +15,7 @@ export function CohortAddButton({ websiteId }: { websiteId: string }) {
<Text>{formatMessage(labels.cohort)}</Text> <Text>{formatMessage(labels.cohort)}</Text>
</Button> </Button>
<Modal> <Modal>
<Dialog title={formatMessage(labels.cohort)} style={{ width: 800, maxHeight: '90vh' }}> <Dialog title={formatMessage(labels.cohort)} style={{ width: 800, minHeight: 300 }}>
{({ close }) => { {({ close }) => {
return <CohortEditForm websiteId={websiteId} onClose={close} />; return <CohortEditForm websiteId={websiteId} onClose={close} />;
}} }}

View file

@ -17,7 +17,7 @@ export function CohortEditButton({
return ( return (
<ActionButton title={formatMessage(labels.edit)} icon={<Edit />}> <ActionButton title={formatMessage(labels.edit)} icon={<Edit />}>
<Dialog title={formatMessage(labels.cohort)} style={{ width: 800, maxHeight: '90vh' }}> <Dialog title={formatMessage(labels.cohort)} style={{ width: 800, minHeight: 300 }}>
{({ close }) => { {({ close }) => {
return ( return (
<CohortEditForm <CohortEditForm

View file

@ -7,18 +7,29 @@ import {
TextField, TextField,
Label, Label,
Loading, Loading,
Column,
ComboBox,
Select,
ListItem,
Grid,
useDebounce,
} from '@umami/react-zen'; } from '@umami/react-zen';
import { subMonths, endOfDay } from 'date-fns'; import {
useMessages,
useUpdateQuery,
useWebsiteCohortQuery,
useWebsiteValuesQuery,
} from '@/components/hooks';
import { DateFilter } from '@/components/input/DateFilter';
import { FieldFilters } from '@/components/input/FieldFilters'; import { FieldFilters } from '@/components/input/FieldFilters';
import { useState } from 'react'; import { SetStateAction, useMemo, useState } from 'react';
import { useMessages, useUpdateQuery, useWebsiteCohortQuery } from '@/components/hooks'; import { endOfDay, subMonths } from 'date-fns';
import { filtersArrayToObject } from '@/lib/params'; import { Empty } from '@/components/common/Empty';
export function CohortEditForm({ export function CohortEditForm({
cohortId, cohortId,
websiteId, websiteId,
filters = [], filters = [],
showFilters = true,
onSave, onSave,
onClose, onClose,
}: { }: {
@ -29,32 +40,46 @@ export function CohortEditForm({
onSave?: () => void; onSave?: () => void;
onClose?: () => void; onClose?: () => void;
}) { }) {
const [action, setAction] = useState('path');
const [search, setSearch] = useState('');
const searchValue = useDebounce(search, 300);
const { data } = useWebsiteCohortQuery(websiteId, cohortId); const { data } = useWebsiteCohortQuery(websiteId, cohortId);
const { formatMessage, labels, messages } = useMessages(); const { formatMessage, labels, messages } = useMessages();
const [currentFilters, setCurrentFilters] = useState(filters);
const startDate = subMonths(endOfDay(new Date()), 6); const startDate = subMonths(endOfDay(new Date()), 6);
const endDate = endOfDay(new Date()); const endDate = endOfDay(new Date());
const { data: searchResults, isLoading } = useWebsiteValuesQuery({
websiteId,
type: action,
search: searchValue,
startDate,
endDate,
});
const { mutate, error, isPending, touch, toast } = useUpdateQuery( const { mutate, error, isPending, touch, toast } = useUpdateQuery(
`/websites/${websiteId}/cohorts${cohortId ? `/${cohortId}` : ''}`, `/websites/${websiteId}/segments${cohortId ? `/${cohortId}` : ''}`,
{ {
...data,
type: 'cohort', type: 'cohort',
}, },
); );
const handleSubmit = async (data: any) => { const items: string[] = useMemo(() => {
mutate( return searchResults?.map(({ value }) => value) || [];
{ ...data, parameters: filtersArrayToObject(currentFilters) }, }, [searchResults]);
{
onSuccess: async () => { const handleSubmit = async (formData: any) => {
toast(formatMessage(messages.save)); mutate(formData, {
touch('cohorts'); onSuccess: async () => {
onSave?.(); toast(formatMessage(messages.saved));
onClose?.(); touch('cohorts');
}, onSave?.();
onClose?.();
}, },
); });
};
const handleSearch = (value: SetStateAction<string>) => {
setSearch(value);
}; };
if (cohortId && !data) { if (cohortId && !data) {
@ -62,7 +87,11 @@ export function CohortEditForm({
} }
return ( return (
<Form error={error} onSubmit={handleSubmit} defaultValues={data}> <Form
error={error}
onSubmit={handleSubmit}
defaultValues={data || { parameters: { filters, dateRange: '30day' } }}
>
<FormField <FormField
name="name" name="name"
label={formatMessage(labels.name)} label={formatMessage(labels.name)}
@ -70,27 +99,74 @@ export function CohortEditForm({
> >
<TextField autoFocus /> <TextField autoFocus />
</FormField> </FormField>
{showFilters && (
<> <Column>
<Label>{formatMessage(labels.filters)}</Label> <Label>{formatMessage(labels.action)}</Label>
<FieldFilters <Grid columns="260px 1fr" gap>
websiteId={websiteId} <Column>
filters={currentFilters} <Select value={action} onChange={setAction}>
startDate={startDate} <ListItem id="path">{formatMessage(labels.viewedPage)}</ListItem>
endDate={endDate} <ListItem id="event">{formatMessage(labels.triggeredEvent)}</ListItem>
onSave={setCurrentFilters} </Select>
/> </Column>
</> <Column>
)} <FormField
name="parameters.action"
rules={{ required: formatMessage(labels.required) }}
>
{({ field }) => {
return (
<ComboBox
aria-label="action"
items={items}
inputValue={field?.value}
onInputChange={value => {
handleSearch(value);
field?.onChange?.(value);
}}
formValue="text"
allowsEmptyCollection
allowsCustomValue
renderEmptyState={() =>
isLoading ? (
<Loading position="center" icon="dots" />
) : (
<Empty message={formatMessage(messages.noResultsFound)} />
)
}
>
{items.map(item => (
<ListItem key={item} id={item}>
{item}
</ListItem>
))}
</ComboBox>
);
}}
</FormField>
</Column>
</Grid>
</Column>
<Column width="260px">
<Label>{formatMessage(labels.dateRange)}</Label>
<FormField name="parameters.dateRange" rules={{ required: formatMessage(labels.required) }}>
<DateFilter placement="bottom start" />
</FormField>
</Column>
<Column>
<Label>{formatMessage(labels.filters)}</Label>
<FormField name="parameters.filters" rules={{ required: formatMessage(labels.required) }}>
<FieldFilters websiteId={websiteId} exclude={['path', 'event']} />
</FormField>
</Column>
<FormButtons> <FormButtons>
<Button isDisabled={isPending} onPress={onClose}> <Button isDisabled={isPending} onPress={onClose}>
{formatMessage(labels.cancel)} {formatMessage(labels.cancel)}
</Button> </Button>
<FormSubmitButton <FormSubmitButton variant="primary" data-test="button-submit" isDisabled={isPending}>
variant="primary"
data-test="button-submit"
isDisabled={isPending || currentFilters.length === 0}
>
{formatMessage(labels.save)} {formatMessage(labels.save)}
</FormSubmitButton> </FormSubmitButton>
</FormButtons> </FormButtons>

View file

@ -15,7 +15,7 @@ export function SegmentAddButton({ websiteId }: { websiteId: string }) {
<Text>{formatMessage(labels.segment)}</Text> <Text>{formatMessage(labels.segment)}</Text>
</Button> </Button>
<Modal> <Modal>
<Dialog title={formatMessage(labels.segment)} style={{ width: 800, maxHeight: '90vh' }}> <Dialog title={formatMessage(labels.segment)} style={{ width: 800, minHeight: 300 }}>
{({ close }) => { {({ close }) => {
return <SegmentEditForm websiteId={websiteId} onClose={close} />; return <SegmentEditForm websiteId={websiteId} onClose={close} />;
}} }}

View file

@ -11,13 +11,13 @@ export function SegmentEditButton({
}: { }: {
segmentId: string; segmentId: string;
websiteId: string; websiteId: string;
filters: any[]; filters?: any[];
}) { }) {
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
return ( return (
<ActionButton title={formatMessage(labels.edit)} icon={<Edit />}> <ActionButton title={formatMessage(labels.edit)} icon={<Edit />}>
<Dialog title={formatMessage(labels.segment)} style={{ width: 800, maxHeight: '90vh' }}> <Dialog title={formatMessage(labels.segment)} style={{ width: 800, minHeight: 300 }}>
{({ close }) => { {({ close }) => {
return ( return (
<SegmentEditForm <SegmentEditForm

View file

@ -5,14 +5,11 @@ import {
FormField, FormField,
FormSubmitButton, FormSubmitButton,
TextField, TextField,
Label,
Loading, Loading,
Label,
} from '@umami/react-zen'; } from '@umami/react-zen';
import { subMonths, endOfDay } from 'date-fns';
import { FieldFilters } from '@/components/input/FieldFilters'; import { FieldFilters } from '@/components/input/FieldFilters';
import { useState } from 'react';
import { useMessages, useUpdateQuery, useWebsiteSegmentQuery } from '@/components/hooks'; import { useMessages, useUpdateQuery, useWebsiteSegmentQuery } from '@/components/hooks';
import { filtersArrayToObject } from '@/lib/params';
import { messages } from '@/components/messages'; import { messages } from '@/components/messages';
export function SegmentEditForm({ export function SegmentEditForm({
@ -32,30 +29,23 @@ export function SegmentEditForm({
}) { }) {
const { data } = useWebsiteSegmentQuery(websiteId, segmentId); const { data } = useWebsiteSegmentQuery(websiteId, segmentId);
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
const [currentFilters, setCurrentFilters] = useState(filters);
const startDate = subMonths(endOfDay(new Date()), 6);
const endDate = endOfDay(new Date());
const { mutate, error, isPending, touch, toast } = useUpdateQuery( const { mutate, error, isPending, touch, toast } = useUpdateQuery(
`/websites/${websiteId}/segments${segmentId ? `/${segmentId}` : ''}`, `/websites/${websiteId}/segments${segmentId ? `/${segmentId}` : ''}`,
{ {
...data,
type: 'segment', type: 'segment',
}, },
); );
const handleSubmit = async (data: any) => { const handleSubmit = async (formData: any) => {
mutate( mutate(formData, {
{ ...data, parameters: filtersArrayToObject(currentFilters) }, onSuccess: async () => {
{ toast(formatMessage(messages.saved));
onSuccess: async () => { touch('segments');
toast(formatMessage(messages.saved)); onSave?.();
touch('segments'); onClose?.();
onSave?.();
onClose?.();
},
}, },
); });
}; };
if (segmentId && !data) { if (segmentId && !data) {
@ -63,35 +53,27 @@ export function SegmentEditForm({
} }
return ( return (
<Form error={error} onSubmit={handleSubmit} defaultValues={data}> <Form error={error} onSubmit={handleSubmit} defaultValues={data || { parameters: { filters } }}>
<FormField <FormField
name="name" name="name"
label={formatMessage(labels.name)} label={formatMessage(labels.name)}
rules={{ required: formatMessage(labels.required) }} rules={{ required: formatMessage(labels.required) }}
> >
<TextField autoFocus /> <TextField autoFocus={!segmentId} />
</FormField> </FormField>
{showFilters && ( {showFilters && (
<> <>
<Label>{formatMessage(labels.filters)}</Label> <Label>{formatMessage(labels.filters)}</Label>
<FieldFilters <FormField name="parameters.filters" rules={{ required: formatMessage(labels.required) }}>
websiteId={websiteId} <FieldFilters websiteId={websiteId} />
filters={currentFilters} </FormField>
startDate={startDate}
endDate={endDate}
onSave={setCurrentFilters}
/>
</> </>
)} )}
<FormButtons> <FormButtons>
<Button isDisabled={isPending} onPress={onClose}> <Button isDisabled={isPending} onPress={onClose}>
{formatMessage(labels.cancel)} {formatMessage(labels.cancel)}
</Button> </Button>
<FormSubmitButton <FormSubmitButton variant="primary" data-test="button-submit" isDisabled={isPending}>
variant="primary"
data-test="button-submit"
isDisabled={isPending || currentFilters.length === 0}
>
{formatMessage(labels.save)} {formatMessage(labels.save)}
</FormSubmitButton> </FormSubmitButton>
</FormButtons> </FormButtons>

View file

@ -2,7 +2,6 @@ import { DataTable, DataColumn, Row } from '@umami/react-zen';
import { useMessages, useNavigation } from '@/components/hooks'; import { useMessages, useNavigation } from '@/components/hooks';
import { Empty } from '@/components/common/Empty'; import { Empty } from '@/components/common/Empty';
import { DateDistance } from '@/components/common/DateDistance'; import { DateDistance } from '@/components/common/DateDistance';
import { filtersObjectToArray } from '@/lib/params';
import { SegmentEditButton } from '@/app/(main)/websites/[websiteId]/segments/SegmentEditButton'; import { SegmentEditButton } from '@/app/(main)/websites/[websiteId]/segments/SegmentEditButton';
import { SegmentDeleteButton } from '@/app/(main)/websites/[websiteId]/segments/SegmentDeleteButton'; import { SegmentDeleteButton } from '@/app/(main)/websites/[websiteId]/segments/SegmentDeleteButton';
import Link from 'next/link'; import Link from 'next/link';
@ -27,15 +26,11 @@ export function SegmentsTable({ data = [] }) {
</DataColumn> </DataColumn>
<DataColumn id="action" align="end" width="100px"> <DataColumn id="action" align="end" width="100px">
{(row: any) => { {(row: any) => {
const { id, name, parameters } = row; const { id, name } = row;
return ( return (
<Row> <Row>
<SegmentEditButton <SegmentEditButton segmentId={id} websiteId={websiteId} />
segmentId={id}
websiteId={websiteId}
filters={filtersObjectToArray(parameters)}
/>
<SegmentDeleteButton segmentId={id} websiteId={websiteId} name={name} /> <SegmentDeleteButton segmentId={id} websiteId={websiteId} name={name} />
</Row> </Row>
); );

View file

@ -4,14 +4,14 @@ import { getQueryFilters, parseRequest } from '@/lib/request';
import { badRequest, json, unauthorized } from '@/lib/response'; import { badRequest, json, unauthorized } from '@/lib/response';
import { getWebsiteSegments, getValues } from '@/queries'; import { getWebsiteSegments, getValues } from '@/queries';
import { z } from 'zod'; import { z } from 'zod';
import { dateRangeParams, searchParams } from '@/lib/schema'; import { dateRangeParams, fieldsParam, searchParams } from '@/lib/schema';
export async function GET( export async function GET(
request: Request, request: Request,
{ params }: { params: Promise<{ websiteId: string }> }, { params }: { params: Promise<{ websiteId: string }> },
) { ) {
const schema = z.object({ const schema = z.object({
type: z.string(), type: fieldsParam,
...dateRangeParams, ...dateRangeParams,
...searchParams, ...searchParams,
}); });
@ -31,7 +31,7 @@ export async function GET(
const { type } = query; const { type } = query;
if (!SESSION_COLUMNS.includes(type) && !EVENT_COLUMNS.includes(type) && !FILTER_GROUPS[type]) { if (!SESSION_COLUMNS.includes(type) && !EVENT_COLUMNS.includes(type) && !FILTER_GROUPS[type]) {
return badRequest('Invalid type.'); return badRequest();
} }
let values; let values;

View file

@ -3,6 +3,7 @@ import { Grid, Column, TextField, Label, Select, Icon, Button, ListItem } from '
import { useFilters, useFormat, useWebsiteValuesQuery } from '@/components/hooks'; import { useFilters, useFormat, useWebsiteValuesQuery } from '@/components/hooks';
import { Close } from '@/components/icons'; import { Close } from '@/components/icons';
import { isSearchOperator } from '@/lib/params'; import { isSearchOperator } from '@/lib/params';
import { Empty } from '@/components/common/Empty';
export interface FilterRecordProps { export interface FilterRecordProps {
websiteId: string; websiteId: string;
@ -41,20 +42,25 @@ export function FilterRecord({
endDate, endDate,
}); });
const isSearch = isSearchOperator(operator); const isSearch = isSearchOperator(operator);
const items = data?.filter(({ value }) => value) || [];
const handleSearch = value => { const handleSearch = (value: string) => {
setSearch(value); setSearch(value);
}; };
const handleSelectOperator = value => { const handleSelectOperator = (value: any) => {
onSelect?.(name, value); onSelect?.(name, value);
}; };
const handleSelectValue = value => { const handleSelectValue = (value: string) => {
setSelected(value); setSelected(value);
onChange?.(name, value); onChange?.(name, value);
}; };
const renderValue = () => {
return formatValue(selected, type);
};
return ( return (
<Column> <Column>
<Label>{fields.find(f => f.name === name)?.label}</Label> <Label>{fields.find(f => f.name === name)?.label}</Label>
@ -78,15 +84,17 @@ export function FilterRecord({
)} )}
{!isSearch && ( {!isSearch && (
<Select <Select
items={data} items={items}
value={selected} value={selected}
onChange={handleSelectValue} onChange={handleSelectValue}
searchValue={search} searchValue={search}
renderValue={renderValue}
onSearch={handleSearch} onSearch={handleSearch}
isLoading={isLoading} isLoading={isLoading}
listProps={{ renderEmptyState: () => <Empty /> }}
allowSearch allowSearch
> >
{data?.map(({ value }) => { {items?.map(({ value }) => {
return ( return (
<ListItem key={value} id={value}> <ListItem key={value} id={value}>
{formatValue(value, type)} {formatValue(value, type)}

View file

@ -13,7 +13,7 @@ export function useWebsiteCohortQuery(
return useQuery({ return useQuery({
queryKey: ['website:cohorts', { websiteId, cohortId, modified }], queryKey: ['website:cohorts', { websiteId, cohortId, modified }],
queryFn: () => get(`/websites/${websiteId}/cohorts/${cohortId}`), queryFn: () => get(`/websites/${websiteId}/segments/${cohortId}`),
enabled: !!(websiteId && cohortId), enabled: !!(websiteId && cohortId),
placeholderData: keepPreviousData, placeholderData: keepPreviousData,
...options, ...options,

View file

@ -1,3 +1,4 @@
import { useMemo } from 'react';
import { getMinimumUnit, parseDateRange, getOffsetDateRange } from '@/lib/date'; import { getMinimumUnit, parseDateRange, getOffsetDateRange } from '@/lib/date';
import { setItem } from '@/lib/storage'; import { setItem } from '@/lib/storage';
import { DATE_RANGE_CONFIG, DEFAULT_DATE_COMPARE, DEFAULT_DATE_RANGE_VALUE } from '@/lib/constants'; import { DATE_RANGE_CONFIG, DEFAULT_DATE_COMPARE, DEFAULT_DATE_RANGE_VALUE } from '@/lib/constants';
@ -6,10 +7,10 @@ import { setDateRangeValue, useApp } from '@/store/app';
import { useLocale } from './useLocale'; import { useLocale } from './useLocale';
import { useApi } from './useApi'; import { useApi } from './useApi';
import { useNavigation } from './useNavigation'; import { useNavigation } from './useNavigation';
import { useMemo } from 'react';
export interface UseDateRangeOptions { export interface UseDateRangeOptions {
ignoreOffset?: boolean; ignoreOffset?: boolean;
useQueryParameter?: boolean;
} }
export function useDateRange(websiteId?: string, options: UseDateRangeOptions = {}) { export function useDateRange(websiteId?: string, options: UseDateRangeOptions = {}) {
@ -20,7 +21,11 @@ export function useDateRange(websiteId?: string, options: UseDateRangeOptions =
} = useNavigation(); } = useNavigation();
const websiteConfig = useWebsites(state => state[websiteId]?.dateRange); const websiteConfig = useWebsites(state => state[websiteId]?.dateRange);
const globalConfig = useApp(state => state.dateRangeValue); const globalConfig = useApp(state => state.dateRangeValue);
const dateValue = date || websiteConfig?.value || globalConfig || DEFAULT_DATE_RANGE_VALUE; const dateValue =
(options.useQueryParameter ? date : false) ||
websiteConfig?.value ||
globalConfig ||
DEFAULT_DATE_RANGE_VALUE;
const dateRange = useMemo(() => { const dateRange = useMemo(() => {
const dateRangeObject = parseDateRange(dateValue, locale); const dateRangeObject = parseDateRange(dateValue, locale);

View file

@ -1,7 +1,7 @@
import { useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
import { messages, labels } from '@/components/messages'; import { messages, labels } from '@/components/messages';
export function useMessages(): any { export function useMessages() {
const intl = useIntl(); const intl = useIntl();
const getMessage = (id: string) => { const getMessage = (id: string) => {

View file

@ -4,31 +4,31 @@ import { endOfYear } from 'date-fns';
import { DatePickerForm } from '@/components/metrics/DatePickerForm'; import { DatePickerForm } from '@/components/metrics/DatePickerForm';
import { useMessages } from '@/components/hooks'; import { useMessages } from '@/components/hooks';
import { DateDisplay } from '@/components/common/DateDisplay'; import { DateDisplay } from '@/components/common/DateDisplay';
import { parseDateRange } from '@/lib/date';
export interface DateFilterProps { export interface DateFilterProps {
value: string; value?: string;
startDate: Date;
endDate: Date;
onChange?: (value: string) => void; onChange?: (value: string) => void;
showAllTime?: boolean; showAllTime?: boolean;
renderDate?: boolean; renderDate?: boolean;
placement?: string;
} }
export function DateFilter({ export function DateFilter({
value, value,
startDate,
endDate,
onChange, onChange,
showAllTime, showAllTime,
renderDate, renderDate,
placement = 'bottom',
}: DateFilterProps) { }: DateFilterProps) {
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
const [showPicker, setShowPicker] = useState(false); const [showPicker, setShowPicker] = useState(false);
const { startDate, endDate } = parseDateRange(value) || {};
const options = [ const options = [
{ label: formatMessage(labels.today), value: '0day' }, { label: formatMessage(labels.today), value: '0day' },
{ {
label: formatMessage(labels.lastHours, { x: 24 }), label: formatMessage(labels.lastHours, { x: '24' }),
value: '24hour', value: '24hour',
}, },
{ {
@ -37,7 +37,7 @@ export function DateFilter({
divider: true, divider: true,
}, },
{ {
label: formatMessage(labels.lastDays, { x: 7 }), label: formatMessage(labels.lastDays, { x: '7' }),
value: '7day', value: '7day',
}, },
{ {
@ -46,21 +46,21 @@ export function DateFilter({
divider: true, divider: true,
}, },
{ {
label: formatMessage(labels.lastDays, { x: 30 }), label: formatMessage(labels.lastDays, { x: '30' }),
value: '30day', value: '30day',
}, },
{ {
label: formatMessage(labels.lastDays, { x: 90 }), label: formatMessage(labels.lastDays, { x: '90' }),
value: '90day', value: '90day',
}, },
{ label: formatMessage(labels.thisYear), value: '0year' }, { label: formatMessage(labels.thisYear), value: '0year' },
{ {
label: formatMessage(labels.lastMonths, { x: 6 }), label: formatMessage(labels.lastMonths, { x: '6' }),
value: '6month', value: '6month',
divider: true, divider: true,
}, },
{ {
label: formatMessage(labels.lastMonths, { x: 12 }), label: formatMessage(labels.lastMonths, { x: '12' }),
value: '12month', value: '12month',
}, },
showAllTime && { showAllTime && {
@ -105,7 +105,7 @@ export function DateFilter({
placeholder={formatMessage(labels.selectDate)} placeholder={formatMessage(labels.selectDate)}
onChange={handleChange} onChange={handleChange}
renderValue={renderValue} renderValue={renderValue}
popoverProps={{ placement: 'bottom' }} popoverProps={{ placement: placement as any }}
> >
{options.map(({ label, value, divider }: any) => { {options.map(({ label, value, divider }: any) => {
return ( return (

View file

@ -1,4 +1,5 @@
import { Key } from 'react'; import { Key } from 'react';
import { subMonths, endOfDay } from 'date-fns';
import { Grid, Column, List, ListItem } from '@umami/react-zen'; import { Grid, Column, List, ListItem } from '@umami/react-zen';
import { useFields, useMessages } from '@/components/hooks'; import { useFields, useMessages } from '@/components/hooks';
import { FilterRecord } from '@/components/common/FilterRecord'; import { FilterRecord } from '@/components/common/FilterRecord';
@ -6,28 +7,23 @@ import { Empty } from '@/components/common/Empty';
export interface FieldFiltersProps { export interface FieldFiltersProps {
websiteId: string; websiteId: string;
filters: { name: string; operator: string; value: string }[]; value?: { name: string; operator: string; value: string }[];
startDate: Date; exclude?: string[];
endDate: Date; onChange?: (data: any) => void;
onSave?: (data: any) => void;
} }
export function FieldFilters({ export function FieldFilters({ websiteId, value, exclude = [], onChange }: FieldFiltersProps) {
websiteId,
filters,
startDate,
endDate,
onSave,
}: FieldFiltersProps) {
const { formatMessage, messages } = useMessages(); const { formatMessage, messages } = useMessages();
const { fields } = useFields(); const { fields } = useFields();
const startDate = subMonths(endOfDay(new Date()), 6);
const endDate = endOfDay(new Date());
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))); onChange(value.map(filter => (filter.name === name ? { ...filter, ...props } : filter)));
}; };
const handleAdd = (name: Key) => { const handleAdd = (name: Key) => {
onSave(filters.concat({ name: name.toString(), operator: 'eq', value: '' })); onChange(value.concat({ name: name.toString(), operator: 'eq', value: '' }));
}; };
const handleChange = (name: string, value: Key) => { const handleChange = (name: string, value: Key) => {
@ -39,25 +35,27 @@ export function FieldFilters({
}; };
const handleRemove = (name: string) => { const handleRemove = (name: string) => {
onSave(filters.filter(filter => filter.name !== name)); onChange(value.filter(filter => filter.name !== name));
}; };
return ( return (
<Grid columns="160px 1fr" overflow="hidden" gapY="6"> <Grid columns="160px 1fr" overflow="hidden" gapY="6">
<Column border="right" paddingRight="3"> <Column border="right" paddingRight="3">
<List onAction={handleAdd}> <List onAction={handleAdd}>
{fields.map((field: any) => { {fields
const isDisabled = !!filters.find(({ name }) => name === field.name); .filter(({ name }) => !exclude.includes(name))
return ( .map(field => {
<ListItem key={field.name} id={field.name} isDisabled={isDisabled}> const isDisabled = !!value.find(({ name }) => name === field.name);
{field.label} return (
</ListItem> <ListItem key={field.name} id={field.name} isDisabled={isDisabled}>
); {field.label}
})} </ListItem>
);
})}
</List> </List>
</Column> </Column>
<Column paddingLeft="6" overflow="auto" gapY="4" height="500px" style={{ contain: 'layout' }}> <Column paddingLeft="6" overflow="auto" gapY="4" height="500px" style={{ contain: 'layout' }}>
{filters.map(filter => { {value.map(filter => {
return ( return (
<FilterRecord <FilterRecord
key={filter.name} key={filter.name}
@ -72,7 +70,7 @@ export function FieldFilters({
/> />
); );
})} })}
{!filters.length && <Empty message={formatMessage(messages.nothingSelected)} />} {!value.length && <Empty message={formatMessage(messages.nothingSelected)} />}
</Column> </Column>
</Grid> </Grid>
); );

View file

@ -72,7 +72,7 @@ export function FilterBar({ websiteId }: { websiteId: string }) {
label={label} label={label}
operator={operatorLabels[operator]} operator={operatorLabels[operator]}
value={paramValue} value={paramValue}
onRemove={name => handleCloseFilter(name)} onRemove={(name: string) => handleCloseFilter(name)}
/> />
); );
})} })}
@ -143,7 +143,7 @@ const FilterItem = ({ name, label, operator, value, onRemove }) => {
{value} {value}
</Text> </Text>
</Row> </Row>
<Icon onClick={() => onRemove(name)} size="xs"> <Icon onClick={() => onRemove(name)} size="xs" style={{ cursor: 'pointer' }}>
<Close /> <Close />
</Icon> </Icon>
</Row> </Row>

View file

@ -51,7 +51,7 @@ export function FilterEditForm({
<TabPanel id="fields"> <TabPanel id="fields">
<FieldFilters <FieldFilters
websiteId={websiteId} websiteId={websiteId}
filters={currentFilters} value={currentFilters}
startDate={startDate} startDate={startDate}
endDate={endDate} endDate={endDate}
onSave={setCurrentFilters} onSave={setCurrentFilters}

View file

@ -38,8 +38,8 @@ export function TeamsButton({ showText = true }: { showText?: boolean }) {
return ( return (
<MenuTrigger> <MenuTrigger>
<Pressable> <Pressable>
<Row width="100%" backgroundColor="2" border borderRadius> <Row role="button" width="100%" backgroundColor="2" border borderRadius>
<SidebarItem label={label} icon={teamId ? <Users /> : <User />}> <SidebarItem role="button" label={label} icon={teamId ? <Users /> : <User />}>
{showText && ( {showText && (
<Icon rotate={90} size="sm"> <Icon rotate={90} size="sm">
<Chevron /> <Chevron />

View file

@ -19,7 +19,7 @@ export function WebsiteDateFilter({
allowCompare, allowCompare,
}: WebsiteDateFilterProps) { }: WebsiteDateFilterProps) {
const { dateRange } = useDateRange(websiteId); const { dateRange } = useDateRange(websiteId);
const { value, startDate, endDate } = dateRange; const { value, endDate } = dateRange;
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
const { const {
router, router,
@ -61,8 +61,6 @@ export function WebsiteDateFilter({
)} )}
<DateFilter <DateFilter
value={value} value={value}
startDate={startDate}
endDate={endDate}
onChange={handleChange} onChange={handleChange}
showAllTime={showAllTime} showAllTime={showAllTime}
renderDate={+offset !== 0} renderDate={+offset !== 0}

View file

@ -51,6 +51,7 @@ export const labels = defineMessages({
data: { id: 'label.data', defaultMessage: 'Data' }, data: { id: 'label.data', defaultMessage: 'Data' },
trackingCode: { id: 'label.tracking-code', defaultMessage: 'Tracking code' }, trackingCode: { id: 'label.tracking-code', defaultMessage: 'Tracking code' },
shareUrl: { id: 'label.share-url', defaultMessage: 'Share URL' }, shareUrl: { id: 'label.share-url', defaultMessage: 'Share URL' },
action: { id: 'label.action', defaultMessage: 'Action' },
actions: { id: 'label.actions', defaultMessage: 'Actions' }, actions: { id: 'label.actions', defaultMessage: 'Actions' },
domain: { id: 'label.domain', defaultMessage: 'Domain' }, domain: { id: 'label.domain', defaultMessage: 'Domain' },
websiteId: { id: 'label.website-id', defaultMessage: 'Website ID' }, websiteId: { id: 'label.website-id', defaultMessage: 'Website ID' },
@ -357,6 +358,7 @@ export const labels = defineMessages({
audience: { id: 'label.audience', defaultMessage: 'Audience' }, audience: { id: 'label.audience', defaultMessage: 'Audience' },
invalidUrl: { id: 'label.invalid-url', defaultMessage: 'Invalid URL' }, invalidUrl: { id: 'label.invalid-url', defaultMessage: 'Invalid URL' },
environment: { id: 'label.environment', defaultMessage: 'Environment' }, environment: { id: 'label.environment', defaultMessage: 'Environment' },
criteria: { id: 'label.criteria', defaultMessage: 'Criteria' },
}); });
export const messages = defineMessages({ export const messages = defineMessages({

View file

@ -85,6 +85,7 @@ export function WeeklyTraffic({ websiteId }: { websiteId: string }) {
height="16px" height="16px"
borderRadius="full" borderRadius="full"
style={{ margin: '0 auto' }} style={{ margin: '0 auto' }}
role="cell"
> >
<Row <Row
backgroundColor="primary" backgroundColor="primary"

View file

@ -19,6 +19,10 @@ export function isSearchOperator(operator: any) {
} }
export function filtersObjectToArray(filters: QueryFilters, options: QueryOptions = {}) { export function filtersObjectToArray(filters: QueryFilters, options: QueryOptions = {}) {
if (!filters) {
return [];
}
return Object.keys(filters).reduce((arr, key) => { return Object.keys(filters).reduce((arr, key) => {
const filter = filters[key]; const filter = filters[key];

View file

@ -87,6 +87,7 @@ export const fieldsParam = z.enum([
'tag', 'tag',
'hostname', 'hostname',
'language', 'language',
'event',
]); ]);
export const reportTypeParam = z.enum([ export const reportTypeParam = z.enum([