Merge branch 'feat/um-285-report-schema' into dev

This commit is contained in:
Brian Cao 2023-05-18 13:21:35 -07:00
commit 40f53e8856
29 changed files with 1007 additions and 14 deletions

View file

@ -20,6 +20,7 @@ export function NavBar() {
{ label: formatMessage(labels.dashboard), url: '/dashboard' },
{ label: formatMessage(labels.reports), url: '/reports' },
{ label: formatMessage(labels.realtime), url: '/realtime' },
{ label: formatMessage(labels.reports), url: '/reports/funnel' },
!cloudMode && { label: formatMessage(labels.settings), url: '/settings' },
].filter(n => n);

View file

@ -0,0 +1,23 @@
import { Column, Row } from 'react-basics';
import styles from './ReportsLayout.module.css';
export function SettingsLayout({ children, filter, header }) {
return (
<>
<Row>{header}</Row>
<Row>
{filter && (
<Column className={styles.filter} defaultSize={12} md={4} lg={3} xl={3}>
<h2>Filters</h2>
{filter}
</Column>
)}
<Column className={styles.content} defaultSize={12} md={8} lg={9} xl={9}>
{children}
</Column>
</Row>
</>
);
}
export default SettingsLayout;

View file

@ -0,0 +1,23 @@
.filter {
margin-top: 30px;
min-width: 200px;
max-width: 100vw;
padding: 10px;
background: var(--base50);
border-radius: 5px;
border: 1px solid var(--border-color);
}
.filter h2 {
padding-bottom: 20px;
}
.content {
min-height: 50vh;
}
@media only screen and (max-width: 768px) {
.menu {
display: none;
}
}

View file

@ -18,6 +18,7 @@ export const labels = defineMessages({
admin: { id: 'label.admin', defaultMessage: 'Administrator' },
confirm: { id: 'label.confirm', defaultMessage: 'Confirm' },
details: { id: 'label.details', defaultMessage: 'Details' },
website: { id: 'label.website', defaultMessage: 'Website' },
websites: { id: 'label.websites', defaultMessage: 'Websites' },
created: { id: 'label.created', defaultMessage: 'Created' },
edit: { id: 'label.edit', defaultMessage: 'Edit' },
@ -186,6 +187,10 @@ export const messages = defineMessages({
id: 'message.delete-website-warning',
defaultMessage: 'All website data will be deleted.',
},
noResultsFound: {
id: 'messages.no-results-found',
defaultMessage: 'No results were found.',
},
noWebsitesConfigured: {
id: 'messages.no-websites-configured',
defaultMessage: 'You do not have any websites configured.',

View file

@ -0,0 +1,28 @@
import useMessages from 'hooks/useMessages';
import { Form, FormButtons, FormInput, FormRow, SubmitButton, TextField } from 'react-basics';
export function FunnelForm() {
const { formatMessage, labels } = useMessages();
const handleSubmit = () => {};
return (
<>
<Form onSubmit={handleSubmit}>
<FormRow label={formatMessage(labels.website)}>
<FormInput name="name" rules={{ required: formatMessage(labels.required) }}>
<TextField />
</FormInput>
</FormRow>
<FormButtons>
<SubmitButton variant="primary" disabled={false}>
Save
</SubmitButton>
</FormButtons>
</Form>
</>
);
}
export default FunnelForm;

View file

@ -0,0 +1,19 @@
.filter {
min-width: 200px;
}
.hiddenInput {
visibility: hidden;
min-height: 0px;
max-height: 0px;
}
.hidden {
visibility: hidden;
min-height: 0px;
max-height: 0px;
}
.urlFormRow label {
min-width: 80px;
}

View file

@ -0,0 +1,185 @@
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 useMessages from 'hooks/useMessages';
import useTheme from 'hooks/useTheme';
import { DEFAULT_ANIMATION_DURATION, THEME_COLORS } from 'lib/constants';
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,
}) {
const { formatMessage, labels } = useMessages();
const canvas = useRef();
const chart = useRef(null);
const [tooltip, setTooltip] = useState(null);
const { locale } = useLocale();
const [theme] = 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 colors = useMemo(
() => ({
text: THEME_COLORS[theme].gray700,
line: THEME_COLORS[theme].gray200,
}),
[theme],
);
const renderYLabel = label => {
return +label > 1000 ? formatLongNumber(label) : label;
};
const renderTooltip = useCallback(model => {
const { opacity, labelColors, 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>,
);
}, []);
const getOptions = useCallback(() => {
return {
responsive: true,
maintainAspectRatio: false,
animation: {
duration: animationDuration,
resize: {
duration: 0,
},
active: {
duration: 0,
},
},
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]);
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]);
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} />}
</>
);
}
export default FunnelChart;

View file

@ -0,0 +1,23 @@
.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;*/
}
}

View file

