ui: table, bignum and comp for funnel, add csv export
This commit is contained in:
parent
a0738bdd11
commit
16ab955da7
10 changed files with 254 additions and 47 deletions
|
|
@ -2,35 +2,19 @@ import React from 'react'
|
|||
import { CompareTag } from "./CustomChartTooltip";
|
||||
|
||||
interface Props {
|
||||
data: { chart: any[], namesMap: string[] };
|
||||
compData: { chart: any[], namesMap: string[] } | null;
|
||||
colors: any;
|
||||
onClick?: (event, index) => void;
|
||||
yaxis?: any;
|
||||
label?: string;
|
||||
hideLegend?: boolean;
|
||||
values: { value: number, compData?: number, series: string, valueLabel?: string }[];
|
||||
}
|
||||
function BigNumChart(props: Props) {
|
||||
const {
|
||||
data = { chart: [], namesMap: [] },
|
||||
compData = { chart: [], namesMap: [] },
|
||||
colors,
|
||||
onClick = () => null,
|
||||
label = 'Number of Sessions',
|
||||
values,
|
||||
} = props;
|
||||
|
||||
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 (
|
||||
<div className={'flex justify-around gap-2 w-full'} style={{ height: 240 }}>
|
||||
{values.map((val, i) => (
|
||||
|
|
@ -41,18 +25,20 @@ function BigNumChart(props: Props) {
|
|||
value={val.value}
|
||||
label={label}
|
||||
compData={val.compData}
|
||||
valueLabel={val.valueLabel}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BigNum({ color, series, value, label, compData }: {
|
||||
function BigNum({ color, series, value, label, compData, valueLabel }: {
|
||||
color: string,
|
||||
series: string,
|
||||
value: number,
|
||||
label: string,
|
||||
compData?: number,
|
||||
valueLabel?: string,
|
||||
}) {
|
||||
const formattedNumber = (num: number) => {
|
||||
return Intl.NumberFormat().format(num);
|
||||
|
|
@ -69,7 +55,7 @@ function BigNum({ color, series, value, label, compData }: {
|
|||
<div>{series}</div>
|
||||
</div>
|
||||
<div className={'font-bold leading-none'} style={{ fontSize: 56 }}>
|
||||
{formattedNumber(value)}
|
||||
{formattedNumber(value)}{valueLabel ? `${valueLabel}` : null}
|
||||
</div>
|
||||
<div className={'text-disabled-text text-xs'}>
|
||||
{label}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ 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 FunnelTable from "../../../Funnels/FunnelWidget/FunnelTable";
|
||||
import AreaChart from '../../Widgets/CustomMetricsWidgets/AreaChart';
|
||||
import BarChart from '../../Widgets/CustomMetricsWidgets/BarChart';
|
||||
import ProgressBarChart from '../../Widgets/CustomMetricsWidgets/ProgressBarChart';
|
||||
|
|
@ -215,6 +216,42 @@ function WidgetChart(props: Props) {
|
|||
const metricWithData = { ...metric, data };
|
||||
|
||||
if (metricType === FUNNEL) {
|
||||
console.log(data, compData);
|
||||
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}
|
||||
onClick={onChartClick}
|
||||
label={
|
||||
'Conversion'
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FunnelWidget
|
||||
metric={metric}
|
||||
|
|
@ -347,11 +384,22 @@ function WidgetChart(props: Props) {
|
|||
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
|
||||
data={data}
|
||||
values={values}
|
||||
inGrid={!props.isPreview}
|
||||
compData={compData}
|
||||
colors={colors}
|
||||
onClick={onChartClick}
|
||||
label={
|
||||
|
|
@ -475,6 +523,7 @@ function WidgetChart(props: Props) {
|
|||
data={data}
|
||||
enabledRows={enabledRows}
|
||||
setEnabledRows={setEnabledRows}
|
||||
metric={metric}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import type { TableProps } from 'antd';
|
|||
import { Eye, EyeOff } from 'lucide-react';
|
||||
import cn from 'classnames';
|
||||
import React, { useState } from 'react';
|
||||
import { TableExporter } from "../../../Funnels/FunnelWidget/FunnelTable";
|
||||
|
||||
const initTableProps = [
|
||||
{
|
||||
|
|
@ -27,6 +28,7 @@ interface Props {
|
|||
enabledRows: string[];
|
||||
setEnabledRows: (rows: string[]) => void;
|
||||
defaultOpen?: boolean;
|
||||
metric: { name: string };
|
||||
}
|
||||
|
||||
function WidgetDatatable(props: Props) {
|
||||
|
|
@ -137,20 +139,11 @@ function WidgetDatatable(props: Props) {
|
|||
size={'small'}
|
||||
scroll={{ x: 'max-content' }}
|
||||
/>
|
||||
{/* 1.23+ export menu floater */}
|
||||
{/*<div className={'absolute top-0 -right-1'}>*/}
|
||||
{/* <ItemMenu*/}
|
||||
{/* items={[*/}
|
||||
{/* { icon: 'pencil', text: 'Rename', onClick: () => null },*/}
|
||||
{/* ]}*/}
|
||||
{/* bold*/}
|
||||
{/* customTrigger={*/}
|
||||
{/* <div className={'flex items-center justify-center bg-gray-lighter cursor-pointer hover:bg-gray-light'} style={{ height: 38, width: 38, boxShadow: '-2px 0px 3px 0px rgba(0, 0, 0, 0.05)' }}>*/}
|
||||
{/* <EllipsisVertical size={16} />*/}
|
||||
{/* </div>*/}
|
||||
{/* }*/}
|
||||
{/* />*/}
|
||||
{/*</div>*/}
|
||||
<TableExporter
|
||||
tableData={tableData}
|
||||
tableColumns={tableProps}
|
||||
filename={props.metric.name}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ function WidgetOptions() {
|
|||
)}
|
||||
|
||||
{metric.metricType === TIMESERIES ? (
|
||||
<SeriesTypeOptions metric={metric} />
|
||||
<SeriesTypeOptions metric={metric} />
|
||||
) : null}
|
||||
{(metric.metricType === FUNNEL || metric.metricType === TABLE) &&
|
||||
metric.metricOf != FilterKey.USERID &&
|
||||
|
|
@ -80,7 +80,7 @@ const SeriesTypeOptions = observer(({ metric }: { metric: any }) => {
|
|||
const items = {
|
||||
sessionCount: 'Total Sessions',
|
||||
userCount: 'Unique Users',
|
||||
}
|
||||
};
|
||||
const chartIcons = {
|
||||
sessionCount: <Library size={16} strokeWidth={1} />,
|
||||
userCount: <Users size={16} strokeWidth={1} />,
|
||||
|
|
@ -112,7 +112,7 @@ const SeriesTypeOptions = observer(({ metric }: { metric: any }) => {
|
|||
</Button>
|
||||
</Dropdown>
|
||||
);
|
||||
})
|
||||
});
|
||||
|
||||
const WidgetViewTypeOptions = observer(({ metric }: { metric: any }) => {
|
||||
const chartTypes = {
|
||||
|
|
@ -139,9 +139,17 @@ const WidgetViewTypeOptions = observer(({ metric }: { metric: any }) => {
|
|||
chart: <ChartBarBig size={16} strokeWidth={1} />,
|
||||
};
|
||||
const allowedTypes = {
|
||||
[TIMESERIES]: ['lineChart', 'barChart', 'areaChart', 'pieChart', 'progressChart', 'table', 'metric',],
|
||||
[FUNNEL]: ['chart', 'columnChart', ] // + table + metric
|
||||
}
|
||||
[TIMESERIES]: [
|
||||
'lineChart',
|
||||
'barChart',
|
||||
'areaChart',
|
||||
'pieChart',
|
||||
'progressChart',
|
||||
'table',
|
||||
'metric',
|
||||
],
|
||||
[FUNNEL]: ['chart', 'columnChart', 'metric', 'table'],
|
||||
};
|
||||
return (
|
||||
<Dropdown
|
||||
menu={{
|
||||
|
|
@ -168,6 +176,6 @@ const WidgetViewTypeOptions = observer(({ metric }: { metric: any }) => {
|
|||
</Button>
|
||||
</Dropdown>
|
||||
);
|
||||
})
|
||||
});
|
||||
|
||||
export default observer(WidgetOptions);
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ function FunnelBar(props: Props) {
|
|||
focusedFilter && index ? focusedFilter === index - 1 : false;
|
||||
return (
|
||||
<div className="w-full mb-2">
|
||||
<FunnelStepText filter={filter} />
|
||||
<FunnelStepText filter={filter} isHorizontal={isHorizontal} />
|
||||
<div className={isHorizontal ? 'flex gap-1' : 'flex flex-col'}>
|
||||
<FunnelBarData
|
||||
data={props.filter}
|
||||
|
|
|
|||
|
|
@ -2,12 +2,14 @@ import React from 'react';
|
|||
|
||||
interface Props {
|
||||
filter: any;
|
||||
isHorizontal?: boolean;
|
||||
}
|
||||
function FunnelStepText(props: Props) {
|
||||
const { filter } = props;
|
||||
const total = filter.value.length;
|
||||
const additionalStyle = props.isHorizontal ? { whiteSpace: 'nowrap', maxWidth: 210, textOverflow: 'ellipsis', overflow: 'hidden' } : {};
|
||||
return (
|
||||
<div className="color-gray-medium">
|
||||
<div className="color-gray-medium" style={additionalStyle}>
|
||||
<span className="color-gray-darkest">{filter.label}</span>
|
||||
<span className="mx-1">{filter.operator}</span>
|
||||
{filter.value.map((value: any, index: number) => (
|
||||
|
|
|
|||
131
frontend/app/components/Funnels/FunnelWidget/FunnelTable.tsx
Normal file
131
frontend/app/components/Funnels/FunnelWidget/FunnelTable.tsx
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
import React from 'react';
|
||||
import { Table } from 'antd';
|
||||
import type { TableProps } from 'antd';
|
||||
import Widget from 'App/mstore/types/widget';
|
||||
import Funnel from 'App/mstore/types/funnel';
|
||||
import { ItemMenu } from 'UI';
|
||||
import { EllipsisVertical } from 'lucide-react';
|
||||
import { exportAntCsv } from '../../../utils';
|
||||
|
||||
interface Props {
|
||||
metric?: Widget;
|
||||
data: { funnel: Funnel };
|
||||
compData: { funnel: Funnel };
|
||||
}
|
||||
|
||||
function FunnelTable(props: Props) {
|
||||
const tableData = [
|
||||
{
|
||||
conversion: props.data.funnel.totalConversionsPercentage,
|
||||
},
|
||||
];
|
||||
const tableProps: TableProps['columns'] = [
|
||||
{
|
||||
title: 'Conversion %',
|
||||
dataIndex: 'conversion',
|
||||
key: 'conversion',
|
||||
fixed: 'left',
|
||||
width: 140,
|
||||
render: (text: string, _, index) => (
|
||||
<div className={'w-full justify-between flex'}>
|
||||
<div>Overall {index > 0 ? '(previous)' : ''}</div>
|
||||
<div>{text}%</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
React.useEffect(() => {
|
||||
const funnel = props.data.funnel;
|
||||
funnel.stages.forEach((st, ind) => {
|
||||
const title = `${st.label} ${st.operator} ${st.value.join(' or ')}`;
|
||||
const wrappedTitle =
|
||||
title.length > 40 ? title.slice(0, 40) + '...' : title;
|
||||
tableProps.push({
|
||||
title: wrappedTitle,
|
||||
dataIndex: 'st_' + ind,
|
||||
key: 'st_' + ind,
|
||||
ellipsis: true,
|
||||
width: 120,
|
||||
});
|
||||
tableData[0]['st_' + ind] = st.count;
|
||||
});
|
||||
if (props.compData) {
|
||||
tableData.push({
|
||||
conversion: props.compData.funnel.totalConversionsPercentage,
|
||||
})
|
||||
const compFunnel = props.compData.funnel;
|
||||
compFunnel.stages.forEach((st, ind) => {
|
||||
tableData[1]['st_' + ind] = st.count;
|
||||
});
|
||||
}
|
||||
}, [props.data]);
|
||||
|
||||
return (
|
||||
<div className={'-mx-4 px-2'}>
|
||||
<div className={'mt-2 relative'}>
|
||||
<Table
|
||||
bordered
|
||||
columns={tableProps}
|
||||
dataSource={tableData}
|
||||
pagination={false}
|
||||
size={'middle'}
|
||||
scroll={{ x: 'max-content' }}
|
||||
rowClassName={(_, index) => (
|
||||
index > 0 ? 'opacity-70' : ''
|
||||
)}
|
||||
/>
|
||||
<TableExporter
|
||||
tableColumns={tableProps}
|
||||
tableData={tableData}
|
||||
filename={props.metric?.name || 'funnel'}
|
||||
top={'top-1'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TableExporter({
|
||||
tableData,
|
||||
tableColumns,
|
||||
filename,
|
||||
top,
|
||||
right,
|
||||
}: {
|
||||
tableData: any;
|
||||
tableColumns: any;
|
||||
filename: string;
|
||||
top?: string;
|
||||
right?: string;
|
||||
}) {
|
||||
const onClick = () => exportAntCsv(tableColumns, tableData, filename);
|
||||
return (
|
||||
<div
|
||||
className={`absolute ${top ? top : 'top-0'} ${
|
||||
right ? right : '-right-1'
|
||||
}`}
|
||||
>
|
||||
<ItemMenu
|
||||
items={[{ icon: 'pencil', text: 'Export', onClick }]}
|
||||
bold
|
||||
customTrigger={
|
||||
<div
|
||||
className={
|
||||
'flex items-center justify-center bg-gray-lighter cursor-pointer hover:bg-gray-light'
|
||||
}
|
||||
style={{
|
||||
height: 38,
|
||||
width: 38,
|
||||
boxShadow: '-2px 0px 3px 0px rgba(0, 0, 0, 0.05)',
|
||||
}}
|
||||
>
|
||||
<EllipsisVertical size={16} />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FunnelTable;
|
||||
|
|
@ -82,7 +82,7 @@ function FunnelWidget(props: Props) {
|
|||
}
|
||||
show={!stages || stages.length === 0}
|
||||
>
|
||||
<div className={cn('w-full border-b -mx-4 px-4', isHorizontal ? 'flex gap-2 flex-wrap justify-around' : '')}>
|
||||
<div className={cn('w-full border-b -mx-4 px-4', isHorizontal ? 'overflow-x-scroll custom-scrollbar flex gap-2 justify-around' : '')}>
|
||||
{!isWidget &&
|
||||
shownStages.map((stage: any, index: any) => (
|
||||
<Stage
|
||||
|
|
|
|||
|
|
@ -395,3 +395,12 @@ svg {
|
|||
-webkit-text-fill-color: transparent;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
height: 4px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background-color: #c6c6c6;
|
||||
border-radius: 4px;
|
||||
cursor: grab;
|
||||
}
|
||||
|
|
@ -549,4 +549,33 @@ const decodeJwt = (jwt: string): any => {
|
|||
}
|
||||
const base64 = base64Url.replace("-", "+").replace("_", "/");
|
||||
return JSON.parse(atob(base64));
|
||||
};
|
||||
};
|
||||
|
||||
function saveAsFile(blob: Blob, filename: string) {
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export function exportAntCsv(tableColumns, tableData, filename = 'table.csv') {
|
||||
console.log(tableColumns, tableData)
|
||||
const headers = tableColumns.map(col => col.title).join(',');
|
||||
const rows = tableData.map(row => {
|
||||
return tableColumns
|
||||
.map(col => {
|
||||
const value = col.dataIndex ? row[col.dataIndex] : '';
|
||||
return typeof value === 'string' ? `"${value.replace(/"/g, '""')}"` : value;
|
||||
})
|
||||
.join(',');
|
||||
});
|
||||
|
||||
const csvContent = [headers, ...rows].join('\n');
|
||||
console.log(csvContent)
|
||||
// const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||
|
||||
// saveAsFile(blob, filename);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue