Upgraded Prisma, use new query compiler. Removed old reports.

This commit is contained in:
Mike Cao 2025-06-07 00:15:30 -07:00
parent a167c590c5
commit 2af95b5802
58 changed files with 88 additions and 2224 deletions

View file

@ -1,6 +1,6 @@
generator client { generator client {
provider = "prisma-client" provider = "prisma-client"
previewFeatures = ["driverAdapters"] previewFeatures = ["queryCompiler", "driverAdapters"]
output = "../src/generated/prisma" output = "../src/generated/prisma"
moduleFormat = "esm" moduleFormat = "esm"
} }

View file

@ -76,7 +76,7 @@
"@fontsource/inter": "^4.5.15", "@fontsource/inter": "^4.5.15",
"@hello-pangea/dnd": "^17.0.0", "@hello-pangea/dnd": "^17.0.0",
"@prisma/adapter-pg": "^6.8.2", "@prisma/adapter-pg": "^6.8.2",
"@prisma/client": "^6.8.2", "@prisma/client": "^6.9.0",
"@prisma/extension-read-replicas": "^0.4.1", "@prisma/extension-read-replicas": "^0.4.1",
"@react-spring/web": "^9.7.3", "@react-spring/web": "^9.7.3",
"@svgr/cli": "^8.1.0", "@svgr/cli": "^8.1.0",
@ -114,7 +114,7 @@
"node-fetch": "^3.2.8", "node-fetch": "^3.2.8",
"npm-run-all": "^4.1.5", "npm-run-all": "^4.1.5",
"pg": "^8.16.0", "pg": "^8.16.0",
"prisma": "6.8.2", "prisma": "6.9.0",
"pure-rand": "^6.1.0", "pure-rand": "^6.1.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-basics": "^0.126.0", "react-basics": "^0.126.0",

83
pnpm-lock.yaml generated
View file

