ui: table, bignum and comp for funnel, add csv export

This commit is contained in:
nick-delirium 2024-12-06 17:17:41 +01:00
parent a0738bdd11
commit 16ab955da7
No known key found for this signature in database
GPG key ID: 93ABD695DF5FDBA0
10 changed files with 254 additions and 47 deletions

View file

@ -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}

View file

@ -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>

View file

@ -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>

View file

@ -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);

View file

@ -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}

View file

@ -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) => (

View 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;

View file

@ -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

View file

@ -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;
}

View file

@ -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);
}