609 lines
17 KiB
TypeScript
609 lines
17 KiB
TypeScript
import React, { useState, useRef, useEffect } from 'react';
|
|
import LineChart from 'App/components/Charts/LineChart';
|
|
import BarChart from 'App/components/Charts/BarChart';
|
|
import PieChart from 'App/components/Charts/PieChart';
|
|
import ColumnChart from 'App/components/Charts/ColumnChart';
|
|
import SankeyChart from 'Components/Charts/SankeyChart';
|
|
import SunBurstChart from 'Components/Charts/SunburstChart/Sunburst'
|
|
import CustomMetricPercentage from 'App/components/Dashboard/Widgets/CustomMetricsWidgets/CustomMetricPercentage';
|
|
import { Styles } from 'App/components/Dashboard/Widgets/common';
|
|
import { observer } from 'mobx-react-lite';
|
|
import { Icon, Loader } from 'UI';
|
|
import { useStore } from 'App/mstore';
|
|
import { getStartAndEndTimestampsByDensity } from 'Types/dashboard/helper';
|
|
import { debounce } from 'App/utils';
|
|
import useIsMounted from 'App/hooks/useIsMounted';
|
|
import { FilterKey } from 'Types/filter/filterType';
|
|
import {
|
|
TIMESERIES,
|
|
TABLE,
|
|
HEATMAP,
|
|
FUNNEL,
|
|
ERRORS,
|
|
INSIGHTS,
|
|
USER_PATH,
|
|
RETENTION,
|
|
} from 'App/constants/card';
|
|
import FunnelWidget from 'App/components/Funnels/FunnelWidget';
|
|
import CustomMetricTableSessions from 'App/components/Dashboard/Widgets/CustomMetricsWidgets/CustomMetricTableSessions';
|
|
import CustomMetricTableErrors from 'App/components/Dashboard/Widgets/CustomMetricsWidgets/CustomMetricTableErrors';
|
|
import ClickMapCard from 'App/components/Dashboard/Widgets/CustomMetricsWidgets/ClickMapCard';
|
|
import InsightsCard from 'App/components/Dashboard/Widgets/CustomMetricsWidgets/InsightsCard';
|
|
import SessionsBy from 'Components/Dashboard/Widgets/CustomMetricsWidgets/SessionsBy';
|
|
import { useInView } from 'react-intersection-observer';
|
|
import CohortCard from '../../Widgets/CustomMetricsWidgets/CohortCard';
|
|
import WidgetPredefinedChart from '../WidgetPredefinedChart';
|
|
import WidgetDatatable from '../WidgetDatatable/WidgetDatatable';
|
|
import BugNumChart from '../../Widgets/CustomMetricsWidgets/BigNumChart';
|
|
import FunnelTable from '../../../Funnels/FunnelWidget/FunnelTable';
|
|
import LongLoader from './LongLoader';
|
|
import { useTranslation } from 'react-i18next';
|
|
|
|
interface Props {
|
|
metric: any;
|
|
isSaved?: boolean;
|
|
isTemplate?: boolean;
|
|
isPreview?: boolean;
|
|
}
|
|
|
|
function WidgetChart(props: Props) {
|
|
const { t } = useTranslation();
|
|
const { ref, inView } = useInView({
|
|
triggerOnce: true,
|
|
rootMargin: '200px 0px',
|
|
});
|
|
const { isSaved = false, metric, isTemplate } = props;
|
|
const { dashboardStore, metricStore } = useStore();
|
|
const _metric: any = props.isPreview ? metricStore.instance : props.metric;
|
|
const { data } = _metric;
|
|
const { period } = dashboardStore;
|
|
const { drillDownPeriod } = dashboardStore;
|
|
const { drillDownFilter } = dashboardStore;
|
|
const colors = Styles.safeColors;
|
|
const [loading, setLoading] = useState(true);
|
|
const [stale, setStale] = useState(false);
|
|
const params = { density: dashboardStore.selectedDensity };
|
|
const metricParams = _metric.params;
|
|
const prevMetricRef = useRef<any>();
|
|
const isMounted = useIsMounted();
|
|
const [compData, setCompData] = useState<any>(null);
|
|
const [enabledRows, setEnabledRows] = useState<string[]>(
|
|
_metric.series.map((s) => s.name),
|
|
);
|
|
const isTableWidget =
|
|
_metric.metricType === 'table' && _metric.viewType === 'table';
|
|
const isPieChart =
|
|
_metric.metricType === 'table' && _metric.viewType === 'pieChart';
|
|
|
|
useEffect(
|
|
() => () => {
|
|
dashboardStore.setComparisonPeriod(null, _metric.metricId);
|
|
dashboardStore.resetDrillDownFilter();
|
|
},
|
|
[],
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (enabledRows.length !== _metric.series.length) {
|
|
const excluded = _metric.series
|
|
.filter((s) => !enabledRows.includes(s.name))
|
|
.map((s) => s.name);
|
|
metricStore.setDisabledSeries(excluded);
|
|
} else {
|
|
metricStore.setDisabledSeries([]);
|
|
}
|
|
}, [enabledRows]);
|
|
|
|
useEffect(() => {
|
|
if (!data.chart) return;
|
|
const series = data.chart[0]
|
|
? Object.keys(data.chart[0]).filter(
|
|
(key) => key !== 'time' && key !== 'timestamp',
|
|
)
|
|
: [];
|
|
if (series.length) {
|
|
setEnabledRows(series);
|
|
}
|
|
}, [data.chart]);
|
|
|
|
const onChartClick = (event: any) => {
|
|
metricStore.setDrillDown(true);
|
|
if (event) {
|
|
if (isTableWidget || isPieChart) {
|
|
// get the filter of clicked row
|
|
const periodTimestamps = drillDownPeriod.toTimestamps();
|
|
drillDownFilter.merge({
|
|
filters: event,
|
|
startTimestamp: periodTimestamps.startTimestamp,
|
|
endTimestamp: periodTimestamps.endTimestamp,
|
|
});
|
|
} else {
|
|
// get the filter of clicked chart point
|
|
const { payload } = event.activePayload[0];
|
|
const { timestamp } = payload;
|
|
const periodTimestamps = getStartAndEndTimestampsByDensity(
|
|
timestamp,
|
|
drillDownPeriod.start,
|
|
drillDownPeriod.end,
|
|
params.density,
|
|
);
|
|
|
|
drillDownFilter.merge({
|
|
startTimestamp: periodTimestamps.startTimestamp,
|
|
endTimestamp: periodTimestamps.endTimestamp,
|
|
});
|
|
}
|
|
}
|
|
};
|
|
|
|
const loadSample = () => console.log('clicked');
|
|
|
|
const depsString = JSON.stringify({
|
|
..._metric.series,
|
|
..._metric.excludes,
|
|
..._metric.startPoint,
|
|
hideExcess: false,
|
|
});
|
|
const fetchMetricChartData = (
|
|
metric: any,
|
|
payload: any,
|
|
isSaved: any,
|
|
period: any,
|
|
isComparison?: boolean,
|
|
) => {
|
|
if (!isMounted()) return;
|
|
setLoading(true);
|
|
const tm = setTimeout(() => {
|
|
setStale(true);
|
|
}, 4000);
|
|
dashboardStore
|
|
.fetchMetricChartData(metric, payload, isSaved, period, isComparison)
|
|
.then((res: any) => {
|
|
if (isComparison) setCompData(res);
|
|
clearTimeout(tm);
|
|
setStale(false);
|
|
})
|
|
.finally(() => {
|
|
if (metric.metricId === 1014) return;
|
|
setLoading(false);
|
|
});
|
|
};
|
|
|
|
const debounceRequest: any = React.useCallback(
|
|
debounce(fetchMetricChartData, 500),
|
|
[],
|
|
);
|
|
const loadPage = () => {
|
|
if (!inView) return;
|
|
if (prevMetricRef.current && prevMetricRef.current.name !== _metric.name) {
|
|
prevMetricRef.current = _metric;
|
|
return;
|
|
}
|
|
prevMetricRef.current = _metric;
|
|
const timestmaps = drillDownPeriod.toTimestamps();
|
|
const payload = isSaved
|
|
? { ...metricParams }
|
|
: { ...params, ...timestmaps, ..._metric.toJson() };
|
|
debounceRequest(
|
|
_metric,
|
|
payload,
|
|
isSaved,
|
|
!isSaved ? drillDownPeriod : period,
|
|
);
|
|
};
|
|
|
|
const loadComparisonData = () => {
|
|
if (!dashboardStore.comparisonPeriods[_metric.metricId])
|
|
return setCompData(null);
|
|
|
|
// TODO: remove after backend adds support for more view types
|
|
const payload = {
|
|
...params,
|
|
..._metric.toJson(),
|
|
};
|
|
fetchMetricChartData(
|
|
_metric,
|
|
payload,
|
|
isSaved,
|
|
dashboardStore.comparisonPeriods[_metric.metricId],
|
|
true,
|
|
);
|
|
};
|
|
useEffect(() => {
|
|
if (!inView || !props.isPreview) return;
|
|
loadComparisonData();
|
|
}, [
|
|
inView,
|
|
props.isPreview,
|
|
dashboardStore.comparisonPeriods[_metric.metricId],
|
|
_metric.metricId,
|
|
drillDownPeriod,
|
|
period,
|
|
depsString,
|
|
dashboardStore.selectedDensity,
|
|
_metric.metricOf,
|
|
]);
|
|
useEffect(() => {
|
|
setCompData(null);
|
|
_metric.updateKey('page', 1);
|
|
_metric.updateKey();
|
|
loadPage();
|
|
}, [
|
|
drillDownPeriod,
|
|
period,
|
|
depsString,
|
|
metric.hideExcess,
|
|
dashboardStore.selectedDensity,
|
|
_metric.metricType,
|
|
_metric.metricOf,
|
|
_metric.metricValue,
|
|
_metric.startType,
|
|
_metric.metricFormat,
|
|
inView,
|
|
]);
|
|
useEffect(loadPage, [_metric.page]);
|
|
|
|
const onFocus = (seriesName: string) => {
|
|
metricStore.setFocusedSeriesName(seriesName);
|
|
metricStore.setDrillDown(true);
|
|
};
|
|
|
|
const renderChart = React.useCallback(() => {
|
|
const { metricType, metricOf } = _metric;
|
|
const { viewType } = _metric;
|
|
const metricWithData = { ..._metric, data };
|
|
|
|
if (metricType === FUNNEL) {
|
|
if (viewType === 'table') {
|
|
return <FunnelTable data={data} compData={compData} />;
|
|
}
|
|
if (viewType === 'metric') {
|
|
const values: {
|
|
value: number;
|
|
compData?: number;
|
|
series: string;
|
|
valueLabel?: string;
|
|
}[] = [
|
|
{
|
|
value: data.funnel.totalConversionsPercentage,
|
|
compData: compData
|
|
? compData.funnel.totalConversionsPercentage
|
|
: undefined,
|
|
series: 'Dynamic',
|
|
valueLabel: '%',
|
|
},
|
|
];
|
|
|
|
return (
|
|
<BugNumChart
|
|
values={values}
|
|
inGrid={!props.isPreview}
|
|
colors={colors}
|
|
hideLegend
|
|
onClick={onChartClick}
|
|
label={t('Conversion')}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<FunnelWidget
|
|
metric={_metric}
|
|
data={data}
|
|
compData={compData}
|
|
isWidget={isSaved || isTemplate}
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (metricType === 'predefined' || metricType === ERRORS) {
|
|
const defaultMetric =
|
|
_metric.data.chart && _metric.data.chart.length === 0
|
|
? metricWithData
|
|
: metric;
|
|
return (
|
|
<WidgetPredefinedChart
|
|
isTemplate={isTemplate}
|
|
metric={defaultMetric}
|
|
data={data}
|
|
predefinedKey={_metric.metricOf}
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (metricType === TIMESERIES) {
|
|
const chartData = { ...data };
|
|
chartData.namesMap = Array.isArray(chartData.namesMap)
|
|
? chartData.namesMap.map((n) => (enabledRows.includes(n) ? n : null))
|
|
: chartData.namesMap;
|
|
const compDataCopy = { ...compData };
|
|
compDataCopy.namesMap = Array.isArray(compDataCopy.namesMap)
|
|
? compDataCopy.namesMap.map((n) => (enabledRows.includes(n) ? n : null))
|
|
: compDataCopy.namesMap;
|
|
|
|
if (viewType === 'lineChart') {
|
|
return (
|
|
<LineChart
|
|
chartName={_metric.name}
|
|
inGrid={!props.isPreview}
|
|
data={chartData}
|
|
compData={compDataCopy}
|
|
onSeriesFocus={onFocus}
|
|
onClick={onChartClick}
|
|
label={
|
|
_metric.metricOf === 'sessionCount'
|
|
? t('Number of Sessions')
|
|
: t('Number of Users')
|
|
}
|
|
/>
|
|
);
|
|
}
|
|
if (viewType === 'areaChart') {
|
|
return (
|
|
<LineChart
|
|
isArea
|
|
chartName={_metric.name}
|
|
data={chartData}
|
|
inGrid={!props.isPreview}
|
|
onClick={onChartClick}
|
|
onSeriesFocus={onFocus}
|
|
label={
|
|
_metric.metricOf === 'sessionCount'
|
|
? t('Number of Sessions')
|
|
: t('Number of Users')
|
|
}
|
|
/>
|
|
);
|
|
}
|
|
if (viewType === 'barChart') {
|
|
return (
|
|
<BarChart
|
|
inGrid={!props.isPreview}
|
|
data={chartData}
|
|
compData={compDataCopy}
|
|
params={params}
|
|
colors={colors}
|
|
onSeriesFocus={onFocus}
|
|
onClick={onChartClick}
|
|
label={
|
|
_metric.metricOf === 'sessionCount'
|
|
? t('Number of Sessions')
|
|
: t('Number of Users')
|
|
}
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (viewType === 'progressChart') {
|
|
return (
|
|
<ColumnChart
|
|
inGrid={!props.isPreview}
|
|
horizontal
|
|
data={chartData}
|
|
compData={compDataCopy}
|
|
params={params}
|
|
colors={colors}
|
|
onSeriesFocus={onFocus}
|
|
label={
|
|
_metric.metricOf === 'sessionCount'
|
|
? t('Number of Sessions')
|
|
: t('Number of Users')
|
|
}
|
|
/>
|
|
);
|
|
}
|
|
if (viewType === 'pieChart') {
|
|
return (
|
|
<PieChart
|
|
inGrid={!props.isPreview}
|
|
data={chartData}
|
|
onSeriesFocus={onFocus}
|
|
label={
|
|
_metric.metricOf === 'sessionCount'
|
|
? t('Number of Sessions')
|
|
: t('Number of Users')
|
|
}
|
|
/>
|
|
);
|
|
}
|
|
if (viewType === 'progress') {
|
|
return (
|
|
<CustomMetricPercentage
|
|
inGrid={!props.isPreview}
|
|
data={data[0]}
|
|
colors={colors}
|
|
params={params}
|
|
label={
|
|
_metric.metricOf === 'sessionCount'
|
|
? t('Number of Sessions')
|
|
: t('Number of Users')
|
|
}
|
|
/>
|
|
);
|
|
}
|
|
if (viewType === 'table') {
|
|
return null;
|
|
}
|
|
if (viewType === 'metric') {
|
|
const values: { value: number; compData?: number; series: string }[] =
|
|
[];
|
|
for (let i = 0; i < data.namesMap.length; i++) {
|
|
if (!data.namesMap[i]) {
|
|
continue;
|
|
}
|
|
|
|
values.push({
|
|
value: data.chart.reduce(
|
|
(acc, curr) => acc + curr[data.namesMap[i]],
|
|
0,
|
|
),
|
|
compData: compData
|
|
? compData.chart.reduce(
|
|
(acc, curr) => acc + curr[compData.namesMap[i]],
|
|
0,
|
|
)
|
|
: undefined,
|
|
series: data.namesMap[i],
|
|
});
|
|
}
|
|
|
|
return (
|
|
<BugNumChart
|
|
values={values}
|
|
inGrid={!props.isPreview}
|
|
colors={colors}
|
|
onSeriesFocus={onFocus}
|
|
label={
|
|
_metric.metricOf === 'sessionCount'
|
|
? t('Number of Sessions')
|
|
: t('Number of Users')
|
|
}
|
|
/>
|
|
);
|
|
}
|
|
}
|
|
|
|
if (metricType === TABLE) {
|
|
if (metricOf === FilterKey.SESSIONS) {
|
|
return (
|
|
<CustomMetricTableSessions
|
|
metric={_metric}
|
|
data={data}
|
|
isTemplate={isTemplate}
|
|
isEdit={!isSaved && !isTemplate}
|
|
/>
|
|
);
|
|
}
|
|
if (metricOf === FilterKey.ERRORS) {
|
|
return (
|
|
<CustomMetricTableErrors
|
|
metric={_metric}
|
|
data={data}
|
|
// isTemplate={isTemplate}
|
|
isEdit={!isSaved && !isTemplate}
|
|
/>
|
|
);
|
|
}
|
|
if (viewType === TABLE) {
|
|
return (
|
|
<SessionsBy
|
|
metric={_metric}
|
|
data={data}
|
|
onClick={onChartClick}
|
|
isTemplate={isTemplate}
|
|
/>
|
|
);
|
|
}
|
|
}
|
|
if (metricType === HEATMAP) {
|
|
if (!props.isPreview) {
|
|
return _metric.thumbnail ? (
|
|
<div
|
|
style={{
|
|
height: '229px',
|
|
overflow: 'hidden',
|
|
marginBottom: '10px',
|
|
}}
|
|
>
|
|
<img src={_metric.thumbnail} alt="clickmap thumbnail" />
|
|
</div>
|
|
) : (
|
|
<div
|
|
className="flex items-center relative justify-center"
|
|
style={{ height: '229px' }}
|
|
>
|
|
<Icon name="info-circle" className="mr-2" size="14" />
|
|
{t('No data available for the selected period.')}
|
|
</div>
|
|
);
|
|
}
|
|
return <ClickMapCard />;
|
|
}
|
|
|
|
if (metricType === INSIGHTS) {
|
|
return <InsightsCard data={data} />;
|
|
}
|
|
|
|
if (metricType === USER_PATH && data && data.links) {
|
|
const isUngrouped = props.isPreview
|
|
? !(_metric.hideExcess ?? true)
|
|
: false;
|
|
const height = props.isPreview ? 550 : 240;
|
|
return (
|
|
<div>
|
|
<SankeyChart
|
|
height={height}
|
|
data={data}
|
|
inGrid={!props.isPreview}
|
|
onChartClick={(filters: any) => {
|
|
dashboardStore.drillDownFilter.merge({ filters, page: 1 });
|
|
}}
|
|
isUngrouped={isUngrouped}
|
|
/>
|
|
<SunBurstChart
|
|
height={height}
|
|
data={data}
|
|
inGrid={!props.isPreview}
|
|
onChartClick={(filters: any) => {
|
|
dashboardStore.drillDownFilter.merge({ filters, page: 1 });
|
|
}}
|
|
isUngrouped={isUngrouped}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (metricType === RETENTION) {
|
|
if (viewType === 'trend') {
|
|
return (
|
|
<LineChart
|
|
data={data}
|
|
colors={colors}
|
|
params={params}
|
|
onClick={onChartClick}
|
|
/>
|
|
);
|
|
}
|
|
if (viewType === 'cohort') {
|
|
return <CohortCard data={data[0]} />;
|
|
}
|
|
}
|
|
console.log('Unknown metric type', metricType);
|
|
return <div>{t('Unknown metric type')}</div>;
|
|
}, [data, compData, enabledRows, _metric]);
|
|
|
|
const showTable =
|
|
_metric.metricType === TIMESERIES &&
|
|
(props.isPreview || _metric.viewType === TABLE);
|
|
const tableMode =
|
|
_metric.viewType === 'table' && _metric.metricType === TIMESERIES;
|
|
return (
|
|
<div ref={ref}>
|
|
{loading ? (
|
|
stale ? (
|
|
<LongLoader onClick={loadSample} />
|
|
) : (
|
|
<Loader loading={loading} style={{ height: '240px' }} />
|
|
)
|
|
) : (
|
|
<div style={{ minHeight: props.isPreview ? undefined : 240 }}>
|
|
{renderChart()}
|
|
{showTable ? (
|
|
<WidgetDatatable
|
|
compData={compData}
|
|
inBuilder={props.isPreview}
|
|
defaultOpen
|
|
data={data}
|
|
tableMode={tableMode}
|
|
enabledRows={enabledRows}
|
|
setEnabledRows={setEnabledRows}
|
|
metric={_metric}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default observer(WidgetChart);
|