Updates to reports.

This commit is contained in:
Mike Cao 2023-06-02 23:10:59 -07:00
parent 1869a809cf
commit 22d477b98b
22 changed files with 388 additions and 117 deletions

View File

@ -1,8 +1,7 @@
.menu {
display: flex;
flex-flow: row wrap;
min-width: 600px;
max-width: 100vw;
max-width: 640px;
padding: 10px;
background: var(--base50);
z-index: var(--z-index-popup);

View File

@ -129,6 +129,8 @@ export const labels = defineMessages({
window: { id: 'label.window', defaultMessage: 'Window' },
addUrl: { id: 'label.add-url', defaultMessage: 'Add URL' },
runQuery: { id: 'label.run-query', defaultMessage: 'Run query' },
fields: { id: 'label.fields', defaultMessage: 'Fields' },
addField: { id: 'label.add-field', defaultMessage: 'Add field' },
});
export const messages = defineMessages({

View File

@ -0,0 +1,43 @@
import { FormRow } from 'react-basics';
import DateFilter from 'components/input/DateFilter';
import WebsiteSelect from 'components/input/WebsiteSelect';
import { parseDateRange } from 'lib/date';
import { useContext } from 'react';
import { ReportContext } from './Report';
import { useMessages } from 'hooks';
export function BaseParameters() {
const { report, updateReport } = useContext(ReportContext);
const { formatMessage, labels } = useMessages();
const { parameters } = report || {};
const { websiteId, dateRange } = parameters || {};
const { value, startDate, endDate } = dateRange || {};
const handleWebsiteSelect = websiteId => {
updateReport({ parameters: { websiteId } });
};
const handleDateChange = value => {
updateReport({ parameters: { dateRange: { ...parseDateRange(value) } } });
};
return (
<>
<FormRow label={formatMessage(labels.website)}>
<WebsiteSelect websiteId={websiteId} onSelect={handleWebsiteSelect} />
</FormRow>
<FormRow label={formatMessage(labels.dateRange)}>
<DateFilter
value={value}
startDate={startDate}
endDate={endDate}
onChange={handleDateChange}
showAllTime
/>
</FormRow>
</>
);
}
export default BaseParameters;

View File

@ -8,8 +8,6 @@ export const ReportContext = createContext(null);
export function Report({ reportId, defaultParameters, children, ...props }) {
const report = useReport(reportId, defaultParameters);
//console.log(report);
return (
<ReportContext.Provider value={{ ...report }}>
<Page {...props} className={styles.container}>

View File

@ -64,24 +64,14 @@ export function ReportHeader({ icon }) {
return (
<PageHeader title={<Title />} className={styles.header}>
<Flexbox gap={20}>
<DateFilter
value={value}
startDate={startDate}
endDate={endDate}
onChange={handleDateChange}
showAllTime
/>
<WebsiteSelect websiteId={websiteId} onSelect={handleWebsiteSelect} />
<LoadingButton
variant="primary"
loading={isCreating || isUpdating}
disabled={!websiteId || !value}
onClick={handleSave}
>
{formatMessage(labels.save)}
</LoadingButton>
</Flexbox>
<LoadingButton
variant="primary"
loading={isCreating || isUpdating}
disabled={!websiteId || !value}
onClick={handleSave}
>
{formatMessage(labels.save)}
</LoadingButton>
{toast}
</PageHeader>
);

View File

@ -10,7 +10,7 @@ import styles from './ReportList.module.css';
const reports = [
{
title: 'Event data',
description: 'Query your event data.',
description: 'Query your custom event data.',
url: '/reports/event-data',
icon: <Nodes />,
},

View File

@ -0,0 +1,66 @@
import { useContext, useRef } from 'react';
import { useApi, useMessages } from 'hooks';
import { Form, FormRow, FormButtons, SubmitButton, Loading } from 'react-basics';
import { ReportContext } from 'components/pages/reports/Report';
import NoData from 'components/common/NoData';
import styles from './EventDataParameters.module.css';
import { DATA_TYPES } from 'lib/constants';
function useFields(websiteId, startDate, endDate) {
const { get, useQuery } = useApi();
const { data, error, isLoading } = useQuery(
['fields', websiteId, startDate, endDate],
() => get('/reports/event-data', { websiteId, startAt: +startDate, endAt: +endDate }),
{ enabled: !!(websiteId && startDate && endDate) },
);
return { data, error, isLoading };
}
export function EventDataParameters() {
const { report, runReport, isRunning } = useContext(ReportContext);
const { formatMessage, labels } = useMessages();
const ref = useRef(null);
const { parameters } = report || {};
const { websiteId, dateRange } = parameters || {};
const { startDate, endDate } = dateRange || {};
const queryDisabled = !websiteId || !dateRange;
const { data, error, isLoading } = useFields(websiteId, startDate, endDate);
const handleSubmit = values => {
runReport(values);
};
if (!websiteId || !dateRange) {
return null;
}
if (isLoading) {
return <Loading icon="dots" />;
}
return (
<Form ref={ref} values={parameters} error={error} onSubmit={handleSubmit}>
<FormRow label={formatMessage(labels.fields)}>
<div className={styles.fields}>
{!data?.length && <NoData />}
{data?.map?.(({ eventKey, eventDataType }) => {
return (
<div className={styles.field} key={eventKey}>
<div className={styles.key}>{eventKey}</div>
<div className={styles.type}>{DATA_TYPES[eventDataType]}</div>
</div>
);
})}
</div>
</FormRow>
<FormButtons>
<SubmitButton variant="primary" disabled={queryDisabled} loading={isRunning}>
{formatMessage(labels.runQuery)}
</SubmitButton>
</FormButtons>
</Form>
);
}
export default EventDataParameters;

View File

@ -0,0 +1,27 @@
.fields {
max-height: 300px;
overflow: auto;
}
.field {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
height: 30px;
cursor: pointer;
padding: 4px;
border-radius: var(--border-radius);
}
.field:hover {
background: var(--base75);
}
.key {
font-weight: 400;
}
.type {
color: var(--font-color300);
}

View File

@ -1,30 +1,23 @@
import { useState } from 'react';
import { Form, FormRow, FormInput, TextField } from 'react-basics';
import Report from '../Report';
import ReportHeader from '../ReportHeader';
import useMessages from 'hooks/useMessages';
import ReportMenu from '../ReportMenu';
import ReportBody from '../ReportBody';
import EventDataParameters from './EventDataParameters';
import Nodes from 'assets/nodes.svg';
import styles from '../reports.module.css';
export default function EventDataReport({ websiteId, data }) {
const [values, setValues] = useState({ query: '' });
const { formatMessage, labels } = useMessages();
const defaultParameters = {
type: 'event-data',
parameters: { fields: [], filters: [] },
};
export default function EventDataReport({ reportId }) {
return (
<Report>
<ReportHeader title={formatMessage(labels.eventData)} icon={<Nodes />} />
<div className={styles.container}>
<div className={styles.menu}>
<Form>
<FormRow label="Properties">
<FormInput name="query">
<TextField value={values.query} />
</FormInput>
</FormRow>
</Form>
</div>
<div className={styles.content}></div>
</div>
<Report reportId={reportId} defaultParameters={defaultParameters}>
<ReportHeader icon={<Nodes />} />
<ReportMenu>
<EventDataParameters />
</ReportMenu>
<ReportBody>hi.</ReportBody>
</Report>
);
}

View File

@ -0,0 +1,28 @@
import { useMessages } from 'hooks';
import { Button, Form, FormButtons, FormRow } from 'react-basics';
export function FieldAddForm({ onClose }) {
const { formatMessage, labels } = useMessages();
const handleSave = () => {
onClose();
};
const handleClose = () => {
onClose();
};
return (
<Form>
<FormRow label={formatMessage(labels.url)}></FormRow>
<FormButtons align="center" flex>
<Button variant="primary" onClick={handleSave}>
{formatMessage(labels.save)}
</Button>
<Button onClick={handleClose}>{formatMessage(labels.cancel)}</Button>
</FormButtons>
</Form>
);
}
export default FieldAddForm;

View File

@ -6,98 +6,99 @@ import {
FormButtons,
FormInput,
FormRow,
Modal,
PopupTrigger,
Popup,
SubmitButton,
Text,
TextField,
Tooltip,
} from 'react-basics';
import Icons from 'components/icons';
import AddUrlForm from './AddUrlForm';
import UrlAddForm from './UrlAddForm';
import { ReportContext } from 'components/pages/reports/Report';
import styles from './FunnelParameters.module.css';
import BaseParameters from '../BaseParameters';
export function FunnelParameters() {
const { report, runReport, updateReport, isRunning } = useContext(ReportContext);
const { formatMessage, labels } = useMessages();
const [show, setShow] = useState(false);
const ref = useRef(null);
const { websiteId, parameters } = report || {};
const queryDisabled = !websiteId || parameters?.urls?.length < 2;
const { parameters } = report || {};
const { websiteId, dateRange, urls } = parameters || {};
const queryDisabled = !websiteId || !dateRange || urls?.length < 2;
const handleSubmit = values => {
runReport(values);
};
const handleAddUrl = url => {
updateReport({ parameters: { ...parameters, urls: parameters.urls.concat(url) } });
updateReport({ parameters: { urls: parameters.urls.concat(url) } });
};
const handleRemoveUrl = (index, e) => {
e.stopPropagation();
const urls = [...parameters.urls];
urls.splice(index, 1);
updateReport({ parameters: { ...parameters, urls } });
updateReport({ parameters: { urls } });
};
const showAddForm = () => setShow(true);
const hideAddForm = () => setShow(false);
return (
<>
<Form ref={ref} values={parameters} onSubmit={handleSubmit}>
<FormRow label={formatMessage(labels.window)}>
<FormInput
name="window"
rules={{ required: formatMessage(labels.required), pattern: /[0-9]+/ }}
>
<TextField autoComplete="off" />
</FormInput>
</FormRow>
<FormRow label={formatMessage(labels.urls)} action={<AddUrlButton onClick={showAddForm} />}>
<div className={styles.urls}>
{parameters?.urls?.map((url, index) => {
return (
<div key={index} className={styles.url}>
<Text>{url}</Text>
<Tooltip
className={styles.icon}
label={formatMessage(labels.remove)}
position="right"
>
<Icon onClick={handleRemoveUrl.bind(null, index)}>
<Icons.Close />
</Icon>
</Tooltip>
</div>
);
})}
</div>
</FormRow>
<FormButtons>
<SubmitButton variant="primary" disabled={queryDisabled} loading={isRunning}>
{formatMessage(labels.runQuery)}
</SubmitButton>
</FormButtons>
</Form>
{show && (
<Modal onClose={hideAddForm}>
<AddUrlForm onSave={handleAddUrl} onClose={hideAddForm} />
</Modal>
)}
</>
<Form ref={ref} values={parameters} onSubmit={handleSubmit}>
<BaseParameters />
<FormRow label={formatMessage(labels.window)}>
<FormInput
name="window"
rules={{ required: formatMessage(labels.required), pattern: /[0-9]+/ }}
>
<TextField autoComplete="off" />
</FormInput>
</FormRow>
<FormRow label={formatMessage(labels.urls)} action={<AddUrlButton onAdd={handleAddUrl} />}>
<div className={styles.urls}>
{parameters?.urls?.map((url, index) => {
return (
<div key={index} className={styles.url}>
<Text>{url}</Text>
<Tooltip
className={styles.icon}
label={formatMessage(labels.remove)}
position="right"
>
<Icon onClick={handleRemoveUrl.bind(null, index)}>
<Icons.Close />
</Icon>
</Tooltip>
</div>
);
})}
</div>
</FormRow>
<FormButtons>
<SubmitButton variant="primary" disabled={queryDisabled} loading={isRunning}>
{formatMessage(labels.runQuery)}
</SubmitButton>
</FormButtons>
</Form>
);
}
function AddUrlButton({ onClick }) {
function AddUrlButton({ onAdd }) {
const { formatMessage, labels } = useMessages();
return (
<Tooltip label={formatMessage(labels.addUrl)}>
<Icon onClick={onClick}>
<Icons.Plus />
</Icon>
</Tooltip>
<PopupTrigger>
<Tooltip label={formatMessage(labels.addUrl)}>
<Icon>
<Icons.Plus />
</Icon>
</Tooltip>
<Popup position="bottom" alignment="start">
{close => {
return <UrlAddForm onSave={onAdd} onClose={close} />;
}}
</Popup>
</PopupTrigger>
);
}

View File

@ -1,17 +1,15 @@
import { useContext } from 'react';
import FunnelChart from './FunnelChart';
import FunnelTable from './FunnelTable';
import FunnelParameters from './FunnelParameters';
import Report, { ReportContext } from '../Report';
import Report from '../Report';
import ReportHeader from '../ReportHeader';
import ReportMenu from '../ReportMenu';
import ReportBody from '../ReportBody';
import Funnel from 'assets/funnel.svg';
import { useReport } from 'hooks';
const defaultParameters = {
type: 'funnel',
parameters: { window: 60, urls: ['/', '/docs'] },
parameters: { window: 60, urls: [] },
};
export default function FunnelReport({ reportId }) {

View File

@ -1,8 +1,9 @@
import { useState } from 'react';
import { useMessages } from 'hooks';
import { Button, Form, FormButtons, FormRow, TextField } from 'react-basics';
import styles from './UrlAddForm.module.css';
export function AddUrlForm({ defaultValue = '', onSave, onClose }) {
export function UrlAddForm({ defaultValue = '', onSave, onClose }) {
const [url, setUrl] = useState(defaultValue);
const { formatMessage, labels } = useMessages();
@ -22,7 +23,7 @@ export function AddUrlForm({ defaultValue = '', onSave, onClose }) {
};
return (
<Form>
<Form className={styles.form}>
<FormRow label={formatMessage(labels.url)}>
<TextField
name="url"
@ -42,4 +43,4 @@ export function AddUrlForm({ defaultValue = '', onSave, onClose }) {
);
}
export default AddUrlForm;
export default UrlAddForm;

View File

@ -0,0 +1,8 @@
.form {
background: var(--base50);
padding: 30px;
margin-top: 10px;
border: 1px solid var(--base400);
border-radius: var(--border-radius);
box-shadow: 0 0 0 5px rgba(0, 0, 0, 0.1);
}

View File

@ -1,6 +1,6 @@
import { useEffect } from 'react';
import { useRouter } from 'next/router';
import { get, setItem } from 'next-basics';
import { httpGet, setItem } from 'next-basics';
import { LOCALE_CONFIG } from 'lib/constants';
import { getDateLocale, getTextDirection } from 'lib/lang';
import useStore, { setLocale } from 'store/app';
@ -21,7 +21,7 @@ export function useLocale() {
const dateLocale = getDateLocale(locale);
async function loadMessages(locale) {
const { ok, data } = await get(`${basePath}/intl/messages/${locale}.json`);
const { ok, data } = await httpGet(`${basePath}/intl/messages/${locale}.json`);
if (ok) {
messages[locale] = data;

View File

@ -69,6 +69,14 @@ export const DYNAMIC_DATA_TYPE = {
array: 5,
} as const;
export const DATA_TYPES = {
[DYNAMIC_DATA_TYPE.string]: 'string',
[DYNAMIC_DATA_TYPE.number]: 'number',
[DYNAMIC_DATA_TYPE.boolean]: 'boolean',
[DYNAMIC_DATA_TYPE.date]: 'date',
[DYNAMIC_DATA_TYPE.array]: 'array',
};
export const KAFKA_TOPIC = {
event: 'event',
eventData: 'event_data',

View File

@ -95,7 +95,7 @@
"node-fetch": "^3.2.8",
"npm-run-all": "^4.1.5",
"react": "^18.2.0",
"react-basics": "^0.82.0",
"react-basics": "^0.83.0",
"react-beautiful-dnd": "^13.1.0",
"react-dom": "^18.2.0",
"react-error-boundary": "^4.0.4",

View File

@ -0,0 +1,60 @@
import { canViewWebsite } from 'lib/auth';
import { useCors, useAuth } from 'lib/middleware';
import { NextApiRequestQueryBody } from 'lib/types';
import { NextApiResponse } from 'next';
import { ok, methodNotAllowed, unauthorized } from 'next-basics';
import { getEventDataFields } from 'queries/analytics/eventData/getEventDataFields';
export interface EventDataRequestBody {
websiteId: string;
urls: string[];
window: number;
dateRange: {
startDate: string;
endDate: string;
};
}
export interface EventDataResponse {
urls: string[];
window: number;
startAt: number;
endAt: number;
}
export default async (
req: NextApiRequestQueryBody<any, EventDataRequestBody>,
res: NextApiResponse<EventDataResponse>,
) => {
await useCors(req, res);
await useAuth(req, res);
if (req.method === 'GET') {
const { websiteId, startAt, endAt } = req.query;
if (!(await canViewWebsite(req.auth, websiteId))) {
return unauthorized(res);
}
const data = await getEventDataFields(websiteId, new Date(+startAt), new Date(+endAt));
return ok(res, data);
}
if (req.method === 'POST') {
const {
websiteId,
dateRange: { startDate, endDate },
} = req.body;
if (!(await canViewWebsite(req.auth, websiteId))) {
return unauthorized(res);
}
const data = {};
return ok(res, data);
}
return methodNotAllowed(res);
};

View File

@ -0,0 +1,48 @@
import clickhouse from 'lib/clickhouse';
import { CLICKHOUSE, PRISMA, runQuery } from 'lib/db';
import prisma from 'lib/prisma';
import { WebsiteEventDataMetric } from 'lib/types';
import { loadWebsite } from 'lib/query';
export async function getEventDataFields(
...args: [websiteId: string, startDate: Date, endDate: Date]
): Promise<WebsiteEventDataMetric[]> {
return runQuery({
[PRISMA]: () => relationalQuery(...args),
[CLICKHOUSE]: () => clickhouseQuery(...args),
});
}
async function relationalQuery(websiteId: string, startDate: Date, endDate: Date) {
const { toUuid, rawQuery } = prisma;
const website = await loadWebsite(websiteId);
const resetDate = new Date(website?.resetAt || website?.createdAt);
const params: any = [websiteId, resetDate, startDate, endDate];
return rawQuery(
`select
distinct event_key as eventKey, data_type as eventDataType
from event_data
where website_id = $1${toUuid()}
and created_at >= $2
and created_at between $3 and $4`,
params,
);
}
async function clickhouseQuery(websiteId: string, startDate: Date, endDate: Date) {
const { rawQuery, getDateFormat, getBetweenDates } = clickhouse;
const website = await loadWebsite(websiteId);
const resetDate = new Date(website?.resetAt || website?.createdAt);
const params = { websiteId };
return rawQuery(
`select
distinct event_key as eventKey, data_type as eventDataType
from event_data
where website_id = {websiteId:UUID}
and created_at >= ${getDateFormat(resetDate)}
and ${getBetweenDates('created_at', startDate, endDate)}`,
params,
);
}

View File

@ -37,7 +37,7 @@ export async function saveSessionData(data: {
},
}),
client.sessionData.createMany({
data: flattendData,
data: flattendData as any,
}),
]);
}

View File

@ -7,6 +7,7 @@ export * from './analytics/event/getEventMetrics';
export * from './analytics/event/getEventUsage';
export * from './analytics/event/getEvents';
export * from './analytics/eventData/getEventData';
export * from './analytics/eventData/getEventDataFields';
export * from './analytics/eventData/getEventDataUsage';
export * from './analytics/event/saveEvent';
export * from './analytics/pageview/getPageviewFunnel';

View File

@ -8191,10 +8191,10 @@ rc@^1.2.7:
minimist "^1.2.0"
strip-json-comments "~2.0.1"
react-basics@^0.82.0:
version "0.82.0"
resolved "https://registry.yarnpkg.com/react-basics/-/react-basics-0.82.0.tgz#1df241f4ef97a8d0c7c81d4954d065efc2c415a5"
integrity sha512-DrHuRJqncx7cWYFz4rAahEsCsF3s5W1CJyJY276EUphfn0NDKZcvnrSnr6G+KTUAEkvksvlAm1t3nDv85coUxg==
react-basics@^0.83.0:
version "0.83.0"
resolved "https://registry.yarnpkg.com/react-basics/-/react-basics-0.83.0.tgz#bc4a962967383ecec20a0eb49b88c004c67a7f3a"
integrity sha512-3P74I1Tp8Ih8gw3xG65mB0aTzG0oTOYg72Gpfbd3igKjg/L1wgN6stWjIwzNP7mgWFc0FQ63BB13P7pL025bNQ==
dependencies:
classnames "^2.3.1"
date-fns "^2.29.3"