@ -30,11 +30,11 @@ importers:
specifier: ^6.8.2 specifier: ^6.8.2
version: 6.8.2(pg@8.16.0) version: 6.8.2(pg@8.16.0)
'@prisma/client': '@prisma/client':
specifier: ^6.8.2 specifier: ^6.9.0
version: 6.8.2(prisma@6.8.2(typescript@5.8.3))(typescript@5.8.3) version: 6.9.0(prisma@6.9.0(typescript@5.8.3))(typescript@5.8.3)
'@prisma/extension-read-replicas': '@prisma/extension-read-replicas':
specifier: ^0.4.1 specifier: ^0.4.1
version: 0.4.1(@prisma/client@6.8.2(prisma@6.8.2(typescript@5.8.3))(typescript@5.8.3)) version: 0.4.1(@prisma/client@6.9.0(prisma@6.9.0(typescript@5.8.3))(typescript@5.8.3))
'@react-spring/web': '@react-spring/web':
specifier: ^9.7.3 specifier: ^9.7.3
version: 9.7.5(react-dom@19.1.0(react@19.1.0))(react@19.1.0) version: 9.7.5(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
@ -144,8 +144,8 @@ importers:
specifier: ^8.16.0 specifier: ^8.16.0
version: 8.16.0 version: 8.16.0
prisma: prisma:
specifier: 6.8.2 specifier: 6.9.0
version: 6.8.2(typescript@5.8.3) version: 6.9.0(typescript@5.8.3)
pure-rand: pure-rand:
specifier: ^6.1.0 specifier: ^6.1.0
version: 6.1.0 version: 6.1.0
@ -1508,8 +1508,8 @@ packages:
peerDependencies: peerDependencies:
pg: ^8.11.3 pg: ^8.11.3
'@prisma/client@6.8.2': '@prisma/client@6.9.0':
resolution: {integrity: sha512-5II+vbyzv4si6Yunwgkj0qT/iY0zyspttoDrL3R4BYgLdp42/d2C8xdi9vqkrYtKt9H32oFIukvyw3Koz5JoDg==} resolution: {integrity: sha512-Gg7j1hwy3SgF1KHrh0PZsYvAaykeR0PaxusnLXydehS96voYCGt1U5zVR31NIouYc63hWzidcrir1a7AIyCsNQ==}
engines: {node: '>=18.18'} engines: {node: '>=18.18'}
peerDependencies: peerDependencies:
prisma: '*' prisma: '*'
@ -1520,31 +1520,34 @@ packages:
typescript: typescript:
optional: true optional: true
'@prisma/config@6.8.2': '@prisma/config@6.9.0':
resolution: {integrity: sha512-ZJY1fF4qRBPdLQ/60wxNtX+eu89c3AkYEcP7L3jkp0IPXCNphCYxikTg55kPJLDOG6P0X+QG5tCv6CmsBRZWFQ==} resolution: {integrity: sha512-Wcfk8/lN3WRJd5w4jmNQkUwhUw0eksaU/+BlAJwPQKW10k0h0LC9PD/6TQFmqKVbHQL0vG2z266r0S1MPzzhbA==}
'@prisma/debug@6.8.2': '@prisma/debug@6.8.2':
resolution: {integrity: sha512-4muBSSUwJJ9BYth5N8tqts8JtiLT8QI/RSAzEogwEfpbYGFo9mYsInsVo8dqXdPO2+Rm5OG5q0qWDDE3nyUbVg==} resolution: {integrity: sha512-4muBSSUwJJ9BYth5N8tqts8JtiLT8QI/RSAzEogwEfpbYGFo9mYsInsVo8dqXdPO2+Rm5OG5q0qWDDE3nyUbVg==}
'@prisma/debug@6.9.0':
resolution: {integrity: sha512-bFeur/qi/Q+Mqk4JdQ3R38upSYPebv5aOyD1RKywVD+rAMLtRkmTFn28ZuTtVOnZHEdtxnNOCH+bPIeSGz1+Fg==}
'@prisma/driver-adapter-utils@6.8.2': '@prisma/driver-adapter-utils@6.8.2':
resolution: {integrity: sha512-5+CzN/41gBsRmA3ekbVy1TXnSImSPBtMlxWAttVH6tg94bv4zGGRmyk5tUCdT83nl0hG1Sq2oMXR7ml6aqILvw==} resolution: {integrity: sha512-5+CzN/41gBsRmA3ekbVy1TXnSImSPBtMlxWAttVH6tg94bv4zGGRmyk5tUCdT83nl0hG1Sq2oMXR7ml6aqILvw==}
'@prisma/engines-version@6.8.0-43.2060c79ba17c6bb9f5823312b6f6b7f4a845738e': '@prisma/engines-version@6.9.0-10.81e4af48011447c3cc503a190e86995b66d2a28e':
resolution: {integrity: sha512-Rkik9lMyHpFNGaLpPF3H5q5TQTkm/aE7DsGM5m92FZTvWQsvmi6Va8On3pWvqLHOt5aPUvFb/FeZTmphI4CPiQ==} resolution: {integrity: sha512-Qp9gMoBHgqhKlrvumZWujmuD7q4DV/gooEyPCLtbkc13EZdSz2RsGUJ5mHb3RJgAbk+dm6XenqG7obJEhXcJ6Q==}
'@prisma/engines@6.8.2': '@prisma/engines@6.9.0':
resolution: {integrity: sha512-XqAJ//LXjqYRQ1RRabs79KOY4+v6gZOGzbcwDQl0D6n9WBKjV7qdrbd042CwSK0v0lM9MSHsbcFnU2Yn7z8Zlw==} resolution: {integrity: sha512-im0X0bwDLA0244CDf8fuvnLuCQcBBdAGgr+ByvGfQY9wWl6EA+kRGwVk8ZIpG65rnlOwtaWIr/ZcEU5pNVvq9g==}
'@prisma/extension-read-replicas@0.4.1': '@prisma/extension-read-replicas@0.4.1':
resolution: {integrity: sha512-mCMDloqUKUwx2o5uedTs1FHX3Nxdt1GdRMoeyp1JggjiwOALmIYWhxfIN08M2BZ0w8SKwvJqicJZMjkQYkkijw==} resolution: {integrity: sha512-mCMDloqUKUwx2o5uedTs1FHX3Nxdt1GdRMoeyp1JggjiwOALmIYWhxfIN08M2BZ0w8SKwvJqicJZMjkQYkkijw==}
peerDependencies: peerDependencies:
'@prisma/client': ^6.5.0 '@prisma/client': ^6.5.0
'@prisma/fetch-engine@6.8.2': '@prisma/fetch-engine@6.9.0':
resolution: {integrity: sha512-lCvikWOgaLOfqXGacEKSNeenvj0n3qR5QvZUOmPE2e1Eh8cMYSobxonCg9rqM6FSdTfbpqp9xwhSAOYfNqSW0g==} resolution: {integrity: sha512-PMKhJdl4fOdeE3J3NkcWZ+tf3W6rx3ht/rLU8w4SXFRcLhd5+3VcqY4Kslpdm8osca4ej3gTfB3+cSk5pGxgFg==}
'@prisma/get-platform@6.8.2': '@prisma/get-platform@6.9.0':
resolution: {integrity: sha512-vXSxyUgX3vm1Q70QwzwkjeYfRryIvKno1SXbIqwSptKwqKzskINnDUcx85oX+ys6ooN2ATGSD0xN2UTfg6Zcow==} resolution: {integrity: sha512-/B4n+5V1LI/1JQcHp+sUpyRT1bBgZVPHbsC4lt4/19Xp4jvNIVcq5KYNtQDk5e/ukTSjo9PZVAxxy9ieFtlpTQ==}
'@react-aria/autocomplete@3.0.0-beta.3': '@react-aria/autocomplete@3.0.0-beta.3':
resolution: {integrity: sha512-8haBygHNMqVt4Ge90VOk+iVlLW+zhiOGHYz2IKCE6+Sy1dTE6mzhHjxrtwWYnSez/OQLbxjHlwLch4CDd5JkLA==} resolution: {integrity: sha512-8haBygHNMqVt4Ge90VOk+iVlLW+zhiOGHYz2IKCE6+Sy1dTE6mzhHjxrtwWYnSez/OQLbxjHlwLch4CDd5JkLA==}
@ -5906,8 +5909,8 @@ packages:
resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
prisma@6.8.2: prisma@6.9.0:
resolution: {integrity: sha512-JNricTXQxzDtRS7lCGGOB4g5DJ91eg3nozdubXze3LpcMl1oWwcFddrj++Up3jnRE6X/3gB/xz3V+ecBk/eEGA==} resolution: {integrity: sha512-resJAwMyZREC/I40LF6FZ6rZTnlrlrYrb63oW37Gq+U+9xHwbyMSPJjKtM7VZf3gTO86t/Oyz+YeSXr3CmAY1Q==}
engines: {node: '>=18.18'} engines: {node: '>=18.18'}
hasBin: true hasBin: true
peerDependencies: peerDependencies:
@ -8209,43 +8212,45 @@ snapshots:
pg: 8.16.0 pg: 8.16.0
postgres-array: 3.0.4 postgres-array: 3.0.4
'@prisma/client@6.8.2(prisma@6.8.2(typescript@5.8.3))(typescript@5.8.3)': '@prisma/client@6.9.0(prisma@6.9.0(typescript@5.8.3))(typescript@5.8.3)':
optionalDependencies: optionalDependencies:
prisma: 6.8.2(typescript@5.8.3) prisma: 6.9.0(typescript@5.8.3)
typescript: 5.8.3 typescript: 5.8.3
'@prisma/config@6.8.2': '@prisma/config@6.9.0':
dependencies: dependencies:
jiti: 2.4.2 jiti: 2.4.2
'@prisma/debug@6.8.2': {} '@prisma/debug@6.8.2': {}
'@prisma/debug@6.9.0': {}
'@prisma/driver-adapter-utils@6.8.2': '@prisma/driver-adapter-utils@6.8.2':
dependencies: dependencies:
'@prisma/debug': 6.8.2 '@prisma/debug': 6.8.2
'@prisma/engines-version@6.8.0-43.2060c79ba17c6bb9f5823312b6f6b7f4a845738e': {} '@prisma/engines-version@6.9.0-10.81e4af48011447c3cc503a190e86995b66d2a28e': {}
'@prisma/engines@6.8.2': '@prisma/engines@6.9.0':
dependencies: dependencies:
'@prisma/debug': 6.8.2 '@prisma/debug': 6.9.0
'@prisma/engines-version': 6.8.0-43.2060c79ba17c6bb9f5823312b6f6b7f4a845738e '@prisma/engines-version': 6.9.0-10.81e4af48011447c3cc503a190e86995b66d2a28e
'@prisma/fetch-engine': 6.8.2 '@prisma/fetch-engine': 6.9.0
'@prisma/get-platform': 6.8.2 '@prisma/get-platform': 6.9.0
'@prisma/extension-read-replicas@0.4.1(@prisma/client@6.8.2(prisma@6.8.2(typescript@5.8.3))(typescript@5.8.3))': '@prisma/extension-read-replicas@0.4.1(@prisma/client@6.9.0(prisma@6.9.0(typescript@5.8.3))(typescript@5.8.3))':
dependencies: dependencies:
'@prisma/client': 6.8.2(prisma@6.8.2(typescript@5.8.3))(typescript@5.8.3) '@prisma/client': 6.9.0(prisma@6.9.0(typescript@5.8.3))(typescript@5.8.3)
'@prisma/fetch-engine@6.8.2': '@prisma/fetch-engine@6.9.0':
dependencies: dependencies:
'@prisma/debug': 6.8.2 '@prisma/debug': 6.9.0
'@prisma/engines-version': 6.8.0-43.2060c79ba17c6bb9f5823312b6f6b7f4a845738e '@prisma/engines-version': 6.9.0-10.81e4af48011447c3cc503a190e86995b66d2a28e
'@prisma/get-platform': 6.8.2 '@prisma/get-platform': 6.9.0
'@prisma/get-platform@6.8.2': '@prisma/get-platform@6.9.0':
dependencies: dependencies:
'@prisma/debug': 6.8.2 '@prisma/debug': 6.9.0
'@react-aria/autocomplete@3.0.0-beta.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': '@react-aria/autocomplete@3.0.0-beta.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies: dependencies:
@ -13603,10 +13608,10 @@ snapshots:
ansi-styles: 5.2.0 ansi-styles: 5.2.0
react-is: 18.3.1 react-is: 18.3.1
prisma@6.8.2(typescript@5.8.3): prisma@6.9.0(typescript@5.8.3):
dependencies: dependencies:
'@prisma/config': 6.8.2 '@prisma/config': 6.9.0
'@prisma/engines': 6.8.2 '@prisma/engines': 6.9.0
optionalDependencies: optionalDependencies:
typescript: 5.8.3 typescript: 5.8.3

View file

@ -1,55 +0,0 @@
import { Button, Icon, Modal, DialogTrigger, Dialog, Text } from '@umami/react-zen';
import { useMessages } from '@/components/hooks';
import { Trash } from '@/components/icons';
import { ConfirmationForm } from '@/components/common/ConfirmationForm';
import { useDeleteQuery } from '@/components/hooks/queries/useDeleteQuery';
export function ReportDeleteButton({
reportId,
reportName,
onDelete,
}: {
reportId: string;
reportName: string;
onDelete?: () => void;
}) {
const { formatMessage, labels, messages } = useMessages();
const { mutate, isPending, error, touch } = useDeleteQuery(`/reports/${reportId}`);
const handleConfirm = (close: () => void) => {
mutate(reportId as any, {
onSuccess: () => {
touch('reports');
onDelete?.();
close();
},
});
};
return (
<DialogTrigger>
<Button>
<Icon>
<Trash />
</Icon>
<Text>{formatMessage(labels.delete)}</Text>
</Button>
<Modal>
<Dialog title={formatMessage(labels.deleteReport)}>
{({ close }) => (
<ConfirmationForm
message={formatMessage(messages.confirmDelete, {
target: <b key={messages.confirmDelete.id}>{reportName}</b>,
})}
isLoading={isPending}
error={error}
onConfirm={handleConfirm.bind(null, close)}
onClose={close}
buttonLabel={formatMessage(labels.delete)}
/>
)}
</Dialog>
</Modal>
</DialogTrigger>
);
}

View file

@ -1,22 +0,0 @@
import { ReactNode } from 'react';
import { useReportsQuery } from '@/components/hooks';
import { DataGrid } from '@/components/common/DataGrid';
import { ReportsTable } from './ReportsTable';
export function ReportsDataTable({
websiteId,
teamId,
children,
}: {
websiteId?: string;
teamId?: string;
children?: ReactNode;
}) {
const queryResult = useReportsQuery({ websiteId, teamId });
return (
<DataGrid queryResult={queryResult} renderEmpty={() => children}>
{({ data }) => <ReportsTable data={data} showDomain={!websiteId} />}
</DataGrid>
);
}

View file

@ -1,26 +0,0 @@
import { SectionHeader } from '@/components/common/SectionHeader';
import { Icon, Text } from '@umami/react-zen';
import { useLoginQuery, useMessages, useNavigation } from '@/components/hooks';
import { Plus } from '@/components/icons';
import { LinkButton } from '@/components/common/LinkButton';
import { ROLES } from '@/lib/constants';
export function ReportsHeader() {
const { formatMessage, labels } = useMessages();
const { renderTeamUrl } = useNavigation();
const { user } = useLoginQuery();
const canEdit = user.role !== ROLES.viewOnly;
return (
<SectionHeader title={formatMessage(labels.reports)}>
{canEdit && (
<LinkButton href={renderTeamUrl('/reports/create')} variant="primary">
<Icon>
<Plus />
</Icon>
<Text>{formatMessage(labels.createReport)}</Text>
</LinkButton>
)}
</SectionHeader>
);
}

View file

@ -1,21 +0,0 @@
'use client';
import { Metadata } from 'next';
import { ReportsHeader } from './ReportsHeader';
import { ReportsDataTable } from './ReportsDataTable';
import { useNavigation } from '@/components/hooks';
import { PageBody } from '@/components/common/PageBody';
export function ReportsPage() {
const { teamId } = useNavigation();
return (
<PageBody>
<ReportsHeader />
<ReportsDataTable teamId={teamId} />
</PageBody>
);
}
export const metadata: Metadata = {
title: 'Reports',
};

View file

@ -1,44 +0,0 @@
import { Icon, Text, DataTable, DataColumn, Row } from '@umami/react-zen';
import { LinkButton } from '@/components/common/LinkButton';
import { useMessages, useLoginQuery, useNavigation } from '@/components/hooks';
import { REPORT_TYPES } from '@/lib/constants';
import { Arrow } from '@/components/icons';
import { ReportDeleteButton } from './ReportDeleteButton';
export function ReportsTable({ data = [] }: { data: any[]; showDomain?: boolean }) {
const { formatMessage, labels } = useMessages();
const { user } = useLoginQuery();
const { renderTeamUrl } = useNavigation();
return (
<DataTable data={data}>
<DataColumn id="name" label={formatMessage(labels.name)} />
<DataColumn id="description" label={formatMessage(labels.description)} />
<DataColumn id="type" label={formatMessage(labels.type)}>
{(row: any) => {
return formatMessage(
labels[Object.keys(REPORT_TYPES).find(key => REPORT_TYPES[key] === row.type)],
);
}}
</DataColumn>
<DataColumn id="action" label="" align="end">
{(row: any) => {
const { id, name, userId, website } = row;
return (
<Row gap="3">
{(user.id === userId || user.id === website?.userId) && (
<ReportDeleteButton reportId={id} reportName={name} />
)}
<LinkButton href={renderTeamUrl(`/reports/${id}`)}>
<Icon>
<Arrow />
</Icon>
<Text>{formatMessage(labels.view)}</Text>
</LinkButton>
</Row>
);
}}
</DataColumn>
</DataTable>
);
}

View file

@ -1,65 +0,0 @@
import { Column, Label } from '@umami/react-zen';
import { useReport } from '@/components/hooks';
import { parseDateRange } from '@/lib/date';
import { DateFilter } from '@/components/input/DateFilter';
import { WebsiteSelect } from '@/components/input/WebsiteSelect';
import { useMessages, useNavigation, useWebsiteQuery } from '@/components/hooks';
export interface BaseParametersProps {
showWebsiteSelect?: boolean;
allowWebsiteSelect?: boolean;
showDateSelect?: boolean;
allowDateSelect?: boolean;
}
export function BaseParameters({
showWebsiteSelect = true,
allowWebsiteSelect = true,
showDateSelect = true,
allowDateSelect = true,
}: BaseParametersProps) {
const { report, updateReport } = useReport();
const { formatMessage, labels } = useMessages();
const { teamId } = useNavigation();
const { parameters } = report || {};
const { websiteId, dateRange } = parameters || {};
const { value, startDate, endDate } = dateRange || {};
const { data: website } = useWebsiteQuery(websiteId);
const { name } = website || {};
const handleWebsiteSelect = (websiteId: string) => {
updateReport({ websiteId, parameters: { websiteId } });
};
const handleDateChange = (value: string) => {
updateReport({ parameters: { dateRange: { ...parseDateRange(value) } } });
};
return (
<>
{showWebsiteSelect && (
<Column>
<Label>{formatMessage(labels.website)}</Label>
{allowWebsiteSelect ? (
<WebsiteSelect teamId={teamId} websiteId={websiteId} onSelect={handleWebsiteSelect} />
) : (
name
)}
</Column>
)}
{showDateSelect && (
<Column>
<Label>{formatMessage(labels.dateRange)}</Label>
{allowDateSelect && (
<DateFilter
value={value}
startDate={startDate}
endDate={endDate}
onChange={handleDateChange}
/>
)}
</Column>
)}
</>
);
}

View file

@ -1,44 +0,0 @@
import { useState } from 'react';
import { createPortal } from 'react-dom';
import { REPORT_PARAMETERS } from '@/lib/constants';
import { FieldSelectForm } from './FieldSelectForm';
export function FieldAddForm({
fields = [],
group,
onAdd,
onClose,
}: {
fields?: any[];
group: string;
onAdd: (group: string, value: string) => void;
onClose: () => void;
}) {
const [selected, setSelected] = useState<{
name: string;
type: string;
value: string;
}>();
const handleSelect = (value: any) => {
const { type } = value;
if (group === REPORT_PARAMETERS.groups || type === 'array' || type === 'boolean') {
value.value = group === REPORT_PARAMETERS.groups ? '' : 'total';
handleSave(value);
return;
}
setSelected(value);
};
const handleSave = (value: any) => {
onAdd(group, value);
onClose();
};
return createPortal(
!selected && <FieldSelectForm fields={fields} onSelect={handleSelect} />,
document.body,
);
}

View file

@ -1,53 +0,0 @@
import { Form, FormRow, Menu, Item } from '@umami/react-zen';
import { useMessages } from '@/components/hooks';
export function FieldAggregateForm({
name,
type,
onSelect,
}: {
name: string;
type: string;
onSelect: (key: any) => void;
}) {
const { formatMessage, labels } = useMessages();
const options = {
number: [
{ label: formatMessage(labels.sum), value: 'sum' },
{ label: formatMessage(labels.average), value: 'average' },
{ label: formatMessage(labels.min), value: 'min' },
{ label: formatMessage(labels.max), value: 'max' },
],
date: [
{ label: formatMessage(labels.min), value: 'min' },
{ label: formatMessage(labels.max), value: 'max' },
],
string: [
{ label: formatMessage(labels.total), value: 'total' },
{ label: formatMessage(labels.unique), value: 'unique' },
],
uuid: [
{ label: formatMessage(labels.total), value: 'total' },
{ label: formatMessage(labels.unique), value: 'unique' },
],
};
const items = options[type];
const handleSelect = (value: any) => {
onSelect({ name, type, value });
};
return (
<Form>
<FormRow label={name}>
<Menu onSelect={handleSelect}>
{items.map(({ label, value }) => {
return <Item key={value}>{label}</Item>;
})}
</Menu>
</FormRow>
</Form>
);
}

View file

@ -1,36 +0,0 @@
.menu {
position: absolute;
max-width: 300px;
max-height: 210px;
}
.filter {
display: flex;
flex-direction: column;
gap: 20px;
}
.dropdown {
min-width: 200px;
}
.text {
min-width: 200px;
}
.selected {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 16px;
white-space: nowrap;
min-width: 200px;
font-weight: 900;
background: var(--base100);
border-radius: var(--border-radius);
cursor: pointer;
}
.search {
position: relative;
}

View file

@ -1,219 +0,0 @@
import { useMemo, useState } from 'react';
import { useFilters, useFormat, useMessages, useWebsiteValuesQuery } from '@/components/hooks';
import { OPERATORS } from '@/lib/constants';
import { isEqualsOperator } from '@/lib/params';
import {
Button,
Column,
Row,
Select,
Icon,
Loading,
Menu,
MenuItem,
ListItem,
SearchField,
Text,
TextField,
Label,
} from '@umami/react-zen';
import { Close } from '@/components/icons';
import styles from './FieldFilterEditForm.module.css';
export interface FieldFilterFormProps {
websiteId?: string;
name: string;
label?: string;
type: string;
startDate: Date;
endDate: Date;
operator?: string;
defaultValue?: string;
onChange?: (filter: { name: string; type: string; operator: string; value: string }) => void;
allowFilterSelect?: boolean;
isNew?: boolean;
}
export function FieldFilterEditForm({
websiteId,
name,
label,
type,
startDate,
endDate,
operator: defaultOperator = 'eq',
defaultValue = '',
onChange,
allowFilterSelect = true,
isNew,
}: FieldFilterFormProps) {
const { formatMessage, labels } = useMessages();
const [operator, setOperator] = useState(defaultOperator);
const [value, setValue] = useState(defaultValue);
const [showMenu, setShowMenu] = useState(false);
const isEquals = isEqualsOperator(operator);
const [search, setSearch] = useState('');
const [selected, setSelected] = useState(isEquals ? value : '');
const { filters } = useFilters();
const { formatValue } = useFormat();
const isDisabled = !operator || (isEquals && !selected) || (!isEquals && !value);
const {
data: values = [],
isLoading,
refetch,
} = useWebsiteValuesQuery({
websiteId,
type: name,
startDate,
endDate,
search,
});
const filterDropdownItems = (name: string) => {
const limitedFilters = ['country', 'region', 'city'];
if (limitedFilters.includes(name)) {
return filters.filter(f => f.type === type && !f.label.match(/contain/gi));
} else {
return filters.filter(f => f.type === type);
}
};
const formattedValues = useMemo(() => {
return values.reduce((obj: { [x: string]: string }, { value }: { value: string }) => {
obj[value] = formatValue(value, name);
return obj;
}, {});
}, [formatValue, name, values]);
const filteredValues = useMemo(() => {
return value
? values.filter((n: string | number) =>
formattedValues[n]?.toLowerCase()?.includes(value.toLowerCase()),
)
: values;
}, [value, formattedValues]);
const handleAdd = () => {
onChange({ name, type, operator, value: isEquals ? selected : value });
};
const handleMenuSelect = (value: string) => {
setSelected(value);
setShowMenu(false);
};
const handleSearch = (value: string) => {
setSearch(value);
};
const handleReset = () => {
setSelected('');
setValue('');
setSearch('');
refetch();
};
const handleOperatorChange = (value: any) => {
setOperator(value);
if ([OPERATORS.equals, OPERATORS.notEquals].includes(value)) {
setValue('');
} else {
setSelected('');
}
};
const handleBlur = () => {
window.setTimeout(() => setShowMenu(false), 500);
};
const items = filterDropdownItems(name);
return (
<Column>
<Row className={styles.filter}>
<Label>{label}</Label>
<Row gap="3">
{allowFilterSelect && (
<Select
className={styles.dropdown}
items={items}
value={operator}
onChange={handleOperatorChange}
>
{({ value, label }: any) => {
return <ListItem key={value}>{label}</ListItem>;
}}
</Select>
)}
{selected && isEquals && (
<div className={styles.selected} onClick={handleReset}>
<Text>{formatValue(selected, name)}</Text>
<Icon>
<Close />
</Icon>
</div>
)}
{!selected && isEquals && (
<div className={styles.search}>
<SearchField
className={styles.text}
value={value}
placeholder={formatMessage(labels.enter)}
onSearch={handleSearch}
delay={500}
onFocus={() => setShowMenu(true)}
onBlur={handleBlur}
/>
{showMenu && (
<ResultsMenu
values={filteredValues}
type={name}
isLoading={isLoading}
onSelect={handleMenuSelect}
/>
)}
</div>
)}
{!selected && !isEquals && (
<TextField
className={styles.text}
value={value}
onChange={e => setValue(e.target.value)}
/>
)}
</Row>
<Button variant="primary" onPress={handleAdd} isDisabled={isDisabled}>
{formatMessage(isNew ? labels.add : labels.update)}
</Button>
</Row>
</Column>
);
}
const ResultsMenu = ({ values, type, isLoading, onSelect }) => {
const { formatValue } = useFormat();
if (isLoading) {
return (
<Menu className={styles.menu}>
<MenuItem>
<Loading icon="dots" position="center" />
</MenuItem>
</Menu>
);
}
if (!values?.length) {
return null;
}
return (
<Menu className={styles.menu} onSelectionChange={onSelect}>
{values?.map(({ value }) => {
return <MenuItem key={value}>{formatValue(value, type)}</MenuItem>;
})}
</Menu>
);
};

View file

@ -1,55 +0,0 @@
import { useFields, useMessages, useReport } from '@/components/hooks';
import { Plus } from '@/components/icons';
import { Button, Row, Label, Icon, Popover, MenuTrigger, Column } from '@umami/react-zen';
import { FieldSelectForm } from '../[reportId]/FieldSelectForm';
import { ParameterList } from '../[reportId]/ParameterList';
export function FieldParameters() {
const { report, updateReport } = useReport();
const { formatMessage, labels } = useMessages();
const { parameters } = report || {};
const { fields } = parameters || {};
const { fields: fieldOptions } = useFields();
const handleAdd = (value: string) => {
if (!fields.find(({ name }) => name === value)) {
const field = fieldOptions.find(({ name }) => name === value);
updateReport({ parameters: { fields: fields.concat(field) } });
}
};
const handleRemove = (name: string) => {
updateReport({ parameters: { fields: fields.filter(f => f.name !== name) } });
};
return (
<Column gap="3">
<Row justifyContent="space-between">
<Label>{formatMessage(labels.fields)}</Label>
<MenuTrigger>
<Button variant="quiet">
<Icon size="sm">
<Plus />
</Icon>
</Button>
<Popover placement="right top">
<FieldSelectForm
fields={fieldOptions.filter(({ name }) => !fields.find(f => f.name === name))}
onSelect={handleAdd}
showType={false}
/>
</Popover>
</MenuTrigger>
</Row>
<ParameterList>
{fields.map(({ name }) => {
return (
<ParameterList.Item key={name} onRemove={() => handleRemove(name)}>
{fieldOptions.find(f => f.name === name)?.label}
</ParameterList.Item>
);
})}
</ParameterList>
</Column>
);
}

View file

@ -1,20 +0,0 @@
.menu {
width: 360px;
max-height: 300px;
overflow: auto;
}
.item {
display: flex;
flex-direction: row;
justify-content: space-between;
border-radius: var(--border-radius);
}
.item:hover {
background: var(--base75);
}
.type {
color: var(--font-color300);
}

View file

@ -1,29 +0,0 @@
import { Menu, MenuItem, Text, MenuSection, Row } from '@umami/react-zen';
import { useMessages } from '@/components/hooks';
export interface FieldSelectFormProps {
fields?: any[];
onSelect?: (value: any) => void;
showType?: boolean;
}
export function FieldSelectForm({ fields = [], onSelect, showType = true }: FieldSelectFormProps) {
const { formatMessage, labels } = useMessages();
return (
<Menu onAction={value => onSelect?.(value)}>
<MenuSection title={formatMessage(labels.fields)} selectionMode="multiple">
{fields.map(({ name, label, type }) => {
return (
<MenuItem key={name} id={name}>
<Row alignItems="center" justifyContent="space-between">
<Text>{label || name}</Text>
{showType && type && <Text color="muted">{type}</Text>}
</Row>
</MenuItem>
);
})}
</MenuSection>
</Menu>
);
}

View file

@ -1,40 +0,0 @@
.item {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px;
overflow: hidden;
}
.label {
color: var(--base800);
border: 1px solid var(--base300);
font-weight: 900;
padding: 2px 8px;
border-radius: 5px;
white-space: nowrap;
}
.op {
color: var(--blue900);
background-color: var(--blue100);
font-size: 12px;
font-weight: 900;
padding: 2px 8px;
border-radius: 5px;
text-transform: uppercase;
white-space: nowrap;
}
.value {
color: var(--base900);
background-color: var(--base100);
font-weight: 900;
padding: 2px 8px;
border-radius: 5px;
white-space: nowrap;
}
.edit {
margin-top: 20px;
}

View file

@ -1,140 +0,0 @@
import { useMessages, useFormat, useFilters, useFields, useReport } from '@/components/hooks';
import { Plus } from '@/components/icons';
import {
Button,
Row,
Column,
Label,
Icon,
Popover,
MenuTrigger,
Text,
Pressable,
} from '@umami/react-zen';
import { FilterSelectForm } from '../[reportId]/FilterSelectForm';
import { ParameterList } from '../[reportId]/ParameterList';
import { FieldFilterEditForm } from '../[reportId]/FieldFilterEditForm';
import { isSearchOperator } from '@/lib/params';
export function FilterParameters() {
const { report, updateReport } = useReport();
const { formatMessage, labels } = useMessages();
const { formatValue } = useFormat();
const { parameters } = report || {};
const { websiteId, filters, dateRange } = parameters || {};
const { fields } = useFields();
const handleAdd = (value: { name: any }) => {
if (!filters.find(({ name }) => name === value.name)) {
updateReport({ parameters: { filters: filters.concat(value) } });
}
};
const handleRemove = (name: string) => {
updateReport({ parameters: { filters: filters.filter(f => f.name !== name) } });
};
const handleChange = (close: () => void, filter: { name: any }) => {
updateReport({
parameters: {
filters: filters.map(f => {
if (filter.name === f.name) {
return filter;
}
return f;
}),
},
});
close();
};
return (
<Column gap="3">
<Row justifyContent="space-between">
<Label>{formatMessage(labels.filters)}</Label>
<MenuTrigger>
<Button variant="quiet">
<Icon size="sm">
<Plus />
</Icon>
</Button>
<Popover placement="right top">
<FilterSelectForm
websiteId={websiteId}
fields={fields.filter(({ name }) => !filters.find(f => f.name === name))}
startDate={dateRange?.startDate}
endDate={dateRange?.endDate}
onChange={handleAdd}
/>
</Popover>
</MenuTrigger>
</Row>
<ParameterList>
{filters.map(
({ name, operator, value }: { name: string; operator: string; value: string }) => {
const label = fields.find(f => f.name === name)?.label;
const isSearch = isSearchOperator(operator);
return (
<ParameterList.Item key={name} onRemove={() => handleRemove(name)}>
<FilterParameter
startDate={dateRange.startDate}
endDate={dateRange.endDate}
websiteId={websiteId}
name={name}
label={label}
operator={operator}
value={isSearch ? value : formatValue(value, name)}
onChange={handleChange}
/>
</ParameterList.Item>
);
},
)}
</ParameterList>
</Column>
);
}
const FilterParameter = ({
websiteId,
name,
label,
operator,
value,
type = 'string',
startDate,
endDate,
onChange,
}) => {
const { operatorLabels } = useFilters();
return (
<MenuTrigger>
<Pressable>
<Row role="button" gap="3" alignItems="center">
<Text>{label}</Text>
<Text size="2" transform="uppercase">
{operatorLabels[operator]}
</Text>
<Text weight="bold">{value}</Text>
</Row>
</Pressable>
<Popover placement="right top">
{(close: any) => (
<FieldFilterEditForm
websiteId={websiteId}
name={name}
label={label}
type={type}
startDate={startDate}
endDate={endDate}
operator={operator}
defaultValue={value}
onChange={onChange.bind(null, close)}
/>
)}
</Popover>
</MenuTrigger>
);
};

View file

@ -1,43 +0,0 @@
import { useState } from 'react';
import { FieldSelectForm } from './FieldSelectForm';
import { FieldFilterEditForm } from './FieldFilterEditForm';
export interface FilterSelectFormProps {
websiteId?: string;
fields: any[];
startDate?: Date;
endDate?: Date;
onChange?: (filter: { name: string; type: string; operator: string; value: string }) => void;
allowFilterSelect?: boolean;
}
export function FilterSelectForm({
websiteId,
fields,
startDate,
endDate,
onChange,
allowFilterSelect,
}: FilterSelectFormProps) {
const [field, setField] = useState<{ name: string; label: string; type: string }>();
if (!field) {
return <FieldSelectForm fields={fields} onSelect={setField} showType={false} />;
}
const { name, label, type } = field;
return (
<FieldFilterEditForm
websiteId={websiteId}
name={name || 'url'}
label={label}
type={type}
startDate={startDate}
endDate={endDate}
onChange={onChange}
allowFilterSelect={allowFilterSelect}
isNew={true}
/>
);
}

View file

@ -1,29 +0,0 @@
.list {
display: flex;
flex-direction: column;
gap: 16px;
}
.item {
display: flex;
gap: 12px;
width: 100%;
flex-wrap: nowrap;
padding: 12px;
border: 1px solid var(--base400);
border-radius: var(--border-radius);
box-shadow: 1px 1px 1px var(--base400);
}
.value {
display: flex;
flex-direction: row;
align-items: center;
flex-wrap: wrap;
flex: 1;
}
.icon,
.close {
height: 1.5rem;
}

View file

@ -1,56 +0,0 @@
import { ReactNode } from 'react';
import { Icon, Row, Text, Button, Column } from '@umami/react-zen';
import { Close } from '@/components/icons';
import { Empty } from '@/components/common/Empty';
import { useMessages } from '@/components/hooks';
export interface ParameterListProps {
children?: ReactNode;
}
export function ParameterList({ children }: ParameterListProps) {
const { formatMessage, labels } = useMessages();
return (
<Column gap="3">
{!children && <Empty message={formatMessage(labels.none)} />}
{children}
</Column>
);
}
const Item = ({
icon,
onClick,
onRemove,
children,
}: {
icon?: ReactNode;
onClick?: () => void;
onRemove?: () => void;
children?: ReactNode;
}) => {
return (
<Row
gap="3"
alignItems="center"
justifyContent="space-between"
onClick={onClick}
backgroundColor="2"
border
borderRadius="2"
paddingLeft="3"
shadow="2"
>
{icon && <Icon>{icon}</Icon>}
<Text>{children}</Text>
<Button onPress={onRemove} variant="quiet">
<Icon>
<Close />
</Icon>
</Button>
</Row>
);
};
ParameterList.Item = Item;

View file

@ -1,7 +0,0 @@
.container {
display: grid;
grid-template-rows: max-content 1fr;
grid-template-columns: max-content 1fr;
margin-bottom: 60px;
height: 90vh;
}

View file

@ -1,29 +0,0 @@
import { createContext, ReactNode } from 'react';
import { Loading, Grid } from '@umami/react-zen';
import { useReportQuery } from '@/components/hooks';
export const ReportContext = createContext(null);
export function Report({
reportId,
defaultParameters,
children,
}: {
reportId: string;
defaultParameters: { type: string; parameters: { [key: string]: any } };
children: ReactNode;
}) {
const report = useReportQuery(reportId, defaultParameters);
if (!report) {
return reportId ? <Loading position="page" /> : null;
}
return (
<ReportContext.Provider value={report}>
<Grid rows="auto 1fr" columns="auto 1fr" gap="6">
{children}
</Grid>
</ReportContext.Provider>
);
}

View file

@ -1,5 +0,0 @@
.body {
padding-inline-start: 20px;
grid-row: 2 / 3;
grid-column: 2 / 3;
}

View file

@ -1,12 +0,0 @@
import { Panel } from '@/components/common/Panel';
import { useReport } from '@/components/hooks';
export function ReportBody({ children }) {
const { report } = useReport();
if (!report) {
return null;
}
return <Panel>{children}</Panel>;
}

View file

@ -1,100 +0,0 @@
import {
Row,
Column,
Text,
Heading,
Icon,
LoadingButton,
InlineEditField,
useToast,
} from '@umami/react-zen';
import { useMessages, useApi, useNavigation, useReport } from '@/components/hooks';
import { REPORT_TYPES } from '@/lib/constants';
export function ReportHeader({ icon }) {
const { report, updateReport } = useReport();
const { formatMessage, labels, messages } = useMessages();
const { toast } = useToast();
const { router, renderTeamUrl } = useNavigation();
const { post, useMutation } = useApi();
const { mutate: create, isPending: isCreating } = useMutation({
mutationFn: (data: any) => post(`/reports`, data),
});
const { mutate: update, isPending: isUpdating } = useMutation({
mutationFn: (data: any) => post(`/reports/${data.id}`, data),
});
const { name, description, parameters } = report || {};
const { websiteId, dateRange } = parameters || {};
const defaultName = formatMessage(labels.untitled);
const handleSave = async () => {
if (!report.id) {
create(report, {
onSuccess: async ({ id }) => {
toast(formatMessage(messages.saved));
router.push(renderTeamUrl(`/reports/${id}`));
},
});
} else {
update(report, {
onSuccess: async () => {
toast(formatMessage(messages.saved));
},
});
}
};
const handleNameChange = (name: string) => {
updateReport({ name: name || defaultName });
};
const handleDescriptionChange = (description: string) => {
updateReport({ description });
};
if (!report) {
return null;
}
return (
<Column marginY="6" gap="3" gridColumn="1 / 3">
<Row gap="3" alignItems="center">
<Icon size="sm">{icon}</Icon>
<Text transform="uppercase" weight="bold">
{formatMessage(
labels[Object.keys(REPORT_TYPES).find(key => REPORT_TYPES[key] === report?.type)],
)}
</Text>
</Row>
<Row justifyContent="space-between" alignItems="center">
<Row gap="6">
<Column gap="3">
<Row gap="3" alignItems="center">
<InlineEditField key={name} name="name" value={name} onCommit={handleNameChange}>
<Heading>{name}</Heading>
</InlineEditField>
</Row>
<InlineEditField
key={description}
name="description"
value={description}
onCommit={handleDescriptionChange}
>
<Text>{description || `+ ${formatMessage(labels.addDescription)}`}</Text>
</InlineEditField>
</Column>
</Row>
<LoadingButton
variant="primary"
isLoading={isCreating || isUpdating}
isDisabled={!websiteId || !dateRange?.value || !name}
onPress={handleSave}
>
{formatMessage(labels.save)}
</LoadingButton>
</Row>
</Column>
);
}

View file

@ -1,38 +0,0 @@
.menu {
position: relative;
width: 300px;
padding-top: 20px;
padding-inline-end: 20px;
border-inline-end: 1px solid var(--base300);
grid-row: 2 / 3;
grid-column: 1 / 2;
}
.button {
position: absolute;
top: 0;
right: 0;
display: flex;
place-content: center;
border: 1px solid var(--base400);
border-right: 0;
width: 30px;
padding: 5px;
cursor: pointer;
border-radius: 4px 0 0 4px;
z-index: 1;
}
.button:hover {
background: var(--base75);
}
.menu.collapsed {
width: 0;
padding: 0;
}
.menu.collapsed .button {
right: 0;
border-radius: 4px 0 0 4px;
}

View file

@ -1,26 +0,0 @@
import { useState } from 'react';
import { Button, Column, Icon, Row } from '@umami/react-zen';
import { PanelLeft } from '@/components/icons';
import { useReport } from '@/components/hooks';
export function ReportMenu({ children }) {
const [collapsed, setCollapsed] = useState(false);
const { report } = useReport();
if (!report) {
return null;
}
return (
<Column>
<Row alignItems="center" justifyContent="flex-end">
<Button variant="quiet" onPress={() => setCollapsed(!collapsed)}>
<Icon>
<PanelLeft />
</Icon>
</Button>
</Row>
{!collapsed && children}
</Column>
);
}

View file

@ -1,35 +0,0 @@
'use client';
import { useReportQuery } from '@/components/hooks';
import { EventDataReport } from '../event-data/EventDataReport';
import { FunnelReport } from '../funnel/FunnelReport';
import { GoalsReport } from '../goals/GoalsReport';
import { InsightsReport } from '../insights/InsightsReport';
import { JourneyReport } from '../journey/JourneyReport';
import { RetentionReport } from '../retention/RetentionReport';
import { UTMReport } from '../utm/UTMReport';
import { RevenueReport } from '../revenue/RevenueReport';
import AttributionReport from '../attribution/AttributionReport';
const reports = {
funnel: FunnelReport,
'event-data': EventDataReport,
insights: InsightsReport,
retention: RetentionReport,
utm: UTMReport,
goals: GoalsReport,
journey: JourneyReport,
revenue: RevenueReport,
attribution: AttributionReport,
};
export function ReportPage({ reportId }: { reportId: string }) {
const { report } = useReportQuery(reportId);
if (!report) {
return null;
}
const ReportComponent = reports[report.type];
return <ReportComponent reportId={reportId} />;
}

View file

@ -1,12 +0,0 @@
import { Metadata } from 'next';
import { ReportPage } from './ReportPage';
export default async function ({ params }: { params: Promise<{ reportId: string }> }) {
const { reportId } = await params;
return <ReportPage reportId={reportId} />;
}
export const metadata: Metadata = {
title: 'Reports',
};

View file

@ -1,174 +0,0 @@
import { useMessages } from '@/components/hooks';
import { Eye, Bolt, Plus } from '@/components/icons';
import { useContext, useState } from 'react';
import {
Button,
Select,
Form,
FormButtons,
FormField,
Icon,
ListItem,
Popover,
DialogTrigger,
Toggle,
FormSubmitButton,
} from '@umami/react-zen';
import { BaseParameters } from '../[reportId]/BaseParameters';
import { ParameterList } from '../[reportId]/ParameterList';
import { ReportContext } from '../[reportId]/Report';
import { FunnelStepAddForm } from '../funnel/FunnelStepAddForm';
import { AttributionStepAddForm } from './AttributionStepAddForm';
import { useRevenueValuesQuery } from '@/components/hooks/queries/useRevenueValuesQuery';
export function AttributionParameters() {
const { report, runReport, updateReport, isRunning } = useContext(ReportContext);
const { formatMessage, labels } = useMessages();
const { id, parameters } = report || {};
const { websiteId, dateRange, steps } = parameters || {};
const queryEnabled = websiteId && dateRange && steps.length > 0;
const [model, setModel] = useState('');
const [revenueMode, setRevenueMode] = useState(false);
const { data: currencyValues = [] } = useRevenueValuesQuery(
websiteId,
dateRange?.startDate,
dateRange?.endDate,
);
const handleSubmit = (data: any, e: any) => {
if (revenueMode === false) {
delete data.currency;
}
e.stopPropagation();
e.preventDefault();
runReport(data);
};
const handleCheck = () => {
setRevenueMode(!revenueMode);
};
const handleAddStep = (step: { type: string; value: string }) => {
if (step.type === 'url') {
setRevenueMode(false);
}
updateReport({ parameters: { steps: parameters.steps.concat(step) } });
};
const handleUpdateStep = (
close: () => void,
index: number,
step: { type: string; value: string },
) => {
if (step.type === 'url') {
setRevenueMode(false);
}
const steps = [...parameters.steps];
steps[index] = step;
updateReport({ parameters: { steps } });
close();
};
const handleRemoveStep = (index: number) => {
const steps = [...parameters.steps];
delete steps[index];
updateReport({ parameters: { steps: steps.filter(n => n) } });
};
const AddStepButton = () => {
return (
<DialogTrigger>
<Button isDisabled={steps.length > 0}>
<Icon>
<Plus />
</Icon>
</Button>
<Popover placement="right top">
<FunnelStepAddForm onChange={handleAddStep} />
</Popover>
</DialogTrigger>
);
};
const items = [
{ id: 'first-click', label: 'First-Click', value: 'firstClick' },
{ id: 'last-click', label: 'Last-Click', value: 'lastClick' },
];
const onModelChange = (value: any) => {
setModel(value);
updateReport({ parameters: { model } });
};
return (
<Form values={parameters} onSubmit={handleSubmit} preventSubmit={true}>
<BaseParameters showDateSelect={true} allowWebsiteSelect={!id} />
<FormField
name="model"
rules={{ required: formatMessage(labels.required) }}
label={formatMessage(labels.model)}
>
<Select items={items} value={model} onChange={onModelChange}>
{({ value, label }: any) => {
return <ListItem key={value}>{label}</ListItem>;
}}
</Select>
</FormField>
<FormField name="step" label={formatMessage(labels.conversionStep)}>
<ParameterList>
{steps.map((step: { type: string; value: string }, index: number) => {
return (
<DialogTrigger key={index}>
<ParameterList.Item
icon={step.type === 'url' ? <Eye /> : <Bolt />}
onRemove={() => handleRemoveStep(index)}
>
<div>{step.value}</div>
</ParameterList.Item>
<Popover placement="right top">
<AttributionStepAddForm
type={step.type}
value={step.value}
onChange={handleUpdateStep.bind(null, close, index)}
/>
</Popover>
</DialogTrigger>
);
})}
</ParameterList>
<AddStepButton />
</FormField>
<Toggle
isSelected={revenueMode}
onClick={handleCheck}
isDisabled={currencyValues.length === 0 || steps[0]?.type === 'url'}
>
<b>Revenue Mode</b>
</Toggle>
{revenueMode && (
<FormField
name="currency"
rules={{ required: formatMessage(labels.required) }}
label={formatMessage(labels.currency)}
>
<Select items={currencyValues.map(item => ({ id: item.currency, value: item.currency }))}>
{({ id, value }: any) => (
<ListItem key={id} id={id}>
{value}
</ListItem>
)}
</Select>
</FormField>
)}
<FormButtons>
<FormSubmitButton variant="primary" isDisabled={!queryEnabled} isLoading={isRunning}>
{formatMessage(labels.runQuery)}
</FormSubmitButton>
</FormButtons>
</Form>
);
}

View file

@ -1,27 +0,0 @@
import { Network } from '@/components/icons';
import { REPORT_TYPES } from '@/lib/constants';
import { Report } from '../[reportId]/Report';
import { ReportBody } from '../[reportId]/ReportBody';
import { ReportHeader } from '../[reportId]/ReportHeader';
import { ReportMenu } from '../[reportId]/ReportMenu';
import { AttributionParameters } from './AttributionParameters';
import { AttributionView } from './AttributionView';
const defaultParameters = {
type: REPORT_TYPES.attribution,
parameters: { model: 'firstClick', steps: [] },
};
export default function AttributionReport({ reportId }: { reportId?: string }) {
return (
<Report reportId={reportId} defaultParameters={defaultParameters}>
<ReportHeader icon={<Network />} />
<ReportMenu>
<AttributionParameters />
</ReportMenu>
<ReportBody>
<AttributionView />
</ReportBody>
</Report>
);
}

View file

@ -1,6 +0,0 @@
'use client';
import AttributionReport from './AttributionReport';
export default function AttributionReportPage() {
return <AttributionReport />;
}

View file

@ -1,77 +0,0 @@
import { useState } from 'react';
import { useMessages } from '@/components/hooks';
import {
Button,
FormButtons,
FormField,
TextField,
Row,
Column,
Select,
ListItem,
} from '@umami/react-zen';
export interface AttributionStepAddFormProps {
type?: string;
value?: string;
onChange?: (step: { type: string; value: string }) => void;
}
export function AttributionStepAddForm({
type: defaultType = 'url',
value: defaultValue = '',
onChange,
}: AttributionStepAddFormProps) {
const [type, setType] = useState(defaultType);
const [value, setValue] = useState(defaultValue);
const { formatMessage, labels } = useMessages();
const items = [
{ id: 'url', label: formatMessage(labels.url), value: 'url' },
{ id: 'event', label: formatMessage(labels.event), value: 'event' },
];
const isDisabled = !type || !value;
const handleSave = () => {
onChange({ type, value });
setValue('');
};
const handleChange = e => {
setValue(e.target.value);
};
const handleKeyDown = e => {
if (e.key === 'Enter') {
e.stopPropagation();
handleSave();
}
};
return (
<Column gap>
<FormField name="steps" label={formatMessage(defaultValue ? labels.update : labels.add)}>
<Row>
<Select items={items} value={type} onChange={(value: any) => setType(value)}>
{({ value, label }: any) => {
return <ListItem key={value}>{label}</ListItem>;
}}
</Select>
<TextField
value={value}
onChange={handleChange}
autoFocus={true}
autoComplete="off"
onKeyDown={handleKeyDown}
/>
</Row>
</FormField>
<FormButtons>
<Button variant="primary" onClick={handleSave} isDisabled={isDisabled}>
{formatMessage(defaultValue ? labels.update : labels.add)}
</Button>
</FormButtons>
</Column>
);
}
export default AttributionStepAddForm;

View file

@ -1,133 +0,0 @@
import { useContext } from 'react';
import { PieChart } from '@/components/charts/PieChart';
import { useMessages } from '@/components/hooks';
import { GridRow } from '@/components/common/GridRow';
import { ListTable } from '@/components/metrics/ListTable';
import { MetricCard } from '@/components/metrics/MetricCard';
import { MetricsBar } from '@/components/metrics/MetricsBar';
import { CHART_COLORS } from '@/lib/constants';
import { formatLongNumber } from '@/lib/format';
import { ReportContext } from '../[reportId]/Report';
export interface AttributionViewProps {
isLoading?: boolean;
}
export function AttributionView({ isLoading }: AttributionViewProps) {
const { formatMessage, labels } = useMessages();
const { report } = useContext(ReportContext);
const {
data,
parameters: { currency },
} = report || {};
const ATTRIBUTION_PARAMS = [
{ value: 'referrer', label: formatMessage(labels.referrers) },
{ value: 'paidAds', label: formatMessage(labels.paidAds) },
];
if (!data) {
return null;
}
const { pageviews, visitors, visits } = data.total;
const metrics = data
? [
{
value: pageviews,
label: formatMessage(labels.views),
formatValue: formatLongNumber,
},
{
value: visits,
label: formatMessage(labels.visits),
formatValue: formatLongNumber,
},
{
value: visitors,
label: formatMessage(labels.visitors),
formatValue: formatLongNumber,
},
]
: [];
function UTMTable(UTMTableProps: { data: any; title: string; utm: string }) {
const { data, title, utm } = UTMTableProps;
const total = data[utm].reduce((sum, { value }) => {
return +sum + +value;
}, 0);
return (
<ListTable
title={title}
metric={formatMessage(currency ? labels.revenue : labels.visitors)}
currency={currency}
data={data[utm].map(({ name, value }) => ({
x: name,
y: Number(value),
z: (value / total) * 100,
}))}
/>
);
}
return (
<div>
<MetricsBar isFetched={data}>
{metrics?.map(({ label, value, formatValue }) => {
return <MetricCard key={label} value={value} label={label} formatValue={formatValue} />;
})}
</MetricsBar>
{ATTRIBUTION_PARAMS.map(({ value, label }) => {
const items = data[value];
const total = items.reduce((sum, { value }) => {
return +sum + +value;
}, 0);
const chartData = {
labels: items.map(({ name }) => name),
datasets: [
{
data: items.map(({ value }) => value),
backgroundColor: CHART_COLORS,
borderWidth: 0,
},
],
};
return (
<div key={value}>
<div>
<div>{label}</div>
<ListTable
metric={formatMessage(currency ? labels.revenue : labels.visitors)}
currency={currency}
data={items.map(({ name, value }) => ({
x: name,
y: Number(value),
z: (value / total) * 100,
}))}
/>
</div>
<div>
<PieChart type="doughnut" data={chartData} isLoading={isLoading} />
</div>
</div>
);
})}
<>
<GridRow columns="two">
<UTMTable data={data} title={formatMessage(labels.sources)} utm={'utm_source'} />
<UTMTable data={data} title={formatMessage(labels.medium)} utm={'utm_medium'} />
</GridRow>
<GridRow columns="three">
<UTMTable data={data} title={formatMessage(labels.campaigns)} utm={'utm_campaign'} />
<UTMTable data={data} title={formatMessage(labels.content)} utm={'utm_content'} />
<UTMTable data={data} title={formatMessage(labels.terms)} utm={'utm_term'} />
</GridRow>
</>
</div>
);
}
export default AttributionView;

View file

@ -1,10 +0,0 @@
import AttributionReportPage from './AttributionReportPage';
import { Metadata } from 'next';
export default function () {
return <AttributionReportPage />;
}
export const metadata: Metadata = {
title: 'Attribution Report',
};

View file

@ -1,6 +0,0 @@
'use client';
import { ReportTemplates } from './ReportTemplates';
export function ReportCreatePage() {
return <ReportTemplates />;
}

View file

@ -1,108 +0,0 @@
import { Icon, Text, Row, Column, Grid } from '@umami/react-zen';
import { useMessages, useNavigation } from '@/components/hooks';
import {
Lightbulb,
Funnel,
Magnet,
Tag,
Target,
Path,
Money,
Network,
Plus,
} from '@/components/icons';
import { SectionHeader } from '@/components/common/SectionHeader';
import { LinkButton } from '@/components/common/LinkButton';
export function ReportTemplates({ showHeader = true }: { showHeader?: boolean }) {
const { formatMessage, labels } = useMessages();
const { renderTeamUrl } = useNavigation();
const reports = [
{
title: formatMessage(labels.insights),
description: formatMessage(labels.insightsDescription),
url: renderTeamUrl('/reports/insights'),
icon: <Lightbulb />,
},
{
title: formatMessage(labels.funnel),
description: formatMessage(labels.funnelDescription),
url: renderTeamUrl('/reports/funnel'),
icon: <Funnel />,
},
{
title: formatMessage(labels.retention),
description: formatMessage(labels.retentionDescription),
url: renderTeamUrl('/reports/retention'),
icon: <Magnet />,
},
{
title: formatMessage(labels.utm),
description: formatMessage(labels.utmDescription),
url: renderTeamUrl('/reports/utm'),
icon: <Tag />,
},
{
title: formatMessage(labels.goals),
description: formatMessage(labels.goalsDescription),
url: renderTeamUrl('/reports/goals'),
icon: <Target />,
},
{
title: formatMessage(labels.journey),
description: formatMessage(labels.journeyDescription),
url: renderTeamUrl('/reports/journey'),
icon: <Path />,
},
{
title: formatMessage(labels.revenue),
description: formatMessage(labels.revenueDescription),
url: renderTeamUrl('/reports/revenue'),
icon: <Money />,
},
{
title: formatMessage(labels.attribution),
description: formatMessage(labels.attributionDescription),
url: renderTeamUrl('/reports/attribution'),
icon: <Network />,
},
];
return (
<>
{showHeader && <SectionHeader title={formatMessage(labels.reports)} />}
<Grid columns="repeat(3, minmax(200px, 1fr))" gap="3">
{reports.map(({ title, description, url, icon }) => {
return (
<ReportItem key={title} icon={icon} title={title} description={description} url={url} />
);
})}
</Grid>
</>
);
}
function ReportItem({ title, description, url, icon }) {
const { formatMessage, labels } = useMessages();
return (
<Column gap="6" padding="6" border borderRadius="3" justifyContent="space-between">
<Row gap="3" alignItems="center">
<Icon size="md">{icon}</Icon>
<Text size="5" weight="bold">
{title}
</Text>
</Row>
<Text>{description}</Text>
<Row justifyContent="flex-end">
<LinkButton href={url} variant="primary">
<Icon>
<Plus />
</Icon>
<Text>{formatMessage(labels.create)}</Text>
</LinkButton>
</Row>
</Column>
);
}

View file

@ -1,10 +0,0 @@
import { ReportCreatePage } from './ReportCreatePage';
import { Metadata } from 'next';
export default function () {
return <ReportCreatePage />;
}
export const metadata: Metadata = {
title: 'Create Report',
};

View file

@ -1,12 +0,0 @@
.parameter {
display: flex;
gap: 10px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
min-width: 0;
}
.op {
font-weight: bold;
}

View file

@ -1,142 +0,0 @@
import {
Form,
FormField,
FormButtons,
FormSubmitButton,
DialogTrigger,
Icon,
Popover,
} from '@umami/react-zen';
import { Empty } from '@/components/common/Empty';
import { Plus } from '@/components/icons';
import { useApi, useMessages, useReport } from '@/components/hooks';
import { DATA_TYPES, REPORT_PARAMETERS } from '@/lib/constants';
import { FieldAddForm } from '../[reportId]/FieldAddForm';
import { ParameterList } from '../[reportId]/ParameterList';
import { BaseParameters } from '../[reportId]/BaseParameters';
import styles from './EventDataParameters.module.css';
function useFields(websiteId, startDate, endDate) {
const { get, useQuery } = useApi();
const { data, error, isLoading } = useQuery({
queryKey: ['fields', websiteId, startDate, endDate],
queryFn: () =>
get('/reports/event-data', {
websiteId,
startAt: +startDate,
endAt: +endDate,
}),
enabled: !!(websiteId && startDate && endDate),
});
return { data, error, isLoading };
}
export function EventDataParameters() {
const { report, runReport, updateReport, isRunning } = useReport();
const { formatMessage, labels, messages } = useMessages();
const { id, parameters } = report || {};
const { websiteId, dateRange, fields, filters, groups } = parameters || {};
const { startDate, endDate } = dateRange || {};
const queryEnabled = websiteId && dateRange && fields?.length;
const { data, error } = useFields(websiteId, startDate, endDate);
const parametersSelected = websiteId && startDate && endDate;
const hasData = data?.length !== 0;
const parameterGroups = [
{ label: formatMessage(labels.fields), group: REPORT_PARAMETERS.fields },
{ label: formatMessage(labels.filters), group: REPORT_PARAMETERS.filters },
];
const parameterData = {
fields,
filters,
groups,
};
const handleSubmit = (values: any) => {
runReport(values);
};
const handleAdd = (group: string, value: any) => {
const data = parameterData[group];
if (!data.find(({ name }) => name === value?.name)) {
updateReport({ parameters: { [group]: data.concat(value) } });
}
};
const handleRemove = (group: string) => {
const data = [...parameterData[group]];
updateReport({ parameters: { [group]: data.filter(({ name }) => name !== group) } });
};
const AddButton = ({ group, onAdd }) => {
return (
<DialogTrigger>
<Icon>
<Plus />
</Icon>
<Popover placement="bottom start">
{({ close }: any) => {
return (
<FieldAddForm
fields={data.map(({ dataKey, eventDataType }) => ({
name: dataKey,
type: DATA_TYPES[eventDataType],
}))}
group={group}
onAdd={onAdd}
onClose={close}
/>
);
}}
</Popover>
</DialogTrigger>
);
};
return (
<Form values={parameters} error={error} onSubmit={handleSubmit}>
<BaseParameters allowWebsiteSelect={!id} />
{!hasData && <Empty message={formatMessage(messages.noEventData)} />}
{parametersSelected &&
hasData &&
parameterGroups.map(({ label, group }) => {
return (
<FormField name={label} key={label} label={label}>
<ParameterList>
{parameterData[group].map(({ name, value }) => {
return (
<ParameterList.Item key={name} onRemove={() => handleRemove(group)}>
<div className={styles.parameter}>
{group === REPORT_PARAMETERS.fields && (
<>
<div>{name}</div>
<div className={styles.op}>{value}</div>
</>
)}
{group === REPORT_PARAMETERS.filters && (
<>
<div>{name}</div>
<div className={styles.op}>{value[0]}</div>
<div>{value[1]}</div>
</>
)}
</div>
</ParameterList.Item>
);
})}
</ParameterList>
<AddButton group={group} onAdd={handleAdd} />
</FormField>
);
})}
<FormButtons>
<FormSubmitButton variant="primary" isDisabled={!queryEnabled} isLoading={isRunning}>
{formatMessage(labels.runQuery)}
</FormSubmitButton>
</FormButtons>
</Form>
);
}

View file

@ -1,26 +0,0 @@
import { Report } from '../[reportId]/Report';
import { ReportHeader } from '../[reportId]/ReportHeader';
import { ReportMenu } from '../[reportId]/ReportMenu';
import { ReportBody } from '../[reportId]/ReportBody';
import { EventDataParameters } from './EventDataParameters';
import { EventDataTable } from './EventDataTable';
import { Nodes } from '@/components/icons';
const defaultParameters = {
type: 'event-data',
parameters: { fields: [], filters: [] },
};
export function EventDataReport({ reportId }: { reportId?: string }) {
return (
<Report reportId={reportId} defaultParameters={defaultParameters}>
<ReportHeader icon={<Nodes />} />
<ReportMenu>
<EventDataParameters />
</ReportMenu>
<ReportBody>
<EventDataTable />
</ReportBody>
</Report>
);
}

View file

@ -1,6 +0,0 @@
'use client';
import { EventDataReport } from './EventDataReport';
export function EventDataReportPage() {
return <EventDataReport />;
}

View file

@ -1,15 +0,0 @@
import { DataTable, DataColumn } from '@umami/react-zen';
import { useMessages, useReport } from '@/components/hooks';
export function EventDataTable() {
const { report } = useReport();
const { formatMessage, labels } = useMessages();
return (
<DataTable data={report?.data || []}>
<DataColumn id="field" label={formatMessage(labels.field)} />
<DataColumn id="value" label={formatMessage(labels.value)} />
<DataColumn id="total" label={formatMessage(labels.total)} />
</DataTable>
);
}

View file

@ -1,10 +0,0 @@
import { Metadata } from 'next';
import { EventDataReportPage } from './EventDataReportPage';
export default function () {
return <EventDataReportPage />;
}
export const metadata: Metadata = {
title: 'Event Data Report',
};

View file

@ -1,32 +0,0 @@
import { useMessages, useReport } from '@/components/hooks';
import { Form, FormButtons, FormSubmitButton } from '@umami/react-zen';
import { BaseParameters } from '../[reportId]/BaseParameters';
import { FieldParameters } from '../[reportId]/FieldParameters';
import { FilterParameters } from '../[reportId]/FilterParameters';
export function InsightsParameters() {
const { report, runReport, isRunning } = useReport();
const { formatMessage, labels } = useMessages();
const { id, parameters } = report || {};
const { websiteId, dateRange, fields, filters } = parameters || {};
const { startDate, endDate } = dateRange || {};
const parametersSelected = websiteId && startDate && endDate;
const queryEnabled = websiteId && dateRange && (fields?.length || filters?.length);
const handleSubmit = (values: any) => {
runReport(values);
};
return (
<Form values={parameters} onSubmit={handleSubmit}>
<BaseParameters allowWebsiteSelect={!id} />
{parametersSelected && <FieldParameters />}
{parametersSelected && <FilterParameters />}
<FormButtons>
<FormSubmitButton variant="primary" isDisabled={!queryEnabled} isLoading={isRunning}>
{formatMessage(labels.runQuery)}
</FormSubmitButton>
</FormButtons>
</Form>
);
}

View file

@ -1,27 +0,0 @@
import { Report } from '../[reportId]/Report';
import { ReportHeader } from '../[reportId]/ReportHeader';
import { ReportMenu } from '../[reportId]/ReportMenu';
import { ReportBody } from '../[reportId]/ReportBody';
import { InsightsParameters } from './InsightsParameters';
import { InsightsTable } from './InsightsTable';
import { Lightbulb } from '@/components/icons';
import { REPORT_TYPES } from '@/lib/constants';
const defaultParameters = {
type: REPORT_TYPES.insights,
parameters: { fields: [], filters: [] },
};
export function InsightsReport({ reportId }: { reportId?: string }) {
return (
<Report reportId={reportId} defaultParameters={defaultParameters}>
<ReportHeader icon={<Lightbulb />} />
<ReportMenu>
<InsightsParameters />
</ReportMenu>
<ReportBody>
<InsightsTable />
</ReportBody>
</Report>
);
}

View file

@ -1,6 +0,0 @@
'use client';
import { InsightsReport } from './InsightsReport';
export function InsightsReportPage() {
return <InsightsReport />;
}

View file

@ -1,57 +0,0 @@
import { useEffect, useState } from 'react';
import { DataTable, DataColumn } from '@umami/react-zen';
import { useFormat, useMessages, useReport } from '@/components/hooks';
import { EmptyPlaceholder } from '@/components/common/EmptyPlaceholder';
import { formatShortTime } from '@/lib/format';
export function InsightsTable() {
const [fields, setFields] = useState([]);
const { report } = useReport();
const { formatMessage, labels } = useMessages();
const { formatValue } = useFormat();
useEffect(
() => {
setFields(report?.parameters?.fields);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[report?.data],
);
if (!fields || !report?.parameters) {
return <EmptyPlaceholder />;
}
return (
<DataTable data={report?.data || []}>
{fields.map(({ name, label }) => {
return (
<DataColumn key={name} id={name} label={label}>
{row => formatValue(row[name], name)}
</DataColumn>
);
})}
<DataColumn id="views" label={formatMessage(labels.views)} align="end">
{(row: any) => row?.views?.toLocaleString()}
</DataColumn>
<DataColumn id="visits" label={formatMessage(labels.visits)} align="end">
{(row: any) => row?.visits?.toLocaleString()}
</DataColumn>
<DataColumn id="visitors" label={formatMessage(labels.visitors)} align="end">
{(row: any) => row?.visitors?.toLocaleString()}
</DataColumn>
<DataColumn id="bounceRate" label={formatMessage(labels.bounceRate)} align="end">
{(row: any) => {
const n = (Math.min(row?.visits, row?.bounces) / row?.visits) * 100;
return Math.round(+n) + '%';
}}
</DataColumn>
<DataColumn id="visitDuration" label={formatMessage(labels.visitDuration)} align="end">
{(row: any) => {
const n = row?.totaltime / row?.visits;
return `${+n < 0 ? '-' : ''}${formatShortTime(Math.abs(~~n), ['m', 's'], ' ')}`;
}}
</DataColumn>
</DataTable>
);
}

View file

@ -1,10 +0,0 @@
import { InsightsReportPage } from './InsightsReportPage';
import { Metadata } from 'next';
export default function () {
return <InsightsReportPage />;
}
export const metadata: Metadata = {
title: 'Insights Report',
};

View file

@ -1,10 +0,0 @@
import { ReportsPage } from './ReportsPage';
import { Metadata } from 'next';
export default function () {
return <ReportsPage />;
}
export const metadata: Metadata = {
title: 'Reports',
};

View file

@ -43,7 +43,11 @@ export function Funnel({ id, name, type, parameters, websiteId, startDate, endDa
<ReportEditButton id={id} name={name} type={type}> <ReportEditButton id={id} name={name} type={type}>
{({ close }) => { {({ close }) => {
return ( return (
<Dialog title={formatMessage(labels.funnel)} variant="modal"> <Dialog
title={formatMessage(labels.funnel)}
variant="modal"
style={{ minHeight: 300, minWidth: 400 }}
>
<FunnelEditForm id={id} websiteId={websiteId} onClose={close} /> <FunnelEditForm id={id} websiteId={websiteId} onClose={close} />
</Dialog> </Dialog>
); );

View file

@ -52,7 +52,7 @@ export function Goal({ id, name, type, parameters, websiteId, startDate, endDate
<Dialog <Dialog
title={formatMessage(labels.goal)} title={formatMessage(labels.goal)}
variant="modal" variant="modal"
style={{ minHeight: 375, minWidth: 400 }} style={{ minHeight: 300, minWidth: 400 }}
> >
<GoalEditForm id={id} websiteId={websiteId} onClose={close} /> <GoalEditForm id={id} websiteId={websiteId} onClose={close} />
</Dialog> </Dialog>

View file

@ -1,12 +1,13 @@
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { TooltipTrigger, Tooltip, Focusable } from '@umami/react-zen'; import { TooltipTrigger, Tooltip, Focusable, Icon, Text, Row } from '@umami/react-zen';
import { firstBy } from 'thenby'; import { firstBy } from 'thenby';
import classNames from 'classnames'; import classNames from 'classnames';
import { useEscapeKey, useMessages, useResultQuery } from '@/components/hooks'; import { useEscapeKey, useMessages, useResultQuery } from '@/components/hooks';
import { File, Lightning } from '@/components/icons';
import { objectToArray } from '@/lib/data'; import { objectToArray } from '@/lib/data';
import styles from './Journey.module.css';
import { formatLongNumber } from '@/lib/format'; import { formatLongNumber } from '@/lib/format';
import { LoadingPanel } from '@/components/common/LoadingPanel'; import { LoadingPanel } from '@/components/common/LoadingPanel';
import styles from './Journey.module.css';
const NODE_HEIGHT = 60; const NODE_HEIGHT = 60;
const NODE_GAP = 10; const NODE_GAP = 10;
@ -221,11 +222,12 @@ export function Journey({
})} })}
onClick={() => handleClick(name, columnIndex, paths)} onClick={() => handleClick(name, columnIndex, paths)}
> >
<div className={styles.name} title={name}> <Row alignItems="center" className={styles.name} title={name} gap>
{name} <Icon>{name.startsWith('/') ? <File /> : <Lightning />}</Icon>
</div> <Text truncate>{name}</Text>
</Row>
<div className={styles.count} title={nodeCount}> <div className={styles.count} title={nodeCount}>
<TooltipTrigger delay={0}> <TooltipTrigger delay={0} isDisabled={columnIndex === 0}>
<Focusable> <Focusable>
<div>{formatLongNumber(nodeCount)}</div> <div>{formatLongNumber(nodeCount)}</div>
</Focusable> </Focusable>

View file

@ -1,6 +1,15 @@
import { useState } from 'react'; import { useState } from 'react';
import { Column, type ColumnProps, Row, Icon, Button } from '@umami/react-zen'; import {
Column,
type ColumnProps,
Row,
Icon,
Button,
TooltipTrigger,
Tooltip,
} from '@umami/react-zen';
import { Maximize, Close } from '@/components/icons'; import { Maximize, Close } from '@/components/icons';
import { useMessages } from '@/components/hooks';
export interface PanelProps extends ColumnProps { export interface PanelProps extends ColumnProps {
allowFullscreen?: boolean; allowFullscreen?: boolean;
@ -16,6 +25,7 @@ const fullscreenStyles = {
} as any; } as any;
export function Panel({ allowFullscreen, style, children, ...props }: PanelProps) { export function Panel({ allowFullscreen, style, children, ...props }: PanelProps) {
const { formatMessage, labels } = useMessages();
const [isFullscreen, setIsFullscreen] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false);
const handleFullscreen = () => { const handleFullscreen = () => {
@ -35,9 +45,12 @@ export function Panel({ allowFullscreen, style, children, ...props }: PanelProps
> >
{allowFullscreen && ( {allowFullscreen && (
<Row justifyContent="flex-end" alignItems="center"> <Row justifyContent="flex-end" alignItems="center">
<TooltipTrigger delay={0} isDisabled={isFullscreen}>
<Button variant="quiet" onPress={handleFullscreen}> <Button variant="quiet" onPress={handleFullscreen}>
<Icon>{isFullscreen ? <Close /> : <Maximize />}</Icon> <Icon>{isFullscreen ? <Close /> : <Maximize />}</Icon>
</Button> </Button>
<Tooltip>{formatMessage(labels.expand)}</Tooltip>
</TooltipTrigger>
</Row> </Row>
)} )}
{children} {children}

View file

@ -324,6 +324,7 @@ export const labels = defineMessages({
pixels: { id: 'label.pixels', defaultMessage: 'Pixels' }, pixels: { id: 'label.pixels', defaultMessage: 'Pixels' },
addBoard: { id: 'label.add-board', defaultMessage: 'Add board' }, addBoard: { id: 'label.add-board', defaultMessage: 'Add board' },
cohort: { id: 'label.cohort', defaultMessage: 'Cohort' }, cohort: { id: 'label.cohort', defaultMessage: 'Cohort' },
expand: { id: 'label.expand', defaultMessage: 'Expand' },
}); });
export const messages = defineMessages({ export const messages = defineMessages({

View file

@ -1,5 +1,6 @@
import debug from 'debug'; import debug from 'debug';
import { PrismaClient } from '@/generated/prisma/client'; import { PrismaClient } from '@/generated/prisma/client';
import { PrismaPg } from '@prisma/adapter-pg';
import { readReplicas } from '@prisma/extension-read-replicas'; import { readReplicas } from '@prisma/extension-read-replicas';
import { formatInTimeZone } from 'date-fns-tz'; import { formatInTimeZone } from 'date-fns-tz';
import { MYSQL, POSTGRESQL, getDatabaseType } from '@/lib/db'; import { MYSQL, POSTGRESQL, getDatabaseType } from '@/lib/db';
@ -353,7 +354,15 @@ function getClient(params?: {
options, options,
} = params || {}; } = params || {};
const url = new URL(process.env.DATABASE_URL);
const adapter = new PrismaPg(
{ connectionString: url.toString() },
{ schema: url.searchParams.get('schema') },
);
const prisma = new PrismaClient({ const prisma = new PrismaClient({
adapter,
errorFormat: 'pretty', errorFormat: 'pretty',
...(logQuery && PRISMA_LOG_OPTIONS), ...(logQuery && PRISMA_LOG_OPTIONS),
...options, ...options,

View file

@ -15,11 +15,6 @@ a:hover {
text-decoration: none; text-decoration: none;
} }
:where(svg) {
width: 1rem;
height: 1rem;
}
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 15px; width: 15px;
background: transparent; background: transparent;