Refactored funnel report. Made BarChart more generic.

This commit is contained in:
Mike Cao 2023-05-24 21:40:02 -07:00
parent 050cd2f5d9
commit fb4dd75e18
24 changed files with 327 additions and 367 deletions

View file

@ -16,8 +16,6 @@ export function ReportHeader({ report, icon }) {
const { id, websiteId, name, parameters } = report || {};
const { value, startDate, endDate } = parameters?.dateRange || {};
console.log('REPORT HEADER', report);
const handleSelect = websiteId => {
updateReport(id, { websiteId });
};

View file

@ -1,184 +1,60 @@
import Chart from 'chart.js/auto';
import classNames from 'classnames';
import { colord } from 'colord';
import HoverTooltip from 'components/common/HoverTooltip';
import Legend from 'components/metrics/Legend';
import useLocale from 'hooks/useLocale';
import { useCallback, useMemo } from 'react';
import { Loading } from 'react-basics';
import useMessages from 'hooks/useMessages';
import useTheme from 'hooks/useTheme';
import { DEFAULT_ANIMATION_DURATION, THEME_COLORS } from 'lib/constants';
import BarChart from 'components/metrics/BarChart';
import { formatLongNumber } from 'lib/format';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Loading, StatusLight } from 'react-basics';
import styles from './FunnelChart.module.css';
export function FunnelChart({
data,
animationDuration = DEFAULT_ANIMATION_DURATION,
stacked = false,
loading = false,
onCreate = () => {},
onUpdate = () => {},
className,
}) {
export function FunnelChart({ report, data, loading, className }) {
const { formatMessage, labels } = useMessages();
const canvas = useRef();
const chart = useRef(null);
const [tooltip, setTooltip] = useState(null);
const { locale } = useLocale();
const [theme] = useTheme();
const { colors } = useTheme();
const datasets = useMemo(() => {
const primaryColor = colord(THEME_COLORS[theme].primary);
return [
{
label: formatMessage(labels.uniqueVisitors),
data: data,
borderWidth: 1,
hoverBackgroundColor: primaryColor.alpha(0.9).toRgbString(),
backgroundColor: primaryColor.alpha(0.6).toRgbString(),
borderColor: primaryColor.alpha(0.9).toRgbString(),
hoverBorderColor: primaryColor.toRgbString(),
},
];
}, [data]);
const { parameters } = report || {};
const colors = useMemo(
() => ({
text: THEME_COLORS[theme].gray700,
line: THEME_COLORS[theme].gray200,
}),
[theme],
const renderXLabel = useCallback(
(label, index) => {
return parameters.urls[index];
},
[parameters],
);
const renderYLabel = label => {
return +label > 1000 ? formatLongNumber(label) : label;
};
const renderTooltip = useCallback(model => {
const { opacity, labelColors, dataPoints } = model.tooltip;
const renderTooltip = useCallback((setTooltip, model) => {
const { opacity, dataPoints } = model.tooltip;
if (!dataPoints?.length || !opacity) {
setTooltip(null);
return;
}
setTooltip(
<div className={styles.tooltip}>
<div>
<StatusLight color={labelColors?.[0]?.backgroundColor}>
<div className={styles.value}>
<div>{dataPoints[0].raw.x}</div>
<div>{formatLongNumber(dataPoints[0].raw.y)}</div>
</div>
</StatusLight>
</div>
</div>,
);
setTooltip(`${formatLongNumber(dataPoints[0].raw.y)} ${formatMessage(labels.visitors)}`);
}, []);
const getOptions = useCallback(() => {
return {
responsive: true,
maintainAspectRatio: false,
animation: {
duration: animationDuration,
resize: {
duration: 0,
},
active: {
duration: 0,
},
const datasets = useMemo(() => {
return [
{
label: formatMessage(labels.uniqueVisitors),
data: data,
borderWidth: 1,
...colors.chart.visitors,
},
plugins: {
legend: {
display: false,
},
tooltip: {
enabled: false,
external: renderTooltip,
},
},
scales: {
x: {
grid: {
display: false,
},
border: {
color: colors.line,
},
ticks: {
color: colors.text,
autoSkip: false,
maxRotation: 0,
},
},
y: {
type: 'linear',
min: 0,
beginAtZero: true,
stacked,
grid: {
color: colors.line,
},
border: {
color: colors.line,
},
ticks: {
color: colors.text,
callback: renderYLabel,
},
},
},
};
}, [animationDuration, renderTooltip, stacked, colors, locale]);
];
}, [data]);
const createChart = () => {
Chart.defaults.font.family = 'Inter';
const options = getOptions();
chart.current = new Chart(canvas.current, {
type: 'bar',
data: { datasets },
options,
});
onCreate(chart.current);
};
const updateChart = () => {
setTooltip(null);
chart.current.data.datasets[0].data = datasets[0].data;
chart.current.data.datasets[0].label = datasets[0].label;
chart.current.options = getOptions();
onUpdate(chart.current);
chart.current.update();
};
useEffect(() => {
if (datasets) {
if (!chart.current) {
createChart();
} else {
updateChart();
}
}
}, [datasets, theme, animationDuration, locale]);
if (loading) {
return <Loading icon="dots" className={styles.loading} />;
}
return (
<>
<div className={classNames(styles.chart, className)}>
{loading && <Loading position="page" icon="dots" />}
<canvas ref={canvas} />
</div>
<Legend chart={chart.current} />
{tooltip && <HoverTooltip tooltip={tooltip} />}
</>
<BarChart
className={className}
datasets={datasets}
unit="day"
loading={loading}
renderXLabel={renderXLabel}
renderTooltip={renderTooltip}
XAxisType="category"
/>
);
}

View file

@ -1,23 +1,3 @@
.chart {
position: relative;
height: 400px;
overflow: hidden;
}
.tooltip {
display: flex;
flex-direction: column;
gap: 10px;
}
.tooltip .value {
display: flex;
flex-direction: column;
text-transform: lowercase;
}
@media only screen and (max-width: 992px) {
.chart {
/*height: 200px;*/
}
.loading {
height: 300px;
}

View file

@ -1,5 +1,6 @@
import { useMessages } from 'hooks';
import {
Button,
Icon,
Form,
FormButtons,
@ -8,29 +9,38 @@ import {
PopupTrigger,
Popup,
SubmitButton,
Text,
TextField,
} from 'react-basics';
import Icons from 'components/icons';
import { updateReport } from 'store/reports';
import { useRef } from 'react';
import { useRef, useState } from 'react';
import styles from './FunnelParameters.module.css';
export function FunnelParameters({ report }) {
const { formatMessage, labels } = useMessages();
const ref = useRef(null);
const { id, websiteId, parameters, isLoading } = report || {};
const queryDisabled = !websiteId || parameters?.urls?.length < 2;
const handleSubmit = values => {
console.log({ values });
updateReport(id, { parameters: values, isLoading: false });
updateReport(id, { parameters: values, isLoading: false, update: Date.now() });
};
console.log('PARAMETERS', parameters);
const handleAdd = url => {
updateReport(id, { parameters: { ...parameters, urls: parameters.urls.concat(url) } });
};
const handleRemove = index => {
const urls = [...parameters.urls];
urls.splice(index, 1);
updateReport(id, { parameters: { ...parameters, urls } });
};
return (
<>
<Form ref={ref} values={parameters} onSubmit={handleSubmit}>
<FormRow label="Window (minutes)">
<FormRow label={formatMessage(labels.window)}>
<FormInput
name="window"
rules={{ required: formatMessage(labels.required), pattern: /[0-9]+/ }}
@ -38,11 +48,22 @@ export function FunnelParameters({ report }) {
<TextField autoComplete="off" />
</FormInput>
</FormRow>
<FormRow label={formatMessage(labels.urls)} action={<AddURLButton />}>
hi
<FormRow label={formatMessage(labels.urls)} action={<AddURLButton onAdd={handleAdd} />}>
<div className={styles.urls}>
{parameters?.urls.map((url, index) => {
return (
<div key={index} className={styles.url}>
<Text>{url}</Text>
<Icon onClick={() => handleRemove(index)}>
<Icons.Close />
</Icon>
</div>
);
})}
</div>
</FormRow>
<FormButtons>
<SubmitButton variant="primary" disabled={!websiteId} loading={isLoading}>
<SubmitButton variant="primary" disabled={queryDisabled} loading={isLoading}>
{formatMessage(labels.query)}
</SubmitButton>
</FormButtons>
@ -51,14 +72,40 @@ export function FunnelParameters({ report }) {
);
}
function AddURLButton() {
function AddURLButton({ onAdd }) {
const [url, setUrl] = useState('');
const { formatMessage, labels } = useMessages();
const handleAdd = close => {
onAdd?.(url);
setUrl('');
close();
};
const handleChange = e => {
setUrl(e.target.value);
};
return (
<PopupTrigger>
<Icon>
<Icons.Plus />
</Icon>
<Popup className={styles.popup} position="right" alignment="start">
HALLO
{close => {
return (
<Form>
<FormRow label={formatMessage(labels.url)}>
<TextField name="url" value={url} onChange={handleChange} autoComplete="off" />
</FormRow>
<FormButtons>
<Button variant="primary" onClick={() => handleAdd(close)}>
{formatMessage(labels.add)}
</Button>
</FormButtons>
</Form>
);
}}
</Popup>
</PopupTrigger>
);

View file

@ -4,4 +4,22 @@
margin-left: 10px;
border: 1px solid var(--base400);
border-radius: var(--border-radius);
}
width: 400px;
}
.urls {
display: flex;
flex-direction: column;
gap: 10px;
}
.url {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 12px;
border: 1px solid var(--base400);
border-radius: var(--border-radius);
box-shadow: 1px 1px 1px var(--base400);
}

View file

@ -7,21 +7,35 @@ import ReportMenu from '../ReportMenu';
import ReportBody from '../ReportBody';
import Funnel from 'assets/funnel.svg';
import { useReport } from 'hooks';
import useApi from 'hooks/useApi';
export default function FunnelReport({ reportId }) {
const report = useReport(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 || {};
console.log('REPORT', { report });
return post(`/reports/funnel`, {
websiteId: websiteId,
...parameters,
startAt: +parameters.dateRange.startDate,
endAt: +parameters.dateRange.endDate,
});
},
{ enabled: !!report?.update },
);
return (
<Report>
<Report error={error} loading={data && isLoading}>
<ReportHeader icon={<Funnel />} report={report} />
<ReportMenu>
<FunnelParameters report={report} />
</ReportMenu>
<ReportBody>
<FunnelChart report={report} />
<FunnelTable report={report} />
<FunnelChart report={report} data={data} />
<FunnelTable data={data} />
</ReportBody>
</Report>
);

View file

@ -1,12 +1,17 @@
import DataTable from 'components/metrics/DataTable';
import { useMessages } from 'hooks';
export function FunnelTable({ ...props }) {
const { data } = props;
export function FunnelTable({ data }) {
const { formatMessage, labels } = useMessages();
const tableData =
data?.map(a => ({ x: a.x, y: a.y, z: Math.floor(a.y / data[0].y) * 100 })) || [];
return <DataTable data={tableData} title="Url" type="device" />;
return (
<DataTable
data={data}
title={formatMessage(labels.url)}
metric={formatMessage(labels.visitors)}
showPercentage={false}
/>
);
}
export default FunnelTable;

View file

@ -6,13 +6,13 @@ import Moon from 'assets/moon.svg';
import styles from './ThemeSetting.module.css';
export function ThemeSetting() {
const [theme, setTheme] = useTheme();
const { theme, saveTheme } = useTheme();
return (
<div className={styles.buttons}>
<Button
className={classNames({ [styles.active]: theme === 'light' })}
onClick={() => setTheme('light')}
onClick={() => saveTheme('light')}
>
<Icon>
<Sun />
@ -20,7 +20,7 @@ export function ThemeSetting() {
</Button>
<Button
className={classNames({ [styles.active]: theme === 'dark' })}
onClick={() => setTheme('dark')}
onClick={() => saveTheme('dark')}
>
<Icon>
<Moon />