Added report context. Removed report store.

This commit is contained in:
Mike Cao 2023-05-28 21:37:34 -07:00
parent bc37f5124e
commit bfb52eb678
31 changed files with 372 additions and 273 deletions

View File

@ -4,7 +4,7 @@ import { ComposableMap, Geographies, Geography, ZoomableGroup } from 'react-simp
import classNames from 'classnames'; import classNames from 'classnames';
import { colord } from 'colord'; import { colord } from 'colord';
import HoverTooltip from 'components/common/HoverTooltip'; import HoverTooltip from 'components/common/HoverTooltip';
import { ISO_COUNTRIES, THEME_COLORS, MAP_FILE } from 'lib/constants'; import { ISO_COUNTRIES, MAP_FILE } from 'lib/constants';
import useTheme from 'hooks/useTheme'; import useTheme from 'hooks/useTheme';
import useCountryNames from 'hooks/useCountryNames'; import useCountryNames from 'hooks/useCountryNames';
import useLocale from 'hooks/useLocale'; import useLocale from 'hooks/useLocale';
@ -15,16 +15,7 @@ import styles from './WorldMap.module.css';
export function WorldMap({ data, className }) { export function WorldMap({ data, className }) {
const { basePath } = useRouter(); const { basePath } = useRouter();
const [tooltip, setTooltip] = useState(); const [tooltip, setTooltip] = useState();
const { theme } = useTheme(); const { theme, colors } = useTheme();
const colors = useMemo(
() => ({
baseColor: THEME_COLORS[theme].primary,
fillColor: THEME_COLORS[theme].gray100,
strokeColor: THEME_COLORS[theme].primary,
hoverColor: THEME_COLORS[theme].primary,
}),
[theme],
);
const { locale } = useLocale(); const { locale } = useLocale();
const countryNames = useCountryNames(locale); const countryNames = useCountryNames(locale);
const metrics = useMemo(() => (data ? percentFilter(data) : []), [data]); const metrics = useMemo(() => (data ? percentFilter(data) : []), [data]);
@ -34,10 +25,10 @@ export function WorldMap({ data, className }) {
const country = metrics?.find(({ x }) => x === code); const country = metrics?.find(({ x }) => x === code);
if (!country) { if (!country) {
return colors.fillColor; return colors.map.fillColor;
} }
return colord(colors.baseColor) return colord(colors.map.baseColor)
[theme === 'light' ? 'lighten' : 'darken'](0.4 * (1.0 - country.z / 100)) [theme === 'light' ? 'lighten' : 'darken'](0.4 * (1.0 - country.z / 100))
.toHex(); .toHex();
} }
@ -70,11 +61,11 @@ export function WorldMap({ data, className }) {
key={geo.rsmKey} key={geo.rsmKey}
geography={geo} geography={geo}
fill={getFillColor(code)} fill={getFillColor(code)}
stroke={colors.strokeColor} stroke={colors.map.strokeColor}
opacity={getOpacity(code)} opacity={getOpacity(code)}
style={{ style={{
default: { outline: 'none' }, default: { outline: 'none' },
hover: { outline: 'none', fill: colors.hoverColor }, hover: { outline: 'none', fill: colors.map.hoverColor },
pressed: { outline: 'none' }, pressed: { outline: 'none' },
}} }}
onMouseOver={() => handleHover(code)} onMouseOver={() => handleHover(code)}

View File

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

View File

@ -1,11 +1,19 @@
import { createContext } from 'react';
import Page from 'components/layout/Page'; import Page from 'components/layout/Page';
import styles from './reports.module.css'; import styles from './reports.module.css';
import { useReport } from 'hooks';
export const ReportContext = createContext(null);
export function Report({ reportId, defaultParameters, children, ...props }) {
const report = useReport(reportId, defaultParameters);
export function Report({ children, ...props }) {
return ( return (
<Page {...props} className={styles.container}> <ReportContext.Provider value={{ ...report }}>
{children} <Page {...props} className={styles.container}>
</Page> {children}
</Page>
</ReportContext.Provider>
); );
} }

View File

@ -1,42 +1,62 @@
import { Flexbox, Icon, LoadingButton, Text, useToast } from 'react-basics'; import { useContext } from 'react';
import { useRouter } from 'next/router';
import { Flexbox, Icon, LoadingButton, InlineEditField, useToast } from 'react-basics';
import WebsiteSelect from 'components/input/WebsiteSelect'; import WebsiteSelect from 'components/input/WebsiteSelect';
import PageHeader from 'components/layout/PageHeader'; import PageHeader from 'components/layout/PageHeader';
import DateFilter from 'components/input/DateFilter'; import DateFilter from 'components/input/DateFilter';
import { parseDateRange } from 'lib/date'; import { parseDateRange } from 'lib/date';
import { updateReport } from 'store/reports';
import { useMessages, useApi } from 'hooks'; import { useMessages, useApi } from 'hooks';
import { ReportContext } from './Report';
import styles from './reports.module.css'; import styles from './reports.module.css';
export function ReportHeader({ report, icon }) { export function ReportHeader({ icon }) {
const { report, updateReport } = useContext(ReportContext);
const { formatMessage, labels, messages } = useMessages(); const { formatMessage, labels, messages } = useMessages();
const { toast, showToast } = useToast(); const { toast, showToast } = useToast();
const { post, useMutation } = useApi(); const { post, useMutation } = useApi();
const { mutate, isLoading } = useMutation(data => post(`/reports`, data)); const router = useRouter();
const { mutate: create, isLoading: isCreating } = useMutation(data => post(`/reports`, data));
const { mutate: update, isLoading: isUpdating } = useMutation(data =>
post(`/reports/${data.id}`, data),
);
const { id, websiteId, name, parameters } = report || {}; const { websiteId, name, dateRange } = report || {};
const { value, startDate, endDate } = parameters?.dateRange || {}; const { value, startDate, endDate } = dateRange || {};
const handleSelect = websiteId => { const handleSelect = websiteId => {
updateReport(id, { websiteId }); updateReport({ websiteId });
}; };
const handleDateChange = value => { const handleDateChange = value => {
updateReport(id, { parameters: { dateRange: { ...parseDateRange(value) } } }); updateReport({ dateRange: { ...parseDateRange(value) } });
}; };
const handleSave = async () => { const handleSave = async () => {
mutate(report, { if (!report.id) {
onSuccess: async () => { create(report, {
showToast({ message: formatMessage(messages.saved), variant: 'success' }); onSuccess: async ({ id }) => {
}, router.push(`/reports/${id}`, null, { shallow: true });
}); showToast({ message: formatMessage(messages.saved), variant: 'success' });
},
});
} else {
update(report, {
onSuccess: async () => {
showToast({ message: formatMessage(messages.saved), variant: 'success' });
},
});
}
};
const handleNameChange = name => {
updateReport({ name });
}; };
const Title = () => { const Title = () => {
return ( return (
<> <>
<Icon size="lg">{icon}</Icon> <Icon size="lg">{icon}</Icon>
<Text>{name}</Text> <InlineEditField value={name} onCommit={handleNameChange} />
</> </>
); );
}; };
@ -54,7 +74,7 @@ export function ReportHeader({ report, icon }) {
<WebsiteSelect websiteId={websiteId} onSelect={handleSelect} /> <WebsiteSelect websiteId={websiteId} onSelect={handleSelect} />
<LoadingButton <LoadingButton
variant="primary" variant="primary"
loading={isLoading} loading={isCreating || isUpdating}
disabled={!websiteId || !value} disabled={!websiteId || !value}
onClick={handleSave} onClick={handleSave}
> >

View File

@ -0,0 +1,45 @@
import { useState } from 'react';
import { useMessages } from 'hooks';
import { Button, Form, FormButtons, FormRow, TextField } from 'react-basics';
export function AddUrlForm({ defaultValue = '', onSave, onClose }) {
const [url, setUrl] = useState(defaultValue);
const { formatMessage, labels } = useMessages();
const handleSave = () => {
onSave?.(url);
setUrl('');
onClose();
};
const handleChange = e => {
setUrl(e.target.value);
};
const handleClose = () => {
setUrl('');
onClose();
};
return (
<Form>
<FormRow label={formatMessage(labels.url)}>
<TextField
name="url"
value={url}
onChange={handleChange}
autoFocus={true}
autoComplete="off"
/>
</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 AddUrlForm;

View File

@ -1,16 +1,18 @@
import { useCallback, useMemo } from 'react'; import { useCallback, useContext, useMemo } from 'react';
import { Loading } from 'react-basics'; import { Loading } from 'react-basics';
import useMessages from 'hooks/useMessages'; import useMessages from 'hooks/useMessages';
import useTheme from 'hooks/useTheme'; import useTheme from 'hooks/useTheme';
import BarChart from 'components/metrics/BarChart'; import BarChart from 'components/metrics/BarChart';
import { formatLongNumber } from 'lib/format'; import { formatLongNumber } from 'lib/format';
import styles from './FunnelChart.module.css'; import styles from './FunnelChart.module.css';
import { ReportContext } from '../Report';
export function FunnelChart({ report, data, loading, className }) { export function FunnelChart({ className, loading }) {
const { report } = useContext(ReportContext);
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
const { colors } = useTheme(); const { colors } = useTheme();
const { parameters } = report || {}; const { parameters, data } = report || {};
const renderXLabel = useCallback( const renderXLabel = useCallback(
(label, index) => { (label, index) => {

View File

@ -1,12 +1,11 @@
import { useContext, useRef, useState } from 'react';
import { useMessages } from 'hooks'; import { useMessages } from 'hooks';
import { import {
Button,
Icon, Icon,
Form, Form,
FormButtons, FormButtons,
FormInput, FormInput,
FormRow, FormRow,
ModalTrigger,
Modal, Modal,
SubmitButton, SubmitButton,
Text, Text,
@ -14,30 +13,36 @@ import {
Tooltip, Tooltip,
} from 'react-basics'; } from 'react-basics';
import Icons from 'components/icons'; import Icons from 'components/icons';
import { updateReport } from 'store/reports'; import AddUrlForm from './AddUrlForm';
import { useRef, useState } from 'react'; import { ReportContext } from 'components/pages/reports/Report';
import styles from './FunnelParameters.module.css'; import styles from './FunnelParameters.module.css';
export function FunnelParameters({ report }) { export function FunnelParameters() {
const { report, runReport, updateReport, isRunning } = useContext(ReportContext);
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
const [show, setShow] = useState(false);
const ref = useRef(null); const ref = useRef(null);
const { id, websiteId, parameters, isLoading } = report || {}; const { websiteId, parameters } = report || {};
const queryDisabled = !websiteId || parameters?.urls?.length < 2; const queryDisabled = !websiteId || parameters?.urls?.length < 2;
const handleSubmit = values => { const handleSubmit = values => {
updateReport(id, { parameters: values, isLoading: false, update: Date.now() }); runReport(values);
}; };
const handleAdd = url => { const handleAddUrl = url => {
updateReport(id, { parameters: { ...parameters, urls: parameters.urls.concat(url) } }); updateReport({ parameters: { ...parameters, urls: parameters.urls.concat(url) } });
}; };
const handleRemove = index => { const handleRemoveUrl = (index, e) => {
e.stopPropagation();
const urls = [...parameters.urls]; const urls = [...parameters.urls];
urls.splice(index, 1); urls.splice(index, 1);
updateReport(id, { parameters: { ...parameters, urls } }); updateReport({ parameters: { ...parameters, urls } });
}; };
const showAddForm = () => setShow(true);
const hideAddForm = () => setShow(false);
return ( return (
<> <>
<Form ref={ref} values={parameters} onSubmit={handleSubmit}> <Form ref={ref} values={parameters} onSubmit={handleSubmit}>
@ -49,72 +54,49 @@ export function FunnelParameters({ report }) {
<TextField autoComplete="off" /> <TextField autoComplete="off" />
</FormInput> </FormInput>
</FormRow> </FormRow>
<FormRow label={formatMessage(labels.urls)} action={<AddURLButton onAdd={handleAdd} />}> <FormRow label={formatMessage(labels.urls)} action={<AddUrlButton onClick={showAddForm} />}>
<div className={styles.urls}> <div className={styles.urls}>
{parameters?.urls.map((url, index) => { {parameters?.urls?.map((url, index) => {
return ( return (
<div key={index} className={styles.url}> <div key={index} className={styles.url}>
<Text>{url}</Text> <Text>{url}</Text>
<Icon onClick={() => handleRemove(index)}> <Tooltip
<Icons.Close /> className={styles.icon}
</Icon> label={formatMessage(labels.remove)}
position="right"
>
<Icon onClick={handleRemoveUrl.bind(null, index)}>
<Icons.Close />
</Icon>
</Tooltip>
</div> </div>
); );
})} })}
</div> </div>
</FormRow> </FormRow>
<FormButtons> <FormButtons>
<SubmitButton variant="primary" disabled={queryDisabled} loading={isLoading}> <SubmitButton variant="primary" disabled={queryDisabled} loading={isRunning}>
{formatMessage(labels.query)} {formatMessage(labels.runQuery)}
</SubmitButton> </SubmitButton>
</FormButtons> </FormButtons>
</Form> </Form>
{show && (
<Modal onClose={hideAddForm}>
<AddUrlForm onSave={handleAddUrl} onClose={hideAddForm} />
</Modal>
)}
</> </>
); );
} }
function AddURLButton({ onAdd }) { function AddUrlButton({ onClick }) {
const [url, setUrl] = useState('');
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
const handleAdd = close => {
onAdd?.(url);
setUrl('');
close();
};
const handleChange = e => {
setUrl(e.target.value);
};
const handleClose = close => {
setUrl('');
close();
};
return ( return (
<Tooltip label={formatMessage(labels.addUrl)}> <Tooltip label={formatMessage(labels.addUrl)}>
<ModalTrigger> <Icon onClick={onClick}>
<Icon> <Icons.Plus />
<Icons.Plus /> </Icon>
</Icon>
<Modal>
{close => {
return (
<Form>
<FormRow label={formatMessage(labels.url)}>
<TextField name="url" value={url} onChange={handleChange} autoComplete="off" />
</FormRow>
<FormButtons align="center" flex>
<Button variant="primary" onClick={() => handleAdd(close)}>
{formatMessage(labels.add)}
</Button>
<Button onClick={() => handleClose(close)}>{formatMessage(labels.cancel)}</Button>
</FormButtons>
</Form>
);
}}
</Modal>
</ModalTrigger>
</Tooltip> </Tooltip>
); );
} }

View File

@ -1,7 +1,7 @@
.urls { .urls {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 10px; gap: 16px;
} }
.url { .url {
@ -14,3 +14,7 @@
border-radius: var(--border-radius); border-radius: var(--border-radius);
box-shadow: 1px 1px 1px var(--base400); box-shadow: 1px 1px 1px var(--base400);
} }
.icon {
align-self: center;
}

View File

@ -1,41 +1,29 @@
import { useContext } from 'react';
import FunnelChart from './FunnelChart'; import FunnelChart from './FunnelChart';
import FunnelTable from './FunnelTable'; import FunnelTable from './FunnelTable';
import FunnelParameters from './FunnelParameters'; import FunnelParameters from './FunnelParameters';
import Report from '../Report'; import Report, { ReportContext } from '../Report';
import ReportHeader from '../ReportHeader'; import ReportHeader from '../ReportHeader';
import ReportMenu from '../ReportMenu'; import ReportMenu from '../ReportMenu';
import ReportBody from '../ReportBody'; import ReportBody from '../ReportBody';
import Funnel from 'assets/funnel.svg'; import Funnel from 'assets/funnel.svg';
import { useReport } from 'hooks'; import { useReport } from 'hooks';
import useApi from 'hooks/useApi';
const defaultParameters = {
type: 'funnel',
parameters: { window: 60, urls: ['/', '/docs'] },
};
export default function FunnelReport({ reportId }) { export default function FunnelReport({ reportId }) {
const report = useReport(reportId, { window: 60, urls: ['/', '/docs'] });
const { post, useQuery } = useApi();
const { data, isLoading, error } = useQuery(
['report:funnel', report?.update],
() => {
const { websiteId, parameters } = report || {};
return post(`/reports/funnel`, {
websiteId: websiteId,
...parameters,
startAt: +parameters.dateRange.startDate,
endAt: +parameters.dateRange.endDate,
});
},
{ enabled: !!report?.update },
);
return ( return (
<Report error={error} loading={data && isLoading}> <Report reportId={reportId} defaultParameters={defaultParameters}>
<ReportHeader icon={<Funnel />} report={report} /> <ReportHeader icon={<Funnel />} />
<ReportMenu> <ReportMenu>
<FunnelParameters report={report} /> <FunnelParameters />
</ReportMenu> </ReportMenu>
<ReportBody> <ReportBody>
<FunnelChart report={report} data={data} /> <FunnelChart />
<FunnelTable data={data} /> <FunnelTable />
</ReportBody> </ReportBody>
</Report> </Report>
); );

View File

@ -1,12 +1,15 @@
import { useContext } from 'react';
import DataTable from 'components/metrics/DataTable'; import DataTable from 'components/metrics/DataTable';
import { useMessages } from 'hooks'; import { useMessages } from 'hooks';
import { ReportContext } from '../Report';
export function FunnelTable({ data }) { export function FunnelTable() {
const { report } = useContext(ReportContext);
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
return ( return (
<DataTable <DataTable
data={data} data={report?.data}
title={formatMessage(labels.url)} title={formatMessage(labels.url)}
metric={formatMessage(labels.visitors)} metric={formatMessage(labels.visitors)}
showPercentage={false} showPercentage={false}

View File

@ -1,20 +1,80 @@
import useStore, { createReport } from 'store/reports'; import { produce } from 'immer';
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import useApi from './useApi';
const baseParameters = {
name: 'Untitled',
description: '',
parameters: {},
};
export function useReport(reportId, defaultParameters) { export function useReport(reportId, defaultParameters) {
const [id, setId] = useState(reportId); const [report, setReport] = useState(null);
const [isRunning, setIsRunning] = useState(false);
const { get, post } = useApi();
const selector = useCallback(state => state[id], [id]); const loadReport = async id => {
const report = useStore(selector); const data = await get(`/reports/${id}`);
setReport(data);
};
const runReport = useCallback(
async parameters => {
const { websiteId, type, dateRange } = report;
setIsRunning(true);
const data = await post(`/reports/${type}`, {
websiteId: websiteId,
...parameters,
startAt: +dateRange?.startDate,
endAt: +dateRange?.endDate,
});
setReport(
produce(state => {
state.parameters = parameters;
state.data = data;
return state;
}),
);
setIsRunning(false);
},
[report],
);
const updateReport = useCallback(
async data => {
setReport(
produce(state => {
const { parameters, ...rest } = data;
if (parameters) {
state.parameters = { ...state.parameters, ...parameters };
}
for (const key in rest) {
state[key] = rest[key];
}
return state;
}),
);
},
[report],
);
useEffect(() => { useEffect(() => {
if (!report) { if (!reportId) {
const newReport = createReport(defaultParameters); setReport({ ...baseParameters, ...defaultParameters });
setId(newReport.id); } else {
loadReport(reportId);
} }
}, []); }, []);
return report; return { report, runReport, updateReport, isRunning };
} }
export default useReport; export default useReport;

View File

@ -17,6 +17,9 @@ export function useTheme() {
const primaryColor = colord(THEME_COLORS[theme].primary); const primaryColor = colord(THEME_COLORS[theme].primary);
const colors = { const colors = {
theme: {
...THEME_COLORS[theme],
},
chart: { chart: {
text: THEME_COLORS[theme].gray700, text: THEME_COLORS[theme].gray700,
line: THEME_COLORS[theme].gray200, line: THEME_COLORS[theme].gray200,
@ -33,6 +36,12 @@ export function useTheme() {
hoverBorderColor: primaryColor.toRgbString(), hoverBorderColor: primaryColor.toRgbString(),
}, },
}, },
map: {
baseColor: THEME_COLORS[theme].primary,
fillColor: THEME_COLORS[theme].gray100,
strokeColor: THEME_COLORS[theme].primary,
hoverColor: THEME_COLORS[theme].primary,
},
}; };
function saveTheme(value) { function saveTheme(value) {

View File

@ -1,4 +1,4 @@
import { UserReport } from '@prisma/client'; import { Report } from '@prisma/client';
import redis from '@umami/redis-client'; import redis from '@umami/redis-client';
import debug from 'debug'; import debug from 'debug';
import { PERMISSIONS, ROLE_PERMISSIONS, SHARE_TOKEN_HEADER } from 'lib/constants'; import { PERMISSIONS, ROLE_PERMISSIONS, SHARE_TOKEN_HEADER } from 'lib/constants';
@ -135,28 +135,28 @@ export async function canDeleteWebsite({ user }: Auth, websiteId: string) {
return false; return false;
} }
export async function canViewUserReport(auth: Auth, userReport: UserReport) { export async function canViewReport(auth: Auth, report: Report) {
if (auth.user.isAdmin) { if (auth.user.isAdmin) {
return true; return true;
} }
if ((auth.user.id = userReport.userId)) { if ((auth.user.id = report.userId)) {
return true; return true;
} }
if (await canViewWebsite(auth, userReport.websiteId)) { if (await canViewWebsite(auth, report.websiteId)) {
return true; return true;
} }
return false; return false;
} }
export async function canUpdateUserReport(auth: Auth, userReport: UserReport) { export async function canUpdateReport(auth: Auth, report: Report) {
if (auth.user.isAdmin) { if (auth.user.isAdmin) {
return true; return true;
} }
if ((auth.user.id = userReport.userId)) { if ((auth.user.id = report.userId)) {
return true; return true;
} }

View File

@ -91,7 +91,7 @@
"maxmind": "^4.3.6", "maxmind": "^4.3.6",
"moment-timezone": "^0.5.35", "moment-timezone": "^0.5.35",
"next": "13.3.1", "next": "13.3.1",
"next-basics": "^0.27.0", "next-basics": "^0.30.0",
"node-fetch": "^3.2.8", "node-fetch": "^3.2.8",
"npm-run-all": "^4.1.5", "npm-run-all": "^4.1.5",
"react": "^18.2.0", "react": "^18.2.0",

View File

@ -1,59 +1,68 @@
import { canUpdateUserReport, canViewUserReport } from 'lib/auth'; import { canUpdateReport, canViewReport } from 'lib/auth';
import { useAuth, useCors } from 'lib/middleware'; import { useAuth, useCors } from 'lib/middleware';
import { NextApiRequestQueryBody } from 'lib/types'; import { NextApiRequestQueryBody } from 'lib/types';
import { NextApiResponse } from 'next'; import { NextApiResponse } from 'next';
import { methodNotAllowed, ok, unauthorized } from 'next-basics'; import { methodNotAllowed, ok, unauthorized } from 'next-basics';
import { getUserReportById, updateUserReport } from 'queries'; import { getReportById, updateReport } from 'queries';
export interface UserReportRequestQuery { export interface ReportRequestQuery {
id: string; id: string;
} }
export interface UserReportRequestBody { export interface ReportRequestBody {
websiteId: string; websiteId: string;
reportName: string; type: string;
templateName: string; name: string;
description: string;
parameters: string; parameters: string;
} }
export default async ( export default async (
req: NextApiRequestQueryBody<UserReportRequestQuery, UserReportRequestBody>, req: NextApiRequestQueryBody<ReportRequestQuery, ReportRequestBody>,
res: NextApiResponse, res: NextApiResponse,
) => { ) => {
await useCors(req, res); await useCors(req, res);
await useAuth(req, res); await useAuth(req, res);
if (req.method === 'GET') { if (req.method === 'GET') {
const { id: userReportId } = req.query; const { id: reportId } = req.query;
const data = await getUserReportById(userReportId); const data = await getReportById(reportId);
if (!(await canViewUserReport(req.auth, data))) { if (!(await canViewReport(req.auth, data))) {
return unauthorized(res); return unauthorized(res);
} }
data.parameters = JSON.parse(data.parameters);
return ok(res, data); return ok(res, data);
} }
if (req.method === 'POST') { if (req.method === 'POST') {
const { id: userReportId } = req.query; const { id: reportId } = req.query;
const data = await getUserReportById(userReportId); const { websiteId, type, name, description, parameters } = req.body;
if (!(await canUpdateUserReport(req.auth, data))) { const data = await getReportById(reportId);
if (!(await canUpdateReport(req.auth, data))) {
return unauthorized(res); return unauthorized(res);
} }
const updated = await updateUserReport( const result = await updateReport(
{ {
...req.body, websiteId,
}, type,
name,
description,
parameters: JSON.stringify(parameters),
} as any,
{ {
id: userReportId, id: reportId,
}, },
); );
return ok(res, updated); return ok(res, result);
} }
return methodNotAllowed(res); return methodNotAllowed(res);

View File

@ -3,17 +3,21 @@ import { useAuth, useCors } from 'lib/middleware';
import { NextApiRequestQueryBody } from 'lib/types'; import { NextApiRequestQueryBody } from 'lib/types';
import { NextApiResponse } from 'next'; import { NextApiResponse } from 'next';
import { methodNotAllowed, ok } from 'next-basics'; import { methodNotAllowed, ok } from 'next-basics';
import { createUserReport, getUserReports } from 'queries'; import { createReport, getReports } from 'queries';
export interface UserReportRequestBody { export interface ReportRequestBody {
websiteId: string; websiteId: string;
reportName: string; name: string;
templateName: string; type: string;
parameters: string; description: string;
parameters: {
window: string;
urls: string[];
};
} }
export default async ( export default async (
req: NextApiRequestQueryBody<any, UserReportRequestBody>, req: NextApiRequestQueryBody<any, ReportRequestBody>,
res: NextApiResponse, res: NextApiResponse,
) => { ) => {
await useCors(req, res); await useCors(req, res);
@ -24,19 +28,25 @@ export default async (
} = req.auth; } = req.auth;
if (req.method === 'GET') { if (req.method === 'GET') {
const data = await getUserReports(userId); const data = await getReports(userId);
return ok(res, data); return ok(res, data);
} }
if (req.method === 'POST') { if (req.method === 'POST') {
const data = await createUserReport({ const { websiteId, type, name, description, parameters } = req.body;
const result = await createReport({
id: uuid(), id: uuid(),
userId, userId,
...req.body, websiteId,
}); type,
name,
description,
parameters: JSON.stringify(parameters),
} as any);
return ok(res, data); return ok(res, result);
} }
return methodNotAllowed(res); return methodNotAllowed(res);

20
pages/reports/[id].js Normal file
View File

@ -0,0 +1,20 @@
import { useRouter } from 'next/router';
import AppLayout from 'components/layout/AppLayout';
import FunnelReport from 'components/pages/reports/funnel/FunnelReport';
import useMessages from 'hooks/useMessages';
export default function ReportsPage() {
const { formatMessage, labels } = useMessages();
const router = useRouter();
const { id } = router.query;
if (!id) {
return null;
}
return (
<AppLayout title={formatMessage(labels.websites)}>
<FunnelReport reportId={id} />
</AppLayout>
);
}

View File

@ -1,13 +1,21 @@
import { useState } from 'react';
import { Item, Tabs } from 'react-basics';
import AppLayout from 'components/layout/AppLayout'; import AppLayout from 'components/layout/AppLayout';
import ReportList from 'components/pages/reports/ReportList'; import ReportList from 'components/pages/reports/ReportList';
import useMessages from 'hooks/useMessages'; import useMessages from 'hooks/useMessages';
export default function ReportsPage() { export default function ReportsPage() {
const [tab, setTab] = useState('create');
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
return ( return (
<AppLayout title={formatMessage(labels.reports)}> <AppLayout title={formatMessage(labels.reports)}>
<ReportList /> <Tabs selectedKey={tab} onSelect={setTab} style={{ marginBottom: 30, fontSize: 14 }}>
<Item key="create">{formatMessage(labels.reports)}</Item>
<Item key="saved">{formatMessage(labels.save)}</Item>
</Tabs>
{tab === 'create' && <ReportList />}
{tab === 'saved' && <h1>My reports</h1>}
</AppLayout> </AppLayout>
); );
} }

33
queries/admin/report.ts Normal file
View File

@ -0,0 +1,33 @@
import { Prisma, Report } from '@prisma/client';
import prisma from 'lib/prisma';
export async function createReport(data: Prisma.ReportUncheckedCreateInput): Promise<Report> {
return prisma.client.report.create({ data });
}
export async function getReportById(reportId: string): Promise<Report> {
return prisma.client.report.findUnique({
where: {
id: reportId,
},
});
}
export async function getReports(userId: string): Promise<Report[]> {
return prisma.client.report.findMany({
where: {
userId,
},
});
}
export async function updateReport(
data: Prisma.ReportUpdateInput,
where: Prisma.ReportWhereUniqueInput,
): Promise<Report> {
return prisma.client.report.update({ data, where });
}
export async function deleteReport(where: Prisma.ReportWhereUniqueInput): Promise<Report> {
return prisma.client.report.delete({ where });
}

View File

@ -210,7 +210,7 @@ export async function deleteUser(
}, },
}, },
}), }),
client.userReport.deleteMany({ client.report.deleteMany({
where: { where: {
OR: [ OR: [
{ {

View File

@ -1,37 +0,0 @@
import { Prisma, UserReport } from '@prisma/client';
import prisma from 'lib/prisma';
export async function createUserReport(
data: Prisma.UserReportUncheckedCreateInput,
): Promise<UserReport> {
return prisma.client.userReport.create({ data });
}
export async function getUserReportById(userReportId: string): Promise<UserReport> {
return prisma.client.userReport.findUnique({
where: {
id: userReportId,
},
});
}
export async function getUserReports(userId: string): Promise<UserReport[]> {
return prisma.client.userReport.findMany({
where: {
userId,
},
});
}
export async function updateUserReport(
data: Prisma.UserReportUpdateInput,
where: Prisma.UserReportWhereUniqueInput,
): Promise<UserReport> {
return prisma.client.userReport.update({ data, where });
}
export async function deleteUserReport(
where: Prisma.UserReportWhereUniqueInput,
): Promise<UserReport> {
return prisma.client.userReport.delete({ where });
}

View File

@ -92,7 +92,7 @@ export async function deleteWebsite(
websiteId, websiteId,
}, },
}), }),
client.userReport.deleteMany({ client.report.deleteMany({
where: { where: {
websiteId, websiteId,
}, },

View File

@ -1,7 +1,7 @@
export * from './admin/team'; export * from './admin/team';
export * from './admin/teamUser'; export * from './admin/teamUser';
export * from './admin/user'; export * from './admin/user';
export * from './admin/userReport'; export * from './admin/report';
export * from './admin/website'; export * from './admin/website';
export * from './analytics/event/getEventMetrics'; export * from './analytics/event/getEventMetrics';
export * from './analytics/event/getEventUsage'; export * from './analytics/event/getEventUsage';

View File

@ -4,7 +4,7 @@ import replace from '@rollup/plugin-replace';
import { terser } from 'rollup-plugin-terser'; import { terser } from 'rollup-plugin-terser';
export default { export default {
input: 'tracker/index.js', input: 'tracker/[id].js',
output: { output: {
file: 'public/script.js', file: 'public/script.js',
format: 'iife', format: 'iife',

View File

@ -1,57 +0,0 @@
import { create } from 'zustand';
import produce from 'immer';
import { getRandomChars } from 'next-basics';
const emptyReport = {
name: 'Untitled',
description: '',
parameters: {},
};
const initialState = {};
const store = create(() => ({ ...initialState }));
export function updateReport(id, data) {
const report = store.getState()[id];
if (report) {
store.setState(
produce(state => {
const item = state[id];
const { parameters, ...rest } = data;
if (parameters) {
item.parameters = { ...item.parameters, ...parameters };
}
for (const key in rest) {
item[key] = rest[key];
}
return state;
}),
);
}
}
export function createReport(parameters) {
const id = `new_${getRandomChars(16)}`;
const report = { ...emptyReport, id, parameters };
store.setState(
produce(state => {
state[id] = report;
return state;
}),
);
return report;
}
export default store;
if (typeof window !== 'undefined') {
window.__STORE__ = store;
}

View File

@ -3715,11 +3715,6 @@ balanced-match@^2.0.0:
resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-2.0.0.tgz#dc70f920d78db8b858535795867bf48f820633d9" resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-2.0.0.tgz#dc70f920d78db8b858535795867bf48f820633d9"
integrity sha512-1ugUSr8BHXRnK23KfuYS+gVMC3LB8QGH9W1iGtDPsNWoQbgtXSExkBu2aDR4epiGWZOjZsj6lDl/N/AqqTC3UA== integrity sha512-1ugUSr8BHXRnK23KfuYS+gVMC3LB8QGH9W1iGtDPsNWoQbgtXSExkBu2aDR4epiGWZOjZsj6lDl/N/AqqTC3UA==
base-x@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/base-x/-/base-x-4.0.0.tgz#d0e3b7753450c73f8ad2389b5c018a4af7b2224a"
integrity sha512-FuwxlW4H5kh37X/oW59pwTzzTKRzfrrQwhmyspRM7swOEZcHtDZSCt45U6oKgtuFE+WYPblePMVIPR4RZrh/hw==
base64-js@^1.3.1: base64-js@^1.3.1:
version "1.5.1" version "1.5.1"
resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a"
@ -7019,14 +7014,14 @@ natural-compare@^1.4.0:
resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7"
integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw== integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==
next-basics@^0.27.0: next-basics@^0.30.0:
version "0.27.0" version "0.30.0"
resolved "https://registry.yarnpkg.com/next-basics/-/next-basics-0.27.0.tgz#7d46c88de4b53cadfef86230f8cbe7dba6f10dc6" resolved "https://registry.yarnpkg.com/next-basics/-/next-basics-0.30.0.tgz#1d5f1b3bd3cd66218f00ab0390659a819ed274fa"
integrity sha512-ZviF4O4/14eBjGG7fK83oswuM/rur37TRxcjXCKPJN4kOTUgrzn9Sz+vpzkB8PZ2WaHv5ONQ7TkBEwnhMCEyMQ== integrity sha512-IWNrBsxM8watkvWHWkUp5PC0WypqeHMEaj5rCufjEfwuIe++ePtDrYgUKY6Se6y6QJWbyHliozCpa1KpVyZHnQ==
dependencies: dependencies:
base-x "^4.0.0"
bcryptjs "^2.4.3" bcryptjs "^2.4.3"
jsonwebtoken "^9.0.0" jsonwebtoken "^9.0.0"
pure-rand "^6.0.2"
next@13.3.1: next@13.3.1:
version "13.3.1" version "13.3.1"
@ -8144,6 +8139,11 @@ punycode@^2.1.0:
resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.0.tgz#f67fa67c94da8f4d0cfff981aee4118064199b8f" resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.0.tgz#f67fa67c94da8f4d0cfff981aee4118064199b8f"
integrity sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA== integrity sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==
pure-rand@^6.0.2:
version "6.0.2"
resolved "https://registry.yarnpkg.com/pure-rand/-/pure-rand-6.0.2.tgz#a9c2ddcae9b68d736a8163036f088a2781c8b306"
integrity sha512-6Yg0ekpKICSjPswYOuC5sku/TSWaRYlA0qsXqJgM/d/4pLPHPuTxK7Nbf7jFKzAeedUhR8C7K9Uv63FBsSo8xQ==
qs@~6.5.2: qs@~6.5.2:
version "6.5.3" version "6.5.3"
resolved "https://registry.npmjs.org/qs/-/qs-6.5.3.tgz" resolved "https://registry.npmjs.org/qs/-/qs-6.5.3.tgz"