@ -0,0 +1,118 @@
import DateFilter from 'components/input/DateFilter';
import WebsiteSelect from 'components/input/WebsiteSelect';
import useMessages from 'hooks/useMessages';
import { parseDateRange } from 'lib/date';
import { useState } from 'react';
import {
Button,
Form,
FormButtons,
FormInput,
FormRow,
SubmitButton,
TextField,
} from 'react-basics';
import styles from './FunnelForm.module.css';
export function FunnelForm({ onSearch }) {
const { formatMessage, labels } = useMessages();
const [dateRange, setDateRange] = useState('');
const [startAt, setStartAt] = useState();
const [endAt, setEndAt] = useState();
const [urls, setUrls] = useState(['/', '/docs/getting-started', '/docs/intall']);
const [websiteId, setWebsiteId] = useState('');
const [window, setWindow] = useState(60);
const handleSubmit = async data => {
onSearch(data);
};
const handleDateChange = value => {
const { startDate, endDate } = parseDateRange(value);
setDateRange(value);
setStartAt(startDate.getTime());
setEndAt(endDate.getTime());
};
const handleAddUrl = () => setUrls([...urls, '']);
const handleRemoveUrl = i => {
const nextUrls = [...urls];
nextUrls.splice(i, 1);
setUrls(nextUrls);
};
const handleWindowChange = value => setWindow(value.target.value);
const handleUrlChange = (value, i) => {
const nextUrls = [...urls];
nextUrls[i] = value.target.value;
setUrls(nextUrls);
};
return (
<>
<Form
values={{
websiteId,
startAt,
endAt,
urls,
window,
}}
onSubmit={handleSubmit}
>
<FormRow label={formatMessage(labels.website)}>
<WebsiteSelect websiteId={websiteId} onSelect={value => setWebsiteId(value)} />
<FormInput name="websiteId" rules={{ required: formatMessage(labels.required) }}>
<TextField value={websiteId} className={styles.hiddenInput} />
</FormInput>
</FormRow>
<FormRow label="Date">
<DateFilter
className={styles.filter}
value={dateRange}
alignment="start"
onChange={handleDateChange}
isF
/>
<FormInput
name="startAt"
className={styles.hiddenInput}
rules={{ required: formatMessage(labels.required) }}
>
<TextField value={startAt} />
</FormInput>
<FormInput name="endAt" rules={{ required: formatMessage(labels.required) }}>
<TextField value={endAt} className={styles.hiddenInput} />
</FormInput>
</FormRow>
<FormRow label="Window (minutes)">
<FormInput
name="window"
rules={{ required: formatMessage(labels.required), pattern: /[0-9]+/ }}
>
<TextField value={window} onChange={handleWindowChange} />
</FormInput>
</FormRow>
<Button onClick={handleAddUrl}>Add URL</Button>
{urls.map((a, i) => (
<FormRow className={styles.urlFormRow} key={`url${i}`} label={`URL ${i + 1}`}>
<TextField value={urls[i]} onChange={value => handleUrlChange(value, i)} />
<Button onClick={() => handleRemoveUrl(i)}>Remove URL</Button>
</FormRow>
))}
<FormButtons>
<SubmitButton variant="primary" disabled={false}>
Query
</SubmitButton>
</FormButtons>
</Form>
</>
);
}
export default FunnelForm;

View file

@ -0,0 +1,13 @@
.filter {
min-width: 200px;
}
.hiddenInput {
visibility: hidden;
min-height: 0px;
max-height: 0px;
}
.urlFormRow label {
min-width: 80px;
}

View file

@ -0,0 +1,36 @@
import { useMutation } from '@tanstack/react-query';
import Page from 'components/layout/Page';
import PageHeader from 'components/layout/PageHeader';
import ReportsLayout from 'components/layout/ReportsLayout';
import useApi from 'hooks/useApi';
import { useState } from 'react';
import FunnelChart from './FunnelChart';
import FunnelTable from './FunnelTable';
import FunnelForm from './FunnelForm';
export default function FunnelPage() {
const { post } = useApi();
const { mutate } = useMutation(data => post('/reports/funnel', data));
const [data, setData] = useState([{}]);
const [setFormData] = useState();
function handleOnSearch(data) {
setFormData(data);
mutate(data, {
onSuccess: async data => {
setData(data);
},
});
}
return (
<ReportsLayout filter={<FunnelForm onSearch={handleOnSearch} />} header={'test'}>
<Page>
<PageHeader title="Funnel Report"></PageHeader>
<FunnelChart data={data} />
<FunnelTable data={data} />
</Page>
</ReportsLayout>
);
}

View file

@ -0,0 +1,10 @@
.filters {
display: flex;
flex-direction: column;
justify-content: space-between;
border: 1px solid var(--base400);
border-radius: var(--border-radius);
line-height: 32px;
padding: 10px;
overflow: hidden;
}

View file

@ -0,0 +1,12 @@
import DataTable from 'components/metrics/DataTable';
export function FunnelTable({ ...props }) {
const { data } = props;
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" />;
}
export default FunnelTable;

View file

@ -7,14 +7,19 @@ import useMessages from 'hooks/useMessages';
export function DateRangeSetting() {
const { formatMessage, labels } = useMessages();
const [dateRange, setDateRange] = useDateRange();
const { startDate, endDate, value } = dateRange;
const { value } = dateRange;
const handleChange = value => setDateRange(value);
const handleReset = () => setDateRange(DEFAULT_DATE_RANGE);
return (
<Flexbox gap={10}>
<DateFilter value={value} startDate={startDate} endDate={endDate} onChange={handleChange} />
<DateFilter
value={value}
startDate={dateRange.startDate}
endDate={dateRange.endDate}
onChange={handleChange}
/>
<Button onClick={handleReset}>{formatMessage(labels.reset)}</Button>
</Flexbox>
);