mirror of
https://github.com/umami-software/umami.git
synced 2026-02-04 04:37:11 +01:00
Upgraded Prisma, use new query compiler. Removed old reports.
This commit is contained in:
parent
a167c590c5
commit
2af95b5802
58 changed files with 88 additions and 2224 deletions
|
|
@ -1,6 +1,6 @@
|
|||
generator client {
|
||||
provider = "prisma-client"
|
||||
previewFeatures = ["driverAdapters"]
|
||||
previewFeatures = ["queryCompiler", "driverAdapters"]
|
||||
output = "../src/generated/prisma"
|
||||
moduleFormat = "esm"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -76,7 +76,7 @@
|
|||
"@fontsource/inter": "^4.5.15",
|
||||
"@hello-pangea/dnd": "^17.0.0",
|
||||
"@prisma/adapter-pg": "^6.8.2",
|
||||
"@prisma/client": "^6.8.2",
|
||||
"@prisma/client": "^6.9.0",
|
||||
"@prisma/extension-read-replicas": "^0.4.1",
|
||||
"@react-spring/web": "^9.7.3",
|
||||
"@svgr/cli": "^8.1.0",
|
||||
|
|
@ -114,7 +114,7 @@
|
|||
"node-fetch": "^3.2.8",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"pg": "^8.16.0",
|
||||
"prisma": "6.8.2",
|
||||
"prisma": "6.9.0",
|
||||
"pure-rand": "^6.1.0",
|
||||
"react": "^19.0.0",
|
||||
"react-basics": "^0.126.0",
|
||||
|
|
|
|||
83
pnpm-lock.yaml
generated
83
pnpm-lock.yaml
generated
|
|
@ -30,11 +30,11 @@ importers:
|
|||
specifier: ^6.8.2
|
||||
version: 6.8.2(pg@8.16.0)
|
||||
'@prisma/client':
|
||||
specifier: ^6.8.2
|
||||
version: 6.8.2(prisma@6.8.2(typescript@5.8.3))(typescript@5.8.3)
|
||||
specifier: ^6.9.0
|
||||
version: 6.9.0(prisma@6.9.0(typescript@5.8.3))(typescript@5.8.3)
|
||||
'@prisma/extension-read-replicas':
|
||||
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':
|
||||
specifier: ^9.7.3
|
||||
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
|
||||
version: 8.16.0
|
||||
prisma:
|
||||
specifier: 6.8.2
|
||||
version: 6.8.2(typescript@5.8.3)
|
||||
specifier: 6.9.0
|
||||
version: 6.9.0(typescript@5.8.3)
|
||||
pure-rand:
|
||||
specifier: ^6.1.0
|
||||
version: 6.1.0
|
||||
|
|
@ -1508,8 +1508,8 @@ packages:
|
|||
peerDependencies:
|
||||
pg: ^8.11.3
|
||||
|
||||
'@prisma/client@6.8.2':
|
||||
resolution: {integrity: sha512-5II+vbyzv4si6Yunwgkj0qT/iY0zyspttoDrL3R4BYgLdp42/d2C8xdi9vqkrYtKt9H32oFIukvyw3Koz5JoDg==}
|
||||
'@prisma/client@6.9.0':
|
||||
resolution: {integrity: sha512-Gg7j1hwy3SgF1KHrh0PZsYvAaykeR0PaxusnLXydehS96voYCGt1U5zVR31NIouYc63hWzidcrir1a7AIyCsNQ==}
|
||||
engines: {node: '>=18.18'}
|
||||
peerDependencies:
|
||||
prisma: '*'
|
||||
|
|
@ -1520,31 +1520,34 @@ packages:
|
|||
typescript:
|
||||
optional: true
|
||||
|
||||
'@prisma/config@6.8.2':
|
||||
resolution: {integrity: sha512-ZJY1fF4qRBPdLQ/60wxNtX+eu89c3AkYEcP7L3jkp0IPXCNphCYxikTg55kPJLDOG6P0X+QG5tCv6CmsBRZWFQ==}
|
||||
'@prisma/config@6.9.0':
|
||||
resolution: {integrity: sha512-Wcfk8/lN3WRJd5w4jmNQkUwhUw0eksaU/+BlAJwPQKW10k0h0LC9PD/6TQFmqKVbHQL0vG2z266r0S1MPzzhbA==}
|
||||
|
||||
'@prisma/debug@6.8.2':
|
||||
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':
|
||||
resolution: {integrity: sha512-5+CzN/41gBsRmA3ekbVy1TXnSImSPBtMlxWAttVH6tg94bv4zGGRmyk5tUCdT83nl0hG1Sq2oMXR7ml6aqILvw==}
|
||||
|
||||
'@prisma/engines-version@6.8.0-43.2060c79ba17c6bb9f5823312b6f6b7f4a845738e':
|
||||
resolution: {integrity: sha512-Rkik9lMyHpFNGaLpPF3H5q5TQTkm/aE7DsGM5m92FZTvWQsvmi6Va8On3pWvqLHOt5aPUvFb/FeZTmphI4CPiQ==}
|
||||
'@prisma/engines-version@6.9.0-10.81e4af48011447c3cc503a190e86995b66d2a28e':
|
||||
resolution: {integrity: sha512-Qp9gMoBHgqhKlrvumZWujmuD7q4DV/gooEyPCLtbkc13EZdSz2RsGUJ5mHb3RJgAbk+dm6XenqG7obJEhXcJ6Q==}
|
||||
|
||||
'@prisma/engines@6.8.2':
|
||||
resolution: {integrity: sha512-XqAJ//LXjqYRQ1RRabs79KOY4+v6gZOGzbcwDQl0D6n9WBKjV7qdrbd042CwSK0v0lM9MSHsbcFnU2Yn7z8Zlw==}
|
||||
'@prisma/engines@6.9.0':
|
||||
resolution: {integrity: sha512-im0X0bwDLA0244CDf8fuvnLuCQcBBdAGgr+ByvGfQY9wWl6EA+kRGwVk8ZIpG65rnlOwtaWIr/ZcEU5pNVvq9g==}
|
||||
|
||||
'@prisma/extension-read-replicas@0.4.1':
|
||||
resolution: {integrity: sha512-mCMDloqUKUwx2o5uedTs1FHX3Nxdt1GdRMoeyp1JggjiwOALmIYWhxfIN08M2BZ0w8SKwvJqicJZMjkQYkkijw==}
|
||||
peerDependencies:
|
||||
'@prisma/client': ^6.5.0
|
||||
|
||||
'@prisma/fetch-engine@6.8.2':
|
||||
resolution: {integrity: sha512-lCvikWOgaLOfqXGacEKSNeenvj0n3qR5QvZUOmPE2e1Eh8cMYSobxonCg9rqM6FSdTfbpqp9xwhSAOYfNqSW0g==}
|
||||
'@prisma/fetch-engine@6.9.0':
|
||||
resolution: {integrity: sha512-PMKhJdl4fOdeE3J3NkcWZ+tf3W6rx3ht/rLU8w4SXFRcLhd5+3VcqY4Kslpdm8osca4ej3gTfB3+cSk5pGxgFg==}
|
||||
|
||||
'@prisma/get-platform@6.8.2':
|
||||
resolution: {integrity: sha512-vXSxyUgX3vm1Q70QwzwkjeYfRryIvKno1SXbIqwSptKwqKzskINnDUcx85oX+ys6ooN2ATGSD0xN2UTfg6Zcow==}
|
||||
'@prisma/get-platform@6.9.0':
|
||||
resolution: {integrity: sha512-/B4n+5V1LI/1JQcHp+sUpyRT1bBgZVPHbsC4lt4/19Xp4jvNIVcq5KYNtQDk5e/ukTSjo9PZVAxxy9ieFtlpTQ==}
|
||||
|
||||
'@react-aria/autocomplete@3.0.0-beta.3':
|
||||
resolution: {integrity: sha512-8haBygHNMqVt4Ge90VOk+iVlLW+zhiOGHYz2IKCE6+Sy1dTE6mzhHjxrtwWYnSez/OQLbxjHlwLch4CDd5JkLA==}
|
||||
|
|
@ -5906,8 +5909,8 @@ packages:
|
|||
resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==}
|
||||
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
|
||||
|
||||
prisma@6.8.2:
|
||||
resolution: {integrity: sha512-JNricTXQxzDtRS7lCGGOB4g5DJ91eg3nozdubXze3LpcMl1oWwcFddrj++Up3jnRE6X/3gB/xz3V+ecBk/eEGA==}
|
||||
prisma@6.9.0:
|
||||
resolution: {integrity: sha512-resJAwMyZREC/I40LF6FZ6rZTnlrlrYrb63oW37Gq+U+9xHwbyMSPJjKtM7VZf3gTO86t/Oyz+YeSXr3CmAY1Q==}
|
||||
engines: {node: '>=18.18'}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
|
|
@ -8209,43 +8212,45 @@ snapshots:
|
|||
pg: 8.16.0
|
||||
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:
|
||||
prisma: 6.8.2(typescript@5.8.3)
|
||||
prisma: 6.9.0(typescript@5.8.3)
|
||||
typescript: 5.8.3
|
||||
|
||||
'@prisma/config@6.8.2':
|
||||
'@prisma/config@6.9.0':
|
||||
dependencies:
|
||||
jiti: 2.4.2
|
||||
|
||||
'@prisma/debug@6.8.2': {}
|
||||
|
||||
'@prisma/debug@6.9.0': {}
|
||||
|
||||
'@prisma/driver-adapter-utils@6.8.2':
|
||||
dependencies:
|
||||
'@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:
|
||||
'@prisma/debug': 6.8.2
|
||||
'@prisma/engines-version': 6.8.0-43.2060c79ba17c6bb9f5823312b6f6b7f4a845738e
|
||||
'@prisma/fetch-engine': 6.8.2
|
||||
'@prisma/get-platform': 6.8.2
|
||||
'@prisma/debug': 6.9.0
|
||||
'@prisma/engines-version': 6.9.0-10.81e4af48011447c3cc503a190e86995b66d2a28e
|
||||
'@prisma/fetch-engine': 6.9.0
|
||||
'@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:
|
||||
'@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:
|
||||
'@prisma/debug': 6.8.2
|
||||
'@prisma/engines-version': 6.8.0-43.2060c79ba17c6bb9f5823312b6f6b7f4a845738e
|
||||
'@prisma/get-platform': 6.8.2
|
||||
'@prisma/debug': 6.9.0
|
||||
'@prisma/engines-version': 6.9.0-10.81e4af48011447c3cc503a190e86995b66d2a28e
|
||||
'@prisma/get-platform': 6.9.0
|
||||
|
||||
'@prisma/get-platform@6.8.2':
|
||||
'@prisma/get-platform@6.9.0':
|
||||
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)':
|
||||
dependencies:
|
||||
|
|
@ -13603,10 +13608,10 @@ snapshots:
|
|||
ansi-styles: 5.2.0
|
||||
react-is: 18.3.1
|
||||
|
||||
prisma@6.8.2(typescript@5.8.3):
|
||||
prisma@6.9.0(typescript@5.8.3):
|
||||
dependencies:
|
||||
'@prisma/config': 6.8.2
|
||||
'@prisma/engines': 6.8.2
|
||||
'@prisma/config': 6.9.0
|
||||
'@prisma/engines': 6.9.0
|
||||
optionalDependencies:
|
||||
typescript: 5.8.3
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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',
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
.container {
|
||||
display: grid;
|
||||
grid-template-rows: max-content 1fr;
|
||||
grid-template-columns: max-content 1fr;
|
||||
margin-bottom: 60px;
|
||||
height: 90vh;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
.body {
|
||||
padding-inline-start: 20px;
|
||||
grid-row: 2 / 3;
|
||||
grid-column: 2 / 3;
|
||||
}
|
||||
|
|
@ -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>;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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} />;
|
||||
}
|
||||
|
|
@ -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',
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
'use client';
|
||||
import AttributionReport from './AttributionReport';
|
||||
|
||||
export default function AttributionReportPage() {
|
||||
return <AttributionReport />;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
import AttributionReportPage from './AttributionReportPage';
|
||||
import { Metadata } from 'next';
|
||||
|
||||
export default function () {
|
||||
return <AttributionReportPage />;
|
||||
}
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Attribution Report',
|
||||
};
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
'use client';
|
||||
import { ReportTemplates } from './ReportTemplates';
|
||||
|
||||
export function ReportCreatePage() {
|
||||
return <ReportTemplates />;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
import { ReportCreatePage } from './ReportCreatePage';
|
||||
import { Metadata } from 'next';
|
||||
|
||||
export default function () {
|
||||
return <ReportCreatePage />;
|
||||
}
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Create Report',
|
||||
};
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
.parameter {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.op {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
'use client';
|
||||
import { EventDataReport } from './EventDataReport';
|
||||
|
||||
export function EventDataReportPage() {
|
||||
return <EventDataReport />;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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',
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
'use client';
|
||||
import { InsightsReport } from './InsightsReport';
|
||||
|
||||
export function InsightsReportPage() {
|
||||
return <InsightsReport />;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
import { InsightsReportPage } from './InsightsReportPage';
|
||||
import { Metadata } from 'next';
|
||||
|
||||
export default function () {
|
||||
return <InsightsReportPage />;
|
||||
}
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Insights Report',
|
||||
};
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
import { ReportsPage } from './ReportsPage';
|
||||
import { Metadata } from 'next';
|
||||
|
||||
export default function () {
|
||||
return <ReportsPage />;
|
||||
}
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Reports',
|
||||
};
|
||||
|
|
@ -43,7 +43,11 @@ export function Funnel({ id, name, type, parameters, websiteId, startDate, endDa
|
|||
<ReportEditButton id={id} name={name} type={type}>
|
||||
{({ close }) => {
|
||||
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} />
|
||||
</Dialog>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ export function Goal({ id, name, type, parameters, websiteId, startDate, endDate
|
|||
<Dialog
|
||||
title={formatMessage(labels.goal)}
|
||||
variant="modal"
|
||||
style={{ minHeight: 375, minWidth: 400 }}
|
||||
style={{ minHeight: 300, minWidth: 400 }}
|
||||
>
|
||||
<GoalEditForm id={id} websiteId={websiteId} onClose={close} />
|
||||
</Dialog>
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
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 classNames from 'classnames';
|
||||
import { useEscapeKey, useMessages, useResultQuery } from '@/components/hooks';
|
||||
import { File, Lightning } from '@/components/icons';
|
||||
import { objectToArray } from '@/lib/data';
|
||||
import styles from './Journey.module.css';
|
||||
import { formatLongNumber } from '@/lib/format';
|
||||
import { LoadingPanel } from '@/components/common/LoadingPanel';
|
||||
import styles from './Journey.module.css';
|
||||
|
||||
const NODE_HEIGHT = 60;
|
||||
const NODE_GAP = 10;
|
||||
|
|
@ -221,11 +222,12 @@ export function Journey({
|
|||
})}
|
||||
onClick={() => handleClick(name, columnIndex, paths)}
|
||||
>
|
||||
<div className={styles.name} title={name}>
|
||||
{name}
|
||||
</div>
|
||||
<Row alignItems="center" className={styles.name} title={name} gap>
|
||||
<Icon>{name.startsWith('/') ? <File /> : <Lightning />}</Icon>
|
||||
<Text truncate>{name}</Text>
|
||||
</Row>
|
||||
<div className={styles.count} title={nodeCount}>
|
||||
<TooltipTrigger delay={0}>
|
||||
<TooltipTrigger delay={0} isDisabled={columnIndex === 0}>
|
||||
<Focusable>
|
||||
<div>{formatLongNumber(nodeCount)}</div>
|
||||
</Focusable>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,15 @@
|
|||
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 { useMessages } from '@/components/hooks';
|
||||
|
||||
export interface PanelProps extends ColumnProps {
|
||||
allowFullscreen?: boolean;
|
||||
|
|
@ -16,6 +25,7 @@ const fullscreenStyles = {
|
|||
} as any;
|
||||
|
||||
export function Panel({ allowFullscreen, style, children, ...props }: PanelProps) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
|
||||
const handleFullscreen = () => {
|
||||
|
|
@ -35,9 +45,12 @@ export function Panel({ allowFullscreen, style, children, ...props }: PanelProps
|
|||
>
|
||||
{allowFullscreen && (
|
||||
<Row justifyContent="flex-end" alignItems="center">
|
||||
<TooltipTrigger delay={0} isDisabled={isFullscreen}>
|
||||
<Button variant="quiet" onPress={handleFullscreen}>
|
||||
<Icon>{isFullscreen ? <Close /> : <Maximize />}</Icon>
|
||||
</Button>
|
||||
<Tooltip>{formatMessage(labels.expand)}</Tooltip>
|
||||
</TooltipTrigger>
|
||||
</Row>
|
||||
)}
|
||||
{children}
|
||||
|
|
|
|||
|
|
@ -324,6 +324,7 @@ export const labels = defineMessages({
|
|||
pixels: { id: 'label.pixels', defaultMessage: 'Pixels' },
|
||||
addBoard: { id: 'label.add-board', defaultMessage: 'Add board' },
|
||||
cohort: { id: 'label.cohort', defaultMessage: 'Cohort' },
|
||||
expand: { id: 'label.expand', defaultMessage: 'Expand' },
|
||||
});
|
||||
|
||||
export const messages = defineMessages({
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import debug from 'debug';
|
||||
import { PrismaClient } from '@/generated/prisma/client';
|
||||
import { PrismaPg } from '@prisma/adapter-pg';
|
||||
import { readReplicas } from '@prisma/extension-read-replicas';
|
||||
import { formatInTimeZone } from 'date-fns-tz';
|
||||
import { MYSQL, POSTGRESQL, getDatabaseType } from '@/lib/db';
|
||||
|
|
@ -353,7 +354,15 @@ function getClient(params?: {
|
|||
options,
|
||||
} = 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({
|
||||
adapter,
|
||||
errorFormat: 'pretty',
|
||||
...(logQuery && PRISMA_LOG_OPTIONS),
|
||||
...options,
|
||||
|
|
|
|||
|
|
@ -15,11 +15,6 @@ a:hover {
|
|||
text-decoration: none;
|
||||
}
|
||||
|
||||
:where(svg) {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 15px;
|
||||
background: transparent;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue