mirror of
https://github.com/umami-software/umami.git
synced 2026-02-08 06:37:18 +01:00
Refactored funnel report. Made BarChart more generic.
This commit is contained in:
parent
050cd2f5d9
commit
fb4dd75e18
24 changed files with 327 additions and 367 deletions
|
|
@ -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 });
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue