mirror of
https://github.com/umami-software/umami.git
synced 2026-05-30 06:47:25 +00:00
Goal/Funnel mobile friendly changes. Polish event-data filters UI/UX
This commit is contained in:
@@ -0,0 +1,252 @@
|
||||
import {
|
||||
Button,
|
||||
Column,
|
||||
ComboBox,
|
||||
FormField,
|
||||
Grid,
|
||||
Icon,
|
||||
ListItem,
|
||||
Loading,
|
||||
Select,
|
||||
useDebounce,
|
||||
} from '@umami/react-zen';
|
||||
import { endOfDay, subMonths } from 'date-fns';
|
||||
import { useState } from 'react';
|
||||
import { Empty } from '@/components/common/Empty';
|
||||
import { useApi, useMessages, useMobile } from '@/components/hooks';
|
||||
import { X } from '@/components/icons';
|
||||
|
||||
export function getEventDataDateRange() {
|
||||
return {
|
||||
startAt: +subMonths(endOfDay(new Date()), 6),
|
||||
endAt: +endOfDay(new Date()),
|
||||
};
|
||||
}
|
||||
|
||||
function PropertySelect({
|
||||
websiteId,
|
||||
eventName,
|
||||
value,
|
||||
onChange,
|
||||
onPropertyChange,
|
||||
}: {
|
||||
websiteId: string;
|
||||
eventName?: string;
|
||||
value?: string;
|
||||
onChange?: (value: string) => void;
|
||||
onPropertyChange?: (value: string) => void;
|
||||
}) {
|
||||
const { get, useQuery } = useApi();
|
||||
const { t, messages } = useMessages();
|
||||
const [search, setSearch] = useState(value ?? '');
|
||||
const searchValue = useDebounce(search, 300);
|
||||
const { startAt, endAt } = getEventDataDateRange();
|
||||
|
||||
const { data, isLoading } = useQuery<
|
||||
Array<{ eventName: string; propertyName: string; total: number }>
|
||||
>({
|
||||
queryKey: ['event-data:properties', { websiteId, eventName, searchValue, startAt, endAt }],
|
||||
queryFn: () =>
|
||||
get(`/websites/${websiteId}/event-data/properties`, {
|
||||
startAt,
|
||||
endAt,
|
||||
...(eventName ? { event: eventName } : {}),
|
||||
...(searchValue ? { propertyName: searchValue } : {}),
|
||||
}),
|
||||
enabled: !!websiteId,
|
||||
});
|
||||
|
||||
const properties = [...new Set(data?.map(d => d.propertyName) ?? [])];
|
||||
|
||||
return (
|
||||
<ComboBox
|
||||
aria-label="PropertySelect"
|
||||
items={properties}
|
||||
inputValue={value}
|
||||
style={{ width: '100%' }}
|
||||
onInputChange={v => {
|
||||
setSearch(v);
|
||||
onChange?.(v);
|
||||
onPropertyChange?.(v);
|
||||
}}
|
||||
formValue="text"
|
||||
allowsEmptyCollection
|
||||
allowsCustomValue
|
||||
renderEmptyState={() =>
|
||||
isLoading ? (
|
||||
<Loading placement="center" icon="dots" />
|
||||
) : (
|
||||
<Empty message={t(messages.noResultsFound)} />
|
||||
)
|
||||
}
|
||||
>
|
||||
{properties.map(p => (
|
||||
<ListItem key={p} id={p}>
|
||||
{p}
|
||||
</ListItem>
|
||||
))}
|
||||
</ComboBox>
|
||||
);
|
||||
}
|
||||
|
||||
function ValueSelect({
|
||||
websiteId,
|
||||
eventName,
|
||||
propertyName,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
websiteId: string;
|
||||
eventName?: string;
|
||||
propertyName?: string;
|
||||
value?: string;
|
||||
onChange?: (value: string) => void;
|
||||
}) {
|
||||
const { get, useQuery } = useApi();
|
||||
const { t, messages } = useMessages();
|
||||
const { startAt, endAt } = getEventDataDateRange();
|
||||
|
||||
const { data, isLoading } = useQuery<Array<{ value: string; total: number }>>({
|
||||
queryKey: ['event-data:values', { websiteId, eventName, propertyName, startAt, endAt }],
|
||||
queryFn: () =>
|
||||
get(`/websites/${websiteId}/event-data/values`, {
|
||||
startAt,
|
||||
endAt,
|
||||
event: eventName,
|
||||
propertyName,
|
||||
}),
|
||||
enabled: !!(websiteId && eventName && propertyName),
|
||||
});
|
||||
|
||||
const values = data?.map(d => d.value) ?? [];
|
||||
|
||||
return (
|
||||
<ComboBox
|
||||
aria-label="ValueSelect"
|
||||
items={values}
|
||||
inputValue={value}
|
||||
style={{ width: '100%' }}
|
||||
onInputChange={v => {
|
||||
onChange?.(v);
|
||||
}}
|
||||
formValue="text"
|
||||
allowsEmptyCollection
|
||||
allowsCustomValue
|
||||
renderEmptyState={() =>
|
||||
isLoading ? (
|
||||
<Loading placement="center" icon="dots" />
|
||||
) : (
|
||||
<Empty message={t(messages.noResultsFound)} />
|
||||
)
|
||||
}
|
||||
>
|
||||
{values.map(v => (
|
||||
<ListItem key={v} id={v}>
|
||||
{v}
|
||||
</ListItem>
|
||||
))}
|
||||
</ComboBox>
|
||||
);
|
||||
}
|
||||
|
||||
function OperatorSelect({
|
||||
value = 'eq',
|
||||
onChange,
|
||||
}: {
|
||||
value?: string;
|
||||
onChange?: (value: string) => void;
|
||||
}) {
|
||||
const { t, labels } = useMessages();
|
||||
return (
|
||||
<Select value={value} onChange={onChange}>
|
||||
<ListItem id="eq">{t(labels.is)}</ListItem>
|
||||
<ListItem id="neq">{t(labels.isNot)}</ListItem>
|
||||
<ListItem id="c">{t(labels.contains)}</ListItem>
|
||||
<ListItem id="dnc">{t(labels.doesNotContain)}</ListItem>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
export interface EventDataFilterRowProps {
|
||||
stepIndex: number;
|
||||
filterIndex: number;
|
||||
websiteId: string;
|
||||
eventName: string;
|
||||
initialProperty?: string;
|
||||
onRemove: () => void;
|
||||
}
|
||||
|
||||
export function EventDataFilterRow({
|
||||
stepIndex,
|
||||
filterIndex,
|
||||
websiteId,
|
||||
eventName,
|
||||
initialProperty,
|
||||
onRemove,
|
||||
}: EventDataFilterRowProps) {
|
||||
const { t, labels } = useMessages();
|
||||
const { isMobile } = useMobile();
|
||||
const [propertyName, setPropertyName] = useState(initialProperty ?? '');
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Grid columns="1fr auto" gap alignItems="start">
|
||||
<Column gap>
|
||||
<FormField
|
||||
name={`steps.${stepIndex}.filters.${filterIndex}.property`}
|
||||
rules={{ required: t(labels.required) }}
|
||||
>
|
||||
<PropertySelect
|
||||
websiteId={websiteId}
|
||||
eventName={eventName}
|
||||
onPropertyChange={v => setPropertyName(v)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField name={`steps.${stepIndex}.filters.${filterIndex}.operator`}>
|
||||
<OperatorSelect />
|
||||
</FormField>
|
||||
<FormField name={`steps.${stepIndex}.filters.${filterIndex}.value`}>
|
||||
<ValueSelect websiteId={websiteId} eventName={eventName} propertyName={propertyName} />
|
||||
</FormField>
|
||||
</Column>
|
||||
<Button onPress={onRemove}>
|
||||
<Icon size="sm">
|
||||
<X />
|
||||
</Icon>
|
||||
</Button>
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Grid columns="1fr auto 1fr auto" gap>
|
||||
<Column style={{ minWidth: 0 }}>
|
||||
<FormField
|
||||
name={`steps.${stepIndex}.filters.${filterIndex}.property`}
|
||||
rules={{ required: t(labels.required) }}
|
||||
>
|
||||
<PropertySelect
|
||||
websiteId={websiteId}
|
||||
eventName={eventName}
|
||||
onPropertyChange={v => setPropertyName(v)}
|
||||
/>
|
||||
</FormField>
|
||||
</Column>
|
||||
<Column style={{ minWidth: 0 }}>
|
||||
<FormField name={`steps.${stepIndex}.filters.${filterIndex}.operator`}>
|
||||
<OperatorSelect />
|
||||
</FormField>
|
||||
</Column>
|
||||
<Column style={{ minWidth: 0, overflow: 'hidden' }}>
|
||||
<FormField name={`steps.${stepIndex}.filters.${filterIndex}.value`}>
|
||||
<ValueSelect websiteId={websiteId} eventName={eventName} propertyName={propertyName} />
|
||||
</FormField>
|
||||
</Column>
|
||||
<Button onPress={onRemove}>
|
||||
<Icon size="sm">
|
||||
<X />
|
||||
</Icon>
|
||||
</Button>
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Box, Column, Dialog, Grid, Icon, ProgressBar, Row, Text } from '@umami/react-zen';
|
||||
import { LoadingPanel } from '@/components/common/LoadingPanel';
|
||||
import { useMessages, useNavigation, useOperatorLabels, useResultQuery } from '@/components/hooks';
|
||||
import { File, User } from '@/components/icons';
|
||||
@@ -7,6 +6,7 @@ import { ChangeLabel } from '@/components/metrics/ChangeLabel';
|
||||
import { Lightning } from '@/components/svg';
|
||||
import { formatLongNumber } from '@/lib/format';
|
||||
import type { FunnelResult } from '@/queries/sql/reports/getFunnel';
|
||||
import { Box, Column, Grid, Icon, ProgressBar, Row, Text } from '@umami/react-zen';
|
||||
import { FunnelEditForm } from './FunnelEditForm';
|
||||
|
||||
interface FunnelProps {
|
||||
@@ -17,7 +17,7 @@ interface FunnelProps {
|
||||
websiteId: string;
|
||||
}
|
||||
|
||||
export function Funnel({ id, name, type, parameters, websiteId }) {
|
||||
export function Funnel({ id, name, type, parameters, websiteId }: FunnelProps) {
|
||||
const { t, labels } = useMessages();
|
||||
const { pathname } = useNavigation();
|
||||
const isSharePage = pathname.includes('/share/');
|
||||
@@ -41,14 +41,15 @@ export function Funnel({ id, name, type, parameters, websiteId }) {
|
||||
</Column>
|
||||
{!isSharePage && (
|
||||
<Column>
|
||||
<ReportEditButton id={id} name={name} type={type}>
|
||||
{({ close }) => {
|
||||
return (
|
||||
<Dialog title={t(labels.funnel)} style={{ minHeight: 300, minWidth: 400 }}>
|
||||
<FunnelEditForm id={id} websiteId={websiteId} onClose={close} />
|
||||
</Dialog>
|
||||
);
|
||||
}}
|
||||
<ReportEditButton
|
||||
id={id}
|
||||
name={name}
|
||||
type={type}
|
||||
title={t(labels.funnel)}
|
||||
width="700px"
|
||||
height="600px"
|
||||
>
|
||||
{({ close }) => <FunnelEditForm id={id} websiteId={websiteId} onClose={close} />}
|
||||
</ReportEditButton>
|
||||
</Column>
|
||||
)}
|
||||
@@ -100,15 +101,11 @@ export function Funnel({ id, name, type, parameters, websiteId }) {
|
||||
</Row>
|
||||
{filters?.map((f, i) => (
|
||||
<Row key={i} gap="1" style={{ paddingLeft: 28 }}>
|
||||
<Text size="1" color="muted">
|
||||
{f.property}
|
||||
</Text>
|
||||
<Text size="1" color="muted" transform="lowercase">
|
||||
<Text color="muted">{f.property}</Text>
|
||||
<Text color="muted" transform="lowercase">
|
||||
{operatorLabels[f.operator] ?? f.operator}
|
||||
</Text>
|
||||
<Text size="1" color="muted">
|
||||
{f.value}
|
||||
</Text>
|
||||
<Text color="muted">{f.value}</Text>
|
||||
</Row>
|
||||
))}
|
||||
</Column>
|
||||
|
||||
@@ -1,24 +1,21 @@
|
||||
import { Button, Dialog, DialogTrigger, Icon, Modal, Text } from '@umami/react-zen';
|
||||
import { useMessages } from '@/components/hooks';
|
||||
import { Plus } from '@/components/icons';
|
||||
import { DialogButton } from '@/components/input/DialogButton';
|
||||
import { FunnelEditForm } from './FunnelEditForm';
|
||||
|
||||
export function FunnelAddButton({ websiteId }: { websiteId: string }) {
|
||||
const { t, labels } = useMessages();
|
||||
|
||||
return (
|
||||
<DialogTrigger>
|
||||
<Button variant="primary">
|
||||
<Icon>
|
||||
<Plus />
|
||||
</Icon>
|
||||
<Text>{t(labels.funnel)}</Text>
|
||||
</Button>
|
||||
<Modal>
|
||||
<Dialog variant="modal" title={t(labels.funnel)} style={{ minHeight: 375, minWidth: 600 }}>
|
||||
{({ close }) => <FunnelEditForm websiteId={websiteId} onClose={close} />}
|
||||
</Dialog>
|
||||
</Modal>
|
||||
</DialogTrigger>
|
||||
<DialogButton
|
||||
variant="primary"
|
||||
icon={<Plus />}
|
||||
label={t(labels.funnel)}
|
||||
title={t(labels.funnel)}
|
||||
width="700px"
|
||||
height="600px"
|
||||
>
|
||||
{({ close }) => <FunnelEditForm websiteId={websiteId} onClose={close} />}
|
||||
</DialogButton>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import {
|
||||
Button,
|
||||
Column,
|
||||
ComboBox,
|
||||
Form,
|
||||
FormButtons,
|
||||
FormField,
|
||||
@@ -9,174 +8,21 @@ import {
|
||||
FormSubmitButton,
|
||||
Grid,
|
||||
Icon,
|
||||
ListItem,
|
||||
ListSeparator,
|
||||
Loading,
|
||||
Row,
|
||||
Select,
|
||||
Text,
|
||||
TextField,
|
||||
useDebounce,
|
||||
} from '@umami/react-zen';
|
||||
import { endOfDay, subMonths } from 'date-fns';
|
||||
import { Fragment, useState } from 'react';
|
||||
import { Empty } from '@/components/common/Empty';
|
||||
import { useApi, useMessages, useReportQuery, useUpdateQuery } from '@/components/hooks';
|
||||
import { useApi, useMessages, useMobile, useReportQuery, useUpdateQuery } from '@/components/hooks';
|
||||
import { Plus, X } from '@/components/icons';
|
||||
import { ActionSelect } from '@/components/input/ActionSelect';
|
||||
import { LookupField } from '@/components/input/LookupField';
|
||||
import { EventDataFilterRow, getEventDataDateRange } from './EventDataFilterRow';
|
||||
|
||||
const FUNNEL_STEPS_MAX = 8;
|
||||
|
||||
function getEventDataDateRange() {
|
||||
return {
|
||||
startAt: +subMonths(endOfDay(new Date()), 6),
|
||||
endAt: +endOfDay(new Date()),
|
||||
};
|
||||
}
|
||||
|
||||
function PropertySelect({
|
||||
websiteId,
|
||||
eventName,
|
||||
value,
|
||||
onChange,
|
||||
onPropertyChange,
|
||||
}: {
|
||||
websiteId: string;
|
||||
eventName?: string;
|
||||
value?: string;
|
||||
onChange?: (value: string) => void;
|
||||
onPropertyChange?: (value: string) => void;
|
||||
}) {
|
||||
const { get, useQuery } = useApi();
|
||||
const { t, messages } = useMessages();
|
||||
const [search, setSearch] = useState(value ?? '');
|
||||
const searchValue = useDebounce(search, 300);
|
||||
const { startAt, endAt } = getEventDataDateRange();
|
||||
|
||||
const { data, isLoading } = useQuery<
|
||||
Array<{ eventName: string; propertyName: string; total: number }>
|
||||
>({
|
||||
queryKey: ['event-data:properties', { websiteId, eventName, searchValue, startAt, endAt }],
|
||||
queryFn: () =>
|
||||
get(`/websites/${websiteId}/event-data/properties`, {
|
||||
startAt,
|
||||
endAt,
|
||||
...(eventName ? { event: eventName } : {}),
|
||||
...(searchValue ? { propertyName: searchValue } : {}),
|
||||
}),
|
||||
enabled: !!websiteId,
|
||||
});
|
||||
|
||||
const properties = [...new Set(data?.map(d => d.propertyName) ?? [])];
|
||||
|
||||
return (
|
||||
<ComboBox
|
||||
aria-label="PropertySelect"
|
||||
items={properties}
|
||||
inputValue={value}
|
||||
onInputChange={v => {
|
||||
setSearch(v);
|
||||
onChange?.(v);
|
||||
onPropertyChange?.(v);
|
||||
}}
|
||||
formValue="text"
|
||||
allowsEmptyCollection
|
||||
allowsCustomValue
|
||||
renderEmptyState={() =>
|
||||
isLoading ? (
|
||||
<Loading placement="center" icon="dots" />
|
||||
) : (
|
||||
<Empty message={t(messages.noResultsFound)} />
|
||||
)
|
||||
}
|
||||
>
|
||||
{properties.map(p => (
|
||||
<ListItem key={p} id={p}>
|
||||
{p}
|
||||
</ListItem>
|
||||
))}
|
||||
</ComboBox>
|
||||
);
|
||||
}
|
||||
|
||||
function ValueSelect({
|
||||
websiteId,
|
||||
eventName,
|
||||
propertyName,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
websiteId: string;
|
||||
eventName?: string;
|
||||
propertyName?: string;
|
||||
value?: string;
|
||||
onChange?: (value: string) => void;
|
||||
}) {
|
||||
const { get, useQuery } = useApi();
|
||||
const { t, messages } = useMessages();
|
||||
const { startAt, endAt } = getEventDataDateRange();
|
||||
|
||||
const { data, isLoading } = useQuery<Array<{ value: string; total: number }>>({
|
||||
queryKey: ['event-data:values', { websiteId, eventName, propertyName, startAt, endAt }],
|
||||
queryFn: () =>
|
||||
get(`/websites/${websiteId}/event-data/values`, {
|
||||
startAt,
|
||||
endAt,
|
||||
event: eventName,
|
||||
propertyName,
|
||||
}),
|
||||
enabled: !!(websiteId && eventName && propertyName),
|
||||
});
|
||||
|
||||
const values = data?.map(d => d.value) ?? [];
|
||||
|
||||
return (
|
||||
<ComboBox
|
||||
aria-label="ValueSelect"
|
||||
items={values}
|
||||
inputValue={value}
|
||||
onInputChange={v => {
|
||||
onChange?.(v);
|
||||
}}
|
||||
formValue="text"
|
||||
allowsEmptyCollection
|
||||
allowsCustomValue
|
||||
renderEmptyState={() =>
|
||||
isLoading ? (
|
||||
<Loading placement="center" icon="dots" />
|
||||
) : (
|
||||
<Empty message={t(messages.noResultsFound)} />
|
||||
)
|
||||
}
|
||||
>
|
||||
{values.map(v => (
|
||||
<ListItem key={v} id={v}>
|
||||
{v}
|
||||
</ListItem>
|
||||
))}
|
||||
</ComboBox>
|
||||
);
|
||||
}
|
||||
|
||||
function OperatorSelect({
|
||||
value = 'eq',
|
||||
onChange,
|
||||
}: {
|
||||
value?: string;
|
||||
onChange?: (value: string) => void;
|
||||
}) {
|
||||
const { t, labels } = useMessages();
|
||||
return (
|
||||
<Select value={value} onChange={onChange}>
|
||||
<ListItem id="eq">{t(labels.is)}</ListItem>
|
||||
<ListItem id="neq">{t(labels.isNot)}</ListItem>
|
||||
<ListItem id="c">{t(labels.contains)}</ListItem>
|
||||
<ListItem id="dnc">{t(labels.doesNotContain)}</ListItem>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
function StepRow({
|
||||
index,
|
||||
websiteId,
|
||||
@@ -189,39 +35,75 @@ function StepRow({
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const { t, labels } = useMessages();
|
||||
const { isMobile } = useMobile();
|
||||
const { get, useQuery } = useApi();
|
||||
const [eventName, setEventName] = useState(initialEventName ?? '');
|
||||
const { startAt, endAt } = getEventDataDateRange();
|
||||
|
||||
const { data: eventProperties } = useQuery<Array<{ propertyName: string }>>({
|
||||
queryKey: ['event-data:properties', { websiteId, eventName, searchValue: '', startAt, endAt }],
|
||||
queryFn: () =>
|
||||
get(`/websites/${websiteId}/event-data/properties`, {
|
||||
startAt,
|
||||
endAt,
|
||||
...(eventName ? { event: eventName } : {}),
|
||||
}),
|
||||
enabled: !!websiteId && !!eventName,
|
||||
});
|
||||
|
||||
const hasEventData = (eventProperties?.length ?? 0) > 0;
|
||||
|
||||
const valueField = (
|
||||
<FormField name={`steps.${index}.value`} rules={{ required: t(labels.required) }}>
|
||||
{({ field, context }) => {
|
||||
const type = context.watch(`steps.${index}.type`);
|
||||
return (
|
||||
<LookupField
|
||||
websiteId={websiteId}
|
||||
type={type}
|
||||
{...field}
|
||||
onValueChange={(v: string) => {
|
||||
setEventName(v);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</FormField>
|
||||
);
|
||||
|
||||
return (
|
||||
<Column gap>
|
||||
<Grid columns="260px 1fr auto" gap>
|
||||
<Column>
|
||||
<FormField name={`steps.${index}.type`} rules={{ required: t(labels.required) }}>
|
||||
<ActionSelect />
|
||||
</FormField>
|
||||
</Column>
|
||||
<Column>
|
||||
<FormField name={`steps.${index}.value`} rules={{ required: t(labels.required) }}>
|
||||
{({ field, context }) => {
|
||||
const type = context.watch(`steps.${index}.type`);
|
||||
return (
|
||||
<LookupField
|
||||
websiteId={websiteId}
|
||||
type={type}
|
||||
{...field}
|
||||
onValueChange={(v: string) => {
|
||||
setEventName(v);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</FormField>
|
||||
</Column>
|
||||
<Button onPress={onRemove}>
|
||||
<Icon size="sm">
|
||||
<X />
|
||||
</Icon>
|
||||
</Button>
|
||||
</Grid>
|
||||
{isMobile ? (
|
||||
<Grid columns="1fr auto" gap alignItems="start">
|
||||
<Column gap>
|
||||
<FormField name={`steps.${index}.type`} rules={{ required: t(labels.required) }}>
|
||||
<ActionSelect />
|
||||
</FormField>
|
||||
{valueField}
|
||||
</Column>
|
||||
<Button onPress={onRemove}>
|
||||
<Icon size="sm">
|
||||
<X />
|
||||
</Icon>
|
||||
</Button>
|
||||
</Grid>
|
||||
) : (
|
||||
<Grid columns="260px 1fr auto" gap>
|
||||
<Column>
|
||||
<FormField name={`steps.${index}.type`} rules={{ required: t(labels.required) }}>
|
||||
<ActionSelect />
|
||||
</FormField>
|
||||
</Column>
|
||||
<Column>
|
||||
{valueField}
|
||||
</Column>
|
||||
<Button onPress={onRemove}>
|
||||
<Icon size="sm">
|
||||
<X />
|
||||
</Icon>
|
||||
</Button>
|
||||
</Grid>
|
||||
)}
|
||||
<FormFieldArray name={`steps.${index}.filters`}>
|
||||
{({ fields: filterFields, append: appendFilter, remove: removeFilter, watch }) => {
|
||||
const stepType = watch(`steps.${index}.type`);
|
||||
@@ -235,7 +117,7 @@ function StepRow({
|
||||
{ id: filterId, property: initialProperty }: { id: string; property?: string },
|
||||
filterIndex: number,
|
||||
) => (
|
||||
<FilterRow
|
||||
<EventDataFilterRow
|
||||
key={filterId}
|
||||
stepIndex={index}
|
||||
filterIndex={filterIndex}
|
||||
@@ -246,17 +128,19 @@ function StepRow({
|
||||
/>
|
||||
),
|
||||
)}
|
||||
<Row>
|
||||
<Button
|
||||
variant="quiet"
|
||||
onPress={() => appendFilter({ property: '', operator: 'eq', value: '' })}
|
||||
>
|
||||
<Icon size="sm">
|
||||
<Plus />
|
||||
</Icon>
|
||||
<Text>{t(labels.filter)}</Text>
|
||||
</Button>
|
||||
</Row>
|
||||
{hasEventData && (
|
||||
<Row>
|
||||
<Button
|
||||
variant="quiet"
|
||||
onPress={() => appendFilter({ property: '', operator: 'eq', value: '' })}
|
||||
>
|
||||
<Icon size="sm">
|
||||
<Plus />
|
||||
</Icon>
|
||||
<Text>{t(labels.filter)}</Text>
|
||||
</Button>
|
||||
</Row>
|
||||
)}
|
||||
</Grid>
|
||||
);
|
||||
}}
|
||||
@@ -265,51 +149,6 @@ function StepRow({
|
||||
);
|
||||
}
|
||||
|
||||
function FilterRow({
|
||||
stepIndex,
|
||||
filterIndex,
|
||||
websiteId,
|
||||
eventName,
|
||||
initialProperty,
|
||||
onRemove,
|
||||
}: {
|
||||
stepIndex: number;
|
||||
filterIndex: number;
|
||||
websiteId: string;
|
||||
eventName: string;
|
||||
initialProperty?: string;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const { t, labels } = useMessages();
|
||||
const [propertyName, setPropertyName] = useState(initialProperty ?? '');
|
||||
|
||||
return (
|
||||
<Grid columns="1fr 140px 1fr auto" gap>
|
||||
<FormField
|
||||
name={`steps.${stepIndex}.filters.${filterIndex}.property`}
|
||||
rules={{ required: t(labels.required) }}
|
||||
>
|
||||
<PropertySelect
|
||||
websiteId={websiteId}
|
||||
eventName={eventName}
|
||||
onPropertyChange={v => setPropertyName(v)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField name={`steps.${stepIndex}.filters.${filterIndex}.operator`}>
|
||||
<OperatorSelect />
|
||||
</FormField>
|
||||
<FormField name={`steps.${stepIndex}.filters.${filterIndex}.value`}>
|
||||
<ValueSelect websiteId={websiteId} eventName={eventName} propertyName={propertyName} />
|
||||
</FormField>
|
||||
<Button onPress={onRemove}>
|
||||
<Icon size="sm">
|
||||
<X />
|
||||
</Icon>
|
||||
</Button>
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
|
||||
export function FunnelEditForm({
|
||||
id,
|
||||
websiteId,
|
||||
@@ -322,7 +161,7 @@ export function FunnelEditForm({
|
||||
onClose?: () => void;
|
||||
}) {
|
||||
const { t, labels } = useMessages();
|
||||
const { data } = useReportQuery(id);
|
||||
const { data, isLoading } = useReportQuery(id);
|
||||
const { mutateAsync, error, isPending, touch } = useUpdateQuery(`/reports${id ? `/${id}` : ''}`);
|
||||
|
||||
const handleSubmit = async ({
|
||||
@@ -345,7 +184,7 @@ export function FunnelEditForm({
|
||||
);
|
||||
};
|
||||
|
||||
if (id && !data) {
|
||||
if (id && isLoading) {
|
||||
return <Loading placement="absolute" />;
|
||||
}
|
||||
|
||||
@@ -405,7 +244,7 @@ export function FunnelEditForm({
|
||||
);
|
||||
}}
|
||||
</FormFieldArray>
|
||||
<FormButtons>
|
||||
<FormButtons style={{ paddingBottom: '16px' }}>
|
||||
<Button onPress={onClose} isDisabled={isPending}>
|
||||
{t(labels.cancel)}
|
||||
</Button>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Column, Dialog, Grid, Icon, ProgressBar, Row, Text } from '@umami/react-zen';
|
||||
import { Column, Grid, Icon, ProgressBar, Row, Text } from '@umami/react-zen';
|
||||
import { LoadingPanel } from '@/components/common/LoadingPanel';
|
||||
import { useMessages, useNavigation, useResultQuery } from '@/components/hooks';
|
||||
import { File, User } from '@/components/icons';
|
||||
@@ -49,18 +49,8 @@ export function Goal({ id, name, type, parameters, websiteId, startDate, endDate
|
||||
</Column>
|
||||
{!isSharePage && (
|
||||
<Column>
|
||||
<ReportEditButton id={id} name={name} type={type}>
|
||||
{({ close }) => {
|
||||
return (
|
||||
<Dialog
|
||||
title={t(labels.goal)}
|
||||
variant="modal"
|
||||
style={{ minHeight: 300, minWidth: 400 }}
|
||||
>
|
||||
<GoalEditForm id={id} websiteId={websiteId} onClose={close} />
|
||||
</Dialog>
|
||||
);
|
||||
}}
|
||||
<ReportEditButton id={id} name={name} type={type} title={t(labels.goal)} minWidth="400px" minHeight="300px">
|
||||
{({ close }) => <GoalEditForm id={id} websiteId={websiteId} onClose={close} />}
|
||||
</ReportEditButton>
|
||||
</Column>
|
||||
)}
|
||||
|
||||
@@ -1,28 +1,21 @@
|
||||
import { Button, Dialog, DialogTrigger, Icon, Modal, Text } from '@umami/react-zen';
|
||||
import { useMessages } from '@/components/hooks';
|
||||
import { Plus } from '@/components/icons';
|
||||
import { DialogButton } from '@/components/input/DialogButton';
|
||||
import { GoalEditForm } from './GoalEditForm';
|
||||
|
||||
export function GoalAddButton({ websiteId }: { websiteId: string }) {
|
||||
const { t, labels } = useMessages();
|
||||
|
||||
return (
|
||||
<DialogTrigger>
|
||||
<Button variant="primary">
|
||||
<Icon>
|
||||
<Plus />
|
||||
</Icon>
|
||||
<Text>{t(labels.goal)}</Text>
|
||||
</Button>
|
||||
<Modal>
|
||||
<Dialog
|
||||
aria-label="add goal"
|
||||
title={t(labels.goal)}
|
||||
style={{ minWidth: 400, minHeight: 300 }}
|
||||
>
|
||||
{({ close }) => <GoalEditForm websiteId={websiteId} onClose={close} />}
|
||||
</Dialog>
|
||||
</Modal>
|
||||
</DialogTrigger>
|
||||
<DialogButton
|
||||
variant="primary"
|
||||
icon={<Plus />}
|
||||
label={t(labels.goal)}
|
||||
title={t(labels.goal)}
|
||||
minWidth="400px"
|
||||
minHeight="300px"
|
||||
>
|
||||
{({ close }) => <GoalEditForm websiteId={websiteId} onClose={close} />}
|
||||
</DialogButton>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
Loading,
|
||||
TextField,
|
||||
} from '@umami/react-zen';
|
||||
import { useMessages, useReportQuery, useUpdateQuery } from '@/components/hooks';
|
||||
import { useMessages, useMobile, useReportQuery, useUpdateQuery } from '@/components/hooks';
|
||||
import { ActionSelect } from '@/components/input/ActionSelect';
|
||||
import { LookupField } from '@/components/input/LookupField';
|
||||
|
||||
@@ -26,6 +26,7 @@ export function GoalEditForm({
|
||||
onClose?: () => void;
|
||||
}) {
|
||||
const { t, labels } = useMessages();
|
||||
const { isMobile } = useMobile();
|
||||
const { data } = useReportQuery(id);
|
||||
const { mutateAsync, error, isPending, touch } = useUpdateQuery(`/reports${id ? `/${id}` : ''}`);
|
||||
|
||||
@@ -64,20 +65,33 @@ export function GoalEditForm({
|
||||
</FormField>
|
||||
<Column>
|
||||
<Label>{t(labels.action)}</Label>
|
||||
<Grid columns="260px 1fr" gap>
|
||||
<Column>
|
||||
{isMobile ? (
|
||||
<Column gap style={{ minWidth: 0 }}>
|
||||
<FormField name="parameters.type" rules={{ required: t(labels.required) }}>
|
||||
<ActionSelect />
|
||||
</FormField>
|
||||
</Column>
|
||||
<Column>
|
||||
<FormField name="parameters.value" rules={{ required: t(labels.required) }}>
|
||||
{({ field }) => {
|
||||
return <LookupField websiteId={websiteId} type={type} {...field} />;
|
||||
}}
|
||||
</FormField>
|
||||
</Column>
|
||||
</Grid>
|
||||
) : (
|
||||
<Grid columns="260px 1fr" gap>
|
||||
<Column style={{ minWidth: 0 }}>
|
||||
<FormField name="parameters.type" rules={{ required: t(labels.required) }}>
|
||||
<ActionSelect />
|
||||
</FormField>
|
||||
</Column>
|
||||
<Column style={{ minWidth: 0 }}>
|
||||
<FormField name="parameters.value" rules={{ required: t(labels.required) }}>
|
||||
{({ field }) => {
|
||||
return <LookupField websiteId={websiteId} type={type} {...field} />;
|
||||
}}
|
||||
</FormField>
|
||||
</Column>
|
||||
</Grid>
|
||||
)}
|
||||
</Column>
|
||||
|
||||
<FormButtons>
|
||||
|
||||
@@ -18,6 +18,8 @@ export interface DialogButtonProps extends Omit<ButtonProps, 'children'> {
|
||||
height?: string;
|
||||
minWidth?: string;
|
||||
minHeight?: string;
|
||||
isOpen?: boolean;
|
||||
onOpenChange?: (isOpen: boolean) => void;
|
||||
children?: DialogProps['children'];
|
||||
}
|
||||
|
||||
@@ -29,6 +31,8 @@ export function DialogButton({
|
||||
height,
|
||||
minWidth,
|
||||
minHeight,
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
children,
|
||||
...props
|
||||
}: DialogButtonProps) {
|
||||
@@ -39,29 +43,44 @@ export function DialogButton({
|
||||
minWidth,
|
||||
minHeight,
|
||||
maxHeight: 'calc(100dvh - 40px)',
|
||||
overflowY: 'auto',
|
||||
padding: '32px',
|
||||
};
|
||||
|
||||
if (isMobile) {
|
||||
style.width = '100%';
|
||||
style.height = '100%';
|
||||
style.minWidth = undefined;
|
||||
style.minHeight = undefined;
|
||||
style.maxHeight = '100%';
|
||||
style.overflowY = 'auto';
|
||||
}
|
||||
|
||||
const dialog = (
|
||||
<Dialog
|
||||
variant={isMobile ? 'sheet' : undefined}
|
||||
title={title === undefined ? label : title}
|
||||
style={style}
|
||||
>
|
||||
{children}
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
if (isOpen !== undefined) {
|
||||
return (
|
||||
<Modal isOpen={isOpen} onOpenChange={onOpenChange} isDismissable placement={isMobile ? 'fullscreen' : 'center'}>
|
||||
{dialog}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DialogTrigger>
|
||||
<Button {...props}>
|
||||
<IconLabel icon={icon} label={label} />
|
||||
</Button>
|
||||
<Modal placement={isMobile ? 'fullscreen' : 'center'}>
|
||||
<Dialog
|
||||
variant={isMobile ? 'sheet' : undefined}
|
||||
title={title === undefined ? label : title}
|
||||
style={style}
|
||||
>
|
||||
{children}
|
||||
</Dialog>
|
||||
{dialog}
|
||||
</Modal>
|
||||
</DialogTrigger>
|
||||
);
|
||||
|
||||
@@ -112,7 +112,7 @@ export function FilterEditForm({ websiteId, onChange, onClose }: FilterEditFormP
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
</Column>
|
||||
<Row alignItems="center" justifyContent="space-between" gap>
|
||||
<Row alignItems="center" justifyContent="space-between" gap style={isMobile ? { paddingBottom: '16px' } : undefined}>
|
||||
<Button onPress={handleReset}>{t(labels.reset)}</Button>
|
||||
<Row alignItems="center" justifyContent="flex-end" gridColumn="span 2" gap>
|
||||
<Button onPress={onClose}>{t(labels.cancel)}</Button>
|
||||
|
||||
@@ -1,22 +1,25 @@
|
||||
import { Empty } from '@/components/common/Empty';
|
||||
import { useMessages, useWebsiteValuesQuery } from '@/components/hooks';
|
||||
import { ComboBox, type ComboBoxProps, ListItem, Loading, useDebounce } from '@umami/react-zen';
|
||||
import { endOfDay, subMonths } from 'date-fns';
|
||||
import { type SetStateAction, useMemo, useState } from 'react';
|
||||
import { Empty } from '@/components/common/Empty';
|
||||
import { useMessages, useWebsiteValuesQuery } from '@/components/hooks';
|
||||
|
||||
export interface LookupFieldProps extends ComboBoxProps {
|
||||
export interface LookupFieldProps extends Omit<ComboBoxProps, 'onChange'> {
|
||||
websiteId: string;
|
||||
type: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
/**
|
||||
* `FormField` injects `onChange` via `cloneElement`, overwriting any custom handler.
|
||||
* Use `onValueChange` for side effects that must survive that override.
|
||||
*/
|
||||
onValueChange?: (value: string) => void;
|
||||
}
|
||||
|
||||
export function LookupField({ websiteId, type, value, onChange, onValueChange, ...props }: LookupFieldProps) {
|
||||
export function LookupField({
|
||||
websiteId,
|
||||
type,
|
||||
value,
|
||||
onChange,
|
||||
onValueChange,
|
||||
...props
|
||||
}: LookupFieldProps) {
|
||||
const { t, messages } = useMessages();
|
||||
const [search, setSearch] = useState(value);
|
||||
const searchValue = useDebounce(search, 300);
|
||||
|
||||
@@ -14,17 +14,28 @@ import { type ReactNode, useState } from 'react';
|
||||
import { useMessages } from '@/components/hooks';
|
||||
import { useDeleteQuery } from '@/components/hooks/queries/useDeleteQuery';
|
||||
import { Edit, MoreHorizontal, Trash } from '@/components/icons';
|
||||
import { DialogButton } from './DialogButton';
|
||||
|
||||
export function ReportEditButton({
|
||||
id,
|
||||
name,
|
||||
type,
|
||||
title,
|
||||
width,
|
||||
height,
|
||||
minWidth,
|
||||
minHeight,
|
||||
children,
|
||||
onDelete,
|
||||
}: {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
title?: ReactNode;
|
||||
width?: string;
|
||||
height?: string;
|
||||
minWidth?: string;
|
||||
minHeight?: string;
|
||||
onDelete?: () => void;
|
||||
children: ({ close }: { close: () => void }) => ReactNode;
|
||||
}) {
|
||||
@@ -81,18 +92,26 @@ export function ReportEditButton({
|
||||
</Menu>
|
||||
</Popover>
|
||||
</MenuTrigger>
|
||||
<Modal isOpen={showEdit || showDelete} isDismissable={true}>
|
||||
{showEdit && children({ close: handleClose })}
|
||||
{showDelete && (
|
||||
<AlertDialog
|
||||
title={t(labels.delete)}
|
||||
onConfirm={handleDelete}
|
||||
onCancel={handleClose}
|
||||
isDanger
|
||||
>
|
||||
<Row gap="1">{t(messages.confirmDelete, { target: name })}</Row>
|
||||
</AlertDialog>
|
||||
)}
|
||||
<DialogButton
|
||||
isOpen={showEdit}
|
||||
onOpenChange={open => !open && handleClose()}
|
||||
title={title}
|
||||
width={width}
|
||||
height={height}
|
||||
minWidth={minWidth}
|
||||
minHeight={minHeight}
|
||||
>
|
||||
{children}
|
||||
</DialogButton>
|
||||
<Modal isOpen={showDelete} isDismissable={true} onOpenChange={open => !open && handleClose()}>
|
||||
<AlertDialog
|
||||
title={t(labels.delete)}
|
||||
onConfirm={handleDelete}
|
||||
onCancel={handleClose}
|
||||
isDanger
|
||||
>
|
||||
<Row gap="1">{t(messages.confirmDelete, { target: name })}</Row>
|
||||
</AlertDialog>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -84,6 +84,7 @@ async function relationalQuery(
|
||||
select 1 from event_data _ed${stepIndex}_${fi}
|
||||
where _ed${stepIndex}_${fi}.website_event_id = ${eventAlias}.event_id
|
||||
and _ed${stepIndex}_${fi}.website_id = {{websiteId::uuid}}
|
||||
and _ed${stepIndex}_${fi}.created_at between {{startDate}} and {{endDate}}
|
||||
and _ed${stepIndex}_${fi}.data_key = {{${keyParam}}}
|
||||
and case when _ed${stepIndex}_${fi}.data_type = 2 then replace(_ed${stepIndex}_${fi}.string_value, '.0000', '') else _ed${stepIndex}_${fi}.string_value end ${op} {{${valParam}}}
|
||||
)`;
|
||||
@@ -210,6 +211,7 @@ async function clickhouseQuery(
|
||||
stepIndex: number,
|
||||
stepFilters: Array<FunnelStepFilter> | undefined,
|
||||
params: Record<string, string>,
|
||||
eventAlias: string,
|
||||
): string {
|
||||
if (!stepFilters?.length) return '';
|
||||
|
||||
@@ -231,9 +233,10 @@ async function clickhouseQuery(
|
||||
}
|
||||
params[valParam] = val;
|
||||
|
||||
return `and event_id in (
|
||||
return `and ${eventAlias}.event_id in (
|
||||
select event_id from event_data
|
||||
where website_id = {websiteId:UUID}
|
||||
and created_at between {startDate:DateTime64} and {endDate:DateTime64}
|
||||
and data_key = {${keyParam}:String}
|
||||
and multiIf(data_type = 2, replaceAll(string_value, '.0000', ''), string_value) ${op} {${valParam}:String}
|
||||
)`;
|
||||
@@ -269,8 +272,11 @@ async function clickhouseQuery(
|
||||
paramValue = cv.value.replace(/^\*|\*$/g, '%');
|
||||
}
|
||||
|
||||
const eventAlias = levelNumber === 1 ? 'level0' : 'y';
|
||||
const eventDataClause =
|
||||
!isURL && cv.filters?.length ? buildEventDataFilters(i, cv.filters, extraParams) : '';
|
||||
!isURL && cv.filters?.length
|
||||
? buildEventDataFilters(i, cv.filters, extraParams, eventAlias)
|
||||
: '';
|
||||
|
||||
if (levelNumber === 1) {
|
||||
pv.levelOneQuery = `\n
|
||||
|
||||
Reference in New Issue
Block a user