ui: comparing for funnels, alternate column view, some refactoring to prepare for customizations...

This commit is contained in:
nick-delirium 2024-12-05 16:37:42 +01:00
parent 7b0a41b743
commit 130e00748b
No known key found for this signature in database
GPG key ID: 93ABD695DF5FDBA0
16 changed files with 458 additions and 386 deletions

View file

@ -19,6 +19,7 @@ interface Props {
yaxis?: Record<string, any>;
label?: string;
hideLegend?: boolean;
inGrid?: boolean;
}
function CustomAreaChart(props: Props) {
@ -29,6 +30,7 @@ function CustomAreaChart(props: Props) {
yaxis = { ...Styles.yaxis },
label = 'Number of Sessions',
hideLegend = false,
inGrid,
} = props;
return (
@ -39,7 +41,7 @@ function CustomAreaChart(props: Props) {
onClick={onClick}
>
{!hideLegend && (
<Legend iconType={'circle'} wrapperStyle={{ top: -26 }} />
<Legend iconType={'circle'} wrapperStyle={{ top: inGrid ? undefined : -18 }} />
)}
<CartesianGrid
strokeDasharray="3 3"

View file

@ -21,6 +21,7 @@ interface Props {
yaxis?: any;
label?: string;
hideLegend?: boolean;
inGrid?: boolean;
}
const getPath = (x, y, width, height) => {
@ -75,6 +76,7 @@ function CustomBarChart(props: Props) {
yaxis = { ...Styles.yaxis },
label = 'Number of Sessions',
hideLegend = false,
inGrid,
} = props;
const resultChart = data.chart.map((item, i) => {
@ -112,7 +114,7 @@ function CustomBarChart(props: Props) {
</pattern>
</defs>
{!hideLegend && (
<Legend iconType={'circle'} wrapperStyle={{ top: -26 }} />
<Legend iconType={'circle'} wrapperStyle={{ top: inGrid ? undefined : -18 }} />
)}
<CartesianGrid
strokeDasharray="3 3"

View file

@ -27,6 +27,7 @@ interface Props {
yaxis?: any;
label?: string;
hideLegend?: boolean;
inGrid?: boolean;
}
function CustomMetricLineChart(props: Props) {
@ -39,6 +40,7 @@ function CustomMetricLineChart(props: Props) {
yaxis = { ...Styles.yaxis },
label = 'Number of Sessions',
hideLegend = false,
inGrid,
} = props;
const resultChart = data.chart.map((item, i) => {
@ -54,7 +56,7 @@ function CustomMetricLineChart(props: Props) {
onClick={onClick}
>
{!hideLegend && (
<Legend iconType={'circle'} wrapperStyle={{ top: -26 }} />
<Legend iconType={'circle'} wrapperStyle={{ top: inGrid ? undefined : -18 }} />
)}
<CartesianGrid
strokeDasharray="3 3"

View file

@ -17,10 +17,11 @@ interface Props {
};
colors: any;
onClick?: (filters) => void;
inGrid?: boolean;
}
function CustomMetricPieChart(props: Props) {
const { metric, data, onClick = () => null } = props;
const { metric, data, onClick = () => null, inGrid } = props;
const onClickHandler = (event) => {
if (event && !event.payload.group) {
@ -62,7 +63,7 @@ function CustomMetricPieChart(props: Props) {
>
<ResponsiveContainer height={240} width="100%">
<PieChart>
<Legend iconType={'circle'} wrapperStyle={{ top: -26 }} />
<Legend iconType={'circle'} wrapperStyle={{ top: inGrid ? undefined : -18 }} />
<Pie
isAnimationActive={false}
data={values}

View file

@ -9,7 +9,7 @@ import { useStore } from 'App/mstore';
import AreaChart from '../../Widgets/CustomMetricsWidgets/AreaChart';
import BarChart from '../../Widgets/CustomMetricsWidgets/BarChart';
import ProgressBarChart from '../../Widgets/CustomMetricsWidgets/ProgressBarChart';
import BugNumChart from '../../Widgets/CustomMetricsWidgets/BigNumChart'
import BugNumChart from '../../Widgets/CustomMetricsWidgets/BigNumChart';
import WidgetDatatable from '../WidgetDatatable/WidgetDatatable';
import WidgetPredefinedChart from '../WidgetPredefinedChart';
import { getStartAndEndTimestampsByDensity } from 'Types/dashboard/helper';
@ -56,7 +56,7 @@ function WidgetChart(props: Props) {
const drillDownFilter = dashboardStore.drillDownFilter;
const colors = Styles.customMetricColors;
const [loading, setLoading] = useState(true);
const params = { density: dashboardStore.selectedDensity }
const params = { density: dashboardStore.selectedDensity };
const metricParams = _metric.params;
const prevMetricRef = useRef<any>();
const isMounted = useIsMounted();
@ -76,13 +76,15 @@ function WidgetChart(props: Props) {
useEffect(() => {
if (!data.chart) return;
const series = data.chart[0] ? Object.keys(data.chart[0]).filter(
(key) => key !== 'time' && key !== 'timestamp'
) : []
const series = data.chart[0]
? Object.keys(data.chart[0]).filter(
(key) => key !== 'time' && key !== 'timestamp'
)
: [];
if (series.length) {
setEnabledRows(series)
setEnabledRows(series);
}
}, [data.chart])
}, [data.chart]);
const onChartClick = (event: any) => {
if (event) {
@ -132,7 +134,7 @@ function WidgetChart(props: Props) {
.fetchMetricChartData(metric, payload, isSaved, period, isComparison)
.then((res: any) => {
if (isMounted()) {
if (isComparison) setCompData(res)
if (isComparison) setCompData(res);
else setData(res);
}
})
@ -169,12 +171,27 @@ function WidgetChart(props: Props) {
const timestamps = dashboardStore.comparisonPeriod.toTimestamps();
// TODO: remove after backend adds support for more view types
const payload = { ...params, ...timestamps, ...metric.toJson(), viewType: 'lineChart' };
fetchMetricChartData(metric, payload, isSaved, dashboardStore.comparisonPeriod, true);
}
const payload = {
...params,
...timestamps,
...metric.toJson(),
viewType: 'lineChart',
};
fetchMetricChartData(
metric,
payload,
isSaved,
dashboardStore.comparisonPeriod,
true
);
};
useEffect(() => {
if (!dashboardStore.comparisonPeriod) {
setCompData(null);
return;
}
loadComparisonData();
}, [dashboardStore.comparisonPeriod])
}, [dashboardStore.comparisonPeriod]);
useEffect(() => {
_metric.updateKey('page', 1);
loadPage();
@ -202,6 +219,7 @@ function WidgetChart(props: Props) {
<FunnelWidget
metric={metric}
data={data}
compData={compData}
isWidget={isSaved || isTemplate}
/>
);
@ -224,10 +242,13 @@ function WidgetChart(props: Props) {
if (metricType === TIMESERIES) {
const chartData = { ...data };
chartData.namesMap = Array.isArray(chartData.namesMap) ? chartData.namesMap.map(n => enabledRows.includes(n) ? n : null) : chartData.namesMap
chartData.namesMap = Array.isArray(chartData.namesMap)
? chartData.namesMap.map((n) => (enabledRows.includes(n) ? n : null))
: chartData.namesMap;
if (viewType === 'lineChart') {
return (
<CustomMetricLineChart
inGrid={!props.isPreview}
data={chartData}
compData={compData}
colors={colors}
@ -245,6 +266,7 @@ function WidgetChart(props: Props) {
return (
<AreaChart
data={chartData}
inGrid={!props.isPreview}
params={params}
colors={colors}
onClick={onChartClick}
@ -259,6 +281,7 @@ function WidgetChart(props: Props) {
if (viewType === 'barChart') {
return (
<BarChart
inGrid={!props.isPreview}
data={chartData}
compData={compData}
params={params}
@ -275,6 +298,7 @@ function WidgetChart(props: Props) {
if (viewType === 'progressChart') {
return (
<ProgressBarChart
inGrid={!props.isPreview}
data={chartData}
compData={compData}
params={params}
@ -291,14 +315,15 @@ function WidgetChart(props: Props) {
if (viewType === 'pieChart') {
return (
<CustomMetricPieChart
inGrid={!props.isPreview}
metric={metric}
data={chartData}
colors={colors}
onClick={onChartClick}
label={
metric.metricOf === 'sessionCount'
? 'Number of Sessions'
: 'Number of Users'
? 'Number of Sessions'
: 'Number of Users'
}
/>
);
@ -306,13 +331,14 @@ function WidgetChart(props: Props) {
if (viewType === 'progress') {
return (
<CustomMetricPercentage
inGrid={!props.isPreview}
data={data[0]}
colors={colors}
params={params}
label={
metric.metricOf === 'sessionCount'
? 'Number of Sessions'
: 'Number of Users'
? 'Number of Sessions'
: 'Number of Users'
}
/>
);
@ -324,16 +350,17 @@ function WidgetChart(props: Props) {
return (
<BugNumChart
data={data}
inGrid={!props.isPreview}
compData={compData}
colors={colors}
onClick={onChartClick}
label={
metric.metricOf === 'sessionCount'
? 'Number of Sessions'
: 'Number of Users'
? 'Number of Sessions'
: 'Number of Users'
}
/>
)
);
}
}
@ -428,13 +455,27 @@ function WidgetChart(props: Props) {
return <div>Unknown metric type</div>;
};
return (
<div ref={ref}>
<Loader loading={loading} style={{ height: `240px` }}>
<div style={{ minHeight: 240 }}>
<div
style={{
minHeight: 240,
paddingTop:
props.isPreview && metric.metricType === TIMESERIES
? '1.5rem'
: 0,
}}
>
{renderChart()}
{metric.metricType === TIMESERIES ? (
<WidgetDatatable defaultOpen={metric.viewType === 'table'} data={data} enabledRows={enabledRows} setEnabledRows={setEnabledRows} />
{props.isPreview && metric.metricType === TIMESERIES ? (
<WidgetDatatable
defaultOpen={metric.viewType === 'table'}
data={data}
enabledRows={enabledRows}
setEnabledRows={setEnabledRows}
/>
) : null}
</div>
</Loader>

View file

@ -5,7 +5,13 @@ import { observer } from 'mobx-react-lite';
import { Space } from 'antd';
import RangeGranularity from "./RangeGranularity";
function WidgetDateRange({ viewType = undefined, label = 'Time Range', isTimeseries = false }: any) {
function WidgetDateRange({
viewType = undefined,
label = 'Time Range',
hasGranularSettings = false,
hasGranularity = false,
hasComparison = false,
}: any) {
const { dashboardStore } = useStore();
const density = dashboardStore.selectedDensity
const onDensityChange = (density: number) => {
@ -25,7 +31,9 @@ function WidgetDateRange({ viewType = undefined, label = 'Time Range', isTimeser
};
const onChangeComparison = (period: any) => {
console.log(period)
dashboardStore.setComparisonPeriod(period);
if (!period) return;
const periodTimestamps = period.toTimestamps();
const compFilter = dashboardStore.cloneCompFilter();
compFilter.merge({
@ -34,8 +42,6 @@ function WidgetDateRange({ viewType = undefined, label = 'Time Range', isTimeser
});
}
const hasGranularity = ['lineChart', 'barChart', 'areaChart'].includes(viewType);
const hasCompare = ['lineChart', 'barChart', 'table', 'progressChart'].includes(viewType);
return (
<Space>
{label && <span className="mr-1 color-gray-medium">{label}</span>}
@ -45,7 +51,7 @@ function WidgetDateRange({ viewType = undefined, label = 'Time Range', isTimeser
isAnt={true}
useButtonStyle={true}
/>
{isTimeseries ? (
{hasGranularSettings ? (
<>
{hasGranularity ? (
<RangeGranularity
@ -54,7 +60,7 @@ function WidgetDateRange({ viewType = undefined, label = 'Time Range', isTimeser
onDensityChange={onDensityChange}
/>
) : null}
{hasCompare ?
{hasComparison ?
<SelectDateRange
period={period}
compPeriod={compPeriod}

View file

@ -22,6 +22,8 @@ import {
Hash,
Users,
Library,
ChartColumnBig,
ChartBarBig,
} from 'lucide-react';
function WidgetOptions() {
@ -32,6 +34,8 @@ function WidgetOptions() {
metric.update({ metricFormat: value });
};
// const hasSeriesTypes = [TIMESERIES, FUNNEL, TABLE].includes(metric.metricType);
const hasViewTypes = [TIMESERIES, FUNNEL].includes(metric.metricType);
return (
<div className={'flex items-center gap-2'}>
{metric.metricType === USER_PATH && (
@ -50,10 +54,7 @@ function WidgetOptions() {
)}
{metric.metricType === TIMESERIES ? (
<>
<SeriesTypeOptions metric={metric} />
<WidgetViewTypeOptions metric={metric} />
</>
) : null}
{(metric.metricType === FUNNEL || metric.metricType === TABLE) &&
metric.metricOf != FilterKey.USERID &&
@ -63,11 +64,12 @@ function WidgetOptions() {
onChange={handleChange}
variant="borderless"
options={[
{ value: 'sessionCount', label: 'Sessions' },
{ value: 'userCount', label: 'Users' },
{ value: 'sessionCount', label: 'All Sessions' },
{ value: 'userCount', label: 'Unique Users' },
]}
/>
)}
{hasViewTypes ? <WidgetViewTypeOptions metric={metric} /> : null}
{metric.metricType === HEATMAP ? <ClickMapRagePicker /> : null}
</div>
@ -121,6 +123,8 @@ const WidgetViewTypeOptions = observer(({ metric }: { metric: any }) => {
progressChart: 'Bar',
table: 'Table',
metric: 'Metric',
chart: 'Funnel Bar',
columnChart: 'Funnel Column',
};
const chartIcons = {
lineChart: <ChartLine size={16} strokeWidth={1} />,
@ -130,16 +134,23 @@ const WidgetViewTypeOptions = observer(({ metric }: { metric: any }) => {
progressChart: <ChartBar size={16} strokeWidth={1} />,
table: <Table size={16} strokeWidth={1} />,
metric: <Hash size={16} strokeWidth={1} />,
// funnel specific
columnChart: <ChartColumnBig size={16} strokeWidth={1} />,
chart: <ChartBarBig size={16} strokeWidth={1} />,
};
const allowedTypes = {
[TIMESERIES]: ['lineChart', 'barChart', 'areaChart', 'pieChart', 'progressChart', 'table', 'metric',],
[FUNNEL]: ['chart', 'columnChart', ] // + table + metric
}
return (
<Dropdown
menu={{
items: Object.entries(chartTypes).map(([key, name]) => ({
items: allowedTypes[metric.metricType].map((key) => ({
key,
label: (
<div className={'flex items-center gap-2'}>
{chartIcons[key]}
<div>{name}</div>
<div>{chartTypes[key]}</div>
</div>
),
})),

View file

@ -3,7 +3,7 @@ import { observer } from 'mobx-react-lite';
import React from 'react';
import WidgetDateRange from "Components/Dashboard/components/WidgetDateRange/WidgetDateRange";
import { useStore } from 'App/mstore';
import { TIMESERIES } from "App/constants/card";
import { FUNNEL, TIMESERIES } from "App/constants/card";
import WidgetWrapper from '../WidgetWrapper';
import WidgetOptions from 'Components/Dashboard/components/WidgetOptions';
@ -16,96 +16,26 @@ interface Props {
function WidgetPreview(props: Props) {
const { className = '' } = props;
const { metricStore, dashboardStore } = useStore();
const { metricStore } = useStore();
const metric: any = metricStore.instance;
// compare logic
const hasGranularSettings = [TIMESERIES, FUNNEL].includes(metric.metricType)
const hasGranularity = ['lineChart', 'barChart', 'areaChart'].includes(metric.viewType);
const hasComparison = metric.metricType === FUNNEL || ['lineChart', 'barChart', 'table', 'progressChart'].includes(metric.viewType);
return (
<>
<div
className={cn(className, 'bg-white rounded-xl border shadow-sm mt-0')}
>
<div className="flex items-center gap-2 px-4 pt-2">
<WidgetDateRange label="" isTimeseries={metric.metricType === TIMESERIES} viewType={metric.viewType} />
<div className="flex items-center ml-auto">
<div className="flex items-center gap-2 px-4 py-2 border-b">
<WidgetDateRange
label=""
hasGranularSettings={hasGranularSettings}
hasGranularity={hasGranularity}
hasComparison={hasComparison}
/>
<div className="ml-auto">
<WidgetOptions />
{/*{metric.metricType === USER_PATH && (*/}
{/* <a*/}
{/* href="#"*/}
{/* onClick={(e) => {*/}
{/* e.preventDefault();*/}
{/* metric.update({ hideExcess: !metric.hideExcess });*/}
{/* }}*/}
{/* >*/}
{/* <Space>*/}
{/* <Switch checked={metric.hideExcess} size="small" />*/}
{/* <span className="mr-4 color-gray-medium">*/}
{/* Hide Minor Paths*/}
{/* </span>*/}
{/* </Space>*/}
{/* </a>*/}
{/*)}*/}
{/*{isTimeSeries && (*/}
{/* <>*/}
{/* <span className="mr-4 color-gray-medium">Visualization</span>*/}
{/* <SegmentSelection*/}
{/* name="viewType"*/}
{/* className="my-3"*/}
{/* primary*/}
{/* size="small"*/}
{/* onSelect={ changeViewType }*/}
{/* value={{ value: metric.viewType }}*/}
{/* list={ [*/}
{/* { value: 'lineChart', name: 'Chart', icon: 'graph-up-arrow' },*/}
{/* { value: 'progress', name: 'Progress', icon: 'hash' },*/}
{/* ]}*/}
{/* />*/}
{/* </>*/}
{/*)}*/}
{/*{!disableVisualization && isTable && (*/}
{/* <>*/}
{/* <span className="mr-4 color-gray-medium">Visualization</span>*/}
{/* <SegmentSelection*/}
{/* name="viewType"*/}
{/* className="my-3"*/}
{/* primary={true}*/}
{/* size="small"*/}
{/* onSelect={ changeViewType }*/}
{/* value={{ value: metric.viewType }}*/}
{/* list={[*/}
{/* { value: 'table', name: 'Table', icon: 'table' },*/}
{/* { value: 'pieChart', name: 'Chart', icon: 'pie-chart-fill' },*/}
{/* ]}*/}
{/* disabledMessage="Chart view is not supported"*/}
{/* />*/}
{/* </>*/}
{/*)}*/}
{/*{isRetention && (*/}
{/* <>*/}
{/* <span className="mr-4 color-gray-medium">Visualization</span>*/}
{/* <SegmentSelection*/}
{/* name="viewType"*/}
{/* className="my-3"*/}
{/* primary={true}*/}
{/* size="small"*/}
{/* onSelect={ changeViewType }*/}
{/* value={{ value: metric.viewType }}*/}
{/* list={[*/}
{/* { value: 'trend', name: 'Trend', icon: 'graph-up-arrow' },*/}
{/* { value: 'cohort', name: 'Cohort', icon: 'dice-3' },*/}
{/* ]}*/}
{/* disabledMessage="Chart view is not supported"*/}
{/* />*/}
{/*</>*/}
{/*)}*/}
{/* add to dashboard */}
{/*{metric.exists() && (*/}
{/* <AddToDashboardButton metricId={metric.metricId}/>*/}
{/*)}*/}
</div>
</div>
<div className="pt-0">

View file

@ -7,12 +7,12 @@ import { useStore } from 'App/mstore';
import { withRouter, RouteComponentProps } from 'react-router-dom';
import { withSiteId, dashboardMetricDetails } from 'App/routes';
import TemplateOverlay from './TemplateOverlay';
import AlertButton from './AlertButton';
import stl from './widgetWrapper.module.css';
import { FilterKey } from 'App/types/filter/filterType';
import { TIMESERIES } from "App/constants/card";
import { TIMESERIES } from 'App/constants/card';
const WidgetChart = lazy(() => import('Components/Dashboard/components/WidgetChart'));
const WidgetChart = lazy(
() => import('Components/Dashboard/components/WidgetChart')
);
interface Props {
className?: string;
@ -74,14 +74,20 @@ function WidgetWrapper(props: Props & RouteComponentProps) {
});
const onDelete = async () => {
dashboardStore.deleteDashboardWidget(dashboard?.dashboardId!, widget.widgetId);
dashboardStore.deleteDashboardWidget(
dashboard?.dashboardId!,
widget.widgetId
);
};
const onChartClick = () => {
if (!isSaved || isPredefined) return;
props.history.push(
withSiteId(dashboardMetricDetails(dashboard?.dashboardId, widget.metricId), siteId)
withSiteId(
dashboardMetricDetails(dashboard?.dashboardId, widget.metricId),
siteId
)
);
};
@ -90,7 +96,7 @@ function WidgetWrapper(props: Props & RouteComponentProps) {
const addOverlay =
isTemplate ||
(!isPredefined &&
isSaved &&
isSaved &&
widget.metricOf !== FilterKey.ERRORS &&
widget.metricOf !== FilterKey.SESSIONS);
@ -106,73 +112,39 @@ function WidgetWrapper(props: Props & RouteComponentProps) {
userSelect: 'none',
opacity: isDragging ? 0.5 : 1,
borderColor:
(canDrop && isOver) || active ? '#394EFF' : isPreview ? 'transparent' : '#EEEEEE',
(canDrop && isOver) || active
? '#394EFF'
: isPreview
? 'transparent'
: '#EEEEEE',
}}
ref={dragDropRef}
onClick={props.onClick ? props.onClick : () => {}}
id={`widget-${widget.widgetId}`}
>
{!isTemplate && isSaved && isPredefined && (
<div
className={cn(
stl.drillDownMessage,
'disabled text-gray text-sm invisible group-hover:visible'
)}
>
{'Cannot drill down system provided metrics'}
</div>
{addOverlay && (
<TemplateOverlay onClick={onChartClick} isTemplate={isTemplate} />
)}
{addOverlay && <TemplateOverlay onClick={onChartClick} isTemplate={isTemplate} />}
<div
className={cn('p-3 pb-4 flex items-center justify-between', {
'cursor-move': !isTemplate && isSaved,
})}
>
{!props.hideName ? (
{!props.hideName ? (
<div
className={cn('p-3 pb-4 flex items-center justify-between', {
'cursor-move': !isTemplate && isSaved,
})}
>
<div className="capitalize-first w-full font-medium">
<TextEllipsis text={widget.name} />
</div>
) : null}
{isSaved && (
<div className="flex items-center" id="no-print">
{!isPredefined && isTimeSeries && !isGridView && (
<>
<AlertButton seriesId={widget.series[0] && widget.series[0].seriesId} />
<div className="mx-2" />
</>
)}
{!isTemplate && !isGridView && (
<ItemMenu
items={[
{
text:
widget.metricType === 'predefined'
? 'Cannot edit system generated metrics'
: 'Edit',
onClick: onChartClick,
disabled: widget.metricType === 'predefined',
},
{
text: 'Hide',
onClick: onDelete,
},
]}
/>
)}
</div>
)}
</div>
<div className="px-4" onClick={onChartClick}>
<WidgetChart
isPreview={isPreview}
metric={widget}
isTemplate={isTemplate}
isSaved={isSaved}
/>
</div>
) : null}
<div className="px-4" onClick={onChartClick}>
<WidgetChart
isPreview={isPreview}
metric={widget}
isTemplate={isTemplate}
isSaved={isSaved}
/>
</div>
</div>
);
}

View file

@ -4,77 +4,162 @@ import FunnelStepText from './FunnelStepText';
import { Icon } from 'UI';
import { Space } from 'antd';
import { Styles } from 'Components/Dashboard/Widgets/common';
import cn from 'classnames';
interface Props {
filter: any;
compData?: any;
index?: number;
focusStage?: (index: number, isFocused: boolean) => void;
focusedFilter?: number | null;
metricLabel?: string;
isHorizontal?: boolean;
}
function FunnelBar(props: Props) {
const { filter, index, focusStage, focusedFilter, metricLabel = 'Sessions' } = props;
const { filter, index, focusStage, focusedFilter, compData, isHorizontal } = props;
const isFocused = focusedFilter && index ? focusedFilter === index - 1 : false;
const isFocused =
focusedFilter && index ? focusedFilter === index - 1 : false;
return (
<div className="w-full mb-4">
<div className="w-full mb-2">
<FunnelStepText filter={filter} />
<div className={isHorizontal ? 'flex gap-1' : 'flex flex-col'}>
<FunnelBarData
data={props.filter}
isHorizontal={isHorizontal}
isComp={false}
index={index}
isFocused={isFocused}
focusStage={focusStage}
/>
{compData ? (
<FunnelBarData
data={props.compData}
isHorizontal={isHorizontal}
isComp
index={index}
isFocused={isFocused}
focusStage={focusStage}
/>
) : null}
</div>
</div>
);
}
function FunnelBarData({
data,
isComp,
isFocused,
focusStage,
index,
isHorizontal,
}: {
data: any;
isComp?: boolean;
isFocused?: boolean;
focusStage?: (index: number, isFocused: boolean) => void;
index?: number;
isHorizontal?: boolean;
}) {
const vertFillBarStyle = {
width: `${data.completedPercentageTotal}%`,
position: 'absolute',
top: 0,
left: 0,
bottom: 0,
backgroundColor: isComp ? Styles.compareColors[2] : Styles.compareColors[1]
};
const horizontalFillBarStyle = {
width: '100%',
height: `${data.completedPercentageTotal}%`,
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
backgroundColor: isComp ? Styles.compareColors[2] : Styles.compareColors[1]
}
const vertEmptyBarStyle = {
width: `${100.1 - data.completedPercentageTotal}%`,
position: 'absolute',
top: 0,
right: 0,
bottom: 0,
background: isFocused
? 'rgba(204, 0, 0, 0.3)'
: 'repeating-linear-gradient(325deg, lightgray, lightgray 2px, #FFF1F0 2px, #FFF1F0 6px)',
cursor: 'pointer',
}
const horizontalEmptyBarStyle = {
height: `${100.1 - data.completedPercentageTotal}%`,
width: '100%',
position: 'absolute',
top: 0,
right: 0,
left: 0,
background: isFocused
? 'rgba(204, 0, 0, 0.3)'
: 'repeating-linear-gradient(325deg, lightgray, lightgray 2px, #FFF1F0 2px, #FFF1F0 6px)',
cursor: 'pointer',
}
const fillBarStyle = isHorizontal ? horizontalFillBarStyle : vertFillBarStyle;
const emptyBarStyle = isHorizontal ? horizontalEmptyBarStyle : vertEmptyBarStyle
return (
<div>
<div
className={isHorizontal ? 'rounded-t' : ''}
style={{
height: '25px',
width: '99.8%',
height: isHorizontal ? '210px' : '21px',
width: isHorizontal ? '200px' : '99.8%',
backgroundColor: '#f5f5f5',
position: 'relative',
borderRadius: '.5rem',
overflow: 'hidden'
borderRadius: isHorizontal ? undefined : '.5rem',
overflow: 'hidden',
opacity: isComp ? 0.7 : 1,
}}
>
<div
className="flex items-center"
style={{
width: `${filter.completedPercentageTotal}%`,
position: 'absolute',
top: 0,
left: 0,
bottom: 0,
backgroundColor: Styles.compareColors[1]
}}
style={fillBarStyle}
>
<div className="color-white absolute right-0 flex items-center font-medium mr-2 leading-3">
{filter.completedPercentageTotal}%
{data.completedPercentageTotal}%
</div>
</div>
<div
style={{
width: `${100.1 - filter.completedPercentageTotal}%`,
position: 'absolute',
top: 0,
right: 0,
bottom: 0,
backgroundColor: isFocused ? 'rgba(204, 0, 0, 0.3)' : '#fff0f0',
cursor: 'pointer'
}}
onClick={() => focusStage?.(index! - 1, filter.isActive)}
className={'hover:opacity-75'}
style={emptyBarStyle}
onClick={() => focusStage?.(index! - 1, data.isActive)}
className={'hover:opacity-70'}
/>
</div>
<div className="flex justify-between py-2">
<div
className={cn('flex justify-between', isComp ? 'opacity-60' : '')}
>
{/* @ts-ignore */}
<div className="flex items-center">
<Icon name="arrow-right-short" size="20" color="green" />
<span className="mx-1">{filter.count} {metricLabel}</span>
<span className="color-gray-medium text-sm">
({filter.completedPercentage}%) Completed
{`${data.completedPercentage}% . ${data.count}`}
</span>
</div>
{index && index > 1 && (
<Space className="items-center">
<Icon name="caret-down-fill" color={filter.droppedCount > 0 ? 'red' : 'gray-light'} size={16} />
<Icon
name="caret-down-fill"
color={data.droppedCount > 0 ? 'red' : 'gray-light'}
size={16}
/>
<span
className={'mx-1 ' + (filter.droppedCount > 0 ? 'color-red' : 'disabled')}>{filter.droppedCount} {metricLabel}</span>
<span
className={'text-sm ' + (filter.droppedCount > 0 ? 'color-red' : 'disabled')}>({filter.droppedPercentage}%) Dropped</span>
className={
'mx-1 ' + (data.droppedCount > 0 ? 'color-red' : 'disabled')
}
>
{data.droppedCount} Skipped
</span>
</Space>
)}
</div>
@ -86,7 +171,7 @@ export function UxTFunnelBar(props: Props) {
const { filter } = props;
return (
<div className="w-full mb-4">
<div className="w-full mb-2">
<div className={'font-medium'}>{filter.title}</div>
<div
style={{
@ -95,22 +180,28 @@ export function UxTFunnelBar(props: Props) {
backgroundColor: '#f5f5f5',
position: 'relative',
borderRadius: '.5rem',
overflow: 'hidden'
overflow: 'hidden',
}}
>
<div
className="flex items-center"
style={{
width: `${(filter.completed / (filter.completed + filter.skipped)) * 100}%`,
width: `${
(filter.completed / (filter.completed + filter.skipped)) * 100
}%`,
position: 'absolute',
top: 0,
left: 0,
bottom: 0,
backgroundColor: '#6272FF'
backgroundColor: '#6272FF',
}}
>
<div className="color-white absolute right-0 flex items-center font-medium mr-2 leading-3">
{((filter.completed / (filter.completed + filter.skipped)) * 100).toFixed(1)}%
{(
(filter.completed / (filter.completed + filter.skipped)) *
100
).toFixed(1)}
%
</div>
</div>
</div>
@ -119,22 +210,22 @@ export function UxTFunnelBar(props: Props) {
<div className={'flex items-center gap-4'}>
<div className="flex items-center">
<Icon name="arrow-right-short" size="20" color="green" />
<span className="mx-1 font-medium">{filter.completed}</span><span>completed this step</span>
<span className="mx-1 font-medium">{filter.completed}</span>
<span>completed this step</span>
</div>
<div className={'flex items-center'}>
<Icon name="clock" size="16" />
<span className="mx-1 font-medium">
{durationFormatted(filter.avgCompletionTime)}
</span>
<span>
avg. completion time
</span>
<span>avg. completion time</span>
</div>
</div>
{/* @ts-ignore */}
<div className="flex items-center">
<Icon name="caret-down-fill" color="red" size={16} />
<span className="font-medium mx-1">{filter.skipped}</span><span> skipped</span>
<span className="font-medium mx-1">{filter.skipped}</span>
<span> skipped</span>
</div>
</div>
</div>
@ -142,11 +233,3 @@ export function UxTFunnelBar(props: Props) {
}
export default FunnelBar;
const calculatePercentage = (completed: number, dropped: number) => {
const total = completed + dropped;
if (dropped === 0) return 100;
if (total === 0) return 0;
return Math.round((completed / dropped) * 100);
};

View file

@ -7,7 +7,7 @@ function FunnelStepText(props: Props) {
const { filter } = props;
const total = filter.value.length;
return (
<div className="mb-2 color-gray-medium">
<div className="color-gray-medium">
<span className="color-gray-darkest">{filter.label}</span>
<span className="mx-1">{filter.operator}</span>
{filter.value.map((value: any, index: number) => (

View file

@ -1,23 +1,3 @@
.step {
/* display: flex; */
position: relative;
transition: all 0.5s ease;
&:before {
content: '';
border-left: 2px solid $gray-lightest;
position: absolute;
top: 16px;
bottom: 9px;
left: 10px;
/* width: 1px; */
height: 100%;
z-index: 0;
}
&:last-child:before {
display: none;
}
}
.step-disabled {
filter: grayscale(1);
opacity: 0.8;

View file

@ -1,6 +1,7 @@
import React, { useEffect } from 'react';
import Widget from 'App/mstore/types/widget';
import Funnelbar, { UxTFunnelBar } from "./FunnelBar";
import Funnel from 'App/mstore/types/funnel'
import cn from 'classnames';
import stl from './FunnelWidget.module.css';
import { observer } from 'mobx-react-lite';
@ -11,134 +12,186 @@ import { useModal } from 'App/components/Modal';
interface Props {
metric?: Widget;
isWidget?: boolean;
data: any;
data: { funnel: Funnel };
compData: { funnel: Funnel };
}
function FunnelWidget(props: Props) {
const [focusedFilter, setFocusedFilter] = React.useState<number | null>(null);
const { isWidget = false, data, metric } = props;
const funnel = data.funnel || { stages: [] };
const totalSteps = funnel.stages.length;
const stages = isWidget ? [...funnel.stages.slice(0, 1), funnel.stages[funnel.stages.length - 1]] : funnel.stages;
const hasMoreSteps = funnel.stages.length > 2;
const lastStage = funnel.stages[funnel.stages.length - 1];
const remainingSteps = totalSteps - 2;
const { hideModal } = useModal();
const metricLabel = metric?.metricFormat == 'userCount' ? 'Users' : 'Sessions';
const [focusedFilter, setFocusedFilter] = React.useState<number | null>(null);
const { isWidget = false, data, metric, compData } = props;
const funnel = data.funnel || { stages: [] };
const totalSteps = funnel.stages.length;
const stages = isWidget
? [...funnel.stages.slice(0, 1), funnel.stages[funnel.stages.length - 1]]
: funnel.stages;
const hasMoreSteps = funnel.stages.length > 2;
const lastStage = funnel.stages[funnel.stages.length - 1];
const remainingSteps = totalSteps - 2;
const { hideModal } = useModal();
const metricLabel =
metric?.metricFormat == 'userCount' ? 'Users' : 'Sessions';
useEffect(() => {
return () => {
if (isWidget) return;
hideModal();
useEffect(() => {
return () => {
if (isWidget) return;
hideModal();
};
}, []);
const focusStage = (index: number) => {
funnel.stages.forEach((s, i) => {
// turning on all filters if one was focused already
if (focusedFilter === index) {
s.updateKey('isActive', true);
setFocusedFilter(null);
} else {
setFocusedFilter(index);
if (i === index) {
s.updateKey('isActive', true);
} else {
s.updateKey('isActive', false);
}
}, []);
}
});
};
const focusStage = (index: number) => {
funnel.stages.forEach((s, i) => {
// turning on all filters if one was focused already
if (focusedFilter === index) {
s.updateKey('isActive', true)
setFocusedFilter(null)
} else {
setFocusedFilter(index)
if (i === index) {
s.updateKey('isActive', true)
} else {
s.updateKey('isActive', false)
}
}
})
const shownStages = React.useMemo(() => {
const stages: { data: Funnel['stages'][0], compData?: Funnel['stages'][0] }[] = [];
for (let i = 0; i < funnel.stages.length; i++) {
const stage: any = { data: funnel.stages[i], compData: undefined }
const compStage = compData?.funnel.stages[i];
if (compStage) {
stage.compData = compStage;
}
stages.push(stage)
}
return (
<NoContent
style={{ minHeight: 220 }}
title={
<div className="flex items-center text-lg">
<Icon name="info-circle" className="mr-2" size="18" />
No data available for the selected period.
</div>
}
show={!stages || stages.length === 0}
>
<div className="w-full">
{ !isWidget && (
stages.map((filter: any, index: any) => (
<Stage
key={index}
index={index + 1}
isWidget={isWidget}
stage={filter}
focusStage={focusStage}
focusedFilter={focusedFilter}
metricLabel={metricLabel}
/>
))
)}
return stages;
}, [data, compData])
{ isWidget && (
<>
<Stage index={1} isWidget={isWidget} stage={stages[0]} />
const viewType = metric?.viewType;
const isHorizontal = viewType === 'columnChart';
return (
<NoContent
style={{ minHeight: 220 }}
title={
<div className="flex items-center text-lg">
<Icon name="info-circle" className="mr-2" size="18" />
No data available for the selected period.
</div>
}
show={!stages || stages.length === 0}
>
<div className={cn('w-full border-b -mx-4 px-4', isHorizontal ? 'flex gap-2 flex-wrap justify-around' : '')}>
{!isWidget &&
shownStages.map((stage: any, index: any) => (
<Stage
key={index}
isHorizontal={isHorizontal}
index={index + 1}
isWidget={isWidget}
stage={stage.data}
compData={stage.compData}
focusStage={focusStage}
focusedFilter={focusedFilter}
metricLabel={metricLabel}
/>
))}
{ hasMoreSteps && (
<>
<EmptyStage total={remainingSteps} />
</>
)}
{isWidget && (
<>
<Stage index={1} isWidget={isWidget} stage={stages[0]} />
{funnel.stages.length > 1 && (
<Stage index={totalSteps} isWidget={isWidget} stage={lastStage} />
)}
</>
)}
</div>
<div className="flex items-center pb-4">
<div className="flex items-center">
<span className="text-base font-medium mr-2">Lost conversion</span>
<Tooltip title={`${funnel.lostConversions} Sessions ${funnel.lostConversionsPercentage}%`}>
<Tag bordered={false} color="red" className='text-lg font-medium rounded-lg'>
{funnel.lostConversions}
</Tag>
</Tooltip>
</div>
<div className="mx-3" />
<div className="flex items-center">
<span className="text-base font-medium mr-2">Total conversion</span>
<Tooltip title={`${funnel.totalConversions} Sessions ${funnel.totalConversionsPercentage}%`}>
<Tag bordered={false} color="green" className='text-lg font-medium rounded-lg'>
{funnel.totalConversions}
</Tag>
</Tooltip>
</div>
</div>
{funnel.totalDropDueToIssues > 0 && <div className="flex items-center mb-2"><Icon name="magic" /> <span className="ml-2">{funnel.totalDropDueToIssues} sessions dropped due to issues.</span></div>}
</NoContent>
);
{hasMoreSteps && (
<>
<EmptyStage total={remainingSteps} />
</>
)}
{funnel.stages.length > 1 && (
<Stage index={totalSteps} isWidget={isWidget} stage={lastStage} />
)}
</>
)}
</div>
<div className="flex items-center py-2 gap-2">
<div className="flex items-center">
<span className="text-base font-medium mr-2">Total conversion</span>
<Tooltip
title={`${funnel.totalConversions} Sessions ${funnel.totalConversionsPercentage}%`}
>
<Tag
bordered={false}
color="#F5F8FF"
className="text-lg rounded-lg !text-black"
>
{funnel.totalConversions}
</Tag>
</Tooltip>
</div>
<div className="flex items-center">
<span className="text-base font-medium mr-2">Lost conversion</span>
<Tooltip
title={`${funnel.lostConversions} Sessions ${funnel.lostConversionsPercentage}%`}
>
<Tag
bordered={false}
color="#FFEFEF"
className="text-lg rounded-lg !text-black"
>
{funnel.lostConversions}
</Tag>
</Tooltip>
</div>
</div>
{funnel.totalDropDueToIssues > 0 && (
<div className="flex items-center mb-2">
<Icon name="magic" />{' '}
<span className="ml-2">
{funnel.totalDropDueToIssues} sessions dropped due to issues.
</span>
</div>
)}
</NoContent>
);
}
export const EmptyStage = observer(({ total }: any) => {
return (
<div className={cn("flex items-center mb-4 pb-3", stl.step)}>
<IndexNumber index={0} />
<div className="w-fit px-2 border border-teal py-1 text-center justify-center bg-teal-lightest flex items-center rounded-full color-teal" style={{ width: '100px'}}>
{`+${total} ${total > 1 ? 'steps' : 'step'}`}
</div>
<div className="border-b w-full border-dashed"></div>
</div>
)
})
return (
<div className={cn('flex items-center mb-4 pb-3 relative border-b -mx-4 px-4 pt-2')}>
<IndexNumber index={0} />
<div
className="w-fit px-2 border border-teal py-1 text-center justify-center bg-teal-lightest flex items-center rounded-full color-teal"
style={{ width: '100px' }}
>
{`+${total} ${total > 1 ? 'steps' : 'step'}`}
</div>
<div className="border-b w-full border-dashed"></div>
</div>
);
});
export const Stage = observer(({ metricLabel, stage, index, isWidget, uxt, focusStage, focusedFilter }: any) => {
export const Stage = observer(({
metricLabel,
stage,
index,
uxt,
focusStage,
focusedFilter,
compData,
isHorizontal,
}: any) => {
return stage ? (
<div
className={cn('flex items-start', stl.step, { [stl['step-disabled']]: !stage.isActive })}
className={cn(
'flex items-start relative pt-2',
{ [stl['step-disabled']]: !stage.isActive },
)}
>
<IndexNumber index={index} />
{!uxt ? <Funnelbar metricLabel={metricLabel} index={index} filter={stage} focusStage={focusStage} focusedFilter={focusedFilter} /> : <UxTFunnelBar filter={stage} />}
{/*{!isWidget && !uxt && <BarActions bar={stage} />}*/}
{!uxt ? <Funnelbar isHorizontal={isHorizontal} compData={compData} metricLabel={metricLabel} index={index} filter={stage} focusStage={focusStage} focusedFilter={focusedFilter} /> : <UxTFunnelBar filter={stage} />}
</div>
) : (
<></>
)
) : null
})
export const IndexNumber = observer(({ index }: any) => {
@ -149,15 +202,4 @@ export const IndexNumber = observer(({ index }: any) => {
);
})
const BarActions = observer(({ bar }: any) => {
return (
<div className="self-end flex items-center justify-center ml-4" style={{ marginBottom: '49px'}}>
<button onClick={() => bar.updateKey('isActive', !bar.isActive)}>
<Icon name="eye-slash-fill" color={bar.isActive ? "gray-light" : "gray-darkest"} size="22" />
</button>
</div>
)
})
export default observer(FunnelWidget);

View file

@ -52,7 +52,8 @@ function SelectDateRange(props: Props) {
);
const onChange = (value: any) => {
if (props.comparison) {
if (props.comparison && props.onChangeComparison) {
if (!value) return props.onChangeComparison(null);
const newPeriod = new Period({
start: props.period.start,
end: props.period.end,

View file

@ -21,7 +21,7 @@ export default class Funnel {
}
this.totalDropDueToIssues = json.totalDropDueToIssues;
if (json.stages.length >= 1) {
if (json.stages?.length >= 1) {
const firstStage = json.stages[0]
this.stages = json.stages ? json.stages.map((stage: any, index: number) => new FunnelStage().fromJSON(stage, firstStage.count, index > 0 ? json.stages[index - 1].count : stage.count)) : []
const filteredStages = this.stages.filter((stage: any) => stage.isActive)

View file

@ -8,7 +8,7 @@ import {FilterKey} from 'Types/filter/filterType';
import Period, {LAST_24_HOURS} from 'Types/app/period';
import Funnel from '../types/funnel';
import {metricService} from 'App/services';
import { FUNNEL, HEATMAP, INSIGHTS, TABLE, USER_PATH } from "App/constants/card";
import { FUNNEL, HEATMAP, INSIGHTS, TABLE, TIMESERIES, USER_PATH } from "App/constants/card";
import { ErrorInfo } from '../types/error';
import {getChartFormatter} from 'Types/dashboard/helper';
import FilterItem from './filterItem';
@ -295,7 +295,7 @@ export default class Widget {
setData(data: { timestamp: number, [seriesName: string]: number}[], period: any, isComparison?: boolean) {
const _data: any = {};
if (isComparison) {
if (isComparison && this.metricType === TIMESERIES) {
data.forEach((point, i) => {
Object.keys(point).forEach((key) => {
if (key === 'timestamp') return;
@ -321,10 +321,9 @@ export default class Widget {
new InsightIssue(i.category, i.name, i.ratio, i.oldValue, i.value, i.change, i.isNew)
);
} else if (this.metricType === FUNNEL) {
_data.funnel = new Funnel().fromJSON(_data);
_data.funnel = new Funnel().fromJSON(data);
} else if (this.metricType === TABLE) {
const total = data[0]['total'];
const count = data[0]['count'];
_data[0]['values'] = data[0]['values'].map((s: any) => new SessionsByRow().fromJson(s, total, this.metricOf));
} else {
if (data.hasOwnProperty('chart')) {