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 {
|
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"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
83
pnpm-lock.yaml
generated
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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}>
|
<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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue