From 1f9019a103893fcbc8dde6923e856f0a39ff0b51 Mon Sep 17 00:00:00 2001 From: Shekar Siri Date: Mon, 27 May 2024 15:03:30 +0200 Subject: [PATCH] change(ui): path analysis clear filter (#2207) --- .../components/CardIssues/CardIssues.tsx | 20 +- .../components/WidgetChart/WidgetChart.tsx | 430 ++++----- .../Insights/SankeyChart/CustomLink.tsx | 87 +- .../Insights/SankeyChart/CustomNode.tsx | 58 +- .../Insights/SankeyChart/NodeButton.tsx | 106 +-- .../Insights/SankeyChart/SankeyChart.tsx | 216 ++--- frontend/app/mstore/types/widget.ts | 828 ++++++++++-------- frontend/package.json | 2 +- frontend/yarn.lock | 10 +- 9 files changed, 930 insertions(+), 827 deletions(-) diff --git a/frontend/app/components/Dashboard/components/CardIssues/CardIssues.tsx b/frontend/app/components/Dashboard/components/CardIssues/CardIssues.tsx index d5f68ef68..9495d37d0 100644 --- a/frontend/app/components/Dashboard/components/CardIssues/CardIssues.tsx +++ b/frontend/app/components/Dashboard/components/CardIssues/CardIssues.tsx @@ -21,6 +21,11 @@ function CardIssues() { const isMounted = useIsMounted(); const pageSize = 5; const { showModal } = useModal(); + const filter = useObserver(() => dashboardStore.drillDownFilter); + const hasFilters = filter.filters.length > 0 || (filter.startTimestamp !== dashboardStore.drillDownPeriod.start || filter.endTimestamp !== dashboardStore.drillDownPeriod.end); + const drillDownPeriod = useObserver(() => dashboardStore.drillDownPeriod); + const depsString = JSON.stringify(widget.series); + function getFilters(filter: any) { const mapSeries = (item: any) => { @@ -61,16 +66,13 @@ function CardIssues() { } }; + const debounceRequest: any = React.useCallback(debounce(fetchIssues, 1000), []); + const handleClick = (issue?: any) => { // const filters = getFilters(widget.filter); showModal(, { right: true, width: 900 }); }; - const filter = useObserver(() => dashboardStore.drillDownFilter); - const drillDownPeriod = useObserver(() => dashboardStore.drillDownPeriod); - const debounceRequest: any = React.useCallback(debounce(fetchIssues, 1000), []); - const depsString = JSON.stringify(widget.series); - useEffect(() => { const newPayload = { ...widget, @@ -81,6 +83,11 @@ function CardIssues() { debounceRequest(newPayload); }, [drillDownPeriod, filter.filters, depsString, metricStore.sessionsPage, filter.page]); + const clearFilters = () => { + metricStore.updateKey('page', 1); + dashboardStore.resetDrillDownFilter(); + }; + return useObserver(() => (
@@ -94,7 +101,8 @@ function CardIssues() {
)}
-
+
+ {hasFilters && }
diff --git a/frontend/app/components/Dashboard/components/WidgetChart/WidgetChart.tsx b/frontend/app/components/Dashboard/components/WidgetChart/WidgetChart.tsx index 59ff4780a..2416152dc 100644 --- a/frontend/app/components/Dashboard/components/WidgetChart/WidgetChart.tsx +++ b/frontend/app/components/Dashboard/components/WidgetChart/WidgetChart.tsx @@ -1,33 +1,32 @@ -import React, { useState, useRef, useEffect } from 'react'; +import React, {useState, useRef, useEffect} from 'react'; import CustomMetriLineChart from 'App/components/Dashboard/Widgets/CustomMetricsWidgets/CustomMetriLineChart'; import CustomMetricPercentage from 'App/components/Dashboard/Widgets/CustomMetricsWidgets/CustomMetricPercentage'; import CustomMetricTable from 'App/components/Dashboard/Widgets/CustomMetricsWidgets/CustomMetricTable'; import CustomMetricPieChart from 'App/components/Dashboard/Widgets/CustomMetricsWidgets/CustomMetricPieChart'; -import { Styles } from 'App/components/Dashboard/Widgets/common'; -import { observer } from 'mobx-react-lite'; -import { Loader } from 'UI'; -import { useStore } from 'App/mstore'; +import {Styles} from 'App/components/Dashboard/Widgets/common'; +import {observer} from 'mobx-react-lite'; +import {Loader} from 'UI'; +import {useStore} from 'App/mstore'; 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, - CLICKMAP, - FUNNEL, - ERRORS, - PERFORMANCE, - RESOURCE_MONITORING, - WEB_VITALS, - INSIGHTS, - USER_PATH, - RETENTION + TIMESERIES, + TABLE, + CLICKMAP, + FUNNEL, + ERRORS, + PERFORMANCE, + RESOURCE_MONITORING, + WEB_VITALS, + INSIGHTS, + USER_PATH, + RETENTION } from 'App/constants/card'; import FunnelWidget from 'App/components/Funnels/FunnelWidget'; -import SessionWidget from '../Sessions/SessionWidget'; import CustomMetricTableSessions from 'App/components/Dashboard/Widgets/CustomMetricsWidgets/CustomMetricTableSessions'; import CustomMetricTableErrors from 'App/components/Dashboard/Widgets/CustomMetricsWidgets/CustomMetricTableErrors'; import ClickMapCard from 'App/components/Dashboard/Widgets/CustomMetricsWidgets/ClickMapCard'; @@ -36,220 +35,221 @@ import SankeyChart from 'Shared/Insights/SankeyChart'; import CohortCard from '../../Widgets/CustomMetricsWidgets/CohortCard'; interface Props { - metric: any; - isWidget?: boolean; - isTemplate?: boolean; - isPreview?: boolean; + metric: any; + isWidget?: boolean; + isTemplate?: boolean; + isPreview?: boolean; } function WidgetChart(props: Props) { - const { isWidget = 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 isOverviewWidget = metric.metricType === WEB_VITALS; - const params = { density: isOverviewWidget ? 7 : 70 }; - const metricParams = { ...params }; - const prevMetricRef = useRef(); - const isMounted = useIsMounted(); - const [data, setData] = useState(metric.data); + const {isWidget = 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 isOverviewWidget = metric.metricType === WEB_VITALS; + const params = {density: isOverviewWidget ? 7 : 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 isTableWidget = metric.metricType === 'table' && metric.viewType === 'table'; + const isPieChart = metric.metricType === 'table' && metric.viewType === 'pieChart'; - useEffect(() => { - return () => { - dashboardStore.resetDrillDownFilter(); + 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 + }); + } + } }; - }, []); - 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 - }); - } - } - }; - - const depsString = JSON.stringify({ - ..._metric.series, ..._metric.excludes, ..._metric.startPoint, - hideExcess: _metric.hideExcess - }); - const fetchMetricChartData = (metric: any, payload: any, isWidget: any, period: any) => { - if (!isMounted()) return; - setLoading(true); - dashboardStore.fetchMetricChartData(metric, payload, isWidget, period).then((res: any) => { - if (isMounted()) setData(res); - }).finally(() => { - setLoading(false); + const depsString = JSON.stringify({ + ..._metric.series, ..._metric.excludes, ..._metric.startPoint, + hideExcess: _metric.hideExcess }); - }; + const fetchMetricChartData = (metric: any, payload: any, isWidget: any, period: any) => { + if (!isMounted()) return; + setLoading(true); + dashboardStore.fetchMetricChartData(metric, payload, isWidget, period).then((res: any) => { + if (isMounted()) setData(res); + }).finally(() => { + setLoading(false); + }); + }; - const debounceRequest: any = React.useCallback(debounce(fetchMetricChartData, 500), []); - const loadPage = () => { - if (prevMetricRef.current && prevMetricRef.current.name !== metric.name) { - prevMetricRef.current = metric; - return; - } - prevMetricRef.current = metric; - const timestmaps = drillDownPeriod.toTimestamps(); - const payload = isWidget ? { ...params } : { ...metricParams, ...timestmaps, ...metric.toJson() }; - debounceRequest(metric, payload, isWidget, !isWidget ? drillDownPeriod : period); - }; - useEffect(() => { - _metric.updateKey('page', 1); - loadPage(); - }, [drillDownPeriod, period, depsString, metric.metricType, metric.metricOf, metric.viewType, metric.metricValue, metric.startType]); - useEffect(loadPage, [_metric.page]); + const debounceRequest: any = React.useCallback(debounce(fetchMetricChartData, 500), []); + const loadPage = () => { + if (prevMetricRef.current && prevMetricRef.current.name !== metric.name) { + prevMetricRef.current = metric; + return; + } + prevMetricRef.current = metric; + const timestmaps = drillDownPeriod.toTimestamps(); + const payload = isWidget ? {...params} : {...metricParams, ...timestmaps, ...metric.toJson()}; + debounceRequest(metric, payload, isWidget, !isWidget ? drillDownPeriod : period); + }; + useEffect(() => { + _metric.updateKey('page', 1); + loadPage(); + }, [drillDownPeriod, period, depsString, metric.metricType, metric.metricOf, metric.viewType, metric.metricValue, metric.startType]); + useEffect(loadPage, [_metric.page]); - const renderChart = () => { - const { metricType, viewType, metricOf } = metric; - const metricWithData = { ...metric, data }; + const renderChart = () => { + const {metricType, viewType, metricOf} = metric; + const metricWithData = {...metric, data}; - if (metricType === FUNNEL) { - return ; - } + if (metricType === FUNNEL) { + return ; + } - if (metricType === 'predefined' || metricType === ERRORS || metricType === PERFORMANCE || metricType === RESOURCE_MONITORING || metricType === WEB_VITALS) { - const defaultMetric = metric.data.chart && metric.data.chart.length === 0 ? metricWithData : metric; - if (isOverviewWidget) { - return ; - } - return ; - } + if (metricType === 'predefined' || metricType === ERRORS || metricType === PERFORMANCE || metricType === RESOURCE_MONITORING || metricType === WEB_VITALS) { + const defaultMetric = metric.data.chart && metric.data.chart.length === 0 ? metricWithData : metric; + if (isOverviewWidget) { + return ; + } + return ; + } - if (metricType === TIMESERIES) { - if (viewType === 'lineChart') { - return ( - - ); - } else if (viewType === 'progress') { - 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 === CLICKMAP) { - if (!props.isPreview) { - return ( -
- clickmap thumbnail -
- ); - } - 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 === CLICKMAP) { + if (!props.isPreview) { + return ( +
+ clickmap thumbnail +
+ ); + } + return ( + + ); + } - if (metricType === INSIGHTS) { - return ; - } + if (metricType === INSIGHTS) { + return ; + } - if (metricType === USER_PATH && data && data.links) { - return { - dashboardStore.drillDownFilter.merge({ filters, page: 1 }); - }} />; - } + 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 ( - - ); - } - } + if (metricType === RETENTION) { + if (viewType === 'trend') { + return ( + + ); + } else if (viewType === 'cohort') { + return ( + + ); + } + } - return
Unknown metric type
; - }; - return ( - -
{renderChart()}
-
- ); + return
Unknown metric type
; + }; + return ( + +
{renderChart()}
+
+ ); } export default observer(WidgetChart); diff --git a/frontend/app/components/shared/Insights/SankeyChart/CustomLink.tsx b/frontend/app/components/shared/Insights/SankeyChart/CustomLink.tsx index e8c185558..7749fa1f6 100644 --- a/frontend/app/components/shared/Insights/SankeyChart/CustomLink.tsx +++ b/frontend/app/components/shared/Insights/SankeyChart/CustomLink.tsx @@ -1,7 +1,26 @@ import React from 'react'; -import { Layer, Rectangle } from 'recharts'; +import { Layer } from 'recharts'; -function CustomLink(props: any) { +interface CustomLinkProps { + hoveredLinks: string[]; + activeLinks: string[]; + payload: any; + sourceX: number; + targetX: number; + sourceY: number; + targetY: number; + sourceControlX: number; + targetControlX: number; + linkWidth: number; + index: number; + activeLink: any; + onClick?: (payload: any) => void; + onMouseEnter?: () => void; + onMouseLeave?: () => void; + strokeOpacity?: number; +} + +const CustomLink: React.FC = (props) => { const [fill, setFill] = React.useState('url(#linkGradient)'); const { hoveredLinks, @@ -15,9 +34,7 @@ function CustomLink(props: any) { targetControlX, linkWidth, index, - activeLink - } = - props; + } = props; const isActive = activeLinks.length > 0 && activeLinks.includes(payload.id); const isHover = hoveredLinks.length > 0 && hoveredLinks.includes(payload.id); @@ -28,36 +45,36 @@ function CustomLink(props: any) { }; return ( - - { - setFill('rgba(57, 78, 255, 0.5)'); - }} - onMouseLeave={() => { - setFill('url(#linkGradient)'); - }} - /> - + + { + setFill('rgba(57, 78, 255, 0.5)'); + }} + onMouseLeave={() => { + setFill('url(#linkGradient)'); + }} + /> + ); -} +}; export default CustomLink; diff --git a/frontend/app/components/shared/Insights/SankeyChart/CustomNode.tsx b/frontend/app/components/shared/Insights/SankeyChart/CustomNode.tsx index dc54d435e..330753d3f 100644 --- a/frontend/app/components/shared/Insights/SankeyChart/CustomNode.tsx +++ b/frontend/app/components/shared/Insights/SankeyChart/CustomNode.tsx @@ -1,35 +1,35 @@ import React from 'react'; import { Layer, Rectangle } from 'recharts'; import NodeButton from './NodeButton'; -import NodeDropdown from './NodeDropdown'; -function CustomNode(props: any) { - const { x, y, width, height, index, payload, containerWidth, activeNodes } = props; - const isOut = x + width + 6 > containerWidth; - - return ( - - - - {/**/} - {/* */} - {/**/} - - - - - - ); +interface CustomNodeProps { + x: number; + y: number; + width: number; + height: number; + index: number; + payload: any; + containerWidth: number; + activeNodes: any[]; } -export default CustomNode; \ No newline at end of file +const CustomNode: React.FC = (props) => { + const { x, y, width, height, index, payload, containerWidth } = props; + const isOut = x + width + 6 > containerWidth; + + return ( + + + + + + + ); +}; + +export default CustomNode; diff --git a/frontend/app/components/shared/Insights/SankeyChart/NodeButton.tsx b/frontend/app/components/shared/Insights/SankeyChart/NodeButton.tsx index f20ee3278..8468ed553 100644 --- a/frontend/app/components/shared/Insights/SankeyChart/NodeButton.tsx +++ b/frontend/app/components/shared/Insights/SankeyChart/NodeButton.tsx @@ -1,63 +1,67 @@ import React from 'react'; -import { Icon } from 'UI'; -import { Popover } from 'antd'; +import {Icon} from 'UI'; +import {Popover} from 'antd'; interface Props { - payload: any; + payload: any; } function NodeButton(props: Props) { - const { payload } = props; + const {payload} = props; - return ( -
- -
-
- -
-
{payload.name}
-
-
-
- -
-
Continuing {Math.round(payload.value)}%
-
- {payload.avgTimeFromPrevious && ( -
-
- -
+ return ( +
+ +
+
+ +
+
{payload.name}
+
+
+
+ +
+
Continuing {Math.round(payload.value)}%
+
+ {payload.avgTimeFromPrevious && ( +
+
+ +
-
- Average time from previous step {payload.avgTimeFromPrevious} -
-
- )} -
}> -
-
{payload.name}
- {Math.round(payload.value) + '%'} +
+ Average time from previous step {payload.avgTimeFromPrevious} +
+
+ )} +
}> +
+
{payload.name}
+ {Math.round(payload.value) + '%'} +
+
- - - ); + ); } export default NodeButton; diff --git a/frontend/app/components/shared/Insights/SankeyChart/SankeyChart.tsx b/frontend/app/components/shared/Insights/SankeyChart/SankeyChart.tsx index 2e65e75cd..02670e875 100644 --- a/frontend/app/components/shared/Insights/SankeyChart/SankeyChart.tsx +++ b/frontend/app/components/shared/Insights/SankeyChart/SankeyChart.tsx @@ -1,140 +1,140 @@ -import React, { useState } from 'react'; -import { Sankey, ResponsiveContainer } from 'recharts'; +import React, {useState} from 'react'; +import {Sankey, ResponsiveContainer} from 'recharts'; import CustomLink from './CustomLink'; import CustomNode from './CustomNode'; -import { NoContent } from 'UI'; +import {NoContent} from 'UI'; interface Node { - idd: number; - name: string; - eventType: string; - avgTimeFromPrevious: number | null; + idd: string; + name: string; + eventType: string; + avgTimeFromPrevious: number | null; } interface Link { - id: string; - eventType: string; - value: number; - source: number; - target: number; + id: string; + eventType: string; + value: number; + source: string; + target: string; } interface Data { - nodes: Node[]; - links: Link[]; + nodes: Node[]; + links: Link[]; } interface Props { - data: Data; - nodeWidth?: number; - height?: number; - onChartClick?: (filters: any[]) => void; + data: Data; + nodeWidth?: number; + height?: number; + onChartClick?: (filters: any[]) => void; } -const SankeyChart: React.FC = ({ - data, - height = 240, - onChartClick - }: Props) => { - const [highlightedLinks, setHighlightedLinks] = useState([]); - const [hoveredLinks, setHoveredLinks] = useState([]); +const SankeyChart: React.FC = ({data, height = 240, onChartClick}: Props) => { + const [highlightedLinks, setHighlightedLinks] = useState([]); + const [hoveredLinks, setHoveredLinks] = useState([]); - function findPreviousLinks(targetNodeIndex: number): Link[] { - const previousLinks: Link[] = []; - const visitedNodes: Set = new Set(); + function findPreviousLinks(targetNodeIndex: number): Link[] { + const previousLinks: Link[] = []; + const visitedNodes: Set = new Set(); - const findPreviousLinksRecursive = (nodeIndex: number) => { - visitedNodes.add(nodeIndex); + const findPreviousLinksRecursive = (nodeIndex: number) => { + visitedNodes.add(nodeIndex); - for (const link of data.links) { - if (link.target === nodeIndex && !visitedNodes.has(link.source)) { - previousLinks.push(link); - findPreviousLinksRecursive(link.source); - } - } + for (const link of data.links) { + if (link.target === nodeIndex && !visitedNodes.has(link.source)) { + previousLinks.push(link); + findPreviousLinksRecursive(link.source); + } + } + }; + + findPreviousLinksRecursive(targetNodeIndex); + + return previousLinks; + } + + const handleLinkMouseEnter = (linkData: any) => { + const {payload} = linkData; + const link: any = data.links.find(link => link.id === payload.id); + const previousLinks: any = findPreviousLinks(link.source).reverse(); + previousLinks.push({id: payload.id}); + setHoveredLinks(previousLinks.map((link: any) => link.id)); }; - findPreviousLinksRecursive(targetNodeIndex); + const clickHandler = () => { + setHighlightedLinks(hoveredLinks); - return previousLinks; - } + const firstLink = data.links.find(link => link.id === hoveredLinks[0]) || null; + const lastLink = data.links.find(link => link.id === hoveredLinks[hoveredLinks.length - 1]) || null; - const handleLinkMouseEnter = (linkData: any) => { - const { payload } = linkData; - const link: any = data.links.find(link => link.id === payload.id); - const previousLinks: any = findPreviousLinks(link.source).reverse(); - previousLinks.push({ id: payload.id }); - setHoveredLinks(previousLinks.map((link: any) => link.id)); - }; + const firstNode = data.nodes[firstLink?.source]; + const lastNode = data.nodes[lastLink?.target]; - const clickHandler = () => { - setHighlightedLinks(hoveredLinks); + const filters = []; - const firstLink = data.links.find(link => link.id === hoveredLinks[0]) || null; - const lastLink = data.links.find(link => link.id === hoveredLinks[hoveredLinks.length - 1]) || null; + if (firstNode) { + filters.push({ + operator: 'is', + type: firstNode.eventType, + value: [firstNode.name], + isEvent: true + }); + } - const firstNode = data.nodes[firstLink?.source]; - const lastNode = data.nodes[lastLink.target]; + if (lastNode) { + filters.push({ + operator: 'is', + type: lastNode.eventType, + value: [lastNode.name], + isEvent: true + }); + } - const filters = []; + onChartClick?.(filters); + }; - if (firstNode) { - filters.push({ - operator: 'is', - type: firstNode.eventType, - value: [firstNode.name], - isEvent: true - }); - } + // const processedData = processData(data); - if (lastNode) { - filters.push({ - operator: 'is', - type: lastNode.eventType, - value: [lastNode.name], - isEvent: true - }); - } - - onChartClick?.(filters); - }; - - - return ( - - - } - sort={true} - onClick={clickHandler} - link={({ source, target, id, ...linkProps }, index) => ( - handleLinkMouseEnter(linkProps)} - onMouseLeave={() => setHoveredLinks([])} - /> - )} - margin={{ right: 200, bottom: 50 }} + return ( + - - - - - - - - - - ); + + } + nodePadding={20} + sort={true} + nodeWidth={4} + iterations={128} + // linkCurvature={0.9} + onClick={clickHandler} + link={({source, target, id, ...linkProps}, index) => ( + handleLinkMouseEnter(linkProps)} + onMouseLeave={() => setHoveredLinks([])} + /> + )} + margin={{right: 130, bottom: 50}} + > + + + + + + + + + + ); }; export default SankeyChart; diff --git a/frontend/app/mstore/types/widget.ts b/frontend/app/mstore/types/widget.ts index 511794cfa..61bcc863f 100644 --- a/frontend/app/mstore/types/widget.ts +++ b/frontend/app/mstore/types/widget.ts @@ -1,410 +1,484 @@ -import { makeAutoObservable, runInAction } from 'mobx'; +import {makeAutoObservable, runInAction} from 'mobx'; import FilterSeries from './filterSeries'; -import { DateTime } from 'luxon'; +import {DateTime} from 'luxon'; import Session from 'App/mstore/types/session'; import Funnelissue from 'App/mstore/types/funnelIssue'; -import { issueOptions, issueCategories, issueCategoriesMap, pathAnalysisEvents } from 'App/constants/filterOptions'; -import { FilterKey } from 'Types/filter/filterType'; -import Period, { LAST_24_HOURS } from 'Types/app/period'; +import {issueOptions, issueCategories, issueCategoriesMap, pathAnalysisEvents} from 'App/constants/filterOptions'; +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, INSIGHTS, TABLE, USER_PATH, WEB_VITALS } from 'App/constants/card'; +import {metricService} from 'App/services'; +import {FUNNEL, INSIGHTS, TABLE, USER_PATH, WEB_VITALS} from 'App/constants/card'; import Error from '../types/error'; -import { getChartFormatter } from 'Types/dashboard/helper'; +import {getChartFormatter} from 'Types/dashboard/helper'; import FilterItem from './filterItem'; -import { filtersMap } from 'Types/filter/newFilter'; +import {filtersMap} from 'Types/filter/newFilter'; import Issue from '../types/issue'; -import { durationFormatted } from 'App/date'; +import {durationFormatted} from 'App/date'; export class InsightIssue { - icon: string; - iconColor: string; - change: number; - isNew = false; - category: string; - label: string; - value: number; - oldValue: number; - isIncreased?: boolean; + icon: string; + iconColor: string; + change: number; + isNew = false; + category: string; + label: string; + value: number; + oldValue: number; + isIncreased?: boolean; - constructor( - category: string, - public name: string, - public ratio: number, - oldValue = 0, - value = 0, - change = 0, - isNew = false - ) { - this.category = category; - this.value = Math.round(value); - this.oldValue = Math.round(oldValue); - // @ts-ignore - this.label = issueCategoriesMap[category]; - this.icon = `ic-${category}`; + constructor( + category: string, + public name: string, + public ratio: number, + oldValue = 0, + value = 0, + change = 0, + isNew = false + ) { + this.category = category; + this.value = Math.round(value); + this.oldValue = Math.round(oldValue); + // @ts-ignore + this.label = issueCategoriesMap[category]; + this.icon = `ic-${category}`; - this.change = parseInt(change.toFixed(2)); - this.isIncreased = this.change > 0; - this.iconColor = 'gray-dark'; - this.isNew = isNew; - } + this.change = parseInt(change.toFixed(2)); + this.isIncreased = this.change > 0; + this.iconColor = 'gray-dark'; + this.isNew = isNew; + } } function cleanFilter(filter: any) { - delete filter['operatorOptions']; - delete filter['placeholder']; - delete filter['category']; - delete filter['label']; - delete filter['icon']; - delete filter['key']; + delete filter['operatorOptions']; + delete filter['placeholder']; + delete filter['category']; + delete filter['label']; + delete filter['icon']; + delete filter['key']; } export default class Widget { - public static get ID_KEY(): string { - return 'metricId'; - } - - metricId: any = undefined; - widgetId: any = undefined; - category?: string = undefined; - name: string = 'Untitled Card'; - metricType: string = 'timeseries'; - metricOf: string = 'sessionCount'; - metricValue: any = ''; - viewType: string = 'lineChart'; - metricFormat: string = 'sessionCount'; - series: FilterSeries[] = []; - sessions: [] = []; - isPublic: boolean = true; - owner: string = ''; - lastModified: DateTime | null = new Date().getTime(); - dashboards: any[] = []; - dashboardIds: any[] = []; - config: any = {}; - page: number = 1; - limit: number = 5; - thumbnail?: string; - params: any = { density: 70 }; - startType: string = 'start'; - startPoint: FilterItem = new FilterItem(filtersMap[FilterKey.LOCATION]); - excludes: FilterItem[] = []; - hideExcess?: boolean = false; - - period: Record = Period({ rangeName: LAST_24_HOURS }); // temp value in detail view - hasChanged: boolean = false; - - position: number = 0; - data: any = { - sessions: [], - issues: [], - total: 0, - chart: [], - namesMap: {}, - avg: 0, - percentiles: [] - }; - isLoading: boolean = false; - isValid: boolean = false; - dashboardId: any = undefined; - predefinedKey: string = ''; - - constructor() { - makeAutoObservable(this); - - const filterSeries = new FilterSeries(); - this.series.push(filterSeries); - } - - updateKey(key: string, value: any) { - this[key] = value; - } - - removeSeries(index: number) { - this.series.splice(index, 1); - } - - setSeries(series: FilterSeries[]) { - this.series = series; - } - - addSeries() { - const series = new FilterSeries(); - series.name = 'Series ' + (this.series.length + 1); - this.series.push(series); - } - - createSeries(filters: Record) { - const series = new FilterSeries().fromData({ filter: { filters } , name: 'AI Query', seriesId: 1 }) - this.setSeries([series]) - } - - fromJson(json: any, period?: any) { - json.config = json.config || {}; - runInAction(() => { - this.metricId = json.metricId; - this.widgetId = json.widgetId; - this.metricValue = this.metricValueFromArray(json.metricValue, json.metricType); - this.metricOf = json.metricOf; - this.metricType = json.metricType; - this.metricFormat = json.metricFormat; - this.viewType = json.viewType; - this.name = json.name; - this.series = - json.series && json.series.length > 0 - ? json.series.map((series: any) => new FilterSeries().fromJson(series)) - : [new FilterSeries()]; - this.dashboards = json.dashboards || []; - this.owner = json.ownerEmail; - this.lastModified = - json.editedAt || json.createdAt - ? DateTime.fromMillis(json.editedAt || json.createdAt) - : null; - this.config = json.config; - this.position = json.config.position; - this.predefinedKey = json.predefinedKey; - this.category = json.category; - this.thumbnail = json.thumbnail; - this.isPublic = json.isPublic; - - if (this.metricType === FUNNEL) { - this.series[0].filter.eventsOrder = 'then'; - this.series[0].filter.eventsOrderSupport = ['then']; - } - - if (this.metricType === USER_PATH) { - this.hideExcess = json.hideExcess; - this.startType = json.startType; - if (json.startPoint) { - if (Array.isArray(json.startPoint) && json.startPoint.length > 0) { - this.startPoint = new FilterItem().fromJson(json.startPoint[0]); - } - - if (json.startPoint == typeof Object) { - this.startPoint = json.startPoint; - } - } - - // TODO change this to excludes after the api change - if (json.exclude) { - this.series[0].filter.excludes = json.exclude.map((i: any) => new FilterItem().fromJson(i)); - } - } - - if (period) { - this.period = period; - } - }); - return this; - } - - toWidget(): any { - return { - config: { - position: this.position, - col: this.config.col, - row: this.config.row - } - }; - } - - toJson() { - const data: any = { - metricId: this.metricId, - widgetId: this.widgetId, - metricOf: this.metricOf, - metricValue: this.metricValueToArray(this.metricValue), - metricType: this.metricType, - metricFormat: this.metricFormat, - viewType: this.viewType, - name: this.name, - series: this.series.map((series: any) => series.toJson()), - thumbnail: this.thumbnail, - page: this.page, - limit: this.limit, - config: { - ...this.config, - col: - this.metricType === FUNNEL || - this.metricOf === FilterKey.ERRORS || - this.metricOf === FilterKey.SESSIONS || - this.metricOf === FilterKey.SLOWEST_RESOURCES || - this.metricOf === FilterKey.MISSING_RESOURCES || - this.metricOf === FilterKey.PAGES_RESPONSE_TIME_DISTRIBUTION || - this.metricType === USER_PATH - ? 4 - : this.metricType === WEB_VITALS - ? 1 - : 2 - } - }; - - if (this.metricType === USER_PATH) { - data.hideExcess = this.hideExcess; - data.startType = this.startType; - data.startPoint = [this.startPoint.toJson()]; - data.excludes = this.series[0].filter.excludes.map((i: any) => i.toJson()); - data.metricOf = 'sessionCount'; + public static get ID_KEY(): string { + return 'metricId'; } - return data; - } - updateStartPoint(startPoint: any) { - runInAction(() => { - this.startPoint = new FilterItem(startPoint); - }); - } + metricId: any = undefined; + widgetId: any = undefined; + category?: string = undefined; + name: string = 'Untitled Card'; + metricType: string = 'timeseries'; + metricOf: string = 'sessionCount'; + metricValue: any = ''; + viewType: string = 'lineChart'; + metricFormat: string = 'sessionCount'; + series: FilterSeries[] = []; + sessions: [] = []; + isPublic: boolean = true; + owner: string = ''; + lastModified: DateTime | null = new Date().getTime(); + dashboards: any[] = []; + dashboardIds: any[] = []; + config: any = {}; + page: number = 1; + limit: number = 5; + thumbnail?: string; + params: any = {density: 70}; + startType: string = 'start'; + startPoint: FilterItem = new FilterItem(filtersMap[FilterKey.LOCATION]); + excludes: FilterItem[] = []; + hideExcess?: boolean = false; - validate() { - this.isValid = this.name.length > 0; - } + period: Record = Period({rangeName: LAST_24_HOURS}); // temp value in detail view + hasChanged: boolean = false; - update(data: any) { - runInAction(() => { - Object.assign(this, data); - }); - } + position: number = 0; + data: any = { + sessions: [], + issues: [], + total: 0, + chart: [], + namesMap: {}, + avg: 0, + percentiles: [] + }; + isLoading: boolean = false; + isValid: boolean = false; + dashboardId: any = undefined; + predefinedKey: string = ''; - exists() { - return this.metricId !== undefined; - } + constructor() { + makeAutoObservable(this); - - setData(data: any, period: any) { - const _data: any = { ...data }; - - if (this.metricType === USER_PATH) { - _data['nodes'] = data.nodes.map((s: any) => ({ - ...s, - avgTimeFromPrevious: s.avgTimeFromPrevious ? durationFormatted(s.avgTimeFromPrevious) : null, - idd: Math.random().toString(36).substring(7), - })); - _data['links'] = data.links.map((s: any) => ({ - ...s, - id: Math.random().toString(36).substring(7), - })); - - Object.assign(this.data, _data); - return _data; + const filterSeries = new FilterSeries(); + this.series.push(filterSeries); } - if (this.metricOf === FilterKey.ERRORS) { - _data['errors'] = data.errors.map((s: any) => new Error().fromJSON(s)); - } else if (this.metricType === INSIGHTS) { - _data['issues'] = data - .filter((i: any) => i.change > 0 || i.change < 0) - .map( - (i: any) => - 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); - } else { - if (data.hasOwnProperty('chart')) { - _data['value'] = data.value; - _data['unit'] = data.unit; - _data['chart'] = getChartFormatter(period)(data.chart); - _data['namesMap'] = data.chart - .map((i: any) => Object.keys(i)) - .flat() - .filter((i: any) => i !== 'time' && i !== 'timestamp') - .reduce((unique: any, item: any) => { - if (!unique.includes(item)) { - unique.push(item); + + updateKey(key: string, value: any) { + this[key] = value; + } + + removeSeries(index: number) { + this.series.splice(index, 1); + } + + setSeries(series: FilterSeries[]) { + this.series = series; + } + + addSeries() { + const series = new FilterSeries(); + series.name = 'Series ' + (this.series.length + 1); + this.series.push(series); + } + + createSeries(filters: Record) { + const series = new FilterSeries().fromData({filter: {filters}, name: 'AI Query', seriesId: 1}) + this.setSeries([series]) + } + + fromJson(json: any, period?: any) { + json.config = json.config || {}; + runInAction(() => { + this.metricId = json.metricId; + this.widgetId = json.widgetId; + this.metricValue = this.metricValueFromArray(json.metricValue, json.metricType); + this.metricOf = json.metricOf; + this.metricType = json.metricType; + this.metricFormat = json.metricFormat; + this.viewType = json.viewType; + this.name = json.name; + this.series = + json.series && json.series.length > 0 + ? json.series.map((series: any) => new FilterSeries().fromJson(series)) + : [new FilterSeries()]; + this.dashboards = json.dashboards || []; + this.owner = json.ownerEmail; + this.lastModified = + json.editedAt || json.createdAt + ? DateTime.fromMillis(json.editedAt || json.createdAt) + : null; + this.config = json.config; + this.position = json.config.position; + this.predefinedKey = json.predefinedKey; + this.category = json.category; + this.thumbnail = json.thumbnail; + this.isPublic = json.isPublic; + + if (this.metricType === FUNNEL) { + this.series[0].filter.eventsOrder = 'then'; + this.series[0].filter.eventsOrderSupport = ['then']; } - return unique; - }, []); - } else { - _data['chart'] = getChartFormatter(period)(Array.isArray(data) ? data : []); - _data['namesMap'] = Array.isArray(data) - ? data - .map((i) => Object.keys(i)) - .flat() - .filter((i) => i !== 'time' && i !== 'timestamp') - .reduce((unique: any, item: any) => { - if (!unique.includes(item)) { - unique.push(item); - } - return unique; - }, []) - : []; - } - } - Object.assign(this.data, _data); - return _data; - } + if (this.metricType === USER_PATH) { + this.hideExcess = json.hideExcess; + this.startType = json.startType; + if (json.startPoint) { + if (Array.isArray(json.startPoint) && json.startPoint.length > 0) { + this.startPoint = new FilterItem().fromJson(json.startPoint[0]); + } - fetchSessions(metricId: any, filter: any): Promise { - return new Promise((resolve) => { - metricService.fetchSessions(metricId, filter).then((response: any[]) => { - resolve( - response.map((cat: { sessions: any[] }) => { - return { - ...cat, - sessions: cat.sessions.map((s: any) => new Session().fromJson(s)) - }; - }) - ); - }); - }); - } + if (json.startPoint == typeof Object) { + this.startPoint = json.startPoint; + } + } + // TODO change this to excludes after the api change + if (json.exclude) { + this.series[0].filter.excludes = json.exclude.map((i: any) => new FilterItem().fromJson(i)); + } + } - async fetchIssues(card: any): Promise { - try { - const response = await metricService.fetchIssues(card); - - if (card.metricType === USER_PATH) { - return { - total: response.count, - issues: response.values.map((issue: any) => new Issue().fromJSON(issue)) - }; - } else { - const mapIssue = (issue: any) => new Funnelissue().fromJSON(issue); - const significantIssues = response.issues.significant?.map(mapIssue) || []; - const insignificantIssues = response.issues.insignificant?.map(mapIssue) || []; - - return { - issues: significantIssues.length > 0 ? significantIssues : insignificantIssues - }; - } - } catch (error) { - console.error('Error fetching issues:', error); - return { - issues: [] - }; - } - } - - - fetchIssue(funnelId: any, issueId: any, params: any): Promise { - return new Promise((resolve, reject) => { - metricService - .fetchIssue(funnelId, issueId, params) - .then((response: any) => { - resolve({ - issue: new Funnelissue().fromJSON(response.issue), - sessions: response.sessions.sessions.map((s: any) => new Session().fromJson(s)) - }); - }) - .catch((error: any) => { - reject(error); + if (period) { + this.period = period; + } }); - }); - } - - private metricValueFromArray(metricValue: any, metricType: string) { - if (!Array.isArray(metricValue)) return metricValue; - if (metricType === TABLE) { - return issueOptions.filter((i: any) => metricValue.includes(i.value)); - } else if (metricType === INSIGHTS) { - return issueCategories.filter((i: any) => metricValue.includes(i.value)); - } else if (metricType === USER_PATH) { - return pathAnalysisEvents.filter((i: any) => metricValue.includes(i.value)); + return this; } - } - private metricValueToArray(metricValue: any) { - if (!Array.isArray(metricValue)) return metricValue; - return metricValue.map((i: any) => i.value); - } + toWidget(): any { + return { + config: { + position: this.position, + col: this.config.col, + row: this.config.row + } + }; + } + + toJson() { + const data: any = { + metricId: this.metricId, + widgetId: this.widgetId, + metricOf: this.metricOf, + metricValue: this.metricValueToArray(this.metricValue), + metricType: this.metricType, + metricFormat: this.metricFormat, + viewType: this.viewType, + name: this.name, + series: this.series.map((series: any) => series.toJson()), + thumbnail: this.thumbnail, + page: this.page, + limit: this.limit, + config: { + ...this.config, + col: + this.metricType === FUNNEL || + this.metricOf === FilterKey.ERRORS || + this.metricOf === FilterKey.SESSIONS || + this.metricOf === FilterKey.SLOWEST_RESOURCES || + this.metricOf === FilterKey.MISSING_RESOURCES || + this.metricOf === FilterKey.PAGES_RESPONSE_TIME_DISTRIBUTION || + this.metricType === USER_PATH + ? 4 + : this.metricType === WEB_VITALS + ? 1 + : 2 + } + }; + + if (this.metricType === USER_PATH) { + data.hideExcess = this.hideExcess; + data.startType = this.startType; + data.startPoint = [this.startPoint.toJson()]; + data.excludes = this.series[0].filter.excludes.map((i: any) => i.toJson()); + data.metricOf = 'sessionCount'; + } + return data; + } + + updateStartPoint(startPoint: any) { + runInAction(() => { + this.startPoint = new FilterItem(startPoint); + }); + } + + validate() { + this.isValid = this.name.length > 0; + } + + update(data: any) { + runInAction(() => { + Object.assign(this, data); + }); + } + + exists() { + return this.metricId !== undefined; + } + + + setData(data: any, period: any) { + const _data: any = {...data}; + + if (this.metricType === USER_PATH) { + // Ensure nodes have unique IDs + const _data = processData(data); + + // const nodes = data.nodes.map(node => ({ + // ...node, + // avgTimeFromPrevious: node.avgTimeFromPrevious ? durationFormatted(node.avgTimeFromPrevious) : null, + // idd: node.idd || Math.random().toString(36).substring(7), + // })); + // + // // Ensure links have unique IDs and use node IDs + // const links = data.links.map(link => ({ + // ...link, + // value: Math.round(link.value), + // id: link.id || Math.random().toString(36).substring(7), + // })); + // + // const _data = { nodes, links }; + + // _data['nodes'] = data.nodes.map((s: any) => ({ + // ...s, + // avgTimeFromPrevious: s.avgTimeFromPrevious ? durationFormatted(s.avgTimeFromPrevious) : null, + // idd: Math.random().toString(36).substring(7), + // })); + // _data['links'] = data.links.map((s: any) => ({ + // ...s, + // value: Math.round(s.value), + // id: Math.random().toString(36).substring(7), + // })); + + Object.assign(this.data, _data); + console.log('data', _data); + return _data; + } + if (this.metricOf === FilterKey.ERRORS) { + _data['errors'] = data.errors.map((s: any) => new Error().fromJSON(s)); + } else if (this.metricType === INSIGHTS) { + _data['issues'] = data + .filter((i: any) => i.change > 0 || i.change < 0) + .map( + (i: any) => + 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); + } else { + if (data.hasOwnProperty('chart')) { + _data['value'] = data.value; + _data['unit'] = data.unit; + _data['chart'] = getChartFormatter(period)(data.chart); + _data['namesMap'] = data.chart + .map((i: any) => Object.keys(i)) + .flat() + .filter((i: any) => i !== 'time' && i !== 'timestamp') + .reduce((unique: any, item: any) => { + if (!unique.includes(item)) { + unique.push(item); + } + return unique; + }, []); + } else { + _data['chart'] = getChartFormatter(period)(Array.isArray(data) ? data : []); + _data['namesMap'] = Array.isArray(data) + ? data + .map((i) => Object.keys(i)) + .flat() + .filter((i) => i !== 'time' && i !== 'timestamp') + .reduce((unique: any, item: any) => { + if (!unique.includes(item)) { + unique.push(item); + } + return unique; + }, []) + : []; + } + } + + Object.assign(this.data, _data); + return _data; + } + + fetchSessions(metricId: any, filter: any): Promise { + return new Promise((resolve) => { + metricService.fetchSessions(metricId, filter).then((response: any[]) => { + resolve( + response.map((cat: { sessions: any[] }) => { + return { + ...cat, + sessions: cat.sessions.map((s: any) => new Session().fromJson(s)) + }; + }) + ); + }); + }); + } + + + async fetchIssues(card: any): Promise { + try { + const response = await metricService.fetchIssues(card); + + if (card.metricType === USER_PATH) { + return { + total: response.count, + issues: response.values.map((issue: any) => new Issue().fromJSON(issue)) + }; + } else { + const mapIssue = (issue: any) => new Funnelissue().fromJSON(issue); + const significantIssues = response.issues.significant?.map(mapIssue) || []; + const insignificantIssues = response.issues.insignificant?.map(mapIssue) || []; + + return { + issues: significantIssues.length > 0 ? significantIssues : insignificantIssues + }; + } + } catch (error) { + console.error('Error fetching issues:', error); + return { + issues: [] + }; + } + } + + + fetchIssue(funnelId: any, issueId: any, params: any): Promise { + return new Promise((resolve, reject) => { + metricService + .fetchIssue(funnelId, issueId, params) + .then((response: any) => { + resolve({ + issue: new Funnelissue().fromJSON(response.issue), + sessions: response.sessions.sessions.map((s: any) => new Session().fromJson(s)) + }); + }) + .catch((error: any) => { + reject(error); + }); + }); + } + + private metricValueFromArray(metricValue: any, metricType: string) { + if (!Array.isArray(metricValue)) return metricValue; + if (metricType === TABLE) { + return issueOptions.filter((i: any) => metricValue.includes(i.value)); + } else if (metricType === INSIGHTS) { + return issueCategories.filter((i: any) => metricValue.includes(i.value)); + } else if (metricType === USER_PATH) { + return pathAnalysisEvents.filter((i: any) => metricValue.includes(i.value)); + } + } + + private metricValueToArray(metricValue: any) { + if (!Array.isArray(metricValue)) return metricValue; + return metricValue.map((i: any) => i.value); + } } + +interface Node { + name: string; + eventType: string; + avgTimeFromPrevious: number | null; + idd?: string; // Making idd optional since it might not be present in raw data +} + +interface Link { + eventType: string; + value: number; + source: number; + target: number; + id?: string; // Making id optional since it might not be present in raw data +} + +interface Data { + nodes: Node[]; + links: Link[]; +} + +const generateUniqueId = (): string => Math.random().toString(36).substring(2, 15); + +const processData = (data: Data): { nodes: Node[], links: { source: number, target: number, value: number, id: string }[] } => { + // Ensure nodes have unique IDs + const nodes = data.nodes.map(node => ({ + ...node, + avgTimeFromPrevious: node.avgTimeFromPrevious ? durationFormatted(node.avgTimeFromPrevious) : null, + idd: node.idd || generateUniqueId(), + })); + + // Ensure links have unique IDs + const links = data.links.map(link => ({ + ...link, + id: link.id || generateUniqueId(), + })); + + // Sort links by source and then by target + links.sort((a, b) => { + if (a.source === b.source) { + return a.target - b.target; + } + return a.source - b.source; + }); + + // Sort nodes based on their first appearance in the sorted links to maintain visual consistency + const sortedNodes = nodes.slice().sort((a, b) => { + const aIndex = links.findIndex(link => link.source === nodes.indexOf(a) || link.target === nodes.indexOf(a)); + const bIndex = links.findIndex(link => link.source === nodes.indexOf(b) || link.target === nodes.indexOf(b)); + return aIndex - bIndex; + }); + + return { nodes: sortedNodes, links }; +}; diff --git a/frontend/package.json b/frontend/package.json index c7e068078..d147175b1 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -78,7 +78,7 @@ "react-toastify": "^9.1.1", "react-virtualized": "^9.22.3", "react18-json-view": "^0.2.8", - "recharts": "^2.8.0", + "recharts": "^2.12.6", "redux": "^4.0.5", "redux-immutable": "^4.0.0", "redux-thunk": "^2.3.0", diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 9daf50ca9..7eaa2109f 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -18352,7 +18352,7 @@ __metadata: react-toastify: ^9.1.1 react-virtualized: ^9.22.3 react18-json-view: ^0.2.8 - recharts: ^2.8.0 + recharts: ^2.12.6 redux: ^4.0.5 redux-immutable: ^4.0.0 redux-thunk: ^2.3.0 @@ -21455,9 +21455,9 @@ __metadata: languageName: node linkType: hard -"recharts@npm:^2.8.0": - version: 2.12.4 - resolution: "recharts@npm:2.12.4" +"recharts@npm:^2.12.6": + version: 2.12.7 + resolution: "recharts@npm:2.12.7" dependencies: clsx: ^2.0.0 eventemitter3: ^4.0.1 @@ -21470,7 +21470,7 @@ __metadata: peerDependencies: react: ^16.0.0 || ^17.0.0 || ^18.0.0 react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 - checksum: 15b250b0b45bf26cb3e3913aa0f2ef931fbf1511d5e535c871f0ac3443a9e71bd1c105ec673761d73cbe492d83e98f66f03bea3e30f905118a606249733d0f5c + checksum: 2522d841a1f4e4c0a37046ddb61fa958ac37a66df63dcd4c6cb9113e3f7a71892d74e44494a55bc40faa0afd74d9cf58fec3d2ce53a8ddf997e75367bdd033fc languageName: node linkType: hard