mirror of
https://github.com/umami-software/umami.git
synced 2026-02-04 04:37:11 +01:00
Compare commits
2 commits
05f9a67727
...
5f27ba149b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f27ba149b | ||
|
|
bab4f8ebcc |
36 changed files with 886 additions and 697 deletions
30
package.json
30
package.json
|
|
@ -74,15 +74,15 @@
|
||||||
"@date-fns/utc": "^1.2.0",
|
"@date-fns/utc": "^1.2.0",
|
||||||
"@dicebear/collection": "^9.2.3",
|
"@dicebear/collection": "^9.2.3",
|
||||||
"@dicebear/core": "^9.2.3",
|
"@dicebear/core": "^9.2.3",
|
||||||
"@fontsource/inter": "^4.5.15",
|
"@fontsource/inter": "^5.2.6",
|
||||||
"@hello-pangea/dnd": "^17.0.0",
|
"@hello-pangea/dnd": "^17.0.0",
|
||||||
"@prisma/adapter-pg": "^6.15.0",
|
"@prisma/adapter-pg": "^6.15.0",
|
||||||
"@prisma/client": "^6.15.0",
|
"@prisma/client": "^6.15.0",
|
||||||
"@prisma/extension-read-replicas": "^0.4.1",
|
"@prisma/extension-read-replicas": "^0.4.1",
|
||||||
"@react-spring/web": "^9.7.3",
|
"@react-spring/web": "^10.0.1",
|
||||||
"@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.169.0",
|
"@umami/react-zen": "^0.171.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",
|
||||||
|
|
@ -97,10 +97,10 @@
|
||||||
"debug": "^4.3.4",
|
"debug": "^4.3.4",
|
||||||
"del": "^6.0.0",
|
"del": "^6.0.0",
|
||||||
"detect-browser": "^5.2.0",
|
"detect-browser": "^5.2.0",
|
||||||
"dotenv": "^10.0.0",
|
"dotenv": "^17.2.1",
|
||||||
"eslint-plugin-promise": "^6.1.1",
|
"eslint-plugin-promise": "^6.1.1",
|
||||||
"fs-extra": "^10.0.1",
|
"fs-extra": "^11.3.1",
|
||||||
"immer": "^9.0.12",
|
"immer": "^10.1.1",
|
||||||
"ipaddr.js": "^2.0.1",
|
"ipaddr.js": "^2.0.1",
|
||||||
"is-ci": "^3.0.1",
|
"is-ci": "^3.0.1",
|
||||||
"is-docker": "^3.0.0",
|
"is-docker": "^3.0.0",
|
||||||
|
|
@ -109,8 +109,8 @@
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
"kafkajs": "^2.1.0",
|
"kafkajs": "^2.1.0",
|
||||||
"lucide-react": "^0.540.0",
|
"lucide-react": "^0.542.0",
|
||||||
"maxmind": "^4.3.28",
|
"maxmind": "^5.0.0",
|
||||||
"md5": "^2.3.0",
|
"md5": "^2.3.0",
|
||||||
"next": "15.5.2",
|
"next": "15.5.2",
|
||||||
"node-fetch": "^3.2.8",
|
"node-fetch": "^3.2.8",
|
||||||
|
|
@ -131,7 +131,7 @@
|
||||||
"serialize-error": "^12.0.0",
|
"serialize-error": "^12.0.0",
|
||||||
"thenby": "^1.3.4",
|
"thenby": "^1.3.4",
|
||||||
"uuid": "^11.1.0",
|
"uuid": "^11.1.0",
|
||||||
"zod": "^3.25.76",
|
"zod": "^4.1.5",
|
||||||
"zustand": "^5.0.8"
|
"zustand": "^5.0.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
@ -143,15 +143,15 @@
|
||||||
"@rollup/plugin-node-resolve": "^15.2.0",
|
"@rollup/plugin-node-resolve": "^15.2.0",
|
||||||
"@rollup/plugin-replace": "^5.0.2",
|
"@rollup/plugin-replace": "^5.0.2",
|
||||||
"@rollup/plugin-terser": "^0.4.4",
|
"@rollup/plugin-terser": "^0.4.4",
|
||||||
"@types/jest": "^29.5.14",
|
"@types/jest": "^30.0.0",
|
||||||
"@types/node": "^22.18.0",
|
"@types/node": "^24.3.0",
|
||||||
"@types/react": "^19.1.12",
|
"@types/react": "^19.1.12",
|
||||||
"@types/react-dom": "^19.1.8",
|
"@types/react-dom": "^19.1.8",
|
||||||
"@types/react-window": "^1.8.8",
|
"@types/react-window": "^1.8.8",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.41.0",
|
"@typescript-eslint/eslint-plugin": "^8.41.0",
|
||||||
"@typescript-eslint/parser": "^8.41.0",
|
"@typescript-eslint/parser": "^8.41.0",
|
||||||
"babel-plugin-react-compiler": "19.1.0-rc.2",
|
"babel-plugin-react-compiler": "19.1.0-rc.2",
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^10.0.0",
|
||||||
"cypress": "^13.6.6",
|
"cypress": "^13.6.6",
|
||||||
"esbuild": "^0.25.8",
|
"esbuild": "^0.25.8",
|
||||||
"eslint": "^8.33.0",
|
"eslint": "^8.33.0",
|
||||||
|
|
@ -164,9 +164,9 @@
|
||||||
"eslint-plugin-jest": "^27.9.0",
|
"eslint-plugin-jest": "^27.9.0",
|
||||||
"eslint-plugin-prettier": "^5.5.3",
|
"eslint-plugin-prettier": "^5.5.3",
|
||||||
"extract-react-intl-messages": "^4.1.1",
|
"extract-react-intl-messages": "^4.1.1",
|
||||||
"husky": "^8.0.3",
|
"husky": "^9.1.7",
|
||||||
"jest": "^29.7.0",
|
"jest": "^29.7.0",
|
||||||
"lint-staged": "^14.0.1",
|
"lint-staged": "^16.1.5",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"postcss-flexbugs-fixes": "^5.0.2",
|
"postcss-flexbugs-fixes": "^5.0.2",
|
||||||
"postcss-import": "^15.1.0",
|
"postcss-import": "^15.1.0",
|
||||||
|
|
@ -187,6 +187,6 @@
|
||||||
"tar": "^6.1.2",
|
"tar": "^6.1.2",
|
||||||
"ts-jest": "^29.4.0",
|
"ts-jest": "^29.4.0",
|
||||||
"ts-node": "^10.9.1",
|
"ts-node": "^10.9.1",
|
||||||
"typescript": "^5.5.3"
|
"typescript": "^5.9.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
869
pnpm-lock.yaml
generated
869
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
|
@ -33,7 +33,7 @@ export function LanguageSetting() {
|
||||||
allowSearch
|
allowSearch
|
||||||
onSearch={setSearch}
|
onSearch={setSearch}
|
||||||
onOpenChange={handleOpen}
|
onOpenChange={handleOpen}
|
||||||
listProps={{ style: { maxHeight: '300px' } }}
|
listProps={{ style: { maxHeight: 300 } }}
|
||||||
>
|
>
|
||||||
{items.map(item => (
|
{items.map(item => (
|
||||||
<ListItem key={item} id={item}>
|
<ListItem key={item} id={item}>
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ export function TimezoneSetting() {
|
||||||
allowSearch={true}
|
allowSearch={true}
|
||||||
onSearch={setSearch}
|
onSearch={setSearch}
|
||||||
onOpenChange={handleOpen}
|
onOpenChange={handleOpen}
|
||||||
listProps={{ style: { maxHeight: '300px' } }}
|
listProps={{ style: { maxHeight: 300 } }}
|
||||||
>
|
>
|
||||||
{items.map((item: any) => (
|
{items.map((item: any) => (
|
||||||
<ListItem key={item} id={item}>
|
<ListItem key={item} id={item}>
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import { useDateRange, useMessages } from '@/components/hooks';
|
||||||
|
|
||||||
export function AttributionPage({ websiteId }: { websiteId: string }) {
|
export function AttributionPage({ websiteId }: { websiteId: string }) {
|
||||||
const [model, setModel] = useState('first-click');
|
const [model, setModel] = useState('first-click');
|
||||||
const [type, setType] = useState('page');
|
const [type, setType] = useState('path');
|
||||||
const [step, setStep] = useState('/');
|
const [step, setStep] = useState('/');
|
||||||
const { formatMessage, labels } = useMessages();
|
const { formatMessage, labels } = useMessages();
|
||||||
const {
|
const {
|
||||||
|
|
@ -36,7 +36,7 @@ export function AttributionPage({ websiteId }: { websiteId: string }) {
|
||||||
defaultValue={type}
|
defaultValue={type}
|
||||||
onChange={setType}
|
onChange={setType}
|
||||||
>
|
>
|
||||||
<ListItem id="page">{formatMessage(labels.page)}</ListItem>
|
<ListItem id="path">{formatMessage(labels.page)}</ListItem>
|
||||||
<ListItem id="event">{formatMessage(labels.event)}</ListItem>
|
<ListItem id="event">{formatMessage(labels.event)}</ListItem>
|
||||||
</Select>
|
</Select>
|
||||||
</Column>
|
</Column>
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,7 @@ export function Funnel({ id, name, type, parameters, websiteId }) {
|
||||||
{ type, value, visitors, previous, dropped, dropoff, remaining }: FunnelResult,
|
{ type, value, visitors, previous, dropped, dropoff, remaining }: FunnelResult,
|
||||||
index: number,
|
index: number,
|
||||||
) => {
|
) => {
|
||||||
const isPage = type === 'page';
|
const isPage = type === 'path';
|
||||||
return (
|
return (
|
||||||
<Grid key={index} columns="auto 1fr" gap="6">
|
<Grid key={index} columns="auto 1fr" gap="6">
|
||||||
<Column alignItems="center" position="relative">
|
<Column alignItems="center" position="relative">
|
||||||
|
|
@ -92,7 +92,7 @@ export function Funnel({ id, name, type, parameters, websiteId }) {
|
||||||
</Row>
|
</Row>
|
||||||
<Row alignItems="center" justifyContent="space-between" gap>
|
<Row alignItems="center" justifyContent="space-between" gap>
|
||||||
<Row alignItems="center" gap>
|
<Row alignItems="center" gap>
|
||||||
<Icon>{type === 'page' ? <File /> : <Lightning />}</Icon>
|
<Icon>{type === 'path' ? <File /> : <Lightning />}</Icon>
|
||||||
<Text>{value}</Text>
|
<Text>{value}</Text>
|
||||||
</Row>
|
</Row>
|
||||||
<Row alignItems="center" gap>
|
<Row alignItems="center" gap>
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,7 @@ export function FunnelEditForm({
|
||||||
const defaultValues = {
|
const defaultValues = {
|
||||||
name: data?.name || '',
|
name: data?.name || '',
|
||||||
window: data?.parameters?.window || 60,
|
window: data?.parameters?.window || 60,
|
||||||
steps: data?.parameters?.steps || [{ type: 'page', value: '/' }],
|
steps: data?.parameters?.steps || [{ type: 'path', value: '/' }],
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -91,7 +91,7 @@ export function FunnelEditForm({
|
||||||
onChange={field.onChange}
|
onChange={field.onChange}
|
||||||
>
|
>
|
||||||
<Grid columns="1fr 1fr" flexGrow={1} gap>
|
<Grid columns="1fr 1fr" flexGrow={1} gap>
|
||||||
<Radio id="page" value="page">
|
<Radio id="path" value="path">
|
||||||
<Icon>
|
<Icon>
|
||||||
<File />
|
<File />
|
||||||
</Icon>
|
</Icon>
|
||||||
|
|
@ -130,7 +130,7 @@ export function FunnelEditForm({
|
||||||
})}
|
})}
|
||||||
<Row>
|
<Row>
|
||||||
<Button
|
<Button
|
||||||
onPress={() => append({ type: 'page', value: '/' })}
|
onPress={() => append({ type: 'path', value: '/' })}
|
||||||
isDisabled={fields.length >= FUNNEL_STEPS_MAX}
|
isDisabled={fields.length >= FUNNEL_STEPS_MAX}
|
||||||
>
|
>
|
||||||
<Icon>
|
<Icon>
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ export function Goal({ id, name, type, parameters, websiteId, startDate, endDate
|
||||||
endDate,
|
endDate,
|
||||||
...parameters,
|
...parameters,
|
||||||
});
|
});
|
||||||
const isPage = parameters?.type === 'page';
|
const isPage = parameters?.type === 'path';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LoadingPanel data={data} isLoading={isLoading} isFetching={isFetching} error={error}>
|
<LoadingPanel data={data} isLoading={isLoading} isFetching={isFetching} error={error}>
|
||||||
|
|
@ -68,7 +68,7 @@ export function Goal({ id, name, type, parameters, websiteId, startDate, endDate
|
||||||
</Row>
|
</Row>
|
||||||
<Row alignItems="center" justifyContent="space-between" gap>
|
<Row alignItems="center" justifyContent="space-between" gap>
|
||||||
<Row alignItems="center" gap>
|
<Row alignItems="center" gap>
|
||||||
<Icon>{parameters.type === 'page' ? <File /> : <Lightning />}</Icon>
|
<Icon>{parameters.type === 'path' ? <File /> : <Lightning />}</Icon>
|
||||||
<Text>{parameters.value}</Text>
|
<Text>{parameters.value}</Text>
|
||||||
</Row>
|
</Row>
|
||||||
<Row alignItems="center" gap>
|
<Row alignItems="center" gap>
|
||||||
|
|
|
||||||
|
|
@ -16,9 +16,10 @@ export function GoalAddButton({ websiteId }: { websiteId: string }) {
|
||||||
</Button>
|
</Button>
|
||||||
<Modal>
|
<Modal>
|
||||||
<Dialog
|
<Dialog
|
||||||
|
aria-label="add goal"
|
||||||
variant="modal"
|
variant="modal"
|
||||||
title={formatMessage(labels.goal)}
|
title={formatMessage(labels.goal)}
|
||||||
style={{ minHeight: 375, minWidth: 400 }}
|
style={{ minWidth: 800, minHeight: 300 }}
|
||||||
>
|
>
|
||||||
{({ close }) => <GoalEditForm websiteId={websiteId} onClose={close} />}
|
{({ close }) => <GoalEditForm websiteId={websiteId} onClose={close} />}
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
|
||||||
|
|
@ -6,14 +6,13 @@ import {
|
||||||
FormButtons,
|
FormButtons,
|
||||||
FormSubmitButton,
|
FormSubmitButton,
|
||||||
Button,
|
Button,
|
||||||
RadioGroup,
|
|
||||||
Radio,
|
|
||||||
Text,
|
|
||||||
Icon,
|
|
||||||
Loading,
|
Loading,
|
||||||
|
Column,
|
||||||
|
Label,
|
||||||
} from '@umami/react-zen';
|
} from '@umami/react-zen';
|
||||||
import { useMessages, useReportQuery, useUpdateQuery } from '@/components/hooks';
|
import { useMessages, useReportQuery, useUpdateQuery } from '@/components/hooks';
|
||||||
import { File, Lightning } from '@/components/icons';
|
import { LookupField } from '@/components/input/LookupField';
|
||||||
|
import { ActionSelect } from '@/components/input/ActionSelect';
|
||||||
|
|
||||||
export function GoalEditForm({
|
export function GoalEditForm({
|
||||||
id,
|
id,
|
||||||
|
|
@ -27,13 +26,12 @@ export function GoalEditForm({
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { formatMessage, labels } = useMessages();
|
const { formatMessage, labels } = useMessages();
|
||||||
|
|
||||||
const { data } = useReportQuery(id);
|
const { data } = useReportQuery(id);
|
||||||
const { mutate, error, isPending, touch } = useUpdateQuery(`/reports${id ? `/${id}` : ''}`);
|
const { mutate, error, isPending, touch } = useUpdateQuery(`/reports${id ? `/${id}` : ''}`);
|
||||||
|
|
||||||
const handleSubmit = async ({ name, ...parameters }) => {
|
const handleSubmit = async (formData: Record<string, any>) => {
|
||||||
mutate(
|
mutate(
|
||||||
{ ...data, id, name, type: 'goal', websiteId, parameters },
|
{ ...formData, type: 'goal', websiteId },
|
||||||
{
|
{
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
if (id) touch(`report:${id}`);
|
if (id) touch(`report:${id}`);
|
||||||
|
|
@ -50,15 +48,15 @@ export function GoalEditForm({
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultValues = {
|
const defaultValues = {
|
||||||
name: data?.name || '',
|
name: '',
|
||||||
type: data?.parameters?.type || 'page',
|
parameters: { type: 'path', value: '' },
|
||||||
value: data?.parameters?.value || '',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form onSubmit={handleSubmit} error={error?.message} defaultValues={defaultValues}>
|
<Form onSubmit={handleSubmit} error={error?.message} defaultValues={data || defaultValues}>
|
||||||
{({ watch }) => {
|
{({ watch }) => {
|
||||||
const watchType = watch('type');
|
const type = watch('parameters.type');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<FormField
|
<FormField
|
||||||
|
|
@ -68,35 +66,30 @@ export function GoalEditForm({
|
||||||
>
|
>
|
||||||
<TextField autoFocus />
|
<TextField autoFocus />
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField
|
<Column>
|
||||||
name="type"
|
<Label>{formatMessage(labels.action)}</Label>
|
||||||
label={formatMessage(labels.type)}
|
<Grid columns="260px 1fr" gap>
|
||||||
rules={{ required: formatMessage(labels.required) }}
|
<Column>
|
||||||
>
|
<FormField
|
||||||
<RadioGroup orientation="horizontal" variant="box">
|
name="parameters.type"
|
||||||
<Grid columns="1fr 1fr" flexGrow={1} gap>
|
rules={{ required: formatMessage(labels.required) }}
|
||||||
<Radio value="page">
|
>
|
||||||
<Icon>
|
<ActionSelect />
|
||||||
<File />
|
</FormField>
|
||||||
</Icon>
|
</Column>
|
||||||
<Text>{formatMessage(labels.page)}</Text>
|
<Column>
|
||||||
</Radio>
|
<FormField
|
||||||
<Radio value="event">
|
name="parameters.value"
|
||||||
<Icon>
|
rules={{ required: formatMessage(labels.required) }}
|
||||||
<Lightning />
|
>
|
||||||
</Icon>
|
{({ field }) => {
|
||||||
<Text>{formatMessage(labels.event)}</Text>
|
return <LookupField websiteId={websiteId} type={type} {...field} />;
|
||||||
</Radio>
|
}}
|
||||||
</Grid>
|
</FormField>
|
||||||
</RadioGroup>
|
</Column>
|
||||||
</FormField>
|
</Grid>
|
||||||
<FormField
|
</Column>
|
||||||
name="value"
|
|
||||||
label={formatMessage(watchType === 'event' ? labels.eventName : labels.path)}
|
|
||||||
rules={{ required: formatMessage(labels.required) }}
|
|
||||||
>
|
|
||||||
<TextField />
|
|
||||||
</FormField>
|
|
||||||
<FormButtons>
|
<FormButtons>
|
||||||
<Button onPress={onClose} isDisabled={isPending}>
|
<Button onPress={onClose} isDisabled={isPending}>
|
||||||
{formatMessage(labels.cancel)}
|
{formatMessage(labels.cancel)}
|
||||||
|
|
|
||||||
|
|
@ -52,12 +52,12 @@ export function WebsiteExpandedView({
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
id: 'referrer',
|
id: 'referrer',
|
||||||
label: formatMessage(labels.referrers),
|
label: formatMessage(labels.referrer),
|
||||||
path: updateParams({ view: 'referrer' }),
|
path: updateParams({ view: 'referrer' }),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'channel',
|
id: 'channel',
|
||||||
label: formatMessage(labels.channels),
|
label: formatMessage(labels.channel),
|
||||||
path: updateParams({ view: 'channel' }),
|
path: updateParams({ view: 'channel' }),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -72,17 +72,17 @@ export function WebsiteExpandedView({
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
id: 'country',
|
id: 'country',
|
||||||
label: formatMessage(labels.countries),
|
label: formatMessage(labels.country),
|
||||||
path: updateParams({ view: 'country' }),
|
path: updateParams({ view: 'country' }),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'region',
|
id: 'region',
|
||||||
label: formatMessage(labels.regions),
|
label: formatMessage(labels.region),
|
||||||
path: updateParams({ view: 'region' }),
|
path: updateParams({ view: 'region' }),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'city',
|
id: 'city',
|
||||||
label: formatMessage(labels.cities),
|
label: formatMessage(labels.city),
|
||||||
path: updateParams({ view: 'city' }),
|
path: updateParams({ view: 'city' }),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
@ -92,7 +92,7 @@ export function WebsiteExpandedView({
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
id: 'browser',
|
id: 'browser',
|
||||||
label: formatMessage(labels.browsers),
|
label: formatMessage(labels.browser),
|
||||||
path: updateParams({ view: 'browser' }),
|
path: updateParams({ view: 'browser' }),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -102,17 +102,17 @@ export function WebsiteExpandedView({
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'device',
|
id: 'device',
|
||||||
label: formatMessage(labels.devices),
|
label: formatMessage(labels.device),
|
||||||
path: updateParams({ view: 'device' }),
|
path: updateParams({ view: 'device' }),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'language',
|
id: 'language',
|
||||||
label: formatMessage(labels.languages),
|
label: formatMessage(labels.language),
|
||||||
path: updateParams({ view: 'language' }),
|
path: updateParams({ view: 'language' }),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'screen',
|
id: 'screen',
|
||||||
label: formatMessage(labels.screens),
|
label: formatMessage(labels.screen),
|
||||||
path: updateParams({ view: 'screen' }),
|
path: updateParams({ view: 'screen' }),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
@ -122,7 +122,7 @@ export function WebsiteExpandedView({
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
id: 'event',
|
id: 'event',
|
||||||
label: formatMessage(labels.events),
|
label: formatMessage(labels.event),
|
||||||
path: updateParams({ view: 'event' }),
|
path: updateParams({ view: 'event' }),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -132,7 +132,7 @@ export function WebsiteExpandedView({
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'tag',
|
id: 'tag',
|
||||||
label: formatMessage(labels.tags),
|
label: formatMessage(labels.tag),
|
||||||
path: updateParams({ view: 'tag' }),
|
path: updateParams({ view: 'tag' }),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { Button, Icon, DialogTrigger, Dialog, Modal, Text } from '@umami/react-zen';
|
import { Button, Icon, DialogTrigger, Dialog, Modal, Text } from '@umami/react-zen';
|
||||||
import { ListFilter } from '@/components/icons';
|
import { ListFilter } from '@/components/icons';
|
||||||
import { FilterEditForm } from '@/components/input/FilterEditForm';
|
import { FilterEditForm } from '@/components/input/FilterEditForm';
|
||||||
import { useMessages, useNavigation, useFilters } from '@/components/hooks';
|
import { useMessages, useNavigation } from '@/components/hooks';
|
||||||
import { filtersArrayToObject } from '@/lib/params';
|
import { filtersArrayToObject } from '@/lib/params';
|
||||||
|
|
||||||
export function WebsiteFilterButton({
|
export function WebsiteFilterButton({
|
||||||
|
|
@ -14,17 +14,12 @@ export function WebsiteFilterButton({
|
||||||
showText?: boolean;
|
showText?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { formatMessage, labels } = useMessages();
|
const { formatMessage, labels } = useMessages();
|
||||||
const {
|
const { replaceParams, router } = useNavigation();
|
||||||
replaceParams,
|
|
||||||
router,
|
|
||||||
query: { segment },
|
|
||||||
} = useNavigation();
|
|
||||||
const { filters } = useFilters();
|
|
||||||
|
|
||||||
const handleChange = ({ filters, segment }) => {
|
const handleChange = ({ filters, segment, cohort }: any) => {
|
||||||
const params = filtersArrayToObject(filters);
|
const params = filtersArrayToObject(filters);
|
||||||
|
|
||||||
const url = replaceParams({ ...params, segment });
|
const url = replaceParams({ ...params, segment, cohort });
|
||||||
|
|
||||||
router.push(url);
|
router.push(url);
|
||||||
};
|
};
|
||||||
|
|
@ -40,15 +35,7 @@ export function WebsiteFilterButton({
|
||||||
<Modal>
|
<Modal>
|
||||||
<Dialog title={formatMessage(labels.filters)} style={{ width: 800, minHeight: 600 }}>
|
<Dialog title={formatMessage(labels.filters)} style={{ width: 800, minHeight: 600 }}>
|
||||||
{({ close }) => {
|
{({ close }) => {
|
||||||
return (
|
return <FilterEditForm websiteId={websiteId} onChange={handleChange} onClose={close} />;
|
||||||
<FilterEditForm
|
|
||||||
websiteId={websiteId}
|
|
||||||
filters={filters}
|
|
||||||
segmentId={segment}
|
|
||||||
onChange={handleChange}
|
|
||||||
onClose={close}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}}
|
}}
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ import { WebsiteControls } from './WebsiteControls';
|
||||||
export function WebsitePage({ websiteId }: { websiteId: string }) {
|
export function WebsitePage({ websiteId }: { websiteId: string }) {
|
||||||
const {
|
const {
|
||||||
router,
|
router,
|
||||||
query: { view, compare },
|
query: { view },
|
||||||
updateParams,
|
updateParams,
|
||||||
} = useNavigation();
|
} = useNavigation();
|
||||||
const handleClose = (close: () => void) => {
|
const handleClose = (close: () => void) => {
|
||||||
|
|
@ -30,7 +30,7 @@ export function WebsitePage({ websiteId }: { websiteId: string }) {
|
||||||
<WebsiteControls websiteId={websiteId} />
|
<WebsiteControls websiteId={websiteId} />
|
||||||
<WebsiteMetricsBar websiteId={websiteId} showChange={true} />
|
<WebsiteMetricsBar websiteId={websiteId} showChange={true} />
|
||||||
<Panel minHeight="520px">
|
<Panel minHeight="520px">
|
||||||
<WebsiteChart websiteId={websiteId} compareMode={compare} />
|
<WebsiteChart websiteId={websiteId} />
|
||||||
</Panel>
|
</Panel>
|
||||||
<WebsitePanels websiteId={websiteId} />
|
<WebsitePanels websiteId={websiteId} />
|
||||||
<Modal isOpen={!!view} onOpenChange={handleOpenChange} isDismissable>
|
<Modal isOpen={!!view} onOpenChange={handleOpenChange} isDismissable>
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ export function WebsitePanels({ websiteId }: { websiteId: string }) {
|
||||||
showMore: true,
|
showMore: true,
|
||||||
metric: formatMessage(labels.visitors),
|
metric: formatMessage(labels.visitors),
|
||||||
};
|
};
|
||||||
const rowProps = { minHeight: 570 };
|
const rowProps = { minHeight: '570px' };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Grid gap="3">
|
<Grid gap="3">
|
||||||
|
|
@ -59,30 +59,7 @@ export function WebsitePanels({ websiteId }: { websiteId: string }) {
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</Panel>
|
</Panel>
|
||||||
</GridRow>
|
</GridRow>
|
||||||
<GridRow layout="two-one" {...rowProps}>
|
|
||||||
<Panel gridColumn="span 2" noPadding>
|
|
||||||
<WorldMap websiteId={websiteId} />
|
|
||||||
</Panel>
|
|
||||||
<Panel>
|
|
||||||
<Heading size="2">{formatMessage(labels.location)}</Heading>
|
|
||||||
<Tabs>
|
|
||||||
<TabList>
|
|
||||||
<Tab id="country">{formatMessage(labels.countries)}</Tab>
|
|
||||||
<Tab id="region">{formatMessage(labels.regions)}</Tab>
|
|
||||||
<Tab id="city">{formatMessage(labels.cities)}</Tab>
|
|
||||||
</TabList>
|
|
||||||
<TabPanel id="country">
|
|
||||||
<MetricsTable type="country" title={formatMessage(labels.country)} {...tableProps} />
|
|
||||||
</TabPanel>
|
|
||||||
<TabPanel id="region">
|
|
||||||
<MetricsTable type="region" title={formatMessage(labels.region)} {...tableProps} />
|
|
||||||
</TabPanel>
|
|
||||||
<TabPanel id="city">
|
|
||||||
<MetricsTable type="city" title={formatMessage(labels.city)} {...tableProps} />
|
|
||||||
</TabPanel>
|
|
||||||
</Tabs>
|
|
||||||
</Panel>
|
|
||||||
</GridRow>
|
|
||||||
<GridRow layout="two" {...rowProps}>
|
<GridRow layout="two" {...rowProps}>
|
||||||
<Panel>
|
<Panel>
|
||||||
<Heading size="2">{formatMessage(labels.environment)}</Heading>
|
<Heading size="2">{formatMessage(labels.environment)}</Heading>
|
||||||
|
|
@ -103,6 +80,33 @@ export function WebsitePanels({ websiteId }: { websiteId: string }) {
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
|
<Panel>
|
||||||
|
<Heading size="2">{formatMessage(labels.location)}</Heading>
|
||||||
|
<Tabs>
|
||||||
|
<TabList>
|
||||||
|
<Tab id="country">{formatMessage(labels.countries)}</Tab>
|
||||||
|
<Tab id="region">{formatMessage(labels.regions)}</Tab>
|
||||||
|
<Tab id="city">{formatMessage(labels.cities)}</Tab>
|
||||||
|
</TabList>
|
||||||
|
<TabPanel id="country">
|
||||||
|
<MetricsTable type="country" title={formatMessage(labels.country)} {...tableProps} />
|
||||||
|
</TabPanel>
|
||||||
|
<TabPanel id="region">
|
||||||
|
<MetricsTable type="region" title={formatMessage(labels.region)} {...tableProps} />
|
||||||
|
</TabPanel>
|
||||||
|
<TabPanel id="city">
|
||||||
|
<MetricsTable type="city" title={formatMessage(labels.city)} {...tableProps} />
|
||||||
|
</TabPanel>
|
||||||
|
</Tabs>
|
||||||
|
</Panel>
|
||||||
|
</GridRow>
|
||||||
|
|
||||||
|
<GridRow layout="two-one" {...rowProps}>
|
||||||
|
<Panel gridColumn="span 2" noPadding>
|
||||||
|
<WorldMap websiteId={websiteId} />
|
||||||
|
</Panel>
|
||||||
|
|
||||||
<Panel>
|
<Panel>
|
||||||
<Heading size="2">{formatMessage(labels.traffic)}</Heading>
|
<Heading size="2">{formatMessage(labels.traffic)}</Heading>
|
||||||
<Row border="bottom" marginBottom="4" />
|
<Row border="bottom" marginBottom="4" />
|
||||||
|
|
|
||||||
|
|
@ -15,10 +15,7 @@ export function CohortAddButton({ websiteId }: { websiteId: string }) {
|
||||||
<Text>{formatMessage(labels.cohort)}</Text>
|
<Text>{formatMessage(labels.cohort)}</Text>
|
||||||
</Button>
|
</Button>
|
||||||
<Modal>
|
<Modal>
|
||||||
<Dialog
|
<Dialog title={formatMessage(labels.cohort)} style={{ width: 800, minHeight: 300 }}>
|
||||||
title={formatMessage(labels.cohort)}
|
|
||||||
style={{ width: 800, minHeight: 300, maxHeight: '90vh' }}
|
|
||||||
>
|
|
||||||
{({ close }) => {
|
{({ close }) => {
|
||||||
return <CohortEditForm websiteId={websiteId} onClose={close} />;
|
return <CohortEditForm websiteId={websiteId} onClose={close} />;
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -18,10 +18,7 @@ export function CohortEditButton({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ActionButton title={formatMessage(labels.edit)} icon={<Edit />}>
|
<ActionButton title={formatMessage(labels.edit)} icon={<Edit />}>
|
||||||
<Dialog
|
<Dialog title={formatMessage(labels.cohort)} style={{ width: 800, minHeight: 300 }}>
|
||||||
title={formatMessage(labels.cohort)}
|
|
||||||
style={{ width: 800, minHeight: 300, maxHeight: '90vh' }}
|
|
||||||
>
|
|
||||||
{({ close }) => {
|
{({ close }) => {
|
||||||
return (
|
return (
|
||||||
<CohortEditForm
|
<CohortEditForm
|
||||||
|
|
|
||||||
|
|
@ -8,23 +8,13 @@ import {
|
||||||
Label,
|
Label,
|
||||||
Loading,
|
Loading,
|
||||||
Column,
|
Column,
|
||||||
ComboBox,
|
|
||||||
Select,
|
|
||||||
ListItem,
|
|
||||||
Grid,
|
Grid,
|
||||||
useDebounce,
|
|
||||||
} from '@umami/react-zen';
|
} from '@umami/react-zen';
|
||||||
import {
|
import { useMessages, useUpdateQuery, useWebsiteCohortQuery } from '@/components/hooks';
|
||||||
useMessages,
|
|
||||||
useUpdateQuery,
|
|
||||||
useWebsiteCohortQuery,
|
|
||||||
useWebsiteValuesQuery,
|
|
||||||
} from '@/components/hooks';
|
|
||||||
import { DateFilter } from '@/components/input/DateFilter';
|
import { DateFilter } from '@/components/input/DateFilter';
|
||||||
import { FieldFilters } from '@/components/input/FieldFilters';
|
import { FieldFilters } from '@/components/input/FieldFilters';
|
||||||
import { SetStateAction, useMemo, useState } from 'react';
|
import { LookupField } from '@/components/input/LookupField';
|
||||||
import { endOfDay, subMonths } from 'date-fns';
|
import { ActionSelect } from '@/components/input/ActionSelect';
|
||||||
import { Empty } from '@/components/common/Empty';
|
|
||||||
|
|
||||||
export function CohortEditForm({
|
export function CohortEditForm({
|
||||||
cohortId,
|
cohortId,
|
||||||
|
|
@ -40,21 +30,8 @@ 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 startDate = subMonths(endOfDay(new Date()), 6);
|
|
||||||
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}/segments${cohortId ? `/${cohortId}` : ''}`,
|
`/websites/${websiteId}/segments${cohortId ? `/${cohortId}` : ''}`,
|
||||||
|
|
@ -63,10 +40,6 @@ export function CohortEditForm({
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const items: string[] = useMemo(() => {
|
|
||||||
return searchResults?.map(({ value }) => value) || [];
|
|
||||||
}, [searchResults]);
|
|
||||||
|
|
||||||
const handleSubmit = async (formData: any) => {
|
const handleSubmit = async (formData: any) => {
|
||||||
mutate(formData, {
|
mutate(formData, {
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
|
|
@ -78,105 +51,84 @@ export function CohortEditForm({
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSearch = (value: SetStateAction<string>) => {
|
|
||||||
setSearch(value);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (cohortId && !data) {
|
if (cohortId && !data) {
|
||||||
return <Loading position="page" />;
|
return <Loading position="page" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const defaultValues = {
|
||||||
|
parameters: { filters, dateRange: '30day', action: { type: 'path', value: '' } },
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form
|
<Form error={error} onSubmit={handleSubmit} defaultValues={data || defaultValues}>
|
||||||
error={error}
|
{({ watch }) => {
|
||||||
onSubmit={handleSubmit}
|
const type = watch('parameters.action.type');
|
||||||
defaultValues={
|
|
||||||
data || { parameters: { filters, dateRange: '30day', action: { type: 'path' } } }
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<FormField
|
|
||||||
name="name"
|
|
||||||
label={formatMessage(labels.name)}
|
|
||||||
rules={{ required: formatMessage(labels.required) }}
|
|
||||||
>
|
|
||||||
<TextField autoFocus />
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
<Column>
|
return (
|
||||||
<Label>{formatMessage(labels.action)}</Label>
|
<>
|
||||||
<Grid columns="260px 1fr" gap>
|
|
||||||
<Column>
|
|
||||||
<FormField
|
<FormField
|
||||||
name="parameters.action.type"
|
name="name"
|
||||||
|
label={formatMessage(labels.name)}
|
||||||
rules={{ required: formatMessage(labels.required) }}
|
rules={{ required: formatMessage(labels.required) }}
|
||||||
>
|
>
|
||||||
<Select onSelectionChange={(value: any) => setAction(value)}>
|
<TextField autoFocus />
|
||||||
<ListItem id="path">{formatMessage(labels.viewedPage)}</ListItem>
|
|
||||||
<ListItem id="event">{formatMessage(labels.triggeredEvent)}</ListItem>
|
|
||||||
</Select>
|
|
||||||
</FormField>
|
</FormField>
|
||||||
</Column>
|
|
||||||
<Column>
|
<Column>
|
||||||
<FormField
|
<Label>{formatMessage(labels.action)}</Label>
|
||||||
name="parameters.action.value"
|
<Grid columns="260px 1fr" gap>
|
||||||
rules={{ required: formatMessage(labels.required) }}
|
<Column>
|
||||||
>
|
<FormField
|
||||||
{({ field }) => {
|
name="parameters.action.type"
|
||||||
return (
|
rules={{ required: formatMessage(labels.required) }}
|
||||||
<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 => (
|
<ActionSelect />
|
||||||
<ListItem key={item} id={item}>
|
</FormField>
|
||||||
{item}
|
</Column>
|
||||||
</ListItem>
|
<Column>
|
||||||
))}
|
<FormField
|
||||||
</ComboBox>
|
name="parameters.action.value"
|
||||||
);
|
rules={{ required: formatMessage(labels.required) }}
|
||||||
}}
|
>
|
||||||
</FormField>
|
{({ field }) => {
|
||||||
</Column>
|
return <LookupField websiteId={websiteId} type={type} {...field} />;
|
||||||
</Grid>
|
}}
|
||||||
</Column>
|
</FormField>
|
||||||
|
</Column>
|
||||||
|
</Grid>
|
||||||
|
</Column>
|
||||||
|
|
||||||
<Column width="260px">
|
<Column width="260px">
|
||||||
<Label>{formatMessage(labels.dateRange)}</Label>
|
<Label>{formatMessage(labels.dateRange)}</Label>
|
||||||
<FormField name="parameters.dateRange" rules={{ required: formatMessage(labels.required) }}>
|
<FormField
|
||||||
<DateFilter placement="bottom start" />
|
name="parameters.dateRange"
|
||||||
</FormField>
|
rules={{ required: formatMessage(labels.required) }}
|
||||||
</Column>
|
>
|
||||||
|
<DateFilter placement="bottom start" />
|
||||||
|
</FormField>
|
||||||
|
</Column>
|
||||||
|
|
||||||
<Column>
|
<Column>
|
||||||
<Label>{formatMessage(labels.filters)}</Label>
|
<Label>{formatMessage(labels.filters)}</Label>
|
||||||
<FormField name="parameters.filters" rules={{ required: formatMessage(labels.required) }}>
|
<FormField
|
||||||
<FieldFilters websiteId={websiteId} exclude={['path', 'event']} />
|
name="parameters.filters"
|
||||||
</FormField>
|
rules={{ required: formatMessage(labels.required) }}
|
||||||
</Column>
|
>
|
||||||
|
<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 variant="primary" data-test="button-submit" isDisabled={isPending}>
|
<FormSubmitButton variant="primary" data-test="button-submit" isDisabled={isPending}>
|
||||||
{formatMessage(labels.save)}
|
{formatMessage(labels.save)}
|
||||||
</FormSubmitButton>
|
</FormSubmitButton>
|
||||||
</FormButtons>
|
</FormButtons>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}}
|
||||||
</Form>
|
</Form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,10 +15,7 @@ export function SegmentAddButton({ websiteId }: { websiteId: string }) {
|
||||||
<Text>{formatMessage(labels.segment)}</Text>
|
<Text>{formatMessage(labels.segment)}</Text>
|
||||||
</Button>
|
</Button>
|
||||||
<Modal>
|
<Modal>
|
||||||
<Dialog
|
<Dialog title={formatMessage(labels.segment)} style={{ width: 800, minHeight: 300 }}>
|
||||||
title={formatMessage(labels.segment)}
|
|
||||||
style={{ width: 800, minHeight: 300, maxHeight: '90vh' }}
|
|
||||||
>
|
|
||||||
{({ close }) => {
|
{({ close }) => {
|
||||||
return <SegmentEditForm websiteId={websiteId} onClose={close} />;
|
return <SegmentEditForm websiteId={websiteId} onClose={close} />;
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
|
import { Dialog } from '@umami/react-zen';
|
||||||
import { ActionButton } from '@/components/input/ActionButton';
|
import { ActionButton } from '@/components/input/ActionButton';
|
||||||
import { Edit } from '@/components/icons';
|
import { Edit } from '@/components/icons';
|
||||||
import { Dialog } from '@umami/react-zen';
|
|
||||||
import { SegmentEditForm } from '@/app/(main)/websites/[websiteId]/segments/SegmentEditForm';
|
|
||||||
import { useMessages } from '@/components/hooks';
|
import { useMessages } from '@/components/hooks';
|
||||||
|
import { SegmentEditForm } from './SegmentEditForm';
|
||||||
import { Filter } from '@/lib/types';
|
import { Filter } from '@/lib/types';
|
||||||
|
|
||||||
export function SegmentEditButton({
|
export function SegmentEditButton({
|
||||||
|
|
@ -18,10 +18,7 @@ export function SegmentEditButton({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ActionButton title={formatMessage(labels.edit)} icon={<Edit />}>
|
<ActionButton title={formatMessage(labels.edit)} icon={<Edit />}>
|
||||||
<Dialog
|
<Dialog title={formatMessage(labels.segment)} style={{ width: 800, minHeight: 300 }}>
|
||||||
title={formatMessage(labels.segment)}
|
|
||||||
style={{ width: 800, minHeight: 300, maxHeight: '90vh' }}
|
|
||||||
>
|
|
||||||
{({ close }) => {
|
{({ close }) => {
|
||||||
return (
|
return (
|
||||||
<SegmentEditForm
|
<SegmentEditForm
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,7 @@ export async function POST(
|
||||||
type,
|
type,
|
||||||
name,
|
name,
|
||||||
description,
|
description,
|
||||||
parameters: parameters,
|
parameters,
|
||||||
} as any);
|
} as any);
|
||||||
|
|
||||||
return json(result);
|
return json(result);
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ export interface LoadingPanelProps extends ColumnProps {
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
isFetching?: boolean;
|
isFetching?: boolean;
|
||||||
loadingIcon?: 'dots' | 'spinner';
|
loadingIcon?: 'dots' | 'spinner';
|
||||||
|
loadingPosition?: 'center' | 'page' | 'inline';
|
||||||
renderEmpty?: () => ReactNode;
|
renderEmpty?: () => ReactNode;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
@ -21,6 +22,7 @@ export function LoadingPanel({
|
||||||
isLoading,
|
isLoading,
|
||||||
isFetching,
|
isFetching,
|
||||||
loadingIcon = 'dots',
|
loadingIcon = 'dots',
|
||||||
|
loadingPosition = 'page',
|
||||||
renderEmpty = () => <Empty />,
|
renderEmpty = () => <Empty />,
|
||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
|
|
@ -32,7 +34,7 @@ export function LoadingPanel({
|
||||||
{/* Show loading spinner only if no data exists */}
|
{/* Show loading spinner only if no data exists */}
|
||||||
{(isLoading || isFetching) && (
|
{(isLoading || isFetching) && (
|
||||||
<Column position="relative" height="100%" {...props}>
|
<Column position="relative" height="100%" {...props}>
|
||||||
<Loading icon={loadingIcon} position="page" />
|
<Loading icon={loadingIcon} position={loadingPosition} />
|
||||||
</Column>
|
</Column>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ export function ActionButton({
|
||||||
title,
|
title,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
onSave?: () => void;
|
onClick?: () => void;
|
||||||
icon?: ReactNode;
|
icon?: ReactNode;
|
||||||
title?: string;
|
title?: string;
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
|
|
|
||||||
18
src/components/input/ActionSelect.tsx
Normal file
18
src/components/input/ActionSelect.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { Select, ListItem } from '@umami/react-zen';
|
||||||
|
import { useMessages } from '@/components/hooks';
|
||||||
|
|
||||||
|
export interface ActionSelectProps {
|
||||||
|
value?: string;
|
||||||
|
onChange?: (value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ActionSelect({ value = 'path', onChange }: ActionSelectProps) {
|
||||||
|
const { formatMessage, labels } = useMessages();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Select value={value} onChange={onChange}>
|
||||||
|
<ListItem id="path">{formatMessage(labels.viewedPage)}</ListItem>
|
||||||
|
<ListItem id="event">{formatMessage(labels.triggeredEvent)}</ListItem>
|
||||||
|
</Select>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -14,7 +14,7 @@ export function CurrencySelect({ value, onChange }) {
|
||||||
value={value}
|
value={value}
|
||||||
defaultValue={value}
|
defaultValue={value}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
listProps={{ style: { maxHeight: '300px' } }}
|
listProps={{ style: { maxHeight: 300 } }}
|
||||||
onSearch={setSearch}
|
onSearch={setSearch}
|
||||||
allowSearch
|
allowSearch
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -30,8 +30,8 @@ export function FilterBar({ websiteId }: { websiteId: string }) {
|
||||||
query: { segment, cohort },
|
query: { segment, cohort },
|
||||||
} = useNavigation();
|
} = useNavigation();
|
||||||
const { filters, operatorLabels } = useFilters();
|
const { filters, operatorLabels } = useFilters();
|
||||||
const { data, isLoading } = useWebsiteSegmentQuery(websiteId, segment);
|
const { data, isLoading } = useWebsiteSegmentQuery(websiteId, segment || cohort);
|
||||||
const canSave = filters.length > 0 && !segment && !cohort;
|
const canSaveSegment = filters.length > 0 && !segment && !cohort;
|
||||||
|
|
||||||
const handleCloseFilter = (param: string) => {
|
const handleCloseFilter = (param: string) => {
|
||||||
router.push(updateParams({ [param]: undefined }));
|
router.push(updateParams({ [param]: undefined }));
|
||||||
|
|
@ -41,11 +41,11 @@ export function FilterBar({ websiteId }: { websiteId: string }) {
|
||||||
router.push(replaceParams());
|
router.push(replaceParams());
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSegmentRemove = () => {
|
const handleSegmentRemove = (type: string) => {
|
||||||
router.push(updateParams({ segment: undefined }));
|
router.push(updateParams({ [type]: undefined }));
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!filters.length && !segment) {
|
if (!filters.length && !segment && !cohort) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -58,7 +58,16 @@ export function FilterBar({ websiteId }: { websiteId: string }) {
|
||||||
label={formatMessage(labels.segment)}
|
label={formatMessage(labels.segment)}
|
||||||
value={data?.name || segment}
|
value={data?.name || segment}
|
||||||
operator={operatorLabels.eq}
|
operator={operatorLabels.eq}
|
||||||
onRemove={handleSegmentRemove}
|
onRemove={() => handleSegmentRemove('segment')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{cohort && !isLoading && (
|
||||||
|
<FilterItem
|
||||||
|
name="cohort"
|
||||||
|
label={formatMessage(labels.cohort)}
|
||||||
|
value={data?.name || cohort}
|
||||||
|
operator={operatorLabels.eq}
|
||||||
|
onRemove={() => handleSegmentRemove('cohort')}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{filters.map(filter => {
|
{filters.map(filter => {
|
||||||
|
|
@ -79,7 +88,7 @@ export function FilterBar({ websiteId }: { websiteId: string }) {
|
||||||
</Row>
|
</Row>
|
||||||
<Row alignItems="center">
|
<Row alignItems="center">
|
||||||
<DialogTrigger>
|
<DialogTrigger>
|
||||||
{canSave && (
|
{canSaveSegment && (
|
||||||
<TooltipTrigger delay={0}>
|
<TooltipTrigger delay={0}>
|
||||||
<Button variant="zero">
|
<Button variant="zero">
|
||||||
<Icon>
|
<Icon>
|
||||||
|
|
|
||||||
|
|
@ -1,40 +1,43 @@
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Column, Tabs, TabList, Tab, TabPanel, Row, Button } from '@umami/react-zen';
|
import { Column, Tabs, TabList, Tab, TabPanel, Row, Button } from '@umami/react-zen';
|
||||||
import { useMessages } from '@/components/hooks';
|
import { useFilters, useMessages, useNavigation } from '@/components/hooks';
|
||||||
import { FieldFilters } from '@/components/input/FieldFilters';
|
import { FieldFilters } from '@/components/input/FieldFilters';
|
||||||
import { SegmentFilters } from '@/components/input/SegmentFilters';
|
import { SegmentFilters } from '@/components/input/SegmentFilters';
|
||||||
|
|
||||||
export interface FilterEditFormProps {
|
export interface FilterEditFormProps {
|
||||||
websiteId?: string;
|
websiteId?: string;
|
||||||
filters: any[];
|
onChange?: (params: { filters: any[]; segment?: string; cohort?: string }) => void;
|
||||||
segmentId?: string;
|
|
||||||
onChange?: (params: { filters: any[]; segment: any }) => void;
|
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FilterEditForm({
|
export function FilterEditForm({ websiteId, onChange, onClose }: FilterEditFormProps) {
|
||||||
websiteId,
|
const {
|
||||||
filters = [],
|
query: { segment, cohort },
|
||||||
segmentId,
|
} = useNavigation();
|
||||||
onChange,
|
const { filters } = useFilters();
|
||||||
onClose,
|
|
||||||
}: FilterEditFormProps) {
|
|
||||||
const { formatMessage, labels } = useMessages();
|
const { formatMessage, labels } = useMessages();
|
||||||
const [currentFilters, setCurrentFilters] = useState(filters);
|
const [currentFilters, setCurrentFilters] = useState(filters);
|
||||||
const [currentSegment, setCurrentSegment] = useState(segmentId);
|
const [currentSegment, setCurrentSegment] = useState(segment);
|
||||||
|
const [currentCohort, setCurrentCohort] = useState(cohort);
|
||||||
|
|
||||||
const handleReset = () => {
|
const handleReset = () => {
|
||||||
setCurrentFilters([]);
|
setCurrentFilters([]);
|
||||||
setCurrentSegment(null);
|
setCurrentSegment(undefined);
|
||||||
|
setCurrentCohort(undefined);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
onChange?.({ filters: currentFilters.filter(f => f.value), segment: currentSegment });
|
onChange?.({
|
||||||
|
filters: currentFilters.filter(f => f.value),
|
||||||
|
segment: currentSegment,
|
||||||
|
cohort: currentCohort,
|
||||||
|
});
|
||||||
onClose?.();
|
onClose?.();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSegmentChange = (id: string) => {
|
const handleSegmentChange = (id: string, type: string) => {
|
||||||
setCurrentSegment(id);
|
setCurrentSegment(type === 'segment' ? id : undefined);
|
||||||
|
setCurrentCohort(type === 'cohort' ? id : undefined);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -43,6 +46,7 @@ export function FilterEditForm({
|
||||||
<TabList>
|
<TabList>
|
||||||
<Tab id="fields">{formatMessage(labels.fields)}</Tab>
|
<Tab id="fields">{formatMessage(labels.fields)}</Tab>
|
||||||
<Tab id="segments">{formatMessage(labels.segments)}</Tab>
|
<Tab id="segments">{formatMessage(labels.segments)}</Tab>
|
||||||
|
<Tab id="cohorts">{formatMessage(labels.cohorts)}</Tab>
|
||||||
</TabList>
|
</TabList>
|
||||||
<TabPanel id="fields">
|
<TabPanel id="fields">
|
||||||
<FieldFilters websiteId={websiteId} value={currentFilters} onChange={setCurrentFilters} />
|
<FieldFilters websiteId={websiteId} value={currentFilters} onChange={setCurrentFilters} />
|
||||||
|
|
@ -51,7 +55,15 @@ export function FilterEditForm({
|
||||||
<SegmentFilters
|
<SegmentFilters
|
||||||
websiteId={websiteId}
|
websiteId={websiteId}
|
||||||
segmentId={currentSegment}
|
segmentId={currentSegment}
|
||||||
onSave={handleSegmentChange}
|
onChange={handleSegmentChange}
|
||||||
|
/>
|
||||||
|
</TabPanel>
|
||||||
|
<TabPanel id="cohorts" style={{ height: 400 }}>
|
||||||
|
<SegmentFilters
|
||||||
|
type="cohort"
|
||||||
|
websiteId={websiteId}
|
||||||
|
segmentId={currentCohort}
|
||||||
|
onChange={handleSegmentChange}
|
||||||
/>
|
/>
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
|
||||||
65
src/components/input/LookupField.tsx
Normal file
65
src/components/input/LookupField.tsx
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
import { SetStateAction, useMemo, useState } from 'react';
|
||||||
|
import { endOfDay, subMonths } from 'date-fns';
|
||||||
|
import { ComboBox, ListItem, Loading, useDebounce, ComboBoxProps } from '@umami/react-zen';
|
||||||
|
import { Empty } from '@/components/common/Empty';
|
||||||
|
import { useMessages, useWebsiteValuesQuery } from '@/components/hooks';
|
||||||
|
|
||||||
|
export interface LookupFieldProps extends ComboBoxProps {
|
||||||
|
websiteId: string;
|
||||||
|
type: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LookupField({ websiteId, type, value, onChange, ...props }: LookupFieldProps) {
|
||||||
|
const { formatMessage, messages } = useMessages();
|
||||||
|
const [search, setSearch] = useState(value);
|
||||||
|
const searchValue = useDebounce(search, 300);
|
||||||
|
const startDate = subMonths(endOfDay(new Date()), 6);
|
||||||
|
const endDate = endOfDay(new Date());
|
||||||
|
|
||||||
|
const { data, isLoading } = useWebsiteValuesQuery({
|
||||||
|
websiteId,
|
||||||
|
type,
|
||||||
|
search: searchValue,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
});
|
||||||
|
|
||||||
|
const items: string[] = useMemo(() => {
|
||||||
|
return data?.map(({ value }) => value) || [];
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
const handleSearch = (value: SetStateAction<string>) => {
|
||||||
|
setSearch(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ComboBox
|
||||||
|
aria-label="LookupField"
|
||||||
|
{...props}
|
||||||
|
items={items}
|
||||||
|
inputValue={value}
|
||||||
|
onInputChange={value => {
|
||||||
|
handleSearch(value);
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -5,14 +5,20 @@ import { LoadingPanel } from '@/components/common/LoadingPanel';
|
||||||
export interface SegmentFiltersProps {
|
export interface SegmentFiltersProps {
|
||||||
websiteId: string;
|
websiteId: string;
|
||||||
segmentId: string;
|
segmentId: string;
|
||||||
onSave?: (data: any) => void;
|
type?: string;
|
||||||
|
onChange?: (id: string, type: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SegmentFilters({ websiteId, segmentId, onSave }: SegmentFiltersProps) {
|
export function SegmentFilters({
|
||||||
const { data, isLoading, isFetching } = useWebsiteSegmentsQuery(websiteId, { type: 'segment' });
|
websiteId,
|
||||||
|
segmentId,
|
||||||
|
type = 'segment',
|
||||||
|
onChange,
|
||||||
|
}: SegmentFiltersProps) {
|
||||||
|
const { data, isLoading, isFetching } = useWebsiteSegmentsQuery(websiteId, { type });
|
||||||
|
|
||||||
const handleChange = (id: string) => {
|
const handleChange = (id: string) => {
|
||||||
onSave?.(id);
|
onChange?.(id, type);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,7 @@ export function MetricsExpandedTable({
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
error={error}
|
error={error}
|
||||||
height="100%"
|
height="100%"
|
||||||
|
loadingIcon="spinner"
|
||||||
>
|
>
|
||||||
<Column overflowY="auto" minHeight="0" height="100%" paddingRight="3" overflow="hidden">
|
<Column overflowY="auto" minHeight="0" height="100%" paddingRight="3" overflow="hidden">
|
||||||
{items && (
|
{items && (
|
||||||
|
|
|
||||||
|
|
@ -68,7 +68,13 @@ export function MetricsTable({
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LoadingPanel data={data} isFetching={isFetching} isLoading={isLoading} error={error}>
|
<LoadingPanel
|
||||||
|
data={data}
|
||||||
|
isFetching={isFetching}
|
||||||
|
isLoading={isLoading}
|
||||||
|
error={error}
|
||||||
|
minHeight="380px"
|
||||||
|
>
|
||||||
{data && <ListTable {...props} data={filteredData} renderLabel={renderLabel} />}
|
{data && <ListTable {...props} data={filteredData} renderLabel={renderLabel} />}
|
||||||
{showMore && limit && (
|
{showMore && limit && (
|
||||||
<Row justifyContent="center">
|
<Row justifyContent="center">
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ import classNames from 'classnames';
|
||||||
import { colord } from 'colord';
|
import { colord } from 'colord';
|
||||||
import { ISO_COUNTRIES, MAP_FILE } from '@/lib/constants';
|
import { ISO_COUNTRIES, MAP_FILE } from '@/lib/constants';
|
||||||
import {
|
import {
|
||||||
useDateRange,
|
|
||||||
useWebsiteMetricsQuery,
|
useWebsiteMetricsQuery,
|
||||||
useCountryNames,
|
useCountryNames,
|
||||||
useLocale,
|
useLocale,
|
||||||
|
|
@ -30,14 +29,11 @@ export function WorldMap({ websiteId, data, className, ...props }: WorldMapProps
|
||||||
const { countryNames } = useCountryNames(locale);
|
const { countryNames } = useCountryNames(locale);
|
||||||
const visitorsLabel = formatMessage(labels.visitors).toLocaleLowerCase(locale);
|
const visitorsLabel = formatMessage(labels.visitors).toLocaleLowerCase(locale);
|
||||||
const unknownLabel = formatMessage(labels.unknown);
|
const unknownLabel = formatMessage(labels.unknown);
|
||||||
const {
|
|
||||||
dateRange: { startDate, endDate },
|
|
||||||
} = useDateRange(websiteId);
|
|
||||||
const { data: mapData } = useWebsiteMetricsQuery(websiteId, {
|
const { data: mapData } = useWebsiteMetricsQuery(websiteId, {
|
||||||
type: 'country',
|
type: 'country',
|
||||||
startAt: +startDate,
|
|
||||||
endAt: +endDate,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const metrics = useMemo(
|
const metrics = useMemo(
|
||||||
() => (data || mapData ? percentFilter((data || mapData) as any[]) : []),
|
() => (data || mapData ? percentFilter((data || mapData) as any[]) : []),
|
||||||
[data, mapData],
|
[data, mapData],
|
||||||
|
|
|
||||||
|
|
@ -101,57 +101,6 @@ export const reportTypeParam = z.enum([
|
||||||
'utm',
|
'utm',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const dateRangeSchema = z.object({ ...dateRangeParams }).superRefine((data, ctx) => {
|
|
||||||
const hasTimestamps = data.startAt !== undefined && data.endAt !== undefined;
|
|
||||||
const hasDates = data.startDate !== undefined && data.endDate !== undefined;
|
|
||||||
|
|
||||||
if (!hasTimestamps && !hasDates) {
|
|
||||||
ctx.addIssue({
|
|
||||||
code: z.ZodIssueCode.custom,
|
|
||||||
message: 'You must provide either startAt & endAt or startDate & endDate.',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasTimestamps && hasDates) {
|
|
||||||
ctx.addIssue({
|
|
||||||
code: z.ZodIssueCode.custom,
|
|
||||||
message: 'Provide either startAt & endAt or startDate & endDate, not both.',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.startAt !== undefined && data.endAt === undefined) {
|
|
||||||
ctx.addIssue({
|
|
||||||
code: z.ZodIssueCode.custom,
|
|
||||||
message: 'If you provide startAt, you must also provide endAt.',
|
|
||||||
path: ['endAt'],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.endAt !== undefined && data.startAt === undefined) {
|
|
||||||
ctx.addIssue({
|
|
||||||
code: z.ZodIssueCode.custom,
|
|
||||||
message: 'If you provide endAt, you must also provide startAt.',
|
|
||||||
path: ['startAt'],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.startDate !== undefined && data.endDate === undefined) {
|
|
||||||
ctx.addIssue({
|
|
||||||
code: z.ZodIssueCode.custom,
|
|
||||||
message: 'If you provide startDate, you must also provide endDate.',
|
|
||||||
path: ['endDate'],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.endDate !== undefined && data.startDate === undefined) {
|
|
||||||
ctx.addIssue({
|
|
||||||
code: z.ZodIssueCode.custom,
|
|
||||||
message: 'If you provide endDate, you must also provide startDate.',
|
|
||||||
path: ['startDate'],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export const goalReportSchema = z.object({
|
export const goalReportSchema = z.object({
|
||||||
type: z.literal('goal'),
|
type: z.literal('goal'),
|
||||||
parameters: z
|
parameters: z
|
||||||
|
|
@ -180,7 +129,7 @@ export const funnelReportSchema = z.object({
|
||||||
steps: z
|
steps: z
|
||||||
.array(
|
.array(
|
||||||
z.object({
|
z.object({
|
||||||
type: z.enum(['page', 'event']),
|
type: z.enum(['path', 'event']),
|
||||||
value: z.string(),
|
value: z.string(),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
@ -233,7 +182,7 @@ export const attributionReportSchema = z.object({
|
||||||
startDate: z.coerce.date(),
|
startDate: z.coerce.date(),
|
||||||
endDate: z.coerce.date(),
|
endDate: z.coerce.date(),
|
||||||
model: z.enum(['first-click', 'last-click']),
|
model: z.enum(['first-click', 'last-click']),
|
||||||
type: z.enum(['page', 'event']),
|
type: z.enum(['path', 'event']),
|
||||||
step: z.string(),
|
step: z.string(),
|
||||||
currency: z.string().optional(),
|
currency: z.string().optional(),
|
||||||
}),
|
}),
|
||||||
|
|
@ -253,6 +202,7 @@ export const reportBaseSchema = z.object({
|
||||||
type: reportTypeParam,
|
type: reportTypeParam,
|
||||||
name: z.string().max(200),
|
name: z.string().max(200),
|
||||||
description: z.string().max(500).optional(),
|
description: z.string().max(500).optional(),
|
||||||
|
parameters: z.object({}).passthrough(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const reportTypeSchema = z.discriminatedUnion('type', [
|
export const reportTypeSchema = z.discriminatedUnion('type', [
|
||||||
|
|
@ -266,7 +216,7 @@ export const reportTypeSchema = z.discriminatedUnion('type', [
|
||||||
breakdownReportSchema,
|
breakdownReportSchema,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const reportSchema = z.intersection(reportBaseSchema, reportTypeSchema);
|
export const reportSchema = reportBaseSchema;
|
||||||
|
|
||||||
export const reportResultSchema = z.intersection(
|
export const reportResultSchema = z.intersection(
|
||||||
z.object({
|
z.object({
|
||||||
|
|
|
||||||
|
|
@ -40,8 +40,8 @@ async function relationalQuery(
|
||||||
): Promise<AttributionResult> {
|
): Promise<AttributionResult> {
|
||||||
const { model, type, currency } = parameters;
|
const { model, type, currency } = parameters;
|
||||||
const { rawQuery, parseFilters } = prisma;
|
const { rawQuery, parseFilters } = prisma;
|
||||||
const eventType = type === 'page' ? EVENT_TYPE.pageView : EVENT_TYPE.customEvent;
|
const eventType = type === 'path' ? EVENT_TYPE.pageView : EVENT_TYPE.customEvent;
|
||||||
const column = type === 'page' ? 'url_path' : 'event_name';
|
const column = type === 'path' ? 'url_path' : 'event_name';
|
||||||
const { filterQuery, joinSessionQuery, cohortQuery, queryParams } = parseFilters({
|
const { filterQuery, joinSessionQuery, cohortQuery, queryParams } = parseFilters({
|
||||||
...filters,
|
...filters,
|
||||||
...parameters,
|
...parameters,
|
||||||
|
|
@ -266,8 +266,8 @@ async function clickhouseQuery(
|
||||||
): Promise<AttributionResult> {
|
): Promise<AttributionResult> {
|
||||||
const { model, type, currency } = parameters;
|
const { model, type, currency } = parameters;
|
||||||
const { rawQuery, parseFilters } = clickhouse;
|
const { rawQuery, parseFilters } = clickhouse;
|
||||||
const eventType = type === 'page' ? EVENT_TYPE.pageView : EVENT_TYPE.customEvent;
|
const eventType = type === 'path' ? EVENT_TYPE.pageView : EVENT_TYPE.customEvent;
|
||||||
const column = type === 'page' ? 'url_path' : 'event_name';
|
const column = type === 'path' ? 'url_path' : 'event_name';
|
||||||
const { filterQuery, cohortQuery, queryParams } = parseFilters({
|
const { filterQuery, cohortQuery, queryParams } = parseFilters({
|
||||||
...filters,
|
...filters,
|
||||||
...parameters,
|
...parameters,
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,7 @@ async function relationalQuery(
|
||||||
(pv, cv, i) => {
|
(pv, cv, i) => {
|
||||||
const levelNumber = i + 1;
|
const levelNumber = i + 1;
|
||||||
const startSum = i > 0 ? 'union ' : '';
|
const startSum = i > 0 ? 'union ' : '';
|
||||||
const isURL = cv.type === 'page';
|
const isURL = cv.type === 'path';
|
||||||
const column = isURL ? 'url_path' : 'event_name';
|
const column = isURL ? 'url_path' : 'event_name';
|
||||||
|
|
||||||
let operator = '=';
|
let operator = '=';
|
||||||
|
|
@ -161,7 +161,7 @@ async function clickhouseQuery(
|
||||||
const levelNumber = i + 1;
|
const levelNumber = i + 1;
|
||||||
const startSum = i > 0 ? 'union all ' : '';
|
const startSum = i > 0 ? 'union all ' : '';
|
||||||
const startFilter = i > 0 ? 'or' : '';
|
const startFilter = i > 0 ? 'or' : '';
|
||||||
const isURL = cv.type === 'page';
|
const isURL = cv.type === 'path';
|
||||||
const column = isURL ? 'url_path' : 'event_name';
|
const column = isURL ? 'url_path' : 'event_name';
|
||||||
|
|
||||||
let operator = '=';
|
let operator = '=';
|
||||||
|
|
|
||||||
|
|
@ -29,8 +29,8 @@ async function relationalQuery(
|
||||||
) {
|
) {
|
||||||
const { startDate, endDate, type, value } = parameters;
|
const { startDate, endDate, type, value } = parameters;
|
||||||
const { rawQuery, parseFilters } = prisma;
|
const { rawQuery, parseFilters } = prisma;
|
||||||
const eventType = type === 'page' ? EVENT_TYPE.pageView : EVENT_TYPE.customEvent;
|
const eventType = type === 'path' ? EVENT_TYPE.pageView : EVENT_TYPE.customEvent;
|
||||||
const column = type === 'page' ? 'url_path' : 'event_name';
|
const column = type === 'path' ? 'url_path' : 'event_name';
|
||||||
const { filterQuery, dateQuery, joinSessionQuery, cohortQuery, queryParams } = parseFilters({
|
const { filterQuery, dateQuery, joinSessionQuery, cohortQuery, queryParams } = parseFilters({
|
||||||
...filters,
|
...filters,
|
||||||
websiteId,
|
websiteId,
|
||||||
|
|
@ -71,8 +71,8 @@ async function clickhouseQuery(
|
||||||
) {
|
) {
|
||||||
const { startDate, endDate, type, value } = parameters;
|
const { startDate, endDate, type, value } = parameters;
|
||||||
const { rawQuery, parseFilters } = clickhouse;
|
const { rawQuery, parseFilters } = clickhouse;
|
||||||
const eventType = type === 'page' ? EVENT_TYPE.pageView : EVENT_TYPE.customEvent;
|
const eventType = type === 'path' ? EVENT_TYPE.pageView : EVENT_TYPE.customEvent;
|
||||||
const column = type === 'page' ? 'url_path' : 'event_name';
|
const column = type === 'path' ? 'url_path' : 'event_name';
|
||||||
const { filterQuery, dateQuery, cohortQuery, queryParams } = parseFilters({
|
const { filterQuery, dateQuery, cohortQuery, queryParams } = parseFilters({
|
||||||
...filters,
|
...filters,
|
||||||
websiteId,
|
websiteId,
|
||||||
|
|
|
||||||
|
|
@ -106,7 +106,7 @@ async function clickhouseQuery(websiteId: string, filters: QueryFilters) {
|
||||||
${getDateStringSQL('min(created_at)')} as firstAt,
|
${getDateStringSQL('min(created_at)')} as firstAt,
|
||||||
${getDateStringSQL('max(created_at)')} as lastAt,
|
${getDateStringSQL('max(created_at)')} as lastAt,
|
||||||
uniq(visit_id) as visits,
|
uniq(visit_id) as visits,
|
||||||
sumIf(views, event_type = 1) as views,
|
sumIf(1, event_type = 1) as views,
|
||||||
lastAt as createdAt
|
lastAt as createdAt
|
||||||
from website_event
|
from website_event
|
||||||
${cohortQuery}
|
${cohortQuery}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue