diff --git a/frontend/app/components/Alerts/AlertForm.js b/frontend/app/components/Alerts/AlertForm.js index 852780e9b..da3b74148 100644 --- a/frontend/app/components/Alerts/AlertForm.js +++ b/frontend/app/components/Alerts/AlertForm.js @@ -52,11 +52,22 @@ function AlertForm(props) { onDelete, style = {height: "calc('100vh - 40px')"}, } = props; - const {alertsStore} = useStore() + const {alertsStore, metricStore} = useStore() const { - triggerOptions, + triggerOptions: allTriggerSeries, loading, } = alertsStore + + const triggerOptions = metricStore.instance.series.length > 0 ? allTriggerSeries.filter(s => { + return metricStore.instance.series.findIndex(ms => ms.seriesId === s.value) !== -1 + }).map(v => { + const labelArr = v.label.split('.') + labelArr.shift() + return { + ...v, + label: labelArr.join('.') + } + }) : allTriggerSeries const instance = alertsStore.instance const deleting = loading diff --git a/frontend/app/components/Assist/AssistSearchField/AssistSearchField.tsx b/frontend/app/components/Assist/AssistSearchActions/AssistSearchActions.tsx similarity index 88% rename from frontend/app/components/Assist/AssistSearchField/AssistSearchField.tsx rename to frontend/app/components/Assist/AssistSearchActions/AssistSearchActions.tsx index 974b72fb7..33f6c23e9 100644 --- a/frontend/app/components/Assist/AssistSearchField/AssistSearchField.tsx +++ b/frontend/app/components/Assist/AssistSearchActions/AssistSearchActions.tsx @@ -1,7 +1,6 @@ import React from 'react'; import { Button } from 'antd'; import { useModal } from 'App/components/Modal'; -import SessionSearchField from 'Shared/SessionSearchField'; import { MODULES } from 'Components/Client/Modules'; import AssistStats from '../../AssistStats'; @@ -9,7 +8,7 @@ import Recordings from '../RecordingsList/Recordings'; import { useStore } from 'App/mstore'; import { observer } from 'mobx-react-lite'; -function AssistSearchField() { +function AssistSearchActions() { const { searchStoreLive, userStore } = useStore(); const modules = userStore.account.settings?.modules ?? []; const isEnterprise = userStore.isEnterprise @@ -27,9 +26,6 @@ function AssistSearchField() { }; return (
-
- -
{isEnterprise && modules.includes(MODULES.OFFLINE_RECORDINGS) ? : null } @@ -50,4 +46,4 @@ function AssistSearchField() { ); } -export default observer(AssistSearchField); +export default observer(AssistSearchActions); diff --git a/frontend/app/components/Assist/AssistSearchActions/index.ts b/frontend/app/components/Assist/AssistSearchActions/index.ts new file mode 100644 index 000000000..64715d02b --- /dev/null +++ b/frontend/app/components/Assist/AssistSearchActions/index.ts @@ -0,0 +1 @@ +export { default } from './AssistSearchActions' \ No newline at end of file diff --git a/frontend/app/components/Assist/AssistSearchField/index.ts b/frontend/app/components/Assist/AssistSearchField/index.ts deleted file mode 100644 index 8ecf4e244..000000000 --- a/frontend/app/components/Assist/AssistSearchField/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from './AssistSearchField' \ No newline at end of file diff --git a/frontend/app/components/Assist/AssistView.tsx b/frontend/app/components/Assist/AssistView.tsx index bfcdcac33..122ffcee3 100644 --- a/frontend/app/components/Assist/AssistView.tsx +++ b/frontend/app/components/Assist/AssistView.tsx @@ -1,14 +1,14 @@ import React from 'react'; import LiveSessionList from 'Shared/LiveSessionList'; import LiveSessionSearch from 'Shared/LiveSessionSearch'; -import AssistSearchField from './AssistSearchField'; +import AssistSearchActions from './AssistSearchActions'; import usePageTitle from '@/hooks/usePageTitle'; function AssistView() { usePageTitle('Co-Browse - OpenReplay'); return (
- +
diff --git a/frontend/app/components/Charts/BarChart.tsx b/frontend/app/components/Charts/BarChart.tsx new file mode 100644 index 000000000..94f599bad --- /dev/null +++ b/frontend/app/components/Charts/BarChart.tsx @@ -0,0 +1,95 @@ +import React from 'react'; +import { + DataProps, + buildCategories, + customTooltipFormatter +} from './utils'; +import { buildBarDatasetsAndSeries } from './barUtils'; +import { defaultOptions, echarts, initWindowStorages } from "./init"; +import { BarChart } from 'echarts/charts'; + +echarts.use([BarChart]); + +interface BarChartProps extends DataProps { + label?: string; + onClick?: (event: any) => void; +} + +function ORBarChart(props: BarChartProps) { + const chartUuid = React.useRef(Math.random().toString(36).substring(7)); + const chartRef = React.useRef(null); + + React.useEffect(() => { + if (!chartRef.current) return; + const chart = echarts.init(chartRef.current); + const obs = new ResizeObserver(() => chart.resize()) + obs.observe(chartRef.current); + + const categories = buildCategories(props.data); + const { datasets, series } = buildBarDatasetsAndSeries(props); + + initWindowStorages(chartUuid.current, categories, props.data.chart, props.compData?.chart ?? []); + series.forEach((s: any) => { + (window as any).__seriesColorMap[chartUuid.current][s.name] = s.itemStyle?.color ?? '#999'; + const ds = datasets.find((d) => d.id === s.datasetId); + if (!ds) return; + const yDim = s.encode.y; + const yDimIndex = ds.dimensions.indexOf(yDim); + if (yDimIndex < 0) return; + + (window as any).__seriesValueMap[chartUuid.current][s.name] = {}; + ds.source.forEach((row: any[]) => { + const rowIdx = row[0]; // 'idx' + (window as any).__seriesValueMap[chartUuid.current][s.name][rowIdx] = row[yDimIndex]; + }); + }); + + + const xAxis: any = { + type: 'category', + data: categories, + }; + const yAxis: any = { + type: 'value', + data: undefined, + name: props.label ?? 'Number of Sessions', + nameLocation: 'middle', + nameGap: 35, + }; + + chart.setOption({ + ...defaultOptions, + legend: { + ...defaultOptions.legend, + data: series.filter((s: any) => !s._hideInLegend).map((s: any) => s.name), + }, + tooltip: { + ...defaultOptions.tooltip, + formatter: customTooltipFormatter(chartUuid.current), + }, + xAxis, + yAxis, + dataset: datasets, + series, + }); + chart.on('click', (event) => { + const index = event.dataIndex; + const timestamp = (window as any).__timestampMap?.[chartUuid.current]?.[index]; + props.onClick?.({ activePayload: [{ payload: { timestamp }}]}) + }) + + return () => { + chart.dispose(); + obs.disconnect(); + delete (window as any).__seriesValueMap[chartUuid.current]; + delete (window as any).__seriesColorMap[chartUuid.current]; + delete (window as any).__categoryMap[chartUuid.current]; + delete (window as any).__timestampMap[chartUuid.current]; + delete (window as any).__timestampCompMap[chartUuid.current]; + }; + }, [props.data, props.compData]); + + return
; +} + +export default ORBarChart; diff --git a/frontend/app/components/Charts/ColumnChart.tsx b/frontend/app/components/Charts/ColumnChart.tsx new file mode 100644 index 000000000..cce550bc4 --- /dev/null +++ b/frontend/app/components/Charts/ColumnChart.tsx @@ -0,0 +1,101 @@ +import React from 'react'; +import { defaultOptions, echarts } from './init'; +import { BarChart } from 'echarts/charts'; +import { customTooltipFormatter } from './utils'; +import { buildColumnChart } from './barUtils' + +echarts.use([BarChart]); + +interface DataItem { + time: string; + timestamp: number; + [seriesName: string]: number | string; +} + +export interface DataProps { + data: { + chart: DataItem[]; + namesMap: string[]; + }; + compData?: { + chart: DataItem[]; + namesMap: string[]; + }; +} + +interface ColumnChartProps extends DataProps { + label?: string; + onSeriesFocus?: (name: string) => void; +} + +function ColumnChart(props: ColumnChartProps) { + const { data, compData, label } = props; + const chartRef = React.useRef(null); + const chartUuid = React.useRef( + Math.random().toString(36).substring(7) + ); + + React.useEffect(() => { + if (!chartRef.current) return; + const chart = echarts.init(chartRef.current); + (window as any).__seriesValueMap = (window as any).__seriesValueMap ?? {}; + (window as any).__seriesValueMap[chartUuid.current] = {}; + (window as any).__seriesColorMap = (window as any).__seriesColorMap ?? {}; + (window as any).__seriesColorMap[chartUuid.current] = {}; + (window as any).__yAxisData = (window as any).__yAxisData ?? {} + + const { yAxisData, series } = buildColumnChart(chartUuid.current, data, compData); + (window as any).__yAxisData[chartUuid.current] = yAxisData + + chart.setOption({ + ...defaultOptions, + tooltip: { + ...defaultOptions.tooltip, + formatter: customTooltipFormatter(chartUuid.current), + }, + legend: { + ...defaultOptions.legend, + data: series + .filter((s: any) => !s._hideInLegend) + .map((s: any) => s.name), + }, + grid: { + ...defaultOptions.grid, + left: 40, + right: 30, + top: 40, + bottom: 30, + }, + xAxis: { + type: 'value', + name: label ?? 'Total', + nameLocation: 'middle', + nameGap: 35, + }, + yAxis: { + type: 'category', + data: yAxisData, + }, + series, + }); + + const obs = new ResizeObserver(() => chart.resize()); + obs.observe(chartRef.current); + chart.on('click', (event) => { + const focusedSeriesName = event.name; + props.onSeriesFocus?.(focusedSeriesName); + }) + + return () => { + chart.dispose(); + obs.disconnect(); + delete (window as any).__seriesValueMap[chartUuid.current]; + delete (window as any).__seriesColorMap[chartUuid.current]; + delete (window as any).__yAxisData[chartUuid.current]; + }; + }, [data, compData, label]); + + return
; +} + +export default ColumnChart; diff --git a/frontend/app/components/Charts/LineChart.tsx b/frontend/app/components/Charts/LineChart.tsx new file mode 100644 index 000000000..75f50d346 --- /dev/null +++ b/frontend/app/components/Charts/LineChart.tsx @@ -0,0 +1,102 @@ +import React from 'react'; +import { echarts, defaultOptions, initWindowStorages } from './init'; +import { customTooltipFormatter, buildCategories, buildDatasetsAndSeries } from './utils' +import type { DataProps } from './utils' +import { LineChart } from 'echarts/charts'; + +echarts.use([LineChart]); + +interface Props extends DataProps { + label?: string; + inGrid?: boolean; + isArea?: boolean; + chartName?: string; + onClick?: (event: any) => void; +} + +function ORLineChart(props: Props) { + const chartUuid = React.useRef(Math.random().toString(36).substring(7)); + const chartRef = React.useRef(null); + + React.useEffect(() => { + if (!chartRef.current) return; + const chart = echarts.init(chartRef.current); + const obs = new ResizeObserver(() => chart.resize()) + obs.observe(chartRef.current); + + const categories = buildCategories(props.data); + const { datasets, series } = buildDatasetsAndSeries(props); + + initWindowStorages(chartUuid.current, categories, props.data.chart, props.compData?.chart ?? []); + + series.forEach((s: any) => { + if (props.isArea) { + s.areaStyle = {}; + s.stack = 'Total'; + } else { + s.areaStyle = null; + } + (window as any).__seriesColorMap[chartUuid.current][s.name] = s.itemStyle?.color ?? '#999'; + const datasetId = s.datasetId || 'current'; + const ds = datasets.find((d) => d.id === datasetId); + if (!ds) return; + const yDim = s.encode.y; + const yDimIndex = ds.dimensions.indexOf(yDim); + if (yDimIndex < 0) return; + + (window as any).__seriesValueMap[chartUuid.current][s.name] = {}; + ds.source.forEach((row: any[]) => { + const rowIdx = row[0]; + (window as any).__seriesValueMap[chartUuid.current][s.name][rowIdx] = row[yDimIndex]; + }); + }); + + chart.setOption({ + ...defaultOptions, + title: { + text: props.chartName ?? "Line Chart", + show: false, + }, + legend: { + ...defaultOptions.legend, + // Only show legend for “current” series + data: series.filter((s: any) => !s._hideInLegend).map((s: any) => s.name), + }, + xAxis: { + type: 'category', + boundaryGap: false, + data: categories, + }, + yAxis: { + name: props.label ?? 'Number of Sessions', + nameLocation: 'middle', + nameGap: 35, + }, + tooltip: { + ...defaultOptions.tooltip, + formatter: customTooltipFormatter(chartUuid.current), + }, + dataset: datasets, + series, + }); + chart.on('click', (event) => { + const index = event.dataIndex; + const timestamp = (window as any).__timestampMap?.[chartUuid.current]?.[index]; + props.onClick?.({ activePayload: [{ payload: { timestamp }}]}) + }) + + return () => { + chart.dispose(); + obs.disconnect(); + delete (window as any).__seriesValueMap[chartUuid.current]; + delete (window as any).__seriesColorMap[chartUuid.current]; + delete (window as any).__categoryMap[chartUuid.current]; + delete (window as any).__timestampMap[chartUuid.current]; + delete (window as any).__timestampCompMap[chartUuid.current]; + }; + }, [props.data, props.compData]); + + return
; +} + +export default ORLineChart; diff --git a/frontend/app/components/Charts/PieChart.tsx b/frontend/app/components/Charts/PieChart.tsx new file mode 100644 index 000000000..9675aab97 --- /dev/null +++ b/frontend/app/components/Charts/PieChart.tsx @@ -0,0 +1,123 @@ +import React, { useEffect, useRef } from 'react'; +import { PieChart as EchartsPieChart } from 'echarts/charts'; +import { echarts, defaultOptions } from './init'; +import { buildPieData, pieTooltipFormatter, pickColorByIndex } from './pieUtils'; + +echarts.use([EchartsPieChart]); + +interface DataItem { + time: string; + timestamp: number; + [seriesName: string]: number | string; +} + +interface PieChartProps { + data: { + chart: DataItem[]; + namesMap: string[]; + }; + label?: string; + inGrid?: boolean; + onSeriesFocus?: (seriesName: string) => void; +} + +function PieChart(props: PieChartProps) { + const { data, label, onClick = () => {}, inGrid = false } = props; + const chartRef = useRef(null); + + useEffect(() => { + if (!chartRef.current) return; + if (!data.chart || data.chart.length === 0) { + chartRef.current.innerHTML = `
No data available
`; + return; + } + + const chartInstance = echarts.init(chartRef.current); + + const pieData = buildPieData(data.chart, data.namesMap); + if (!pieData.length) { + chartRef.current.innerHTML = `
No data available
`; + return; + } + + // const largestSlice = pieData.reduce((acc, curr) => + // curr.value > acc.value ? curr : acc + // ); + // const largestVal = largestSlice.value || 1; // avoid divide-by-zero + + const option = { + ...defaultOptions, + tooltip: { + ...defaultOptions.tooltip, + trigger: 'item', + formatter: pieTooltipFormatter, + }, + grid: { + top: 10, + bottom: 10, + left: 10, + right: 10, + }, + legend: { + ...defaultOptions.legend, + type: 'plain', + show: true, + top: inGrid ? undefined : 0, + }, + series: [ + { + type: 'pie', + name: label ?? 'Data', + radius: [50, 100], + center: ['50%', '55%'], + data: pieData.map((d, idx) => { + return { + name: d.name, + value: d.value, + label: { + show: false, //d.value / largestVal >= 0.03, + position: 'outside', + formatter: (params: any) => { + return params.value; + }, + }, + labelLine: { + show: false, // d.value / largestVal >= 0.03, + length: 10, + length2: 20, + lineStyle: { color: '#3EAAAF' }, + }, + itemStyle: { + color: pickColorByIndex(idx), + }, + }; + }), + emphasis: { + scale: true, + scaleSize: 4, + }, + }, + ], + }; + + chartInstance.setOption(option); + const obs = new ResizeObserver(() => chartInstance.resize()) + obs.observe(chartRef.current); + + chartInstance.on('click', function (params) { + const focusedSeriesName = params.name + props.onSeriesFocus?.(focusedSeriesName); + }); + + return () => { + chartInstance.dispose(); + obs.disconnect(); + }; + }, [data, label, onClick, inGrid]); + + return ( +
+ ); +} + +export default PieChart; diff --git a/frontend/app/components/Charts/SankeyChart.tsx b/frontend/app/components/Charts/SankeyChart.tsx new file mode 100644 index 000000000..a86251092 --- /dev/null +++ b/frontend/app/components/Charts/SankeyChart.tsx @@ -0,0 +1,254 @@ +// START GEN +import React from 'react'; +import { echarts, defaultOptions } from './init'; +import { SankeyChart } from 'echarts/charts'; +import { sankeyTooltip, getEventPriority, getNodeName } from './sankeyUtils' +echarts.use([SankeyChart]); + +interface SankeyNode { + name: string | null; // e.g. "/en/deployment/", or null + eventType?: string; // e.g. "LOCATION" (not strictly needed by ECharts) +} + +interface SankeyLink { + source: number; // index of source node + target: number; // index of target node + value: number; // percentage + sessionsCount: number; + eventType?: string; // optional +} + +interface Data { + nodes: SankeyNode[]; + links: SankeyLink[]; +} + +interface Props { + data: Data; + height?: number; + onChartClick?: (filters: any[]) => void; +} + +// Not working properly +function findHighestContributors(nodeIndex: number, links: SankeyLink[]) { + const contributors: SankeyLink[] = []; + let currentNode = nodeIndex; + + while (true) { + let maxContribution = -Infinity; + let primaryLink: SankeyLink | null = null; + + for (const link of links) { + if (link.target === currentNode) { + if (link.value > maxContribution) { + maxContribution = link.value; + primaryLink = link; + } + } + } + + if (primaryLink) { + contributors.push(primaryLink); + currentNode = primaryLink.source; + } else { + break; + } + } + + return contributors; +} + +const EChartsSankey: React.FC = (props) => { + const { data, height = 240, onChartClick } = props; + const chartRef = React.useRef(null); + + React.useEffect(() => { + if (!chartRef.current) return; + + const chart = echarts.init(chartRef.current); + + const nodeValues = new Array(data.nodes.length).fill(0); + const echartNodes = data.nodes + .map((n, i) => ({ + name: getNodeName(n.eventType || 'Other', n.name), + depth: n.depth, + type: n.eventType, + id: n.id, + })) + .sort((a, b) => { + if (a.depth === b.depth) { + return getEventPriority(a.type || '') - getEventPriority(b.type || '') + } else { + return a.depth - b.depth; + } + }); + const echartLinks = data.links.map((l, i) => ({ + source: echartNodes.findIndex((n) => n.id === l.source), + target: echartNodes.findIndex((n) => n.id === l.target), + value: l.sessionsCount, + percentage: l.value, + })); + nodeValues.forEach((v, i) => { + const outgoingValues = echartLinks + .filter((l) => l.source === i) + .reduce((p, c) => p + c.value, 0); + const incomingValues = echartLinks + .filter((l) => l.target === i) + .reduce((p, c) => p + c.value, 0); + nodeValues[i] = Math.max(outgoingValues, incomingValues); + }) + + const option = { + ...defaultOptions, + tooltip: { + trigger: 'item', + }, + series: [ + { + layoutIterations: 0, + type: 'sankey', + data: echartNodes, + links: echartLinks, + emphasis: { + focus: 'adjacency', + blurScope: 'global', + }, + label: { + formatter: '{b} - {c}' + }, + tooltip: { + formatter: sankeyTooltip(echartNodes, nodeValues) + }, + nodeAlign: 'right', + nodeWidth: 10, + nodeGap: 8, + lineStyle: { + color: 'source', + curveness: 0.5, + opacity: 0.3, + }, + itemStyle: { + color: '#394eff', + borderRadius: 4, + }, + }, + ], + }; + + chart.setOption(option); + + const seriesIndex = 0; + function highlightNode(nodeIdx: number) { + chart.dispatchAction({ + type: 'highlight', + seriesIndex, + dataType: 'node', + dataIndex: nodeIdx, + }); + } + function highlightLink(linkIdx: number) { + chart.dispatchAction({ + type: 'highlight', + seriesIndex, + dataType: 'edge', + dataIndex: linkIdx, + }); + } + function resetHighlight() { + chart.dispatchAction({ + type: 'downplay', + seriesIndex, + }); + } + + chart.on('click', function (params) { + if (!onChartClick) return; + + if (params.dataType === 'node') { + const nodeIndex = params.dataIndex; + const node = data.nodes[nodeIndex]; + onChartClick([{ node }]); + } else if (params.dataType === 'edge') { + const linkIndex = params.dataIndex; + const link = data.links[linkIndex]; + onChartClick([{ link }]); + } + }); + + // chart.on('mouseover', function (params) { + // if (params.seriesIndex !== seriesIndex) return; // ignore if not sankey + // resetHighlight(); // dim everything first + // + // if (params.dataType === 'node') { + // const hoveredNodeIndex = params.dataIndex; + // // find outgoing links + // const outgoingLinks: number[] = []; + // data.links.forEach((link, linkIdx) => { + // if (link.source === hoveredNodeIndex) { + // outgoingLinks.push(linkIdx); + // } + // }); + // + // // find incoming highest contributors + // const highestContribLinks = findHighestContributors(hoveredNodeIndex, data.links); + // + // // highlight outgoing links + // outgoingLinks.forEach((linkIdx) => highlightLink(linkIdx)); + // // highlight the "highest path" of incoming links + // highestContribLinks.forEach((lk) => { + // // We need to find which link index in data.links => lk + // const linkIndex = data.links.indexOf(lk); + // if (linkIndex >= 0) { + // highlightLink(linkIndex); + // } + // }); + // + // // highlight the node itself + // highlightNode(hoveredNodeIndex); + // + // // highlight the nodes that are "source/target" of the highlighted links + // const highlightNodeSet = new Set(); + // outgoingLinks.forEach((lIdx) => { + // highlightNodeSet.add(data.links[lIdx].target); + // highlightNodeSet.add(data.links[lIdx].source); + // }); + // highestContribLinks.forEach((lk) => { + // highlightNodeSet.add(lk.source); + // highlightNodeSet.add(lk.target); + // }); + // // also add the hovered node + // highlightNodeSet.add(hoveredNodeIndex); + // + // // highlight those nodes + // highlightNodeSet.forEach((nIdx) => highlightNode(nIdx)); + // + // } else if (params.dataType === 'edge') { + // const hoveredLinkIndex = params.dataIndex; + // // highlight just that edge + // highlightLink(hoveredLinkIndex); + // + // // highlight source & target node + // const link = data.links[hoveredLinkIndex]; + // highlightNode(link.source); + // highlightNode(link.target); + // } + // }); + // + // chart.on('mouseout', function () { + // // revert to normal + // resetHighlight(); + // }); + + const ro = new ResizeObserver(() => chart.resize()); + ro.observe(chartRef.current); + + return () => { + chart.dispose(); + ro.disconnect(); + }; + }, [data, height, onChartClick]); + + return
; +}; + +export default EChartsSankey; diff --git a/frontend/app/components/Charts/barUtils.ts b/frontend/app/components/Charts/barUtils.ts new file mode 100644 index 000000000..86c55a95d --- /dev/null +++ b/frontend/app/components/Charts/barUtils.ts @@ -0,0 +1,129 @@ +import type { DataProps, DataItem } from './utils'; +import { createDataset, assignColorsByBaseName, assignColorsByCategory } from './utils'; + +export function createBarSeries( + data: DataProps['data'], + datasetId: string, + dashed: boolean, + hideFromLegend: boolean, +) { + return data.namesMap.filter(Boolean).map((fullName) => { + const baseName = fullName.replace(/^Previous\s+/, ''); + + const encode = { x: 'idx', y: fullName }; + + const borderRadius = [6, 6, 0, 0]; + const decal = dashed ? { symbol: 'line', symbolSize: 10, rotation: 1 } : { symbol: 'none' }; + return { + name: fullName, + _baseName: baseName, + type: 'bar', + datasetId, + animation: false, + encode, + showSymbol: false, + itemStyle: { borderRadius, decal }, + _hideInLegend: hideFromLegend, + }; + }); +} + +export function buildBarDatasetsAndSeries(props: DataProps) { + const mainDataset = createDataset('current', props.data); + const mainSeries = createBarSeries(props.data, 'current', false, false); + + let compDataset: Record | null = null; + let compSeries: Record[] = []; + if (props.compData && props.compData.chart?.length) { + compDataset = createDataset('previous', props.compData); + compSeries = createBarSeries(props.compData, 'previous', true, true); + } + + const datasets = compDataset ? [mainDataset, compDataset] : [mainDataset]; + const series = [...mainSeries, ...compSeries]; + + assignColorsByBaseName(series); + + return { datasets, series }; +} + + +// START GEN +function sumSeries(chart: DataItem[], seriesName: string): number { + return chart.reduce((acc, row) => acc + (Number(row[seriesName]) || 0), 0); +} + +/** + * Build a horizontal bar chart with: + * - yAxis categories = each name in data.namesMap + * - 1 bar series for "Current" + * - 1 bar series for "Previous" (optional, if compData present) + */ +export function buildColumnChart( + chartUuid: string, + data: DataProps['data'], + compData: DataProps['compData'] +) { + const categories = data.namesMap.filter(Boolean); + + const currentValues = categories.map((name) => { + const val = sumSeries(data.chart, name); + (window as any).__seriesValueMap[chartUuid][name] = val; + return val; + }); + + let previousValues: number[] = []; + if (compData && compData.chart?.length) { + previousValues = categories.map((name) => { + const val = sumSeries(compData.chart, `Previous ${name}`); + (window as any).__seriesValueMap[chartUuid][`Previous ${name}`] = val; + return val; + }); + } + + const currentSeries = { + name: 'Current', + type: 'bar', + barWidth: 16, + data: currentValues, + _baseName: 'Current', + itemStyle: { + borderRadius: [0, 6, 6, 0], + }, + }; + + let previousSeries: any = null; + if (previousValues.length > 0) { + previousSeries = { + name: 'Previous', + type: 'bar', + barWidth: 16, + data: previousValues, + _baseName: 'Previous', + itemStyle: { + borderRadius: [0, 6, 6, 0], + decal: { + show: true, + symbol: 'line', + symbolSize: 6, + rotation: 1, + dashArrayX: 4, + dashArrayY: 4, + }, + }, + }; + } + + const series = previousSeries ? [currentSeries, previousSeries] : [currentSeries]; + + assignColorsByCategory(series, categories); + + series.forEach((s) => { + (window as any).__seriesColorMap[chartUuid][s.name] = s.itemStyle.color; + }); + + return { + yAxisData: categories, + series, + }; +} diff --git a/frontend/app/components/Charts/init.ts b/frontend/app/components/Charts/init.ts new file mode 100644 index 000000000..a93a4a2d8 --- /dev/null +++ b/frontend/app/components/Charts/init.ts @@ -0,0 +1,93 @@ +import * as echarts from 'echarts/core'; +import { + DatasetComponent, + TitleComponent, + TooltipComponent, + GridComponent, + LegendComponent, + // TransformComponent, + ToolboxComponent, +} from 'echarts/components'; +import { SVGRenderer } from 'echarts/renderers'; + +echarts.use([ + DatasetComponent, + TitleComponent, + TooltipComponent, + GridComponent, + LegendComponent, + // TransformComponent, + SVGRenderer, + ToolboxComponent +]); + +const defaultOptions = { + aria: { + enabled: true, + decal: { + show: true, + }, + }, + tooltip: { + trigger: 'item', + backgroundColor: 'transparent', + borderWidth: 0, + padding: 0, + extraCssText: 'box-shadow: none; pointer-events: auto;', + axisPointer: { + type: 'cross', + label: { + backgroundColor: '#6a7985' + }, + } + }, + grid: { + bottom: 20, + top: 40, + left: 55, + right: 15, + containLabel: true, + }, + toolbox: { + show: true, + right: 10, + top: 10, + feature: { + saveAsImage: { + pixelRatio: 1.5, + } + } + }, + legend: { + type: 'plain', + show: true, + top: 10, + icon: 'pin' + }, +} + +export function initWindowStorages(chartUuid: string, categories: string[] = [], chartArr: any[] = [], compChartArr: any[] = []) { + (window as any).__seriesValueMap = (window as any).__seriesValueMap ?? {}; + (window as any).__seriesColorMap = (window as any).__seriesColorMap ?? {}; + (window as any).__timestampMap = (window as any).__timestampMap ?? {}; + (window as any).__timestampCompMap = (window as any).__timestampCompMap ?? {}; + (window as any).__categoryMap = (window as any).__categoryMap ?? {}; + + if (!(window as any).__seriesColorMap[chartUuid]) { + (window as any).__seriesColorMap[chartUuid] = {}; + } + if (!(window as any).__seriesValueMap[chartUuid]) { + (window as any).__seriesValueMap[chartUuid] = {}; + } + if (!(window as any).__categoryMap[chartUuid]) { + (window as any).__categoryMap[chartUuid] = categories; + } + if (!(window as any).__timestampMap[chartUuid]) { + (window as any).__timestampMap[chartUuid] = chartArr.map((item) => item.timestamp); + } + if (!(window as any).__timestampCompMap[chartUuid]) { + (window as any).__timestampCompMap[chartUuid] = compChartArr.map((item) => item.timestamp); + } +} + +export { echarts, defaultOptions }; \ No newline at end of file diff --git a/frontend/app/components/Charts/pieUtils.ts b/frontend/app/components/Charts/pieUtils.ts new file mode 100644 index 000000000..2c5426cf4 --- /dev/null +++ b/frontend/app/components/Charts/pieUtils.ts @@ -0,0 +1,31 @@ +import { colors } from './utils'; +import { numberWithCommas } from 'App/utils'; + + +export function buildPieData( + chart: Array>, + namesMap: string[] +) { + const result: { name: string; value: number }[] = namesMap.map((name) => { + let sum = 0; + chart.forEach((row) => { + sum += Number(row[name] ?? 0); + }); + return { name, value: sum }; + }); + return result; +} + +export function pieTooltipFormatter(params: any) { + const { name, value, marker, percent } = params; + return ` +
+
${marker} ${name}
+
${numberWithCommas(value)} (${percent}%)
+
+ `; +} + +export function pickColorByIndex(idx: number) { + return colors[idx % colors.length]; +} \ No newline at end of file diff --git a/frontend/app/components/Charts/sankeyUtils.ts b/frontend/app/components/Charts/sankeyUtils.ts new file mode 100644 index 000000000..2459f3b51 --- /dev/null +++ b/frontend/app/components/Charts/sankeyUtils.ts @@ -0,0 +1,60 @@ +export function sankeyTooltip(echartNodes, nodeValues) { + return (params) => { + if ('source' in params.data && 'target' in params.data) { + const sourceName = echartNodes[params.data.source].name; + const targetName = echartNodes[params.data.target].name; + const sourceValue = nodeValues[params.data.source]; + return ` +
+
+
+
+ +
+
+
+
${sourceName}
+
${sourceValue}
+
${targetName}
+
+ ${params.data.value} + ${params.data.percentage.toFixed( + 2 + )}% +
+
+
+ `; + //${sourceName} -> ${targetName}: ${params.data.value} sessions (${params.data.percentage.toFixed(2)}%) + } + if ('name' in params.data) { + return ` +
+
${params.data.name}
+
${params.value} sessions
+
+ `; + } + }; +} + + +export const getEventPriority = (type: string) => { + switch (type) { + case 'DROP': + return 3; + case 'OTHER': + return 2; + default: + return 1; + } +}; + +export const getNodeName = (eventType: string, nodeName: string | null) => { + if (!nodeName) { + // only capitalize first + return eventType.charAt(0) + eventType.slice(1).toLowerCase(); + } + return nodeName; +} + diff --git a/frontend/app/components/Charts/utils.ts b/frontend/app/components/Charts/utils.ts new file mode 100644 index 000000000..8a5454991 --- /dev/null +++ b/frontend/app/components/Charts/utils.ts @@ -0,0 +1,346 @@ +import { formatTimeOrDate } from 'App/date'; + +export const colors = [ + '#394EFF', + '#3EAAAF', + '#9276da', + '#ceba64', + '#bc6f9d', + '#966fbc', + '#64ce86', + '#e06da3', + '#6dabe0', +]; + +/** + * Match colors by baseName so “Previous Series 1” uses the same color as “Series 1”. + */ +export function assignColorsByBaseName(series: any[]) { + const palette = colors; + + const colorMap: Record = {}; + let colorIndex = 0; + + // Assign to current lines first + series.forEach((s) => { + if (!s._hideInLegend) { + const baseName = s._baseName || s.name; + if (!colorMap[baseName]) { + colorMap[baseName] = palette[colorIndex % palette.length]; + colorIndex++; + } + } + }); + + series.forEach((s) => { + const baseName = s._baseName || s.name; + const color = colorMap[baseName]; + s.itemStyle = { ...s.itemStyle, color }; + s.lineStyle = { ...(s.lineStyle || {}), color }; + }); +} + +function buildCategoryColorMap(categories: string[]): Record { + const colorMap: Record = {}; + categories.forEach((_, i) => { + colorMap[i] = colors[i % colors.length]; + }); + return colorMap; +} + +/** + * For each series, transform its data array to an array of objects + * with `value` and `itemStyle.color` based on the category index. + */ +export function assignColorsByCategory( + series: any[], + categories: string[] +) { + const categoryColorMap = buildCategoryColorMap(categories); + + series.forEach((s, si) => { + s.data = s.data.map((val: any, i: number) => { + const color = categoryColorMap[i]; + if (typeof val === 'number') { + return { + value: val, + itemStyle: { color }, + }; + } + return { + ...val, + itemStyle: { + ...(val.itemStyle || {}), + color, + }, + }; + }); + s.itemStyle = { ...s.itemStyle, color: colors[si] }; + }); +} + +/** + * Show the hovered “current” or “previous” line + the matching partner (if it exists). + */ +export function customTooltipFormatter(uuid: string) { + return (params: any): string => { + // With trigger='item', params is a single object describing the hovered point + // { seriesName, dataIndex, data, marker, color, encode, ... } + if (!params) return ''; + const { seriesName, dataIndex } = params; + const baseName = seriesName.replace(/^Previous\s+/, ''); + + if (!Array.isArray(params.data)) { + const isPrevious = /Previous/.test(seriesName); + const categoryName = (window as any).__yAxisData?.[uuid]?.[dataIndex]; + const fullname = isPrevious ? `Previous ${categoryName}` : categoryName; + const partnerName = isPrevious ? categoryName : `Previous ${categoryName}`; + const partnerValue = (window as any).__seriesValueMap?.[uuid]?.[ + partnerName + ]; + + let str = ` +
+
+
+
+
${fullname}
+
+ +
+
+ Total: +
+
+
${params.value}
+ ${buildCompareTag(params.value, partnerValue)} +
+
+ `; + if (partnerValue !== undefined) { + const partnerColor = + (window as any).__seriesColorMap?.[uuid]?.[partnerName] || '#999'; + str += ` +
+
+ ${isPrevious ? 'Current' : 'Previous'} Total: +
+
+
${partnerValue ?? '—'}
+ ${buildCompareTag(partnerValue, params.value)} +
+
`; + } + + str += '
'; + + return str; + } + const isPrevious = /^Previous\s+/.test(seriesName); + const partnerName = isPrevious ? baseName : `Previous ${baseName}`; + // 'value' of the hovered point + const yKey = params.encode.y[0]; // "Series 1" + const value = params.data?.[yKey]; + + const timestamp = (window as any).__timestampMap?.[uuid]?.[dataIndex]; + const comparisonTimestamp = (window as any).__timestampCompMap?.[uuid]?.[ + dataIndex + ]; + + // Get partner’s value from some global map + + const partnerVal = (window as any).__seriesValueMap?.[uuid]?.[ + partnerName + ]?.[dataIndex]; + + const categoryLabel = (window as any).__categoryMap[uuid] + ? (window as any).__categoryMap[uuid][dataIndex] + : dataIndex; + + const firstTs = isPrevious ? comparisonTimestamp : timestamp; + const secondTs = isPrevious ? timestamp : comparisonTimestamp; + let tooltipContent = ` +
+
+
+
+
${seriesName}
+
+ +
+
+ ${firstTs ? formatTimeOrDate(firstTs) : categoryLabel} +
+
+
${value ?? '—'}
+ ${buildCompareTag(value, partnerVal)} +
+
+ `; + + if (partnerVal !== undefined) { + const partnerColor = + (window as any).__seriesColorMap?.[uuid]?.[partnerName] || '#999'; + tooltipContent += ` +
+
+ ${secondTs ? formatTimeOrDate(secondTs) : categoryLabel} +
+
+
${partnerVal ?? '—'}
+
+
+ `; + } + + tooltipContent += '
'; + return tooltipContent; + }; +} + +/** + * Build a small "compare" tag to show ▲ or ▼ plus absolute delta plus percent change. + * For example, if val=120, prevVal=100 => ▲ 20 (20%) + */ +function buildCompareTag(val: number, prevVal: number): string { + if (val == null || prevVal == null) { + return ''; + } + + const delta = val - prevVal; + const isHigher = delta > 0; + const arrow = isHigher ? '▲' : '▼'; + const absDelta = Math.abs(delta); + const ratio = prevVal !== 0 ? ((delta / prevVal) * 100).toFixed(2) : null; + + const tagColor = isHigher ? '#D1FADF' : '#FEE2E2'; + const arrowColor = isHigher ? '#059669' : '#DC2626'; + + return ` +
+ ${arrow} + ${absDelta} + ${ratio ? `(${ratio}%)` : ''} +
+ `; +} + +/** + * Build category labels (["Sun", "Mon", ...]) from the "current" data only + */ +export function buildCategories(data: DataProps['data']): string[] { + return data.chart.map((item) => item.time); +} + +/** + * Create a dataset with dimension [idx, ...names]. + * The `idx` dimension aligns with xAxis = "category" + * (which is dates in our case) + */ +export function createDataset(id: string, data: DataProps['data']) { + const dimensions = ['idx', ...data.namesMap]; + const source = data.chart.map((item, idx) => { + const row: (number | undefined)[] = [idx]; + data.namesMap.forEach((name) => { + const val = + typeof item[name] === 'number' ? (item[name] as number) : undefined; + row.push(val); + }); + return row; + }); + return { id, dimensions, source }; +} + +/** + * Create line series referencing the dataset dimension by name. + * `_baseName` is used to match “Series 1” <-> “Previous Series 1”. + */ +export function createSeries( + data: DataProps['data'], + datasetId: string, + dashed: boolean, + hideFromLegend: boolean +) { + return data.namesMap.filter(Boolean).map((fullName) => { + const baseName = fullName.replace(/^Previous\s+/, ''); + return { + name: fullName, + _baseName: baseName, + type: 'line', + animation: false, + datasetId, + encode: { x: 'idx', y: fullName }, + lineStyle: dashed ? { type: 'dashed' } : undefined, + showSymbol: false, + // custom flag to hide prev data from legend + _hideInLegend: hideFromLegend, + itemStyle: { opacity: 1 }, + emphasis: { + focus: 'series', + itemStyle: { opacity: 1 }, + lineStyle: { opacity: 1 }, + }, + blur: { + itemStyle: { opacity: 0.2 }, + lineStyle: { opacity: 0.2 }, + }, + }; + }); +} + +export function buildDatasetsAndSeries(props: DataProps) { + const mainDataset = createDataset('current', props.data); + const mainSeries = createSeries(props.data, 'current', false, false); + + let compDataset: Record | null = null; + let compSeries: Record[] = []; + if (props.compData && props.compData.chart?.length) { + compDataset = createDataset('previous', props.compData); + compSeries = createSeries(props.compData, 'previous', true, true); + } + + const datasets = compDataset ? [mainDataset, compDataset] : [mainDataset]; + const series = [...mainSeries, ...compSeries]; + assignColorsByBaseName(series as any); + + return { datasets, series }; +} + +export interface DataItem { + time: string; + timestamp: number; + [seriesName: string]: number | string; +} + +export interface DataProps { + data: { + chart: DataItem[]; + // series names + namesMap: string[]; + }; + compData?: { + chart: DataItem[]; + // same as data.namesMap, but with "Previous" prefix + namesMap: string[]; + }; +} diff --git a/frontend/app/components/Client/Projects/ProjectList.tsx b/frontend/app/components/Client/Projects/ProjectList.tsx index b167cef86..1cf10605e 100644 --- a/frontend/app/components/Client/Projects/ProjectList.tsx +++ b/frontend/app/components/Client/Projects/ProjectList.tsx @@ -57,7 +57,7 @@ const ProjectList: React.FC = () => {
} onChange={(e) => setSearch(e.target.value)} @@ -73,7 +73,7 @@ const ProjectList: React.FC = () => { mode="inline" onClick={onClick} selectedKeys={[String(projectsStore.config.pid)]} - className="w-full !bg-white !border-0 " + className="w-full !bg-white !border-0" inlineIndent={11} items={menuItems} /> diff --git a/frontend/app/components/Dashboard/Widgets/CardSessionsByList.tsx b/frontend/app/components/Dashboard/Widgets/CardSessionsByList.tsx index 5f2efe77d..7d3dabc73 100644 --- a/frontend/app/components/Dashboard/Widgets/CardSessionsByList.tsx +++ b/frontend/app/components/Dashboard/Widgets/CardSessionsByList.tsx @@ -17,7 +17,7 @@ interface Props { function CardSessionsByList({ list, selected, paginated, onClickHandler = () => null, metric, total }: Props) { const { dashboardStore, metricStore, sessionStore } = useStore(); const drillDownPeriod = dashboardStore.drillDownPeriod; - const params = { density: 70 }; + const params = { density: 35 }; const metricParams = { ...params }; const [loading, setLoading] = React.useState(false); const data = paginated ? metric?.data[0]?.values : list; 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..e858f4f2e --- /dev/null +++ b/frontend/app/components/Dashboard/Widgets/CustomMetricsWidgets/AreaChart.tsx @@ -0,0 +1,130 @@ +import React, { useState } from 'react'; +import CustomTooltip from "./CustomChartTooltip"; +import { Styles } from '../common'; +import { + ResponsiveContainer, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + AreaChart, + Area, + Legend, +} from 'recharts'; + +interface Props { + data: { chart: any[]; namesMap: string[] }; + colors: any; + onClick?: (event, index) => void; + yaxis?: Record; + label?: string; + hideLegend?: boolean; + inGrid?: boolean; +} + +function CustomAreaChart(props: Props) { + const { + data = { chart: [], namesMap: [] }, + colors, + onClick = () => null, + yaxis = { ...Styles.yaxis }, + label = 'Number of Sessions', + hideLegend = false, + inGrid, + } = props; + + const [hoveredSeries, setHoveredSeries] = useState(null); + + const handleMouseOver = (key: string) => () => { + setHoveredSeries(key); + }; + + const handleMouseLeave = () => { + setHoveredSeries(null); + }; + + // Dynamically reorder namesMap to render hovered series last + const reorderedNamesMap = hoveredSeries + ? [...data.namesMap.filter((key) => key !== hoveredSeries), hoveredSeries] + : data.namesMap; + + return ( + + + {!hideLegend && ( + ({ + value: key, + type: 'line', + color: colors[index], + id: key, + })) + } + /> + )} + + + Styles.tickFormatter(val)} + label={{ + ...Styles.axisLabelLeft, + value: label || 'Number of Sessions', + }} + /> + } // Pass hoveredSeries to tooltip + /> + {Array.isArray(reorderedNamesMap) && + reorderedNamesMap.map((key, index) => ( + + ))} + + + ); +} + +export default CustomAreaChart; \ No newline at end of file diff --git a/frontend/app/components/Dashboard/Widgets/CustomMetricsWidgets/BigNumChart.tsx b/frontend/app/components/Dashboard/Widgets/CustomMetricsWidgets/BigNumChart.tsx new file mode 100644 index 000000000..3f22c9103 --- /dev/null +++ b/frontend/app/components/Dashboard/Widgets/CustomMetricsWidgets/BigNumChart.tsx @@ -0,0 +1,96 @@ +import React from 'react' +import { CompareTag } from "./CustomChartTooltip"; +import cn from 'classnames' + +interface Props { + colors: any; + yaxis?: any; + label?: string; + hideLegend?: boolean; + values: { value: number, compData?: number, series: string, valueLabel?: string }[]; + onSeriesFocus?: (name: string) => void; +} +function BigNumChart(props: Props) { + const { + colors, + label = 'Number of Sessions', + values, + onSeriesFocus, + hideLegend, + } = props; + return ( +
+
+ {values.map((val, i) => ( + + ))} +
+
+ ) +} + +function BigNum({ color, series, value, label, compData, valueLabel, onSeriesFocus, hideLegend }: { + color: string, + series: string, + value: number, + label: string, + compData?: number, + valueLabel?: string, + onSeriesFocus?: (name: string) => void + hideLegend?: boolean +}) { + const formattedNumber = (num: number) => { + return Intl.NumberFormat().format(num); + } + + const changePercent = React.useMemo(() => { + if (!compData || compData === 0) return '0'; + return `${(((value - compData) / compData) * 100).toFixed(2)}`; + }, [value, compData]) + const change = React.useMemo(() => { + if (!compData) return 0; + return value - compData; + }, [value, compData]) + return ( +
onSeriesFocus?.(series)} + className={cn( + 'flex flex-col flex-auto justify-center items-center rounded-lg transition-all', + 'hover:transition-all ease-in-out hover:ease-in-out hover:bg-teal/5 hover:cursor-pointer' + )} + > + {hideLegend ? null : +
+
+
{series}
+
+ } +
+ {formattedNumber(value)} + {valueLabel ? `${valueLabel}` : null} +
+
{label}
+ {compData ? ( + compData} + absDelta={change} + delta={changePercent} + /> + ) : null} +
+ ); +} + +export default BigNumChart; \ No newline at end of file diff --git a/frontend/app/components/Dashboard/Widgets/CustomMetricsWidgets/ClickMapCard/ClickMapCard.tsx b/frontend/app/components/Dashboard/Widgets/CustomMetricsWidgets/ClickMapCard/ClickMapCard.tsx index 72eb6a791..e857b2520 100644 --- a/frontend/app/components/Dashboard/Widgets/CustomMetricsWidgets/ClickMapCard/ClickMapCard.tsx +++ b/frontend/app/components/Dashboard/Widgets/CustomMetricsWidgets/ClickMapCard/ClickMapCard.tsx @@ -15,7 +15,7 @@ function ClickMapCard() { const sessionId = metricStore.instance.data.sessionId; const url = metricStore.instance.data.path; - const operator = metricStore.instance.series[0].filter.filters[0].operator + const operator = metricStore.instance.series[0]?.filter.filters[0]?.operator ? metricStore.instance.series[0].filter.filters[0].operator : 'startsWith' React.useEffect(() => { 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..ba76974c7 --- /dev/null +++ b/frontend/app/components/Dashboard/Widgets/CustomMetricsWidgets/CustomChartTooltip.tsx @@ -0,0 +1,124 @@ +import React from 'react'; +import { formatTimeOrDate } from 'App/date'; +import cn from 'classnames'; +import { ArrowUp, ArrowDown } from 'lucide-react'; + +interface PayloadItem { + hide?: boolean; + name: string; + value: number; + prevValue?: number; + color?: string; + payload?: any; +} + +interface Props { + active: boolean; + payload: PayloadItem[]; + label: string; + hoveredSeries?: string | null; +} + +function CustomTooltip(props: Props) { + const { active, payload, label, hoveredSeries = null } = props; + + // Return null if tooltip is not active or there is no valid payload + if (!active || !payload?.length || !hoveredSeries) return null; + + // Find the current and comparison payloads + const currentPayload = payload.find((p) => p.name === hoveredSeries); + const comparisonPayload = payload.find( + (p) => + p.name === `${hoveredSeries.replace(' (Comparison)', '')} (Comparison)` || + p.name === `${hoveredSeries} (Comparison)` + ); + + if (!currentPayload) return null; + + // Create transformed array with comparison data + const transformedArray = [ + { + ...currentPayload, + prevValue: comparisonPayload ? comparisonPayload.value : null, + }, + ]; + + const isHigher = (item: { value: number; prevValue: number }) => + item.prevValue !== null && item.prevValue < item.value; + + const getPercentDelta = (val: number, prevVal: number) => + (((val - prevVal) / prevVal) * 100).toFixed(2); + + return ( +
+ {transformedArray.map((p, index) => ( + +
+
+
{index + 1}
+
+
{p.name}
+
+
+
+ {label},{' '} + {p.payload?.timestamp + ? formatTimeOrDate(p.payload.timestamp) + :
'Timestamp is not Applicable'
} +
+
+
{p.value}
+ + +
+
+
+ ))} +
+ ); +} + +export function CompareTag({ + isHigher, + absDelta, + delta, +}: { + isHigher: boolean | null; // Allow null for default view + absDelta?: number | string | null; + delta?: string | null; +}) { + return ( +
+ {isHigher === null ? ( +
No Comparison
+ ) : ( + <> + {!isHigher ? : } +
{absDelta}
+
({delta}%)
+ + )} +
+ ); +} + +export default CustomTooltip; \ No newline at end of file diff --git a/frontend/app/components/Dashboard/Widgets/CustomMetricsWidgets/CustomLegend.tsx b/frontend/app/components/Dashboard/Widgets/CustomMetricsWidgets/CustomLegend.tsx new file mode 100644 index 000000000..17c22b214 --- /dev/null +++ b/frontend/app/components/Dashboard/Widgets/CustomMetricsWidgets/CustomLegend.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import { Legend } from 'recharts'; + +interface CustomLegendProps { + payload?: any[]; +} + +function CustomLegend({ payload }: CustomLegendProps) { + return ( +
+ {payload?.map((entry) => ( +
+ {entry.value.includes('(Comparison)') ? ( +
+ ) : ( +
+ )} + {entry.value} +
+ ))} +
+ ); +} + +export default CustomLegend; \ 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 deleted file mode 100644 index f898da57c..000000000 --- a/frontend/app/components/Dashboard/Widgets/CustomMetricsWidgets/CustomMetricLineChart/CustomMetricLineChart.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import React from 'react' -import {Styles} from '../../common'; -import {ResponsiveContainer, XAxis, YAxis, CartesianGrid, Tooltip} from 'recharts'; -import {LineChart, Line, Legend} from 'recharts'; - -interface Props { - data: any; - params: any; - // seriesMap: 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; - - return ( - - - - - Styles.tickFormatter(val)} - label={{ - ...Styles.axisLabelLeft, - value: label || "Number of Sessions" - }} - /> - {!hideLegend && } - - {Array.isArray(data.namesMap) && data.namesMap.map((key, index) => ( - - ))} - - - ) -} - -export default CustomMetricLineChart diff --git a/frontend/app/components/Dashboard/Widgets/CustomMetricsWidgets/CustomMetricLineChart/index.ts b/frontend/app/components/Dashboard/Widgets/CustomMetricsWidgets/CustomMetricLineChart/index.ts deleted file mode 100644 index b8570c250..000000000 --- a/frontend/app/components/Dashboard/Widgets/CustomMetricsWidgets/CustomMetricLineChart/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from './CustomMetricLineChart'; diff --git a/frontend/app/components/Dashboard/Widgets/CustomMetricsWidgets/CustomMetricPieChart/CustomMetricPieChart.tsx b/frontend/app/components/Dashboard/Widgets/CustomMetricsWidgets/CustomMetricPieChart/CustomMetricPieChart.tsx index a453222e5..b0d8029c0 100644 --- a/frontend/app/components/Dashboard/Widgets/CustomMetricsWidgets/CustomMetricPieChart/CustomMetricPieChart.tsx +++ b/frontend/app/components/Dashboard/Widgets/CustomMetricsWidgets/CustomMetricPieChart/CustomMetricPieChart.tsx @@ -1,126 +1,167 @@ -//@ts-nocheck -import React from 'react' +import React, { useState } 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'; +import CustomTooltip from '../CustomChartTooltip'; + interface Props { - metric: any, - data: any; - colors: any; - onClick?: (filters) => void; + metric: { + metricOf: string; + metricType: string; + }; + data: { + chart: any[]; + namesMap: string[]; + }; + colors: any; + onClick?: (filters) => void; + inGrid?: boolean; } function CustomMetricPieChart(props: Props) { - const { metric, data = { values: [] }, onClick = () => null } = props; + const { metric, data, onClick = () => null, inGrid } = 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 [hoveredSeries, setHoveredSeries] = useState(null); - filters.push(filter); - onClick(filters); - } + 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); } - 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 handleMouseOver = (name: string) => setHoveredSeries(name); + const handleMouseLeave = () => setHoveredSeries(null); + + const getTotalForSeries = (series: string) => + data.chart ? data.chart.reduce((acc, curr) => acc + curr[series], 0) : 0; + + const values = data.namesMap.map((k) => ({ + name: k, + value: getTotalForSeries(k), + })); + + const highest = values.reduce( + (acc, curr) => (acc.value > curr.value ? acc : curr), + { name: '', value: 0 } + ); + + return ( + + + + + } + /> + handleMouseOver(name)} + onMouseLeave={handleMouseLeave} + labelLine={({ + cx, + cy, + midAngle, + innerRadius, + outerRadius, + value, + }) => { + 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.map((entry, index) => ( + + ))} + + + + + ); } -export default CustomMetricPieChart; +export default CustomMetricPieChart; \ No newline at end of file diff --git a/frontend/app/components/Dashboard/Widgets/common/Styles.js b/frontend/app/components/Dashboard/Widgets/common/Styles.js index a6f88bd26..2bf9c6090 100644 --- a/frontend/app/components/Dashboard/Widgets/common/Styles.js +++ b/frontend/app/components/Dashboard/Widgets/common/Styles.js @@ -8,6 +8,7 @@ const compareColors = ['#192EDB', '#6272FF', '#808DFF', '#B3BBFF', '#C9CFFF']; const compareColorsx = ["#222F99", "#2E3ECC", "#394EFF", "#6171FF", "#8895FF", "#B0B8FF", "#D7DCFF"].reverse(); const customMetricColors = ['#394EFF', '#3EAAAF', '#565D97']; const colorsPie = colors.concat(["#DDDDDD"]); +const safeColors = ['#394EFF', '#3EAAAF', '#9276da', '#ceba64', "#bc6f9d", '#966fbc', '#64ce86', '#e06da3', '#6dabe0']; const countView = count => { const isMoreThanK = count >= 1000; @@ -22,6 +23,7 @@ export default { colorsx, compareColors, compareColorsx, + safeColors, lineColor: '#2A7B7F', lineColorCompare: '#394EFF', strokeColor: compareColors[0], @@ -29,13 +31,13 @@ export default { axisLine: {stroke: '#CCCCCC'}, interval: 0, dataKey: "time", - tick: {fill: '#999999', fontSize: 9}, + tick: {fill: '#000000', fontSize: 9}, tickLine: {stroke: '#CCCCCC'}, strokeWidth: 0.5 }, yaxis: { axisLine: {stroke: '#CCCCCC'}, - tick: {fill: '#999999', fontSize: 9}, + tick: {fill: '#000000', fontSize: 9}, tickLine: {stroke: '#CCCCCC'}, }, axisLabelLeft: { @@ -50,8 +52,8 @@ export default { tickFormatterBytes: val => Math.round(val / 1024 / 1024), chartMargins: {left: 0, right: 20, top: 10, bottom: 5}, tooltip: { - cursor: { - fill: '#f6f6f6' + wrapperStyle: { + zIndex: 999, }, contentStyle: { padding: '5px', @@ -73,6 +75,9 @@ export default { lineHeight: '0.75rem', color: '#000', fontSize: '12px' + }, + cursor: { + fill: '#eee' } }, gradientDef: () => ( diff --git a/frontend/app/components/Dashboard/components/AddCardSection/AddCardSection.tsx b/frontend/app/components/Dashboard/components/AddCardSection/AddCardSection.tsx new file mode 100644 index 000000000..08b0cfb9f --- /dev/null +++ b/frontend/app/components/Dashboard/components/AddCardSection/AddCardSection.tsx @@ -0,0 +1,308 @@ +import React from 'react'; +import { FolderOutlined } from '@ant-design/icons'; +import { Segmented, Button } from 'antd'; +import { + LineChart, + Filter, + ArrowUpDown, + WifiOff, + Turtle, + FileStack, + AppWindow, + Combine, + Users, + Sparkles, + Globe, + MonitorSmartphone, +} from 'lucide-react'; +import { Icon } from 'UI'; +import FilterSeries from 'App/mstore/types/filterSeries'; +import { useModal } from 'App/components/Modal'; +import { + CARD_LIST, + CardType, +} from '../DashboardList/NewDashModal/ExampleCards'; +import { useStore } from 'App/mstore'; +import { + HEATMAP, + FUNNEL, + TABLE, + TIMESERIES, + USER_PATH, + CATEGORIES, +} from 'App/constants/card'; +import { useHistory } from 'react-router-dom'; +import { dashboardMetricCreate, withSiteId, metricCreate } from 'App/routes'; +import { FilterKey } from 'Types/filter/filterType'; +import MetricsLibraryModal from '../MetricsLibraryModal/MetricsLibraryModal'; +import { observer } from 'mobx-react-lite'; + +interface TabItem { + icon: React.ReactNode; + title: string; + description: string; + type: string; +} +export const tabItems: Record = { + [CATEGORIES.product_analytics]: [ + { + icon: , + title: 'Trends', + type: TIMESERIES, + description: 'Track session trends over time.', + }, + { + icon: , + title: 'Funnels', + type: FUNNEL, + description: 'Visualize user progression through critical steps.', + }, + { + icon: ( + + ), + title: 'Journeys', + type: USER_PATH, + description: 'Understand the paths users take through your product.', + }, + // { TODO: 1.23+ + // icon: , + // title: 'Retention', + // type: RETENTION, + // description: 'Analyze user retention over specific time periods.', + // }, + { + icon: , + title: 'Heatmaps', + type: HEATMAP, + description: 'Visualize user interaction patterns on your pages.', + }, + ], + [CATEGORIES.monitors]: [ + { + icon: ( + + ), + title: 'JS Errors', + type: FilterKey.ERRORS, + description: 'Monitor JS errors affecting user experience.', + }, + { + icon: , + title: 'Top Network Requests', + type: FilterKey.FETCH, + description: 'Identify the most frequent network requests.', + }, + { + icon: , + title: '4xx/5xx Requests', + type: TIMESERIES + '_4xx_requests', + description: 'Track client and server errors for performance issues.', + }, + { + icon: , + title: 'Slow Network Requests', + type: TIMESERIES + '_slow_network_requests', + description: 'Pinpoint the slowest network requests causing delays.', + }, + ], + [CATEGORIES.web_analytics]: [ + { + icon: , + title: 'Top Pages', + type: FilterKey.LOCATION, + description: 'Discover the most visited pages on your site.', + }, + { + icon: , + title: 'Top Browsers', + type: FilterKey.USER_BROWSER, + description: 'Analyze the browsers your visitors are using the most.', + }, + { + icon: , + title: 'Top Referrer', + type: FilterKey.REFERRER, + description: 'See where your traffic is coming from.', + }, + { + icon: , + title: 'Top Users', + type: FilterKey.USERID, + description: 'Identify the users with the most interactions.', + }, + { + icon: , + title: 'Top Countries', + type: FilterKey.USER_COUNTRY, + description: 'Track the geographical distribution of your audience.', + }, + { + icon: , + title: 'Top Devices', + type: FilterKey.USER_DEVICE, + description: 'Explore the devices used by your users.', + } + // { TODO: 1.23+ maybe + // icon: , + // title: 'Speed Index by Country', + // type: TABLE, + // description: 'Measure performance across different regions.', + // }, + ], +}; + +function CategoryTab({ tab, inCards }: { tab: string; inCards?: boolean }) { + const items = tabItems[tab]; + const { metricStore, projectsStore, dashboardStore } = useStore(); + const history = useHistory(); + + const handleCardSelection = (card: string) => { + metricStore.init(); + const selectedCard = CARD_LIST.find((c) => c.key === card) as CardType; + const cardData: any = { + metricType: selectedCard.cardType, + name: selectedCard.title, + metricOf: selectedCard.metricOf, + category: card, + }; + + if (selectedCard.filters) { + cardData.series = [ + new FilterSeries().fromJson({ + name: 'Series 1', + filter: { + filters: selectedCard.filters, + }, + }), + ]; + } + + // TODO This code here makes 0 sense + if (selectedCard.cardType === FUNNEL) { + cardData.series = []; + cardData.series.push(new FilterSeries()); + cardData.series[0].filter.addFunnelDefaultFilters(); + cardData.series[0].filter.eventsOrder = 'then'; + cardData.series[0].filter.eventsOrderSupport = ['then']; + } + + metricStore.setCardCategory(tab); + metricStore.merge(cardData); + + if (projectsStore.activeSiteId) { + if (inCards) { + history.push(withSiteId(metricCreate(), projectsStore.activeSiteId)); + } else if (dashboardStore.selectedDashboard) { + history.push( + withSiteId( + dashboardMetricCreate(dashboardStore.selectedDashboard.dashboardId), + projectsStore.activeSiteId + ) + ); + } + } + }; + return ( +
+ {items.map((item, index) => ( +
handleCardSelection(item.type)} + key={index} + className={ + 'flex items-start gap-2 p-2 hover:bg-active-blue rounded-xl hover:text-teal group cursor-pointer' + } + > + {item.icon} +
+
{item.title}
+
+ {item.description} +
+
+
+ ))} +
+ ); +} + +const AddCardSection = observer( + ({ inCards, handleOpenChange }: { inCards?: boolean, handleOpenChange?: (isOpen: boolean) => void }) => { + const { showModal } = useModal(); + const { metricStore, dashboardStore, projectsStore } = useStore(); + const [tab, setTab] = React.useState('product_analytics'); + const options = [ + { label: 'Product Analytics', value: 'product_analytics' }, + { label: 'Monitors', value: 'monitors' }, + { label: 'Web Analytics', value: 'web_analytics' }, + ]; + + const originStr = window.env.ORIGIN || window.location.origin; + const isSaas = /api\.openreplay\.com/.test(originStr); + const onExistingClick = () => { + const dashboardId = dashboardStore.selectedDashboard?.dashboardId; + const siteId = projectsStore.activeSiteId; + showModal( + , + { + right: true, + width: 800, + onClose: () => { + metricStore.updateKey('metricsSearch', ''); + }, + } + ); + handleOpenChange?.(false); + }; + return ( +
+
+
+ What do you want to visualize? +
+ {isSaas ? ( +
+ +
Ask AI
+
+ ) : null} +
+
+ setTab(value)} + /> +
+ +
+ +
+ {inCards ? null : +
+ +
+ } +
+ ); + } +); + +export default AddCardSection; diff --git a/frontend/app/components/Dashboard/components/AddToDashboardButton.tsx b/frontend/app/components/Dashboard/components/AddToDashboardButton.tsx index 054576024..509c66426 100644 --- a/frontend/app/components/Dashboard/components/AddToDashboardButton.tsx +++ b/frontend/app/components/Dashboard/components/AddToDashboardButton.tsx @@ -1,64 +1,67 @@ +// Components/Dashboard/components/AddToDashboardButton.tsx + import React from 'react'; -import {Grid2x2Check} from "lucide-react" -import {Button, Modal} from "antd"; -import Select from "Shared/Select/Select"; -import {Form} from "UI"; -import {useStore} from "App/mstore"; +import { Grid2x2Check } from 'lucide-react'; +import { Button, Modal } from 'antd'; +import Select from 'Shared/Select/Select'; +import { Form } from 'UI'; +import { useStore } from 'App/mstore'; interface Props { - metricId: string; + metricId: string; } -function AddToDashboardButton({metricId}: Props) { - const {dashboardStore} = useStore(); - const dashboardOptions = dashboardStore.dashboards.map((i: any) => ({ - key: i.id, - label: i.name, - value: i.dashboardId, - })); - const [selectedId, setSelectedId] = React.useState(dashboardOptions[0]?.value); +export const showAddToDashboardModal = (metricId: string, dashboardStore: any) => { + const dashboardOptions = dashboardStore.dashboards.map((i: any) => ({ + key: i.id, + label: i.name, + value: i.dashboardId, + })); + let selectedId = dashboardOptions[0]?.value; - const onSave = (close: any) => { - const dashboard = dashboardStore.getDashboard(selectedId) - if (dashboard) { - dashboardStore.addWidgetToDashboard(dashboard, [metricId]).then(close) - } + const onSave = (close: any) => { + const dashboard = dashboardStore.getDashboard(selectedId); + if (dashboard) { + dashboardStore.addWidgetToDashboard(dashboard, [metricId]).then(close); } + }; - const onClick = () => { - Modal.confirm({ - title: 'Add to selected dashboard', - icon: null, - content: ( - - (selectedId = value.value)} + /> + + ), + cancelText: 'Cancel', + onOk: onSave, + okText: 'Add', + footer: (_, { OkBtn, CancelBtn }) => ( + <> + + + + ), + }); +}; - return ( - - ); -} +const AddToDashboardButton = ({ metricId }: Props) => { + const { dashboardStore } = useStore(); -export default AddToDashboardButton; + return ( + + ); +}; + +export default AddToDashboardButton; \ No newline at end of file diff --git a/frontend/app/components/Dashboard/components/Alerts/AlertsList.tsx b/frontend/app/components/Dashboard/components/Alerts/AlertsList.tsx index e88017e11..0338c46d8 100644 --- a/frontend/app/components/Dashboard/components/Alerts/AlertsList.tsx +++ b/frontend/app/components/Dashboard/components/Alerts/AlertsList.tsx @@ -60,8 +60,8 @@ function AlertsList({ siteId }: Props) {
- Showing {Math.min(list.length, pageSize)} out of{' '} - {list.length} Alerts + Showing {Math.min(list.length, pageSize)} out of{' '} + {list.length} Alerts
{ triggerOptions, loading, } = alertsStore + const deleting = loading const webhooks = settingsStore.webhooks const fetchWebhooks = settingsStore.fetchWebhooks diff --git a/frontend/app/components/Dashboard/components/CardUserList/CardUserList.tsx b/frontend/app/components/Dashboard/components/CardUserList/CardUserList.tsx index e8d330da3..e6d7a1a4d 100644 --- a/frontend/app/components/Dashboard/components/CardUserList/CardUserList.tsx +++ b/frontend/app/components/Dashboard/components/CardUserList/CardUserList.tsx @@ -59,8 +59,8 @@ function CardUserList(props: RouteComponentProps) {
- Showing {Math.min(data.length, pageSize)} out of{' '} - {data.length} Issues + Showing {Math.min(data.length, pageSize)} out of{' '} + {data.length} Issues
- - setShowModal(false)} open={showModal}/> - ; + const createNewDashboard = async () => { + setDashboardCreating(true); + dashboardStore.initDashboard(); + await dashboardStore + .save(dashboardStore.dashboardInstance) + .then(async (syncedDashboard) => { + dashboardStore.selectDashboardById(syncedDashboard.dashboardId); + history.push(`/${siteId}/dashboard/${syncedDashboard.dashboardId}`); + }) + .finally(() => { + setDashboardCreating(false); + }); + }; + return ( + <> + + + ); } export default CreateDashboardButton; diff --git a/frontend/app/components/Dashboard/components/DashboardHeader/DashboardHeader.tsx b/frontend/app/components/Dashboard/components/DashboardHeader/DashboardHeader.tsx index 09f2348f6..f2dcefbbb 100644 --- a/frontend/app/components/Dashboard/components/DashboardHeader/DashboardHeader.tsx +++ b/frontend/app/components/Dashboard/components/DashboardHeader/DashboardHeader.tsx @@ -1,40 +1,31 @@ import React from 'react'; -//import {Breadcrumb} from 'Shared/Breadcrumb'; -import BackButton from '../../../shared/Breadcrumb/BackButton'; +import BackButton from 'Shared/Breadcrumb/BackButton'; import { withSiteId } from 'App/routes'; import { withRouter, RouteComponentProps } from 'react-router-dom'; -import { Button, PageTitle, confirm, Tooltip } from 'UI'; +import { PageTitle, confirm } from 'UI'; +import { Tooltip } from 'antd'; import SelectDateRange from 'Shared/SelectDateRange'; import { useStore } from 'App/mstore'; -import { useModal } from 'App/components/Modal'; import DashboardOptions from '../DashboardOptions'; import withModal from 'App/components/Modal/withModal'; import { observer } from 'mobx-react-lite'; import DashboardEditModal from '../DashboardEditModal'; -import CreateDashboardButton from 'Components/Dashboard/components/CreateDashboardButton'; -import CreateCard from 'Components/Dashboard/components/DashboardList/NewDashModal/CreateCard'; -import CreateCardButton from 'Components/Dashboard/components/CreateCardButton'; interface IProps { - dashboardId: string; siteId: string; renderReport?: any; } - type Props = IProps & RouteComponentProps; -const MAX_CARDS = 29; function DashboardHeader(props: Props) { - const { siteId, dashboardId } = props; + const { siteId } = props; const { dashboardStore } = useStore(); - const { showModal } = useModal(); const [focusTitle, setFocusedInput] = React.useState(true); const [showEditModal, setShowEditModal] = React.useState(false); const period = dashboardStore.period; const dashboard: any = dashboardStore.selectedDashboard; - const canAddMore: boolean = dashboard?.widgets?.length <= MAX_CARDS; const onEdit = (isTitle: boolean) => { dashboardStore.initDashboard(dashboard); @@ -47,7 +38,7 @@ function DashboardHeader(props: Props) { await confirm({ header: 'Delete Dashboard', confirmButton: 'Yes, delete', - confirmation: `Are you sure you want to permanently delete this Dashboard?` + confirmation: `Are you sure you want to permanently delete this Dashboard?`, }) ) { dashboardStore.deleteDashboard(dashboard).then(() => { @@ -56,32 +47,26 @@ function DashboardHeader(props: Props) { } }; return ( -
+ <> setShowEditModal(false)} focusTitle={focusTitle} /> -
-
- - - {/* */} +
+
+ + {dashboard?.name} } @@ -89,45 +74,28 @@ function DashboardHeader(props: Props) { className="mr-3 select-none border-b border-b-borderColor-transparent hover:border-dashed hover:border-gray-medium cursor-pointer" />
-
- +
+ dashboardStore.setPeriod(period)} + right={true} + isAnt={true} + useButtonStyle={true} + /> -
- dashboardStore.setPeriod(period)} - right={true} - isAnt={true} - useButtonStyle={true} - /> -
- -
- -
+
-
- {/* @ts-ignore */} - -

onEdit(false)} - > - {/* {dashboard?.description || 'Describe the purpose of this dashboard'} */} -

-
-
-
+ ); } diff --git a/frontend/app/components/Dashboard/components/DashboardList/DashboardList.tsx b/frontend/app/components/Dashboard/components/DashboardList/DashboardList.tsx index 20a44c01f..360e70109 100644 --- a/frontend/app/components/Dashboard/components/DashboardList/DashboardList.tsx +++ b/frontend/app/components/Dashboard/components/DashboardList/DashboardList.tsx @@ -1,4 +1,6 @@ -import { LockOutlined, TeamOutlined } from '@ant-design/icons'; +import { observer } from 'mobx-react-lite'; +import React from 'react'; +import { useHistory } from 'react-router'; import { Empty, Switch, @@ -7,18 +9,16 @@ import { Tag, Tooltip, Typography, + Dropdown, + Button, } from 'antd'; -import { observer } from 'mobx-react-lite'; -import React from 'react'; -import { useHistory } from 'react-router'; - +import { LockOutlined, TeamOutlined, MoreOutlined } from '@ant-design/icons'; import { checkForRecent } from 'App/date'; import { useStore } from 'App/mstore'; import Dashboard from 'App/mstore/types/dashboard'; import { dashboardSelected, withSiteId } from 'App/routes'; import CreateDashboardButton from 'Components/Dashboard/components/CreateDashboardButton'; -import { ItemMenu, confirm } from 'UI'; - +import { Icon, confirm } from 'UI'; import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG'; import DashboardEditModal from '../DashboardEditModal'; @@ -26,6 +26,7 @@ import DashboardEditModal from '../DashboardEditModal'; function DashboardList() { const { dashboardStore, projectsStore } = useStore(); const siteId = projectsStore.siteId; + const optionsRef = React.useRef(null); const [focusTitle, setFocusedInput] = React.useState(true); const [showEditModal, setShowEditModal] = React.useState(false); @@ -103,6 +104,7 @@ function DashboardList() { } checkedChildren={'Team'} unCheckedChildren={'Private'} + className="toggle-team-private" />
@@ -121,23 +123,52 @@ function DashboardList() { }, { - title: 'Options', + title: '', dataIndex: 'dashboardId', width: '5%', - onCell: () => ({ onClick: (e) => e.stopPropagation() }), render: (id) => ( - onEdit(id, true) }, - { - icon: 'users', - text: 'Visibility & Access', - onClick: () => onEdit(id, false), - }, - { icon: 'trash', text: 'Delete', onClick: () => onDelete(id) }, - ]} - /> +
e.stopPropagation()}> + , + key: 'rename', + label: 'Rename', + }, + { + icon: , + key: 'access', + label: 'Visibility & Access', + }, + { + icon: , + key: 'delete', + label: 'Delete', + }, + ], + onClick: async ({ key }) => { + if (key === 'rename') { + onEdit(id, true); + } else if (key === 'access') { + onEdit(id, false); + } else if (key === 'delete') { + await onDelete(id); + } + }, + }} + > +
), }, ]; @@ -198,9 +229,22 @@ function DashboardList() { showTotal: (total, range) => `Showing ${range[0]}-${range[1]} of ${total} items`, size: 'small', + simple: 'true', + className: 'px-4 pr-8 mb-0', }} onRow={(record) => ({ - onClick: () => { + onClick: (e) => { + const possibleDropdown = + document.querySelector('.ant-dropdown-menu'); + const btn = document.querySelector('#ignore-prop'); + if ( + e.target.classList.contains('lucide') || + e.target.id === 'ignore-prop' || + possibleDropdown?.contains(e.target) || + btn?.contains(e.target) + ) { + return; + } dashboardStore.selectDashboardById(record.dashboardId); const path = withSiteId( dashboardSelected(record.dashboardId), diff --git a/frontend/app/components/Dashboard/components/DashboardList/DashboardSearch.tsx b/frontend/app/components/Dashboard/components/DashboardList/DashboardSearch.tsx index 219145c18..355a1d091 100644 --- a/frontend/app/components/Dashboard/components/DashboardList/DashboardSearch.tsx +++ b/frontend/app/components/Dashboard/components/DashboardList/DashboardSearch.tsx @@ -28,7 +28,7 @@ function DashboardSearch() { value={query} allowClear name="dashboardsSearch" - className="w-full" + className="w-full btn-search-dashboard" placeholder="Filter by dashboard title" onChange={write} onSearch={(value) => dashboardStore.updateKey('filter', { ...dashboardStore.filter, query: value })} diff --git a/frontend/app/components/Dashboard/components/DashboardList/NewDashModal/ExampleCards.tsx b/frontend/app/components/Dashboard/components/DashboardList/NewDashModal/ExampleCards.tsx index ddde7facd..bf9c9e812 100644 --- a/frontend/app/components/Dashboard/components/DashboardList/NewDashModal/ExampleCards.tsx +++ b/frontend/app/components/Dashboard/components/DashboardList/NewDashModal/ExampleCards.tsx @@ -9,24 +9,13 @@ import { HEATMAP, ERRORS, FUNNEL, - INSIGHTS, TABLE, TIMESERIES, USER_PATH, - PERFORMANCE, -} from 'App/constants/card'; +} from "App/constants/card"; import { FilterKey } from 'Types/filter/filterType'; import { BarChart, TrendingUp, SearchSlash } from 'lucide-react'; -import ByIssues from 'Components/Dashboard/components/DashboardList/NewDashModal/Examples/SessionsBy/ByIssues'; -import InsightsExample from 'Components/Dashboard/components/DashboardList/NewDashModal/Examples/InsightsExample'; import ByUser from 'Components/Dashboard/components/DashboardList/NewDashModal/Examples/SessionsBy/ByUser'; -import BarChartCard from 'Components/Dashboard/components/DashboardList/NewDashModal/Examples/BarChart'; -import SpeedIndexByLocationExample - from 'Components/Dashboard/components/DashboardList/NewDashModal/Examples/SpeedIndexByLocationExample'; -import CallsWithErrorsExample - from 'Components/Dashboard/components/DashboardList/NewDashModal/Examples/CallsWithErrorsExample'; -import SlowestDomains - from 'Components/Dashboard/components/DashboardList/NewDashModal/Examples/SessionsBy/SlowestDomains'; import HeatmapsExample from 'Components/Dashboard/components/DashboardList/NewDashModal/Examples/HeatmapsExample'; import ByReferrer from 'Components/Dashboard/components/DashboardList/NewDashModal/Examples/SessionsBy/ByRferrer'; import ByFetch from 'Components/Dashboard/components/DashboardList/NewDashModal/Examples/SessionsBy/ByFecth'; @@ -60,7 +49,7 @@ export interface CardType { export const CARD_LIST: CardType[] = [ { - title: 'Funnel', + title: 'Untitled Funnel', key: FUNNEL, cardType: FUNNEL, category: CARD_CATEGORIES[0].key, @@ -92,7 +81,7 @@ export const CARD_LIST: CardType[] = [ } }, { - title: 'Heatmaps', + title: 'Untitled Heatmaps', key: HEATMAP, cardType: HEATMAP, metricOf: 'heatMapUrl', @@ -100,14 +89,14 @@ export const CARD_LIST: CardType[] = [ example: HeatmapsExample }, { - title: 'Path Finder', + title: 'Untitled Journey', key: USER_PATH, cardType: USER_PATH, category: CARD_CATEGORIES[0].key, example: ExamplePath }, { - title: 'Sessions Trend', + title: 'Untitled Trend', key: TIMESERIES, cardType: TIMESERIES, metricOf: 'sessionCount', @@ -122,7 +111,7 @@ export const CARD_LIST: CardType[] = [ example: ExampleTrend }, { - title: 'Users Trend', + title: 'Untitled Users Trend', key: TIMESERIES + '_userCount', cardType: TIMESERIES, metricOf: 'userCount', @@ -140,7 +129,7 @@ export const CARD_LIST: CardType[] = [ // Web analytics { - title: 'Top Users', + title: 'Untitled Top Users', key: FilterKey.USERID, cardType: TABLE, metricOf: FilterKey.USERID, @@ -149,7 +138,7 @@ export const CARD_LIST: CardType[] = [ }, { - title: 'Top Browsers', + title: 'Untitled Top Browsers', key: FilterKey.USER_BROWSER, cardType: TABLE, metricOf: FilterKey.USER_BROWSER, @@ -165,7 +154,7 @@ export const CARD_LIST: CardType[] = [ // example: BySystem, // }, { - title: 'Top Countries', + title: 'Untitled Top Countries', key: FilterKey.USER_COUNTRY, cardType: TABLE, metricOf: FilterKey.USER_COUNTRY, @@ -174,7 +163,7 @@ export const CARD_LIST: CardType[] = [ }, { - title: 'Top Devices', + title: 'Untitled Top Devices', key: FilterKey.USER_DEVICE, cardType: TABLE, metricOf: FilterKey.USER_DEVICE, @@ -182,7 +171,7 @@ export const CARD_LIST: CardType[] = [ example: BySystem }, { - title: 'Top Pages', + title: 'Untitled Top Pages', key: FilterKey.LOCATION, cardType: TABLE, metricOf: FilterKey.LOCATION, @@ -191,7 +180,7 @@ export const CARD_LIST: CardType[] = [ }, { - title: 'Top Referrer', + title: 'Untitled Top Referrer', key: FilterKey.REFERRER, cardType: TABLE, metricOf: FilterKey.REFERRER, @@ -201,7 +190,7 @@ export const CARD_LIST: CardType[] = [ // Monitors { - title: 'Table of Errors', + title: 'Untitled Table of Errors', key: FilterKey.ERRORS, cardType: TABLE, metricOf: FilterKey.ERRORS, @@ -216,7 +205,7 @@ export const CARD_LIST: CardType[] = [ example: TableOfErrors }, { - title: 'Top Network Requests', + title: 'Untitled Top Network Requests', key: FilterKey.FETCH, cardType: TABLE, metricOf: FilterKey.FETCH, @@ -224,7 +213,7 @@ export const CARD_LIST: CardType[] = [ example: ByFetch }, { - title: 'Sessions with 4xx/5xx Requests', + title: 'Untitled Sessions with 4xx/5xx Requests', key: TIMESERIES + '_4xx_requests', cardType: TIMESERIES, metricOf: 'sessionCount', @@ -258,7 +247,7 @@ export const CARD_LIST: CardType[] = [ example: ExampleTrend }, { - title: 'Sessions with Slow Network Requests', + title: 'Untitled Sessions with Slow Network Requests', key: TIMESERIES + '_slow_network_requests', cardType: TIMESERIES, metricOf: 'sessionCount', diff --git a/frontend/app/components/Dashboard/components/DashboardList/NewDashModal/Examples/AreaChartCard.tsx b/frontend/app/components/Dashboard/components/DashboardList/NewDashModal/Examples/AreaChartCard.tsx index 03c083ff2..988311c54 100644 --- a/frontend/app/components/Dashboard/components/DashboardList/NewDashModal/Examples/AreaChartCard.tsx +++ b/frontend/app/components/Dashboard/components/DashboardList/NewDashModal/Examples/AreaChartCard.tsx @@ -57,7 +57,7 @@ function AreaChartCard(props: Props) { margin={Styles.chartMargins} > {gradientDef} - + {/**/} - void; - deleteHandler: any; - renderReport: any; + editHandler: (isTitle: boolean) => void; + deleteHandler: any; + renderReport: any; } function DashboardOptions(props: Props) { - const { userStore } = useStore(); - const isEnterprise = userStore.isEnterprise; - const { editHandler, deleteHandler, renderReport } = props; - const menuItems = [ - { icon: 'pencil', text: 'Rename', onClick: () => editHandler(true) }, - { icon: 'users', text: 'Visibility & Access', onClick: editHandler }, - { icon: 'trash', text: 'Delete', onClick: deleteHandler }, - { icon: 'pdf-download', text: 'Download Report', onClick: renderReport, disabled: !isEnterprise, tooltipTitle: ENTERPRISE_REQUEIRED } - ] + const { userStore } = useStore(); + const isEnterprise = userStore.isEnterprise; + const { editHandler, deleteHandler, renderReport } = props; - return ( - - ); + const menu = { + items: [ + { + icon: , + key: 'rename', + label: 'Rename', + onClick: () => editHandler(true), + }, + { + icon: , + key: 'visibility', + label: 'Visibility & Access', + onClick: editHandler, + }, + { + icon: , + key: 'delete', + label: 'Delete', + onClick: deleteHandler, + }, + { + icon: , + key: 'download', + label: 'Download Report', + onClick: renderReport, + disabled: !isEnterprise, + tooltipTitle: ENTERPRISE_REQUEIRED, + }, + ], + }; + + return ( + +
+ )} - )); + ); } -export default DashboardWidgetGrid; +function GridItem({ item, index, dashboard, dashboardId, siteId }: any) { + const [popoverOpen, setPopoverOpen] = React.useState(false); + const handleOpenChange = (open: boolean) => { + setPopoverOpen(open); + }; + + return ( +
+ + dashboard?.swapWidgetPosition(dragIndex, hoverIndex) + } + dashboardId={dashboardId} + siteId={siteId} + grid="other" + showMenu={true} + isSaved={true} + /> +
+ } + trigger={'click'} + > + +
+
+ ) +} + + +export default observer(DashboardWidgetGrid); diff --git a/frontend/app/components/Dashboard/components/FilterSeries/ExcludeFilters.tsx b/frontend/app/components/Dashboard/components/FilterSeries/ExcludeFilters.tsx index d1d78c63c..398403016 100644 --- a/frontend/app/components/Dashboard/components/FilterSeries/ExcludeFilters.tsx +++ b/frontend/app/components/Dashboard/components/FilterSeries/ExcludeFilters.tsx @@ -28,9 +28,9 @@ function ExcludeFilters(props: Props) { }; return ( -
+
{filter.excludes.length > 0 ? ( -
+
EXCLUDES
{filter.excludes.map((f: any, index: number) => ( { +const FilterCountLabels = observer( + (props: { filters: any; toggleExpand: any }) => { const events = props.filters.filter((i: any) => i && i.isEvent).length; const filters = props.filters.filter((i: any) => i && !i.isEvent).length; - return
+ return ( +
- {events > 0 && ( - - )} + {events > 0 && ( + + )} - {filters > 0 && ( - - )} + {filters > 0 && ( + + )} -
; -}); +
+ ); + } +); -const FilterSeriesHeader = observer((props: { - expanded: boolean, - hidden: boolean, - seriesIndex: number, - series: any, - onRemove: (seriesIndex: any) => void, - canDelete: boolean | undefined, - toggleExpand: () => void -}) => { - - const onUpdate = (name: any) => { - props.series.update('name', name) - } - return
- - - {!props.expanded && - } - - - -
; -}) - -interface Props { +const FilterSeriesHeader = observer( + (props: { + expanded: boolean; + hidden: boolean; seriesIndex: number; series: any; - onRemoveSeries: (seriesIndex: any) => void; - canDelete?: boolean; - supportsEmpty?: boolean; - hideHeader?: boolean; - emptyMessage?: any; - observeChanges?: () => void; - excludeFilterKeys?: Array; - canExclude?: boolean; - expandable?: boolean; + onRemove: (seriesIndex: any) => void; + canDelete: boolean | undefined; + toggleExpand: () => void; + onChange: () => void; + }) => { + const onUpdate = (name: any) => { + props.series.update('name', name); + props.onChange(); + }; + return ( +
+ + null} + /> + + + + {!props.expanded && ( + + )} +
+ ); + } +); + +interface Props { + seriesIndex: number; + series: any; + onRemoveSeries: (seriesIndex: any) => void; + canDelete?: boolean; + supportsEmpty?: boolean; + hideHeader?: boolean; + emptyMessage?: any; + observeChanges?: () => void; + excludeFilterKeys?: Array; + excludeCategory?: string[] + canExclude?: boolean; + expandable?: boolean; + isHeatmap?: boolean; + removeEvents?: boolean; + collapseState: boolean; + onToggleCollapse: () => void; } function FilterSeries(props: Props) { - const { - observeChanges = () => { - }, - canDelete, - hideHeader = false, - emptyMessage = 'Add an event or filter step to define the series.', - supportsEmpty = true, - excludeFilterKeys = [], - canExclude = false, - expandable = false - } = props; - const [expanded, setExpanded] = useState(!expandable); - const {series, seriesIndex} = props; - const [prevLength, setPrevLength] = useState(0); + const { + observeChanges = () => {}, + canDelete, + hideHeader = false, + emptyMessage = 'Add an event or filter step to define the series.', + supportsEmpty = true, + excludeFilterKeys = [], + canExclude = false, + expandable = false, + isHeatmap, + removeEvents, + collapseState, + onToggleCollapse, + excludeCategory + } = props; + const expanded = !collapseState + const setExpanded = onToggleCollapse + const { series, seriesIndex } = props; - useEffect(() => { - if (series.filter.filters.length === 1 && prevLength === 0 && seriesIndex === 0) { - setExpanded(true); - } - setPrevLength(series.filter.filters.length); - }, [series.filter.filters.length]); + const onUpdateFilter = (filterIndex: any, filter: any) => { + series.filter.updateFilter(filterIndex, filter); + observeChanges(); + }; - const onUpdateFilter = (filterIndex: any, filter: any) => { - series.filter.updateFilter(filterIndex, filter); - observeChanges(); - }; + const onFilterMove = (newFilters: any) => { + series.filter.replaceFilters(newFilters.toArray()); + observeChanges(); + }; - const onFilterMove = (newFilters: any) => { - series.filter.replaceFilters(newFilters.toArray()) - observeChanges(); - } + const onChangeEventsOrder = (_: any, { name, value }: any) => { + series.filter.updateKey(name, value); + observeChanges(); + }; - const onChangeEventsOrder = (_: any, {name, value}: any) => { - series.filter.updateKey(name, value); - observeChanges(); - }; + const onRemoveFilter = (filterIndex: any) => { + series.filter.removeFilter(filterIndex); + observeChanges(); + }; - const onRemoveFilter = (filterIndex: any) => { - series.filter.removeFilter(filterIndex); - observeChanges(); - }; + const onAddFilter = (filter: any) => { + series.filter.addFilter(filter); + observeChanges(); + } - return ( -
- {canExclude && } + return ( +
+ {canExclude && } - {!hideHeader && ( -
- ); + {expanded ? ( + <> + {removeEvents ? null : + + } + + + ) : null} +
+ ); } export default observer(FilterSeries); diff --git a/frontend/app/components/Dashboard/components/FilterSeries/SeriesName/SeriesName.tsx b/frontend/app/components/Dashboard/components/FilterSeries/SeriesName/SeriesName.tsx index 54f94aff9..61dbfec7e 100644 --- a/frontend/app/components/Dashboard/components/FilterSeries/SeriesName/SeriesName.tsx +++ b/frontend/app/components/Dashboard/components/FilterSeries/SeriesName/SeriesName.tsx @@ -1,61 +1,71 @@ import React, { useState, useRef, useEffect } from 'react'; -import { Icon } from 'UI'; -import {Input, Tooltip} from 'antd'; +import { Input, Tooltip } from 'antd'; interface Props { name: string; - onUpdate: (name) => void; + onUpdate: (name: string) => void; + onChange: () => void; seriesIndex?: number; } + function SeriesName(props: Props) { const { seriesIndex = 1 } = props; - const [editing, setEditing] = useState(false) - const [name, setName] = useState(props.name) - const ref = useRef(null) + const [editing, setEditing] = useState(false); + const [name, setName] = useState(props.name); + const ref = useRef(null); - const write = ({ target: { value, name } }) => { - setName(value) - } + const write = ({ target: { value } }) => { + setName(value); + props.onChange(); + }; const onBlur = () => { - setEditing(false) - props.onUpdate(name) - } + setEditing(false); + props.onUpdate(name); + }; + + const onKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + setEditing(false); + props.onUpdate(name); + } + }; useEffect(() => { if (editing) { - ref.current.focus() + ref.current.focus(); } - }, [editing]) + }, [editing]); useEffect(() => { - setName(props.name) - }, [props.name]) - - // const { name } = props; + setName(props.name); + }, [props.name]); + return (
- { editing ? ( + {editing ? ( setEditing(true)} - className='bg-white' + onKeyDown={onKeyDown} + className="bg-white text-lg border-transparent rounded-lg font-medium ps-2 input-rename-series" + maxLength={22} + size='small' /> ) : ( -
{name && name.trim() === '' ? 'Series ' + (seriesIndex + 1) : name }
- )} - - -
setEditing(true)}> - - + +
setEditing(true)} + data-event='input-rename-series' + > + {name && name.trim() === '' ? 'Series ' + (seriesIndex + 1) : name} +
-
+ )}
); } diff --git a/frontend/app/components/Dashboard/components/MetricListItem/MetricListItem.tsx b/frontend/app/components/Dashboard/components/MetricListItem/MetricListItem.tsx index 2f22e6ead..8cb522b24 100644 --- a/frontend/app/components/Dashboard/components/MetricListItem/MetricListItem.tsx +++ b/frontend/app/components/Dashboard/components/MetricListItem/MetricListItem.tsx @@ -1,46 +1,66 @@ import React, { useEffect, useState } from 'react'; -import { Icon, Modal } from 'UI'; -import { Tooltip, Input, Button, Dropdown, Menu, Tag, Modal as AntdModal, Form, Avatar } from 'antd'; -import { TeamOutlined, LockOutlined, EditOutlined, DeleteOutlined, MoreOutlined } from '@ant-design/icons'; +import { Icon } from 'UI'; +import { + Tooltip, + Input, + Button, + Dropdown, + Tag, + Modal as AntdModal, + Avatar, +} from 'antd'; +import { + TeamOutlined, + LockOutlined, + EditOutlined, + DeleteOutlined, +} from '@ant-design/icons'; import { RouteComponentProps } from 'react-router-dom'; import { withSiteId } from 'App/routes'; -import { TYPES } from 'App/constants/card'; +import { TYPE_ICONS, TYPE_NAMES } from 'App/constants/card'; import { useStore } from 'App/mstore'; import { observer } from 'mobx-react-lite'; import { toast } from 'react-toastify'; import { useHistory } from 'react-router'; +import { EllipsisVertical } from 'lucide-react'; +import cn from 'classnames' interface Props extends RouteComponentProps { metric: any; siteId: string; - selected?: boolean; toggleSelection?: any; disableSelection?: boolean; renderColumn: string; + inLibrary?: boolean; } function MetricTypeIcon({ type }: any) { - const [card, setCard] = useState(''); - useEffect(() => { - const t = TYPES.find((i) => i.slug === type); - setCard(t || {}); - }, [type]); - return ( - {card.title}
}> - } size="small" className="bg-tealx-lightest mr-2" /> + {TYPE_NAMES[type]}
}> + + } + size="default" + className="bg-tealx-lightest text-tealx mr-2 cursor-default avatar-card-list-item" + /> ); } const MetricListItem: React.FC = ({ - metric, - siteId, - toggleSelection = () => { - }, - disableSelection = false, - renderColumn - }) => { + metric, + siteId, + toggleSelection = () => {}, + disableSelection = false, + renderColumn, + inLibrary, +}) => { const history = useHistory(); const { metricStore } = useStore(); const [isEdit, setIsEdit] = useState(false); @@ -67,7 +87,7 @@ const MetricListItem: React.FC = ({ cancelText: 'No', onOk: async () => { await metricStore.delete(metric); - } + }, }); } if (key === 'rename') { @@ -132,29 +152,34 @@ const MetricListItem: React.FC = ({ } else if (diffDays <= 3) { return `${diffDays} days ago at ${formatTime(date)}`; } else { - return `${date.getDate()}/${date.getMonth() + 1}/${date.getFullYear()} at ${formatTime(date)}`; + return `${date.getDate()}/${ + date.getMonth() + 1 + }/${date.getFullYear()} at ${formatTime(date)}`; } }; const menuItems = [ { - key: "rename", + key: 'rename', icon: , - label: "Rename" + label: 'Rename', }, { - key: "delete", + key: 'delete', icon: , - label: "Delete" - } - ] + label: 'Delete', + }, + ]; switch (renderColumn) { case 'title': return ( <> -
+
-
{metric.name}
+
{metric.name}
{renderModal()} @@ -165,7 +190,11 @@ const MetricListItem: React.FC = ({ return (
- {metric.isPublic ? : } + {metric.isPublic ? ( + + ) : ( + + )} {metric.isPublic ? 'Team' : 'Private'}
@@ -176,13 +205,18 @@ const MetricListItem: React.FC = ({ case 'options': return ( <> -
- -
{renderModal()} diff --git a/frontend/app/components/Dashboard/components/MetricTypeSelector.tsx b/frontend/app/components/Dashboard/components/MetricTypeSelector.tsx new file mode 100644 index 000000000..e2f853c45 --- /dev/null +++ b/frontend/app/components/Dashboard/components/MetricTypeSelector.tsx @@ -0,0 +1,81 @@ +import React from 'react'; +import { Segmented } from 'antd'; +import { useStore } from 'App/mstore'; +import { observer } from 'mobx-react-lite'; +import { tabItems } from 'Components/Dashboard/components/AddCardSection/AddCardSection' +import { + CARD_LIST, +} from 'Components/Dashboard/components/DashboardList/NewDashModal/ExampleCards'; +import FilterSeries from "App/mstore/types/filterSeries"; +import { FUNNEL, USER_PATH } from "App/constants/card"; + +function MetricTypeSelector() { + const { metricStore } = useStore(); + const [selectedCategory, setSelectedCategory] = React.useState(null); + const metric = metricStore.instance; + const cardCategory = metricStore.cardCategory; + if (!cardCategory) { + return null; + } + const options = tabItems[cardCategory].map(opt => ({ + value: opt.type, + icon: opt.icon, + })) + React.useEffect(() => { + const selected = metric.category ? { value: metric.category } : options.find( + (i) => { + if (metric.metricType === 'table') { + return i.value === metric.metricOf; + } + return i.value === metric.metricType + }) + if (selected) { + setSelectedCategory(selected.value); + } + }, []) + + const onChange = (type: string) => { + const selectedCard = CARD_LIST.find((i) => i.key === type); + + if (selectedCard) { + setSelectedCategory(type); + metricStore.init(); + const cardData: Record = { + metricType: selectedCard.cardType, + name: selectedCard.title, + metricOf: selectedCard.metricOf, + series: [new FilterSeries()], + category: type, + } + if (selectedCard.filters) { + cardData.series = [ + new FilterSeries().fromJson({ + name: "Series 1", + filter: { + filters: selectedCard.filters, + } + }) + ]; + } + if (selectedCard.cardType === USER_PATH) { + cardData.series = []; + cardData.series.push(new FilterSeries()); + } + if (selectedCard.cardType === FUNNEL) { + cardData.series = []; + cardData.series.push(new FilterSeries()); + cardData.series[0].filter.addFunnelDefaultFilters(); + cardData.series[0].filter.eventsOrder = 'then'; + cardData.series[0].filter.eventsOrderSupport = ['then']; + } + + metricStore.merge(cardData); + } + }; + + return ( + + ); +} + +export default observer(MetricTypeSelector); diff --git a/frontend/app/components/Dashboard/components/MetricViewHeader/MetricViewHeader.tsx b/frontend/app/components/Dashboard/components/MetricViewHeader/MetricViewHeader.tsx index ac9d69bdd..bc1191654 100644 --- a/frontend/app/components/Dashboard/components/MetricViewHeader/MetricViewHeader.tsx +++ b/frontend/app/components/Dashboard/components/MetricViewHeader/MetricViewHeader.tsx @@ -1,142 +1,88 @@ import React, { useEffect } from 'react'; -import { PageTitle, Toggler, Icon } from "UI"; -import { Segmented, Button } from 'antd'; -import { PlusOutlined } from '@ant-design/icons'; +import { PageTitle } from 'UI'; +import { Button, Popover, Space, Dropdown, Menu } from 'antd'; +import { PlusOutlined, DownOutlined } from '@ant-design/icons'; +import AddCardSection from '../AddCardSection/AddCardSection'; import MetricsSearch from '../MetricsSearch'; -import Select from 'Shared/Select'; import { useStore } from 'App/mstore'; -import { observer, useObserver } from 'mobx-react-lite'; +import { observer } from 'mobx-react-lite'; import { DROPDOWN_OPTIONS } from 'App/constants/card'; -import AddCardModal from 'Components/Dashboard/components/AddCardModal'; -import { useModal } from 'Components/Modal'; -import AddCardSelectionModal from "Components/Dashboard/components/AddCardSelectionModal"; -import NewDashboardModal from "Components/Dashboard/components/DashboardList/NewDashModal"; -function MetricViewHeader({ siteId }: { siteId: string }) { - const { metricStore } = useStore(); - const filter = metricStore.filter; - const { showModal } = useModal(); - const [showAddCardModal, setShowAddCardModal] = React.useState(false); +const options = [ + { + key: 'all', + label: 'All Types', + }, + ...DROPDOWN_OPTIONS.map((option) => ({ + key: option.value, + label: option.label, + })), + { + key: 'monitors', + label: 'Monitors', + }, + { + key: 'web_analytics', + label: 'Web Analytics', + }, +] - // Set the default sort order to 'desc' - useEffect(() => { - metricStore.updateKey('sort', { by: 'desc' }); - }, [metricStore]); +function MetricViewHeader() { + const { metricStore } = useStore(); + const filter = metricStore.filter; - return ( -
-
-
- -
-
- -
- -
-
-
+ useEffect(() => { + metricStore.updateKey('sort', { by: 'desc' }); + }, [metricStore]); + const handleMenuClick = ({ key }) => { + metricStore.updateKey('filter', { ...filter, type: key }); + }; -
-
- onChange(value)} - isMulti={true} - color='black' - /> - ); -} - -function ListViewToggler() { - const { metricStore } = useStore(); - const listView = useObserver(() => metricStore.listView); - return ( -
- - -
List
-
, - value: 'list' - }, - { - label:
- -
Grid
-
, - value: 'grid' - } - ]} - onChange={(val) => { - metricStore.updateKey('listView', val === 'list') - }} - value={listView ? 'list' : 'grid'} - /> -
- ); -} diff --git a/frontend/app/components/Dashboard/components/MetricsLibraryModal/MetricsLibraryModal.tsx b/frontend/app/components/Dashboard/components/MetricsLibraryModal/MetricsLibraryModal.tsx index a269fd915..0ef6a279e 100644 --- a/frontend/app/components/Dashboard/components/MetricsLibraryModal/MetricsLibraryModal.tsx +++ b/frontend/app/components/Dashboard/components/MetricsLibraryModal/MetricsLibraryModal.tsx @@ -5,6 +5,7 @@ import { Icon } from 'UI'; import { useStore } from 'App/mstore'; import { observer } from 'mobx-react-lite'; import FooterContent from './FooterContent'; +import { Input } from 'antd' interface Props { dashboardId?: number; @@ -46,7 +47,7 @@ function MetricsLibraryModal(props: Props) {
- +
@@ -61,12 +62,11 @@ export default observer(MetricsLibraryModal); function MetricSearch({ onChange }: any) { return (
- -
); diff --git a/frontend/app/components/Dashboard/components/MetricsList/ListView.tsx b/frontend/app/components/Dashboard/components/MetricsList/ListView.tsx index 07ffedd7e..e0a962f49 100644 --- a/frontend/app/components/Dashboard/components/MetricsList/ListView.tsx +++ b/frontend/app/components/Dashboard/components/MetricsList/ListView.tsx @@ -1,28 +1,33 @@ import React, { useState, useMemo } from 'react'; -import { Checkbox, Table, Typography } from 'antd'; +import { Checkbox, Table, Typography, Switch, Tag, Tooltip } from 'antd'; import MetricListItem from '../MetricListItem'; import { TablePaginationConfig, SorterResult } from 'antd/lib/table/interface'; import Widget from 'App/mstore/types/widget'; +import { LockOutlined, TeamOutlined } from "@ant-design/icons"; +import classNames from 'classnames'; const { Text } = Typography; -// interface Metric { -// metricId: number; -// name: string; -// owner: string; -// lastModified: string; -// visibility: string; -// } +interface Metric { + metricId: number; + name: string; + owner: string; + lastModified: string; + visibility: string; +} interface Props { list: Widget[]; siteId: string; selectedList: number[]; - toggleSelection?: (metricId: number) => void; + toggleSelection?: (metricId: number | Array) => void; toggleAll?: (e: any) => void; disableSelection?: boolean; allSelected?: boolean; existingCardIds?: number[]; + showOwn?: boolean; + toggleOwn: () => void; + inLibrary?: boolean; } const ListView: React.FC = (props: Props) => { @@ -32,8 +37,7 @@ const ListView: React.FC = (props: Props) => { selectedList, toggleSelection, disableSelection = false, - allSelected = false, - toggleAll + inLibrary = false } = props; const [sorter, setSorter] = useState<{ field: string; order: 'ascend' | 'descend' }>({ field: 'lastModified', @@ -66,7 +70,7 @@ const ListView: React.FC = (props: Props) => { const paginatedData = useMemo(() => { const start = (pagination.current! - 1) * pagination.pageSize!; const end = start + pagination.pageSize!; - return sortedData.slice(start, end); + return sortedData.slice(start, end).map(metric => ({ ...metric, key: metric.metricId})); }, [sortedData, pagination]); const handleTableChange = ( @@ -84,34 +88,19 @@ const ListView: React.FC = (props: Props) => { const columns = [ { - title: ( -
- {!disableSelection && ( - - )} - Title -
- ), + title: 'Title', dataIndex: 'name', key: 'title', - className: 'cap-first', + className: 'cap-first pl-4', sorter: true, + width: '25%', render: (text: string, metric: Metric) => ( { - e.stopPropagation(); - toggleSelection && toggleSelection(metric.metricId); - }} + inLibrary={inLibrary} + disableSelection={!inLibrary} renderColumn="title" /> ) @@ -121,7 +110,7 @@ const ListView: React.FC = (props: Props) => { dataIndex: 'owner', key: 'owner', className: 'capitalize', - width: '30%', + width: '25%', sorter: true, render: (text: string, metric: Metric) => ( = (props: Props) => { dataIndex: 'lastModified', key: 'lastModified', sorter: true, - width: '16.67%', + width: '25%', render: (text: string, metric: Metric) => ( = (props: Props) => { /> ) }, - // { - // title: 'Visibility', - // dataIndex: 'visibility', - // key: 'visibility', - // width: '10%', - // render: (text: string, metric: Metric) => ( - // - // ) - // }, - { - title: '', - key: 'options', - className: 'text-right', - width: '5%', - render: (text: string, metric: Metric) => ( - - ) - } ]; + if (!inLibrary) { + columns.push({ + title: '', + key: 'options', + className: 'text-right', + width: '5%', + render: (text: string, metric: Metric) => ( + + ) + }) + } else { + columns.forEach(col => { + col.width = '31%'; + }) + } return ( = (props: Props) => { dataSource={paginatedData} rowKey="metricId" onChange={handleTableChange} - size='middle' + onRow={inLibrary ? (record) => ({ + onClick: () => disableSelection ? null : toggleSelection?.(record.metricId) + }) : undefined} rowSelection={ !disableSelection ? { - selectedRowKeys: selectedList.map((id: number) => id.toString()), + selectedRowKeys: selectedList, onChange: (selectedRowKeys) => { - selectedRowKeys.forEach((key: any) => { - toggleSelection && toggleSelection(parseInt(key)); - }); - } + toggleSelection(selectedRowKeys); + }, + columnWidth: 16, } : undefined } - // footer={() => ( - //
- // - // Select All - // - //
- // )} pagination={{ current: pagination.current, pageSize: pagination.pageSize, @@ -211,7 +186,8 @@ const ListView: React.FC = (props: Props) => { className: 'px-4', showLessItems: true, showTotal: () => totalMessage, - showQuickJumper: true + size: 'small', + simple: 'true', }} /> ); diff --git a/frontend/app/components/Dashboard/components/MetricsList/MetricsList.tsx b/frontend/app/components/Dashboard/components/MetricsList/MetricsList.tsx index e0ceafa7e..0400e90a5 100644 --- a/frontend/app/components/Dashboard/components/MetricsList/MetricsList.tsx +++ b/frontend/app/components/Dashboard/components/MetricsList/MetricsList.tsx @@ -1,6 +1,6 @@ -import { observer, useObserver } from 'mobx-react-lite'; +import { observer } from 'mobx-react-lite'; import React, { useEffect, useMemo, useState } from 'react'; -import { NoContent, Pagination, Icon, Loader } from 'UI'; +import { NoContent, Loader } from 'UI'; import { useStore } from 'App/mstore'; import { sliceListPerPage } from 'App/utils'; import GridView from './GridView'; @@ -8,24 +8,37 @@ import ListView from './ListView'; import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG'; function MetricsList({ - siteId, - onSelectionChange - }: { + siteId, + onSelectionChange, + inLibrary, +}: { siteId: string; onSelectionChange?: (selected: any[]) => void; + inLibrary?: boolean; }) { const { metricStore, dashboardStore } = useStore(); const metricsSearch = metricStore.filter.query; - const listView = useObserver(() => metricStore.listView); + const listView = inLibrary ? true : metricStore.listView; const [selectedMetrics, setSelectedMetrics] = useState([]); const dashboard = dashboardStore.selectedDashboard; - const existingCardIds = useMemo(() => dashboard?.widgets?.map(i => parseInt(i.metricId)), [dashboard]); - const cards = useMemo(() => !!onSelectionChange ? metricStore.filteredCards.filter(i => !existingCardIds?.includes(parseInt(i.metricId))) : metricStore.filteredCards, [metricStore.filteredCards]); + const existingCardIds = useMemo( + () => dashboard?.widgets?.map((i) => parseInt(i.metricId)), + [dashboard] + ); + const cards = useMemo( + () => + !!onSelectionChange + ? metricStore.filteredCards.filter( + (i) => !existingCardIds?.includes(parseInt(i.metricId)) + ) + : metricStore.filteredCards, + [metricStore.filteredCards] + ); const loading = metricStore.isLoading; useEffect(() => { - metricStore.fetchList(); + void metricStore.fetchList(); }, []); useEffect(() => { @@ -36,42 +49,59 @@ function MetricsList({ }, [selectedMetrics]); const toggleMetricSelection = (id: any) => { + if (Array.isArray(id)) { + setSelectedMetrics(id); + return + } if (selectedMetrics.includes(id)) { - setSelectedMetrics(selectedMetrics.filter((i: number) => i !== id)); + setSelectedMetrics((prev) => prev.filter((i: number) => i !== id)); } else { - setSelectedMetrics([...selectedMetrics, id]); + setSelectedMetrics((prev) => [...prev, id]); } }; - const lenth = cards.length; + const length = cards.length; useEffect(() => { metricStore.updateKey('sessionsPage', 1); }, []); + const showOwn = metricStore.filter.showMine; + const toggleOwn = () => { + metricStore.updateKey('showMine', !showOwn); + } return (
- {metricsSearch !== '' ? 'No matching results' : 'You haven\'t created any cards yet'} + {metricsSearch !== '' + ? 'No matching results' + : "You haven't created any cards yet"}
} - subtext="Utilize cards to visualize key user interactions or product performance metrics." + subtext={ + metricsSearch !== '' + ? '' + : 'Utilize cards to visualize key user interactions or product performance metrics.' + } > {listView ? ( setSelectedMetrics(checked ? cards.map((i: any) => i.metricId).slice(0, 30 - existingCardIds!.length) : []) } @@ -87,8 +117,8 @@ function MetricsList({
Showing{' '} - {Math.min(cards.length, metricStore.pageSize)} out - of {cards.length} cards + {Math.min(cards.length, metricStore.pageSize)} out + of {cards.length} cards
diff --git a/frontend/app/components/Dashboard/components/MetricsView/MetricsView.tsx b/frontend/app/components/Dashboard/components/MetricsView/MetricsView.tsx index 9c799b225..98fa79052 100644 --- a/frontend/app/components/Dashboard/components/MetricsView/MetricsView.tsx +++ b/frontend/app/components/Dashboard/components/MetricsView/MetricsView.tsx @@ -11,7 +11,9 @@ function MetricsView({ siteId }: Props) { return (
+
+
); } diff --git a/frontend/app/components/Dashboard/components/SessionsModal/SessionsModal.tsx b/frontend/app/components/Dashboard/components/SessionsModal/SessionsModal.tsx index 7edb5fa97..8655c09a8 100644 --- a/frontend/app/components/Dashboard/components/SessionsModal/SessionsModal.tsx +++ b/frontend/app/components/Dashboard/components/SessionsModal/SessionsModal.tsx @@ -64,8 +64,8 @@ function SessionsModal(props: Props) {
Showing {Math.min(length, 10)} out of{' '} - {total} Issues + className='font-medium'>{Math.min(length, 10)} out of{' '} + {total} Issues
void }) { + return ( +
+
+ +
Processing data...
+
+
+ +
+
+ This is taking longer than expected. +
+
+ Use sample data to speed up query and get a faster response. +
+ +
+ ) +} + +export default LongLoader; \ No newline at end of file diff --git a/frontend/app/components/Dashboard/components/WidgetChart/WidgetChart.tsx b/frontend/app/components/Dashboard/components/WidgetChart/WidgetChart.tsx index 960431b35..50044f983 100644 --- a/frontend/app/components/Dashboard/components/WidgetChart/WidgetChart.tsx +++ b/frontend/app/components/Dashboard/components/WidgetChart/WidgetChart.tsx @@ -1,274 +1,570 @@ -import React, {useState, useRef, useEffect} from 'react'; -import CustomMetricLineChart from 'App/components/Dashboard/Widgets/CustomMetricsWidgets/CustomMetricLineChart'; +import React, { useState, useRef, useEffect } from 'react'; +import LineChart from 'App/components/Charts/LineChart' +import BarChart from 'App/components/Charts/BarChart' +import PieChart from 'App/components/Charts/PieChart' +import ColumnChart from 'App/components/Charts/ColumnChart' +import SankeyChart from 'Components/Charts/SankeyChart'; + 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 FunnelTable from "../../../Funnels/FunnelWidget/FunnelTable"; +import BugNumChart from '../../Widgets/CustomMetricsWidgets/BigNumChart'; +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'; import CustomMetricTableErrors from 'App/components/Dashboard/Widgets/CustomMetricsWidgets/CustomMetricTableErrors'; import ClickMapCard from 'App/components/Dashboard/Widgets/CustomMetricsWidgets/ClickMapCard'; import InsightsCard from 'App/components/Dashboard/Widgets/CustomMetricsWidgets/InsightsCard'; -import SankeyChart from 'Shared/Insights/SankeyChart'; +import { filterMinorPaths } from 'Shared/Insights/SankeyChart/utils' 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'; +import LongLoader from "./LongLoader"; 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 { ref, inView } = useInView({ + triggerOnce: true, + rootMargin: '200px 0px', + }); + const { isSaved = false, metric, isTemplate } = props; + const { dashboardStore, metricStore } = useStore(); + const _metric: any = props.isPreview ? metricStore.instance : props.metric; + const data = _metric.data; + const period = dashboardStore.period; + const drillDownPeriod = dashboardStore.drillDownPeriod; + const drillDownFilter = dashboardStore.drillDownFilter; + const colors = Styles.safeColors; + const [loading, setLoading] = useState(true); + const [stale, setStale] = useState(false); + const params = { density: dashboardStore.selectedDensity }; + const metricParams = _metric.params; + const prevMetricRef = useRef(); + const isMounted = useIsMounted(); + const [compData, setCompData] = useState(null); + const [enabledRows, setEnabledRows] = useState([]); + 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(); - }; - }, []); - - 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.setComparisonPeriod(null, _metric.metricId); + 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(() => { + if (!data.chart) return; + const series = data.chart[0] + ? Object.keys(data.chart[0]).filter( + (key) => key !== 'time' && key !== 'timestamp' + ) + : []; + if (series.length) { + setEnabledRows(series); + } + }, [data.chart]); + + const onChartClick = (event: any) => { + metricStore.setDrillDown(true); + 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 loadSample = () => console.log('clicked') - const renderChart = () => { - const {metricType, viewType, metricOf} = metric; - const metricWithData = {...metric, data}; + const depsString = JSON.stringify({ + ..._metric.series, + ..._metric.excludes, + ..._metric.startPoint, + hideExcess: false, + }); + const fetchMetricChartData = ( + metric: any, + payload: any, + isSaved: any, + period: any, + isComparison?: boolean + ) => { + if (!isMounted()) return; + setLoading(true); + const tm = setTimeout(() => { + setStale(true) + }, 4000) + dashboardStore + .fetchMetricChartData(metric, payload, isSaved, period, isComparison) + .then((res: any) => { + if (isComparison) setCompData(res); + // /65/metrics/1014 + if (metric.metricId === 1014) return; + clearTimeout(tm) + setStale(false) + }) + .finally(() => { + if (metric.metricId === 1014) return; + setLoading(false); + }); + }; - 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 + ? { ...metricParams } + : { ...params, ...timestmaps, ..._metric.toJson() }; + debounceRequest( + _metric, + payload, + isSaved, + !isSaved ? drillDownPeriod : period ); + }; + + const loadComparisonData = () => { + if (!dashboardStore.comparisonPeriods[_metric.metricId]) return setCompData(null); + + // TODO: remove after backend adds support for more view types + const payload = { + ...params, + ..._metric.toJson(), + viewType: 'lineChart', + }; + fetchMetricChartData( + _metric, + payload, + isSaved, + dashboardStore.comparisonPeriods[_metric.metricId], + true + ); + }; + useEffect(() => { + if (!inView || !props.isPreview) return; + loadComparisonData(); + }, [ + dashboardStore.comparisonPeriods[_metric.metricId], + _metric.metricId, + inView, + props.isPreview, + drillDownPeriod, + period, + depsString, + dashboardStore.selectedDensity, + ]); + useEffect(() => { + setCompData(null); + _metric.updateKey('page', 1); + _metric.updateKey() + loadPage(); + }, [ + drillDownPeriod, + period, + depsString, + dashboardStore.selectedDensity, + _metric.metricType, + _metric.metricOf, + _metric.metricValue, + _metric.startType, + _metric.metricFormat, + inView, + ]); + useEffect(loadPage, [_metric.page]); + + const onFocus = (seriesName: string)=> { + metricStore.setFocusedSeriesName(seriesName); + metricStore.setDrillDown(true) + } + + const renderChart = React.useCallback(() => { + const { metricType, metricOf } = _metric; + const viewType = _metric.viewType; + const metricWithData = { ..._metric, data }; + + if (metricType === FUNNEL) { + if (viewType === 'table') { + return ( + + ) + } + if (viewType === 'metric') { + const values: { + value: number; + compData?: number; + series: string; + valueLabel?: string; + }[] = [ + { + value: data.funnel.totalConversionsPercentage, + compData: compData + ? compData.funnel.totalConversionsPercentage + : undefined, + series: 'Dynamic', + valueLabel: '%' + }, + ]; + + return ( + + ); + } + + 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; + const compDataCopy = { ...compData }; + compDataCopy.namesMap = Array.isArray(compDataCopy.namesMap) + ? compDataCopy.namesMap.map((n) => + enabledRows.includes(n) ? n : null + ) + : compDataCopy.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 (viewType === 'metric') { + const values: { value: number, compData?: number, series: string }[] = []; + for (let i = 0; i < data.namesMap.length; i++) { + if (!data.namesMap[i]) { + continue; + } + + values.push({ + value: data.chart.reduce((acc, curr) => acc + curr[data.namesMap[i]], 0), + compData: compData ? compData.chart.reduce((acc, curr) => acc + curr[compData.namesMap[i]], 0) : undefined, + series: data.namesMap[i], + }); + } + + return ( + + ); + } + } + + 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) { + const usedData = _metric.hideExcess ? filterMinorPaths(data) : data; + return ( + { + dashboardStore.drillDownFilter.merge({ filters, page: 1 }); + }} + /> + ); + } + + if (metricType === RETENTION) { + if (viewType === 'trend') { + return ( + + ); + } else if (viewType === 'cohort') { + return ; + } + } + console.log('Unknown metric type', metricType); + return
Unknown metric type
; + }, [data, compData, enabledRows, _metric]); + + + const showTable = _metric.metricType === TIMESERIES && (props.isPreview || _metric.viewType === TABLE) + return ( +
+ {loading ? stale ? : : ( +
+ {renderChart()} + {showTable ? ( + + ) : 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..08637a70d --- /dev/null +++ b/frontend/app/components/Dashboard/components/WidgetDatatable/WidgetDatatable.tsx @@ -0,0 +1,181 @@ +import { Button, Table, Divider } from 'antd'; +import type { TableProps } from 'antd'; + +import { Eye, EyeOff } from 'lucide-react'; +import cn from 'classnames'; +import React, { useState } from 'react'; +import { TableExporter } from 'Components/Funnels/FunnelWidget/FunnelTable'; + +const initTableProps = [ + { + title: Series, + dataIndex: 'seriesName', + key: 'seriesName', + sorter: (a, b) => a.seriesName.localeCompare(b.seriesName), + fixed: 'left', + }, + { + title: Avg., + dataIndex: 'average', + key: 'average', + sorter: (a, b) => a.average - b.average, + fixed: 'left', + }, +]; + +interface Props { + data: { chart: any[]; namesMap: string[] }; + compData?: { chart: any[]; namesMap: string[] }; + enabledRows: string[]; + setEnabledRows: (rows: string[]) => void; + defaultOpen?: boolean; + metric: { name: string; viewType: string }; + inBuilder?: boolean; +} + +function WidgetDatatable(props: Props) { + const [tableProps, setTableProps] = + useState(initTableProps); + const data = React.useMemo(() => { + const dataObj = { ...props.data } + if (props.compData) { + dataObj.chart = dataObj.chart.map((item, i) => { + const compItem = props.compData!.chart[i]; + const newItem = { ...item }; + Object.keys(compItem).forEach((key) => { + if (key !== 'timestamp' && key !== 'time') { + newItem[key] = compItem[key]; + } + }); + return newItem; + }); + const blank = new Array(dataObj.namesMap.length * 2).fill(''); + dataObj.namesMap = blank.map((_, i) => { + return i % 2 !== 0 + ? `Previous ${dataObj.namesMap[i / 2]}` + : dataObj.namesMap[i / 2]; + }) + } + return dataObj + }, [props.data, props.compData]); + + const [showTable, setShowTable] = useState(props.defaultOpen); + const [tableData, setTableData] = useState([]); + + const columnNames = []; + const series = !data.chart[0] + ? [] + : data.namesMap; + + React.useEffect(() => { + if (!data.chart) return; + setTableProps(initTableProps); + columnNames.length = data.chart.length; + // for example: mon, tue, wed, thu, fri, sat, sun + data.chart.forEach((p: any, i) => { + columnNames[i] = p.time; + }); + + // as many items (rows) as we have series in filter + const items: Record[] = []; + series.forEach((s, i) => { + items.push({ seriesName: s, average: 0, key: s }); + }); + const tableCols: { + title: React.ReactNode; + dataIndex: string; + key: string; + sorter: any; + }[] = []; + columnNames.forEach((name: string, i) => { + tableCols.push({ + title: {name}, + dataIndex: name+'_'+i, + key: name+'_'+i, + sorter: (a, b) => a[name+'_'+i] - b[name+'_'+i], + }); + const values = data.chart[i]; + series.forEach((s) => { + const ind = items.findIndex((item) => item.seriesName === s); + if (ind === -1) return; + items[ind][name+'_'+i] = values[s]; + }); + }); + // calculating averages for each row + items.forEach((item) => { + const itemsLen = columnNames.length; + const keys = Object.keys(item).filter(k => !['seriesName', 'key', 'average'].includes(k)); + let sum = 0; + const values = keys.map(k => item[k]); + values.forEach((v) => { + sum += v; + }); + item.average = (sum / itemsLen).toFixed(1); + }); + + setTableProps((prev) => [...prev, ...tableCols]); + setTableData(items); + props.setEnabledRows(data.namesMap); + }, [data.chart]); + + const rowSelection: TableProps['rowSelection'] = { + selectedRowKeys: props.enabledRows, + onChange: (selectedRowKeys: React.Key[]) => { + props.setEnabledRows(selectedRowKeys as string[]); + }, + getCheckboxProps: (record: any) => ({ + name: record.name, + checked: false, + }), + type: 'checkbox', + }; + + const isTableOnlyMode = props.metric.viewType === 'table'; + return ( +
+ {!isTableOnlyMode && ( +
+ + + +
+ )} + + {showTable || isTableOnlyMode ? ( +
+
+ {props.inBuilder ? ( + + ) : null} + + ) : null} + + ); +} + +export default WidgetDatatable; diff --git a/frontend/app/components/Dashboard/components/WidgetDateRange/RangeGranularity.tsx b/frontend/app/components/Dashboard/components/WidgetDateRange/RangeGranularity.tsx new file mode 100644 index 000000000..588843656 --- /dev/null +++ b/frontend/app/components/Dashboard/components/WidgetDateRange/RangeGranularity.tsx @@ -0,0 +1,82 @@ +import React from 'react' +import { DownOutlined } from "@ant-design/icons"; +import { Button, Dropdown, MenuProps } from 'antd'; + + +function RangeGranularity({ + period, + density, + onDensityChange +}: { + period: { + getDuration(): number; + }, + density: number, + onDensityChange: (density: number) => void +}) { + const granularityOptions = React.useMemo(() => { + if (!period) return [] + return calculateGranularities(period.getDuration()); + }, [period]); + + + const menuProps: MenuProps = { + items: granularityOptions, + onClick: (item: any) => onDensityChange(Number(item.key)), + } + const selected = React.useMemo(() => { + let selected = 'Custom'; + for (const option of granularityOptions) { + if (option.key === density) { + selected = option.label; + break; + } + } + return selected; + }, [period, density]) + + React.useEffect(() => { + if (granularityOptions.length === 0) return; + const defaultOption = Math.max(granularityOptions.length - 2, 0); + onDensityChange(granularityOptions[defaultOption].key); + }, [period, granularityOptions.length]); + + return ( + + + + ) +} + +const PAST_24_HR_MS = 24 * 60 * 60 * 1000 +function calculateGranularities(periodDurationMs: number) { + const granularities = [ + { label: 'Hourly', durationMs: 60 * 60 * 1000 }, + { label: 'Daily', durationMs: 24 * 60 * 60 * 1000 }, + { label: 'Weekly', durationMs: 7 * 24 * 60 * 60 * 1000 }, + { label: 'Monthly', durationMs: 30 * 24 * 60 * 60 * 1000 }, + { label: 'Quarterly', durationMs: 3 * 30 * 24 * 60 * 60 * 1000 }, + ]; + + const result = []; + if (periodDurationMs === PAST_24_HR_MS) { + // if showing for 1 day, show by minute split as well + granularities.unshift( + { label: 'By minute', durationMs: 60 * 1000 }, + ) + } + + for (const granularity of granularities) { + if (periodDurationMs >= granularity.durationMs) { + const density = Math.floor(Number(BigInt(periodDurationMs) / BigInt(granularity.durationMs))); + result.push({ label: granularity.label, key: density }); + } + } + + return result; +} + +export default RangeGranularity; \ No newline at end of file diff --git a/frontend/app/components/Dashboard/components/WidgetDateRange/WidgetDateRange.tsx b/frontend/app/components/Dashboard/components/WidgetDateRange/WidgetDateRange.tsx index d795c45bc..1ff285009 100644 --- a/frontend/app/components/Dashboard/components/WidgetDateRange/WidgetDateRange.tsx +++ b/frontend/app/components/Dashboard/components/WidgetDateRange/WidgetDateRange.tsx @@ -1,37 +1,118 @@ 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'; +import RangeGranularity from "./RangeGranularity"; +import { + CUSTOM_RANGE, + DATE_RANGE_COMPARISON_OPTIONS, +} from 'App/dateRange'; +import Period from 'Types/app/period'; function WidgetDateRange({ - label = 'Time Range', - }: any) { - const {dashboardStore} = useStore(); - const period = useObserver(() => dashboardStore.drillDownPeriod); - const drillDownFilter = useObserver(() => dashboardStore.drillDownFilter); + label = 'Time Range', + hasGranularSettings = false, + hasGranularity = false, + hasComparison = false, + presetComparison = null, +}: any) { + const { dashboardStore, metricStore } = useStore(); + const density = dashboardStore.selectedDensity + const onDensityChange = (density: number) => { + dashboardStore.setDensity(density); + } + const period = dashboardStore.drillDownPeriod; + const compPeriod = dashboardStore.comparisonPeriods[metricStore.instance.metricId]; + 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) => { + dashboardStore.setDrillDownPeriod(period); + const periodTimestamps = period.toTimestamps(); + drillDownFilter.merge({ + startTimestamp: periodTimestamps.startTimestamp, + endTimestamp: periodTimestamps.endTimestamp, + }); + }; + + const onChangeComparison = (period: any) => { + dashboardStore.setComparisonPeriod(period, metricStore.instance.metricId); + } + + React.useEffect(() => { + if (presetComparison) { + const option = DATE_RANGE_COMPARISON_OPTIONS.find((option: any) => option.value === presetComparison[0]); + if (option) { + // @ts-ignore + const newPeriod = new Period({ + start: period.start, + end: period.end, + substract: option.value, + }); + setTimeout(() => { + onChangeComparison(newPeriod); + }, 1) + } else { + const day = 86400000; + const originalPeriodLength = Math.ceil( + (period.end - period.start) / day + ); + const start = presetComparison[0]; + const end = presetComparison[1] + originalPeriodLength * day; + + // @ts-ignore + const compRange = new Period({ + start, + end, + rangeName: CUSTOM_RANGE, + }); + setTimeout(() => { + onChangeComparison(compRange); + }, 1) + } } + }, [presetComparison]) - return ( - - {label && {label}} - { + metricStore.instance.setComparisonRange(range); + metricStore.instance.updateKey('hasChanged', true) + } + + return ( + + {label && {label}} + + {hasGranularSettings ? ( + <> + {hasGranularity ? ( + - - ); + ) : null} + {hasComparison ? + + : null} + + ) : null} + + ); } -export default WidgetDateRange; +export default observer(WidgetDateRange); diff --git a/frontend/app/components/Dashboard/components/WidgetForm/WidgetFormNew.tsx b/frontend/app/components/Dashboard/components/WidgetForm/WidgetFormNew.tsx index a95b7482e..cce9d5457 100644 --- a/frontend/app/components/Dashboard/components/WidgetForm/WidgetFormNew.tsx +++ b/frontend/app/components/Dashboard/components/WidgetForm/WidgetFormNew.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Card, Space, Typography, Button, Alert, Form } from 'antd'; +import { Card, Space, Button, Alert, Form, Select, Tooltip } from 'antd'; import { useStore } from 'App/mstore'; import { eventKeys } from 'Types/filter/newFilter'; import { @@ -9,89 +9,147 @@ import { INSIGHTS, RETENTION, TABLE, - USER_PATH + USER_PATH, } from 'App/constants/card'; import FilterSeries from 'Components/Dashboard/components/FilterSeries/FilterSeries'; -import { issueCategories, metricOf } from 'App/constants/filterOptions'; -import { AudioWaveform, ChevronDown, ChevronUp, PlusIcon } from 'lucide-react'; +import { issueCategories } from 'App/constants/filterOptions'; +import { PlusIcon, ChevronUp } from 'lucide-react'; import { observer } from 'mobx-react-lite'; -import AddStepButton from 'Components/Dashboard/components/FilterSeries/AddStepButton'; import FilterItem from 'Shared/Filters/FilterItem'; -import { FilterKey } from 'Types/filter/filterType'; -import Select from 'Shared/Select'; +import { FilterKey, FilterCategory } from 'Types/filter/filterType'; -function WidgetFormNew() { - const { metricStore, dashboardStore, aiFiltersStore } = useStore(); +const getExcludedKeys = (metricType: string) => { + switch (metricType) { + case USER_PATH: + case HEATMAP: + return eventKeys; + default: + return []; + } +} + +const getExcludedCategories = (metricType: string) => { + switch (metricType) { + case USER_PATH: + case FUNNEL: + return [FilterCategory.DEVTOOLS] + default: + return []; + } +} + +function WidgetFormNew({ layout }: { layout: string }) { + const { metricStore } = useStore(); const metric: any = metricStore.instance; + const excludeFilterKeys = getExcludedKeys(metric.metricType); + const excludeCategory = getExcludedCategories(metric.metricType); - const eventsLength = metric.series[0].filter.filters.filter((i: any) => i && i.isEvent).length; - const filtersLength = metric.series[0].filter.filters.filter((i: any) => i && !i.isEvent).length; - const isClickMap = metric.metricType === HEATMAP; - const isPathAnalysis = metric.metricType === USER_PATH; - const excludeFilterKeys = isClickMap || isPathAnalysis ? eventKeys : []; - const hasFilters = filtersLength > 0 || eventsLength > 0; - const isPredefined = metric.metricType === ERRORS + const isPredefined = metric.metricType === ERRORS; - return isPredefined ? : ( + return isPredefined ? ( + + ) : ( - {/*{!hasFilters && ()}*/} - {/*{hasFilters && ()}*/} - + ); } export default observer(WidgetFormNew); +const FilterSection = observer(({ layout, metric, excludeFilterKeys, excludeCategory }: any) => { + const allOpen = layout.startsWith('flex-row'); + const defaultClosed = React.useRef(!allOpen && metric.exists()); + const [seriesCollapseState, setSeriesCollapseState] = React.useState>({}); -function DefineSteps({ metric, excludeFilterKeys }: any) { - return ( -
- Filter - -
- ); -} - - -const FilterSection = observer(({ metric, excludeFilterKeys }: any) => { - // const timeseriesOptions = metricOf.filter((i) => i.type === 'timeseries'); - // const tableOptions = metricOf.filter((i) => i.type === 'table'); + React.useEffect(() => { + const defaultSeriesCollapseState: Record = {}; + metric.series.forEach((s: any) => { + defaultSeriesCollapseState[s.seriesId] = defaultSeriesCollapseState[ + s.seriesId + ] + ? defaultSeriesCollapseState[s.seriesId] + : allOpen + ? false + : defaultClosed.current; + }); + setSeriesCollapseState(defaultSeriesCollapseState); + }, [metric.series]); const isTable = metric.metricType === TABLE; - const isClickMap = metric.metricType === HEATMAP; + const isHeatMap = metric.metricType === HEATMAP; const isFunnel = metric.metricType === FUNNEL; const isInsights = metric.metricType === INSIGHTS; const isPathAnalysis = metric.metricType === USER_PATH; const isRetention = metric.metricType === RETENTION; const canAddSeries = metric.series.length < 3; - const eventsLength = metric.series[0].filter.filters.filter((i: any) => i && i.isEvent).length; - // const cannotSaveFunnel = isFunnel && (!metric.series[0] || eventsLength <= 1); - const isSingleSeries = isTable || isFunnel || isClickMap || isInsights || isRetention; - - // const onAddFilter = (filter: any) => { - // metric.series[0].filter.addFilter(filter); - // metric.updateKey('hasChanged', true) - // } + const isSingleSeries = + isTable || + isFunnel || + isHeatMap || + isInsights || + isRetention || + isPathAnalysis; + const collapseAll = () => { + setSeriesCollapseState((seriesCollapseState) => { + const newState = { ...seriesCollapseState }; + Object.keys(newState).forEach((key) => { + newState[key] = true; + }); + return newState; + }); + } + const expandAll = () => { + setSeriesCollapseState((seriesCollapseState) => { + const newState = { ...seriesCollapseState }; + Object.keys(newState).forEach((key) => { + newState[key] = false; + }); + return newState; + }); + } + + const allCollapsed = Object.values(seriesCollapseState).every((v) => v); return ( <> - { - metric.series.length > 0 && metric.series + {metric.series.length > 0 && + metric.series .slice(0, isSingleSeries ? 1 : metric.series.length) .map((series: any, index: number) => ( -
+
metric.updateKey('hasChanged', true)} - hideHeader={isTable || isClickMap || isInsights || isPathAnalysis || isFunnel} + hideHeader={ + isTable || + isHeatMap || + isInsights || + isPathAnalysis || + isFunnel + } seriesIndex={index} series={series} onRemoveSeries={() => metric.removeSeries(index)} canDelete={metric.series.length > 1} + collapseState={seriesCollapseState[series.seriesId]} + onToggleCollapse={() => { + setSeriesCollapseState((seriesCollapseState) => ({ + ...seriesCollapseState, + [series.seriesId]: !seriesCollapseState[series.seriesId], + })); + }} emptyMessage={ isTable ? 'Filter data using any event or attribute. Use Add Step button below to do so.' @@ -100,74 +158,110 @@ const FilterSection = observer(({ metric, excludeFilterKeys }: any) => { expandable={isSingleSeries} />
- )) - } - - {!isSingleSeries && canAddSeries && ( - + ))} + {isSingleSeries ? null : +
+ + + - - )} +
+ } ); }); - const PathAnalysisFilter = observer(({ metric, writeOption }: any) => { const metricValueOptions = [ { value: 'location', label: 'Pages' }, { value: 'click', label: 'Clicks' }, { value: 'input', label: 'Input' }, - { value: 'custom', label: 'Custom' } + { value: 'custom', label: 'Custom' }, ]; return ( - + - - - - - - metric.updateStartPoint(val)} - onRemoveFilter={() => { - }} - /> +
+ User journeys with: + +
+ writeOption({ name: 'metricValue', value })} + placeholder="Select Metrics" + size="small" + /> +
+
+
+ + + metric.updateStartPoint(val)} + onRemoveFilter={() => {}} + /> + + +
); }); @@ -184,6 +278,7 @@ const InsightsFilter = observer(({ metric, writeOption }: any) => { onChange={writeOption} isMulti placeholder="All Categories" + allowClear /> @@ -192,7 +287,7 @@ const InsightsFilter = observer(({ metric, writeOption }: any) => { }); const AdditionalFilters = observer(() => { - const { metricStore, dashboardStore, aiFiltersStore } = useStore(); + const { metricStore } = useStore(); const metric: any = metricStore.instance; const writeOption = ({ value, name }: { value: any; name: any }) => { @@ -203,14 +298,22 @@ const AdditionalFilters = observer(() => { return ( <> - {metric.metricType === USER_PATH && } - {metric.metricType === INSIGHTS && } + {metric.metricType === USER_PATH && ( + + )} + {metric.metricType === INSIGHTS && ( + + )} ); }); - const PredefinedMessage = () => ( - + ); diff --git a/frontend/app/components/Dashboard/components/WidgetForm/components/MetricTypeDropdown/MetricTypeDropdown.tsx b/frontend/app/components/Dashboard/components/WidgetForm/components/MetricTypeDropdown/MetricTypeDropdown.tsx index 7155ae111..e755dfc96 100644 --- a/frontend/app/components/Dashboard/components/WidgetForm/components/MetricTypeDropdown/MetricTypeDropdown.tsx +++ b/frontend/app/components/Dashboard/components/WidgetForm/components/MetricTypeDropdown/MetricTypeDropdown.tsx @@ -14,8 +14,8 @@ interface Props { } function MetricTypeDropdown(props: Props) { const { metricStore, userStore } = useStore(); - const isEnterprise = userStore.isEnterprise; const metric: any = metricStore.instance; + const isEnterprise = userStore.isEnterprise; const options = React.useMemo(() => { return DROPDOWN_OPTIONS.map((option: any) => { @@ -26,18 +26,6 @@ function MetricTypeDropdown(props: Props) { }); }, []); - React.useEffect(() => { - const queryCardType = props.query.get('type'); - if (queryCardType && options.length > 0 && metric.metricType) { - const type: Option = options.find((i) => i.value === queryCardType) as Option; - if (type.disabled) { - return; - } - setTimeout(() => onChange(type.value), 0); - } - // setTimeout(() => onChange(USER_PATH), 0); - }, []); - const onChange = (type: string) => { metricStore.changeType(type); }; @@ -51,7 +39,7 @@ function MetricTypeDropdown(props: Props) { value={ DROPDOWN_OPTIONS.find((i: any) => i.value === metric.metricType) || DROPDOWN_OPTIONS[0] } - onChange={props.onSelect} + onChange={({ value }) => onChange(value.value)} components={{ SingleValue: ({ children, ...props }: any) => { const { diff --git a/frontend/app/components/Dashboard/components/WidgetName/WidgetName.tsx b/frontend/app/components/Dashboard/components/WidgetName/WidgetName.tsx index 2db526d7a..4e17c8b6a 100644 --- a/frontend/app/components/Dashboard/components/WidgetName/WidgetName.tsx +++ b/frontend/app/components/Dashboard/components/WidgetName/WidgetName.tsx @@ -1,92 +1,79 @@ import React, { useState, useRef, useEffect } from 'react'; -import { Icon, Tooltip } from 'UI'; -import { Input } from 'antd'; +import { Input, Tooltip } from 'antd'; import cn from 'classnames'; interface Props { name: string; - onUpdate: (name: any) => void; + onUpdate: (name: string) => void; seriesIndex?: number; - canEdit?: boolean + canEdit?: boolean; } + function WidgetName(props: Props) { const { canEdit = true } = props; - const [editing, setEditing] = useState(false) - const [name, setName] = useState(props.name) - const ref = useRef(null) + const [editing, setEditing] = useState(false); + const [name, setName] = useState(props.name); + const ref = useRef(null); const write = ({ target: { value } }) => { - setName(value) - } + setName(value); + }; const onBlur = (nameInput?: string) => { - setEditing(false) - const toUpdate = nameInput || name - props.onUpdate(toUpdate && toUpdate.trim() === '' ? 'New Widget' : toUpdate) - } + setEditing(false); + const toUpdate = nameInput || name; + props.onUpdate(toUpdate && toUpdate.trim() === '' ? 'New Widget' : toUpdate); + }; + + const onKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + onBlur(name); + } + if (e.key === 'Escape' || e.key === 'Esc') { + setEditing(false); + } + }; useEffect(() => { if (editing) { - ref.current.focus() + ref.current.focus(); } - }, [editing]) + }, [editing]); useEffect(() => { - setName(props.name) - }, [props.name]) - - useEffect(() => { - const handler = (e: KeyboardEvent) => { - if (e.key === 'Enter') { - onBlur(name) - } - if (e.key === 'Escape' || e.key === 'Esc') { - setEditing(false) - } - } - document.addEventListener('keydown', handler, false) - - return () => { - document.removeEventListener('keydown', handler, false) - } - }, [name]) + setName(props.name); + }, [props.name]); return (
- { editing ? ( + {editing ? ( onBlur()} + onKeyDown={onKeyDown} onFocus={() => setEditing(true)} maxLength={80} + className="bg-white text-2xl ps-2 rounded-lg h-8" /> ) : ( // @ts-ignore - +
setEditing(true)} - className={ - cn( - "text-2xl h-8 flex items-center border-transparent", - canEdit && 'cursor-pointer select-none border-b border-b-borderColor-transparent hover:border-dotted hover:border-gray-medium' - ) - } + onClick={() => setEditing(true)} + className={cn( + "text-2xl h-8 flex items-center p-2 rounded-lg", + canEdit && 'cursor-pointer select-none ps-2 hover:bg-teal/10' + )} > - { name } + {name}
- )} - { canEdit &&
setEditing(true)}> - - - -
}
); } -export default WidgetName; +export default WidgetName; \ No newline at end of file diff --git a/frontend/app/components/Dashboard/components/WidgetOptions.tsx b/frontend/app/components/Dashboard/components/WidgetOptions.tsx index 3a3298f8b..992cc8c08 100644 --- a/frontend/app/components/Dashboard/components/WidgetOptions.tsx +++ b/frontend/app/components/Dashboard/components/WidgetOptions.tsx @@ -1,60 +1,214 @@ 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, + Hash, + Users, + Library, + ChartColumnBig, + ChartBarBig, +} from 'lucide-react'; -interface Props { - -} - - -function WidgetOptions(props: Props) { - const { metricStore, dashboardStore } = useStore(); +function WidgetOptions() { + const { metricStore } = useStore(); const metric: any = metricStore.instance; const handleChange = (value: any) => { metric.update({ metricFormat: value }); + metric.updateKey('hasChanged', true); }; + // const hasSeriesTypes = [TIMESERIES, FUNNEL, TABLE].includes(metric.metricType); + const hasViewTypes = [TIMESERIES, FUNNEL].includes(metric.metricType); return ( -
+
{metric.metricType === USER_PATH && ( { e.preventDefault(); metric.update({ hideExcess: !metric.hideExcess }); + metric.updateKey('hasChanged', true); }} > - - Hide Minor Paths - + Hide Minor Paths )} - {(metric.metricType === FUNNEL || metric.metricType === TABLE) && metric.metricOf != FilterKey.USERID && metric.metricOf != FilterKey.ERRORS && ( -
, + // funnel specific + columnChart: , + chart: , + }; + const allowedTypes = { + [TIMESERIES]: [ + 'lineChart', + 'areaChart', + 'barChart', + 'progressChart', + 'pieChart', + 'metric', + 'table', + ], + [FUNNEL]: ['chart', 'columnChart', 'metric', 'table'], + }; + return ( + ({ + key, + label: ( +
+ {chartIcons[key]} +
{usedChartTypes[key]}
+
+ ), + })), + onClick: ({ key }: any) => { + metric.updateKey('viewType', key); + metric.updateKey('hasChanged', true); + }, + }} + > + +
+ ); +}); + export default observer(WidgetOptions); diff --git a/frontend/app/components/Dashboard/components/WidgetPreview/WidgetPreview.tsx b/frontend/app/components/Dashboard/components/WidgetPreview/WidgetPreview.tsx index 06fcddf88..8994324b0 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 { FUNNEL, TIMESERIES } from "App/constants/card"; import WidgetWrapper from '../WidgetWrapper'; import WidgetOptions from 'Components/Dashboard/components/WidgetOptions'; @@ -18,101 +16,32 @@ interface Props { function WidgetPreview(props: Props) { const { className = '' } = props; - const { metricStore, dashboardStore } = useStore(); + const { metricStore } = useStore(); const metric: any = metricStore.instance; + const hasGranularSettings = [TIMESERIES, FUNNEL].includes(metric.metricType) + const hasGranularity = ['lineChart', 'barChart', 'areaChart'].includes(metric.viewType); + const hasComparison = metric.metricType === FUNNEL || ['lineChart', 'barChart', 'table', 'progressChart', 'metric'].includes(metric.viewType); + // [rangeStart, rangeEnd] or [period_name] -- have to check options + const presetComparison = metric.compareTo; return ( <>
-
-

{props.name}

-
+
+ +
- {/*{metric.metricType === USER_PATH && (*/} - {/* {*/} - {/* e.preventDefault();*/} - {/* metric.update({ hideExcess: !metric.hideExcess });*/} - {/* }}*/} - {/* >*/} - {/* */} - {/* */} - {/* */} - {/* Hide Minor Paths*/} - {/* */} - {/* */} - {/* */} - {/*)}*/} - - - {/*{isTimeSeries && (*/} - {/* <>*/} - {/* Visualization*/} - {/* */} - {/* */} - {/*)}*/} - - {/*{!disableVisualization && isTable && (*/} - {/* <>*/} - {/* Visualization*/} - {/* */} - {/* */} - {/*)}*/} - - {/*{isRetention && (*/} - {/* <>*/} - {/* Visualization*/} - {/* */} - {/**/} - {/*)}*/} - - - - {/* add to dashboard */} - {/*{metric.exists() && (*/} - {/* */} - {/*)}*/}
-
+
(null); const { className = '' } = props; const [activeSeries, setActiveSeries] = useState('all'); const [data, setData] = useState([]); const isMounted = useIsMounted(); const [loading, setLoading] = useState(false); - const filteredSessions = getListSessionsBySeries(data, activeSeries); + // all filtering done through series now + const filteredSessions = getListSessionsBySeries(data, 'all'); const { dashboardStore, metricStore, sessionStore, customFieldStore } = useStore(); + const focusedSeries = metricStore.focusedSeriesName; const filter = dashboardStore.drillDownFilter; const widget = metricStore.instance; const startTime = DateTime.fromMillis(filter.startTimestamp).toFormat('LLL dd, yyyy HH:mm'); @@ -34,15 +37,23 @@ function WidgetSessions(props: Props) { const filterText = filter.filters.length > 0 ? filter.filters[0].value : ''; const metaList = customFieldStore.list.map((i: any) => i.key); - const writeOption = ({ value }: any) => setActiveSeries(value.value); + const seriesDropdownItems = seriesOptions.map((option) => ({ + key: option.value, + label: ( +
setActiveSeries(option.value)}> + {option.label} +
+ ) + })); + useEffect(() => { - if (!data) return; - const seriesOptions = data.map((item: any) => ({ - label: item.seriesName, + if (!widget.series) return; + const seriesOptions = widget.series.map((item: any) => ({ + label: item.name, value: item.seriesId })); setSeriesOptions([{ label: 'All', value: 'all' }, ...seriesOptions]); - }, [data]); + }, [widget.series]); const fetchSessions = (metricId: any, filter: any) => { if (!isMounted()) return; @@ -52,6 +63,17 @@ function WidgetSessions(props: Props) { .fetchSessions(metricId, filter) .then((res: any) => { setData(res); + if (metricStore.drillDown) { + setTimeout(() => { + notification.open({ + placement: 'top', + role: 'status', + message: 'Sessions Refreshed!' + }) + listRef.current?.scrollIntoView({ behavior: 'smooth' }); + metricStore.setDrillDown(false); + }, 0) + } }) .finally(() => { setLoading(false); @@ -89,9 +111,10 @@ function WidgetSessions(props: Props) { }; debounceClickMapSearch(customFilter); } else { + const usedSeries = focusedSeries ? widget.series.filter((s) => s.name === focusedSeries) : widget.series; debounceRequest(widget.metricId, { ...filter, - series: widget.series.map((s) => s.toJson()), + series: usedSeries.map((s) => s.toJson()), page: metricStore.sessionsPage, limit: metricStore.sessionsPageSize }); @@ -106,9 +129,23 @@ function WidgetSessions(props: Props) { filter.filters, depsString, metricStore.clickMapSearch, - activeSeries + focusedSeries ]); useEffect(loadData, [metricStore.sessionsPage]); + useEffect(() => { + if (activeSeries === 'all') { + metricStore.setFocusedSeriesName(null); + } else { + metricStore.setFocusedSeriesName(seriesOptions.find((option) => option.value === activeSeries)?.label, false); + } + }, [activeSeries]) + useEffect(() => { + if (focusedSeries) { + setActiveSeries(seriesOptions.find((option) => option.label === focusedSeries)?.value || 'all'); + } else { + setActiveSeries('all'); + } + }, [focusedSeries]) const clearFilters = () => { metricStore.updateKey('sessionsPage', 1); @@ -116,34 +153,46 @@ function WidgetSessions(props: Props) { }; return ( -
+
-
+

{metricStore.clickMapSearch ? 'Clicks' : 'Sessions'}

{metricStore.clickMapLabel ? `on "${metricStore.clickMapLabel}" ` : null} between {startTime} and{' '} {endTime}{' '}
+ {hasFilters && }
- {hasFilters && widget.metricType === 'table' && -
{filterText}
} + {hasFilters && widget.metricType === 'table' &&
{filterText}
} +
- {hasFilters && } - {widget.metricType !== 'table' && widget.metricType !== HEATMAP && ( -
- Filter by Series -
( + index > 0 ? 'opacity-70' : '' + )} + /> + + + + ); +} + +export function TableExporter({ + tableData, + tableColumns, + filename, + top, + right, +}: { + tableData: any; + tableColumns: any; + filename: string; + top?: string; + right?: string; +}) { + const onClick = () => exportAntCsv(tableColumns, tableData, filename); + return ( + +
+ + +
+ } + /> + +
+ ); +} + +export default FunnelTable; diff --git a/frontend/app/components/Funnels/FunnelWidget/FunnelWidget.module.css b/frontend/app/components/Funnels/FunnelWidget/FunnelWidget.module.css index 0102e4d9f..48b1791ec 100644 --- a/frontend/app/components/Funnels/FunnelWidget/FunnelWidget.module.css +++ b/frontend/app/components/Funnels/FunnelWidget/FunnelWidget.module.css @@ -1,23 +1,3 @@ -.step { - /* display: flex; */ - position: relative; - transition: all 0.5s ease; - &:before { - content: ''; - border-left: 2px solid $gray-lightest; - position: absolute; - top: 16px; - bottom: 9px; - left: 10px; - /* width: 1px; */ - height: 100%; - z-index: 0; - } - &:last-child:before { - display: none; - } -} - .step-disabled { filter: grayscale(1); opacity: 0.8; diff --git a/frontend/app/components/Funnels/FunnelWidget/FunnelWidget.tsx b/frontend/app/components/Funnels/FunnelWidget/FunnelWidget.tsx index 11df5f487..3380445fc 100644 --- a/frontend/app/components/Funnels/FunnelWidget/FunnelWidget.tsx +++ b/frontend/app/components/Funnels/FunnelWidget/FunnelWidget.tsx @@ -1,6 +1,7 @@ import React, { useEffect } from 'react'; import Widget from 'App/mstore/types/widget'; -import Funnelbar, { UxTFunnelBar } from './FunnelBar'; +import Funnelbar, { UxTFunnelBar } from "./FunnelBar"; +import Funnel from 'App/mstore/types/funnel' import cn from 'classnames'; import stl from './FunnelWidget.module.css'; import { observer } from 'mobx-react-lite'; @@ -11,15 +12,16 @@ import { useStore } from '@/mstore'; import Filter from '@/mstore/types/filter'; interface Props { - metric?: Widget; - isWidget?: boolean; - data: any; + metric?: Widget; + isWidget?: boolean; + data: { funnel: Funnel }; + compData: { funnel: Funnel }; } function FunnelWidget(props: Props) { const { dashboardStore, searchStore } = useStore(); const [focusedFilter, setFocusedFilter] = React.useState(null); - const { isWidget = false, data, metric } = props; + const { isWidget = false, data, metric, compData } = props; const funnel = data.funnel || { stages: [] }; const totalSteps = funnel.stages.length; const stages = isWidget ? [...funnel.stages.slice(0, 1), funnel.stages[funnel.stages.length - 1]] : funnel.stages; @@ -30,11 +32,12 @@ function FunnelWidget(props: Props) { const metricLabel = metric?.metricFormat == 'userCount' ? 'Users' : 'Sessions'; const drillDownFilter = dashboardStore.drillDownFilter; const drillDownPeriod = dashboardStore.drillDownPeriod; + const comparisonPeriod = metric ? dashboardStore.comparisonPeriods[metric.metricId] : undefined const metricFilters = metric?.series[0]?.filter.filters || []; - const applyDrillDown = (index: number) => { + const applyDrillDown = (index: number, isComp?: boolean) => { const filter = new Filter().fromData({ filters: metricFilters.slice(0, index + 1) }); - const periodTimestamps = drillDownPeriod.toTimestamps(); + const periodTimestamps = isComp && index > -1 ? comparisonPeriod.toTimestamps() : drillDownPeriod.toTimestamps(); drillDownFilter.merge({ filters: filter.toJson().filters, startTimestamp: periodTimestamps.startTimestamp, @@ -49,7 +52,7 @@ function FunnelWidget(props: Props) { }; }, []); - const focusStage = (index: number) => { + const focusStage = (index: number, isComp?: boolean) => { funnel.stages.forEach((s, i) => { // turning on all filters if one was focused already if (focusedFilter === index) { @@ -65,9 +68,25 @@ function FunnelWidget(props: Props) { } }); - applyDrillDown(focusedFilter === index ? -1 : index); + applyDrillDown(focusedFilter === index ? -1 : index, isComp); }; + const shownStages = React.useMemo(() => { + const stages: { data: Funnel['stages'][0], compData?: Funnel['stages'][0] }[] = []; + for (let i = 0; i < funnel.stages.length; i++) { + const stage: any = { data: funnel.stages[i], compData: undefined } + const compStage = compData?.funnel.stages[i]; + if (compStage) { + stage.compData = compStage; + } + stages.push(stage) + } + + return stages; + }, [data, compData]) + + const viewType = metric?.viewType; + const isHorizontal = viewType === 'columnChart'; return ( -
- {!isWidget && ( - stages.map((filter: any, index: any) => ( +
+ {!isWidget && + shownStages.map((stage: any, index: any) => ( - )) - )} + ))} {isWidget && ( <> @@ -110,38 +130,56 @@ function FunnelWidget(props: Props) { )}
-
-
- Lost conversion - - - {funnel.lostConversions} - - -
-
+
Total conversion - - + + {funnel.totalConversions}
+
+ Lost conversion + + + {funnel.lostConversions} + + +
- {funnel.totalDropDueToIssues > 0 &&
{funnel.totalDropDueToIssues} sessions dropped due to issues.
} + {funnel.totalDropDueToIssues > 0 && ( +
+ {' '} + + {funnel.totalDropDueToIssues} sessions dropped due to issues. + +
+ )} ); } export const EmptyStage = observer(({ total }: any) => { return ( -
+
+ style={{ width: '100px' }} + > {`+${total} ${total > 1 ? 'steps' : 'step'}`}
@@ -149,39 +187,35 @@ export const EmptyStage = observer(({ total }: any) => { ); }); -export const Stage = observer(({ metricLabel, stage, index, isWidget, uxt, focusStage, focusedFilter }: any) => { - return stage ? ( -
- - {!uxt ? : } - {/*{!isWidget && !uxt && }*/} -
- ) : ( - <> - ); -}); +export const Stage = observer(({ + metricLabel, + stage, + index, + uxt, + focusStage, + focusedFilter, + compData, + isHorizontal, +}: any) => { + return stage ? ( +
+ + {!uxt ? : } +
+ ) : null +}) export const IndexNumber = observer(({ index }: any) => { - return ( -
- {index === 0 ? : index} -
- ); -}); - - -const BarActions = observer(({ bar }: any) => { - return ( -
- -
- ); -}); + return ( +
+ {index === 0 ? : index} +
+ ); +}) export default observer(FunnelWidget); diff --git a/frontend/app/components/Header/HealthStatus/HealthModal/HealthModal.tsx b/frontend/app/components/Header/HealthStatus/HealthModal/HealthModal.tsx index f8dc428a6..06f0b1cee 100644 --- a/frontend/app/components/Header/HealthStatus/HealthModal/HealthModal.tsx +++ b/frontend/app/components/Header/HealthStatus/HealthModal/HealthModal.tsx @@ -17,7 +17,10 @@ function HealthModal({ }: { getHealth: () => void; isLoading: boolean; - healthResponse: { overallHealth: boolean; healthMap: Record }; + healthResponse: { + overallHealth: boolean; + healthMap: Record; + }; setShowModal: (isOpen: boolean) => void; setPassed?: () => void; }) { @@ -39,7 +42,7 @@ function HealthModal({ setShowModal(false); }; - const isSetup = document.location.pathname.includes('/signup') + const isSetup = document.location.pathname.includes('/signup'); return (
e.stopPropagation()} - className={'flex flex-col bg-white rounded border border-figmaColors-divider'} + className={ + 'flex flex-col bg-white rounded border border-figmaColors-divider' + } >
-
-
- {isLoading ? null - : Object.keys(healthResponse.healthMap).map((service) => ( - - setSelectedService(service)} - healthOk={healthResponse.healthMap[service].healthOk} - name={healthResponse.healthMap[service].name} - isSelectable - isSelected={selectedService === service} + {healthResponse ? ( + <> +
+
+ {isLoading + ? null + : Object.keys(healthResponse.healthMap).map((service) => ( + + setSelectedService(service)} + healthOk={ + healthResponse.healthMap[service].healthOk + } + name={healthResponse.healthMap[service].name} + isSelectable + isSelected={selectedService === service} + /> + + ))} +
+
+ {isLoading ? null : selectedService ? ( + - - ))} + ) : ( + + )} +
+
+ {isSetup ? ( +
+ +
+ ) : null} + + ) : ( +
+
Error while fetching data...
-
- {isLoading ? null : selectedService ? ( - - ) : - } -
-
- {isSetup ? ( -
- -
- ) : null} + )}
@@ -137,7 +160,10 @@ function ServiceStatus({ service }: { service: Record }) {
{Object.keys(subservices).map((subservice: string) => ( - + ))}
diff --git a/frontend/app/components/Session_/OverviewPanel/OverviewPanel.tsx b/frontend/app/components/Session_/OverviewPanel/OverviewPanel.tsx index 03ef25f0a..f551f7e2c 100644 --- a/frontend/app/components/Session_/OverviewPanel/OverviewPanel.tsx +++ b/frontend/app/components/Session_/OverviewPanel/OverviewPanel.tsx @@ -315,6 +315,7 @@ function PanelComponent({ /> {summaryChecked ? ( setZoomTab(val)} options={[ diff --git a/frontend/app/components/Session_/OverviewPanel/components/PerformanceGraph/PerformanceGraph.tsx b/frontend/app/components/Session_/OverviewPanel/components/PerformanceGraph/PerformanceGraph.tsx index 51a5a0898..b956bfb50 100644 --- a/frontend/app/components/Session_/OverviewPanel/components/PerformanceGraph/PerformanceGraph.tsx +++ b/frontend/app/components/Session_/OverviewPanel/components/PerformanceGraph/PerformanceGraph.tsx @@ -58,7 +58,7 @@ const PerformanceGraph = React.memo((props: Props) => { {disabled ? (
diff --git a/frontend/app/components/Session_/OverviewPanel/components/TimelinePointer/TimelinePointer.tsx b/frontend/app/components/Session_/OverviewPanel/components/TimelinePointer/TimelinePointer.tsx index 4518ddf1c..a445b416a 100644 --- a/frontend/app/components/Session_/OverviewPanel/components/TimelinePointer/TimelinePointer.tsx +++ b/frontend/app/components/Session_/OverviewPanel/components/TimelinePointer/TimelinePointer.tsx @@ -158,7 +158,7 @@ function GroupedIssue({ onClick={createEventClickHandler(pointer, type)} className={'flex items-center gap-2 mb-1 cursor-pointer border-b border-transparent hover:border-gray-lightest'} > -
@{shortDurationFromMs(pointer.time)}
+
@{shortDurationFromMs(pointer.time)}
))} diff --git a/frontend/app/components/Session_/Player/Controls/AssistSessionsModal/AssistSessionsModal.tsx b/frontend/app/components/Session_/Player/Controls/AssistSessionsModal/AssistSessionsModal.tsx index ee884318d..027867ed8 100644 --- a/frontend/app/components/Session_/Player/Controls/AssistSessionsModal/AssistSessionsModal.tsx +++ b/frontend/app/components/Session_/Player/Controls/AssistSessionsModal/AssistSessionsModal.tsx @@ -7,7 +7,7 @@ import { KEYS } from 'Types/filter/customFilter'; import { capitalize } from 'App/utils'; import { useStore } from 'App/mstore'; import { observer } from 'mobx-react-lite'; -import AssistSearchField from 'App/components/Assist/AssistSearchField'; +import AssistSearchField from 'App/components/Assist/AssistSearchActions'; import LiveSessionSearch from 'Shared/LiveSessionSearch'; import cn from 'classnames'; import Session from 'App/mstore/types/session'; @@ -70,7 +70,7 @@ function AssistSessionsModal(props: ConnectProps) { icon="arrow-repeat" /> - +
Sort By diff --git a/frontend/app/components/Signup/Signup.tsx b/frontend/app/components/Signup/Signup.tsx index b5538cc10..f8704c54b 100644 --- a/frontend/app/components/Signup/Signup.tsx +++ b/frontend/app/components/Signup/Signup.tsx @@ -33,9 +33,14 @@ const Signup: React.FC = ({ history }) => { const getHealth = async () => { setHealthStatusLoading(true); - const { healthMap } = await getHealthRequest(true); - setHealthStatus(healthMap); - setHealthStatusLoading(false); + try { + const { healthMap } = await getHealthRequest(true); + setHealthStatus(healthMap); + } catch (e) { + console.error(e); + } finally { + setHealthStatusLoading(false); + } }; useEffect(() => { diff --git a/frontend/app/components/Spots/SpotsList/SpotsListHeader.tsx b/frontend/app/components/Spots/SpotsList/SpotsListHeader.tsx index b2368469f..781893575 100644 --- a/frontend/app/components/Spots/SpotsList/SpotsListHeader.tsx +++ b/frontend/app/components/Spots/SpotsList/SpotsListHeader.tsx @@ -50,7 +50,7 @@ const SpotsListHeader = observer(

Spot List

- +
{tenantHasSpots ? ( diff --git a/frontend/app/components/hocs/withPermissions.js b/frontend/app/components/hocs/withPermissions.js index 42a4986ac..672901bce 100644 --- a/frontend/app/components/hocs/withPermissions.js +++ b/frontend/app/components/hocs/withPermissions.js @@ -1,7 +1,7 @@ import { useStore } from "App/mstore"; import React from 'react'; import { NoPermission, NoSessionPermission } from 'UI'; - +import { observer } from 'mobx-react-lite' export default (requiredPermissions, className, isReplay = false, andEd = true) => (BaseComponent) => { @@ -26,5 +26,5 @@ export default (requiredPermissions, className, isReplay = false, andEd = true)
); } - return WrapperClass; + return observer(WrapperClass); } diff --git a/frontend/app/components/shared/Breadcrumb/BackButton.tsx b/frontend/app/components/shared/Breadcrumb/BackButton.tsx index a93ad705f..ef046d5a0 100644 --- a/frontend/app/components/shared/Breadcrumb/BackButton.tsx +++ b/frontend/app/components/shared/Breadcrumb/BackButton.tsx @@ -1,16 +1,20 @@ import React from 'react'; import { Button } from 'antd'; import { useHistory } from 'react-router-dom'; -import { LeftOutlined } from '@ant-design/icons'; +import { LeftOutlined, ArrowLeftOutlined } from '@ant-design/icons'; -function BackButton() { +function BackButton({ compact }: { compact?: boolean }) { const history = useHistory(); const siteId = location.pathname.split('/')[1]; const handleBackClick = () => { history.push(`/${siteId}/dashboard`); }; - + if (compact) { + return ( +
- {readonly ? null : ( -
- - - -
- )}
{bottomLine1} diff --git a/frontend/app/components/shared/DateRangeDropdown/DateRangePopup.tsx b/frontend/app/components/shared/DateRangeDropdown/DateRangePopup.tsx index e7663a785..1c73e4c3f 100644 --- a/frontend/app/components/shared/DateRangeDropdown/DateRangePopup.tsx +++ b/frontend/app/components/shared/DateRangeDropdown/DateRangePopup.tsx @@ -17,12 +17,26 @@ import { DateTime, Interval } from 'luxon'; import styles from './dateRangePopup.module.css'; function DateRangePopup(props: any) { - const [range, setRange] = React.useState(props.selectedDateRange || Interval.fromDateTimes(DateTime.now(), DateTime.now())); + const [range, setRange] = React.useState( + props.selectedDateRange || + Interval.fromDateTimes(DateTime.now(), DateTime.now()) + ); const [value, setValue] = React.useState(null); const selectCustomRange = (range) => { - const updatedRange = Interval.fromDateTimes(DateTime.fromJSDate(range[0]), DateTime.fromJSDate(range[1])); - setRange(updatedRange); + let newRange; + if (props.singleDay) { + newRange = Interval.fromDateTimes( + DateTime.fromJSDate(range), + DateTime.fromJSDate(range) + ); + } else { + newRange = Interval.fromDateTimes( + DateTime.fromJSDate(range[0]), + DateTime.fromJSDate(range[1]) + ); + } + setRange(newRange); setValue(CUSTOM_RANGE); }; @@ -53,8 +67,12 @@ function DateRangePopup(props: any) { }; const { onCancel } = props; - const isUSLocale = navigator.language === 'en-US' || navigator.language.startsWith('en-US'); - const rangeForDisplay = [range.start!.startOf('day').ts, range.end!.startOf('day').ts] + const isUSLocale = + navigator.language === 'en-US' || navigator.language.startsWith('en-US'); + + const rangeForDisplay = props.singleDay + ? range.start.ts + : [range.start!.startOf('day').ts, range.end!.startOf('day').ts]; return (
@@ -84,41 +102,51 @@ function DateRangePopup(props: any) { isOpen maxDate={new Date()} value={rangeForDisplay} + calendarProps={{ + tileDisabled: props.isTileDisabled, + selectRange: props.singleDay ? false : true, + }} />
-
- - {range.start.toFormat(isUSLocale ? "MM/dd" : "dd/MM")} - - - {range.end.toFormat(isUSLocale ? "MM/dd" : "dd/MM")} - -
+ {props.singleDay ? ( +
+ Compare from {range.start.toFormat('MMM dd, yyyy')} +
+ ) : ( +
+ + {range.start.toFormat(isUSLocale ? 'MM/dd' : 'dd/MM')} + + + {range.end.toFormat(isUSLocale ? 'MM/dd' : 'dd/MM')} + +
+ )}
- +
@@ -126,4 +154,4 @@ function DateRangePopup(props: any) { ); } -export default DateRangePopup; \ No newline at end of file +export default DateRangePopup; diff --git a/frontend/app/components/shared/Dropdown/index.tsx b/frontend/app/components/shared/Dropdown/index.tsx new file mode 100644 index 000000000..1ceb0e3ac --- /dev/null +++ b/frontend/app/components/shared/Dropdown/index.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { Dropdown, MenuProps } from 'antd'; + +function AntlikeDropdown(props: { + label: string; + leftIcon?: React.ReactNode; + rightIcon?: React.ReactNode; + menuProps: MenuProps; + useButtonStyle?: boolean; + className?: string; +}) { + const { label, leftIcon, rightIcon, menuProps, useButtonStyle, className } = props; + return ( + + {useButtonStyle ? ( +
+ {leftIcon} + {label} + {rightIcon} +
+ ) : ( +
+ {leftIcon} + {label} + {rightIcon} +
+ )} +
+ ); +} + +export default AntlikeDropdown; \ No newline at end of file diff --git a/frontend/app/components/shared/Filters/FilterAutoComplete/AutocompleteModal.tsx b/frontend/app/components/shared/Filters/FilterAutoComplete/AutocompleteModal.tsx new file mode 100644 index 000000000..efd2e0127 --- /dev/null +++ b/frontend/app/components/shared/Filters/FilterAutoComplete/AutocompleteModal.tsx @@ -0,0 +1,251 @@ +import React, { useRef, useState, useEffect } from 'react'; +import { Button, Checkbox, Input, Tooltip } from 'antd'; +import cn from 'classnames'; +import { Loader } from 'UI'; +import OutsideClickDetectingDiv from '../../OutsideClickDetectingDiv'; + + +function TruncatedText({ text, maxWidth }: { text?: string; maxWidth?: string;}) { + const textRef = useRef(null); + const [isTruncated, setIsTruncated] = useState(false); + + useEffect(() => { + if (textRef.current) { + setIsTruncated(textRef.current.scrollWidth > textRef.current.offsetWidth); + } + }, [text]); + + return ( + +
+ {text} +
+
+ ); +} + + +export function AutocompleteModal({ + onClose, + onApply, + values, + handleFocus, + loadOptions, + options, + isLoading, + placeholder, + commaQuery, +}: { + values: string[]; + onClose: () => void; + onApply: (values: string[]) => void; + handleFocus?: () => void; + loadOptions: (query: string) => void; + options: { value: string; label: string }[]; + placeholder?: string; + isLoading?: boolean; + commaQuery?: boolean; +}) { + const [query, setQuery] = React.useState(''); + const [selectedValues, setSelectedValues] = React.useState( + values.filter((i) => i && i.length > 0) + ); + + const handleInputChange = (value: string) => { + setQuery(value); + loadOptions(value); + }; + const onSelectOption = (item: { value: string; label: string }) => { + const selected = isSelected(item); + if (!selected) { + setSelectedValues([...selectedValues, item.value]); + } else { + setSelectedValues(selectedValues.filter((i) => i !== item.value)); + } + }; + const isSelected = (item: { value: string; label: string }) => { + return selectedValues.includes(item.value); + }; + + const applyValues = () => { + onApply(selectedValues); + }; + + const applyQuery = () => { + const vals = commaQuery ? query.split(',').map((i) => i.trim()) : [query]; + onApply(vals); + }; + + const sortedOptions = React.useMemo(() => { + if (values[0] && values[0].length) { + const sorted = options.sort((a, b) => { + return values.includes(a.value) ? -1 : 1; + }); + return sorted; + } + return options; + }, [options, values]); + + const queryBlocks = commaQuery ? query.split(',') : [query]; + const blocksAmount = queryBlocks.length; + const queryStr = React.useMemo(() => { + let str = ''; + queryBlocks.forEach((block, index) => { + if (index === blocksAmount - 1 && blocksAmount > 1) { + str += ' and '; + } + str += `"${block.trim()}"`; + if (index < blocksAmount - 2) { + str += ', '; + } + }); + return str; + }, [query]); + + return ( + { + onClose(); + }} + > + handleInputChange(e.target.value)} + placeholder={placeholder} + className="rounded-lg" + /> + + <> +
+ {sortedOptions.map((item) => ( +
onSelectOption(item)} + className={ + 'cursor-pointer w-full py-1 hover:bg-active-blue rounded px-2' + } + > + {item.label} +
+ ))} +
+ {query.length ? ( +
+
+ Apply {queryStr} +
+
+ ) : null} + +
+
+ + +
+
+ ); +} + +// Props interface +interface Props { + value: string[]; + params?: any; + onApplyValues: (values: string[]) => void; + modalRenderer: (props: any) => React.ReactElement; + placeholder?: string; + modalProps?: any; + mapValues?: (value: string) => string; +} + +// AutoCompleteContainer component +export function AutoCompleteContainer(props: Props) { + const filterValueContainer = useRef(null); + const [showValueModal, setShowValueModal] = useState(false); + const isEmpty = props.value.length === 0 || !props.value[0]; + const onClose = () => setShowValueModal(false); + const onApply = (values: string[]) => { + props.onApplyValues(values); + setShowValueModal(false); + }; + + return ( +
+
setTimeout(() => setShowValueModal(true), 0)} + className={'flex items-center gap-2 cursor-pointer'} + > + {!isEmpty ? ( + <> + + {props.value.length > 1 && ( + <> + or + + {props.value.length > 2 && ( + + )} + + )} + + ) : ( +
+ {props.placeholder ? props.placeholder : 'Select value(s)'} +
+ )} +
+ {showValueModal ? ( + + ) : null} +
+ ); +} \ No newline at end of file diff --git a/frontend/app/components/shared/Filters/FilterAutoComplete/FilterAutoComplete.tsx b/frontend/app/components/shared/Filters/FilterAutoComplete/FilterAutoComplete.tsx index c812d373b..8a2f27498 100644 --- a/frontend/app/components/shared/Filters/FilterAutoComplete/FilterAutoComplete.tsx +++ b/frontend/app/components/shared/Filters/FilterAutoComplete/FilterAutoComplete.tsx @@ -1,117 +1,25 @@ -import React, { useState, useEffect, useCallback, useRef, ChangeEvent, KeyboardEvent } from 'react'; -import { Icon } from 'UI'; -import APIClient from 'App/api_client'; +import React, { + useState, + useEffect, + useCallback, + useRef, +} from 'react'; import { debounce } from 'App/utils'; -import stl from './FilterAutoComplete.module.css'; -import colors from 'App/theme/colors'; -import Select from 'react-select'; -import cn from 'classnames'; import { useStore } from 'App/mstore'; import { observer } from 'mobx-react-lite'; -import { searchService} from 'App/services'; - -const dropdownStyles = { - option: (provided: any, state: any) => ({ - ...provided, - whiteSpace: 'nowrap', - width: '100%', - minWidth: 150, - transition: 'all 0.3s', - overflow: 'hidden', - textOverflow: 'ellipsis', - backgroundColor: state.isFocused ? colors['active-blue'] : 'transparent', - color: state.isFocused ? colors.teal : 'black', - fontSize: '14px', - '&:hover': { - transition: 'all 0.2s', - backgroundColor: colors['active-blue'] - }, - '&:focus': { - transition: 'all 0.2s', - backgroundColor: colors['active-blue'] - } - }), - control: (provided: any) => { - const obj = { - ...provided, - border: 'solid thin transparent !important', - backgroundColor: 'transparent', - cursor: 'pointer', - height: '26px', - minHeight: '26px', - borderRadius: '.5rem', - boxShadow: 'none !important' - }; - return obj; - }, - valueContainer: (provided: any) => ({ - ...provided, - // paddingRight: '0px', - width: 'fit-content', - alignItems: 'center', - height: '26px', - padding: '0 3px' - }), - indicatorsContainer: (provided: any) => ({ - ...provided, - padding: '0px', - height: '26px' - }), - menu: (provided: any, state: any) => ({ - ...provided, - top: 0, - borderRadius: '3px', - border: `1px solid ${colors['gray-light']}`, - backgroundColor: '#fff', - boxShadow: '1px 1px 1px rgba(0, 0, 0, 0.1)', - position: 'absolute', - width: 'unset', - maxWidth: '300px', - overflow: 'hidden', - zIndex: 100 - }), - menuList: (provided: any, state: any) => ({ - ...provided, - padding: 0 - }), - noOptionsMessage: (provided: any) => ({ - ...provided, - whiteSpace: 'nowrap !important' - // minWidth: 'fit-content', - }), - container: (provided: any) => ({ - ...provided, - top: '18px', - position: 'absolute' - }), - input: (provided: any) => ({ - ...provided, - height: '22px', - '& input:focus': { - border: 'none !important' - } - }), - singleValue: (provided: any, state: { isDisabled: any }) => { - const opacity = state.isDisabled ? 0.5 : 1; - const transition = 'opacity 300ms'; - - return { - ...provided, - opacity, - transition, - display: 'flex', - alignItems: 'center', - height: '20px' - }; - } -}; +import { searchService } from 'App/services'; +import { AutocompleteModal, AutoCompleteContainer } from './AutocompleteModal'; type FilterParam = { [key: string]: any }; function processKey(input: FilterParam): FilterParam { const result: FilterParam = {}; for (const key in input) { - if (input.type === 'metadata' && typeof input[key] === 'string' && input[key].startsWith('_')) { + if ( + input.type === 'metadata' && + typeof input[key] === 'string' && + input[key].startsWith('_') + ) { result[key] = input[key].substring(1); } else { result[key] = input[key]; @@ -123,191 +31,114 @@ function processKey(input: FilterParam): FilterParam { interface Props { showOrButton?: boolean; showCloseButton?: boolean; - onRemoveValue?: () => void; - onAddValue?: () => void; + onRemoveValue?: (ind: number) => void; + onAddValue?: (ind: number) => void; endpoint?: string; method?: string; params?: any; headerText?: string; placeholder?: string; - onSelect: (e: any, item: any) => void; + onSelect: (e: any, item: any, index: number) => void; value: any; icon?: string; hideOrText?: boolean; + onApplyValues: (values: string[]) => void; + modalProps?: Record } -const FilterAutoComplete: React.FC = ({ - showCloseButton = false, - placeholder = 'Type to search', - method = 'GET', - showOrButton = false, - endpoint = '', - params = {}, - value = '', - hideOrText = false, - onSelect, - onRemoveValue, - onAddValue - }: Props) => { - const [loading, setLoading] = useState(false); - const [options, setOptions] = useState<{ value: string; label: string }[]>([]); - const [query, setQuery] = useState(value); - const [menuIsOpen, setMenuIsOpen] = useState(false); - const [initialFocus, setInitialFocus] = useState(false); - const [previousQuery, setPreviousQuery] = useState(value); - const selectRef = useRef(null); - const inputRef = useRef(null); - const { filterStore } = useStore(); - const _params = processKey(params); - const filterKey = `${_params.type}${_params.key || ''}`; - const topValues = filterStore.topValues[filterKey] || []; - const [topValuesLoading, setTopValuesLoading] = useState(false); +const FilterAutoComplete = observer( + ({ + params = {}, + onClose, + onApply, + values, + placeholder, + }: { params: any, values: string[], onClose: () => void, onApply: (values: string[]) => void, placeholder?: string }) => { + const [options, setOptions] = useState<{ value: string; label: string }[]>( + [] + ); + const [initialFocus, setInitialFocus] = useState(false); + const [loading, setLoading] = useState(false); + const { filterStore } = useStore(); + const _params = processKey(params); + const filterKey = `${_params.type}${_params.key || ''}`; + const topValues = filterStore.topValues[filterKey] || []; - const loadTopValues = () => { - setTopValuesLoading(true); - filterStore.fetchTopValues(_params.type, _params.key).finally(() => { - setTopValuesLoading(false); - setLoading(false); - }); - }; + const loadTopValues = async () => { + setLoading(true) + await filterStore.fetchTopValues(_params.type, _params.key); + setLoading(false) + }; - useEffect(() => { - if (topValues.length > 0) { - const mappedValues = topValues.map((i) => ({ value: i.value, label: i.value })); - setOptions(mappedValues); - if (!query.length && initialFocus) { - setMenuIsOpen(true); + useEffect(() => { + if (topValues.length > 0) { + const mappedValues = topValues.map((i) => ({ + value: i.value, + label: i.value, + })); + setOptions(mappedValues); } - } - }, [topValues, initialFocus, query.length]); + }, [topValues, initialFocus]); - useEffect(loadTopValues, [_params.type]); + useEffect(() => { void loadTopValues() }, [_params.type]); - useEffect(() => { - setQuery(value); - }, [value]); + const loadOptions = async ( + inputValue: string, + ) => { + if (!inputValue.length) { + const mappedValues = topValues.map((i) => ({ + value: i.value, + label: i.value, + })); + setOptions(mappedValues); + return; + } + setLoading(true); + try { + const data = await searchService.fetchAutoCompleteValues({ + ..._params, + q: inputValue, + }); + const _options = + data.map((i: any) => ({ value: i.value, label: i.value })) || []; + setOptions(_options); + } catch (e) { + throw new Error(e); + } finally { + setLoading(false); + } + }; - const loadOptions = async (inputValue: string, callback: (options: { value: string; label: string }[]) => void) => { - if (!inputValue.length) { - const mappedValues = topValues.map((i) => ({ value: i.value, label: i.value })); - setOptions(mappedValues); - callback(mappedValues); - setLoading(false); - return; - } + const debouncedLoadOptions = useCallback(debounce(loadOptions, 500), [ + params, + topValues, + ]); - try { - // const response = await new APIClient()[method.toLowerCase()](endpoint, { ..._params, q: inputValue }); - const data = await searchService.fetchAutoCompleteValues({ ..._params, q: inputValue }) - // const data = await response.json(); - const _options = data.map((i: any) => ({ value: i.value, label: i.value })) || []; - setOptions(_options); - callback(_options); - } catch (e) { - throw new Error(e); - } finally { - setLoading(false); - } - }; + const handleInputChange = (newValue: string) => { + setInitialFocus(true); + debouncedLoadOptions(newValue); + }; - const debouncedLoadOptions = useCallback(debounce(loadOptions, 1000), [params, topValues]); - - const handleInputChange = (newValue: string) => { - setLoading(true); - setInitialFocus(true); - setQuery(newValue); - debouncedLoadOptions(newValue, () => { - selectRef.current?.focus(); - }); - }; - - const handleChange = (item: { value: string }) => { - setMenuIsOpen(false); - setQuery(item.value); - onSelect(null, item.value); - }; - - const handleFocus = () => { - setInitialFocus(true); - if (!query.length) { - setLoading(topValuesLoading); - setMenuIsOpen(!topValuesLoading && topValues.length > 0); + const handleFocus = () => { + setInitialFocus(true); setOptions(topValues.map((i) => ({ value: i.value, label: i.value }))); - } else { - setMenuIsOpen(true); - } - }; + }; - const handleBlur = () => { - setMenuIsOpen(false); - setInitialFocus(false); - if (query !== previousQuery) { - onSelect(null, query); - } - setPreviousQuery(query); - }; + return + } +); - const selected = value ? options.find((i) => i.value === query) : null; - const uniqueOptions = options.filter((i) => i.value !== query); - const selectOptionsArr = query.length ? [{ value: query, label: query }, ...uniqueOptions] : options; +function AutoCompleteController(props: Props) { + return +} - return ( -
-
- ) => handleInputChange(e.target.value)} - onClick={handleFocus} - onFocus={handleFocus} - onBlur={handleBlur} - placeholder={placeholder} - onKeyDown={(e: KeyboardEvent) => { - if (e.key === 'Enter') { - inputRef.current.blur(); - } - }} - /> - {loading && ( -
- -
- )} - setShowModal(true)} - value={ query } - autoFocus={ true } - type={ type } - placeholder={ placeholder } - onKeyDown={handleKeyDown} - /> -
- { showCloseButton &&
} - { showOrButton &&
or
} -
-
- - { !showOrButton && isMultilple &&
or
} -
+ const [options, setOptions] = useState<{ value: string; label: string }[]>( + values.filter(val => val.length).map((value) => ({ value, label: value })) ); + + const onApplyValues = (values: string[]) => { + setOptions(values.map((value) => ({ value, label: value }))); + onApply(values); + } + + const splitValues = (value: string) => { + const values = value.split(',').filter(v => v.length) + setOptions(values.map((value) => ({ value, label: value }))); + } + + return } -export default FilterAutoCompleteLocal; \ No newline at end of file +function FilterLocalController(props: Props) { + return +} + +export default FilterLocalController; diff --git a/frontend/app/components/shared/Filters/FilterDuration/FilterDuration.js b/frontend/app/components/shared/Filters/FilterDuration/FilterDuration.js index 7780702b8..da79a56a9 100644 --- a/frontend/app/components/shared/Filters/FilterDuration/FilterDuration.js +++ b/frontend/app/components/shared/Filters/FilterDuration/FilterDuration.js @@ -1,6 +1,6 @@ import React from 'react'; import styles from './FilterDuration.module.css'; -import { Input } from 'UI' +import { Input } from 'antd' const fromMs = value => value ? `${ value / 1000 / 60 }` : '' const toMs = value => value !== '' ? value * 1000 * 60 : null diff --git a/frontend/app/components/shared/Filters/FilterItem/FilterItem.tsx b/frontend/app/components/shared/Filters/FilterItem/FilterItem.tsx index d848ffa58..28df71ae1 100644 --- a/frontend/app/components/shared/Filters/FilterItem/FilterItem.tsx +++ b/frontend/app/components/shared/Filters/FilterItem/FilterItem.tsx @@ -7,6 +7,7 @@ import FilterSource from '../FilterSource'; import { FilterKey, FilterType } from 'App/types/filter/filterType'; import SubFilterItem from '../SubFilterItem'; import { CircleMinus } from 'lucide-react'; +import cn from 'classnames' interface Props { filterIndex?: number; @@ -17,6 +18,7 @@ interface Props { saveRequestPayloads?: boolean; disableDelete?: boolean; excludeFilterKeys?: Array; + excludeCategory?: Array; allowedFilterKeys?: Array; readonly?: boolean; hideIndex?: boolean; @@ -34,6 +36,7 @@ function FilterItem(props: Props) { hideDelete = false, allowedFilterKeys = [], excludeFilterKeys = [], + excludeCategory = [], isConditional, hideIndex = false, } = props; @@ -42,7 +45,7 @@ function FilterItem(props: Props) { const replaceFilter = (filter: any) => { props.onUpdate({ ...filter, - value: [''], + value: filter.value, filters: filter.filters ? filter.filters.map((i: any) => ({...i, value: ['']})) : [], }); }; @@ -67,66 +70,71 @@ function FilterItem(props: Props) { }); }; + const isReversed = filter.key === FilterKey.TAGGED_ELEMENT return (
-
+
{!isFilter && !hideIndex && filterIndex >= 0 && (
+ className="flex-shrink-0 w-6 h-6 text-xs flex items-center justify-center rounded-full bg-gray-lighter mr-2"> {filterIndex + 1}
)} - {/* Filter with Source */} - {filter.hasSource && ( - <> - - - - )} +
+ {/* Filter with Source */} + {filter.hasSource && ( + <> + + + + )} - {/* Filter values */} - {!isSubFilter && filter.operatorOptions && ( - <> - - {canShowValues && ( - <> - {props.readonly ? ( -
- {filter.value.map((val: string) => { - return filter.options && filter.options.length - ? filter.options[filter.options.findIndex((i: any) => i.value === val)]?.label ?? val - : val - }).join(', ')} -
- ) : ( - - )} - - )} - - )} + {/* Filter values */} + {!isSubFilter && filter.operatorOptions && ( + <> + + {canShowValues && ( + <> + {props.readonly ? ( +
+ {filter.value.map((val: string) => { + return filter.options && filter.options.length + ? filter.options[filter.options.findIndex((i: any) => i.value === val)]?.label ?? val + : val + }).join(', ')} +
+ ) : ( + + )} + + )} + + )} +
{/* filters */} {isSubFilter && ( @@ -156,6 +164,7 @@ function FilterItem(props: Props) { type="text" onClick={props.onRemoveFilter} size="small" + className='btn-remove-step mt-2' > diff --git a/frontend/app/components/shared/Filters/FilterList/EventsOrder.tsx b/frontend/app/components/shared/Filters/FilterList/EventsOrder.tsx index 6f7f8a0b6..619f3384d 100644 --- a/frontend/app/components/shared/Filters/FilterList/EventsOrder.tsx +++ b/frontend/app/components/shared/Filters/FilterList/EventsOrder.tsx @@ -1,55 +1,57 @@ -import {observer} from "mobx-react-lite"; -import {Tooltip} from "UI"; -import {Segmented} from "antd"; -import React from "react"; +import React from 'react'; +import { observer } from 'mobx-react-lite'; +import { Dropdown, Button, Tooltip } from 'antd'; -const EventsOrder = observer((props: { - onChange: (e: any, v: any) => void, - filter: any, -}) => { - const {filter, onChange} = props; +const EventsOrder = observer( + (props: { onChange: (e: any, v: any) => void; filter: any }) => { + const { filter, onChange } = props; const eventsOrderSupport = filter.eventsOrderSupport; - const options = [ - { - name: 'eventsOrder', - label: 'THEN', - value: 'then', - disabled: eventsOrderSupport && !eventsOrderSupport.includes('then'), - }, - { - name: 'eventsOrder', - label: 'AND', - value: 'and', - disabled: eventsOrderSupport && !eventsOrderSupport.includes('and'), - }, - { - name: 'eventsOrder', - label: 'OR', - value: 'or', - disabled: eventsOrderSupport && !eventsOrderSupport.includes('or'), - }, + + const menuItems = [ + { + key: 'then', + label: 'THEN', + disabled: eventsOrderSupport && !eventsOrderSupport.includes('then'), + }, + { + key: 'and', + label: 'AND', + disabled: eventsOrderSupport && !eventsOrderSupport.includes('and'), + }, + { + key: 'or', + label: 'OR', + disabled: eventsOrderSupport && !eventsOrderSupport.includes('or'), + }, ]; + const onClick = ({ key }: any) => { + onChange(null, { name: 'eventsOrder', value: key, key }); + }; - return
-
item.key === filter.eventsOrder + )?.label; + return ( +
+ - -
Events Order
-
-
+
Events Order
+ - onChange(null, options.find((i) => i.value === v))} - value={filter.eventsOrder} - options={options} - /> -
; -}); + + + +
+ ); + } +); export default EventsOrder; diff --git a/frontend/app/components/shared/Filters/FilterList/FilterList.tsx b/frontend/app/components/shared/Filters/FilterList/FilterList.tsx index 0a0ff0572..e2352fe7f 100644 --- a/frontend/app/components/shared/Filters/FilterList/FilterList.tsx +++ b/frontend/app/components/shared/Filters/FilterList/FilterList.tsx @@ -1,223 +1,315 @@ -import {Space} from 'antd'; -import {List} from 'immutable'; -import {GripHorizontal} from 'lucide-react'; -import {observer} from 'mobx-react-lite'; -import React, {useEffect} from 'react'; - +import { GripVertical, Plus, Filter } from 'lucide-react'; +import { observer } from 'mobx-react-lite'; +import React, { useEffect } from 'react'; +import { Button } from 'antd'; +import cn from 'classnames'; import FilterItem from '../FilterItem'; -import EventsOrder from "Shared/Filters/FilterList/EventsOrder"; +import EventsOrder from 'Shared/Filters/FilterList/EventsOrder'; +import FilterSelection from '../FilterSelection/FilterSelection'; interface Props { - filter?: any; // event/filter - onUpdateFilter: (filterIndex: any, filter: any) => void; - onFilterMove?: (filters: any) => void; - onRemoveFilter: (filterIndex: any) => void; - onChangeEventsOrder: (e: any, {name, value}: any) => void; - hideEventsOrder?: boolean; - observeChanges?: () => void; - saveRequestPayloads?: boolean; - supportsEmpty?: boolean; - readonly?: boolean; - excludeFilterKeys?: Array; - isConditional?: boolean; - actions?: React.ReactNode[]; + filter?: any; + onUpdateFilter: (filterIndex: any, filter: any) => void; + onFilterMove?: (filters: any) => void; + onRemoveFilter: (filterIndex: any) => void; + onChangeEventsOrder: (e: any, { name, value }: any) => void; + hideEventsOrder?: boolean; + observeChanges?: () => void; + saveRequestPayloads?: boolean; + supportsEmpty?: boolean; + readonly?: boolean; + excludeFilterKeys?: Array; + excludeCategory?: string[]; + isConditional?: boolean; + actions?: React.ReactNode[]; + onAddFilter: (filter: any) => void; + mergeDown?: boolean; + mergeUp?: boolean; + borderless?: boolean; + cannotAdd?: boolean; } -function FilterList(props: Props) { - const { - observeChanges = () => { - }, - filter, - hideEventsOrder = false, - saveRequestPayloads, - supportsEmpty = true, - excludeFilterKeys = [], - isConditional, - actions = [] - } = props; +export const FilterList = observer((props: Props) => { + const { + observeChanges = () => {}, + filter, + excludeFilterKeys = [], + isConditional, + onAddFilter, + readonly, + borderless, + excludeCategory, + } = props; - const filters = filter.filters; - const hasEvents = filters.filter((i: any) => i.isEvent).length > 0; - const hasFilters = filters.filter((i: any) => !i.isEvent).length > 0; + const filters = filter.filters; + useEffect(observeChanges, [filters]); - let rowIndex = 0; - const cannotDeleteFilter = hasEvents && !supportsEmpty; + const onRemoveFilter = (filterIndex: any) => { + props.onRemoveFilter(filterIndex); + }; + return ( +
+
+
Filters
+ + + +
+ {filters.map((filter: any, filterIndex: any) => + !filter.isEvent ? ( +
+ props.onUpdateFilter(filterIndex, filter)} + onRemoveFilter={() => onRemoveFilter(filterIndex)} + excludeFilterKeys={excludeFilterKeys} + isConditional={isConditional} + /> +
+ ) : null + )} +
+ ); +}); - useEffect(observeChanges, [filters]); +export const EventsList = observer((props: Props) => { + const { + observeChanges = () => {}, + filter, + hideEventsOrder = false, + saveRequestPayloads, + supportsEmpty = true, + excludeFilterKeys = [], + isConditional, + actions = [], + onAddFilter, + cannotAdd, + excludeCategory, + } = props; - const onRemoveFilter = (filterIndex: any) => { - props.onRemoveFilter(filterIndex); - }; + const filters = filter.filters; + const hasEvents = filters.filter((i: any) => i.isEvent).length > 0; - const [hoveredItem, setHoveredItem] = React.useState>({ - i: null, - position: null, - }); - const [draggedInd, setDraggedItem] = React.useState(null); + let rowIndex = 0; + const cannotDeleteFilter = hasEvents && !supportsEmpty; - const handleDragOverEv = (event: Record, i: number) => { - event.preventDefault(); - const target = event.currentTarget.getBoundingClientRect(); - const hoverMiddleY = (target.bottom - target.top) / 2; - const hoverClientY = event.clientY - target.top; + useEffect(observeChanges, [filters]); - const position = hoverClientY < hoverMiddleY ? 'top' : 'bottom'; - setHoveredItem({position, i}); - }; + const onRemoveFilter = (filterIndex: any) => { + props.onRemoveFilter(filterIndex); + }; - const calculateNewPosition = React.useCallback( - (draggedInd: number, hoveredIndex: number, hoveredPosition: string) => { - if (hoveredPosition === 'bottom') { - hoveredIndex++; - } - return draggedInd < hoveredIndex ? hoveredIndex - 1 : hoveredIndex; - }, - [] - ); + const [hoveredItem, setHoveredItem] = React.useState>({ + i: null, + position: null, + }); + const [draggedInd, setDraggedItem] = React.useState(null); - const handleDragStart = React.useCallback(( - ev: Record, - index: number, - elId: string - ) => { - ev.dataTransfer.setData("text/plain", index.toString()); - setDraggedItem(index); - const el = document.getElementById(elId); - if (el) { - ev.dataTransfer.setDragImage(el, 0, 0); - } - }, []) + const handleDragOverEv = (event: Record, i: number) => { + event.preventDefault(); + const target = event.currentTarget.getBoundingClientRect(); + const hoverMiddleY = (target.bottom - target.top) / 2; + const hoverClientY = event.clientY - target.top; - const handleDrop = React.useCallback( - (event: Record) => { - event.preventDefault(); - if (draggedInd === null) return; - const newItems = filters.toArray(); - const newPosition = calculateNewPosition( - draggedInd, - hoveredItem.i, - hoveredItem.position - ); + const position = hoverClientY < hoverMiddleY ? 'top' : 'bottom'; + setHoveredItem({ position, i }); + }; - const reorderedItem = newItems.splice(draggedInd, 1)[0]; - newItems.splice(newPosition, 0, reorderedItem); + const calculateNewPosition = React.useCallback( + (draggedInd: number, hoveredIndex: number, hoveredPosition: string) => { + if (hoveredPosition === 'bottom') { + hoveredIndex++; + } + return draggedInd < hoveredIndex ? hoveredIndex - 1 : hoveredIndex; + }, + [] + ); - props.onFilterMove?.(List(newItems)); - setHoveredItem({i: null, position: null}); - setDraggedItem(null); - }, - [draggedInd, hoveredItem, filters, props.onFilterMove] - ); + const handleDragStart = React.useCallback( + (ev: Record, index: number, elId: string) => { + ev.dataTransfer.setData('text/plain', index.toString()); + setDraggedItem(index); + const el = document.getElementById(elId); + if (el) { + ev.dataTransfer.setDragImage(el, 0, 0); + } + }, + [] + ); - const eventsNum = filters.filter((i: any) => i.isEvent).size - return ( -
- {hasEvents && ( - <> -
-
- {filter.eventsHeader || 'EVENTS'} -
+ const handleDrop = React.useCallback( + (event: Record) => { + event.preventDefault(); + if (draggedInd === null) return; + const newItems = filters; + const newPosition = calculateNewPosition( + draggedInd, + hoveredItem.i, + hoveredItem.position + ); - - {!hideEventsOrder && } - {actions && actions.map((action, index) => ( -
{action}
- ))} -
-
-
- {filters.map((filter: any, filterIndex: number) => - filter.isEvent ? ( -
handleDragOverEv(e, filterIndex)} - onDrop={(e) => handleDrop(e)} - key={`${filter.key}-${filterIndex}`} - > - {!!props.onFilterMove && eventsNum > 1 ? ( -
- handleDragStart( - e, - filterIndex, - `${filter.key}-${filterIndex}` - ) - } - > - -
- ) : null} - - props.onUpdateFilter(filterIndex, filter) - } - onRemoveFilter={() => onRemoveFilter(filterIndex)} - saveRequestPayloads={saveRequestPayloads} - disableDelete={cannotDeleteFilter} - excludeFilterKeys={excludeFilterKeys} - readonly={props.readonly} - isConditional={isConditional} - /> -
- ) : null - )} -
-
- - )} + const reorderedItem = newItems.splice(draggedInd, 1)[0]; + newItems.splice(newPosition, 0, reorderedItem); - {hasFilters && ( - <> - {hasEvents &&
} -
FILTERS
- {filters.map((filter: any, filterIndex: any) => - !filter.isEvent ? ( -
- props.onUpdateFilter(filterIndex, filter)} - onRemoveFilter={() => onRemoveFilter(filterIndex)} - excludeFilterKeys={excludeFilterKeys} - isConditional={isConditional} - /> -
- ) : null - )} - - )} + props.onFilterMove?.(newItems); + setHoveredItem({ i: null, position: null }); + setDraggedItem(null); + }, + [draggedInd, hoveredItem, filters, props.onFilterMove] + ); + + const eventsNum = filters.filter((i: any) => i.isEvent).length; + return ( +
+
+
Events
+ {cannotAdd ? null : ( + + + + )} + +
+ {!hideEventsOrder && ( + + )} + {actions && + actions.map((action, index) =>
{action}
)}
- ); -} - -export default observer(FilterList); +
+
+ {filters.map((filter: any, filterIndex: number) => + filter.isEvent ? ( +
handleDragOverEv(e, filterIndex)} + onDrop={(e) => handleDrop(e)} + key={`${filter.key}-${filterIndex}`} + > + {!!props.onFilterMove && eventsNum > 1 ? ( +
+ handleDragStart(e, filterIndex, `${filter.key}-${filterIndex}`) + } + onDragEnd={() => { + setHoveredItem({ i: null, position: null }); + setDraggedItem(null); + }} + style={{ + cursor: draggedInd !== null ? 'grabbing' : 'grab', + }} + > + +
+ ) : null} + props.onUpdateFilter(filterIndex, filter)} + onRemoveFilter={() => onRemoveFilter(filterIndex)} + saveRequestPayloads={saveRequestPayloads} + disableDelete={cannotDeleteFilter} + excludeFilterKeys={excludeFilterKeys} + readonly={props.readonly} + isConditional={isConditional} + excludeCategory={excludeCategory} + /> +
+ ) : null + )} +
+
+ ); +}); diff --git a/frontend/app/components/shared/Filters/FilterList/index.ts b/frontend/app/components/shared/Filters/FilterList/index.ts index ecf0adf70..cce8ebe5e 100644 --- a/frontend/app/components/shared/Filters/FilterList/index.ts +++ b/frontend/app/components/shared/Filters/FilterList/index.ts @@ -1 +1 @@ -export { default } from './FilterList'; \ No newline at end of file +export { FilterList, EventsList } from './FilterList'; \ No newline at end of file diff --git a/frontend/app/components/shared/Filters/FilterModal/FilterModal.module.css b/frontend/app/components/shared/Filters/FilterModal/FilterModal.module.css index 077a57370..c95cef973 100644 --- a/frontend/app/components/shared/Filters/FilterModal/FilterModal.module.css +++ b/frontend/app/components/shared/Filters/FilterModal/FilterModal.module.css @@ -2,8 +2,6 @@ border-radius: .5rem; border: solid thin $gray-light; padding: 20px; - overflow: hidden; - overflow-y: auto; box-shadow: 0 2px 2px 0 $gray-light; } .optionItem { diff --git a/frontend/app/components/shared/Filters/FilterModal/FilterModal.tsx b/frontend/app/components/shared/Filters/FilterModal/FilterModal.tsx index 490126b36..f5274a4bd 100644 --- a/frontend/app/components/shared/Filters/FilterModal/FilterModal.tsx +++ b/frontend/app/components/shared/Filters/FilterModal/FilterModal.tsx @@ -7,11 +7,14 @@ import { CircleAlert, Clock2, Code, - ContactRound, CornerDownRight, + ContactRound, + CornerDownRight, Cpu, Earth, - FileStack, Layers, - MapPin, Megaphone, + FileStack, + Layers, + MapPin, + Megaphone, MemoryStick, MonitorSmartphone, Navigation, @@ -25,63 +28,70 @@ import { Timer, VenetianMask, Workflow, - Flag + Flag, + ChevronRight, } from 'lucide-react'; import React from 'react'; import { Icon, Loader } from 'UI'; import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG'; +import { Input } from 'antd'; -import { FilterKey } from 'Types/filter/filterType'; +import { FilterCategory, FilterKey, FilterType } from "Types/filter/filterType"; import stl from './FilterModal.module.css'; import { observer } from 'mobx-react-lite'; import { useStore } from 'App/mstore'; -const IconMap = { - [FilterKey.CLICK]: , - [FilterKey.LOCATION]: , - [FilterKey.INPUT]: , - [FilterKey.CUSTOM]: , - [FilterKey.FETCH]: , - [FilterKey.GRAPHQL]: , - [FilterKey.STATEACTION]: , - [FilterKey.ERROR]: , - [FilterKey.ISSUE]: , - [FilterKey.FETCH_FAILED]: , - [FilterKey.DOM_COMPLETE]: , - [FilterKey.LARGEST_CONTENTFUL_PAINT_TIME]: , - [FilterKey.TTFB]: , - [FilterKey.AVG_CPU_LOAD]: , - [FilterKey.AVG_MEMORY_USAGE]: , - [FilterKey.USERID]: , - [FilterKey.USERANONYMOUSID]: , - [FilterKey.USER_CITY]: , - [FilterKey.USER_STATE]: , - [FilterKey.USER_COUNTRY]: , - [FilterKey.USER_DEVICE]: , - [FilterKey.USER_OS]: , - [FilterKey.USER_BROWSER]: , - [FilterKey.PLATFORM]: , - [FilterKey.REVID]: , - [FilterKey.REFERRER]: , - [FilterKey.DURATION]: , - [FilterKey.TAGGED_ELEMENT]: , - [FilterKey.METADATA]: , - [FilterKey.UTM_SOURCE]: , - [FilterKey.UTM_MEDIUM]: , - [FilterKey.UTM_CAMPAIGN]: , - [FilterKey.FEATURE_FLAG]: +export const IconMap = { + [FilterKey.CLICK]: , + [FilterKey.LOCATION]: , + [FilterKey.INPUT]: , + [FilterKey.CUSTOM]: , + [FilterKey.FETCH]: , + [FilterKey.GRAPHQL]: , + [FilterKey.STATEACTION]: , + [FilterKey.ERROR]: , + [FilterKey.ISSUE]: , + [FilterKey.FETCH_FAILED]: , + [FilterKey.DOM_COMPLETE]: , + [FilterKey.LARGEST_CONTENTFUL_PAINT_TIME]: , + [FilterKey.TTFB]: , + [FilterKey.AVG_CPU_LOAD]: , + [FilterKey.AVG_MEMORY_USAGE]: , + [FilterKey.USERID]: , + [FilterKey.USERANONYMOUSID]: , + [FilterKey.USER_CITY]: , + [FilterKey.USER_STATE]: , + [FilterKey.USER_COUNTRY]: , + [FilterKey.USER_DEVICE]: , + [FilterKey.USER_OS]: , + [FilterKey.USER_BROWSER]: , + [FilterKey.PLATFORM]: , + [FilterKey.REVID]: , + [FilterKey.REFERRER]: , + [FilterKey.DURATION]: , + [FilterKey.TAGGED_ELEMENT]: , + [FilterKey.METADATA]: , + [FilterKey.UTM_SOURCE]: , + [FilterKey.UTM_MEDIUM]: , + [FilterKey.UTM_CAMPAIGN]: , + [FilterKey.FEATURE_FLAG]: , }; function filterJson( jsonObj: Record, excludeKeys: string[] = [], - allowedFilterKeys: string[] = [] + excludeCategory: string[] = [], + allowedFilterKeys: string[] = [], + mode: 'filters' | 'events' ): Record { return Object.fromEntries( Object.entries(jsonObj) .map(([key, value]) => { - const arr = value.filter((i: { key: string }) => { + const arr = value.filter((i: { key: string, isEvent: boolean, category: string }) => { + if (excludeCategory.includes(i.category)) return false; if (excludeKeys.includes(i.key)) return false; + if (mode === 'events' && !i.isEvent) return false; + if (mode === 'filters' && i.isEvent) return false; return !( allowedFilterKeys.length > 0 && !allowedFilterKeys.includes(i.key) ); @@ -102,8 +112,8 @@ export const getMatchingEntries = ( if (lowerCaseQuery.length === 0) return { - matchingCategories: Object.keys(filters), - matchingFilters: filters + matchingCategories: ['All', ...Object.keys(filters)], + matchingFilters: filters, }; Object.keys(filters).forEach((name) => { @@ -120,7 +130,7 @@ export const getMatchingEntries = ( } }); - return { matchingCategories, matchingFilters }; + return { matchingCategories: ['All', ...matchingCategories], matchingFilters }; }; interface Props { @@ -131,42 +141,79 @@ interface Props { isMainSearch?: boolean; searchQuery?: string; excludeFilterKeys?: Array; + excludeCategory?: Array; allowedFilterKeys?: Array; isConditional?: boolean; isMobile?: boolean; + mode: 'filters' | 'events'; } +export const getNewIcon = (filter: Record) => { + if (filter.icon?.includes('metadata')) { + return IconMap[FilterKey.METADATA]; + } + // @ts-ignore + if (IconMap[filter.key]) { + // @ts-ignore + return IconMap[filter.key]; + } else return ; +}; + function FilterModal(props: Props) { const { isLive, onFilterClick = () => null, isMainSearch = false, - searchQuery = '', excludeFilterKeys = [], + excludeCategory = [], allowedFilterKeys = [], isConditional, + mode, } = props; + const [searchQuery, setSearchQuery] = React.useState(''); + const [category, setCategory] = React.useState('All'); const { searchStore, searchStoreLive, projectsStore } = useStore(); const isMobile = projectsStore.active?.platform === 'ios'; // TODO - should be using mobile once the app is changed - const filters = isLive ? searchStoreLive.filterListLive : (isMobile ? searchStore.filterListMobile : searchStoreLive.filterList); + const filters = isLive + ? searchStoreLive.filterListLive + : isMobile + ? searchStore.filterListMobile + : searchStoreLive.filterList; const conditionalFilters = searchStore.filterListConditional; const mobileConditionalFilters = searchStore.filterListMobileConditional; const showSearchList = isMainSearch && searchQuery.length > 0; - const filterSearchList = isLive ? searchStoreLive.filterSearchList : searchStore.filterSearchList; - const fetchingFilterSearchList = isLive ? searchStoreLive.loadingFilterSearch : searchStore.loadingFilterSearch; + const filterSearchList = isLive + ? searchStoreLive.filterSearchList + : searchStore.filterSearchList; + const fetchingFilterSearchList = isLive + ? searchStoreLive.loadingFilterSearch + : searchStore.loadingFilterSearch; + const parseAndAdd = (filter) => { + if (filter.category === FilterCategory.EVENTS && filter.key.startsWith('_')) { + filter.value = [filter.key.substring(1)]; + filter.key = FilterKey.CUSTOM; + filter.label = 'Custom Events' + } + if (filter.type === FilterType.ISSUE && filter.key.startsWith(`${FilterKey.ISSUE}_`)) { + filter.key = FilterKey.ISSUE; + } + onFilterClick(filter) + } const onFilterSearchClick = (filter: any) => { const _filter = { ...filtersMap[filter.type] }; _filter.value = [filter.value]; - onFilterClick(_filter); + parseAndAdd(_filter); }; const filterJsonObj = isConditional - ? isMobile ? mobileConditionalFilters : conditionalFilters + ? isMobile + ? mobileConditionalFilters + : conditionalFilters : filters; const { matchingCategories, matchingFilters } = getMatchingEntries( searchQuery, - filterJson(filterJsonObj, excludeFilterKeys, allowedFilterKeys) + filterJson(filterJsonObj, excludeFilterKeys, excludeCategory, allowedFilterKeys, mode) ); const isResultEmpty = @@ -174,53 +221,63 @@ function FilterModal(props: Props) { matchingCategories.length === 0 && Object.keys(matchingFilters).length === 0; - const getNewIcon = (filter: Record) => { - if (filter.icon?.includes('metadata')) { - return IconMap[FilterKey.METADATA]; - } - // @ts-ignore - if (IconMap[filter.key]) { - // @ts-ignore - return IconMap[filter.key]; - } else return ; - }; + const displayedFilters = + category === 'All' + ? Object.entries(matchingFilters).flatMap(([category, filters]) => + filters.map((f: any) => ({ ...f, category })) + ) + : matchingFilters[category]; + + + console.log(displayedFilters) return (
-
1 ? 'auto 200px' : 1 }} - > - {matchingCategories.map((key) => { - return ( + setSearchQuery(e.target.value)} + /> +
+
+ {matchingCategories.map((key) => (
setCategory(key)} + className={cn('rounded-xl px-4 py-2 hover:bg-active-blue capitalize cursor-pointer font-medium', key === category ? 'bg-active-blue text-teal' : '')} > -
- {key} -
-
- {matchingFilters[key] && - matchingFilters[key].map((filter: Record) => ( -
onFilterClick({ ...filter, value: [''] })} - > - {getNewIcon(filter)} - {filter.label} -
- ))} -
+ {key}
- ); - })} + ))} +
+
+ {displayedFilters.length + ? displayedFilters.map((filter: Record) => ( +
parseAndAdd({ ...filter })} + > + {filter.category ?
+ {filter.subCategory ? filter.subCategory : filter.category} + +
: null} +
+ {getNewIcon(filter)} + {filter.label} +
+
+ )) + : null} +
{showSearchList && ( diff --git a/frontend/app/components/shared/Filters/FilterOperator/FilterOperator.tsx b/frontend/app/components/shared/Filters/FilterOperator/FilterOperator.tsx index bc2e10c34..84e8bda4f 100644 --- a/frontend/app/components/shared/Filters/FilterOperator/FilterOperator.tsx +++ b/frontend/app/components/shared/Filters/FilterOperator/FilterOperator.tsx @@ -1,3 +1,4 @@ +import { backgroundClip } from 'html2canvas/dist/types/css/property-descriptors/background-clip'; import React from 'react'; import Select from 'Shared/Select'; @@ -5,22 +6,24 @@ const dropdownStyles = { control: (provided: any) => { const obj = { ...provided, - border: 'solid thin #ddd !important', + border: 'solid thin #ddd', boxShadow: 'none !important', cursor: 'pointer', height: '26px', minHeight: '26px', - backgroundColor: '#f6f6f6', + backgroundColor: 'white', + borderRadius: '.5rem', '&:hover': { - backgroundColor: '#EEEEEE', - }, + borderColor: 'rgb(115 115 115 / 0.9)', } + } + return obj; }, valueContainer: (provided: any) => ({ ...provided, - paddingRight: '0px', width: 'fit-content', + height: 26, '& input': { marginTop: '-3px', }, @@ -29,9 +32,7 @@ const dropdownStyles = { ...provided, }), indicatorsContainer: (provided: any) => ({ - ...provided, - padding: '0px', - height: '26px', + display: 'none', }), // option: (provided: any, state: any) => ({ // ...provided, @@ -39,11 +40,13 @@ const dropdownStyles = { // }), menu: (provided: any, state: any) => ({ ...provided, - top: 20, + marginTop: '0.5rem', left: 0, minWidth: 'fit-content', overflow: 'hidden', zIndex: 100, + border: 'none', + boxShadow: '0px 4px 10px rgba(0,0,0, 0.15)', }), container: (provided: any) => ({ ...provided, @@ -81,6 +84,7 @@ function FilterOperator(props: Props) { isDisabled={isDisabled} value={value ? options?.find((i: any) => i.value === value) : null} onChange={({ value }: any) => onChange(null, { name: 'operator', value: value.value })} + className='btn-event-operator' />
); diff --git a/frontend/app/components/shared/Filters/FilterSelection/FilterSelection.tsx b/frontend/app/components/shared/Filters/FilterSelection/FilterSelection.tsx index d3eb42b2d..1fdfd970a 100644 --- a/frontend/app/components/shared/Filters/FilterSelection/FilterSelection.tsx +++ b/frontend/app/components/shared/Filters/FilterSelection/FilterSelection.tsx @@ -1,22 +1,24 @@ import React, { useState } from 'react'; import FilterModal from '../FilterModal'; import OutsideClickDetectingDiv from 'Shared/OutsideClickDetectingDiv'; -import { Icon } from 'UI'; import { assist as assistRoute, isRoute } from 'App/routes'; import cn from 'classnames'; import { observer } from 'mobx-react-lite'; +import { getNewIcon } from "../FilterModal/FilterModal"; const ASSIST_ROUTE = assistRoute(); interface Props { - filter?: any; // event/filter + filter?: any; onFilterClick: (filter: any) => void; children?: any; excludeFilterKeys?: Array; + excludeCategory?: Array; allowedFilterKeys?: Array; disabled?: boolean; isConditional?: boolean; isMobile?: boolean; + mode: 'filters' | 'events'; } function FilterSelection(props: Props) { @@ -25,60 +27,75 @@ function FilterSelection(props: Props) { onFilterClick, children, excludeFilterKeys = [], + excludeCategory = [], allowedFilterKeys = [], disabled = false, isConditional, - isMobile + isMobile, + mode, } = props; const [showModal, setShowModal] = useState(false); + const onAddFilter = (filter: any) => { + onFilterClick(filter); + setShowModal(false); + } + + const label = filter?.category === 'Issue' ? 'Issue' : filter?.label; return ( -
+
- setTimeout(function() { + onClickOutside={() => { + setTimeout(() => { setShowModal(false); - }, 200) + }, 0) + } } > {children ? ( React.cloneElement(children, { onClick: (e) => { - e.stopPropagation(); - e.preventDefault(); setShowModal(true); }, - disabled: disabled + disabled: disabled, }) ) : (
setShowModal(true)} > +
{getNewIcon(filter)}
+
{`${filter.category} •`}
- {filter.label} + {label}
- +
+ )} + {showModal && ( +
+
)}
- {showModal && ( -
- -
- )}
); } diff --git a/frontend/app/components/shared/Filters/FilterValue/FilterValue.tsx b/frontend/app/components/shared/Filters/FilterValue/FilterValue.tsx index f88c03a5b..f365ddeac 100644 --- a/frontend/app/components/shared/Filters/FilterValue/FilterValue.tsx +++ b/frontend/app/components/shared/Filters/FilterValue/FilterValue.tsx @@ -7,183 +7,184 @@ import FilterDuration from '../FilterDuration'; import { debounce } from 'App/utils'; import { assist as assistRoute, isRoute } from 'App/routes'; import cn from 'classnames'; +import { observer } from 'mobx-react-lite'; const ASSIST_ROUTE = assistRoute(); interface Props { - filter: any; - onUpdate: (filter: any) => void; - isConditional?: boolean; + filter: any; + onUpdate: (filter: any) => void; + isConditional?: boolean; } function FilterValue(props: Props) { - const { filter } = props; - const [durationValues, setDurationValues] = useState({ - minDuration: filter.value[0], - maxDuration: filter.value.length > 1 ? filter.value[1] : filter.value[0], - }); - const showCloseButton = filter.value.length > 1; - const lastIndex = filter.value.length - 1; + const { filter } = props; + const [durationValues, setDurationValues] = useState({ + minDuration: filter.value?.[0], + maxDuration: filter.value.length > 1 ? filter.value[1] : filter.value[0], + }); + const showCloseButton = filter.value.length > 1; - const onAddValue = () => { - const newValue = filter.value.concat(''); - props.onUpdate({ ...filter, value: newValue }); - }; + const onAddValue = () => { + const newValue = filter.value.concat(''); + props.onUpdate({ ...filter, value: newValue }); + }; - const onRemoveValue = (valueIndex: any) => { - const newValue = filter.value.filter((_: any, index: any) => index !== valueIndex); - props.onUpdate({ ...filter, value: newValue }); - }; + const onApplyValues = (values: string[]) => { + props.onUpdate({ ...filter, value: values }); + } - const onChange = (e: any, item: any, valueIndex: any) => { - const newValues = filter.value.map((_: any, _index: any) => { - if (_index === valueIndex) { - return item; - } - return _; - }); - props.onUpdate({ ...filter, value: newValues }); - }; - - const debounceOnSelect = React.useCallback(debounce(onChange, 500), [onChange]); - - const onDurationChange = (newValues: any) => { - setDurationValues({ ...durationValues, ...newValues }); - }; - - const handleBlur = () => { - if (filter.type === FilterType.DURATION) { - const { maxDuration, minDuration } = filter; - if (maxDuration || minDuration) return; - if (maxDuration !== durationValues.maxDuration || minDuration !== durationValues.minDuration) { - props.onUpdate({ ...filter, value: [durationValues.minDuration, durationValues.maxDuration] }); - } - } - }; - - const getParms = (key: any) => { - let params: any = { type: filter.key }; - switch (filter.category) { - case FilterCategory.METADATA: - params = { type: FilterKey.METADATA, key: key }; - } - - if (isRoute(ASSIST_ROUTE, window.location.pathname)) { - params = { ...params, live: true }; - } - - return params; - }; - - const renderValueFiled = (value: any, valueIndex: any) => { - const showOrButton = valueIndex === lastIndex && filter.type !== FilterType.NUMBER; - switch (filter.type) { - case FilterType.STRING: - return ( - onRemoveValue(valueIndex)} - onSelect={(e, item) => debounceOnSelect(e, item, valueIndex)} - icon={filter.icon} - /> - ); - case FilterType.DROPDOWN: - return ( - onChange(null, { value }, valueIndex)} - /> - ); - case FilterType.ISSUE: - case FilterType.MULTIPLE_DROPDOWN: - return ( - onChange(null, value, valueIndex)} - onAddValue={onAddValue} - onRemoveValue={() => onRemoveValue(valueIndex)} - showCloseButton={showCloseButton} - showOrButton={showOrButton} - /> - ); - case FilterType.DURATION: - return ( - - ); - case FilterType.NUMBER_MULTIPLE: - return ( - onRemoveValue(valueIndex)} - onSelect={(e, item) => debounceOnSelect(e, item, valueIndex)} - icon={filter.icon} - type="number" - /> - ); - case FilterType.NUMBER: - return ( - onRemoveValue(valueIndex)} - onSelect={(e, item) => debounceOnSelect(e, item, valueIndex)} - icon={filter.icon} - type="number" - allowDecimals={false} - isMultilple={false} - /> - ); - case FilterType.MULTIPLE: - return ( - onRemoveValue(valueIndex)} - method={'GET'} - endpoint="/PROJECT_ID/events/search" - params={getParms(filter.key)} - headerText={''} - placeholder={filter.placeholder} - onSelect={(e, item) => onChange(e, item, valueIndex)} - icon={filter.icon} - /> - ); - } - }; - - return ( -
- {filter.type === FilterType.DURATION - ? renderValueFiled(filter.value, 0) - : filter.value && - filter.value.map((value: any, valueIndex: any) =>
{renderValueFiled(value, valueIndex)}
)} -
+ const onRemoveValue = (valueIndex: any) => { + const newValue = filter.value.filter( + (_: any, index: any) => index !== valueIndex ); + props.onUpdate({ ...filter, value: newValue }); + }; + + const onChange = (e: any, item: any, valueIndex: any) => { + const newValues = filter.value.map((_: any, _index: any) => { + if (_index === valueIndex) { + return item; + } + return _; + }); + props.onUpdate({ ...filter, value: newValues }); + }; + + const debounceOnSelect = React.useCallback(debounce(onChange, 500), [ + onChange, + ]); + + const onDurationChange = (newValues: any) => { + setDurationValues({ ...durationValues, ...newValues }); + }; + + const handleBlur = () => { + if (filter.type === FilterType.DURATION) { + const { maxDuration, minDuration } = filter; + if (maxDuration || minDuration) return; + if ( + maxDuration !== durationValues.maxDuration || + minDuration !== durationValues.minDuration + ) { + props.onUpdate({ + ...filter, + value: [durationValues.minDuration, durationValues.maxDuration], + }); + } + } + }; + + const getParms = (key: any) => { + let params: any = { type: filter.key }; + switch (filter.category) { + case FilterCategory.METADATA: + params = { type: FilterKey.METADATA, key: key }; + } + + if (isRoute(ASSIST_ROUTE, window.location.pathname)) { + params = { ...params, live: true }; + } + + return params; + }; + + const renderValueFiled = (value: any[]) => { + const showOrButton = filter.value.length > 1; + const BaseFilterLocalAutoComplete = (props) => ( + onRemoveValue(index)} + onSelect={(e, item, index) => debounceOnSelect(e, item, index)} + icon={filter.icon} + placeholder={filter.placeholder} + modalProps={{ placeholder: '' }} + {...props} + /> + ); + const BaseDropDown = (props) => ( + onChange(null, { value: item.value }, index)} + {...props} + /> + ) + switch (filter.type) { + case FilterType.NUMBER_MULTIPLE: + return ; + case FilterType.NUMBER: + return ( + + ); + case FilterType.STRING: + return ; + case FilterType.DROPDOWN: + return ( + + ); + case FilterType.ISSUE: + case FilterType.MULTIPLE_DROPDOWN: + return ( + onRemoveValue(ind)} + showCloseButton={showCloseButton} + showOrButton={showOrButton} + placeholder={filter.placeholder} + /> + ); + case FilterType.DURATION: + return ( + + ); + case FilterType.MULTIPLE: + return ( + onRemoveValue(index)} + method={'GET'} + endpoint="/PROJECT_ID/events/search" + params={getParms(filter.key)} + headerText={''} + placeholder={filter.placeholder} + onSelect={(e, item, index) => onChange(e, item, index)} + icon={filter.icon} + modalProps={{ placeholder: 'Search' }} + /> + ); + } + }; + + return ( +
+ {renderValueFiled(filter.value)} +
+ ); } -export default FilterValue; +export default observer(FilterValue); diff --git a/frontend/app/components/shared/Filters/FilterValueDropdown/FilterValueDropdown.module.css b/frontend/app/components/shared/Filters/FilterValueDropdown/FilterValueDropdown.module.css index 6e34010b3..913e37c72 100644 --- a/frontend/app/components/shared/Filters/FilterValueDropdown/FilterValueDropdown.module.css +++ b/frontend/app/components/shared/Filters/FilterValueDropdown/FilterValueDropdown.module.css @@ -13,7 +13,7 @@ display: flex; align-items: stretch; padding: 0; - background-color: $gray-lightest; + background-color: white; border-top-right-radius: 3px; border-bottom-right-radius: 3px; margin-left: auto; diff --git a/frontend/app/components/shared/Filters/FilterValueDropdown/FilterValueDropdown.tsx b/frontend/app/components/shared/Filters/FilterValueDropdown/FilterValueDropdown.tsx index 54509dfc8..72b6f0198 100644 --- a/frontend/app/components/shared/Filters/FilterValueDropdown/FilterValueDropdown.tsx +++ b/frontend/app/components/shared/Filters/FilterValueDropdown/FilterValueDropdown.tsx @@ -1,128 +1,62 @@ import React from 'react'; -import { Icon } from 'UI'; -import stl from './FilterValueDropdown.module.css'; -import Select from 'Shared/Select'; +import { AutoCompleteContainer, AutocompleteModal } from "../FilterAutoComplete/AutocompleteModal"; -const dropdownStyles = { - control: (provided: any) => { - const obj = { - ...provided, - border: 'solid thin transparent !important', - backgroundColor: 'transparent', - cursor: 'pointer', - height: '26px', - minHeight: '26px', - borderRadius: '.5rem', - boxShadow: 'none !important', - }; - return obj; - }, - valueContainer: (provided: any) => ({ - ...provided, - // paddingRight: '0px', - width: 'fit-content', - alignItems: 'center', - height: '26px', - padding: '0 3px', - }), - // placeholder: (provided: any) => ({ - // ...provided, - // }), - indicatorsContainer: (provided: any) => ({ - ...provided, - padding: '0px', - height: '26px', - }), - option: (provided: any, state: any) => ({ - ...provided, - whiteSpace: 'nowrap', - }), - menu: (provided: any, state: any) => ({ - ...provided, - top: 20, - left: 0, - minWidth: 'fit-content', - overflow: 'hidden', - }), - container: (provided: any) => ({ - ...provided, - width: '100%', - }), - input: (provided: any) => ({ - ...provided, - height: '22px', - '& input:focus': { - border: 'none !important', - }, - }), - singleValue: (provided: any, state: { isDisabled: any }) => { - const opacity = state.isDisabled ? 0.5 : 1; - const transition = 'opacity 300ms'; - - return { - ...provided, - opacity, - transition, - display: 'flex', - alignItems: 'center', - height: '20px', - }; - }, -}; interface Props { + options: any[]; + onApply: (values: string[]) => void; + onClose: () => void; + values: string[]; +} +function FilterValueDropdown(props: Props) { + const { + options, + onApply, + onClose, + values, + } = props; + const [query, setQuery] = React.useState(''); + + const filteredOptions = query.length ? options.filter((option) => { + return option.label.toLowerCase().includes(query.toLowerCase()); + }) : options + return ( + + ); +} + +interface MainProps { placeholder?: string; - value: string; - onChange: (value: any) => void; + value: string[]; + onApplyValues: (values: string[]) => void; className?: string; options: any[]; search?: boolean; showCloseButton?: boolean; showOrButton?: boolean; - onRemoveValue?: () => void; - onAddValue?: () => void; - isMultilple?: boolean; -} -function FilterValueDropdown(props: Props) { - const { - placeholder = 'Select', - isMultilple = true, - search = false, - options, - onChange, - value, - showCloseButton = true, - showOrButton = true, - } = props; - - return ( -
-
- i.isEvent).length > 0; + const hasFilters = + appliedFilter.filters.filter((i: any) => !i.isEvent).length > 0; + const savedSearch = searchStore.savedSearch; + const hasSavedSearch = savedSearch && savedSearch.exists(); + const hasSearch = hasFilters || hasSavedSearch; + + const title = useMemo(() => { + if (activeTab && activeTab.type === 'bookmarks') { + return isEnterprise ? 'Vault' : 'Bookmarks'; + } + return 'Sessions'; + }, [activeTab?.type, isEnterprise]); + + // @ts-ignore + const originStr = window.env.ORIGIN || window.location.origin; + const isSaas = /app\.openreplay\.com/.test(originStr); + const showAiField = isSaas && activeTab.type === 'sessions'; + const showPanel = hasEvents || hasFilters || aiFiltersStore.isLoading; + return !metaLoading ? ( +
+
+

{title}

+ {isSaas && showAiField ? : null} +
+ + +
+ {showPanel ? ( + <> + {aiFiltersStore.isLoading ? ( +
+ + Translating your query into search steps... +
+ ) : null} + + ) : null} +
+ ) : null; +} + +export default observer(SearchActions); diff --git a/frontend/app/components/shared/SearchActions/index.ts b/frontend/app/components/shared/SearchActions/index.ts new file mode 100644 index 000000000..120182f5c --- /dev/null +++ b/frontend/app/components/shared/SearchActions/index.ts @@ -0,0 +1 @@ +export { default } from './SearchActions'; \ No newline at end of file diff --git a/frontend/app/components/shared/Select/Select.tsx b/frontend/app/components/shared/Select/Select.tsx index f4f5f32a5..3f56af2dd 100644 --- a/frontend/app/components/shared/Select/Select.tsx +++ b/frontend/app/components/shared/Select/Select.tsx @@ -21,24 +21,25 @@ interface Props { onChange: (newValue: { name: string, value: Value }) => void; name?: string; placeholder?: string; - + className?: string; [x: string]: any; } export default function ({ - placeholder = 'Select', - name = '', - onChange, - right = false, - plain = false, - options, - isSearchable = false, - components = {}, - styles = {}, - defaultValue = '', - controlStyle = {}, - ...rest - }: Props) { + placeholder = 'Select', + name = '', + onChange, + right = false, + plain = false, + options, + isSearchable = false, + components = {}, + styles = {}, + defaultValue = '', + controlStyle = {}, + className = '', + ...rest +}: Props) { const defaultSelected = Array.isArray(defaultValue) ? defaultValue.map((value) => options.find((option) => option.value === value)) : @@ -79,7 +80,7 @@ export default function ({ }), menuList: (provided: any, state: any) => ({ ...provided, - padding: 0 + padding: 0, }), control: (provided: any) => { const obj = { @@ -143,6 +144,7 @@ export default function ({ return ( +
+ {hasFilters ? : } +
+
+ ) : null + } + /> +
+ ); + } +); + +function AiSessionSearchField() { + const { aiFiltersStore } = useStore(); + + return ( +
+
+ +
+
+ ); +} + +export const gradientBox = { + border: 'double 1.5px transparent', + borderRadius: '100px', + background: + 'linear-gradient(#ffffff, #ffffff), linear-gradient(-45deg, #394eff, #3eaaaf, #3ccf65)', + backgroundOrigin: 'border-box', + backgroundSize: '200% 200%', + backgroundClip: 'content-box, border-box', + display: 'flex', + gap: '0.25rem', + alignItems: 'center', + width: '100%', + overflow: 'hidden', +}; + +export default observer(AiSessionSearchField); diff --git a/frontend/app/components/shared/SessionFilters/SessionFilters.tsx b/frontend/app/components/shared/SessionFilters/SessionFilters.tsx new file mode 100644 index 000000000..7e3a4caca --- /dev/null +++ b/frontend/app/components/shared/SessionFilters/SessionFilters.tsx @@ -0,0 +1,115 @@ +import React, { useEffect } from 'react'; +import { debounce } from 'App/utils'; +import { FilterList, EventsList } from 'Shared/Filters/FilterList'; + +import { observer } from 'mobx-react-lite'; +import { useStore } from 'App/mstore'; +import useSessionSearchQueryHandler from 'App/hooks/useSessionSearchQueryHandler'; +import { FilterKey } from 'App/types/filter/filterType'; +import { addOptionsToFilter } from 'App/types/filter/newFilter'; + +let debounceFetch: any = () => {}; +function SessionFilters() { + const { + searchStore, + projectsStore, + customFieldStore, + tagWatchStore, + } = useStore(); + + const appliedFilter = searchStore.instance; + const metaLoading = customFieldStore.isLoading; + const saveRequestPayloads = + projectsStore.instance?.saveRequestPayloads ?? false; + const activeProject = projectsStore.active + + useEffect(() => { + if (searchStore.instance.filters.length === 0 && activeProject?.platform !== 'web') { + searchStore.addFilterByKeyAndValue(FilterKey.LOCATION, '', 'isAny') + } + }, [projectsStore.activeSiteId, activeProject]) + + useSessionSearchQueryHandler({ + appliedFilter, + loading: metaLoading, + onBeforeLoad: async () => { + const tags = await tagWatchStore.getTags(); + if (tags) { + addOptionsToFilter( + FilterKey.TAGGED_ELEMENT, + tags.map((tag) => ({ + label: tag.name, + value: tag.tagId.toString(), + })) + ); + searchStore.refreshFilterOptions(); + } + }, + }); + + useEffect(() => { + debounceFetch = debounce(() => searchStore.fetchSessions(), 500); + }, []); + + useEffect(() => { + debounceFetch(); + }, [appliedFilter.filters]); + + const onAddFilter = (filter: any) => { + searchStore.addFilter(filter); + }; + + const onUpdateFilter = (filterIndex: any, filter: any) => { + searchStore.updateFilter(filterIndex, filter); + }; + + const onFilterMove = (newFilters: any) => { + searchStore.updateFilter(0, { + ...appliedFilter, + filters: newFilters, + }); + + debounceFetch(); + }; + + const onRemoveFilter = (filterIndex: any) => { + searchStore.removeFilter(filterIndex); + + debounceFetch(); + }; + + const onChangeEventsOrder = (e: any, { value }: any) => { + searchStore.edit({ + eventsOrder: value, + }); + + debounceFetch(); + }; + + return ( +
+ + +
+ ); +} + +export default observer(SessionFilters); diff --git a/frontend/app/components/shared/SessionSearchField/SessionSearchField.module.css b/frontend/app/components/shared/SessionFilters/SessionSearchField.module.css similarity index 100% rename from frontend/app/components/shared/SessionSearchField/SessionSearchField.module.css rename to frontend/app/components/shared/SessionFilters/SessionSearchField.module.css diff --git a/frontend/app/components/shared/SessionFilters/index.ts b/frontend/app/components/shared/SessionFilters/index.ts new file mode 100644 index 000000000..9493929ff --- /dev/null +++ b/frontend/app/components/shared/SessionFilters/index.ts @@ -0,0 +1 @@ +export { default } from './SessionFilters'; \ No newline at end of file diff --git a/frontend/app/components/shared/SessionItem/MetaItem/MetaItem.tsx b/frontend/app/components/shared/SessionItem/MetaItem/MetaItem.tsx index 5b88d1896..42eae0244 100644 --- a/frontend/app/components/shared/SessionItem/MetaItem/MetaItem.tsx +++ b/frontend/app/components/shared/SessionItem/MetaItem/MetaItem.tsx @@ -10,11 +10,11 @@ interface Props { export default function MetaItem(props: Props) { const { className = '', label, value } = props return ( -
- +
+ - +
diff --git a/frontend/app/components/shared/SessionItem/SessionItem.tsx b/frontend/app/components/shared/SessionItem/SessionItem.tsx index a87bfc3c4..ea5b54882 100644 --- a/frontend/app/components/shared/SessionItem/SessionItem.tsx +++ b/frontend/app/components/shared/SessionItem/SessionItem.tsx @@ -13,14 +13,7 @@ import { sessions as sessionsRoute, } from 'App/routes'; import { capitalize } from 'App/utils'; -import { - Avatar, - CountryFlag, - Icon, - Label, - TextEllipsis, - Tooltip, -} from 'UI'; +import { Avatar, CountryFlag, Icon, Label, TextEllipsis, Tooltip } from 'UI'; import Counter from './Counter'; import ErrorBars from './ErrorBars'; @@ -83,7 +76,7 @@ const PREFETCH_STATE = { none: 0, loading: 1, fetched: 2, -} +}; function SessionItem(props: RouteComponentProps & Props) { const { settingsStore, sessionStore } = useStore(); @@ -130,32 +123,32 @@ function SessionItem(props: RouteComponentProps & Props) { const location = props.location; const queryParams = Object.fromEntries(new URLSearchParams(location.search)); - const isMobile = platform !== 'web' + const isMobile = platform !== 'web'; const formattedDuration = durationFormatted(duration); const hasUserId = userId || userAnonymousId; const isSessions = isRoute(SESSIONS_ROUTE, location.pathname); const isAssist = (!ignoreAssist && - (isRoute(ASSIST_ROUTE, location.pathname) || - isRoute(ASSIST_LIVE_SESSION, location.pathname) || - location.pathname.includes('multiview'))) || + (isRoute(ASSIST_ROUTE, location.pathname) || + isRoute(ASSIST_LIVE_SESSION, location.pathname) || + location.pathname.includes('multiview'))) || props.live; const isLastPlayed = lastPlayedSessionId === sessionId; - const _metaList = Object.keys(metadata) - .map((key) => { - const value = metadata[key]; - return { label: key, value }; - }); + const _metaList = Object.keys(metadata).map((key) => { + const value = metadata[key]; + return { label: key, value }; + }); const handleHover = async () => { if ( - prefetchState !== PREFETCH_STATE.none - || props.live - || isAssist - || isMobile - ) return; + prefetchState !== PREFETCH_STATE.none || + props.live || + isAssist || + isMobile + ) + return; setPrefetched(PREFETCH_STATE.loading); try { @@ -167,10 +160,10 @@ function SessionItem(props: RouteComponentProps & Props) { }; const populateData = () => { if ( - props.live - || isAssist - || prefetchState === PREFETCH_STATE.none - || isMobile + props.live || + isAssist || + prefetchState === PREFETCH_STATE.none || + isMobile ) { return; } @@ -193,38 +186,46 @@ function SessionItem(props: RouteComponentProps & Props) {
{!compact && (
-
- -
-
-
- !disableUser && !hasUserFilter && hasUserId - ? onUserClick(userId, userAnonymousId) - : null - } - > - +
+
+
+
+ !disableUser && !hasUserFilter && hasUserId + ? onUserClick(userId, userAnonymousId) + : null + } + > + +
+
+ {_metaList.length > 0 && ( + + )}
)}
@@ -281,8 +282,8 @@ function SessionItem(props: RouteComponentProps & Props) { {eventsCount} {eventsCount === 0 || eventsCount > 1 - ? 'Events' - : 'Event'} + ? 'Events' + : 'Event'}
@@ -292,8 +293,8 @@ function SessionItem(props: RouteComponentProps & Props) { {live || props.live ? ( ) : ( - formattedDuration - )} + formattedDuration + )}
@@ -394,22 +395,19 @@ function SessionItem(props: RouteComponentProps & Props) {
) : ( - - )} + + )}
- {_metaList.length > 0 && ( - - )}
); diff --git a/frontend/app/components/shared/SessionSearch/SessionSearch.tsx b/frontend/app/components/shared/SessionSearch/SessionSearch.tsx deleted file mode 100644 index 2e3f36279..000000000 --- a/frontend/app/components/shared/SessionSearch/SessionSearch.tsx +++ /dev/null @@ -1,138 +0,0 @@ -import React, { useEffect } from 'react'; -import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG'; -import FilterList from 'Shared/Filters/FilterList'; -import FilterSelection from 'Shared/Filters/FilterSelection'; -import SaveFilterButton from 'Shared/SaveFilterButton'; -import { FilterKey } from 'Types/filter/filterType'; -import { addOptionsToFilter } from 'Types/filter/newFilter'; -import { Button, Loader } from 'UI'; -import { observer } from 'mobx-react-lite'; -import { useStore } from 'App/mstore'; -import { debounce } from 'App/utils'; -import useSessionSearchQueryHandler from 'App/hooks/useSessionSearchQueryHandler'; - -let debounceFetch: () => void; - -function SessionSearch() { - const { tagWatchStore, aiFiltersStore, searchStore, customFieldStore, projectsStore } = useStore(); - const appliedFilter = searchStore.instance; - const metaLoading = customFieldStore.isLoading; - const hasEvents = appliedFilter.filters.some((i: any) => i.isEvent); - const hasFilters = appliedFilter.filters.some((i: any) => !i.isEvent); - const saveRequestPayloads = projectsStore.instance?.saveRequestPayloads ?? false; - - useSessionSearchQueryHandler({ - appliedFilter, - loading: metaLoading, - onBeforeLoad: async () => { - try { - const tags = await tagWatchStore.getTags(); - if (tags) { - addOptionsToFilter( - FilterKey.TAGGED_ELEMENT, - tags.map((tag) => ({ - label: tag.name, - value: tag.tagId.toString() - })) - ); - searchStore.refreshFilterOptions(); - } - } catch (error) { - console.error('Error during onBeforeLoad:', error); - } - } - }); - - useEffect(() => { - debounceFetch = debounce(() => searchStore.fetchSessions(), 500); - }, []); - - useEffect(() => { - if (searchStore.urlParsed) return; - debounceFetch(); - }, [appliedFilter.filters]); - - const onAddFilter = (filter: any) => { - searchStore.addFilter(filter); - - debounceFetch(); - }; - - const onUpdateFilter = (filterIndex: any, filter: any) => { - searchStore.updateFilter(filterIndex, filter); - - debounceFetch(); - }; - - const onFilterMove = (newFilters: any) => { - searchStore.updateFilter(0, { - ...appliedFilter, - filters: newFilters - }); - - debounceFetch(); - }; - - const onRemoveFilter = (filterIndex: any) => { - const newFilters = appliedFilter.filters.filter((_filter: any, i: any) => { - return i !== filterIndex; - }); - - searchStore.removeFilter(filterIndex); - - debounceFetch(); - }; - - const onChangeEventsOrder = (e: any, { value }: any) => { - searchStore.edit({ - eventsOrder: value - }); - - debounceFetch(); - }; - - const showPanel = hasEvents || hasFilters || aiFiltersStore.isLoading; - - if (metaLoading) return null; - if (!showPanel) return null; - - return ( -
-
- {aiFiltersStore.isLoading ? ( -
- - Translating your query into search steps... -
- ) : null} - {hasEvents || hasFilters ? ( - - ) : null} -
- - {hasEvents || hasFilters ? ( -
-
- - - -
-
- -
-
- ) : null} -
- ); -} - -export default observer(SessionSearch); diff --git a/frontend/app/components/shared/SessionSearch/index.ts b/frontend/app/components/shared/SessionSearch/index.ts deleted file mode 100644 index d9c909f0d..000000000 --- a/frontend/app/components/shared/SessionSearch/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from './SessionSearch'; \ No newline at end of file diff --git a/frontend/app/components/shared/SessionSearchField/AiSessionSearchField.tsx b/frontend/app/components/shared/SessionSearchField/AiSessionSearchField.tsx deleted file mode 100644 index 451657b3c..000000000 --- a/frontend/app/components/shared/SessionSearchField/AiSessionSearchField.tsx +++ /dev/null @@ -1,334 +0,0 @@ -import { CloseOutlined, EnterOutlined } from '@ant-design/icons'; -import { Tour } from 'antd'; -import { observer } from 'mobx-react-lite'; -import React, { useState } from 'react'; -import { useStore } from 'App/mstore'; -import { assist as assistRoute, isRoute } from 'App/routes'; -import { debounce } from 'App/utils'; -import { Icon, Input } from 'UI'; - -import FilterModal from 'Shared/Filters/FilterModal'; - -import OutsideClickDetectingDiv from '../OutsideClickDetectingDiv'; - -const ASSIST_ROUTE = assistRoute(); - -interface Props { - setFocused?: (focused: boolean) => void; -} - -function SessionSearchField(props: Props) { - const { searchStore, searchStoreLive } = useStore(); - const isLive = - isRoute(ASSIST_ROUTE, window.location.pathname) || - window.location.pathname.includes('multiview'); - const debounceFetchFilterSearch = React.useCallback( - debounce( - isLive ? searchStoreLive.fetchFilterSearch : searchStore.fetchFilterSearch, - 1000 - ), - [] - ); - - const [showModal, setShowModal] = useState(false); - const [searchQuery, setSearchQuery] = useState(''); - - const onSearchChange = ({ target: { value } }: any) => { - setSearchQuery(value); - debounceFetchFilterSearch({ q: value }); - }; - - const onAddFilter = (filter: any) => { - isLive - ? searchStoreLive.addFilterByKeyAndValue(filter.key, filter.value) - : searchStore.addFilterByKeyAndValue(filter.key, filter.value); - }; - - const onFocus = () => { - setShowModal(true); - props.setFocused?.(true); - }; - const onBlur = () => { - setTimeout(() => { - setShowModal(false); - props.setFocused?.(false); - }, 200); - }; - return ( -
- - - {showModal && ( -
- -
- )} -
- ); -} - -const AiSearchField = observer(() => { - const { searchStore } = useStore(); - const appliedFilter = searchStore.instance; - const hasFilters = - appliedFilter && appliedFilter.filters && appliedFilter.filters.length > 0; - const { aiFiltersStore } = useStore(); - const [searchQuery, setSearchQuery] = useState(''); - - - const onSearchChange = ({ target: { value } }: any) => { - setSearchQuery(value); - }; - - const fetchResults = () => { - if (searchQuery) { - void aiFiltersStore.getSearchFilters(searchQuery); - } - }; - - const handleKeyDown = (event: any) => { - if (event.key === 'Enter') { - fetchResults(); - } - }; - - const clearAll = () => { - searchStore.clearSearch(); - setSearchQuery(''); - }; - - React.useEffect(() => { - if (aiFiltersStore.filtersSetKey !== 0) { - searchStore.edit(aiFiltersStore.filters); - } - }, [aiFiltersStore.filters, aiFiltersStore.filtersSetKey]); - - return ( -
- -
- {hasFilters ? : } -
-
- ) : null - } - /> -
- ); - } -); - -function AiSessionSearchField(props: Props) { - const askTourKey = '__or__ask-tour'; - const tabKey = '__or__tab'; - const { aiFiltersStore } = useStore(); - const isTourShown = localStorage.getItem(askTourKey) !== null; - const [tab, setTab] = useState(localStorage.getItem(tabKey) || 'search'); - const [touring, setTouring] = useState(!isTourShown); - const [isFocused, setFocused] = React.useState(false); - const askAiRef = React.useRef(null); - - const closeTour = () => { - setTouring(false); - localStorage.setItem(askTourKey, 'true'); - }; - const changeValue = (v?: string) => { - const newTab = v ? v : tab !== 'ask' ? 'ask' : 'search'; - setTab(newTab); - localStorage.setItem(tabKey, newTab); - }; - - const boxStyle = tab === 'ask' - ? gradientBox - : isFocused ? regularBoxFocused : regularBoxUnfocused; - return ( -
-
-
- -
- {tab === 'ask' ? ( - - ) : ( - - )} - - Introducing - - Ask AI -
- ), - target: () => askAiRef.current, - description: - 'Easily find sessions with our AI search. Just enable Ask AI, type in your query naturally, and the AI will swiftly and precisely display relevant sessions.', - nextButtonProps: { - children: ( - - Ask AI - - - ), - onClick: () => { - changeValue('ask'); - closeTour(); - } - } - } - ]} - /> -
-
- ); -} - -export const AskAiSwitchToggle = ({ - enabled, - setEnabled, - loading - }: { - enabled: boolean; - loading: boolean; - setEnabled: () => void; -}) => { - return ( -
setEnabled()} - className={loading ? 'animate-bg-spin' : ''} - style={{ - position: 'relative', - display: 'inline-block', - height: 24, - background: enabled - ? 'linear-gradient(-25deg, #394eff, #3EAAAf, #3ccf65)' - : 'rgb(170 170 170)', - backgroundSize: loading ? '200% 200%' : 'unset', - borderRadius: 100, - cursor: 'pointer', - transition: 'all 0.2s ease-in-out', - border: 0, - verticalAlign: 'middle' - }} - > -
-
-
Ask AI
-
-
- ); -}; - -export const gradientBox = { - border: 'double 1.5px transparent', - borderRadius: '100px', - background: - 'linear-gradient(#ffffff, #ffffff), linear-gradient(-45deg, #394eff, #3eaaaf, #3ccf65)', - backgroundOrigin: 'border-box', - backgroundSize: '200% 200%', - backgroundClip: 'content-box, border-box', - display: 'flex', - gap: '0.25rem', - alignItems: 'center', - width: '100%' -}; - -const regularBoxUnfocused = { - borderRadius: '100px', - border: 'solid 1.5px #BFBFBF', - background: '#fffff', - display: 'flex', - gap: '0.25rem', - alignItems: 'center', - width: '100%' -}; - -const regularBoxFocused = { - borderRadius: '100px', - border: 'solid 1.5px #394EFF', - background: '#fffff', - display: 'flex', - gap: '0.25rem', - alignItems: 'center', - width: '100%' -}; - -export default observer(AiSessionSearchField); diff --git a/frontend/app/components/shared/SessionSearchField/SessionSearchField.tsx b/frontend/app/components/shared/SessionSearchField/SessionSearchField.tsx deleted file mode 100644 index 29469d8b9..000000000 --- a/frontend/app/components/shared/SessionSearchField/SessionSearchField.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import React, { useState } from 'react'; -import { Input } from 'UI'; -import FilterModal from 'Shared/Filters/FilterModal'; -import { debounce } from 'App/utils'; -import { assist as assistRoute, isRoute } from 'App/routes'; - -const ASSIST_ROUTE = assistRoute(); -import { observer } from 'mobx-react-lite'; -import { useStore } from 'App/mstore'; - -interface Props { - -} - -function SessionSearchField(props: Props) { - const { searchStore, searchStoreLive } = useStore(); - const isLive = - isRoute(ASSIST_ROUTE, window.location.pathname) || - window.location.pathname.includes('multiview'); - - const fetchFilterSearch = isLive - ? searchStoreLive.fetchFilterSearch.bind(searchStoreLive) - : searchStore.fetchFilterSearch.bind(searchStore); - - const debounceFetchFilterSearch = React.useCallback( - debounce(fetchFilterSearch, 1000), - [] - ); - const [showModal, setShowModal] = useState(false); - const [searchQuery, setSearchQuery] = useState(''); - - const onSearchChange = ({ target: { value } }: any) => { - setSearchQuery(value); - debounceFetchFilterSearch({ q: value }); - }; - - const onAddFilter = (filter: any) => { - isLive - ? searchStoreLive.addFilterByKeyAndValue(filter.key, filter.value) - : searchStore.addFilterByKeyAndValue(filter.key, filter.value); - }; - - return ( -
- setShowModal(true)} - onBlur={() => setTimeout(setShowModal, 200, false)} - onChange={onSearchChange} - placeholder={'Search sessions using any captured event (click, input, page, error...)'} - style={{ minWidth: 360 }} - id="search" - type="search" - autoComplete="off" - className="hover:border-gray-medium text-lg placeholder-lg h-9 shadow-sm" - /> - - {showModal && ( -
- -
- )} -
- ); -} - -export default observer(SessionSearchField); diff --git a/frontend/app/components/shared/SessionSearchField/index.ts b/frontend/app/components/shared/SessionSearchField/index.ts deleted file mode 100644 index 1f99e0c0b..000000000 --- a/frontend/app/components/shared/SessionSearchField/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from './SessionSearchField'; \ No newline at end of file diff --git a/frontend/app/components/shared/SessionsTabOverview/SessionsTabOverview.tsx b/frontend/app/components/shared/SessionsTabOverview/SessionsTabOverview.tsx index fe19c1051..97b806034 100644 --- a/frontend/app/components/shared/SessionsTabOverview/SessionsTabOverview.tsx +++ b/frontend/app/components/shared/SessionsTabOverview/SessionsTabOverview.tsx @@ -8,13 +8,14 @@ import SessionList from './components/SessionList'; import { observer } from 'mobx-react-lite'; import NoSessionsMessage from 'Shared/NoSessionsMessage/NoSessionsMessage'; import MainSearchBar from 'Shared/MainSearchBar/MainSearchBar'; -import SessionSearch from 'Shared/SessionSearch/SessionSearch'; +import SearchActions from "../SearchActions"; import usePageTitle from '@/hooks/usePageTitle'; function SessionsTabOverview() { const [query, setQuery] = React.useState(''); const { aiFiltersStore, searchStore } = useStore(); const appliedFilter = searchStore.instance; + const activeTab = searchStore.activeTab; usePageTitle('Sessions - OpenReplay'); const handleKeyDown = (event: any) => { @@ -30,8 +31,8 @@ function SessionsTabOverview() { return ( <> + -
{testingKey ? ( @@ -43,7 +44,7 @@ function SessionsTabOverview() { placeholder={'ask session ai'} /> ) : null} - + {activeTab.type !== 'bookmarks' && }
diff --git a/frontend/app/components/shared/SessionsTabOverview/components/Notes/NotesRoute.tsx b/frontend/app/components/shared/SessionsTabOverview/components/Notes/NotesRoute.tsx new file mode 100644 index 000000000..6affbaf35 --- /dev/null +++ b/frontend/app/components/shared/SessionsTabOverview/components/Notes/NotesRoute.tsx @@ -0,0 +1,21 @@ +import React from 'react' +import NotesList from "./NoteList"; +import NoteTags from "./NoteTags"; + +function NotesRoute() { + return ( +
+
+
+
+

Notes

+ +
+
+ +
+
+ ) +} + +export default NotesRoute \ No newline at end of file diff --git a/frontend/app/components/shared/SessionsTabOverview/components/SessionHeader/SessionHeader.tsx b/frontend/app/components/shared/SessionsTabOverview/components/SessionHeader/SessionHeader.tsx index 829804ef4..10138f5ae 100644 --- a/frontend/app/components/shared/SessionsTabOverview/components/SessionHeader/SessionHeader.tsx +++ b/frontend/app/components/shared/SessionsTabOverview/components/SessionHeader/SessionHeader.tsx @@ -1,4 +1,4 @@ -import React, { useMemo } from 'react'; +import React from 'react'; import Period from 'Types/app/period'; import SelectDateRange from 'Shared/SelectDateRange'; import SessionTags from '../SessionTags'; @@ -8,21 +8,11 @@ import { useStore } from 'App/mstore'; import { observer } from 'mobx-react-lite'; function SessionHeader() { - const { searchStore, userStore } = useStore(); - const isEnterprise = userStore.isEnterprise; - const activeTab = searchStore.activeTab; + const { searchStore } = useStore(); const { startDate, endDate, rangeValue } = searchStore.instance; const period = Period({ start: startDate, end: endDate, rangeName: rangeValue }); - const title = useMemo(() => { - if (!activeTab) return; - if (activeTab.type === 'bookmarks') { - return isEnterprise ? 'Vault' : 'Bookmarks'; - } - return 'Sessions'; - }, [isEnterprise, activeTab]); - const onDateChange = (e: any) => { const dateValues = e.toJSON(); searchStore.edit(dateValues); @@ -31,13 +21,11 @@ function SessionHeader() { return (
-

{title}

- {activeTab?.type !== 'bookmarks' && } +
- {activeTab?.type !== 'bookmarks' && - } +
diff --git a/frontend/app/components/ui/Icon/Icon.tsx b/frontend/app/components/ui/Icon/Icon.tsx index 20354a973..19d0a0d0f 100644 --- a/frontend/app/components/ui/Icon/Icon.tsx +++ b/frontend/app/components/ui/Icon/Icon.tsx @@ -9,6 +9,7 @@ interface IProps { height?: number width?: number color?: string + strokeColor?: string className?: string style?: object marginRight?: number @@ -22,6 +23,7 @@ const Icon: React.FunctionComponent = ({ height = size, width = size, color = 'gray-medium', + strokeColor, className = '', style={}, marginRight = 0, @@ -38,7 +40,10 @@ const Icon: React.FunctionComponent = ({ _style.marginRight = `${ marginRight }px`; } - const additionalStyles = color === 'inherit' ? { fill: 'currentcolor' } : {} + const additionalStyles = { + ...(color === 'inherit' ? { fill: 'currentColor' } : {}), + ...(strokeColor ? { stroke: strokeColor } : {}), + }; return ( = ({ className={ cn(className, styles.wrapper, `fill-${ color }`) } data-inline={ inline } > - + ); } diff --git a/frontend/app/components/ui/Icons/console_error.tsx b/frontend/app/components/ui/Icons/console_error.tsx index 636e8498f..356d83910 100644 --- a/frontend/app/components/ui/Icons/console_error.tsx +++ b/frontend/app/components/ui/Icons/console_error.tsx @@ -12,7 +12,7 @@ interface Props { function Console_error(props: Props) { const { size = 14, width = size, height = size, fill = '' } = props; return ( - + ); } diff --git a/frontend/app/components/ui/Icons/dashboards_circle_alert.tsx b/frontend/app/components/ui/Icons/dashboards_circle_alert.tsx new file mode 100644 index 000000000..84c4f9e08 --- /dev/null +++ b/frontend/app/components/ui/Icons/dashboards_circle_alert.tsx @@ -0,0 +1,19 @@ + +/* Auto-generated, do not edit */ +import React from 'react'; + +interface Props { + size?: number | string; + width?: number | string; + height?: number | string; + fill?: string; +} + +function Dashboards_circle_alert(props: Props) { + const { size = 14, width = size, height = size, fill = '' } = props; + return ( + + ); +} + +export default Dashboards_circle_alert; diff --git a/frontend/app/components/ui/Icons/dashboards_cohort_chart.tsx b/frontend/app/components/ui/Icons/dashboards_cohort_chart.tsx new file mode 100644 index 000000000..491c10e77 --- /dev/null +++ b/frontend/app/components/ui/Icons/dashboards_cohort_chart.tsx @@ -0,0 +1,19 @@ + +/* Auto-generated, do not edit */ +import React from 'react'; + +interface Props { + size?: number | string; + width?: number | string; + height?: number | string; + fill?: string; +} + +function Dashboards_cohort_chart(props: Props) { + const { size = 14, width = size, height = size, fill = '' } = props; + return ( + + ); +} + +export default Dashboards_cohort_chart; diff --git a/frontend/app/components/ui/Icons/dashboards_heatmap_2.tsx b/frontend/app/components/ui/Icons/dashboards_heatmap_2.tsx new file mode 100644 index 000000000..4357167ad --- /dev/null +++ b/frontend/app/components/ui/Icons/dashboards_heatmap_2.tsx @@ -0,0 +1,19 @@ + +/* Auto-generated, do not edit */ +import React from 'react'; + +interface Props { + size?: number | string; + width?: number | string; + height?: number | string; + fill?: string; +} + +function Dashboards_heatmap_2(props: Props) { + const { size = 14, width = size, height = size, fill = '' } = props; + return ( + + ); +} + +export default Dashboards_heatmap_2; diff --git a/frontend/app/components/ui/Icons/dashboards_user_journey.tsx b/frontend/app/components/ui/Icons/dashboards_user_journey.tsx new file mode 100644 index 000000000..895777a54 --- /dev/null +++ b/frontend/app/components/ui/Icons/dashboards_user_journey.tsx @@ -0,0 +1,19 @@ + +/* Auto-generated, do not edit */ +import React from 'react'; + +interface Props { + size?: number | string; + width?: number | string; + height?: number | string; + fill?: string; +} + +function Dashboards_user_journey(props: Props) { + const { size = 14, width = size, height = size, fill = '' } = props; + return ( + + ); +} + +export default Dashboards_user_journey; diff --git a/frontend/app/components/ui/Icons/exclamation_circle.tsx b/frontend/app/components/ui/Icons/exclamation_circle.tsx index b0b806510..d9f64ae42 100644 --- a/frontend/app/components/ui/Icons/exclamation_circle.tsx +++ b/frontend/app/components/ui/Icons/exclamation_circle.tsx @@ -12,7 +12,7 @@ interface Props { function Exclamation_circle(props: Props) { const { size = 14, width = size, height = size, fill = '' } = props; return ( - + ); } diff --git a/frontend/app/components/ui/Icons/exclamation_triangle.tsx b/frontend/app/components/ui/Icons/exclamation_triangle.tsx index 8a4ac14f7..a16b75d4b 100644 --- a/frontend/app/components/ui/Icons/exclamation_triangle.tsx +++ b/frontend/app/components/ui/Icons/exclamation_triangle.tsx @@ -12,7 +12,7 @@ interface Props { function Exclamation_triangle(props: Props) { const { size = 14, width = size, height = size, fill = '' } = props; return ( - + ); } diff --git a/frontend/app/components/ui/Icons/filter.tsx b/frontend/app/components/ui/Icons/filter.tsx index 77b2b86ea..3b146389a 100644 --- a/frontend/app/components/ui/Icons/filter.tsx +++ b/frontend/app/components/ui/Icons/filter.tsx @@ -12,7 +12,7 @@ interface Props { function Filter(props: Props) { const { size = 14, width = size, height = size, fill = '' } = props; return ( - + ); } diff --git a/frontend/app/components/ui/Icons/filters_error.tsx b/frontend/app/components/ui/Icons/filters_error.tsx index 8b2a0e079..8a4c00d76 100644 --- a/frontend/app/components/ui/Icons/filters_error.tsx +++ b/frontend/app/components/ui/Icons/filters_error.tsx @@ -12,7 +12,7 @@ interface Props { function Filters_error(props: Props) { const { size = 14, width = size, height = size, fill = '' } = props; return ( - + ); } diff --git a/frontend/app/components/ui/Icons/funnel.tsx b/frontend/app/components/ui/Icons/funnel.tsx index 8e5d79b32..fc8e4384f 100644 --- a/frontend/app/components/ui/Icons/funnel.tsx +++ b/frontend/app/components/ui/Icons/funnel.tsx @@ -12,7 +12,7 @@ interface Props { function Funnel(props: Props) { const { size = 14, width = size, height = size, fill = '' } = props; return ( - + ); } diff --git a/frontend/app/components/ui/Icons/funnel_exclamation_circle.tsx b/frontend/app/components/ui/Icons/funnel_exclamation_circle.tsx index 8e878d955..0ba7fc6e3 100644 --- a/frontend/app/components/ui/Icons/funnel_exclamation_circle.tsx +++ b/frontend/app/components/ui/Icons/funnel_exclamation_circle.tsx @@ -12,7 +12,7 @@ interface Props { function Funnel_exclamation_circle(props: Props) { const { size = 14, width = size, height = size, fill = '' } = props; return ( - + ); } diff --git a/frontend/app/components/ui/Icons/graph_up.tsx b/frontend/app/components/ui/Icons/graph_up.tsx index e2f344fa4..45506a08a 100644 --- a/frontend/app/components/ui/Icons/graph_up.tsx +++ b/frontend/app/components/ui/Icons/graph_up.tsx @@ -12,7 +12,7 @@ interface Props { function Graph_up(props: Props) { const { size = 14, width = size, height = size, fill = '' } = props; return ( - + ); } diff --git a/frontend/app/components/ui/Icons/ic_errors.tsx b/frontend/app/components/ui/Icons/ic_errors.tsx index 0248d1e51..03507db4a 100644 --- a/frontend/app/components/ui/Icons/ic_errors.tsx +++ b/frontend/app/components/ui/Icons/ic_errors.tsx @@ -12,7 +12,7 @@ interface Props { function Ic_errors(props: Props) { const { size = 14, width = size, height = size, fill = '' } = props; return ( - + ); } diff --git a/frontend/app/components/ui/Icons/index.ts b/frontend/app/components/ui/Icons/index.ts index 9927d1556..570e74c12 100644 --- a/frontend/app/components/ui/Icons/index.ts +++ b/frontend/app/components/ui/Icons/index.ts @@ -161,6 +161,10 @@ export { default as Cubes } from './cubes'; export { default as Cursor_trash } from './cursor_trash'; export { default as Dash } from './dash'; export { default as Dashboard_icn } from './dashboard_icn'; +export { default as Dashboards_circle_alert } from './dashboards_circle_alert'; +export { default as Dashboards_cohort_chart } from './dashboards_cohort_chart'; +export { default as Dashboards_heatmap_2 } from './dashboards_heatmap_2'; +export { default as Dashboards_user_journey } from './dashboards_user_journey'; export { default as Db_icons_icn_card_clickmap } from './db_icons_icn_card_clickMap'; export { default as Db_icons_icn_card_errors } from './db_icons_icn_card_errors'; export { default as Db_icons_icn_card_funnel } from './db_icons_icn_card_funnel'; @@ -463,6 +467,7 @@ export { default as Turtle } from './turtle'; export { default as User_alt } from './user_alt'; export { default as User_circle } from './user_circle'; export { default as User_friends } from './user_friends'; +export { default as User_journey } from './user_journey'; export { default as User_switch } from './user_switch'; export { default as Users } from './users'; export { default as Vendors_graphql } from './vendors_graphql'; diff --git a/frontend/app/components/ui/Icons/list_alt.tsx b/frontend/app/components/ui/Icons/list_alt.tsx index 8c2993dff..5b9959b35 100644 --- a/frontend/app/components/ui/Icons/list_alt.tsx +++ b/frontend/app/components/ui/Icons/list_alt.tsx @@ -12,7 +12,7 @@ interface Props { function List_alt(props: Props) { const { size = 14, width = size, height = size, fill = '' } = props; return ( - + ); } diff --git a/frontend/app/components/ui/Icons/signpost_split.tsx b/frontend/app/components/ui/Icons/signpost_split.tsx index 3cd780361..8ee289d08 100644 --- a/frontend/app/components/ui/Icons/signpost_split.tsx +++ b/frontend/app/components/ui/Icons/signpost_split.tsx @@ -12,7 +12,7 @@ interface Props { function Signpost_split(props: Props) { const { size = 14, width = size, height = size, fill = '' } = props; return ( - + ); } diff --git a/frontend/app/components/ui/Icons/user_journey.tsx b/frontend/app/components/ui/Icons/user_journey.tsx new file mode 100644 index 000000000..53a750f82 --- /dev/null +++ b/frontend/app/components/ui/Icons/user_journey.tsx @@ -0,0 +1,19 @@ + +/* Auto-generated, do not edit */ +import React from 'react'; + +interface Props { + size?: number | string; + width?: number | string; + height?: number | string; + fill?: string; +} + +function User_journey(props: Props) { + const { size = 14, width = size, height = size, fill = '' } = props; + return ( + + ); +} + +export default User_journey; 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/components/ui/SVG.tsx b/frontend/app/components/ui/SVG.tsx index b84a8f099..24e8b8b02 100644 --- a/frontend/app/components/ui/SVG.tsx +++ b/frontend/app/components/ui/SVG.tsx @@ -163,6 +163,10 @@ import { Cursor_trash, Dash, Dashboard_icn, + Dashboards_circle_alert, + Dashboards_cohort_chart, + Dashboards_heatmap_2, + Dashboards_user_journey, Db_icons_icn_card_clickmap, Db_icons_icn_card_errors, Db_icons_icn_card_funnel, @@ -465,6 +469,7 @@ import { User_alt, User_circle, User_friends, + User_journey, User_switch, Users, Vendors_graphql, @@ -475,7 +480,7 @@ import { Zoom_in } from './Icons' -export type IconNames = 'activity' | 'analytics' | 'anchor' | 'arrow-bar-left' | 'arrow-clockwise' | 'arrow-counterclockwise' | 'arrow-down-short' | 'arrow-down-up' | 'arrow-down' | 'arrow-repeat' | 'arrow-right-short' | 'arrow-up-short' | 'arrow-up' | 'avatar/icn_avatar1' | 'avatar/icn_avatar10' | 'avatar/icn_avatar11' | 'avatar/icn_avatar12' | 'avatar/icn_avatar13' | 'avatar/icn_avatar14' | 'avatar/icn_avatar15' | 'avatar/icn_avatar16' | 'avatar/icn_avatar17' | 'avatar/icn_avatar18' | 'avatar/icn_avatar19' | 'avatar/icn_avatar2' | 'avatar/icn_avatar20' | 'avatar/icn_avatar21' | 'avatar/icn_avatar22' | 'avatar/icn_avatar23' | 'avatar/icn_avatar3' | 'avatar/icn_avatar4' | 'avatar/icn_avatar5' | 'avatar/icn_avatar6' | 'avatar/icn_avatar7' | 'avatar/icn_avatar8' | 'avatar/icn_avatar9' | 'ban' | 'bar-chart-line' | 'bar-pencil' | 'battery-charging' | 'battery' | 'bell-plus' | 'bell-slash' | 'bell' | 'binoculars' | 'book' | 'bookmark' | 'broadcast' | 'browser/browser' | 'browser/chrome' | 'browser/edge' | 'browser/electron' | 'browser/facebook' | 'browser/firefox' | 'browser/ie' | 'browser/opera' | 'browser/safari' | 'buildings' | 'bullhorn' | 'calendar' | 'call' | 'camera-video-off' | 'camera-video' | 'camera' | 'card-list' | 'card-text' | 'caret-down-fill' | 'caret-right-fill' | 'chat-dots' | 'chat-left-text' | 'chat-square-quote' | 'check-circle-fill' | 'check-circle' | 'check' | 'chevron-down' | 'chevron-left' | 'chevron-right' | 'chevron-up' | 'circle-fill' | 'circle' | 'click-hesitation' | 'click-rage' | 'clipboard-check' | 'clock-history' | 'clock' | 'close' | 'code' | 'cog' | 'cogs' | 'collection-play' | 'collection' | 'color/apple' | 'color/browser/chrome' | 'color/browser/edge' | 'color/browser/facebook' | 'color/browser/firefox' | 'color/browser/google' | 'color/browser/opera' | 'color/browser/safari' | 'color/browser/unknown' | 'color/browser/whale' | 'color/chrome' | 'color/country/de' | 'color/country/fr' | 'color/country/gb' | 'color/country/in' | 'color/country/us' | 'color/de' | 'color/device/desktop' | 'color/device/mobile' | 'color/device/tablet' | 'color/device/unkown' | 'color/edge' | 'color/fedora' | 'color/firefox' | 'color/fr' | 'color/gb' | 'color/in' | 'color/issues/bad_request' | 'color/issues/click_rage' | 'color/issues/cpu' | 'color/issues/crash' | 'color/issues/custom' | 'color/issues/dead_click' | 'color/issues/errors' | 'color/issues/excessive_scrolling' | 'color/issues/js_exception' | 'color/issues/memory' | 'color/issues/missing_resource' | 'color/issues/mouse_thrashing' | 'color/issues/slow_page_load' | 'color/microsoft' | 'color/opera' | 'color/os/android' | 'color/os/apple' | 'color/os/elementary' | 'color/os/fedora' | 'color/os/ios' | 'color/os/linux' | 'color/os/macos' | 'color/os/microsoft' | 'color/os/ubuntu' | 'color/os/unkown' | 'color/safari' | 'color/ubuntu' | 'color/us' | 'columns-gap' | 'console/error' | 'console/exception' | 'console/info' | 'console/warning' | 'console' | 'controller' | 'cookies' | 'copy' | 'credit-card-2-back' | 'cross' | 'cubes' | 'cursor-trash' | 'dash' | 'dashboard-icn' | 'db-icons/icn-card-clickMap' | 'db-icons/icn-card-errors' | 'db-icons/icn-card-funnel' | 'db-icons/icn-card-funnels' | 'db-icons/icn-card-insights' | 'db-icons/icn-card-library' | 'db-icons/icn-card-mapchart' | 'db-icons/icn-card-pathAnalysis' | 'db-icons/icn-card-performance' | 'db-icons/icn-card-resources' | 'db-icons/icn-card-table' | 'db-icons/icn-card-timeseries' | 'db-icons/icn-card-webVitals' | 'desktop' | 'device' | 'diagram-3' | 'dizzy' | 'door-closed' | 'download' | 'drag' | 'edit' | 'ellipsis-v' | 'emoji-dizzy' | 'enter' | 'envelope-check' | 'envelope-paper' | 'envelope-x' | 'envelope' | 'errors-icon' | 'event/click' | 'event/click_hesitation' | 'event/clickrage' | 'event/code' | 'event/i-cursor' | 'event/input' | 'event/input_hesitation' | 'event/link' | 'event/location' | 'event/mouse_thrashing' | 'event/resize' | 'event/view' | 'exclamation-circle-fill' | 'exclamation-circle' | 'exclamation-triangle' | 'explosion' | 'external-link-alt' | 'eye-slash-fill' | 'eye-slash' | 'eye' | 'fetch-request' | 'fetch' | 'fflag-multi' | 'fflag-single' | 'file-bar-graph' | 'file-code' | 'file-medical-alt' | 'file-pdf' | 'file' | 'files' | 'filetype-js' | 'filetype-pdf' | 'filter' | 'filters/arrow-return-right' | 'filters/browser' | 'filters/chevrons-up-down' | 'filters/click' | 'filters/clickrage' | 'filters/code' | 'filters/console' | 'filters/country' | 'filters/cpu-load' | 'filters/custom' | 'filters/device' | 'filters/dom-complete' | 'filters/duration' | 'filters/error' | 'filters/fetch-failed' | 'filters/fetch' | 'filters/file-code' | 'filters/graphql' | 'filters/i-cursor' | 'filters/input' | 'filters/lcpt' | 'filters/link' | 'filters/location' | 'filters/memory-load' | 'filters/metadata' | 'filters/os' | 'filters/perfromance-network-request' | 'filters/platform' | 'filters/referrer' | 'filters/resize' | 'filters/rev-id' | 'filters/screen' | 'filters/state-action' | 'filters/tag-element' | 'filters/ttfb' | 'filters/user-alt' | 'filters/userid' | 'filters/view' | 'flag-na' | 'folder-plus' | 'folder2' | 'fullscreen' | 'funnel/cpu-fill' | 'funnel/cpu' | 'funnel/dizzy' | 'funnel/emoji-angry-fill' | 'funnel/emoji-angry' | 'funnel/emoji-dizzy-fill' | 'funnel/exclamation-circle-fill' | 'funnel/exclamation-circle' | 'funnel/file-earmark-break-fill' | 'funnel/file-earmark-break' | 'funnel/file-earmark-minus-fill' | 'funnel/file-earmark-minus' | 'funnel/file-medical-alt' | 'funnel/file-x' | 'funnel/hdd-fill' | 'funnel/hourglass-top' | 'funnel/image-fill' | 'funnel/image' | 'funnel/microchip' | 'funnel/mouse' | 'funnel/patch-exclamation-fill' | 'funnel/sd-card' | 'funnel-fill' | 'funnel' | 'gear' | 'github' | 'graph-up' | 'grid-3x3' | 'grid-check' | 'grid' | 'hash' | 'headset' | 'history' | 'ic-errors' | 'ic-network' | 'ic-rage' | 'ic-resources' | 'icn_fetch-request' | 'icn_referrer' | 'icn_url' | 'id-card' | 'image' | 'info-circle-fill' | 'info-circle' | 'info-square' | 'info' | 'input-hesitation' | 'inspect' | 'integrations/assist' | 'integrations/bugsnag-text' | 'integrations/bugsnag' | 'integrations/cloudwatch-text' | 'integrations/cloudwatch' | 'integrations/datadog' | 'integrations/dynatrace' | 'integrations/elasticsearch-text' | 'integrations/elasticsearch' | 'integrations/github' | 'integrations/graphql' | 'integrations/jira-text' | 'integrations/jira' | 'integrations/mobx' | 'integrations/newrelic-text' | 'integrations/newrelic' | 'integrations/ngrx' | 'integrations/openreplay-text' | 'integrations/openreplay' | 'integrations/redux' | 'integrations/rollbar-text' | 'integrations/rollbar' | 'integrations/segment' | 'integrations/sentry-text' | 'integrations/sentry' | 'integrations/slack-bw' | 'integrations/slack' | 'integrations/stackdriver' | 'integrations/sumologic-text' | 'integrations/sumologic' | 'integrations/teams-white' | 'integrations/teams' | 'integrations/vuejs' | 'integrations/zustand' | 'journal-code' | 'key' | 'keyboard' | 'layers-half' | 'lightbulb-on' | 'lightbulb' | 'link-45deg' | 'list-alt' | 'list-ul' | 'list' | 'low-disc-space' | 'magic' | 'map-marker-alt' | 'memory-ios' | 'memory' | 'mic-mute' | 'mic' | 'minus' | 'mobile' | 'mouse-alt' | 'mouse-pointer-click' | 'network' | 'next1' | 'no-dashboard' | 'no-metrics-chart' | 'no-metrics' | 'no-recordings' | 'orIcn' | 'orSpot' | 'orspotOutline' | 'os/android' | 'os/chrome_os' | 'os/fedora' | 'os/ios' | 'os/linux' | 'os/mac_os_x' | 'os/other' | 'os/ubuntu' | 'os/windows' | 'os' | 'pause-circle-fill' | 'pause-fill' | 'pause' | 'pdf-download' | 'pencil-stop' | 'pencil' | 'people' | 'percent' | 'performance-icon' | 'person-border' | 'person-fill' | 'person' | 'pie-chart-fill' | 'pin-fill' | 'play-circle-bold' | 'play-circle-light' | 'play-circle' | 'play-fill-new' | 'play-fill' | 'play-hover' | 'play' | 'plug' | 'plus-circle' | 'plus' | 'prev1' | 'pulse' | 'puzzle-piece' | 'puzzle' | 'question-circle' | 'question-lg' | 'quotes' | 'record-circle-fill' | 'record-circle' | 'record2' | 'redo' | 'redux' | 'referrer' | 'remote-control' | 'resources-icon' | 'safe' | 'sandglass' | 'search' | 'server' | 'share-alt' | 'shield-lock' | 'side_menu_closed' | 'side_menu_open' | 'signpost-split' | 'signup' | 'slack' | 'slash-circle' | 'sleep' | 'sliders' | 'social/slack' | 'social/trello' | 'sparkles' | 'speedometer2' | 'spinner' | 'star' | 'step-forward' | 'stickies' | 'stop-record-circle' | 'stopwatch' | 'store' | 'sync-alt' | 'table' | 'tags' | 'terminal' | 'thermometer-sun' | 'toggles' | 'tools' | 'trash' | 'turtle' | 'user-alt' | 'user-circle' | 'user-friends' | 'user-switch' | 'users' | 'vendors/graphql' | 'web-vitals' | 'wifi' | 'window-x' | 'window' | 'zoom-in'; +export type IconNames = 'activity' | 'analytics' | 'anchor' | 'arrow-bar-left' | 'arrow-clockwise' | 'arrow-counterclockwise' | 'arrow-down-short' | 'arrow-down-up' | 'arrow-down' | 'arrow-repeat' | 'arrow-right-short' | 'arrow-up-short' | 'arrow-up' | 'avatar/icn_avatar1' | 'avatar/icn_avatar10' | 'avatar/icn_avatar11' | 'avatar/icn_avatar12' | 'avatar/icn_avatar13' | 'avatar/icn_avatar14' | 'avatar/icn_avatar15' | 'avatar/icn_avatar16' | 'avatar/icn_avatar17' | 'avatar/icn_avatar18' | 'avatar/icn_avatar19' | 'avatar/icn_avatar2' | 'avatar/icn_avatar20' | 'avatar/icn_avatar21' | 'avatar/icn_avatar22' | 'avatar/icn_avatar23' | 'avatar/icn_avatar3' | 'avatar/icn_avatar4' | 'avatar/icn_avatar5' | 'avatar/icn_avatar6' | 'avatar/icn_avatar7' | 'avatar/icn_avatar8' | 'avatar/icn_avatar9' | 'ban' | 'bar-chart-line' | 'bar-pencil' | 'battery-charging' | 'battery' | 'bell-plus' | 'bell-slash' | 'bell' | 'binoculars' | 'book' | 'bookmark' | 'broadcast' | 'browser/browser' | 'browser/chrome' | 'browser/edge' | 'browser/electron' | 'browser/facebook' | 'browser/firefox' | 'browser/ie' | 'browser/opera' | 'browser/safari' | 'buildings' | 'bullhorn' | 'calendar' | 'call' | 'camera-video-off' | 'camera-video' | 'camera' | 'card-list' | 'card-text' | 'caret-down-fill' | 'caret-right-fill' | 'chat-dots' | 'chat-left-text' | 'chat-square-quote' | 'check-circle-fill' | 'check-circle' | 'check' | 'chevron-down' | 'chevron-left' | 'chevron-right' | 'chevron-up' | 'circle-fill' | 'circle' | 'click-hesitation' | 'click-rage' | 'clipboard-check' | 'clock-history' | 'clock' | 'close' | 'code' | 'cog' | 'cogs' | 'collection-play' | 'collection' | 'color/apple' | 'color/browser/chrome' | 'color/browser/edge' | 'color/browser/facebook' | 'color/browser/firefox' | 'color/browser/google' | 'color/browser/opera' | 'color/browser/safari' | 'color/browser/unknown' | 'color/browser/whale' | 'color/chrome' | 'color/country/de' | 'color/country/fr' | 'color/country/gb' | 'color/country/in' | 'color/country/us' | 'color/de' | 'color/device/desktop' | 'color/device/mobile' | 'color/device/tablet' | 'color/device/unkown' | 'color/edge' | 'color/fedora' | 'color/firefox' | 'color/fr' | 'color/gb' | 'color/in' | 'color/issues/bad_request' | 'color/issues/click_rage' | 'color/issues/cpu' | 'color/issues/crash' | 'color/issues/custom' | 'color/issues/dead_click' | 'color/issues/errors' | 'color/issues/excessive_scrolling' | 'color/issues/js_exception' | 'color/issues/memory' | 'color/issues/missing_resource' | 'color/issues/mouse_thrashing' | 'color/issues/slow_page_load' | 'color/microsoft' | 'color/opera' | 'color/os/android' | 'color/os/apple' | 'color/os/elementary' | 'color/os/fedora' | 'color/os/ios' | 'color/os/linux' | 'color/os/macos' | 'color/os/microsoft' | 'color/os/ubuntu' | 'color/os/unkown' | 'color/safari' | 'color/ubuntu' | 'color/us' | 'columns-gap' | 'console/error' | 'console/exception' | 'console/info' | 'console/warning' | 'console' | 'controller' | 'cookies' | 'copy' | 'credit-card-2-back' | 'cross' | 'cubes' | 'cursor-trash' | 'dash' | 'dashboard-icn' | 'dashboards/circle-alert' | 'dashboards/cohort-chart' | 'dashboards/heatmap-2' | 'dashboards/user-journey' | 'db-icons/icn-card-clickMap' | 'db-icons/icn-card-errors' | 'db-icons/icn-card-funnel' | 'db-icons/icn-card-funnels' | 'db-icons/icn-card-insights' | 'db-icons/icn-card-library' | 'db-icons/icn-card-mapchart' | 'db-icons/icn-card-pathAnalysis' | 'db-icons/icn-card-performance' | 'db-icons/icn-card-resources' | 'db-icons/icn-card-table' | 'db-icons/icn-card-timeseries' | 'db-icons/icn-card-webVitals' | 'desktop' | 'device' | 'diagram-3' | 'dizzy' | 'door-closed' | 'download' | 'drag' | 'edit' | 'ellipsis-v' | 'emoji-dizzy' | 'enter' | 'envelope-check' | 'envelope-paper' | 'envelope-x' | 'envelope' | 'errors-icon' | 'event/click' | 'event/click_hesitation' | 'event/clickrage' | 'event/code' | 'event/i-cursor' | 'event/input' | 'event/input_hesitation' | 'event/link' | 'event/location' | 'event/mouse_thrashing' | 'event/resize' | 'event/view' | 'exclamation-circle-fill' | 'exclamation-circle' | 'exclamation-triangle' | 'explosion' | 'external-link-alt' | 'eye-slash-fill' | 'eye-slash' | 'eye' | 'fetch-request' | 'fetch' | 'fflag-multi' | 'fflag-single' | 'file-bar-graph' | 'file-code' | 'file-medical-alt' | 'file-pdf' | 'file' | 'files' | 'filetype-js' | 'filetype-pdf' | 'filter' | 'filters/arrow-return-right' | 'filters/browser' | 'filters/chevrons-up-down' | 'filters/click' | 'filters/clickrage' | 'filters/code' | 'filters/console' | 'filters/country' | 'filters/cpu-load' | 'filters/custom' | 'filters/device' | 'filters/dom-complete' | 'filters/duration' | 'filters/error' | 'filters/fetch-failed' | 'filters/fetch' | 'filters/file-code' | 'filters/graphql' | 'filters/i-cursor' | 'filters/input' | 'filters/lcpt' | 'filters/link' | 'filters/location' | 'filters/memory-load' | 'filters/metadata' | 'filters/os' | 'filters/perfromance-network-request' | 'filters/platform' | 'filters/referrer' | 'filters/resize' | 'filters/rev-id' | 'filters/screen' | 'filters/state-action' | 'filters/tag-element' | 'filters/ttfb' | 'filters/user-alt' | 'filters/userid' | 'filters/view' | 'flag-na' | 'folder-plus' | 'folder2' | 'fullscreen' | 'funnel/cpu-fill' | 'funnel/cpu' | 'funnel/dizzy' | 'funnel/emoji-angry-fill' | 'funnel/emoji-angry' | 'funnel/emoji-dizzy-fill' | 'funnel/exclamation-circle-fill' | 'funnel/exclamation-circle' | 'funnel/file-earmark-break-fill' | 'funnel/file-earmark-break' | 'funnel/file-earmark-minus-fill' | 'funnel/file-earmark-minus' | 'funnel/file-medical-alt' | 'funnel/file-x' | 'funnel/hdd-fill' | 'funnel/hourglass-top' | 'funnel/image-fill' | 'funnel/image' | 'funnel/microchip' | 'funnel/mouse' | 'funnel/patch-exclamation-fill' | 'funnel/sd-card' | 'funnel-fill' | 'funnel' | 'gear' | 'github' | 'graph-up' | 'grid-3x3' | 'grid-check' | 'grid' | 'hash' | 'headset' | 'history' | 'ic-errors' | 'ic-network' | 'ic-rage' | 'ic-resources' | 'icn_fetch-request' | 'icn_referrer' | 'icn_url' | 'id-card' | 'image' | 'info-circle-fill' | 'info-circle' | 'info-square' | 'info' | 'input-hesitation' | 'inspect' | 'integrations/assist' | 'integrations/bugsnag-text' | 'integrations/bugsnag' | 'integrations/cloudwatch-text' | 'integrations/cloudwatch' | 'integrations/datadog' | 'integrations/dynatrace' | 'integrations/elasticsearch-text' | 'integrations/elasticsearch' | 'integrations/github' | 'integrations/graphql' | 'integrations/jira-text' | 'integrations/jira' | 'integrations/mobx' | 'integrations/newrelic-text' | 'integrations/newrelic' | 'integrations/ngrx' | 'integrations/openreplay-text' | 'integrations/openreplay' | 'integrations/redux' | 'integrations/rollbar-text' | 'integrations/rollbar' | 'integrations/segment' | 'integrations/sentry-text' | 'integrations/sentry' | 'integrations/slack-bw' | 'integrations/slack' | 'integrations/stackdriver' | 'integrations/sumologic-text' | 'integrations/sumologic' | 'integrations/teams-white' | 'integrations/teams' | 'integrations/vuejs' | 'integrations/zustand' | 'journal-code' | 'key' | 'keyboard' | 'layers-half' | 'lightbulb-on' | 'lightbulb' | 'link-45deg' | 'list-alt' | 'list-ul' | 'list' | 'low-disc-space' | 'magic' | 'map-marker-alt' | 'memory-ios' | 'memory' | 'mic-mute' | 'mic' | 'minus' | 'mobile' | 'mouse-alt' | 'mouse-pointer-click' | 'network' | 'next1' | 'no-dashboard' | 'no-metrics-chart' | 'no-metrics' | 'no-recordings' | 'orIcn' | 'orSpot' | 'orspotOutline' | 'os/android' | 'os/chrome_os' | 'os/fedora' | 'os/ios' | 'os/linux' | 'os/mac_os_x' | 'os/other' | 'os/ubuntu' | 'os/windows' | 'os' | 'pause-circle-fill' | 'pause-fill' | 'pause' | 'pdf-download' | 'pencil-stop' | 'pencil' | 'people' | 'percent' | 'performance-icon' | 'person-border' | 'person-fill' | 'person' | 'pie-chart-fill' | 'pin-fill' | 'play-circle-bold' | 'play-circle-light' | 'play-circle' | 'play-fill-new' | 'play-fill' | 'play-hover' | 'play' | 'plug' | 'plus-circle' | 'plus' | 'prev1' | 'pulse' | 'puzzle-piece' | 'puzzle' | 'question-circle' | 'question-lg' | 'quotes' | 'record-circle-fill' | 'record-circle' | 'record2' | 'redo' | 'redux' | 'referrer' | 'remote-control' | 'resources-icon' | 'safe' | 'sandglass' | 'search' | 'server' | 'share-alt' | 'shield-lock' | 'side_menu_closed' | 'side_menu_open' | 'signpost-split' | 'signup' | 'slack' | 'slash-circle' | 'sleep' | 'sliders' | 'social/slack' | 'social/trello' | 'sparkles' | 'speedometer2' | 'spinner' | 'star' | 'step-forward' | 'stickies' | 'stop-record-circle' | 'stopwatch' | 'store' | 'sync-alt' | 'table' | 'tags' | 'terminal' | 'thermometer-sun' | 'toggles' | 'tools' | 'trash' | 'turtle' | 'user-alt' | 'user-circle' | 'user-friends' | 'user-journey' | 'user-switch' | 'users' | 'vendors/graphql' | 'web-vitals' | 'wifi' | 'window-x' | 'window' | 'zoom-in'; interface Props { name: IconNames; @@ -973,6 +978,18 @@ const SVG = (props: Props) => { // case 'dashboard-icn': case 'dashboard-icn': return ; + // case 'dashboards/circle-alert': + case 'dashboards/circle-alert': return ; + + // case 'dashboards/cohort-chart': + case 'dashboards/cohort-chart': return ; + + // case 'dashboards/heatmap-2': + case 'dashboards/heatmap-2': return ; + + // case 'dashboards/user-journey': + case 'dashboards/user-journey': return ; + // case 'db-icons/icn-card-clickMap': case 'db-icons/icn-card-clickMap': return ; @@ -1879,6 +1896,9 @@ const SVG = (props: Props) => { // case 'user-friends': case 'user-friends': return ; + // case 'user-journey': + case 'user-journey': return ; + // case 'user-switch': case 'user-switch': return ; diff --git a/frontend/app/constants/card.ts b/frontend/app/constants/card.ts index 70808fbdc..0143c8c17 100644 --- a/frontend/app/constants/card.ts +++ b/frontend/app/constants/card.ts @@ -19,10 +19,16 @@ export const FUNNEL = 'funnel'; export const ERRORS = 'errors'; export const USER_PATH = 'pathAnalysis'; export const RETENTION = 'retention'; -export const FEATURE_ADOPTION = 'featureAdoption'; -export const INSIGHTS = 'insights'; +export const INSIGHTS = 'insights'; // SaaS and EE export const PERFORMANCE = 'performance'; +export const CATEGORIES = { + product_analytics: 'product_analytics', + monitors: 'monitors', + web_analytics: 'web_analytics', +} + + export interface Option { label: string; icon: string; @@ -31,52 +37,75 @@ export interface Option { disabled?: boolean; } +export const TYPE_ICONS = { + [LIBRARY]: 'grid', + [TIMESERIES]: 'graph-up', + [TABLE]: 'list-alt', + [HEATMAP]: 'dashboards/heatmap-2', + [FUNNEL]: 'funnel', + [ERRORS]: 'exclamation-circle', + [USER_PATH]: 'user-journey', + [TABLE]: 'list-alt', +} as const +export const TYPE_NAMES = { + [LIBRARY]: 'Library', + [TIMESERIES]: 'Trend', + [TABLE]: 'Table', + [HEATMAP]: 'Heatmap', + [FUNNEL]: 'Funnel', + [ERRORS]: 'Errors', + [USER_PATH]: 'Journeys', + [RETENTION]: 'Retention', + [INSIGHTS]: 'Insights', + [PERFORMANCE]: 'Performance', +} as const + export const TYPES: CardType[] = [ { title: 'Add from Library', - icon: 'grid', + icon: TYPE_ICONS[LIBRARY], description: 'Select an existing card from your library', slug: LIBRARY, }, { - title: 'Timeseries', - icon: 'graph-up', + title: TYPE_NAMES[TIMESERIES], + icon: TYPE_ICONS[TIMESERIES], description: 'Combine captured events and filters to track trends over time.', slug: TIMESERIES, subTypes: [{ title: 'Session Count', slug: 'sessionCount', description: '' }], }, { - title: 'Heatmap', - icon: 'puzzle-piece', + title: TYPE_NAMES[HEATMAP], + icon: TYPE_ICONS[HEATMAP], description: 'See where users click and where they get frustrated.', slug: HEATMAP, subTypes: [{ title: 'Visited URL', slug: FilterKey.CLICKMAP_URL, description: '' }], }, + // { + // title: 'Table', + // icon: 'list-alt', + // description: 'Create custom tables of users, sessions, errors, issues and more.', + // slug: TABLE, + // subTypes: [ + // { title: 'Users', slug: FilterKey.USERID, description: '' }, + // { title: 'Sessions', slug: FilterKey.SESSIONS, description: '' }, + // { title: 'JS Errors', slug: FilterKey.ERRORS, description: '' }, + // { title: 'Issues', slug: FilterKey.ISSUE, description: '' }, + // { title: 'Browser', slug: FilterKey.USER_BROWSER, description: '' }, + // { title: 'Devices', slug: FilterKey.USER_DEVICE, description: '' }, + // { title: 'Countries', slug: FilterKey.USER_COUNTRY, description: '' }, + // { title: 'URLs', slug: FilterKey.LOCATION, description: '' }, + // ], + // }, { - title: 'Table', - icon: 'list-alt', - description: 'Create custom tables of users, sessions, errors, issues and more.', - slug: TABLE, - subTypes: [ - { title: 'Users', slug: FilterKey.USERID, description: '' }, - { title: 'Sessions', slug: FilterKey.SESSIONS, description: '' }, - { title: 'JS Errors', slug: FilterKey.ERRORS, description: '' }, - { title: 'Issues', slug: FilterKey.ISSUE, description: '' }, - { title: 'Browser', slug: FilterKey.USER_BROWSER, description: '' }, - { title: 'Devices', slug: FilterKey.USER_DEVICE, description: '' }, - { title: 'Countries', slug: FilterKey.USER_COUNTRY, description: '' }, - { title: 'URLs', slug: FilterKey.LOCATION, description: '' }, - ], - }, - { - title: 'Funnel', - icon: 'funnel', + title: TYPE_NAMES[FUNNEL], + icon: TYPE_ICONS[FUNNEL], description: 'Find out where users are dropping and understand why.', slug: FUNNEL, }, { - title: 'Path Analysis', - icon: 'signpost-split', + title: TYPE_NAMES[USER_PATH], + icon: TYPE_ICONS[USER_PATH], description: 'See where users are flowing and explore their journeys.', slug: USER_PATH, }, diff --git a/frontend/app/dateRange.js b/frontend/app/dateRange.js index 518c75982..24895a68a 100644 --- a/frontend/app/dateRange.js +++ b/frontend/app/dateRange.js @@ -17,6 +17,14 @@ const DATE_RANGE_LABELS = { [CUSTOM_RANGE]: "Custom Range", }; +const COMPARISON_DATE_RANGE_LABELS = { + PREV_24_HOURS: "Previous Day", + PREV_7_DAYS: "Previous Week", + PREV_30_DAYS: "Previous Month", + PREV_QUARTER: "Previous Quarter", + CUSTOM_RANGE: "Custom", +} + const DATE_RANGE_VALUES = {}; Object.keys(DATE_RANGE_LABELS).forEach((key) => { DATE_RANGE_VALUES[key] = key; @@ -31,6 +39,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]; @@ -67,6 +81,18 @@ export function getDateRangeFromValue(value) { now.minus({ days: 30 }).startOf('day'), now.endOf('day') ); + case COMPARISON_DATE_RANGE_LABELS.PREV_24_HOURS: + return Interval.fromDateTimes(now.minus({ hours: 48 }), now.minus({ hours: 24 })); + case COMPARISON_DATE_RANGE_LABELS.PREV_7_DAYS: + return Interval.fromDateTimes( + now.minus({ days: 14 }).startOf('day'), + now.minus({ days: 7 }).endOf('day') + ); + case COMPARISON_DATE_RANGE_LABELS.PREV_30_DAYS: + return Interval.fromDateTimes( + now.minus({ days: 60 }).startOf('day'), + now.minus({ days: 30 }).endOf('day') + ); // case DATE_RANGE_VALUES.THIS_MONTH: // return Interval.fromDateTimes(now.startOf('month'), now.endOf('month')); // case DATE_RANGE_VALUES.LAST_MONTH: diff --git a/frontend/app/hooks/useSessionSearchQueryHandler.ts b/frontend/app/hooks/useSessionSearchQueryHandler.ts index 12511be38..c9335ce9b 100644 --- a/frontend/app/hooks/useSessionSearchQueryHandler.ts +++ b/frontend/app/hooks/useSessionSearchQueryHandler.ts @@ -29,8 +29,9 @@ const useSessionSearchQueryHandler = ({ onBeforeLoad, appliedFilter, loading }: const converter = JsonUrlConverter.urlParamsToJson(history.location.search); const json = getFilterFromJson(converter.toJSON()); const filter = new Search(json); - searchStore.applyFilter(filter, true); searchStore.setUrlParsed(); + if (filter.filters.length === 0) return; + searchStore.applyFilter(filter, true); } catch (error) { console.error('Error applying filter from query:', error); } diff --git a/frontend/app/mstore/customFieldStore.ts b/frontend/app/mstore/customFieldStore.ts index f5502615d..26221f1b5 100644 --- a/frontend/app/mstore/customFieldStore.ts +++ b/frontend/app/mstore/customFieldStore.ts @@ -1,17 +1,16 @@ import { makeAutoObservable } from 'mobx'; -import { customFieldService } from 'App/services'; - +import { customFieldService, filterService } from 'App/services'; import { addElementToConditionalFiltersMap, addElementToMobileConditionalFiltersMap, addElementToFiltersMap, addElementToFlagConditionsMap, addElementToLiveFiltersMap, - clearMetaFilters + clearMetaFilters, } from 'Types/filter/newFilter'; -import { FilterCategory } from 'Types/filter/filterType'; +import { FilterCategory, FilterType } from "Types/filter/filterType"; import CustomField from 'App/mstore/types/customField'; -import customFields from 'Components/Client/CustomFields'; +import filterOptions from 'App/constants'; class CustomFieldStore { isLoading: boolean = false; @@ -48,14 +47,41 @@ class CustomFieldStore { const response = await customFieldService.fetchList(siteId); clearMetaFilters(); response.forEach((item: any) => { - addElementToFiltersMap(FilterCategory.METADATA, '_' + item.key); - addElementToLiveFiltersMap(FilterCategory.METADATA, '_' + item.key); - addElementToFlagConditionsMap(FilterCategory.METADATA, '_' + item.key); - addElementToConditionalFiltersMap(FilterCategory.METADATA, '_' + item.key); - addElementToMobileConditionalFiltersMap(FilterCategory.METADATA, '_' + item.key); + const calls = [ + addElementToFiltersMap, + addElementToLiveFiltersMap, + addElementToFlagConditionsMap, + addElementToConditionalFiltersMap, + addElementToMobileConditionalFiltersMap, + ]; + calls.forEach((call) => { + call(FilterCategory.METADATA, '_' + item.key); + }); }); this.list = response.map((item_1: any) => new CustomField(item_1)); this.fetchedMetadata = true; + // custom_event values fetcher; turned off for now; useful for later + // filterService.fetchTopValues('custom', undefined).then((response: []) => { + // response.forEach((item: any) => { + // const calls = [ + // addElementToFiltersMap, + // addElementToFlagConditionsMap, + // addElementToConditionalFiltersMap, + // addElementToMobileConditionalFiltersMap, + // ]; + // calls.forEach((call) => { + // call( + // FilterCategory.EVENTS, + // '_' + item.value, + // FilterType.MULTIPLE, + // 'is', + // filterOptions.stringOperators, + // 'filters/custom', + // true + // ); + // }); + // }); + // }); } finally { this.isLoading = false; } @@ -65,11 +91,14 @@ class CustomFieldStore { this.isLoading = true; try { const response = await customFieldService.get('/integration/sources'); - this.sources = response.map(({ value, ...item }: any) => new CustomField({ - label: value, - key: value, - ...item - })); + this.sources = response.map( + ({ value, ...item }: any) => + new CustomField({ + label: value, + key: value, + ...item, + }) + ); } finally { this.isLoading = false; } @@ -79,16 +108,18 @@ class CustomFieldStore { this.isSaving = true; try { const wasCreating = !instance.exists(); - const response = wasCreating ? await customFieldService.create(siteId, instance.toData()) : - await customFieldService.update(siteId, instance.toData()); + const response = wasCreating + ? await customFieldService.create(siteId, instance.toData()) + : await customFieldService.update(siteId, instance.toData()); const updatedInstance = new CustomField(response); if (wasCreating) { this.list.push(updatedInstance); } else { - const index = this.list.findIndex(item => item.index === instance.index); - if (index >= 0) - this.list[index] = updatedInstance; + const index = this.list.findIndex( + (item) => item.index === instance.index + ); + if (index >= 0) this.list[index] = updatedInstance; } } finally { this.isSaving = false; @@ -99,7 +130,7 @@ class CustomFieldStore { this.isSaving = true; try { await customFieldService.delete(siteId, index); - this.list = this.list.filter(item => item.index !== index); + this.list = this.list.filter((item) => item.index !== index); } finally { this.isSaving = false; } @@ -115,5 +146,4 @@ class CustomFieldStore { } } - export default CustomFieldStore; diff --git a/frontend/app/mstore/dashboardStore.ts b/frontend/app/mstore/dashboardStore.ts index 36a34cbdb..64550e52c 100644 --- a/frontend/app/mstore/dashboardStore.ts +++ b/frontend/app/mstore/dashboardStore.ts @@ -22,7 +22,10 @@ export default class DashboardStore { widgets: Widget[] = []; period: Record = Period({ rangeName: LAST_24_HOURS }); drillDownFilter: Filter = new Filter(); + comparisonFilter: Filter = new Filter(); drillDownPeriod: Record = Period({ rangeName: LAST_7_DAYS }); + selectedDensity: number = 7 // depends on default drilldown, 7 points here!!!; + comparisonPeriods: Record = {} startTimestamp: number = 0; endTimestamp: number = 0; pendingRequests: number = 0; @@ -55,6 +58,10 @@ export default class DashboardStore { this.resetDrillDownFilter(); } + setDensity = (density: any) => { + this.selectedDensity = parseInt(density, 10); + } + get sortedDashboards() { const sortOrder = this.sort.by; return [...this.dashboards].sort((a, b) => @@ -408,6 +415,20 @@ export default class DashboardStore { }); } + setComparisonPeriod(period: any, metricId: string) { + if (!period) { + return this.comparisonPeriods[metricId] = null + } + this.comparisonPeriods[metricId] = period; + } + + cloneCompFilter() { + const filterData = this.drillDownFilter.toData() + this.comparisonFilter = new Filter().fromData(filterData); + + return this.comparisonFilter; + } + toggleAlertModal(val: boolean) { this.showAlertModal = val; } @@ -424,12 +445,14 @@ export default class DashboardStore { metric: Widget, data: any, isSaved: boolean = false, - period: Record + period: Record, + isComparison?: boolean ): Promise { period = period.toTimestamps(); + const density = data.density; const params = { ...period, ...data, key: metric.predefinedKey }; - if (metric.page && metric.limit) { + if (!isComparison && metric.page && metric.limit) { params['page'] = metric.page; params['limit'] = metric.limit; } @@ -437,13 +460,13 @@ export default class DashboardStore { return new Promise(async (resolve, reject) => { this.upPendingRequests() - if (metric.metricType === 'table' && metric.metricOf === 'jsException') { + if (!isComparison && metric.metricType === 'table' && metric.metricOf === 'jsException') { params.limit = 5; } try { const data = await metricService.getMetricChartData(metric, params, isSaved); - resolve(metric.setData(data, period)); + resolve(metric.setData(data, period, isComparison, density)); } catch (error) { reject(error); } finally { diff --git a/frontend/app/mstore/filterStore.ts b/frontend/app/mstore/filterStore.ts index 3ffd9023c..0af2be151 100644 --- a/frontend/app/mstore/filterStore.ts +++ b/frontend/app/mstore/filterStore.ts @@ -1,5 +1,4 @@ import { makeAutoObservable } from 'mobx'; -import { filters } from 'Types/filter/newFilter'; import { filterService } from 'App/services'; interface TopValue { @@ -19,8 +18,9 @@ export default class FilterStore { makeAutoObservable(this); } - setTopValues = (key: string, values: TopValue[]) => { - this.topValues[key] = values?.filter((value) => value !== null && value.value !== ''); + setTopValues = (key: string, values: Record | TopValue[]) => { + const vals = Array.isArray(values) ? values : values.data + this.topValues[key] = vals?.filter((value) => value !== null && value.value !== ''); }; fetchTopValues = async (key: string, source?: string) => { diff --git a/frontend/app/mstore/metricStore.ts b/frontend/app/mstore/metricStore.ts index 43eb6b223..40ebb01a3 100644 --- a/frontend/app/mstore/metricStore.ts +++ b/frontend/app/mstore/metricStore.ts @@ -1,4 +1,4 @@ -import { makeAutoObservable } from 'mobx'; +import { makeAutoObservable, runInAction } from "mobx"; import Widget from './types/widget'; import { metricService, errorService } from 'App/services'; import { toast } from 'react-toastify'; @@ -11,10 +11,65 @@ import { INSIGHTS, HEATMAP, USER_PATH, - RETENTION + RETENTION, + CATEGORIES, } from 'App/constants/card'; import { clickmapFilter } from 'App/types/filter/newFilter'; import { getRE } from 'App/utils'; +import { FilterKey } from 'Types/filter/filterType'; + +const handleFilter = (card: Widget, filterType?: string) => { + const metricType = card.metricType; + if (filterType === 'all' || !filterType || !metricType) { + return true; + } + if ([CATEGORIES.monitors, CATEGORIES.web_analytics].includes(filterType)) { + if (metricType !== 'table') return false; + const metricOf = card.metricOf; + if (filterType === CATEGORIES.monitors) { + return [ + FilterKey.ERRORS, + FilterKey.FETCH, + TIMESERIES + '_4xx_requests', + TIMESERIES + '_slow_network_requests' + ].includes(metricOf) + } + if (filterType === CATEGORIES.web_analytics) { + return [ + FilterKey.LOCATION, + FilterKey.USER_BROWSER, + FilterKey.REFERRER, + FilterKey.USERID, + FilterKey.LOCATION, + FilterKey.USER_DEVICE, + ].includes(metricOf) + } + } else { + return filterType === metricType; + } +} + +const cardToCategory = (cardType: string) => { + switch (cardType) { + case TIMESERIES: + case FUNNEL: + case USER_PATH: + case HEATMAP: + return CATEGORIES.product_analytics; + case FilterKey.ERRORS: + case FilterKey.FETCH: + case TIMESERIES + '_4xx_requests': + case TIMESERIES + '_slow_network_requests': + return CATEGORIES.monitors; + case FilterKey.LOCATION: + case FilterKey.USER_BROWSER: + case FilterKey.REFERRER: + case FilterKey.USERID: + return CATEGORIES.web_analytics; + default: + return CATEGORIES.product_analytics; + } +} interface MetricFilter { query?: string; @@ -44,6 +99,11 @@ export default class MetricStore { clickMapSearch = ''; clickMapLabel = ''; + cardCategory: string | null = CATEGORIES.product_analytics; + + focusedSeriesName: string | null = null; + drillDown = false; + constructor() { makeAutoObservable(this); } @@ -63,7 +123,7 @@ export default class MetricStore { (this.filter.showMine ? card.owner === JSON.parse(localStorage.getItem('user')!).account.email : true) && - (this.filter.type === 'all' || card.metricType === this.filter.type) && + handleFilter(card, this.filter.type) && (!dbIds.length || card.dashboards.map((i) => i.dashboardId).some((id) => dbIds.includes(id))) && // @ts-ignore @@ -79,6 +139,22 @@ export default class MetricStore { this.instance.update(metric || new Widget()); } + setDrillDown(val: boolean) { + this.drillDown = val; + } + + setFocusedSeriesName(name: string | null, resetOnSame = true) { + if (this.focusedSeriesName === name && resetOnSame) { + this.focusedSeriesName = null; + } else { + this.focusedSeriesName = name; + } + } + + setCardCategory(category: string) { + this.cardCategory = category; + } + updateKey(key: string, value: any) { // @ts-ignore this[key] = value; @@ -100,11 +176,13 @@ export default class MetricStore { merge(obj: any, updateChangeFlag: boolean = true) { const type = obj.metricType; - // handle metricType change if (obj.hasOwnProperty('metricType') && type !== this.instance.metricType) { this.instance.series.forEach((s: any, i: number) => { this.instance.series[i].filter.eventsOrderSupport = ['then', 'or', 'and'] }) + if (type === HEATMAP && 'series' in obj) { + delete obj['series'] + } this.changeType(type); } @@ -129,8 +207,19 @@ export default class MetricStore { this.instance.updateKey('hasChanged', updateChangeFlag); } - changeType(value: string) { - const obj: any = { metricType: value }; + changeType(value: string, metricOf?: string) { + const defaultData = { + sessionId: '', + sessions: [], + issues: [], + total: 0, + chart: [], + namesMap: {}, + avg: 0, + percentiles: [], + values: [], + }; + const obj: any = { metricType: value, data: defaultData }; obj.series = this.instance.series; obj.series = obj.series.slice(0, 1); @@ -193,6 +282,10 @@ export default class MetricStore { } } + if (metricOf) { + obj['metricOf'] = metricOf; + } + this.instance.update(obj); } @@ -283,7 +376,13 @@ export default class MetricStore { return metricService .getMetric(id) .then((metric: any) => { - return (this.instance = new Widget().fromJson(metric, period)); + const inst = new Widget().fromJson(metric, period) + runInAction(() => { + this.instance = inst; + const type = inst.metricType === 'table' ? inst.metricOf : inst.metricType + this.cardCategory = cardToCategory(type); + }) + return inst; }) .finally(() => { this.setLoading(false); diff --git a/frontend/app/mstore/projectsStore.ts b/frontend/app/mstore/projectsStore.ts index 7fda0c44c..483e2d271 100644 --- a/frontend/app/mstore/projectsStore.ts +++ b/frontend/app/mstore/projectsStore.ts @@ -34,6 +34,10 @@ export default class ProjectsStore { return this.active ? ['ios', 'android'].includes(this.active.platform) : false; } + get activeSiteId() { + return this.active?.id || this.siteId; + } + syncProjectInList = (project: Partial) => { const index = this.list.findIndex(site => site.id === project.id); if (index !== -1) { diff --git a/frontend/app/mstore/searchStore.ts b/frontend/app/mstore/searchStore.ts index 0ff942d43..1c9f7373e 100644 --- a/frontend/app/mstore/searchStore.ts +++ b/frontend/app/mstore/searchStore.ts @@ -5,7 +5,7 @@ import { filtersMap, generateFilterOptions, liveFiltersMap, - mobileConditionalFiltersMap + mobileConditionalFiltersMap, } from 'Types/filter/newFilter'; import { List } from 'immutable'; import { makeAutoObservable, runInAction } from 'mobx'; @@ -28,18 +28,18 @@ export const checkValues = (key: any, value: any) => { }; export const filterMap = ({ - category, - value, - key, - operator, - sourceOperator, - source, - custom, - isEvent, - filters, - sort, - order - }: any) => ({ + category, + value, + key, + operator, + sourceOperator, + source, + custom, + isEvent, + filters, + sort, + order, +}: any) => ({ value: checkValues(key, value), custom, type: category === FilterCategory.METADATA ? FilterKey.METADATA : key, @@ -47,7 +47,7 @@ export const filterMap = ({ source: category === FilterCategory.METADATA ? key.replace(/^_/, '') : source, sourceOperator, isEvent, - filters: filters ? filters.map(filterMap) : [] + filters: filters ? filters.map(filterMap) : [], }); export const TAB_MAP: any = { @@ -55,11 +55,11 @@ export const TAB_MAP: any = { sessions: { name: 'Sessions', type: 'sessions' }, bookmarks: { name: 'Bookmarks', type: 'bookmarks' }, notes: { name: 'Notes', type: 'notes' }, - recommendations: { name: 'Recommendations', type: 'recommendations' } + recommendations: { name: 'Recommendations', type: 'recommendations' }, }; class SearchStore { - list = List(); + list: SavedSearch[] = []; latestRequestTime: number | null = null; latestList = List(); alertMetricId: number | null = null; @@ -107,13 +107,19 @@ class SearchStore { applySavedSearch(savedSearch: ISavedSearch) { this.savedSearch = savedSearch; - this.edit({ filters: savedSearch.filter ? savedSearch.filter.filters.map((i: FilterItem) => new FilterItem().fromJson(i)) : [] }); + this.edit({ + filters: savedSearch.filter + ? savedSearch.filter.filters.map((i: FilterItem) => + new FilterItem().fromJson(i) + ) + : [], + }); this.currentPage = 1; } async fetchSavedSearchList() { const response = await searchService.fetchSavedSearch(); - this.list = List(response.map((item: any) => new SavedSearch(item))); + this.list = response.map((item: any) => new SavedSearch(item)); } edit(instance: Partial) { @@ -122,7 +128,9 @@ class SearchStore { } editSavedSearch(instance: Partial) { - this.savedSearch = new SavedSearch(Object.assign(this.savedSearch.toData(), instance)); + this.savedSearch = new SavedSearch( + Object.assign(this.savedSearch.toData(), instance) + ); } apply(filter: any, fromUrl: boolean) { @@ -145,7 +153,10 @@ class SearchStore { .fetchFilterSearch(params) .then((response: any[]) => { this.filterSearchList = response.reduce( - (acc: Record, item: any) => { + ( + acc: Record, + item: any + ) => { const { projectId, type, value } = item; if (!acc[type]) acc[type] = []; acc[type].push({ projectId, value }); @@ -200,23 +211,25 @@ class SearchStore { await this.fetchSavedSearchList(); if (isNew) { - const lastSavedSearch = this.list.last(); + const lastSavedSearch = this.list[this.list.length - 1]; this.applySavedSearch(lastSavedSearch); } } clearList() { - this.list = List(); + this.list = []; } clearSearch() { const instance = this.instance; - this.edit(new Search({ - rangeValue: instance.rangeValue, - startDate: instance.startDate, - endDate: instance.endDate, - filters: [] - })); + this.edit( + new Search({ + rangeValue: instance.rangeValue, + startDate: instance.startDate, + endDate: instance.endDate, + filters: [], + }) + ); this.savedSearch = new SavedSearch({}); sessionStore.clearList(); @@ -226,7 +239,11 @@ class SearchStore { checkForLatestSessions() { const filter = this.instance.toSearch(); if (this.latestRequestTime) { - const period = Period({ rangeName: CUSTOM_RANGE, start: this.latestRequestTime, end: Date.now() }); + const period = Period({ + rangeName: CUSTOM_RANGE, + start: this.latestRequestTime, + end: Date.now(), + }); const newTimestamps: any = period.toJSON(); filter.startDate = newTimestamps.startDate; filter.endDate = newTimestamps.endDate; @@ -242,28 +259,32 @@ class SearchStore { } addFilter(filter: any) { - const index = filter.isEvent ? -1 : this.instance.filters.findIndex((i: FilterItem) => i.key === filter.key); + const index = filter.isEvent + ? -1 + : this.instance.filters.findIndex( + (i: FilterItem) => i.key === filter.key + ); filter.value = checkFilterValue(filter.value); filter.filters = filter.filters ? filter.filters.map((subFilter: any) => ({ - ...subFilter, - value: checkFilterValue(subFilter.value) - })) + ...subFilter, + value: checkFilterValue(subFilter.value), + })) : null; if (index > -1) { const oldFilter = new FilterItem(this.instance.filters[index]); const updatedFilter = { ...oldFilter, - value: oldFilter.value.concat(filter.value) + value: oldFilter.value.concat(filter.value), }; oldFilter.merge(updatedFilter); this.updateFilter(index, updatedFilter); } else { this.instance.filters.push(filter); this.instance = new Search({ - ...this.instance.toData() + ...this.instance.toData(), }); } @@ -274,7 +295,13 @@ class SearchStore { } } - addFilterByKeyAndValue(key: any, value: any, operator?: string, sourceOperator?: string, source?: string) { + addFilterByKeyAndValue( + key: any, + value: any, + operator?: string, + sourceOperator?: string, + source?: string + ) { let defaultFilter = { ...filtersMap[key] }; defaultFilter.value = value; @@ -304,7 +331,7 @@ class SearchStore { this.instance = new Search({ ...this.instance.toData(), - filters: newFilters + filters: newFilters, }); }; @@ -315,7 +342,7 @@ class SearchStore { this.instance = new Search({ ...this.instance.toData(), - filters: newFilters + filters: newFilters, }); }; @@ -327,13 +354,18 @@ class SearchStore { // TODO } - async fetchSessions(force: boolean = false, bookmarked: boolean = false): Promise { + async fetchSessions( + force: boolean = false, + bookmarked: boolean = false + ): Promise { const filter = this.instance.toSearch(); if (this.activeTags[0] && this.activeTags[0] !== 'all') { const tagFilter = filtersMap[FilterKey.ISSUE]; tagFilter.type = tagFilter.type.toLowerCase(); - tagFilter.value = [issues_types.find((i: any) => i.type === this.activeTags[0])?.type]; + tagFilter.value = [ + issues_types.find((i: any) => i.type === this.activeTags[0])?.type, + ]; delete tagFilter.operatorOptions; delete tagFilter.options; delete tagFilter.placeholder; @@ -345,14 +377,17 @@ class SearchStore { this.latestRequestTime = Date.now(); this.latestList = List(); - await sessionStore.fetchSessions({ - ...filter, - page: this.currentPage, - perPage: this.pageSize, - tab: this.activeTab.type, - bookmarked: bookmarked ? true : undefined - }, force); - }; + await sessionStore.fetchSessions( + { + ...filter, + page: this.currentPage, + perPage: this.pageSize, + tab: this.activeTab.type, + bookmarked: bookmarked ? true : undefined, + }, + force + ); + } } export default SearchStore; diff --git a/frontend/app/mstore/types/dashboard.ts b/frontend/app/mstore/types/dashboard.ts index a82702596..728c06503 100644 --- a/frontend/app/mstore/types/dashboard.ts +++ b/frontend/app/mstore/types/dashboard.ts @@ -14,6 +14,7 @@ export default class Dashboard { widgets: Widget[] = [] metrics: any[] = [] isValid: boolean = false + id: string = "" currentWidget: Widget = new Widget() config: any = {} createdAt: number = new Date().getTime() diff --git a/frontend/app/mstore/types/filterItem.ts b/frontend/app/mstore/types/filterItem.ts index 6082aae4b..18716fb55 100644 --- a/frontend/app/mstore/types/filterItem.ts +++ b/frontend/app/mstore/types/filterItem.ts @@ -4,7 +4,7 @@ import { filtersMap, mobileConditionalFiltersMap, } from 'Types/filter/newFilter'; -import { action, makeAutoObservable, observable } from 'mobx'; +import { makeAutoObservable } from 'mobx'; import { pageUrlOperators } from '../../constants/filterOptions'; diff --git a/frontend/app/mstore/types/funnel.ts b/frontend/app/mstore/types/funnel.ts index 198c0c166..f99d2fdd3 100644 --- a/frontend/app/mstore/types/funnel.ts +++ b/frontend/app/mstore/types/funnel.ts @@ -21,7 +21,7 @@ export default class Funnel { } this.totalDropDueToIssues = json.totalDropDueToIssues; - if (json.stages.length >= 1) { + if (json.stages?.length >= 1) { const firstStage = json.stages[0] this.stages = json.stages ? json.stages.map((stage: any, index: number) => new FunnelStage().fromJSON(stage, firstStage.count, index > 0 ? json.stages[index - 1].count : stage.count)) : [] const filteredStages = this.stages.filter((stage: any) => stage.isActive) diff --git a/frontend/app/mstore/types/funnelStage.ts b/frontend/app/mstore/types/funnelStage.ts index 9b158363d..c5f7e08e8 100644 --- a/frontend/app/mstore/types/funnelStage.ts +++ b/frontend/app/mstore/types/funnelStage.ts @@ -33,7 +33,7 @@ export default class FunnelStage { this.value = json.value; this.type = json.type; this.label = filterLabelMap[json.type] || json.type; - this.completedPercentage = total ? Math.round((this.count / previousSessionCount) * 100) : 0; + this.completedPercentage = previousSessionCount ? Math.round((this.count / previousSessionCount) * 100) : 0; this.completedPercentageTotal = total ? Math.round((this.count / total) * 100) : 0; this.dropDueToIssuesPercentage = total ? Math.round((this.dropDueToIssues / total) * 100) : 0; this.droppedCount = previousSessionCount - this.count; diff --git a/frontend/app/mstore/types/widget.ts b/frontend/app/mstore/types/widget.ts index 94ae08fe2..1dd613640 100644 --- a/frontend/app/mstore/types/widget.ts +++ b/frontend/app/mstore/types/widget.ts @@ -8,7 +8,7 @@ import {FilterKey} from 'Types/filter/filterType'; import Period, {LAST_24_HOURS} from 'Types/app/period'; import Funnel from '../types/funnel'; import {metricService} from 'App/services'; -import { FUNNEL, HEATMAP, INSIGHTS, TABLE, USER_PATH } from "App/constants/card"; +import { FUNNEL, HEATMAP, INSIGHTS, TABLE, TIMESERIES, USER_PATH } from "App/constants/card"; import { ErrorInfo } from '../types/error'; import {getChartFormatter} from 'Types/dashboard/helper'; import FilterItem from './filterItem'; @@ -85,11 +85,12 @@ export default class Widget { page: number = 1; limit: number = 20; thumbnail?: string; - params: any = {density: 70}; + params: any = {density: 35}; startType: string = 'start'; startPoint: FilterItem = new FilterItem(filtersMap[FilterKey.LOCATION]); excludes: FilterItem[] = []; hideExcess?: boolean = false; + compareTo: [startDate?: string, endDate?: string] | null = null period: Record = Period({rangeName: LAST_24_HOURS}); // temp value in detail view hasChanged: boolean = false; @@ -100,8 +101,9 @@ export default class Widget { sessions: [], issues: [], total: 0, + values: [], chart: [], - namesMap: {}, + namesMap: [], avg: 0, percentiles: [] }; @@ -123,6 +125,7 @@ export default class Widget { removeSeries(index: number) { this.series.splice(index, 1); + this.hasChanged = true; } setSeries(series: FilterSeries[]) { @@ -130,6 +133,7 @@ export default class Widget { } addSeries() { + this.hasChanged = true; const series = new FilterSeries(); series.name = 'Series ' + (this.series.length + 1); this.series.push(series); @@ -151,6 +155,7 @@ export default class Widget { this.metricFormat = json.metricFormat; this.viewType = json.viewType; this.name = json.name; + this.compareTo = json.compareTo || null; this.series = json.series && json.series.length > 0 ? json.series.map((series: any) => new FilterSeries().fromJson(series, this.metricType === HEATMAP)) @@ -225,6 +230,7 @@ export default class Widget { sessionId: this.data.sessionId, page: this.page, limit: this.limit, + compareTo: this.compareTo, config: { ...this.config, col: @@ -293,8 +299,22 @@ export default class Widget { this.page = page; } - setData(data: any, period: any) { - const _data: any = {...data}; + setData(data: { timestamp: number, [seriesName: string]: number}[], period: any, isComparison: boolean = false, density?: number) { + if (!data) return; + const _data: any = {}; + if (isComparison && this.metricType === TIMESERIES) { + data.forEach((point, i) => { + Object.keys(point).forEach((key) => { + if (key === 'timestamp') return; + point[`Previous ${key}`] = point[key]; + delete point[key]; + }) + }) + } + + if (this.metricType === HEATMAP) { + return; + } if (this.metricType === USER_PATH) { const _data = processData(data); @@ -312,16 +332,16 @@ export default class Widget { new InsightIssue(i.category, i.name, i.ratio, i.oldValue, i.value, i.change, i.isNew) ); } else if (this.metricType === FUNNEL) { - _data.funnel = new Funnel().fromJSON(_data); + _data.funnel = new Funnel().fromJSON(data); } else if (this.metricType === TABLE) { // const total = data[0]['total']; const count = data[0]['count']; - _data[0]['values'] = data[0]['values'].map((s: any) => new SessionsByRow().fromJson(s, count, this.metricOf)); + _data['values'] = data[0]['values'].map((s: any) => new SessionsByRow().fromJson(s, count, this.metricOf)); } else { if (data.hasOwnProperty('chart')) { _data['value'] = data.value; _data['unit'] = data.unit; - _data['chart'] = getChartFormatter(period)(data.chart); + _data['chart'] = getChartFormatter(period, density)(data.chart); _data['namesMap'] = data.chart .map((i: any) => Object.keys(i)) .flat() @@ -333,10 +353,9 @@ export default class Widget { return unique; }, []); } else { - const updatedData: any = this.calculateTotalSeries(data); - _data['chart'] = getChartFormatter(period)(updatedData); - _data['namesMap'] = Array.isArray(updatedData) - ? updatedData + _data['chart'] = getChartFormatter(period, density)(data); + _data['namesMap'] = Array.isArray(data) + ? data .map((i) => Object.keys(i)) .flat() .filter((i) => i !== 'time' && i !== 'timestamp') @@ -350,7 +369,12 @@ export default class Widget { } } - Object.assign(this.data, _data); + + if (!isComparison) { + runInAction(() => { + Object.assign(this.data, _data); + }) + } return _data; } @@ -396,6 +420,10 @@ export default class Widget { } } + setComparisonRange(range: [start: string, end?: string] | null) { + this.compareTo = range; + } + fetchIssue(funnelId: any, issueId: any, params: any): Promise { return new Promise((resolve, reject) => { diff --git a/frontend/app/mstore/userStore.ts b/frontend/app/mstore/userStore.ts index 3ac15f9b3..b8bee0386 100644 --- a/frontend/app/mstore/userStore.ts +++ b/frontend/app/mstore/userStore.ts @@ -619,7 +619,7 @@ type AuthDetails = { sso: string | null; ssoProvider: string | null; enforceSSO: boolean | null; - edition: 'foss' | 'ee' | 'msaas'; + edition?: 'foss' | 'ee' | 'msaas'; }; class AuthStore { @@ -641,10 +641,13 @@ class AuthStore { { key: 'authDetails', serialize: (ad) => { + delete ad['edition'] return Object.keys(ad).length > 0 ? JSON.stringify(ad) : JSON.stringify({}); }, deserialize: (json) => { - return JSON.parse(json); + const ad = JSON.parse(json) + delete ad['edition'] + return ad; } } ], diff --git a/frontend/app/services/MetricService.ts b/frontend/app/services/MetricService.ts index 4e90ccb4d..0a24c0e4e 100644 --- a/frontend/app/services/MetricService.ts +++ b/frontend/app/services/MetricService.ts @@ -82,7 +82,7 @@ export default class MetricService { } const path = isSaved ? `/cards/${metric.metricId}/chart` : `/cards/try`; if (metric.metricType === USER_PATH) { - data.density = 5; + data.density = 3; data.metricOf = 'sessionCount'; } try { diff --git a/frontend/app/styles/general.css b/frontend/app/styles/general.css index 4278fdaae..76dffb1f1 100644 --- a/frontend/app/styles/general.css +++ b/frontend/app/styles/general.css @@ -158,7 +158,7 @@ } .border-gray-light { - border: solid thin $gray-light + border: solid thin rgb(229 231 235 / var(--tw-text-opacity, 1)) } .btn-disabled { @@ -363,8 +363,8 @@ p { 135deg, transparent, transparent 2px, - #ccc 2px, - #ccc 4px + #ccc 1px, + #ccc 1px ); } @@ -447,6 +447,12 @@ p { display: flex; align-items: center; } +input[type="number"]::-webkit-inner-spin-button, +input[type="number"]::-webkit-outer-spin-button { + -webkit-appearance: none; /* Remove default styling */ + appearance: none; + margin: 0; /* Fix margin if necessary */ +} .ant-segmented-item{ border-radius: .5rem !important; diff --git a/frontend/app/styles/main.css b/frontend/app/styles/main.css index 92a15fbe7..e7d2b388f 100644 --- a/frontend/app/styles/main.css +++ b/frontend/app/styles/main.css @@ -386,4 +386,21 @@ svg { .code-font { font-family: Menlo, Monaco, Consolas, serif; letter-spacing: -0.025rem +} + +.ai-gradient { + background: linear-gradient(180deg, rgba(0, 199, 149, 0.72) 0%, rgba(60, 0, 255, 0.85) 77%); + background-clip: text; + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + -webkit-text-fill-color: transparent; +} + +.custom-scrollbar::-webkit-scrollbar { + height: 4px; +} +.custom-scrollbar::-webkit-scrollbar-thumb { + background-color: #c6c6c6; + border-radius: 4px; + cursor: grab; } \ No newline at end of file diff --git a/frontend/app/svg/icons/console/error.svg b/frontend/app/svg/icons/console/error.svg index a170d1363..a1db73a3d 100644 --- a/frontend/app/svg/icons/console/error.svg +++ b/frontend/app/svg/icons/console/error.svg @@ -1,4 +1 @@ - - - - \ No newline at end of file + \ No newline at end of file diff --git a/frontend/app/svg/icons/dashboards/circle-alert.svg b/frontend/app/svg/icons/dashboards/circle-alert.svg new file mode 100644 index 000000000..90fc1a433 --- /dev/null +++ b/frontend/app/svg/icons/dashboards/circle-alert.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/frontend/app/svg/icons/dashboards/cohort-chart.svg b/frontend/app/svg/icons/dashboards/cohort-chart.svg new file mode 100644 index 000000000..e33cace6d --- /dev/null +++ b/frontend/app/svg/icons/dashboards/cohort-chart.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/frontend/app/svg/icons/dashboards/heatmap-2.svg b/frontend/app/svg/icons/dashboards/heatmap-2.svg new file mode 100644 index 000000000..eae6f966b --- /dev/null +++ b/frontend/app/svg/icons/dashboards/heatmap-2.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/frontend/app/svg/icons/dashboards/user-journey.svg b/frontend/app/svg/icons/dashboards/user-journey.svg new file mode 100644 index 000000000..f2b4f5eab --- /dev/null +++ b/frontend/app/svg/icons/dashboards/user-journey.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/frontend/app/svg/icons/exclamation-circle.svg b/frontend/app/svg/icons/exclamation-circle.svg index a170d1363..cbf65c624 100644 --- a/frontend/app/svg/icons/exclamation-circle.svg +++ b/frontend/app/svg/icons/exclamation-circle.svg @@ -1,4 +1 @@ - - - - \ No newline at end of file + \ No newline at end of file diff --git a/frontend/app/svg/icons/exclamation-triangle.svg b/frontend/app/svg/icons/exclamation-triangle.svg index 84b3ce962..4b1e0110b 100644 --- a/frontend/app/svg/icons/exclamation-triangle.svg +++ b/frontend/app/svg/icons/exclamation-triangle.svg @@ -1,4 +1 @@ - - - - \ No newline at end of file + \ No newline at end of file diff --git a/frontend/app/svg/icons/filter.svg b/frontend/app/svg/icons/filter.svg index 7cfb69d18..5c69db749 100644 --- a/frontend/app/svg/icons/filter.svg +++ b/frontend/app/svg/icons/filter.svg @@ -1,3 +1 @@ - - - \ No newline at end of file + \ No newline at end of file diff --git a/frontend/app/svg/icons/filters/error.svg b/frontend/app/svg/icons/filters/error.svg index a170d1363..a1db73a3d 100644 --- a/frontend/app/svg/icons/filters/error.svg +++ b/frontend/app/svg/icons/filters/error.svg @@ -1,4 +1 @@ - - - - \ No newline at end of file + \ No newline at end of file diff --git a/frontend/app/svg/icons/funnel.svg b/frontend/app/svg/icons/funnel.svg index 5f29fe302..5c69db749 100644 --- a/frontend/app/svg/icons/funnel.svg +++ b/frontend/app/svg/icons/funnel.svg @@ -1,3 +1 @@ - - - \ No newline at end of file + \ No newline at end of file diff --git a/frontend/app/svg/icons/funnel/exclamation-circle.svg b/frontend/app/svg/icons/funnel/exclamation-circle.svg index 3b32c2a0c..cbf65c624 100644 --- a/frontend/app/svg/icons/funnel/exclamation-circle.svg +++ b/frontend/app/svg/icons/funnel/exclamation-circle.svg @@ -1,4 +1 @@ - - - - \ No newline at end of file + \ No newline at end of file diff --git a/frontend/app/svg/icons/graph-up.svg b/frontend/app/svg/icons/graph-up.svg index 3a8d30c84..05493fd82 100644 --- a/frontend/app/svg/icons/graph-up.svg +++ b/frontend/app/svg/icons/graph-up.svg @@ -1,3 +1 @@ - - - + \ No newline at end of file diff --git a/frontend/app/svg/icons/ic-errors.svg b/frontend/app/svg/icons/ic-errors.svg index 3b32c2a0c..a1db73a3d 100644 --- a/frontend/app/svg/icons/ic-errors.svg +++ b/frontend/app/svg/icons/ic-errors.svg @@ -1,4 +1 @@ - - - - \ No newline at end of file + \ No newline at end of file diff --git a/frontend/app/svg/icons/list-alt.svg b/frontend/app/svg/icons/list-alt.svg index 1ff78196c..8ecaa917f 100644 --- a/frontend/app/svg/icons/list-alt.svg +++ b/frontend/app/svg/icons/list-alt.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/frontend/app/svg/icons/signpost-split.svg b/frontend/app/svg/icons/signpost-split.svg index ca20fd1a4..0497a2298 100644 --- a/frontend/app/svg/icons/signpost-split.svg +++ b/frontend/app/svg/icons/signpost-split.svg @@ -1,3 +1,18 @@ - - + + + + \ No newline at end of file diff --git a/frontend/app/svg/icons/user-journey.svg b/frontend/app/svg/icons/user-journey.svg new file mode 100644 index 000000000..0497a2298 --- /dev/null +++ b/frontend/app/svg/icons/user-journey.svg @@ -0,0 +1,18 @@ + + + + + \ No newline at end of file diff --git a/frontend/app/theme/colors.js b/frontend/app/theme/colors.js index f11c112f2..6a1b41e20 100644 --- a/frontend/app/theme/colors.js +++ b/frontend/app/theme/colors.js @@ -9,6 +9,7 @@ module.exports = { "gray-medium": "#888", "gray-dark": "#666", "gray-darkest": "#333", + "gray-light-blue": "#F8F8FA", teal: "#394EFF", /* blue */ "teal-dark": "#2331A8", /* "blue-dark" */ diff --git a/frontend/app/types/app/period.js b/frontend/app/types/app/period.js index 01c2e9d01..bd4338698 100644 --- a/frontend/app/types/app/period.js +++ b/frontend/app/types/app/period.js @@ -11,6 +11,10 @@ export const THIS_MONTH = "THIS_MONTH"; export const LAST_MONTH = "LAST_MONTH"; export const THIS_YEAR = "THIS_YEAR"; export const CUSTOM_RANGE = "CUSTOM_RANGE"; +export const PREV_24_HOURS = "PREV_24_HOURS"; +export const PREV_7_DAYS = "PREV_7_DAYS"; +export const PREV_30_DAYS = "PREV_30_DAYS"; +export const PREV_QUARTER = "PREV_QUARTER"; function getRange(rangeName, offset) { const now = DateTime.now().setZone(offset); @@ -47,33 +51,60 @@ function getRange(rangeName, offset) { return Interval.fromDateTimes(lastMonth.startOf("month"), lastMonth.endOf("month")); case THIS_YEAR: return Interval.fromDateTimes(now.startOf("year"), now.endOf("year")); + case PREV_24_HOURS: + return Interval.fromDateTimes(now.minus({ hours: 48 }), now.minus({ hours: 24 })); + case PREV_7_DAYS: + return Interval.fromDateTimes( + now.minus({ days: 14 }).startOf("day"), + now.minus({ days: 7 }).endOf("day") + ); + case PREV_30_DAYS: + return Interval.fromDateTimes( + now.minus({ days: 60 }).startOf("day"), + now.minus({ days: 30 }).endOf("day") + ); default: return Interval.fromDateTimes(now, now); } } +const substractValues = { + [PREV_24_HOURS]: { hours: 24 }, + [PREV_7_DAYS]: { days: 7 }, + [PREV_30_DAYS]: { days: 30 }, + [PREV_QUARTER]: { months: 3 }, +} + export default Record( { start: 0, end: 0, rangeName: CUSTOM_RANGE, range: Interval.fromDateTimes(DateTime.now(), DateTime.now()), + substract: null, }, { + // type substractors = 'PREV_24_HOURS' | 'PREV_7_DAYS' | 'PREV_30_DAYS' | 'PREV_QUARTER'; fromJS: (period) => { const offset = period.timezoneOffset || DateTime.now().offset; if (!period.rangeName || period.rangeName === CUSTOM_RANGE) { const isLuxon = DateTime.isDateTime(period.start); - const start = isLuxon + let start = isLuxon ? period.start : DateTime.fromMillis(period.start || 0, { zone: Settings.defaultZone }); - const end = isLuxon + let end = isLuxon ? period.end : DateTime.fromMillis(period.end || 0, { zone: Settings.defaultZone }); + if (period.substract) { + const delta = substractValues[period.substract] + start = start.minus(delta); + end = end.minus(delta); + } const range = Interval.fromDateTimes(start, end); return { ...period, range, start: range.start.toMillis(), end: range.end.toMillis(), + rangeName: period.substract ? period.substract : undefined }; } const range = getRange(period.rangeName, offset); @@ -99,6 +130,9 @@ export default Record( endTimestamp: this.end, }; }, + getDuration() { + return this.range.end.diff(this.range.start).as("milliseconds"); + }, rangeFormatted(format = "MMM dd yyyy, HH:mm", tz) { const start = this.range.start.setZone(tz); const end = this.range.end.setZone(tz); diff --git a/frontend/app/types/dashboard/helper.ts b/frontend/app/types/dashboard/helper.ts index 97a7c17e8..b3c9932d7 100644 --- a/frontend/app/types/dashboard/helper.ts +++ b/frontend/app/types/dashboard/helper.ts @@ -4,21 +4,26 @@ const WEEK = DAY * 8; const startWithZero = (num: number) => (num < 10 ? `0${ num }` : `${ num }`); const weekdays = [ 'Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat' ]; -export const getTimeString = (ts, period) => { +export const getTimeString = (ts, period, density,) => { const date = new Date(ts); const diff = period.endTimestamp - period.startTimestamp; if (diff <= DAY) { - var isPM = date.getHours() >= 12; + const isPM = date.getHours() >= 12; return `${ isPM ? date.getHours() - 12 : date.getHours() }:${ startWithZero(date.getMinutes()) } ${isPM? 'pm' : 'am'}`; } if (diff <= WEEK) { - return weekdays[ date.getDay() ]; + if (density < 20) { + return weekdays[ date.getDay() ]; + } else { + const isPM = date.getHours() >= 12; + return `${weekdays[ date.getDay() ]} ${ isPM ? date.getHours() - 12 : date.getHours() }:${ startWithZero(date.getMinutes()) } ${isPM? 'pm' : 'am'}`; + } } return `${ date.getDate() }/${ startWithZero(date.getMonth() + 1) } `; }; -export const getChartFormatter = period => (data = []) => - data.map(({ timestamp, ...rest }) => ({ time: getTimeString(timestamp, period), ...rest, timestamp })); +export const getChartFormatter = (period, density) => (data = []) => + data.map(({ timestamp, ...rest }) => ({ time: getTimeString(timestamp, period, density), ...rest, timestamp })); export const getStartAndEndTimestampsByDensity = (current: number, start: number, end: number, density: number) => { const diff = end - start; diff --git a/frontend/app/types/filter/filterType.ts b/frontend/app/types/filter/filterType.ts index 0f57a5dcd..1e951f774 100644 --- a/frontend/app/types/filter/filterType.ts +++ b/frontend/app/types/filter/filterType.ts @@ -1,11 +1,11 @@ export enum FilterCategory { - INTERACTIONS = 'Interactions', - GEAR = 'Gear', - RECORDING_ATTRIBUTES = 'Recording Attributes', - TECHNICAL = 'Technical', - USER = 'User Identification', - METADATA = 'Session & User Metadata', - PERFORMANCE = 'Performance', + AUTOCAPTURE = 'Autocapture', + DEVTOOLS = 'DevTools', + USER = 'User', + METADATA = 'Metadata', + SESSION = 'Session', + ISSUE = 'Issue', + EVENTS = 'Events', } export const setQueryParamKeyFromFilterkey = (filterKey: string) => { diff --git a/frontend/app/types/filter/newFilter.js b/frontend/app/types/filter/newFilter.js index 38ea74baa..77aa9649e 100644 --- a/frontend/app/types/filter/newFilter.js +++ b/frontend/app/types/filter/newFilter.js @@ -1,6 +1,6 @@ import { - clickSelectorOperators -} from 'App/constants/filterOptions'; + clickSelectorOperators, issueOptions +} from "App/constants/filterOptions"; import Record from 'Types/Record'; import { FilterType, FilterKey, FilterCategory } from './filterType'; import filterOptions, { countries, platformOptions } from 'App/constants'; @@ -11,18 +11,21 @@ const countryOptions = Object.keys(countries).map(i => ({ label: countries[i], v const containsFilters = [{ key: 'contains', label: 'contains', text: 'contains', value: 'contains' }]; const filterOrder = { - [FilterCategory.INTERACTIONS]: 0, - [FilterCategory.TECHNICAL]: 1, - [FilterCategory.PERFORMANCE]: 2, + [FilterCategory.EVENTS]: 0, + [FilterCategory.AUTOCAPTURE]: 1, + [FilterCategory.DEVTOOLS]: 2, [FilterCategory.USER]: 3, - [FilterCategory.GEAR]: 4 + [FilterCategory.SESSION]: 4, + [FilterCategory.ISSUE]: 5, + [FilterCategory.METADATA]: 6 }; export const mobileFilters = [ { key: FilterKey.CLICK_MOBILE, type: FilterType.MULTIPLE, - category: FilterCategory.INTERACTIONS, + category: FilterCategory.EVENTS, + subCategory: FilterCategory.AUTOCAPTURE, label: 'Tap', operator: 'on', operatorOptions: filterOptions.targetOperators, @@ -32,7 +35,8 @@ export const mobileFilters = [ { key: FilterKey.INPUT_MOBILE, type: FilterType.MULTIPLE, - category: FilterCategory.INTERACTIONS, + category: FilterCategory.EVENTS, + subCategory: FilterCategory.AUTOCAPTURE, label: 'Text Input', placeholder: 'Enter input label name', operator: 'is', @@ -43,7 +47,8 @@ export const mobileFilters = [ { key: FilterKey.VIEW_MOBILE, type: FilterType.MULTIPLE, - category: FilterCategory.INTERACTIONS, + category: FilterCategory.EVENTS, + subCategory: FilterCategory.AUTOCAPTURE, label: 'Screen', placeholder: 'Enter screen name', operator: 'is', @@ -54,7 +59,7 @@ export const mobileFilters = [ { key: FilterKey.CUSTOM_MOBILE, type: FilterType.MULTIPLE, - category: FilterCategory.TECHNICAL, + category: FilterCategory.EVENTS, label: 'Custom Events', placeholder: 'Enter event key', operator: 'is', @@ -65,7 +70,7 @@ export const mobileFilters = [ { key: FilterKey.ERROR_MOBILE, type: FilterType.MULTIPLE, - category: FilterCategory.TECHNICAL, + category: FilterCategory.DEVTOOLS, label: 'Error Message', placeholder: 'E.g. Uncaught SyntaxError', operator: 'is', @@ -76,7 +81,8 @@ export const mobileFilters = [ { key: FilterKey.SWIPE_MOBILE, type: FilterType.MULTIPLE, - category: FilterCategory.INTERACTIONS, + category: FilterCategory.EVENTS, + subCategory: FilterCategory.AUTOCAPTURE, label: 'Swipe', operator: 'on', operatorOptions: filterOptions.targetOperators, @@ -85,12 +91,25 @@ export const mobileFilters = [ } ]; +const issueFilters = issueOptions.map((i) => ({ + key: `${FilterKey.ISSUE}_${i.value}`, + type: FilterType.ISSUE, + category: FilterCategory.ISSUE, + label: i.label, + value: i.value, + placeholder: 'Select an issue', + operator: 'is', + operatorOptions: filterOptions.getOperatorsByKeys(['is', 'isAny', 'isNot']), + icon: 'filters/click', + options: filterOptions.issueOptions, +})); export const filters = [ ...mobileFilters, { key: FilterKey.CLICK, type: FilterType.MULTIPLE, - category: FilterCategory.INTERACTIONS, + category: FilterCategory.EVENTS, + subCategory: FilterCategory.AUTOCAPTURE, label: 'Click', operator: 'on', operatorOptions: filterOptions.targetOperators.concat(clickSelectorOperators), @@ -100,7 +119,8 @@ export const filters = [ { key: FilterKey.INPUT, type: FilterType.MULTIPLE, - category: FilterCategory.INTERACTIONS, + category: FilterCategory.EVENTS, + subCategory: FilterCategory.AUTOCAPTURE, label: 'Text Input', placeholder: 'Enter input label name', operator: 'is', @@ -111,7 +131,8 @@ export const filters = [ { key: FilterKey.LOCATION, type: FilterType.MULTIPLE, - category: FilterCategory.INTERACTIONS, + category: FilterCategory.EVENTS, + subCategory: FilterCategory.AUTOCAPTURE, label: 'Visited URL', placeholder: 'Enter path', operator: 'is', @@ -119,10 +140,22 @@ export const filters = [ icon: 'filters/location', isEvent: true }, + { + key: FilterKey.TAGGED_ELEMENT, + type: FilterType.MULTIPLE_DROPDOWN, + category: FilterCategory.EVENTS, + subCategory: FilterCategory.AUTOCAPTURE, + label: 'Tagged Element', + operator: 'is', + isEvent: true, + icon: 'filters/tag-element', + operatorOptions: filterOptions.tagElementOperators, + options: [] + }, { key: FilterKey.CUSTOM, type: FilterType.MULTIPLE, - category: FilterCategory.TECHNICAL, + category: FilterCategory.EVENTS, label: 'Custom Events', placeholder: 'Enter event key', operator: 'is', @@ -134,14 +167,14 @@ export const filters = [ { key: FilterKey.FETCH, type: FilterType.SUB_FILTERS, - category: FilterCategory.TECHNICAL, + category: FilterCategory.DEVTOOLS, operator: 'is', label: 'Network Request', filters: [ { key: FilterKey.FETCH_URL, type: FilterType.MULTIPLE, - category: FilterCategory.PERFORMANCE, + category: FilterCategory.DEVTOOLS, label: 'with URL', placeholder: 'Enter path or URL', operator: 'is', @@ -151,7 +184,7 @@ export const filters = [ { key: FilterKey.FETCH_STATUS_CODE, type: FilterType.NUMBER_MULTIPLE, - category: FilterCategory.PERFORMANCE, + category: FilterCategory.DEVTOOLS, label: 'with status code', placeholder: 'Enter status code', operator: '=', @@ -161,7 +194,7 @@ export const filters = [ { key: FilterKey.FETCH_METHOD, type: FilterType.MULTIPLE_DROPDOWN, - category: FilterCategory.PERFORMANCE, + category: FilterCategory.DEVTOOLS, label: 'with method', operator: 'is', placeholder: 'Select method type', @@ -172,7 +205,7 @@ export const filters = [ { key: FilterKey.FETCH_DURATION, type: FilterType.NUMBER, - category: FilterCategory.PERFORMANCE, + category: FilterCategory.DEVTOOLS, label: 'with duration (ms)', placeholder: 'E.g. 12', operator: '=', @@ -182,7 +215,7 @@ export const filters = [ { key: FilterKey.FETCH_REQUEST_BODY, type: FilterType.STRING, - category: FilterCategory.PERFORMANCE, + category: FilterCategory.DEVTOOLS, label: 'with request body', operator: 'is', operatorOptions: filterOptions.stringOperators, @@ -191,7 +224,7 @@ export const filters = [ { key: FilterKey.FETCH_RESPONSE_BODY, type: FilterType.STRING, - category: FilterCategory.PERFORMANCE, + category: FilterCategory.DEVTOOLS, label: 'with response body', operator: 'is', operatorOptions: filterOptions.stringOperators, @@ -204,7 +237,7 @@ export const filters = [ { key: FilterKey.GRAPHQL, type: FilterType.SUB_FILTERS, - category: FilterCategory.TECHNICAL, + category: FilterCategory.DEVTOOLS, label: 'GraphQL', operator: 'is', operatorOptions: filterOptions.stringOperators, @@ -214,7 +247,7 @@ export const filters = [ { key: FilterKey.GRAPHQL_NAME, type: FilterType.MULTIPLE, - category: FilterCategory.PERFORMANCE, + category: FilterCategory.DEVTOOLS, label: 'with name', operator: 'is', operatorOptions: filterOptions.stringOperators, @@ -223,7 +256,7 @@ export const filters = [ { key: FilterKey.GRAPHQL_METHOD, type: FilterType.MULTIPLE_DROPDOWN, - category: FilterCategory.PERFORMANCE, + category: FilterCategory.DEVTOOLS, label: 'with method', operator: 'is', operatorOptions: filterOptions.stringOperatorsLimited, @@ -233,7 +266,7 @@ export const filters = [ { key: FilterKey.GRAPHQL_REQUEST_BODY, type: FilterType.STRING, - category: FilterCategory.PERFORMANCE, + category: FilterCategory.DEVTOOLS, label: 'with request body', operator: 'is', operatorOptions: filterOptions.stringOperators, @@ -242,7 +275,7 @@ export const filters = [ { key: FilterKey.GRAPHQL_RESPONSE_BODY, type: FilterType.STRING, - category: FilterCategory.PERFORMANCE, + category: FilterCategory.DEVTOOLS, label: 'with response body', operator: 'is', operatorOptions: filterOptions.stringOperators, @@ -253,7 +286,7 @@ export const filters = [ { key: FilterKey.STATEACTION, type: FilterType.MULTIPLE, - category: FilterCategory.TECHNICAL, + category: FilterCategory.DEVTOOLS, label: 'State Action', placeholder: 'E.g. 12', operator: 'is', @@ -264,7 +297,7 @@ export const filters = [ { key: FilterKey.ERROR, type: FilterType.MULTIPLE, - category: FilterCategory.TECHNICAL, + category: FilterCategory.DEVTOOLS, label: 'Error Message', placeholder: 'E.g. Uncaught SyntaxError', operator: 'is', @@ -278,7 +311,7 @@ export const filters = [ { key: FilterKey.REFERRER, type: FilterType.MULTIPLE, - category: FilterCategory.RECORDING_ATTRIBUTES, + category: FilterCategory.SESSION, label: 'Referrer', operator: 'is', operatorOptions: filterOptions.stringOperators, @@ -287,27 +320,16 @@ export const filters = [ { key: FilterKey.DURATION, type: FilterType.DURATION, - category: FilterCategory.RECORDING_ATTRIBUTES, + category: FilterCategory.SESSION, label: 'Duration', operator: 'is', operatorOptions: filterOptions.getOperatorsByKeys(['is']), icon: 'filters/duration' }, - { - key: FilterKey.TAGGED_ELEMENT, - type: FilterType.MULTIPLE_DROPDOWN, - category: FilterCategory.RECORDING_ATTRIBUTES, - label: 'Tagged Element', - operator: 'is', - isEvent: true, - icon: 'filters/tag-element', - operatorOptions: filterOptions.tagElementOperators, - options: [] - }, { key: FilterKey.UTM_SOURCE, type: FilterType.MULTIPLE, - category: FilterCategory.RECORDING_ATTRIBUTES, + category: FilterCategory.SESSION, label: 'UTM Source', operator: 'is', operatorOptions: filterOptions.stringOperators, @@ -316,7 +338,7 @@ export const filters = [ { key: FilterKey.UTM_MEDIUM, type: FilterType.MULTIPLE, - category: FilterCategory.RECORDING_ATTRIBUTES, + category: FilterCategory.SESSION, label: 'UTM Medium', operator: 'is', operatorOptions: filterOptions.stringOperators, @@ -325,7 +347,7 @@ export const filters = [ { key: FilterKey.UTM_CAMPAIGN, type: FilterType.MULTIPLE, - category: FilterCategory.RECORDING_ATTRIBUTES, + category: FilterCategory.SESSION, label: 'UTM Campaign', operator: 'is', operatorOptions: filterOptions.stringOperators, @@ -334,8 +356,8 @@ export const filters = [ { key: FilterKey.USER_COUNTRY, type: FilterType.MULTIPLE_DROPDOWN, - category: FilterCategory.USER, - label: 'User Country', + category: FilterCategory.SESSION, + label: 'Country', operator: 'is', operatorOptions: filterOptions.getOperatorsByKeys(['is', 'isAny', 'isNot']), icon: 'filters/country', @@ -344,8 +366,8 @@ export const filters = [ { key: FilterKey.USER_CITY, type: FilterType.MULTIPLE, - category: FilterCategory.USER, - label: 'User City', + category: FilterCategory.SESSION, + label: 'City', operator: 'is', operatorOptions: filterOptions.getOperatorsByKeys(['is', 'isAny', 'isNot']), icon: 'filters/country', @@ -354,7 +376,7 @@ export const filters = [ { key: FilterKey.USER_STATE, type: FilterType.MULTIPLE, - category: FilterCategory.USER, + category: FilterCategory.SESSION, label: 'State / Province', operator: 'is', operatorOptions: filterOptions.getOperatorsByKeys(['is', 'isAny', 'isNot']), @@ -382,65 +404,11 @@ export const filters = [ icon: 'filters/userid' }, - // PERFORMANCE - { - key: FilterKey.DOM_COMPLETE, - type: FilterType.MULTIPLE, - category: FilterCategory.PERFORMANCE, - label: 'DOM Complete', - placeholder: 'Enter path', - operator: 'isAny', - operatorOptions: filterOptions.stringOperatorsPerformance, - source: [], - icon: 'filters/dom-complete', - isEvent: true, - hasSource: true, - sourceOperator: '>=', - sourcePlaceholder: 'E.g. 12', - sourceUnit: 'ms', - sourceType: FilterType.NUMBER, - sourceOperatorOptions: filterOptions.customOperators - }, - { - key: FilterKey.LARGEST_CONTENTFUL_PAINT_TIME, - type: FilterType.MULTIPLE, - category: FilterCategory.PERFORMANCE, - label: 'Largest Contentful Paint', - placeholder: 'Enter path', - operator: 'isAny', - operatorOptions: filterOptions.stringOperatorsPerformance, - source: [], - icon: 'filters/lcpt', - isEvent: true, - hasSource: true, - sourceOperator: '>=', - sourcePlaceholder: 'E.g. 12', - sourceUnit: 'ms', - sourceType: FilterType.NUMBER, - sourceOperatorOptions: filterOptions.customOperators - }, - { - key: FilterKey.TTFB, - type: FilterType.MULTIPLE, - category: FilterCategory.PERFORMANCE, - label: 'Time to First Byte', - placeholder: 'Enter path', - operator: 'isAny', - operatorOptions: filterOptions.stringOperatorsPerformance, - source: [], - icon: 'filters/ttfb', - isEvent: true, - hasSource: true, - sourceOperator: '>=', - sourceUnit: 'ms', - sourceType: FilterType.NUMBER, - sourceOperatorOptions: filterOptions.customOperators, - sourcePlaceholder: 'E.g. 12' - }, + { key: FilterKey.AVG_CPU_LOAD, type: FilterType.MULTIPLE, - category: FilterCategory.PERFORMANCE, + category: FilterCategory.DEVTOOLS, label: 'Avg CPU Load', placeholder: 'Enter path', operator: 'isAny', @@ -458,7 +426,7 @@ export const filters = [ { key: FilterKey.AVG_MEMORY_USAGE, type: FilterType.MULTIPLE, - category: FilterCategory.PERFORMANCE, + category: FilterCategory.DEVTOOLS, label: 'Avg Memory Usage', placeholder: 'Enter path', operator: 'isAny', @@ -473,21 +441,10 @@ export const filters = [ sourceType: FilterType.NUMBER, sourceOperatorOptions: filterOptions.customOperators }, - { - key: FilterKey.FETCH_FAILED, - type: FilterType.MULTIPLE, - category: FilterCategory.PERFORMANCE, - label: 'Failed Request', - placeholder: 'Enter path', - operator: 'isAny', - operatorOptions: filterOptions.stringOperatorsPerformance, - icon: 'filters/fetch-failed', - isEvent: true - }, { key: FilterKey.ISSUE, type: FilterType.ISSUE, - category: FilterCategory.TECHNICAL, + category: FilterCategory.ISSUE, label: 'Issue', placeholder: 'Select an issue', operator: 'is', @@ -495,11 +452,12 @@ export const filters = [ icon: 'filters/click', options: filterOptions.issueOptions }, + ...issueFilters, { key: FilterKey.USER_OS, type: FilterType.MULTIPLE, - category: FilterCategory.GEAR, - label: 'User OS', + category: FilterCategory.SESSION, + label: 'OS', operator: 'is', operatorOptions: filterOptions.stringOperators, icon: 'filters/os' @@ -507,8 +465,8 @@ export const filters = [ { key: FilterKey.USER_BROWSER, type: FilterType.MULTIPLE, - category: FilterCategory.GEAR, - label: 'User Browser', + category: FilterCategory.SESSION, + label: 'Browser', operator: 'is', operatorOptions: filterOptions.stringOperators, icon: 'filters/browser' @@ -516,8 +474,8 @@ export const filters = [ { key: FilterKey.USER_DEVICE, type: FilterType.MULTIPLE, - category: FilterCategory.GEAR, - label: 'User Device', + category: FilterCategory.SESSION, + label: 'Device', operator: 'is', operatorOptions: filterOptions.stringOperators, icon: 'filters/device' @@ -525,7 +483,7 @@ export const filters = [ { key: FilterKey.PLATFORM, type: FilterType.MULTIPLE_DROPDOWN, - category: FilterCategory.GEAR, + category: FilterCategory.SESSION, label: 'Platform', operator: 'is', operatorOptions: filterOptions.baseOperators, @@ -535,7 +493,7 @@ export const filters = [ { key: FilterKey.REVID, type: FilterType.MULTIPLE, - category: FilterCategory.GEAR, + category: FilterCategory.SESSION, label: 'Version ID', placeholder: 'E.g. v1.0.8', operator: 'is', @@ -552,8 +510,8 @@ export const flagConditionFilters = [ { key: FilterKey.USER_OS, type: FilterType.MULTIPLE, - category: FilterCategory.GEAR, - label: 'User OS', + category: FilterCategory.SESSION, + label: 'OS', operator: 'is', operatorOptions: filterOptions.stringOperators, icon: 'filters/os' @@ -561,8 +519,8 @@ export const flagConditionFilters = [ { key: FilterKey.USER_BROWSER, type: FilterType.MULTIPLE, - category: FilterCategory.GEAR, - label: 'User Browser', + category: FilterCategory.SESSION, + label: 'Browser', operator: 'is', operatorOptions: filterOptions.stringOperators, icon: 'filters/browser' @@ -570,8 +528,8 @@ export const flagConditionFilters = [ { key: FilterKey.USER_DEVICE, type: FilterType.MULTIPLE, - category: FilterCategory.GEAR, - label: 'User Device', + category: FilterCategory.SESSION, + label: 'Device', operator: 'is', operatorOptions: filterOptions.stringOperators, icon: 'filters/device' @@ -579,7 +537,7 @@ export const flagConditionFilters = [ { key: FilterKey.REFERRER, type: FilterType.MULTIPLE, - category: FilterCategory.USER, + category: FilterCategory.SESSION, label: 'Referrer', operator: 'is', operatorOptions: filterOptions.stringOperators, @@ -588,8 +546,8 @@ export const flagConditionFilters = [ { key: FilterKey.USER_COUNTRY, type: FilterType.MULTIPLE_DROPDOWN, - category: FilterCategory.USER, - label: 'User Country', + category: FilterCategory.SESSION, + label: 'Country', operator: 'is', operatorOptions: filterOptions.getOperatorsByKeys(['is', 'isAny', 'isNot']), icon: 'filters/country', @@ -598,8 +556,8 @@ export const flagConditionFilters = [ { key: FilterKey.USER_CITY, type: FilterType.MULTIPLE, - category: FilterCategory.USER, - label: 'User City', + category: FilterCategory.SESSION, + label: 'City', operator: 'is', operatorOptions: filterOptions.getOperatorsByKeys(['is', 'isAny', 'isNot']), icon: 'filters/country', @@ -608,7 +566,7 @@ export const flagConditionFilters = [ { key: FilterKey.USER_STATE, type: FilterType.MULTIPLE, - category: FilterCategory.USER, + category: FilterCategory.SESSION, label: 'State / Province', operator: 'is', operatorOptions: filterOptions.getOperatorsByKeys(['is', 'isAny', 'isNot']), @@ -638,7 +596,8 @@ export const conditionalFilters = [ { key: FilterKey.CLICK, type: FilterType.MULTIPLE, - category: FilterCategory.INTERACTIONS, + category: FilterCategory.EVENTS, + subCategory: FilterCategory.AUTOCAPTURE, label: 'Click', operator: 'on', operatorOptions: filterOptions.targetConditional, @@ -648,7 +607,8 @@ export const conditionalFilters = [ { key: FilterKey.LOCATION, type: FilterType.MULTIPLE, - category: FilterCategory.INTERACTIONS, + category: FilterCategory.EVENTS, + subCategory: FilterCategory.AUTOCAPTURE, label: 'Visited URL', placeholder: 'Enter path', operator: 'is', @@ -659,7 +619,7 @@ export const conditionalFilters = [ { key: FilterKey.CUSTOM, type: FilterType.MULTIPLE, - category: FilterCategory.TECHNICAL, + category: FilterCategory.DEVTOOLS, label: 'Custom Events', placeholder: 'Enter event key', operator: 'is', @@ -670,14 +630,14 @@ export const conditionalFilters = [ { key: FilterKey.FETCH, type: FilterType.SUB_FILTERS, - category: FilterCategory.TECHNICAL, + category: FilterCategory.DEVTOOLS, operator: 'is', label: 'Network Request', filters: [ { key: FilterKey.FETCH_URL, type: FilterType.MULTIPLE, - category: FilterCategory.PERFORMANCE, + category: FilterCategory.DEVTOOLS, label: 'with URL', placeholder: 'Enter path or URL', operator: 'is', @@ -687,7 +647,7 @@ export const conditionalFilters = [ { key: FilterKey.FETCH_STATUS_CODE, type: FilterType.NUMBER_MULTIPLE, - category: FilterCategory.PERFORMANCE, + category: FilterCategory.DEVTOOLS, label: 'with status code', placeholder: 'Enter status code', operator: '=', @@ -697,7 +657,7 @@ export const conditionalFilters = [ { key: FilterKey.FETCH_METHOD, type: FilterType.MULTIPLE_DROPDOWN, - category: FilterCategory.PERFORMANCE, + category: FilterCategory.DEVTOOLS, label: 'with method', operator: 'is', placeholder: 'Select method type', @@ -708,7 +668,7 @@ export const conditionalFilters = [ { key: FilterKey.FETCH_DURATION, type: FilterType.NUMBER, - category: FilterCategory.PERFORMANCE, + category: FilterCategory.DEVTOOLS, label: 'with duration (ms)', placeholder: 'E.g. 12', operator: '=', @@ -722,7 +682,7 @@ export const conditionalFilters = [ { key: FilterKey.ERROR, type: FilterType.MULTIPLE, - category: FilterCategory.TECHNICAL, + category: FilterCategory.DEVTOOLS, label: 'Error Message', placeholder: 'E.g. Uncaught SyntaxError', operator: 'is', @@ -733,7 +693,7 @@ export const conditionalFilters = [ { key: FilterKey.DURATION, type: FilterType.DURATION, - category: FilterCategory.RECORDING_ATTRIBUTES, + category: FilterCategory.SESSION, label: 'Duration', operator: 'is', operatorOptions: filterOptions.getOperatorsByKeys(['is']), @@ -769,7 +729,7 @@ export const mobileConditionalFilters = [ { key: FilterKey.DURATION, type: FilterType.DURATION, - category: FilterCategory.RECORDING_ATTRIBUTES, + category: FilterCategory.SESSION, label: 'Duration', operator: 'is', operatorOptions: filterOptions.getOperatorsByKeys(['is']), @@ -779,14 +739,14 @@ export const mobileConditionalFilters = [ { key: FilterKey.FETCH, type: FilterType.SUB_FILTERS, - category: FilterCategory.RECORDING_ATTRIBUTES, + category: FilterCategory.DEVTOOLS, operator: 'is', label: 'Network Request', filters: [ { key: FilterKey.FETCH_URL, type: FilterType.MULTIPLE, - category: FilterCategory.PERFORMANCE, + category: FilterCategory.DEVTOOLS, label: 'with URL', placeholder: 'Enter path or URL', operator: 'is', @@ -796,7 +756,7 @@ export const mobileConditionalFilters = [ { key: FilterKey.FETCH_STATUS_CODE, type: FilterType.NUMBER_MULTIPLE, - category: FilterCategory.PERFORMANCE, + category: FilterCategory.DEVTOOLS, label: 'with status code', placeholder: 'Enter status code', operator: '=', @@ -806,7 +766,7 @@ export const mobileConditionalFilters = [ { key: FilterKey.FETCH_METHOD, type: FilterType.MULTIPLE_DROPDOWN, - category: FilterCategory.PERFORMANCE, + category: FilterCategory.DEVTOOLS, label: 'with method', operator: 'is', placeholder: 'Select method type', @@ -817,7 +777,7 @@ export const mobileConditionalFilters = [ { key: FilterKey.FETCH_DURATION, type: FilterType.NUMBER, - category: FilterCategory.PERFORMANCE, + category: FilterCategory.DEVTOOLS, label: 'with duration (ms)', placeholder: 'E.g. 12', operator: '=', @@ -831,7 +791,7 @@ export const mobileConditionalFilters = [ { key: FilterKey.CUSTOM, type: FilterType.MULTIPLE, - category: FilterCategory.RECORDING_ATTRIBUTES, + category: FilterCategory.DEVTOOLS, label: 'Custom Events', placeholder: 'Enter event key', operator: 'is', @@ -842,7 +802,7 @@ export const mobileConditionalFilters = [ { key: 'thermalState', type: FilterType.MULTIPLE_DROPDOWN, - category: FilterCategory.PERFORMANCE, + category: FilterCategory.DEVTOOLS, label: 'Device Thermal State', placeholder: 'Pick an option', operator: 'is', @@ -858,7 +818,7 @@ export const mobileConditionalFilters = [ { key: 'mainThreadCPU', type: FilterType.STRING, - category: FilterCategory.PERFORMANCE, + category: FilterCategory.DEVTOOLS, label: 'Main CPU Load %', placeholder: '0 .. 100', operator: '=', @@ -868,7 +828,8 @@ export const mobileConditionalFilters = [ { key: 'viewComponent', type: FilterType.STRING, - category: FilterCategory.RECORDING_ATTRIBUTES, + category: FilterCategory.EVENTS, + subCategory: FilterCategory.AUTOCAPTURE, label: 'View on screen', placeholder: 'View Name', operator: 'is', @@ -898,7 +859,7 @@ export const mobileConditionalFilters = [ { key: 'logEvent', type: FilterType.STRING, - category: FilterCategory.RECORDING_ATTRIBUTES, + category: FilterCategory.DEVTOOLS, label: 'Log in console', placeholder: 'logged value', operator: 'is', @@ -908,7 +869,8 @@ export const mobileConditionalFilters = [ { key: 'clickEvent', type: FilterType.STRING, - category: FilterCategory.INTERACTIONS, + category: FilterCategory.EVENTS, + subCategory: FilterCategory.AUTOCAPTURE, label: 'Tap on view', placeholder: 'View Name', operator: 'is', @@ -918,7 +880,7 @@ export const mobileConditionalFilters = [ { key: 'memoryUsage', type: FilterType.STRING, - category: FilterCategory.PERFORMANCE, + category: FilterCategory.DEVTOOLS, label: 'Memory usage %', placeholder: '0 .. 100', operatorOptions: filterOptions.customOperators, @@ -938,7 +900,8 @@ export const nonConditionalFlagFilters = filters.filter(i => { export const clickmapFilter = { key: FilterKey.LOCATION, type: FilterType.MULTIPLE, - category: FilterCategory.INTERACTIONS, + category: FilterCategory.EVENTS, + subCategory: FilterCategory.AUTOCAPTURE, label: 'Visited URL', placeholder: 'Enter URL or path', operator: filterOptions.pageUrlOperators[0].value, operatorOptions: filterOptions.pageUrlOperators, @@ -948,7 +911,9 @@ export const clickmapFilter = { const mapFilters = (list) => { return list.reduce((acc, filter) => { - filter.value = ['']; + filter.value = filter.value + ? Array.isArray(filter.value) ? filter.value : [filter.value] + : ['']; acc[filter.key] = filter; return acc; }, {}); @@ -959,9 +924,8 @@ const mapLiveFilters = (list) => { const obj = {}; list.forEach(filter => { if ( - filter.category !== FilterCategory.INTERACTIONS && - filter.category !== FilterCategory.TECHNICAL && - filter.category !== FilterCategory.PERFORMANCE && + filter.category !== FilterCategory.EVENTS && + filter.category !== FilterCategory.DEVTOOLS && filter.key !== FilterKey.DURATION && filter.key !== FilterKey.REFERRER && filter.key !== FilterKey.TAGGED_ELEMENT @@ -1001,6 +965,7 @@ export const clearMetaFilters = () => { * @param {*} operator * @param {*} operatorOptions * @param {*} icon + * @param {*} isEvent */ export const addElementToFiltersMap = ( category = FilterCategory.METADATA, @@ -1008,7 +973,8 @@ export const addElementToFiltersMap = ( type = FilterType.MULTIPLE, operator = 'is', operatorOptions = filterOptions.stringOperators, - icon = 'filters/metadata' + icon = 'filters/metadata', + isEvent = false ) => { filtersMap[key] = { key, @@ -1019,7 +985,8 @@ export const addElementToFiltersMap = ( operator: operator, operatorOptions, icon, - isLive: true + isLive: true, + isEvent, }; }; @@ -1042,7 +1009,8 @@ export const addElementToFlagConditionsMap = ( type = FilterType.MULTIPLE, operator = 'is', operatorOptions = filterOptions.stringOperators, - icon = 'filters/metadata' + icon = 'filters/metadata', + isEvent = false ) => { fflagsConditionsMap[key] = { key, @@ -1052,7 +1020,8 @@ export const addElementToFlagConditionsMap = ( operator: operator, operatorOptions, icon, - isLive: true + isLive: true, + isEvent, }; }; @@ -1062,7 +1031,8 @@ export const addElementToConditionalFiltersMap = ( type = FilterType.MULTIPLE, operator = 'is', operatorOptions = filterOptions.stringOperators, - icon = 'filters/metadata' + icon = 'filters/metadata', + isEvent = false ) => { conditionalFiltersMap[key] = { key, @@ -1072,7 +1042,8 @@ export const addElementToConditionalFiltersMap = ( operator: operator, operatorOptions, icon, - isLive: true + isLive: true, + isEvent, }; }; @@ -1082,7 +1053,8 @@ export const addElementToMobileConditionalFiltersMap = ( type = FilterType.MULTIPLE, operator = 'is', operatorOptions = filterOptions.stringOperators, - icon = 'filters/metadata' + icon = 'filters/metadata', + isEvent = false ) => { mobileConditionalFiltersMap[key] = { key, @@ -1092,7 +1064,8 @@ export const addElementToMobileConditionalFiltersMap = ( operator: operator, operatorOptions, icon, - isLive: true + isLive: true, + isEvent, }; }; diff --git a/frontend/app/utils/index.ts b/frontend/app/utils/index.ts index 03419fa72..79c492d20 100644 --- a/frontend/app/utils/index.ts +++ b/frontend/app/utils/index.ts @@ -549,4 +549,33 @@ const decodeJwt = (jwt: string): any => { } const base64 = base64Url.replace("-", "+").replace("_", "/"); return JSON.parse(atob(base64)); -}; \ No newline at end of file +}; + +function saveAsFile(blob: Blob, filename: string) { + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + + a.href = url; + a.download = filename; + a.click(); + window.URL.revokeObjectURL(url); +} + +export function exportAntCsv(tableColumns, tableData, filename = 'table.csv') { + console.log(tableColumns, tableData) + const headers = tableColumns.map(col => col.title).join(','); + const rows = tableData.map(row => { + return tableColumns + .map(col => { + const value = col.dataIndex ? row[col.dataIndex] : ''; + return typeof value === 'string' ? `"${value.replace(/"/g, '""')}"` : value; + }) + .join(','); + }); + + const csvContent = [headers, ...rows].join('\n'); + console.log(csvContent) + // const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); + + // saveAsFile(blob, filename); +} \ No newline at end of file diff --git a/frontend/package.json b/frontend/package.json index da375048a..965229bc3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -35,6 +35,7 @@ "classnames": "^2.3.1", "copy-to-clipboard": "^3.3.1", "country-flag-icons": "^1.5.7", + "echarts": "^5.6.0", "fflate": "^0.8.2", "fzstd": "^0.1.1", "hls.js": "^1.5.13", @@ -46,7 +47,7 @@ "jshint": "^2.11.1", "jspdf": "^2.5.1", "lottie-react": "^2.4.0", - "lucide-react": "^0.396.0", + "lucide-react": "0.454.0", "luxon": "^3.5.0", "microdiff": "^1.4.0", "mobx": "^6.13.3", diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 07ea74cb7..1a9de6cb8 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -6838,6 +6838,16 @@ __metadata: languageName: node linkType: hard +"echarts@npm:^5.6.0": + version: 5.6.0 + resolution: "echarts@npm:5.6.0" + dependencies: + tslib: "npm:2.3.0" + zrender: "npm:5.6.1" + checksum: 10c1/0695d5951f0cfccfb54a0e223a2c23313bd789aa7529caa1b6d8f8b48cc24f480aa6dcc7fecbd9cef260492b5806470a0a0dafa2305a68d0326c4b6a0be0e3ba + languageName: node + linkType: hard + "ee-first@npm:1.1.1": version: 1.1.1 resolution: "ee-first@npm:1.1.1" @@ -10637,12 +10647,12 @@ __metadata: languageName: node linkType: hard -"lucide-react@npm:^0.396.0": - version: 0.396.0 - resolution: "lucide-react@npm:0.396.0" +"lucide-react@npm:0.454.0": + version: 0.454.0 + resolution: "lucide-react@npm:0.454.0" peerDependencies: - react: ^16.5.1 || ^17.0.0 || ^18.0.0 - checksum: 10c1/f07aa9b36cf50488120893d9e7b6198927fb0dfce09a78a8e1c3051754d5a822f5b06088abd398a78c8b70b3723da5209e2b35eb0744f2f53e6c1995d9d885f2 + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc + checksum: 10c1/74a826345610813f909a52e0c74c4ec374fb3c23fdae598670a7b80001610672afc97b7e887546164e7464e5ba69e2eb386ba1dbfbca73e4cb1f707a9a69dc3c languageName: node linkType: hard @@ -11607,6 +11617,7 @@ __metadata: cypress: "npm:13.17.0" cypress-image-snapshot: "npm:^4.0.1" dotenv: "npm:^6.2.0" + echarts: "npm:^5.6.0" esbuild-loader: "npm:^4.2.2" eslint: "npm:^8.15.0" eslint-plugin-react: "npm:^7.29.4" @@ -11624,7 +11635,7 @@ __metadata: jshint: "npm:^2.11.1" jspdf: "npm:^2.5.1" lottie-react: "npm:^2.4.0" - lucide-react: "npm:^0.396.0" + lucide-react: "npm:0.454.0" luxon: "npm:^3.5.0" microdiff: "npm:^1.4.0" mini-css-extract-plugin: "npm:^2.6.0" @@ -15762,6 +15773,13 @@ __metadata: languageName: node linkType: hard +"tslib@npm:2.3.0": + version: 2.3.0 + resolution: "tslib@npm:2.3.0" + checksum: 10c1/851bc4c307068b0a65c428e0dab91abb30b8c145fa249d7204281efd1146edd170c8152780347270dc550c2eaf0e47743e3c3fabcf4f66180457471e6e66179e + languageName: node + linkType: hard + "tslib@npm:^1.8.1, tslib@npm:^1.9.3": version: 1.14.1 resolution: "tslib@npm:1.14.1" @@ -16876,3 +16894,12 @@ __metadata: checksum: 10c1/3c46904537cf83a416bb8373a2e6c1cac4568528888b605a6f8a319c4a84ae4ac368b31f0a54df59d3e33efd7aef2d2499ca8210d329e18028a1d039e1bb5722 languageName: node linkType: hard + +"zrender@npm:5.6.1": + version: 5.6.1 + resolution: "zrender@npm:5.6.1" + dependencies: + tslib: "npm:2.3.0" + checksum: 10c1/593dd84b3ea01f4e7941fd15f103550251b769218f4d524af935595bb628fa6e94e4c091654ec390e1c7176dd60ca8a7c7341d372d310ee3beebfe602792c1c6 + languageName: node + linkType: hard diff --git a/third-party.md b/third-party.md index 9eb006e3e..096446172 100644 --- a/third-party.md +++ b/third-party.md @@ -3,123 +3,124 @@ Below is the list of dependencies used in OpenReplay software. Licenses may change between versions, so please keep this up to date with every new library you use. -| Library | License | Scope | -|----------------------------|------------------|------------------| -| btcutil | IST | Go | -| confluent-kafka-go | Apache2 | Go | -| compress | Apache2 | Go | -| uuid | BSD3 | Go | -| mux | BSD3 | Go | -| lib/pq | MIT | Go | -| pgconn | MIT | Go | -| pgx | MIT | Go | -| go-redis | BSD2 | Go | -| pgerrcode | MIT | Go | -| pgzip | MIT | Go | -| maxminddb-golang | IST | Go | -| realip | MIT | Go | -| uap-go | Apache2 | Go | -| clickhouse-go | MIT | Go | -| aws-sdk-go | Apache2 | Go | -| logging | Apache2 | Go | -| squirrel | MIT | Go | -| go-elasticsearch | Apache2 | Go | -| gorilla/websocket | BSD2 | Go | -| radix | MIT | Go | -| api | BSD3 | Go | -| urllib3 | MIT | Python | -| boto3 | Apache2 | Python | -| requests | Apache2 | Python | -| pyjwt | MIT | Python | -| jsbeautifier | MIT | Python | -| psycopg2-binary | LGPL | Python | -| fastapi | MIT | Python | -| uvicorn | BSD | Python | -| python-decouple | MIT | Python | -| pydantic | MIT | Python | -| apscheduler | MIT | Python | -| python-multipart | Apache | Python | -| elasticsearch-py | Apache2 | Python | -| jira | BSD2 | Python | -| redis-py | MIT | Python | -| clickhouse-driver | MIT | Python | -| python3-saml | MIT | Python | -| kubernetes | Apache2 | Python | -| chalice | Apache2 | Python | -| pandas | BSD3 | Python | -| numpy | BSD3 | Python | -| scikit-learn | BSD3 | Python | -| apache-airflow | Apache2 | Python | -| airflow-code-editor | Apache2 | Python | -| mlflow | Apache2 | Python | -| sqlalchemy | MIT | Python | -| pandas-redshift | MIT | Python | -| confluent-kafka | Apache2 | Python | -| cachetools | MIT | Python | -| amplitude-js | MIT | JavaScript | -| classnames | MIT | JavaScript | -| codemirror | MIT | JavaScript | -| copy-to-clipboard | MIT | JavaScript | -| jsonwebtoken | MIT | JavaScript | -| datamaps | MIT | JavaScript | -| microdiff | MIT | JavaScript | -| immutable | MIT | JavaScript | -| jshint | MIT | JavaScript | -| luxon | MIT | JavaScript | -| mobx | MIT | JavaScript | -| mobx-react-lite | MIT | JavaScript | -| optimal-select | MIT | JavaScript | -| rc-time-picker | MIT | JavaScript | -| snabbdom | MIT | JavaScript | -| react | MIT | JavaScript | -| react-codemirror2 | MIT | JavaScript | -| react-confirm | MIT | JavaScript | -| react-datepicker | MIT | JavaScript | -| react-daterange-picker | Apache2 | JavaScript | -| react-dnd | MIT | JavaScript | -| react-dnd-html5-backend | MIT | JavaScript | -| react-dom | MIT | JavaScript | -| react-google-recaptcha | MIT | JavaScript | -| react-json-view | MIT | JavaScript | -| react-redux | MIT | JavaScript | -| react-router | MIT | JavaScript | -| react-router-dom | MIT | JavaScript | -| react-stripe-elements | MIT | JavaScript | -| react-toastify | MIT | JavaScript | -| recharts | MIT | JavaScript | -| redux | MIT | JavaScript | -| redux-immutable | BSD3 | JavaScript | -| redux-thunk | MIT | JavaScript | -| socket.io | MIT | JavaScript | -| socket.io-client | MIT | JavaScript | -| uWebSockets.js | Apache2 | JavaScript | -| aws-sdk | Apache2 | JavaScript | -| serverless | MIT | JavaScript | -| peerjs | MIT | JavaScript | -| geoip-lite | Apache2 | JavaScript | -| ua-parser-js | MIT | JavaScript | -| express | MIT | JavaScript | -| jspdf | MIT | JavaScript | -| html-to-image | MIT | JavaScript | -| kafka | Apache2 | Infrastructure | -| stern | Apache2 | Infrastructure | -| k9s | Apache2 | Infrastructure | -| minio | AGPLv3 | Infrastructure | -| postgreSQL | PostgreSQL License | Infrastructure | -| k3s | Apache2 | Infrastructure | -| nginx | BSD2 | Infrastructure | -| clickhouse | Apache2 | Infrastructure | -| redis | BSD3 | Infrastructure | -| yq | MIT | Infrastructure | -| html2canvas | MIT | JavaScript | -| eget | MIT | Infrastructure | -| @medv/finder | MIT | JavaScript | -| fflate | MIT | JavaScript | -| fzstd | MIT | JavaScript | -| prom-client | Apache2 | JavaScript | -| winston | MIT | JavaScript | -| @wojtekmaj/react-daterange-picker | MIT | JavaScript | -| prismjs | MIT | JavaScript | -| virtua | MIT | JavaScript | -| babel-plugin-prismjs | MIT | JavaScript | -| react-intersection-observer | MIT | JavaScript | \ No newline at end of file +| Library | License | Scope | +|-----------------------------------|--------------------|-----------------| +| btcutil | IST | Go | +| confluent-kafka-go | Apache2 | Go | +| compress | Apache2 | Go | +| uuid | BSD3 | Go | +| mux | BSD3 | Go | +| lib/pq | MIT | Go | +| pgconn | MIT | Go | +| pgx | MIT | Go | +| go-redis | BSD2 | Go | +| pgerrcode | MIT | Go | +| pgzip | MIT | Go | +| maxminddb-golang | IST | Go | +| realip | MIT | Go | +| uap-go | Apache2 | Go | +| clickhouse-go | MIT | Go | +| aws-sdk-go | Apache2 | Go | +| logging | Apache2 | Go | +| squirrel | MIT | Go | +| go-elasticsearch | Apache2 | Go | +| gorilla/websocket | BSD2 | Go | +| radix | MIT | Go | +| api | BSD3 | Go | +| urllib3 | MIT | Python | +| boto3 | Apache2 | Python | +| requests | Apache2 | Python | +| pyjwt | MIT | Python | +| jsbeautifier | MIT | Python | +| psycopg2-binary | LGPL | Python | +| fastapi | MIT | Python | +| uvicorn | BSD | Python | +| python-decouple | MIT | Python | +| pydantic | MIT | Python | +| apscheduler | MIT | Python | +| python-multipart | Apache | Python | +| elasticsearch-py | Apache2 | Python | +| jira | BSD2 | Python | +| redis-py | MIT | Python | +| clickhouse-driver | MIT | Python | +| python3-saml | MIT | Python | +| kubernetes | Apache2 | Python | +| chalice | Apache2 | Python | +| pandas | BSD3 | Python | +| numpy | BSD3 | Python | +| scikit-learn | BSD3 | Python | +| apache-airflow | Apache2 | Python | +| airflow-code-editor | Apache2 | Python | +| mlflow | Apache2 | Python | +| sqlalchemy | MIT | Python | +| pandas-redshift | MIT | Python | +| confluent-kafka | Apache2 | Python | +| cachetools | MIT | Python | +| amplitude-js | MIT | JavaScript | +| classnames | MIT | JavaScript | +| codemirror | MIT | JavaScript | +| copy-to-clipboard | MIT | JavaScript | +| jsonwebtoken | MIT | JavaScript | +| datamaps | MIT | JavaScript | +| microdiff | MIT | JavaScript | +| immutable | MIT | JavaScript | +| jshint | MIT | JavaScript | +| luxon | MIT | JavaScript | +| mobx | MIT | JavaScript | +| mobx-react-lite | MIT | JavaScript | +| optimal-select | MIT | JavaScript | +| rc-time-picker | MIT | JavaScript | +| snabbdom | MIT | JavaScript | +| react | MIT | JavaScript | +| react-codemirror2 | MIT | JavaScript | +| react-confirm | MIT | JavaScript | +| react-datepicker | MIT | JavaScript | +| react-daterange-picker | Apache2 | JavaScript | +| react-dnd | MIT | JavaScript | +| react-dnd-html5-backend | MIT | JavaScript | +| react-dom | MIT | JavaScript | +| react-google-recaptcha | MIT | JavaScript | +| react-json-view | MIT | JavaScript | +| react-redux | MIT | JavaScript | +| react-router | MIT | JavaScript | +| react-router-dom | MIT | JavaScript | +| react-stripe-elements | MIT | JavaScript | +| react-toastify | MIT | JavaScript | +| recharts | MIT | JavaScript | +| redux | MIT | JavaScript | +| redux-immutable | BSD3 | JavaScript | +| redux-thunk | MIT | JavaScript | +| socket.io | MIT | JavaScript | +| socket.io-client | MIT | JavaScript | +| uWebSockets.js | Apache2 | JavaScript | +| aws-sdk | Apache2 | JavaScript | +| serverless | MIT | JavaScript | +| peerjs | MIT | JavaScript | +| geoip-lite | Apache2 | JavaScript | +| ua-parser-js | MIT | JavaScript | +| express | MIT | JavaScript | +| jspdf | MIT | JavaScript | +| html-to-image | MIT | JavaScript | +| kafka | Apache2 | Infrastructure | +| stern | Apache2 | Infrastructure | +| k9s | Apache2 | Infrastructure | +| minio | AGPLv3 | Infrastructure | +| postgreSQL | PostgreSQL License | Infrastructure | +| k3s | Apache2 | Infrastructure | +| nginx | BSD2 | Infrastructure | +| clickhouse | Apache2 | Infrastructure | +| redis | BSD3 | Infrastructure | +| yq | MIT | Infrastructure | +| html2canvas | MIT | JavaScript | +| eget | MIT | Infrastructure | +| @medv/finder | MIT | JavaScript | +| fflate | MIT | JavaScript | +| fzstd | MIT | JavaScript | +| prom-client | Apache2 | JavaScript | +| winston | MIT | JavaScript | +| @wojtekmaj/react-daterange-picker | MIT | JavaScript | +| prismjs | MIT | JavaScript | +| virtua | MIT | JavaScript | +| babel-plugin-prismjs | MIT | JavaScript | +| react-intersection-observer | MIT | JavaScript | +| echarts | Apache2 | JavaScript | \ No newline at end of file diff --git a/tracker/.husky/prepare-commit-msg b/tracker/.husky/prepare-commit-msg deleted file mode 100755 index 2655902bc..000000000 --- a/tracker/.husky/prepare-commit-msg +++ /dev/null @@ -1,57 +0,0 @@ -#!/bin/sh - -if [ "$LEFTHOOK_VERBOSE" = "1" -o "$LEFTHOOK_VERBOSE" = "true" ]; then - set -x -fi - -if [ "$LEFTHOOK" = "0" ]; then - exit 0 -fi - -call_lefthook() -{ - if test -n "$LEFTHOOK_BIN" - then - "$LEFTHOOK_BIN" "$@" - elif lefthook -h >/dev/null 2>&1 - then - lefthook "$@" - else - dir="$(git rev-parse --show-toplevel)" - osArch=$(uname | tr '[:upper:]' '[:lower:]') - cpuArch=$(uname -m | sed 's/aarch64/arm64/;s/x86_64/x64/') - if test -f "$dir/node_modules/lefthook-${osArch}-${cpuArch}/bin/lefthook" - then - "$dir/node_modules/lefthook-${osArch}-${cpuArch}/bin/lefthook" "$@" - elif test -f "$dir/node_modules/@evilmartians/lefthook/bin/lefthook-${osArch}-${cpuArch}/lefthook" - then - "$dir/node_modules/@evilmartians/lefthook/bin/lefthook-${osArch}-${cpuArch}/lefthook" "$@" - elif test -f "$dir/node_modules/@evilmartians/lefthook-installer/bin/lefthook" - then - "$dir/node_modules/@evilmartians/lefthook-installer/bin/lefthook" "$@" - elif test -f "$dir/node_modules/lefthook/bin/index.js" - then - "$dir/node_modules/lefthook/bin/index.js" "$@" - - elif bundle exec lefthook -h >/dev/null 2>&1 - then - bundle exec lefthook "$@" - elif yarn lefthook -h >/dev/null 2>&1 - then - yarn lefthook "$@" - elif pnpm lefthook -h >/dev/null 2>&1 - then - pnpm lefthook "$@" - elif swift package plugin lefthook >/dev/null 2>&1 - then - swift package --disable-sandbox plugin lefthook "$@" - elif command -v mint >/dev/null 2>&1 - then - mint run csjones/lefthook-plugin "$@" - else - echo "Can't find lefthook in PATH" - fi - fi -} - -call_lefthook run "prepare-commit-msg" "$@" diff --git a/tracker/tracker/src/main/modules/userTesting/styles.ts b/tracker/tracker/src/main/modules/userTesting/styles.ts index 14965dffa..972fd54f9 100644 --- a/tracker/tracker/src/main/modules/userTesting/styles.ts +++ b/tracker/tracker/src/main/modules/userTesting/styles.ts @@ -19,7 +19,7 @@ export const containerStyle = { alignItems: 'center', padding: '1.5rem', borderRadius: '2px', - border: '1px solid #D9D9D9', + border: '1px solid rgb(255 255 255 / var(--tw-bg-opacity, 1))', background: '#FFF', width: '22rem', } @@ -31,7 +31,7 @@ export const containerWidgetStyle = { padding: 'unset', fontFamily: `-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"`, 'border-radius': '2px', - border: '1px solid #D9D9D9', + border: '1px solid rgb(255 255 255 / var(--tw-bg-opacity, 1))', background: 'rgba(255, 255, 255, 0.75)', width: '22rem', } @@ -129,7 +129,7 @@ export const descriptionWidgetStyle = { boxSizing: 'border-box', display: 'block', width: '100%', - borderBottom: '1px solid #D9D9D9', + borderBottom: '1px solid rgb(255 255 255 / var(--tw-bg-opacity, 1))', background: '#FFF', padding: '0.65rem', alignSelf: 'stretch',