diff --git a/frontend/app/components/Dashboard/Widgets/CustomMetricsWidgets/AreaChart.tsx b/frontend/app/components/Dashboard/Widgets/CustomMetricsWidgets/AreaChart.tsx new file mode 100644 index 000000000..3f6e742ec --- /dev/null +++ b/frontend/app/components/Dashboard/Widgets/CustomMetricsWidgets/AreaChart.tsx @@ -0,0 +1,83 @@ +import React from 'react'; +import CustomTooltip from "./CustomChartTooltip"; +import { Styles } from '../common'; +import { + ResponsiveContainer, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + AreaChart, + Area, + Legend, +} from 'recharts'; + +interface Props { + data: any; + params: any; + colors: any; + onClick?: (event, index) => void; + yaxis?: any; + label?: string; + hideLegend?: boolean; +} + +function CustomMetricLineChart(props: Props) { + const { + data = { chart: [], namesMap: [] }, + params, + colors, + onClick = () => null, + yaxis = { ...Styles.yaxis }, + label = 'Number of Sessions', + hideLegend = false, + } = props; + + + console.log(data.namesMap, data.chart) + return ( + + + {!hideLegend && ( + + )} + + + Styles.tickFormatter(val)} + label={{ + ...Styles.axisLabelLeft, + value: label || 'Number of Sessions', + }} + /> + + {Array.isArray(data.namesMap) && + data.namesMap.map((key, index) => ( + + ))} + + + ); +} + +export default CustomMetricLineChart; diff --git a/frontend/app/components/Dashboard/Widgets/CustomMetricsWidgets/BarChart.tsx b/frontend/app/components/Dashboard/Widgets/CustomMetricsWidgets/BarChart.tsx new file mode 100644 index 000000000..5c6df8ee7 --- /dev/null +++ b/frontend/app/components/Dashboard/Widgets/CustomMetricsWidgets/BarChart.tsx @@ -0,0 +1,104 @@ +import React from 'react'; +import CustomTooltip from "./CustomChartTooltip"; +import { Styles } from '../common'; +import { + ResponsiveContainer, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + BarChart, + Bar, + Legend, + Rectangle, +} from 'recharts'; + +interface Props { + data: any; + params: any; + colors: any; + onClick?: (event, index) => void; + yaxis?: any; + label?: string; + hideLegend?: boolean; +} +const getPath = (x, y, width, height) => { + const radius = Math.min(width / 2, height / 2); + return ` + M${x + radius},${y} + H${x + width - radius} + A${radius},${radius} 0 0 1 ${x + width},${y + radius} + V${y + height - radius} + A${radius},${radius} 0 0 1 ${x + width - radius},${y + height} + H${x + radius} + A${radius},${radius} 0 0 1 ${x},${y + height - radius} + V${y + radius} + A${radius},${radius} 0 0 1 ${x + radius},${y} + Z + `; +}; + +const PillBar = (props) => { + const { fill, x, y, width, height } = props; + + return ; +}; + + +function CustomMetricLineChart(props: Props) { + const { + data = { chart: [], namesMap: [] }, + params, + colors, + onClick = () => null, + yaxis = { ...Styles.yaxis }, + label = 'Number of Sessions', + hideLegend = false, + } = props; + + return ( + + + {!hideLegend && ( + + )} + + + Styles.tickFormatter(val)} + label={{ + ...Styles.axisLabelLeft, + value: label || 'Number of Sessions', + }} + /> + + {Array.isArray(data.namesMap) && + data.namesMap.map((key, index) => ( + } + fill={colors[index]} + legendType={key === 'Total' ? 'none' : 'line'} + activeBar={} + // strokeDasharray={'4 3'} FOR COPMARISON ONLY + /> + ))} + + + ); +} + +export default CustomMetricLineChart; diff --git a/frontend/app/components/Dashboard/Widgets/CustomMetricsWidgets/CustomChartTooltip.tsx b/frontend/app/components/Dashboard/Widgets/CustomMetricsWidgets/CustomChartTooltip.tsx new file mode 100644 index 000000000..6ad49bfda --- /dev/null +++ b/frontend/app/components/Dashboard/Widgets/CustomMetricsWidgets/CustomChartTooltip.tsx @@ -0,0 +1,36 @@ +import React from "react"; +import { formatTimeOrDate } from "App/date"; + +function CustomTooltip({ active, payload, label }) { + if (!active) return; + + const shownPayloads = payload.filter((p) => !p.hide); + return ( +
+ {shownPayloads.map((p, index) => ( + <> +
+
+
{index + 1}
+
+
{p.name}
+
+
+
+ {label}, {formatTimeOrDate(p.payload.timestamp)} +
+
{p.value}
+
+ + ))} +
+ ); +} + +export default CustomTooltip; \ No newline at end of file diff --git a/frontend/app/components/Dashboard/Widgets/CustomMetricsWidgets/CustomMetricLineChart/CustomMetricLineChart.tsx b/frontend/app/components/Dashboard/Widgets/CustomMetricsWidgets/CustomMetricLineChart/CustomMetricLineChart.tsx index 8d3b424ef..6b267b714 100644 --- a/frontend/app/components/Dashboard/Widgets/CustomMetricsWidgets/CustomMetricLineChart/CustomMetricLineChart.tsx +++ b/frontend/app/components/Dashboard/Widgets/CustomMetricsWidgets/CustomMetricLineChart/CustomMetricLineChart.tsx @@ -2,6 +2,7 @@ import React, { useState } from 'react'; import { formatTimeOrDate } from 'App/date'; import { Button, Table } from 'antd'; import type { TableProps } from 'antd'; +import CustomTooltip from "../CustomChartTooltip"; import { Eye, EyeOff } from 'lucide-react'; import { Styles } from '../../common'; @@ -15,6 +16,7 @@ import { Line, Legend, } from 'recharts'; +import cn from 'classnames'; interface Props { data: any; @@ -26,18 +28,6 @@ interface Props { hideLegend?: boolean; } -const initTableProps = [{ - title: 'Series', - dataIndex: 'seriesName', - key: 'seriesName', -}, - { - title: 'Avg.', - dataIndex: 'average', - key: 'average', - } -] - function CustomMetricLineChart(props: Props) { const { data = { chart: [], namesMap: [] }, @@ -48,176 +38,55 @@ function CustomMetricLineChart(props: Props) { label = 'Number of Sessions', hideLegend = false, } = props; - const [showTable, setShowTable] = useState(false); - const hasMultipleSeries = data.namesMap.length > 1; - const [tableData, setTableData] = useState([]); - const [tableProps, setTableProps] = useState(initTableProps); - // console.log(params.density / 7, data.chart) - - const columnNames = new Set(); - /** - * basically we have an array of - * { time: some_date, series1: 1, series2: 2, series3: 3, timestamp: 123456 } - * which we turn into a table where each series of filters = row; - * and each unique time = column - * + average for each row - * [ { seriesName: 'series1', mon: 1, tue: 2, wed: 3, average: 2 }, ... ] - * */ - React.useEffect(()=> { - setTableProps(initTableProps) - const series = Object.keys(data.chart[0]) - .filter((key) => key !== 'time' && key !== 'timestamp') - columnNames.clear() - data.chart.forEach((p: any) => { - columnNames.add(p.time) - }) // for example: mon, tue, wed, thu, fri, sat, sun - const avg: any = {} // { seriesName: {itemsCount: 0, total: 0} } - const items: Record[] = []; // as many items (rows) as we have series in filter - series.forEach(s => { - items.push({ seriesName: s, average: 0 }) - avg[s] = { itemsCount: 0, total: 0 } - }) - const tableCols: { title: string, dataIndex: string, key: string }[] = []; - Array.from(columnNames).forEach((name: string) => { - tableCols.push({ - title: name, - dataIndex: name, - key: name, - }) - const values = data.chart.filter((p) => p.time === name) - series.forEach((s) => { - const toDateAvg = values.reduce((acc, curr) => acc + curr[s], 0) / values.length; - avg[s].itemsCount += 1 - avg[s].total += toDateAvg - const ind = items.findIndex((item) => item.seriesName === s) - if (ind === -1) return - items[ind][name] = (values.reduce((acc, curr) => acc + curr[s], 0) / values.length) - .toFixed(2) - }) - }) - Object.keys(avg).forEach((key) => { - const ind = items.findIndex((item) => item.seriesName === key) - if (ind === -1) return - items[ind].average = (avg[key].total / avg[key].itemsCount).toFixed(2) - }) - - setTableProps((prev) => [...prev, ...tableCols]) - setTableData(items) - }, [data.chart.length]) return ( -
- - - {!hideLegend && ( - - )} - - - Styles.tickFormatter(val)} - label={{ - ...Styles.axisLabelLeft, - value: label || 'Number of Sessions', - }} - /> - - {Array.isArray(data.namesMap) && - data.namesMap.map((key, index) => ( - - ))} - - - {hasMultipleSeries ? ( -
-
-
- -
- {showTable ? ( - + + {!hideLegend && ( + + )} + + + Styles.tickFormatter(val)} + label={{ + ...Styles.axisLabelLeft, + value: label || 'Number of Sessions', + }} + /> + + {Array.isArray(data.namesMap) && + data.namesMap.map((key, index) => key ? ( + - ) : null} - - ) : null} - - ); -} - -function CustomTooltip({ active, payload, label }) { - if (!active) return; - - const shownPayloads = payload.filter((p) => !p.hide); - return ( -
- {shownPayloads.map((p, index) => ( - <> -
-
-
{index + 1}
-
-
{p.name}
-
-
-
- {label}, {formatTimeOrDate(p.payload.timestamp)} -
-
{p.value}
-
- - ))} -
+ ) : null)} +
+ ); } diff --git a/frontend/app/components/Dashboard/Widgets/CustomMetricsWidgets/CustomMetricPieChart/CustomMetricPieChart.tsx b/frontend/app/components/Dashboard/Widgets/CustomMetricsWidgets/CustomMetricPieChart/CustomMetricPieChart.tsx index a453222e5..e155838db 100644 --- a/frontend/app/components/Dashboard/Widgets/CustomMetricsWidgets/CustomMetricPieChart/CustomMetricPieChart.tsx +++ b/frontend/app/components/Dashboard/Widgets/CustomMetricsWidgets/CustomMetricPieChart/CustomMetricPieChart.tsx @@ -1,126 +1,156 @@ //@ts-nocheck -import React from 'react' +import React from 'react'; import { ResponsiveContainer, Tooltip } from 'recharts'; -import { PieChart, Pie, Cell } from 'recharts'; +import { PieChart, Pie, Cell, Legend } from 'recharts'; import { Styles } from '../../common'; import { NoContent } from 'UI'; import { filtersMap } from 'Types/filter/newFilter'; import { numberWithCommas } from 'App/utils'; interface Props { - metric: any, - data: any; - colors: any; - onClick?: (filters) => void; + metric: any; + data: any; + colors: any; + onClick?: (filters) => void; } function CustomMetricPieChart(props: Props) { - const { metric, data = { values: [] }, onClick = () => null } = props; + const { metric, data, onClick = () => null } = props; - const onClickHandler = (event) => { - if (event && !event.payload.group) { - const filters = Array(); - let filter = { ...filtersMap[metric.metricOf] } - filter.value = [event.payload.name] - filter.type = filter.key - delete filter.key - delete filter.operatorOptions - delete filter.category - delete filter.icon - delete filter.label - delete filter.options + const onClickHandler = (event) => { + if (event && !event.payload.group) { + const filters = Array(); + let filter = { ...filtersMap[metric.metricOf] }; + filter.value = [event.payload.name]; + filter.type = filter.key; + delete filter.key; + delete filter.operatorOptions; + delete filter.category; + delete filter.icon; + delete filter.label; + delete filter.options; - filters.push(filter); - onClick(filters); - } + filters.push(filter); + onClick(filters); } - return ( - - - - { - const RADIAN = Math.PI / 180; - let radius1 = 15 + innerRadius + (outerRadius - innerRadius); - let radius2 = innerRadius + (outerRadius - innerRadius); - let x2 = cx + radius1 * Math.cos(-midAngle * RADIAN); - let y2 = cy + radius1 * Math.sin(-midAngle * RADIAN); - let x1 = cx + radius2 * Math.cos(-midAngle * RADIAN); - let y1 = cy + radius2 * Math.sin(-midAngle * RADIAN); + }; - const percentage = value * 100 / data.values.reduce((a, b) => a + b.sessionCount, 0); - - if (percentage<3){ - return null; - } - - return( - - ) - }} - label={({ - cx, - cy, - midAngle, - innerRadius, - outerRadius, - value, - index - }) => { - const RADIAN = Math.PI / 180; - let radius = 20 + innerRadius + (outerRadius - innerRadius); - let x = cx + radius * Math.cos(-midAngle * RADIAN); - let y = cy + radius * Math.sin(-midAngle * RADIAN); - const percentage = (value / data.values.reduce((a, b) => a + b.sessionCount, 0)) * 100; - let name = data.values[index].name || 'Unidentified'; - name = name.length > 20 ? name.substring(0, 20) + '...' : name; - if (percentage<3){ - return null; - } - return ( - cx ? "start" : "end"} - dominantBaseline="central" - fill='#666' - > - {name || 'Unidentified'} {numberWithCommas(value)} - - ); - }} - > - {data && data.values && data.values.map((entry, index) => ( - - ))} - - - - - -
Top 5
-
- ) + const getTotalForSeries = (series: string) => { + return data.chart ? data.chart.reduce((acc, curr) => acc + curr[series], 0) : 0 + } + const values = data.namesMap.map((k, i) => { + return { + name: k, + value: getTotalForSeries(k) + } + }) + const highest = values.reduce( + (acc, curr) => + acc.value > curr.value ? acc : curr, + { name: '', value: 0 }); + return ( + + + + + { + const RADIAN = Math.PI / 180; + let radius1 = 15 + innerRadius + (outerRadius - innerRadius); + let radius2 = innerRadius + (outerRadius - innerRadius); + let x2 = cx + radius1 * Math.cos(-midAngle * RADIAN); + let y2 = cy + radius1 * Math.sin(-midAngle * RADIAN); + let x1 = cx + radius2 * Math.cos(-midAngle * RADIAN); + let y1 = cy + radius2 * Math.sin(-midAngle * RADIAN); + + const percentage = + (value * 100) / + highest.value; + + if (percentage < 3) { + return null; + } + + return ( + + ); + }} + label={({ + cx, + cy, + midAngle, + innerRadius, + outerRadius, + value, + index, + }) => { + const RADIAN = Math.PI / 180; + let radius = 20 + innerRadius + (outerRadius - innerRadius); + let x = cx + radius * Math.cos(-midAngle * RADIAN); + let y = cy + radius * Math.sin(-midAngle * RADIAN); + const percentage = + (value / highest.value) * + 100; + let name = values[index].name || 'Unidentified'; + name = name.length > 20 ? name.substring(0, 20) + '...' : name; + if (percentage < 3) { + return null; + } + return ( + cx ? 'start' : 'end'} + dominantBaseline="central" + fill="#666" + > + {numberWithCommas(value)} + + ); + }} + > + {values && values.map((entry, index) => ( + + ))} + + + + + + ); } export default CustomMetricPieChart; diff --git a/frontend/app/components/Dashboard/Widgets/CustomMetricsWidgets/ProgressBarChart.tsx b/frontend/app/components/Dashboard/Widgets/CustomMetricsWidgets/ProgressBarChart.tsx new file mode 100644 index 000000000..570be0977 --- /dev/null +++ b/frontend/app/components/Dashboard/Widgets/CustomMetricsWidgets/ProgressBarChart.tsx @@ -0,0 +1,57 @@ +import React from 'react'; + +interface Props { + data: { chart: any[], namesMap: string[] }; + params: any; + colors: any; + onClick?: (event, index) => void; + yaxis?: any; + label?: string; + hideLegend?: boolean; +} + +function ProgressBarChart(props: Props) { + const { + data = { chart: [], namesMap: [] }, + colors, + onClick = () => null, + label = 'Number of Sessions', + } = props; + + const getTotalForSeries = (series: string) => { + return data.chart.reduce((acc, curr) => acc + curr[series], 0); + } + const values = data.namesMap.map((k, i) => { + return { + name: k, + value: getTotalForSeries(k) + } + }) + const highest = values.reduce( + (acc, curr) => + acc.value > curr.value ? acc : curr, + { name: '', value: 0 }); + + const formattedNumber = (num: number) => { + return Intl.NumberFormat().format(num); + } + return ( +
+ {values.map((val, i) => ( +
+
+
+ {val.name} +
+
+
+
{formattedNumber(val.value)}
+
+
+
+ ))} +
+ ); +} + +export default ProgressBarChart; diff --git a/frontend/app/components/Dashboard/components/FilterSeries/FilterSeries.tsx b/frontend/app/components/Dashboard/components/FilterSeries/FilterSeries.tsx index 24288fe59..a5ad36c3e 100644 --- a/frontend/app/components/Dashboard/components/FilterSeries/FilterSeries.tsx +++ b/frontend/app/components/Dashboard/components/FilterSeries/FilterSeries.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useState } from 'react'; -import { EventsList } from 'Shared/Filters/FilterList'; +import { EventsList, FilterList } from 'Shared/Filters/FilterList'; import SeriesName from './SeriesName'; import cn from 'classnames'; import { observer } from 'mobx-react-lite'; @@ -203,6 +203,7 @@ function FilterSeries(props: Props) { )} {expanded ? ( + <> + + ) : null}
); diff --git a/frontend/app/components/Dashboard/components/WidgetChart/WidgetChart.tsx b/frontend/app/components/Dashboard/components/WidgetChart/WidgetChart.tsx index da6173eb1..e20413305 100644 --- a/frontend/app/components/Dashboard/components/WidgetChart/WidgetChart.tsx +++ b/frontend/app/components/Dashboard/components/WidgetChart/WidgetChart.tsx @@ -1,26 +1,30 @@ -import React, {useState, useRef, useEffect} from 'react'; +import React, { useState, useRef, useEffect } from 'react'; import CustomMetricLineChart from 'App/components/Dashboard/Widgets/CustomMetricsWidgets/CustomMetricLineChart'; import CustomMetricPercentage from 'App/components/Dashboard/Widgets/CustomMetricsWidgets/CustomMetricPercentage'; import CustomMetricPieChart from 'App/components/Dashboard/Widgets/CustomMetricsWidgets/CustomMetricPieChart'; -import {Styles} from 'App/components/Dashboard/Widgets/common'; -import {observer} from 'mobx-react-lite'; +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 { useStore } from 'App/mstore'; +import AreaChart from '../../Widgets/CustomMetricsWidgets/AreaChart'; +import BarChart from '../../Widgets/CustomMetricsWidgets/BarChart'; +import ProgressBarChart from '../../Widgets/CustomMetricsWidgets/ProgressBarChart'; + +import WidgetDatatable from '../WidgetDatatable/WidgetDatatable'; import WidgetPredefinedChart from '../WidgetPredefinedChart'; -import CustomMetricOverviewChart from 'App/components/Dashboard/Widgets/CustomMetricsWidgets/CustomMetricOverviewChart'; -import {getStartAndEndTimestampsByDensity} from 'Types/dashboard/helper'; -import {debounce} from 'App/utils'; +import { getStartAndEndTimestampsByDensity } from 'Types/dashboard/helper'; +import { debounce } from 'App/utils'; import useIsMounted from 'App/hooks/useIsMounted'; -import {FilterKey} from 'Types/filter/filterType'; +import { FilterKey } from 'Types/filter/filterType'; import { - TIMESERIES, - TABLE, - HEATMAP, - FUNNEL, - ERRORS, - INSIGHTS, - USER_PATH, - RETENTION + 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'; @@ -29,245 +33,367 @@ import ClickMapCard from 'App/components/Dashboard/Widgets/CustomMetricsWidgets/ import InsightsCard from 'App/components/Dashboard/Widgets/CustomMetricsWidgets/InsightsCard'; import SankeyChart from 'Shared/Insights/SankeyChart'; import CohortCard from '../../Widgets/CustomMetricsWidgets/CohortCard'; -import SessionsBy from "Components/Dashboard/Widgets/CustomMetricsWidgets/SessionsBy"; -import { useInView } from "react-intersection-observer"; +import SessionsBy from 'Components/Dashboard/Widgets/CustomMetricsWidgets/SessionsBy'; +import { useInView } from 'react-intersection-observer'; interface Props { - metric: any; - isSaved?: boolean; - isTemplate?: boolean; - isPreview?: boolean; + metric: any; + isSaved?: boolean; + isTemplate?: boolean; + isPreview?: boolean; } function WidgetChart(props: Props) { - const { ref, inView } = useInView({ - triggerOnce: true, - rootMargin: "200px 0px", - }); - const {isSaved = false, metric, isTemplate} = props; - const {dashboardStore, metricStore, sessionStore} = useStore(); - const _metric: any = metricStore.instance; - const period = dashboardStore.period; - const drillDownPeriod = dashboardStore.drillDownPeriod; - const drillDownFilter = dashboardStore.drillDownFilter; - const colors = Styles.customMetricColors; - const [loading, setLoading] = useState(true); - const params = {density: 70}; - const metricParams = {...params}; - const prevMetricRef = useRef(); - const isMounted = useIsMounted(); - const [data, setData] = useState(metric.data); - const isTableWidget = metric.metricType === 'table' && metric.viewType === 'table'; - const isPieChart = metric.metricType === 'table' && metric.viewType === 'pieChart'; + const { ref, inView } = useInView({ + triggerOnce: true, + rootMargin: '200px 0px', + }); + const { isSaved = false, metric, isTemplate } = props; + const { dashboardStore, metricStore, sessionStore } = useStore(); + const _metric: any = metricStore.instance; + const period = dashboardStore.period; + const drillDownPeriod = dashboardStore.drillDownPeriod; + const drillDownFilter = dashboardStore.drillDownFilter; + const colors = Styles.customMetricColors; + const [loading, setLoading] = useState(true); + const params = { density: 70 }; + const metricParams = { ...params }; + const prevMetricRef = useRef(); + const isMounted = useIsMounted(); + const [data, setData] = useState(metric.data); + const [enabledRows, setEnabledRows] = useState([]); + const isTableWidget = + metric.metricType === 'table' && metric.viewType === 'table'; + const isPieChart = + metric.metricType === 'table' && metric.viewType === 'pieChart'; - useEffect(() => { - return () => { - dashboardStore.resetDrillDownFilter(); - }; - }, []); - - const onChartClick = (event: any) => { - 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].payload; - const timestamp = payload.timestamp; - const periodTimestamps = getStartAndEndTimestampsByDensity(timestamp, drillDownPeriod.start, drillDownPeriod.end, params.density); - - drillDownFilter.merge({ - startTimestamp: periodTimestamps.startTimestamp, - endTimestamp: periodTimestamps.endTimestamp - }); - } - } + useEffect(() => { + return () => { + dashboardStore.resetDrillDownFilter(); }; + }, []); - const depsString = JSON.stringify({ - ..._metric.series, ..._metric.excludes, ..._metric.startPoint, - hideExcess: _metric.hideExcess - }); - const fetchMetricChartData = (metric: any, payload: any, isSaved: any, period: any) => { - if (!isMounted()) return; - setLoading(true); - dashboardStore.fetchMetricChartData(metric, payload, isSaved, period).then((res: any) => { - if (isMounted()) setData(res); - }).finally(() => { - setLoading(false); + useEffect(() => { + const series = data.chart[0] ? Object.keys(data.chart[0]).filter( + (key) => key !== 'time' && key !== 'timestamp' + ) : [] + if (series.length) { + setEnabledRows(series) + } + }, [data.chart.length]) + + const onChartClick = (event: any) => { + 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].payload; + const timestamp = payload.timestamp; + const periodTimestamps = getStartAndEndTimestampsByDensity( + timestamp, + drillDownPeriod.start, + drillDownPeriod.end, + params.density + ); - 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 ? {...params} : {...metricParams, ...timestmaps, ...metric.toJson()}; - debounceRequest(metric, payload, isSaved, !isSaved ? drillDownPeriod : period); - }; - useEffect(() => { - _metric.updateKey('page', 1); - loadPage(); - }, [ - drillDownPeriod, - period, - depsString, - metric.metricType, - metric.metricOf, - metric.viewType, - metric.metricValue, - metric.startType, - metric.metricFormat, - inView, - ]); - useEffect(loadPage, [_metric.page]); + drillDownFilter.merge({ + startTimestamp: periodTimestamps.startTimestamp, + endTimestamp: periodTimestamps.endTimestamp, + }); + } + } + }; + const depsString = JSON.stringify({ + ..._metric.series, + ..._metric.excludes, + ..._metric.startPoint, + hideExcess: _metric.hideExcess, + }); + const fetchMetricChartData = ( + metric: any, + payload: any, + isSaved: any, + period: any + ) => { + if (!isMounted()) return; + setLoading(true); + dashboardStore + .fetchMetricChartData(metric, payload, isSaved, period) + .then((res: any) => { + if (isMounted()) setData(res); + }) + .finally(() => { + setLoading(false); + }); + }; - const renderChart = () => { - const {metricType, viewType, metricOf} = metric; - const metricWithData = {...metric, data}; - - if (metricType === FUNNEL) { - return ; - } - - if (metricType === 'predefined' || metricType === ERRORS) { - const defaultMetric = metric.data.chart && metric.data.chart.length === 0 ? metricWithData : metric; - return ; - } - - if (metricType === TIMESERIES) { - if (viewType === 'lineChart') { - return ( - - ); - } else if (viewType === 'progress') { - return ( - - ); - } - } - - if (metricType === TABLE) { - if (metricOf === FilterKey.SESSIONS) { - return ( - - ); - } - if (metricOf === FilterKey.ERRORS) { - return ( - - ); - } - if (viewType === TABLE) { - return ( - - ); - } else if (viewType === 'pieChart') { - return ( - - ); - } - } - if (metricType === HEATMAP) { - if (!props.isPreview) { - return metric.thumbnail ? ( -
- clickmap thumbnail -
- ) : ( -
- - No data available for the selected period. -
- ); - } - return ( - - ); - } - - if (metricType === INSIGHTS) { - return ; - } - - if (metricType === USER_PATH && data && data.links) { - // return ; - return { - dashboardStore.drillDownFilter.merge({filters, page: 1}); - }}/>; - } - - if (metricType === RETENTION) { - if (viewType === 'trend') { - return ( - - ); - } else if (viewType === 'cohort') { - return ( - - ); - } - } - - return
Unknown metric type
; - }; - return ( -
- -
{renderChart()}
-
-
+ 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 + ? { ...params } + : { ...metricParams, ...timestmaps, ...metric.toJson() }; + debounceRequest( + metric, + payload, + isSaved, + !isSaved ? drillDownPeriod : period ); + }; + useEffect(() => { + _metric.updateKey('page', 1); + loadPage(); + }, [ + drillDownPeriod, + period, + depsString, + metric.metricType, + metric.metricOf, + metric.metricValue, + metric.startType, + metric.metricFormat, + inView, + ]); + useEffect(loadPage, [_metric.page]); + + const renderChart = () => { + const { metricType, metricOf } = metric; + const viewType = metric.viewType; + const metricWithData = { ...metric, data }; + + if (metricType === FUNNEL) { + return ( + + ); + } + + if (metricType === 'predefined' || metricType === ERRORS) { + const defaultMetric = + metric.data.chart && metric.data.chart.length === 0 + ? metricWithData + : metric; + return ( + + ); + } + + if (metricType === TIMESERIES) { + const chartData = { ...data }; + chartData.namesMap = Array.isArray(chartData.namesMap) ? chartData.namesMap.map(n => enabledRows.includes(n) ? n : null) : chartData.namesMap + if (viewType === 'lineChart') { + return ( + + ); + } + if (viewType === 'areaChart') { + return ( + + ); + } + if (viewType === 'barChart') { + return ( + + ); + } + if (viewType === 'progressChart') { + return ( + + ); + } + if (viewType === 'pieChart') { + return ( + + ); + } + if (viewType === 'progress') { + return ( + + ); + } + if (viewType === 'table') { + return null; + } + } + + if (metricType === TABLE) { + if (metricOf === FilterKey.SESSIONS) { + return ( + + ); + } + if (metricOf === FilterKey.ERRORS) { + return ( + + ); + } + if (viewType === TABLE) { + return ( + + ); + } + } + if (metricType === HEATMAP) { + if (!props.isPreview) { + return metric.thumbnail ? ( +
+ clickmap thumbnail +
+ ) : ( +
+ + No data available for the selected period. +
+ ); + } + return ; + } + + if (metricType === INSIGHTS) { + return ; + } + + if (metricType === USER_PATH && data && data.links) { + // return ; + return ( + { + dashboardStore.drillDownFilter.merge({ filters, page: 1 }); + }} + /> + ); + } + + if (metricType === RETENTION) { + if (viewType === 'trend') { + return ( + + ); + } else if (viewType === 'cohort') { + return ; + } + } + + return
Unknown metric type
; + }; + return ( +
+ +
+ {renderChart()} + {metric.metricType === TIMESERIES ? ( + + ) : null} +
+
+
+ ); } export default observer(WidgetChart); diff --git a/frontend/app/components/Dashboard/components/WidgetDatatable/WidgetDatatable.tsx b/frontend/app/components/Dashboard/components/WidgetDatatable/WidgetDatatable.tsx new file mode 100644 index 000000000..4fcf017e3 --- /dev/null +++ b/frontend/app/components/Dashboard/components/WidgetDatatable/WidgetDatatable.tsx @@ -0,0 +1,161 @@ +import { Button, Table } from 'antd'; +import type { TableProps } from 'antd'; + +import { Eye, EyeOff } from 'lucide-react'; +import cn from 'classnames'; +import React, { useState } from 'react'; + +const initTableProps = [ + { + title: 'Series', + dataIndex: 'seriesName', + key: 'seriesName', + sorter: (a, b) => a.seriesName.localeCompare(b.seriesName), + }, + { + title: 'Avg.', + dataIndex: 'average', + key: 'average', + sorter: (a, b) => a.average - b.average, + }, +]; + +interface Props { + data: { chart: any[]; namesMap: string[] }; + enabledRows: string[]; + setEnabledRows: (rows: string[]) => void; +} + +function WidgetDatatable(props: Props) { + const [tableProps, setTableProps] = + useState(initTableProps); + // console.log(params.density / 7, data.chart) + const data = props.data; + + const [showTable, setShowTable] = useState(false); + const hasMultipleSeries = data.namesMap.length > 1; + const [tableData, setTableData] = useState([]); + + const columnNames = new Set(); + /** + * basically we have an array of + * { time: some_date, series1: 1, series2: 2, series3: 3, timestamp: 123456 } + * which we turn into a table where each series of filters = row; + * and each unique time = column + * + average for each row + * [ { seriesName: 'series1', mon: 1, tue: 2, wed: 3, average: 2 }, ... ] + * */ + const series = Object.keys(data.chart[0]).filter( + (key) => key !== 'time' && key !== 'timestamp' + ); + React.useEffect(() => { + setTableProps(initTableProps); + columnNames.clear(); + data.chart.forEach((p: any) => { + columnNames.add(p.time); + }); // for example: mon, tue, wed, thu, fri, sat, sun + const avg: any = {}; // { seriesName: {itemsCount: 0, total: 0} } + const items: Record[] = []; // as many items (rows) as we have series in filter + series.forEach((s, i) => { + items.push({ seriesName: s, average: 0, key: s }); + avg[s] = { itemsCount: 0, total: 0 }; + }); + const tableCols: { + title: string; + dataIndex: string; + key: string; + sorter: any; + }[] = []; + const uniqueColArr = Array.from(columnNames); + uniqueColArr.forEach((name: string, i) => { + tableCols.push({ + title: name, + dataIndex: name, + key: name, + sorter: (a, b) => a[name] - b[name], + }); + const values = data.chart.filter((p) => p.time === name); + series.forEach((s) => { + avg[s].itemsCount += 1; + avg[s].total += values.reduce((acc, curr) => acc + curr[s], 0); + const ind = items.findIndex((item) => item.seriesName === s); + if (ind === -1) return; + items[ind][name] = values.reduce((acc, curr) => acc + curr[s], 0); + }); + }); + Object.keys(avg).forEach((key) => { + const ind = items.findIndex((item) => item.seriesName === key); + if (ind === -1) return; + items[ind].average = (avg[key].total / avg[key].itemsCount).toFixed(2); + }); + + setTableProps((prev) => [...prev, ...tableCols]); + setTableData(items); + }, [data.chart.length]); + + const rowSelection: TableProps['rowSelection'] = { + selectedRowKeys: props.enabledRows, + onChange: (selectedRowKeys: React.Key[], selectedRows: any[]) => { + props.setEnabledRows(selectedRowKeys as string[]); + console.log( + `selectedRowKeys: ${selectedRowKeys}`, + 'selectedRows: ', + selectedRows + ); + }, + getCheckboxProps: (record: any) => ({ + name: record.name, + checked: false, + }), + type: 'checkbox', + }; + return hasMultipleSeries ? ( +
+
+
+ +
+ {showTable ? ( +
+
+ {/* 1.23+ export menu floater */} + {/*
*/} + {/* null },*/} + {/* ]}*/} + {/* bold*/} + {/* customTrigger={*/} + {/*
*/} + {/* */} + {/*
*/} + {/* }*/} + {/* />*/} + {/*
*/} + + ) : null} + + ) : null; +} + +export default WidgetDatatable; diff --git a/frontend/app/components/Dashboard/components/WidgetDateRange/WidgetDateRange.tsx b/frontend/app/components/Dashboard/components/WidgetDateRange/WidgetDateRange.tsx index d795c45bc..51c77c319 100644 --- a/frontend/app/components/Dashboard/components/WidgetDateRange/WidgetDateRange.tsx +++ b/frontend/app/components/Dashboard/components/WidgetDateRange/WidgetDateRange.tsx @@ -1,37 +1,39 @@ import React from 'react'; import SelectDateRange from 'Shared/SelectDateRange'; -import {useStore} from 'App/mstore'; -import {useObserver} from 'mobx-react-lite'; -import {Space} from "antd"; +import { useStore } from 'App/mstore'; +import { observer } from 'mobx-react-lite'; +import { Space } from 'antd'; -function WidgetDateRange({ - label = 'Time Range', - }: any) { - const {dashboardStore} = useStore(); - const period = useObserver(() => dashboardStore.drillDownPeriod); - const drillDownFilter = useObserver(() => dashboardStore.drillDownFilter); +function WidgetDateRange({ label = 'Time Range', comparison = false }: any) { + const { dashboardStore } = useStore(); + const period = comparison ? dashboardStore.comparisonPeriod : dashboardStore.drillDownPeriod; + const drillDownFilter = dashboardStore.drillDownFilter; - const onChangePeriod = (period: any) => { - dashboardStore.setDrillDownPeriod(period); - const periodTimestamps = period.toTimestamps(); - drillDownFilter.merge({ - startTimestamp: periodTimestamps.startTimestamp, - endTimestamp: periodTimestamps.endTimestamp, - }) + const onChangePeriod = (period: any) => { + if (comparison) dashboardStore.setComparisonPeriod(period); + else { + dashboardStore.setDrillDownPeriod(period); + const periodTimestamps = period.toTimestamps(); + drillDownFilter.merge({ + startTimestamp: periodTimestamps.startTimestamp, + endTimestamp: periodTimestamps.endTimestamp, + }); } + }; - return ( - - {label && {label}} - - - ); + return ( + + {label && {label}} + + + ); } -export default WidgetDateRange; +export default observer(WidgetDateRange); diff --git a/frontend/app/components/Dashboard/components/WidgetOptions.tsx b/frontend/app/components/Dashboard/components/WidgetOptions.tsx index 3a3298f8b..16fd82927 100644 --- a/frontend/app/components/Dashboard/components/WidgetOptions.tsx +++ b/frontend/app/components/Dashboard/components/WidgetOptions.tsx @@ -1,17 +1,14 @@ import React from 'react'; -import { FUNNEL, HEATMAP, TABLE, USER_PATH } from 'App/constants/card'; -import { Select, Space, Switch } from 'antd'; +import { FUNNEL, HEATMAP, TABLE, TIMESERIES, USER_PATH } from "App/constants/card"; +import { Select, Space, Switch, Dropdown, Button} from 'antd'; +import { DownOutlined } from '@ant-design/icons'; import { useStore } from 'App/mstore'; import ClickMapRagePicker from 'Components/Dashboard/components/ClickMapRagePicker/ClickMapRagePicker'; import { FilterKey } from 'Types/filter/filterType'; import { observer } from 'mobx-react-lite'; +import { ChartLine, ChartArea, ChartColumn, ChartBar, ChartPie, Table } from 'lucide-react' -interface Props { - -} - - -function WidgetOptions(props: Props) { +function WidgetOptions() { const { metricStore, dashboardStore } = useStore(); const metric: any = metricStore.instance; @@ -19,6 +16,22 @@ function WidgetOptions(props: Props) { metric.update({ metricFormat: value }); }; + const chartTypes = { + lineChart: 'Chart', + barChart: 'Column', + areaChart: 'Area', + pieChart: 'Pie', + progressChart: 'Bar', + table: 'Table', + } + const chartIcons = { + lineChart: , + barChart: , + areaChart: , + pieChart: , + progressChart: , + table:
, + } return (
{metric.metricType === USER_PATH && ( @@ -31,28 +44,50 @@ function WidgetOptions(props: Props) { > - - Hide Minor Paths - + Hide Minor Paths )} - {(metric.metricType === FUNNEL || metric.metricType === TABLE) && metric.metricOf != FilterKey.USERID && metric.metricOf != FilterKey.ERRORS && ( - + )} + + {metric.metricType === HEATMAP ? : null}
); } diff --git a/frontend/app/components/Dashboard/components/WidgetPreview/WidgetPreview.tsx b/frontend/app/components/Dashboard/components/WidgetPreview/WidgetPreview.tsx index 06fcddf88..f001c043e 100644 --- a/frontend/app/components/Dashboard/components/WidgetPreview/WidgetPreview.tsx +++ b/frontend/app/components/Dashboard/components/WidgetPreview/WidgetPreview.tsx @@ -1,11 +1,9 @@ -import { Button, Space, Switch } from 'antd'; import cn from 'classnames'; import { observer } from 'mobx-react-lite'; import React from 'react'; - -import { HEATMAP, USER_PATH } from 'App/constants/card'; +import WidgetDateRange from "Components/Dashboard/components/WidgetDateRange/WidgetDateRange"; import { useStore } from 'App/mstore'; -import ClickMapRagePicker from 'Components/Dashboard/components/ClickMapRagePicker'; +import { TIMESERIES } from "../../../../constants/card"; import WidgetWrapper from '../WidgetWrapper'; import WidgetOptions from 'Components/Dashboard/components/WidgetOptions'; @@ -21,14 +19,16 @@ function WidgetPreview(props: Props) { const { metricStore, dashboardStore } = useStore(); const metric: any = metricStore.instance; + // compare logic return ( <>
-
-

{props.name}

-
+
+ + {metric.metricType === TIMESERIES ? : null} +
{/*{metric.metricType === USER_PATH && (*/} {/* */} {/*)}*/} - {/*{isTimeSeries && (*/} {/* <>*/} {/* Visualization*/} @@ -104,8 +103,6 @@ function WidgetPreview(props: Props) { {/**/} {/*)}*/} - - {/* add to dashboard */} {/*{metric.exists() && (*/} {/* */} diff --git a/frontend/app/components/Dashboard/components/WidgetView/WidgetViewHeader.tsx b/frontend/app/components/Dashboard/components/WidgetView/WidgetViewHeader.tsx index ea0cbf829..3712e01ab 100644 --- a/frontend/app/components/Dashboard/components/WidgetView/WidgetViewHeader.tsx +++ b/frontend/app/components/Dashboard/components/WidgetView/WidgetViewHeader.tsx @@ -4,7 +4,6 @@ import WidgetName from "Components/Dashboard/components/WidgetName"; import {useStore} from "App/mstore"; import {useObserver} from "mobx-react-lite"; import AddToDashboardButton from "Components/Dashboard/components/AddToDashboardButton"; -import WidgetDateRange from "Components/Dashboard/components/WidgetDateRange/WidgetDateRange"; import {Button, Space} from "antd"; import CardViewMenu from "Components/Dashboard/components/WidgetView/CardViewMenu"; @@ -30,7 +29,6 @@ function WidgetViewHeader({onClick, onSave, undoChanges}: Props) { /> - - ) : ( -
- {isCustomRange ? customRange : selectedValue?.label} - + {props.comparison ? ( +
+ +
+ + {isCustomRange + ? customRange + : `Compare to ${selectedValue ? selectedValue?.label : ''}`} + + +
+
+
props.onChange(null)} + > +
- )} - +
+ ) : ( + + {useButtonStyle ? ( +
+ + + {isCustomRange ? customRange : selectedValue?.label} + + +
+ ) : ( +
+ + {isCustomRange ? customRange : selectedValue?.label} + + +
+ )} +
+ )} {isCustom && ( { if ( - e.target.className.includes('react-calendar') - || e.target.parentElement.parentElement.classList.contains( + e.target.className.includes('react-calendar') || + e.target.parentElement.parentElement.classList.contains( 'rc-time-picker-panel-select' ) || e.target.parentElement.parentElement.classList[0]?.includes( @@ -117,7 +144,7 @@ function SelectDateRange(props: Props) { onApply={onApplyDateRange} onCancel={() => setIsCustom(false)} selectedDateRange={period.range} - className='h-fit' + className="h-fit" />
diff --git a/frontend/app/components/ui/ItemMenu/ItemMenu.tsx b/frontend/app/components/ui/ItemMenu/ItemMenu.tsx index 62e3ccc2f..1232de1fe 100644 --- a/frontend/app/components/ui/ItemMenu/ItemMenu.tsx +++ b/frontend/app/components/ui/ItemMenu/ItemMenu.tsx @@ -1,9 +1,9 @@ -import React from "react"; -import { Icon, Popover, Tooltip } from "UI"; -import { Dropdown, Menu, Button} from "antd"; -import {EllipsisVertical} from 'lucide-react'; -import styles from "./itemMenu.module.css"; -import cn from "classnames"; +import React from 'react'; +import { Icon, Popover, Tooltip } from 'UI'; +import { Dropdown, Menu, Button } from 'antd'; +import { EllipsisVertical } from 'lucide-react'; +import styles from './itemMenu.module.css'; +import cn from 'classnames'; interface Item { icon?: string; @@ -21,6 +21,7 @@ interface Props { label?: React.ReactNode; sm?: boolean; onToggle?: (args: any) => void; + customTrigger?: React.ReactElement; } export default class ItemMenu extends React.PureComponent { @@ -30,13 +31,13 @@ export default class ItemMenu extends React.PureComponent { displayed: false, }; - handleEsc = (e: KeyboardEvent) => e.key === "Escape" && this.closeMenu(); + handleEsc = (e: KeyboardEvent) => e.key === 'Escape' && this.closeMenu(); componentDidMount() { - document.addEventListener("keydown", this.handleEsc, false); + document.addEventListener('keydown', this.handleEsc, false); } componentWillUnmount() { - document.removeEventListener("keydown", this.handleEsc, false); + document.removeEventListener('keydown', this.handleEsc, false); } onClick = (callback: Function) => (e: React.MouseEvent) => { @@ -58,13 +59,17 @@ export default class ItemMenu extends React.PureComponent { render() { const { items, label, bold, sm } = this.props; const { displayed } = this.state; - const parentStyles = label ? "hover:bg-gray-light" : ""; + const parentStyles = label ? 'hover:bg-gray-light' : ''; return ( ( -
+
{items .filter(({ hidden }) => !hidden) .map( @@ -73,15 +78,22 @@ export default class ItemMenu extends React.PureComponent { text, icon, disabled = false, - tooltipTitle = "", + tooltipTitle = '', }) => ( - +
{}} - className={`${disabled ? "cursor-not-allowed" : ""}`} + className={`${disabled ? 'cursor-not-allowed' : ''}`} role="menuitem" > -
+
{icon && (
@@ -96,32 +108,38 @@ export default class ItemMenu extends React.PureComponent {
)} > - + {this.props.customTrigger ? ( + this.props.customTrigger + ) : ( + <> - + + )} ); } -} \ No newline at end of file +} diff --git a/frontend/app/dateRange.js b/frontend/app/dateRange.js index 518c75982..45e5f022f 100644 --- a/frontend/app/dateRange.js +++ b/frontend/app/dateRange.js @@ -17,6 +17,19 @@ const DATE_RANGE_LABELS = { [CUSTOM_RANGE]: "Custom Range", }; +const COMPARISON_DATE_RANGE_LABELS = { + // LAST_30_MINUTES: '30 Minutes', + // TODAY: 'Today', + LAST_24_HOURS: "Previous Day", + // YESTERDAY: 'Yesterday', + LAST_7_DAYS: "Previous Week", + LAST_30_DAYS: "Previous Month", + //THIS_MONTH: 'This Month', + //LAST_MONTH: 'Previous Month', + //THIS_YEAR: 'This Year', + [CUSTOM_RANGE]: "Custom Range", +} + const DATE_RANGE_VALUES = {}; Object.keys(DATE_RANGE_LABELS).forEach((key) => { DATE_RANGE_VALUES[key] = key; @@ -31,6 +44,12 @@ export const DATE_RANGE_OPTIONS = Object.keys(DATE_RANGE_LABELS).map((key) => { value: key, }; }); +export const DATE_RANGE_COMPARISON_OPTIONS = Object.keys(COMPARISON_DATE_RANGE_LABELS).map((key) => { + return { + label: COMPARISON_DATE_RANGE_LABELS[key], + value: key, + }; +}); export function getDateRangeLabel(value) { return DATE_RANGE_LABELS[value]; diff --git a/frontend/app/mstore/dashboardStore.ts b/frontend/app/mstore/dashboardStore.ts index 36a34cbdb..285995333 100644 --- a/frontend/app/mstore/dashboardStore.ts +++ b/frontend/app/mstore/dashboardStore.ts @@ -23,6 +23,7 @@ export default class DashboardStore { period: Record = Period({ rangeName: LAST_24_HOURS }); drillDownFilter: Filter = new Filter(); drillDownPeriod: Record = Period({ rangeName: LAST_7_DAYS }); + comparisonPeriod: Record | null = null startTimestamp: number = 0; endTimestamp: number = 0; pendingRequests: number = 0; @@ -408,6 +409,17 @@ export default class DashboardStore { }); } + setComparisonPeriod(period: any) { + if (!period) { + return this.comparisonPeriod = null + } + this.comparisonPeriod = Period({ + start: period.start, + end: period.end, + rangeName: period.rangeName, + }); + } + toggleAlertModal(val: boolean) { this.showAlertModal = val; }