ui: omnisearch, timeseries charts redesign (#2791)

* ui: start redesign for live search/list

* ui: remove search field, show filters picker by default for assist

* ui: filter modal wip

* ui: filter modal wip

* ui: finish with omnisearch thing

* ui: start new dashboard redesign

* refining new card section

* ui: some "new dashboard" view improvs, fix icons fill inheritance, add ai button colors

* ui: split up search component (1.22+ tbd?), restrict filter type to own modals

* ui: mimic ant card

* ui: some changes for card creation flow, add series table to CustomMetricLineChart.tsx

* ui: more chart types, add table with filtering out series, start "compare to" thing

* ui: comparison designs

* ui: better granularity support, comparison view for bar chart

* ui: add comparison to more charts, add "metric" chart (BigNumChart.tsx)

* ui: cleanup logs

* ui: fix defualt import, fix sessheader crash, fix condition set ui

* ui: some refactoring and type coverage...

* ui: more refactoring; silence warnings for list renderers

* ui: moveing and renaming filters

* ui: add metricOf selector

* ui: check for metric type

* ui: fix crashes, add widget library table

* ui: change new series btn

* ui: restrict filterselection

* ui: fix timeseries table format

* ui: autoclose autocomplete modal

* ui: some fixes to issue filters default value, display and placeholder consistency

* ui: some dashboard issues with card selection modal and empty states

* ui: comparing for funnels, alternate column view, some refactoring to prepare for customizations...

* Style improvements in omnisearch headers

* Revert "Style improvements in omnisearch headers"

This reverts commit 89e51b0531.

* ui: show health status fetch error

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

* Omni-search improvements. (#2823)

Co-authored-by: Sudheer Salavadi <connect.uxmaster@gmail.com>

* ui: fix bad merge (git hallo?)

* ui: fix filter mapper

* rm husky

* ui: add card floater

* ui: add card floater

* ui: refactor local autocomplete input

* ui: filterout empty options

* UI improvements in New Cards (#2864)

* ui: some minor dashb improvements

* ui: metric type selector for head

* ui: change card type selector, add automapping

* ui: check chart/widget components for crashes

* ui: fix crash with table metrics

* ui: fix crashes related to metric type changes

* ui: filter category for clickmap filt

* ui: fix dash options menu, fix cr/up button

* ui: fix dash list menu propagation

* ui: hide addevent in heatmaps

* ui: fix time mapping for charts

* ui: fix exclusion component for path

* ui: fix series amount for path analysis, rm grid/list selector

* ui: fix icons in list view

* ui: fix for dlt button in widgets

* Various improvements Cards, OmniSearch and Cards  Listing (#2881)

* ui: some improvements for cards list view, funnels and general filter display

* ui: longer node width for journey

* Product Analytics UI Improvements. (#2896)

* Various improvements Cards, OmniSearch and Cards  Listing

* Improved cards listing page

* Various improvements in product analytics

* Charts UI improvements

---------

Co-authored-by: nick-delirium <nikita@openreplay.com>

* Live se red s2 (#2902)

* Various improvements Cards, OmniSearch and Cards  Listing

* Improved cards listing page

* Various improvements in product analytics

* Charts UI improvements

* ui crash

---------

Co-authored-by: Sudheer Salavadi <connect.uxmaster@gmail.com>

* ui: fix lucide version

* ui: fix custom comparison period

* ui: fix custom comparison period

* ui: handle minor paths on frontend for path/sankey

* ui: assign icon for event types in sankey nodes

* ui: some strings changed

* ui: hide btn control for table view

* Various improvements in graphs, and analytics pages. (#2908)

* Various improvements Cards, OmniSearch and Cards  Listing

* Improved cards listing page

* Various improvements in product analytics

* Charts UI improvements

* ui crash

* Chart improvements and layout toggling

* Various improvements

* Tooltips

---------

Co-authored-by: nick-delirium <nikita@openreplay.com>

* ui: fix weekday mapper for x axis on >7d range

* ui: lower default density to 35, fix table card display

* ui: filterMinorPaths -> return input data if nodes arr. is empty

* ui: use default filter for sessions, move around saved search actions, remove tags modal

* ui: fix card creator visibility in grid, fix table exporter visiblility in grid

* ui: fix some proptype warnings

* ui: change new series default expand state

* ui: save comp range in widget details

* ui: move timeseries to apache echarts

* ui: use unique id for window values

* ui: add timestamp for comp tooltip row

* ui: rename var for readability

* ui: fix comparison for 24hr

* Streamlined icons and improved echarts trends (#2920)

* Various improvements Cards, OmniSearch and Cards  Listing

* Improved cards listing page

* Various improvements in product analytics

* Charts UI improvements

* ui crash

* Chart improvements and layout toggling

* Various improvements

* Tooltips

* Improved icons in cards listing page

* Update WidgetFormNew.tsx

* Sankey improvements

* Icon and text updates

Text alignment and color changes in x-ray
Icon Mapping with appropriate names and shapes

* Colors and Trend Chart Interaction updates

* ui

---------

Co-authored-by: nick-delirium <nikita@openreplay.com>

* ui: series update observe

* ui: resize chart on window

* ui: move barchart to echarts

* ui: fixing bars under comparison

* ui: fixing horizontal bar tooltip

* ui: rm unused

* ui: keep state in storage

* ui: small fixes for granularity and comparisons

* ui: fix savesearch button, fix comparison period tracking

* ui: fix funnel type selection

* ui: fixing saved search button

* ui: enable error logging, remove immutable reference

* ui: update savedsearch drop

* ui: disable button if no saved

* ui: small ui fixes

* ui: add drill to summary charts, add more options to card category picker

* ui: filter compSeries with table

* ui: swap tag_el operator and value

* ui: fix top countries

* ui: further changes for search/cards

* ui: move focus to session list on line click

* ui: fix issue filter mapper

* ui: fix alert pre-init function, fix metric list options, fix legend placement

* ui: fixes for card library

* ui: work on new sankey chart

* ui: fix metadata prefetch

* ui: moving snakey to echarts

* ui: fix funnel comparison focus

* ui: stale loader

---------

Co-authored-by: Sudheer Salavadi <connect.uxmaster@gmail.com>
This commit is contained in:
Delirium 2025-01-24 09:58:35 +01:00 committed by GitHub
parent 954e811be0
commit 622d0a7dfa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
203 changed files with 8759 additions and 4599 deletions

View file

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

View file

@ -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 (
<div className="flex items-center w-full gap-2">
<div style={{ width: '60%' }}>
<SessionSearchField />
</div>
{isEnterprise && modules.includes(MODULES.OFFLINE_RECORDINGS)
? <Button type="primary" ghost onClick={showRecords}>Training Videos</Button> : null
}
@ -50,4 +46,4 @@ function AssistSearchField() {
);
}
export default observer(AssistSearchField);
export default observer(AssistSearchActions);

View file

@ -0,0 +1 @@
export { default } from './AssistSearchActions'

View file

@ -1 +0,0 @@
export { default } from './AssistSearchField'

View file

@ -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 (
<div className="w-full mx-auto" style={{ maxWidth: '1360px'}}>
<AssistSearchField />
<AssistSearchActions />
<LiveSessionSearch />
<div className="my-4" />
<LiveSessionList />

View file

@ -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<string>(Math.random().toString(36).substring(7));
const chartRef = React.useRef<HTMLDivElement>(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 <div ref={chartRef} style={{ width: '100%', height: 240 }} />;
}
export default ORBarChart;

View file

@ -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<HTMLDivElement>(null);
const chartUuid = React.useRef<string>(
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 <div ref={chartRef} style={{ width: '100%', height: 240 }} />;
}
export default ColumnChart;

View file

@ -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<string>(Math.random().toString(36).substring(7));
const chartRef = React.useRef<HTMLDivElement>(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 <div ref={chartRef} style={{ width: '100%', height: 240 }} />;
}
export default ORLineChart;

View file

@ -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<HTMLDivElement>(null);
useEffect(() => {
if (!chartRef.current) return;
if (!data.chart || data.chart.length === 0) {
chartRef.current.innerHTML = `<div style="text-align:center;padding:20px;">No data available</div>`;
return;
}
const chartInstance = echarts.init(chartRef.current);
const pieData = buildPieData(data.chart, data.namesMap);
if (!pieData.length) {
chartRef.current.innerHTML = `<div style="text-align:center;padding:20px;">No data available</div>`;
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 (
<div style={{ width: '100%', height: 240, position: 'relative' }} ref={chartRef} />
);
}
export default PieChart;

View file

@ -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> = (props) => {
const { data, height = 240, onChartClick } = props;
const chartRef = React.useRef<HTMLDivElement>(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<number>();
// 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 <div ref={chartRef} style={{ width: '100%', height }} />;
};
export default EChartsSankey;

View file

@ -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<string, any> | null = null;
let compSeries: Record<string, any>[] = [];
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,
};
}

View file

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

View file

@ -0,0 +1,31 @@
import { colors } from './utils';
import { numberWithCommas } from 'App/utils';
export function buildPieData(
chart: Array<Record<string, any>>,
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 `
<div class="flex flex-col gap-1 bg-white shadow border rounded p-2 z-50">
<div style="margin-bottom: 2px;">${marker} <b>${name}</b></div>
<div>${numberWithCommas(value)} (${percent}%)</div>
</div>
`;
}
export function pickColorByIndex(idx: number) {
return colors[idx % colors.length];
}

View file

@ -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 `
<div class="flex gap-2 w-fit px-2 bg-white items-center">
<div class="flex flex-col">
<div class="border-t border-l rounded-tl border-dotted border-gray-500" style="width: 8px; height: 30px"></div>
<div class="border-b border-l rounded-bl border-dotted border-gray-500 relative" style="width: 8px; height: 30px">
<div class="w-0 h-0 border-l-4 border-l-gray-500 border-y-4 border-y-transparent border-r-0 absolute -right-1 -bottom-1.5"></div>
</div>
</div>
<div class="flex flex-col">
<div class="font-semibold">${sourceName}</div>
<div>${sourceValue}</div>
<div class="font-semibold mt-2">${targetName}</div>
<div>
<span>${params.data.value}</span>
<span class="text-disabled-text">${params.data.percentage.toFixed(
2
)}%</span>
</div>
</div>
</div>
`;
//${sourceName} -> ${targetName}: ${params.data.value} sessions (${params.data.percentage.toFixed(2)}%)
}
if ('name' in params.data) {
return `
<div class="flex flex-col bg-white">
<div class="font-semibold">${params.data.name}</div>
<div>${params.value} sessions</div>
</div>
`;
}
};
}
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;
}

View file

@ -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<string, string> = {};
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<number, string> {
const colorMap: Record<number, string> = {};
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 = `
<div class="flex flex-col gap-1 bg-white shadow border rounded p-2 z-50">
<div class="flex gap-2 items-center">
<div style="
border-radius: 99px;
background: ${params.color};
width: 1rem;
height: 1rem;">
</div>
<div class="font-medium text-black">${fullname}</div>
</div>
<div style="border-left: 2px solid ${
params.color
};" class="flex flex-col px-2 ml-2">
<div class="text-neutral-600 text-sm">
Total:
</div>
<div class="flex items-center gap-1">
<div class="font-medium text-black">${params.value}</div>
${buildCompareTag(params.value, partnerValue)}
</div>
</div>
`;
if (partnerValue !== undefined) {
const partnerColor =
(window as any).__seriesColorMap?.[uuid]?.[partnerName] || '#999';
str += `
<div style="border-left: 2px dashed ${partnerColor};" class="flex flex-col px-2 ml-2">
<div class="text-neutral-600 text-sm">
${isPrevious ? 'Current' : 'Previous'} Total:
</div>
<div class="flex items-center gap-1">
<div class="font-medium">${partnerValue ?? '—'}</div>
${buildCompareTag(partnerValue, params.value)}
</div>
</div>`;
}
str += '</div>';
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 partners 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 = `
<div class="flex flex-col gap-1 bg-white shadow border rounded p-2 z-50">
<div class="flex gap-2 items-center">
<div style="
border-radius: 99px;
background: ${params.color};
width: 1rem;
height: 1rem;">
</div>
<div class="font-medium text-black">${seriesName}</div>
</div>
<div style="border-left: 2px solid ${
params.color
};" class="flex flex-col px-2 ml-2">
<div class="text-neutral-600 text-sm">
${firstTs ? formatTimeOrDate(firstTs) : categoryLabel}
</div>
<div class="flex items-center gap-1">
<div class="font-medium text-black">${value ?? '—'}</div>
${buildCompareTag(value, partnerVal)}
</div>
</div>
`;
if (partnerVal !== undefined) {
const partnerColor =
(window as any).__seriesColorMap?.[uuid]?.[partnerName] || '#999';
tooltipContent += `
<div style="border-left: 2px dashed ${partnerColor};" class="flex flex-col px-2 ml-2">
<div class="text-neutral-600 text-sm">
${secondTs ? formatTimeOrDate(secondTs) : categoryLabel}
</div>
<div class="flex items-center gap-1">
<div class="font-medium">${partnerVal ?? '—'}</div>
</div>
</div>
`;
}
tooltipContent += '</div>';
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 `
<div style="
display: inline-flex;
align-items: center;
gap: 4px;
background: ${tagColor};
color: ${arrowColor};
padding: 2px 6px;
border-radius: 4px;
font-size: 0.75rem;">
<span>${arrow}</span>
<span>${absDelta}</span>
<span>${ratio ? `(${ratio}%)` : ''}</span>
</div>
`;
}
/**
* 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<string, any> | null = null;
let compSeries: Record<string, any>[] = [];
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[];
};
}

View file

@ -57,7 +57,7 @@ const ProjectList: React.FC = () => {
<div className="h-full flex flex-col gap-4">
<div className="flex flex-row gap-2 items-center p-3">
<Input
placeholder="Search"
placeholder="Search projects"
// onSearch={handleSearch}
prefix={<SearchOutlined />}
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}
/>

View file

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

View file

@ -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<string, any>;
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<string | null>(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 (
<ResponsiveContainer height={240} width="100%">
<AreaChart
data={data.chart}
margin={Styles.chartMargins}
onClick={onClick}
onMouseLeave={handleMouseLeave} // Reset hover state on mouse leave
>
{!hideLegend && (
<Legend
iconType={'wye'}
className='font-normal'
wrapperStyle={{ top: inGrid ? undefined : -18 }}
payload={
data.namesMap.map((key, index) => ({
value: key,
type: 'line',
color: colors[index],
id: key,
}))
}
/>
)}
<CartesianGrid
strokeDasharray="1 3"
vertical={false}
stroke="rgba(0,0,0,.15)"
/>
<XAxis {...Styles.xaxis} dataKey="time" interval={'equidistantPreserveStart'} />
<YAxis
{...yaxis}
allowDecimals={false}
tickFormatter={(val) => Styles.tickFormatter(val)}
label={{
...Styles.axisLabelLeft,
value: label || 'Number of Sessions',
}}
/>
<Tooltip
{...Styles.tooltip}
content={<CustomTooltip hoveredSeries={hoveredSeries} />} // Pass hoveredSeries to tooltip
/>
{Array.isArray(reorderedNamesMap) &&
reorderedNamesMap.map((key, index) => (
<Area
key={key}
name={key}
type="linear"
dataKey={key}
stroke={colors[data.namesMap.indexOf(key)]} // Match original color
fill={colors[data.namesMap.indexOf(key)]}
fillOpacity={
hoveredSeries && hoveredSeries !== key ? 0.2 : 0.1
} // Adjust opacity for non-hovered lines
strokeOpacity={
hoveredSeries && hoveredSeries !== key ? 0.2 : 1
} // Adjust stroke opacity
legendType={key === 'Total' ? 'none' : 'line'}
dot={false}
activeDot={
hoveredSeries === key
? {
r: 8,
stroke: '#fff',
strokeWidth: 2,
fill: colors[data.namesMap.indexOf(key)],
filter: 'drop-shadow(0px 0px 1px rgba(0, 0, 0, 0.2))',
}
: false
} // Show active dot only for the hovered line
onMouseOver={handleMouseOver(key)} // Set hover state on mouse over
style={{ cursor: 'pointer' }}
/>
))}
</AreaChart>
</ResponsiveContainer>
);
}
export default CustomAreaChart;

View file

@ -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 (
<div className={'pb-3'}>
<div className={'flex flex-row flex-wrap gap-2'} style={{ height: 240 }}>
{values.map((val, i) => (
<BigNum
key={i}
hideLegend={hideLegend}
color={colors[i]}
series={val.series}
value={val.value}
label={label}
compData={val.compData}
valueLabel={val.valueLabel}
onSeriesFocus={onSeriesFocus}
/>
))}
</div>
</div>
)
}
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 (
<div
onClick={() => 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 :
<div
className={'flex items-center gap-2 font-medium text-gray-darkest'}
>
<div className={'rounded w-4 h-4'} style={{ background: color }} />
<div>{series}</div>
</div>
}
<div className={'font-bold leading-none'} style={{ fontSize: 56 }}>
{formattedNumber(value)}
{valueLabel ? `${valueLabel}` : null}
</div>
<div className={'text-disabled-text text-xs'}>{label}</div>
{compData ? (
<CompareTag
isHigher={value > compData}
absDelta={change}
delta={changePercent}
/>
) : null}
</div>
);
}
export default BigNumChart;

View file

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

View file

@ -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 (
<div className="flex flex-col gap-1 bg-white shadow border rounded p-2 z-50">
{transformedArray.map((p, index) => (
<React.Fragment key={p.name + index}>
<div className="flex gap-2 items-center">
<div
style={{ borderRadius: 99, background: p.color }}
className="h-5 w-5 flex items-center justify-center"
>
<div className="invert text-sm">{index + 1}</div>
</div>
<div className="font-medium">{p.name}</div>
</div>
<div
style={{ borderLeft: `2px solid ${p.color}` }}
className="flex flex-col px-2 ml-2"
>
<div className="text-neutral-600 text-sm">
{label},{' '}
{p.payload?.timestamp
? formatTimeOrDate(p.payload.timestamp)
: <div className='hidden'>'Timestamp is not Applicable'</div>}
</div>
<div className="flex items-center gap-1">
<div className="font-medium">{p.value}</div>
<CompareTag
isHigher={isHigher(p)}
absDelta={Math.abs(p.value - p.prevValue)}
delta={getPercentDelta(p.value, p.prevValue)}
/>
</div>
</div>
</React.Fragment>
))}
</div>
);
}
export function CompareTag({
isHigher,
absDelta,
delta,
}: {
isHigher: boolean | null; // Allow null for default view
absDelta?: number | string | null;
delta?: string | null;
}) {
return (
<div
className={cn(
'px-2 py-1 w-fit rounded flex items-center gap-1',
isHigher === null
? 'bg-neutral-200 text-neutral-600 text-xs'
: isHigher
? 'bg-green2/10 text-xs'
: 'bg-red2/10 text-xs'
)}
>
{isHigher === null ? (
<div>No Comparison</div>
) : (
<>
{!isHigher ? <ArrowDown size={12} /> : <ArrowUp size={12} />}
<div>{absDelta}</div>
<div>({delta}%)</div>
</>
)}
</div>
);
}
export default CustomTooltip;

View file

@ -0,0 +1,39 @@
import React from 'react';
import { Legend } from 'recharts';
interface CustomLegendProps {
payload?: any[];
}
function CustomLegend({ payload }: CustomLegendProps) {
return (
<div className="custom-legend" style={{ display: 'flex', justifyContent:'center', gap: '1rem', flexWrap: 'wrap' }}>
{payload?.map((entry) => (
<div key={entry.value} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
{entry.value.includes('(Comparison)') ? (
<div
style={{
width: 20,
height: 2,
backgroundImage: 'linear-gradient(to right, black 50%, transparent 50%)',
backgroundSize: '4px 2px',
backgroundRepeat: 'repeat-x',
}}
/>
) : (
<div
style={{
width: 20,
height: 2,
backgroundColor: entry.color,
}}
/>
)}
<span className='text-sm'>{entry.value}</span>
</div>
))}
</div>
);
}
export default CustomLegend;

View file

@ -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 (
<ResponsiveContainer height={240} width="100%">
<LineChart
data={data.chart}
margin={Styles.chartMargins}
// syncId={ showSync ? "domainsErrors_4xx" : undefined }
onClick={onClick}
// isAnimationActive={ false }
>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#EEEEEE"/>
<XAxis
{...Styles.xaxis}
dataKey="time"
interval={params.density / 7}
/>
<YAxis
{...yaxis}
allowDecimals={false}
tickFormatter={val => Styles.tickFormatter(val)}
label={{
...Styles.axisLabelLeft,
value: label || "Number of Sessions"
}}
/>
{!hideLegend && <Legend />}
<Tooltip {...Styles.tooltip} />
{Array.isArray(data.namesMap) && data.namesMap.map((key, index) => (
<Line
key={key}
name={key}
type="monotone"
dataKey={key}
stroke={colors[index]}
fillOpacity={1}
strokeWidth={2}
strokeOpacity={key === 'Total' ? 0 : 0.6}
// fill="url(#colorCount)"
legendType={key === 'Total' ? 'none' : 'line'}
dot={false}
/>
))}
</LineChart>
</ResponsiveContainer>
)
}
export default CustomMetricLineChart

View file

@ -1 +0,0 @@
export { default } from './CustomMetricLineChart';

View file

@ -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<any>();
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<string | null>(null);
filters.push(filter);
onClick(filters);
}
const onClickHandler = (event) => {
if (event && !event.payload.group) {
const filters = Array<any>();
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 (
<NoContent size="small" title="No data available" show={!data.values || data.values.length === 0} style={{ minHeight: '240px'}}>
<ResponsiveContainer height={ 220 } width="100%">
<PieChart>
<Pie
isAnimationActive={ false }
data={data.values}
dataKey="sessionCount"
nameKey="name"
cx="50%"
cy="50%"
// innerRadius={40}
outerRadius={70}
// fill={colors[0]}
activeIndex={1}
onClick={onClickHandler}
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 / data.values.reduce((a, b) => a + b.sessionCount, 0);
if (percentage<3){
return null;
}
return(
<line x1={x1} y1={y1} x2={x2} y2={y2} stroke="#3EAAAF" strokeWidth={1} />
)
}}
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 (
<text
x={x}
y={y}
fontWeight="400"
fontSize="12px"
// fontFamily="'Source Sans Pro', 'Roboto', 'Helvetica Neue', 'Helvetica', 'Arial', 'sans-serif'"
textAnchor={x > cx ? "start" : "end"}
dominantBaseline="central"
fill='#666'
>
{name || 'Unidentified'} {numberWithCommas(value)}
</text>
);
}}
>
{data && data.values && data.values.map((entry, index) => (
<Cell key={`cell-${index}`} fill={Styles.colorsPie[index % Styles.colorsPie.length]} />
))}
</Pie>
<Tooltip {...Styles.tooltip} />
</PieChart>
</ResponsiveContainer>
<div className="text-sm color-gray-medium">Top 5 </div>
</NoContent>
)
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 (
<NoContent
size="small"
title="No data available"
show={!data.chart || data.chart.length === 0}
style={{ minHeight: '240px' }}
>
<ResponsiveContainer height={240} width="100%">
<PieChart>
<Legend iconType={'triangle'} wrapperStyle={{ top: inGrid ? undefined : -18 }} />
<Tooltip
content={<CustomTooltip hoveredSeries={hoveredSeries} />}
/>
<Pie
isAnimationActive={false}
data={values}
cx="50%"
cy="60%"
innerRadius={60}
outerRadius={100}
activeIndex={1}
onClick={onClickHandler}
onMouseOver={({ name }) => 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 (
<line
x1={x1}
y1={y1}
x2={x2}
y2={y2}
stroke="#3EAAAF"
strokeWidth={1}
/>
);
}}
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 (
<text
x={x}
y={y}
fontWeight="400"
fontSize="12px"
textAnchor={x > cx ? 'start' : 'end'}
dominantBaseline="central"
fill="#666"
>
{numberWithCommas(value)}
</text>
);
}}
>
{values.map((entry, index) => (
<Cell
key={`cell-${index}`}
fill={Styles.safeColors[index % Styles.safeColors.length]}
/>
))}
</Pie>
</PieChart>
</ResponsiveContainer>
</NoContent>
);
}
export default CustomMetricPieChart;
export default CustomMetricPieChart;

View file

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

View file

@ -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<string, TabItem[]> = {
[CATEGORIES.product_analytics]: [
{
icon: <LineChart width={16} />,
title: 'Trends',
type: TIMESERIES,
description: 'Track session trends over time.',
},
{
icon: <Filter width={16} />,
title: 'Funnels',
type: FUNNEL,
description: 'Visualize user progression through critical steps.',
},
{
icon: (
<Icon name={'dashboards/user-journey'} color={'inherit'} size={16} />
),
title: 'Journeys',
type: USER_PATH,
description: 'Understand the paths users take through your product.',
},
// { TODO: 1.23+
// icon: <Icon name={'dashboards/cohort-chart'} color={'inherit'} size={16} />,
// title: 'Retention',
// type: RETENTION,
// description: 'Analyze user retention over specific time periods.',
// },
{
icon: <Icon name={'dashboards/heatmap-2'} color={'inherit'} size={16} />,
title: 'Heatmaps',
type: HEATMAP,
description: 'Visualize user interaction patterns on your pages.',
},
],
[CATEGORIES.monitors]: [
{
icon: (
<Icon name={'dashboards/circle-alert'} color={'inherit'} size={16} />
),
title: 'JS Errors',
type: FilterKey.ERRORS,
description: 'Monitor JS errors affecting user experience.',
},
{
icon: <ArrowUpDown width={16} />,
title: 'Top Network Requests',
type: FilterKey.FETCH,
description: 'Identify the most frequent network requests.',
},
{
icon: <WifiOff width={16} />,
title: '4xx/5xx Requests',
type: TIMESERIES + '_4xx_requests',
description: 'Track client and server errors for performance issues.',
},
{
icon: <Turtle width={16} />,
title: 'Slow Network Requests',
type: TIMESERIES + '_slow_network_requests',
description: 'Pinpoint the slowest network requests causing delays.',
},
],
[CATEGORIES.web_analytics]: [
{
icon: <FileStack width={16} />,
title: 'Top Pages',
type: FilterKey.LOCATION,
description: 'Discover the most visited pages on your site.',
},
{
icon: <AppWindow width={16} />,
title: 'Top Browsers',
type: FilterKey.USER_BROWSER,
description: 'Analyze the browsers your visitors are using the most.',
},
{
icon: <Combine width={16} />,
title: 'Top Referrer',
type: FilterKey.REFERRER,
description: 'See where your traffic is coming from.',
},
{
icon: <Users width={16} />,
title: 'Top Users',
type: FilterKey.USERID,
description: 'Identify the users with the most interactions.',
},
{
icon: <Globe width={16} />,
title: 'Top Countries',
type: FilterKey.USER_COUNTRY,
description: 'Track the geographical distribution of your audience.',
},
{
icon: <MonitorSmartphone width={16} />,
title: 'Top Devices',
type: FilterKey.USER_DEVICE,
description: 'Explore the devices used by your users.',
}
// { TODO: 1.23+ maybe
// icon: <ArrowDown10 width={16} />,
// 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 (
<div className={'flex flex-col gap-3'}>
{items.map((item, index) => (
<div
onClick={() => 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}
<div className={'leading-none'}>
<div>{item.title}</div>
<div className={'text-disabled-text group-hover:text-teal/60 text-sm'}>
{item.description}
</div>
</div>
</div>
))}
</div>
);
}
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(
<MetricsLibraryModal siteId={siteId} dashboardId={dashboardId} />,
{
right: true,
width: 800,
onClose: () => {
metricStore.updateKey('metricsSearch', '');
},
}
);
handleOpenChange?.(false);
};
return (
<div
className={
'pt-4 pb-6 px-6 rounded-xl bg-white border border-gray-lighter flex flex-col gap-2'
}
>
<div className={'flex justify-between p-2'}>
<div className={'text-xl font-medium mb-1'}>
What do you want to visualize?
</div>
{isSaas ? (
<div
className={'font-medium flex items-center gap-2 cursor-pointer'}
>
<Sparkles color={'#3C00FFD8'} size={16} />
<div className={'ai-gradient'}>Ask AI</div>
</div>
) : null}
</div>
<div>
<Segmented
options={options}
value={tab}
onChange={(value) => setTab(value)}
/>
</div>
<div className="py-2">
<CategoryTab tab={tab} inCards={inCards} />
</div>
{inCards ? null :
<div
className={
'w-full flex items-center justify-center border-t mt-auto border-t-gray-lighter gap-2 pt-2 cursor-pointer'
}
>
<Button
className="w-full mt-4 hover:bg-active-blue hover:text-teal"
type="text"
variant="text"
onClick={onExistingClick}
>
<FolderOutlined /> Add existing card
</Button>
</div>
}
</div>
);
}
);
export default AddCardSection;

View file

@ -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: (
<Form.Field>
<Select
options={dashboardOptions}
defaultValue={dashboardOptions[0].value}
onChange={({value}: any) => setSelectedId(value.value)}
/>
</Form.Field>
),
cancelText: 'Cancel',
onOk: onSave,
okText: 'Add',
footer: (_, {OkBtn, CancelBtn}) => (
<>
<CancelBtn/>
<OkBtn/>
</>
),
})
}
Modal.confirm({
title: 'Add to selected dashboard',
icon: null,
content: (
<Form.Field>
<Select
options={dashboardOptions}
defaultValue={selectedId}
onChange={({ value }: any) => (selectedId = value.value)}
/>
</Form.Field>
),
cancelText: 'Cancel',
onOk: onSave,
okText: 'Add',
footer: (_, { OkBtn, CancelBtn }) => (
<>
<CancelBtn />
<OkBtn />
</>
),
});
};
return (
<Button
type="default"
onClick={onClick}
icon={<Grid2x2Check size={18}/>}
>
Add to Dashboard
</Button>
);
}
const AddToDashboardButton = ({ metricId }: Props) => {
const { dashboardStore } = useStore();
export default AddToDashboardButton;
return (
<Button
type="default"
onClick={() => showAddToDashboardModal(metricId, dashboardStore)}
icon={<Grid2x2Check size={18} />}
>
Add to Dashboard
</Button>
);
};
export default AddToDashboardButton;

View file

@ -60,8 +60,8 @@ function AlertsList({ siteId }: Props) {
<div className='w-full flex items-center justify-between pt-4 px-6'>
<div className=''>
Showing <span className='font-semibold'>{Math.min(list.length, pageSize)}</span> out of{' '}
<span className='font-semibold'>{list.length}</span> Alerts
Showing <span className='font-medium'>{Math.min(list.length, pageSize)}</span> out of{' '}
<span className='font-medium'>{list.length}</span> Alerts
</div>
<Pagination
page={page}

View file

@ -76,6 +76,7 @@ const NewAlert = (props: IProps) => {
triggerOptions,
loading,
} = alertsStore
const deleting = loading
const webhooks = settingsStore.webhooks
const fetchWebhooks = settingsStore.fetchWebhooks

View file

@ -59,8 +59,8 @@ function CardUserList(props: RouteComponentProps<Props>) {
<div className="w-full flex items-center justify-between pt-4">
<div className="text-disabled-text">
Showing <span className="font-semibold">{Math.min(data.length, pageSize)}</span> out of{' '}
<span className="font-semibold">{data.length}</span> Issues
Showing <span className="font-medium">{Math.min(data.length, pageSize)}</span> out of{' '}
<span className="font-medium">{data.length}</span> Issues
</div>
<Pagination
page={metricStore.sessionsPage}

View file

@ -1,25 +1,45 @@
import React from "react";
import {PlusOutlined} from "@ant-design/icons";
import NewDashboardModal from "Components/Dashboard/components/DashboardList/NewDashModal";
import {Button} from "antd";
import React from 'react';
import { PlusOutlined } from '@ant-design/icons';
import { Button } from 'antd';
import { useStore } from 'App/mstore';
import { useHistory } from 'react-router-dom';
interface Props {
disabled?: boolean;
disabled?: boolean;
}
function CreateDashboardButton({disabled = false}: Props) {
const [showModal, setShowModal] = React.useState(false);
function CreateDashboardButton({ disabled }: Props) {
const [dashboardCreating, setDashboardCreating] = React.useState(false);
const { projectsStore, dashboardStore } = useStore();
const siteId = projectsStore.siteId;
const history = useHistory();
return <>
<Button
icon={<PlusOutlined/>}
type="primary"
onClick={() => setShowModal(true)}
>
Create Dashboard
</Button>
<NewDashboardModal onClose={() => 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 (
<>
<Button
loading={dashboardCreating}
icon={<PlusOutlined />}
disabled={disabled}
type="primary"
onClick={createNewDashboard}
>
Create Dashboard
</Button>
</>
);
}
export default CreateDashboardButton;

View file

@ -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 (
<div>
<>
<DashboardEditModal
show={showEditModal}
closeHandler={() => setShowEditModal(false)}
focusTitle={focusTitle}
/>
<div className="flex items-center mb-2 justify-between">
<div className="flex items-center" style={{ flex: 3 }}>
<BackButton siteId={siteId} />
{/* <Breadcrumb
items={[
{
label: 'Back',
to: withSiteId('/dashboard', siteId),
},
{label: (dashboard && dashboard.name) || ''},
]}
/> */}
<div className="flex items-center justify-between px-4 pt-4 bg-white">
<div className="flex items-center gap-2" style={{ flex: 3 }}>
<BackButton siteId={siteId} compact />
<PageTitle
title={
// @ts-ignore
<Tooltip delay={0} title="Double click to edit" placement="bottom">
<Tooltip
delay={0}
title="Double click to edit"
placement="bottom"
>
{dashboard?.name}
</Tooltip>
}
@ -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"
/>
</div>
<div className="flex items-center gap-2" style={{ flex: 1, justifyContent: 'end' }}>
<CreateCardButton disabled={canAddMore} />
<div
className="flex items-center gap-2"
style={{ flex: 1, justifyContent: 'end' }}
>
<SelectDateRange
style={{ width: '300px' }}
period={period}
onChange={(period: any) => dashboardStore.setPeriod(period)}
right={true}
isAnt={true}
useButtonStyle={true}
/>
<div
className="flex items-center flex-shrink-0 justify-end dashboardDataPeriodSelector"
style={{ width: 'fit-content' }}
>
<SelectDateRange
style={{ width: '300px' }}
period={period}
onChange={(period: any) => dashboardStore.setPeriod(period)}
right={true}
isAnt={true}
useButtonStyle={true}
/>
</div>
<div className="flex items-center flex-shrink-0">
<DashboardOptions
editHandler={onEdit}
deleteHandler={onDelete}
renderReport={props.renderReport}
isTitlePresent={!!dashboard?.description}
/>
</div>
<DashboardOptions
editHandler={onEdit}
deleteHandler={onDelete}
renderReport={props.renderReport}
isTitlePresent={!!dashboard?.description}
/>
</div>
</div>
<div className="pb-4">
{/* @ts-ignore */}
<Tooltip arrow title="Double click to edit" placement="top" className="w-fit !block">
<h2
className="my-2 font-normal w-fit text-disabled-text border-b border-b-borderColor-transparent hover:border-dotted hover:border-gray-medium cursor-pointer"
onDoubleClick={() => onEdit(false)}
>
{/* {dashboard?.description || 'Describe the purpose of this dashboard'} */}
</h2>
</Tooltip>
</div>
</div>
</>
);
}

View file

@ -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<HTMLDivElement>(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"
/>
</Tooltip>
</div>
@ -121,23 +123,52 @@ function DashboardList() {
},
{
title: 'Options',
title: '',
dataIndex: 'dashboardId',
width: '5%',
onCell: () => ({ onClick: (e) => e.stopPropagation() }),
render: (id) => (
<ItemMenu
bold
items={[
{ icon: 'pencil', text: 'Rename', onClick: () => onEdit(id, true) },
{
icon: 'users',
text: 'Visibility & Access',
onClick: () => onEdit(id, false),
},
{ icon: 'trash', text: 'Delete', onClick: () => onDelete(id) },
]}
/>
<div onClick={(e) => e.stopPropagation()}>
<Dropdown
arrow={false}
trigger={['click']}
className={'ignore-prop-dp'}
menu={{
items: [
{
icon: <Icon name={'pencil'} />,
key: 'rename',
label: 'Rename',
},
{
icon: <Icon name={'users'} />,
key: 'access',
label: 'Visibility & Access',
},
{
icon: <Icon name={'trash'} />,
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);
}
},
}}
>
<Button
id={'ignore-prop'}
icon={<MoreOutlined />}
type="text"
className="btn-dashboards-list-item-more-options"
/>
</Dropdown>
</div>
),
},
];
@ -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),

View file

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

View file

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

View file

@ -57,7 +57,7 @@ function AreaChartCard(props: Props) {
margin={Styles.chartMargins}
>
{gradientDef}
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#EEEEEE"/>
<CartesianGrid strokeDasharray="1 3" vertical={false} stroke="rgba(0,0,0,1.5)"/>
<XAxis {...Styles.xaxis} dataKey="time" interval={3}/>
<YAxis
{...Styles.yaxis}

View file

@ -1,8 +1,7 @@
import React from 'react';
import ExCard from './ExCard';
import AreaChartCard from "Components/Dashboard/components/DashboardList/NewDashModal/Examples/AreaChartCard";
import CustomMetricLineChart from "Components/Dashboard/Widgets/CustomMetricsWidgets/CustomMetricLineChart";
import LineChart from 'App/components/Charts/LineChart'
import {Styles} from "Components/Dashboard/Widgets/common";
interface Props {
@ -24,7 +23,7 @@ function ExampleTrend(props: Props) {
}
>
{/*<AreaChartCard data={props.data} label={props.data?.label}/>*/}
<CustomMetricLineChart
<LineChart
data={props.data}
colors={Styles.compareColors}
params={{

View file

@ -1,31 +1,57 @@
import React from 'react';
import { ItemMenu } from 'UI';
import { observer } from 'mobx-react-lite';
import { useStore } from "App/mstore";
import { useStore } from 'App/mstore';
import { ENTERPRISE_REQUEIRED } from 'App/constants';
import { Dropdown, Button } from 'antd';
import { EllipsisVertical } from 'lucide-react';
import { Icon } from 'UI';
interface Props {
editHandler: (isTitle: boolean) => 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 (
<ItemMenu
bold
items={menuItems}
/>
);
const menu = {
items: [
{
icon: <Icon name={'pencil'} />,
key: 'rename',
label: 'Rename',
onClick: () => editHandler(true),
},
{
icon: <Icon name={'users'} />,
key: 'visibility',
label: 'Visibility & Access',
onClick: editHandler,
},
{
icon: <Icon name={'trash'} />,
key: 'delete',
label: 'Delete',
onClick: deleteHandler,
},
{
icon: <Icon name={'pdf-download'} />,
key: 'download',
label: 'Download Report',
onClick: renderReport,
disabled: !isEnterprise,
tooltipTitle: ENTERPRISE_REQUEIRED,
},
],
};
return (
<Dropdown menu={menu}>
<Button id={'ignore-prop'} icon={<EllipsisVertical size={16} />} />
</Dropdown>
);
}
export default observer(DashboardOptions);

View file

@ -5,7 +5,7 @@ import { observer } from 'mobx-react-lite';
import React from 'react';
import colors from 'tailwindcss/colors';
import { gradientBox } from 'App/components/shared/SessionSearchField/AiSessionSearchField';
import { gradientBox } from 'App/components/shared/SessionFilters/AiSessionSearchField';
import aiSpinner from 'App/lottie/aiSpinner.json';
import { useStore } from 'App/mstore';
import { Icon, Input } from 'UI';
@ -41,7 +41,7 @@ const InputBox = observer(({ inModal }: { inModal?: boolean }) => {
<>
{!inModal ? <div className={"flex items-center mb-2 gap-2"}>
<Icon name={"sparkles"} size={16} />
<div className={"font-semibold"}>What would you like to visualize?</div>
<div className={"font-medium"}>What would you like to visualize?</div>
</div> : null}
<div style={gradientBox}>
<Input
@ -114,7 +114,7 @@ function Loader() {
return (
<div
className={
'flex items-center justify-center flex-col font-semibold text-xl min-h-80'
'flex items-center justify-center flex-col font-medium text-xl min-h-80'
}
>
<div style={{ width: 150, height: 150 }}>

View file

@ -98,7 +98,7 @@ function DashboardView(props: Props) {
const isSaas = /app\.openreplay\.com/.test(originStr);
return (
<Loader loading={loading}>
<div style={{maxWidth: '1360px', margin: 'auto'}}>
<div style={{maxWidth: '1360px', margin: 'auto'}} className={'rounded-lg shadow-sm overflow-hidden bg-white border'}>
{/* @ts-ignore */}
<DashboardHeader renderReport={props.renderReport} siteId={siteId} dashboardId={dashboardId}/>
{isSaas ? <AiQuery /> : null}

View file

@ -96,9 +96,9 @@ function AddMetric({ history, siteId, title, description }: IProps) {
<div className="py-4 border-t px-8 bg-white w-full flex items-center justify-between">
<div>
{'Selected '}
<span className="font-semibold">{selectedWidgetIds.length}</span>
<span className="font-medium">{selectedWidgetIds.length}</span>
{' out of '}
<span className="font-semibold">{metrics ? metrics.length : 0}</span>
<span className="font-medium">{metrics ? metrics.length : 0}</span>
</div>
<Button variant="primary" disabled={selectedWidgetIds.length === 0} onClick={onSave}>
Add Selected

View file

@ -141,9 +141,9 @@ function AddPredefinedMetric({ history, siteId, title, description }: IProps) {
<div className="py-4 border-t px-8 bg-white w-full flex items-center justify-between">
<div>
{'Selected '}
<span className="font-semibold">{selectedWidgetIds.length}</span>
<span className="font-medium">{selectedWidgetIds.length}</span>
{' out of '}
<span className="font-semibold">{totalMetricCount}</span>
<span className="font-medium">{totalMetricCount}</span>
</div>
<Button variant="primary" disabled={selectedWidgetIds.length === 0} onClick={onSave}>
Add Selected

View file

@ -1,10 +1,12 @@
import React from 'react';
import { useStore } from 'App/mstore';
import WidgetWrapperNew from 'Components/Dashboard/components/WidgetWrapper/WidgetWrapperNew';
import { Empty } from 'antd';
import { NoContent, Loader } from 'UI';
import { useObserver } from 'mobx-react-lite';
import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG';
import { observer } from 'mobx-react-lite';
import AddCardSection from '../AddCardSection/AddCardSection';
import cn from 'classnames';
import { Button, Popover, Tooltip } from 'antd'
import { PlusOutlined } from '@ant-design/icons'
import { Loader } from 'UI';
interface Props {
siteId: string;
@ -16,56 +18,85 @@ interface Props {
function DashboardWidgetGrid(props: Props) {
const { dashboardId, siteId } = props;
const { dashboardStore } = useStore();
const loading = useObserver(() => dashboardStore.isLoading);
const loading = dashboardStore.isLoading;
const dashboard = dashboardStore.selectedDashboard;
const list = useObserver(() => dashboard?.widgets);
const list = dashboard?.widgets;
return useObserver(() => (
return (
<Loader loading={loading}>
{
list?.length === 0 ? (
<div className="bg-white rounded-lg shadow-sm p-5">
<NoContent
show={true}
icon="no-metrics-chart"
title={
<div className="text-center">
<div className='mb-4'>
<AnimatedSVG name={ICONS.NO_RESULTS} size={60} />
</div>
<div className="text-xl font-medium mb-2">
There are no cards in this dashboard
</div>
<div className="text-base font-normal">
Create a card by clicking the "Add Card" button to visualize insights here.
</div>
</div>
}
{list?.length === 0 ? (
<div
className={'flex-1 flex justify-center items-center pt-10'}
style={{ minHeight: 620 }}
>
<AddCardSection />
</div>
) : (
<div
className="pb-10 px-4 pt-2 grid gap-2 rounded grid-cols-4 items-start "
id={props.id}
>
{list?.map((item: any, index: any) => (
<GridItem
key={item.widgetId}
item={item}
index={index}
dashboard={dashboard}
dashboardId={dashboardId}
siteId={siteId}
/>
</div>
) : (
<div className="grid gap-4 grid-cols-4 items-start pb-10" id={props.id}>
{list?.map((item: any, index: any) => (
<React.Fragment key={item.widgetId}>
<WidgetWrapperNew
index={index}
widget={item}
moveListItem={(dragIndex: any, hoverIndex: any) =>
dashboard?.swapWidgetPosition(dragIndex, hoverIndex)
}
dashboardId={dashboardId}
siteId={siteId}
grid="other"
showMenu={true}
isSaved={true}
/>
</React.Fragment>
))}
</div>
)
}
))}
</div>
)}
</Loader>
));
);
}
export default DashboardWidgetGrid;
function GridItem({ item, index, dashboard, dashboardId, siteId }: any) {
const [popoverOpen, setPopoverOpen] = React.useState(false);
const handleOpenChange = (open: boolean) => {
setPopoverOpen(open);
};
return (
<div
key={item.widgetId}
className={cn('col-span-' + item.config.col, 'group relative pl-6 pr-4 py-4 hover:bg-active-blue w-full rounded-xl')}
>
<WidgetWrapperNew
index={index}
widget={item}
moveListItem={(dragIndex: any, hoverIndex: any) =>
dashboard?.swapWidgetPosition(dragIndex, hoverIndex)
}
dashboardId={dashboardId}
siteId={siteId}
grid="other"
showMenu={true}
isSaved={true}
/>
<div
className={cn(
'invisible group-hover:visible ',
'absolute -left-2 top-1/2 -translate-y-1/2',
)}
>
<Popover
open={popoverOpen}
onOpenChange={handleOpenChange}
arrow={false}
overlayInnerStyle={{ padding: 0, borderRadius: '0.75rem' }}
content={<AddCardSection handleOpenChange={handleOpenChange} />}
trigger={'click'}
>
<Tooltip title="Add Card">
<Button icon={<PlusOutlined size={14} />} shape={'circle'} size={'small'} />
</Tooltip>
</Popover>
</div>
</div>
)
}
export default observer(DashboardWidgetGrid);

View file

@ -28,9 +28,9 @@ function ExcludeFilters(props: Props) {
};
return (
<div className={cn("flex items-center border-b", { 'p-5' : hasExcludes, 'px-2': !hasExcludes })}>
<div className={cn("flex items-center mb-2")}>
{filter.excludes.length > 0 ? (
<div className="flex items-center mb-2 flex-col">
<div className="flex items-center mb-2 bg-white rounded-xl flex-col px-4 py-2 w-full">
<div className="text-sm color-gray-medium mr-auto mb-2">EXCLUDES</div>
{filter.excludes.map((f: any, index: number) => (
<FilterItem

View file

@ -1,182 +1,250 @@
import React, { useEffect, useState } from 'react';
import FilterList from 'Shared/Filters/FilterList';
import { EventsList, FilterList } from 'Shared/Filters/FilterList';
import SeriesName from './SeriesName';
import cn from 'classnames';
import {observer} from 'mobx-react-lite';
import { observer } from 'mobx-react-lite';
import ExcludeFilters from './ExcludeFilters';
import AddStepButton from "Components/Dashboard/components/FilterSeries/AddStepButton";
import {Button, Space} from "antd";
import {ChevronDown, ChevronUp, Trash} from "lucide-react";
import { Button, Space } from 'antd';
import { ChevronDown, ChevronUp, Trash } from 'lucide-react';
const FilterCountLabels = observer((props: { filters: any, toggleExpand: any }) => {
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 <div className="flex items-center">
return (
<div className="flex items-center">
<Space>
{events > 0 && (
<Button type="primary" ghost size="small" onClick={props.toggleExpand}>
{`${events} Event${events > 1 ? 's' : ''}`}
</Button>
)}
{events > 0 && (
<Button
type="text"
size="small"
onClick={props.toggleExpand}
className='btn-series-event-count'
>
{`${events} Event${events > 1 ? 's' : ''}`}
</Button>
)}
{filters > 0 && (
<Button type="primary" ghost size="small" onClick={props.toggleExpand}>
{`${filters} Filter${filters > 1 ? 's' : ''}`}
</Button>
)}
{filters > 0 && (
<Button
type="text"
size="small"
onClick={props.toggleExpand}
className='btn-series-filter-count'
>
{`${filters} Filter${filters > 1 ? 's' : ''}`}
</Button>
)}
</Space>
</div>;
});
</div>
);
}
);
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 <div className={cn("border-b px-5 h-12 flex items-center relative", {hidden: props.hidden})}>
<Space className="mr-auto" size={30}>
<SeriesName
seriesIndex={props.seriesIndex}
name={props.series.name}
onUpdate={onUpdate}
/>
{!props.expanded &&
<FilterCountLabels filters={props.series.filter.filters} toggleExpand={props.toggleExpand}/>}
</Space>
<Space>
<Button onClick={props.onRemove}
size="small"
disabled={!props.canDelete}
icon={<Trash size={14}/>}/>
<Button onClick={props.toggleExpand}
size="small"
icon={props.expanded ? <ChevronUp size={16}/> : <ChevronDown size={16}/>}/>
</Space>
</div>;
})
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<string>;
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 (
<div
className={cn('px-4 ps-2 h-12 flex items-center relative bg-white border-gray-lighter border-t border-l border-r rounded-t-xl', {
hidden: props.hidden,
'rounded-b-xl': !props.expanded,
})}
>
<Space className="mr-auto" size={30}>
<SeriesName
seriesIndex={props.seriesIndex}
name={props.series.name}
onUpdate={onUpdate}
onChange={() => null}
/>
</Space>
<Space>
{!props.expanded && (
<FilterCountLabels
filters={props.series.filter.filters}
toggleExpand={props.toggleExpand}
/>
)}
<Button
onClick={props.onRemove}
size="small"
disabled={!props.canDelete}
icon={<Trash size={14} />}
type='text'
className={cn(
'btn-delete-series', 'disabled:hidden'
)}
/>
<Button
onClick={props.toggleExpand}
size="small"
icon={
props.expanded ? (
<ChevronUp size={16} />
) : (
<ChevronDown size={16} />
)
}
type='text'
className='btn-toggle-series'
/>
</Space>
</div>
);
}
);
interface Props {
seriesIndex: number;
series: any;
onRemoveSeries: (seriesIndex: any) => void;
canDelete?: boolean;
supportsEmpty?: boolean;
hideHeader?: boolean;
emptyMessage?: any;
observeChanges?: () => void;
excludeFilterKeys?: Array<string>;
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 (
<div className="border rounded-lg shadow-sm bg-white">
{canExclude && <ExcludeFilters filter={series.filter}/>}
return (
<div>
{canExclude && <ExcludeFilters filter={series.filter} />}
{!hideHeader && (
<FilterSeriesHeader hidden={hideHeader}
seriesIndex={seriesIndex}
series={series}
onRemove={props.onRemoveSeries}
canDelete={canDelete}
expanded={expanded}
toggleExpand={() => setExpanded(!expanded)}/>
{!hideHeader && (
<FilterSeriesHeader
hidden={hideHeader}
seriesIndex={seriesIndex}
onChange={observeChanges}
series={series}
onRemove={props.onRemoveSeries}
canDelete={canDelete}
expanded={expanded}
toggleExpand={() => setExpanded(!expanded)}
/>
)}
{!hideHeader && expandable && (
<Space
className="justify-between w-full py-2 cursor-pointer"
onClick={() => setExpanded(!expanded)}
>
<div>
{!expanded && (
<FilterCountLabels
filters={series.filter.filters}
toggleExpand={() => setExpanded(!expanded)}
/>
)}
</div>
<Button
size="small"
icon={
expanded ? <ChevronUp size={16} /> : <ChevronDown size={16} />
}
/>
</Space>
)}
{expandable && (
<Space className="justify-between w-full px-5 py-2 cursor-pointer" onClick={() => setExpanded(!expanded)}>
<div>{!expanded && <FilterCountLabels filters={series.filter.filters} toggleExpand={() => setExpanded(!expanded)}/>}</div>
<Button size="small"
icon={expanded ? <ChevronUp size={16}/> : <ChevronDown size={16}/>}/>
</Space>
)}
{expanded && (
<>
<div className="p-5">
{series.filter.filters.length > 0 ? (
<FilterList
filter={series.filter}
onUpdateFilter={onUpdateFilter}
onRemoveFilter={onRemoveFilter}
onChangeEventsOrder={onChangeEventsOrder}
supportsEmpty={supportsEmpty}
onFilterMove={onFilterMove}
excludeFilterKeys={excludeFilterKeys}
// actions={[
// expandable && (
// <Button onClick={() => setExpanded(!expanded)}
// size="small"
// icon={expanded ? <ChevronUp size={16}/> : <ChevronDown size={16}/>}/>
// )
// ]}
/>
) : (
<div className="color-gray-medium">{emptyMessage}</div>
)}
</div>
<div className="border-t h-12 flex items-center">
<div className="-mx-4 px-5">
<AddStepButton excludeFilterKeys={excludeFilterKeys} series={series}/>
</div>
</div>
</>
)}
</div>
);
{expanded ? (
<>
{removeEvents ? null :
<EventsList
filter={series.filter}
onUpdateFilter={onUpdateFilter}
onRemoveFilter={onRemoveFilter}
onChangeEventsOrder={onChangeEventsOrder}
supportsEmpty={supportsEmpty}
onFilterMove={onFilterMove}
excludeFilterKeys={excludeFilterKeys}
onAddFilter={onAddFilter}
mergeUp={!hideHeader}
mergeDown
cannotAdd={isHeatmap}
excludeCategory={excludeCategory}
/>
}
<FilterList
filter={series.filter}
onUpdateFilter={onUpdateFilter}
onRemoveFilter={onRemoveFilter}
onChangeEventsOrder={onChangeEventsOrder}
supportsEmpty={supportsEmpty}
onFilterMove={onFilterMove}
excludeFilterKeys={excludeFilterKeys}
onAddFilter={onAddFilter}
mergeUp={!removeEvents}
excludeCategory={excludeCategory}
/>
</>
) : null}
</div>
);
}
export default observer(FilterSeries);

View file

@ -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<any>(null)
const [editing, setEditing] = useState(false);
const [name, setName] = useState(props.name);
const ref = useRef<any>(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 (
<div className="flex items-center">
{ editing ? (
{editing ? (
<Input
ref={ ref }
ref={ref}
name="name"
value={name}
// readOnly={!editing}
onChange={write}
onBlur={onBlur}
onFocus={() => 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'
/>
) : (
<div className="text-base h-8 flex items-center border-transparent">{name && name.trim() === '' ? 'Series ' + (seriesIndex + 1) : name }</div>
)}
<div className="ml-3 cursor-pointer " onClick={() => setEditing(true)}>
<Tooltip title='Rename' placement='bottom'>
<Icon name="pencil" size="14" />
<Tooltip title="Click to rename">
<div
className="text-lg font-medium h-8 flex items-center border-transparent p-2 hover:bg-teal/10 cursor-pointer rounded-lg btn-input-rename-series"
onClick={() => setEditing(true)}
data-event='input-rename-series'
>
{name && name.trim() === '' ? 'Series ' + (seriesIndex + 1) : name}
</div>
</Tooltip>
</div>
)}
</div>
);
}

View file

@ -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<any>('');
useEffect(() => {
const t = TYPES.find((i) => i.slug === type);
setCard(t || {});
}, [type]);
return (
<Tooltip title={<div className="capitalize">{card.title}</div>}>
<Avatar src={card.icon && <Icon name={card.icon} size="16" color="tealx" />} size="small" className="bg-tealx-lightest mr-2" />
<Tooltip title={<div className="capitalize">{TYPE_NAMES[type]}</div>}>
<Avatar
src={
<Icon
name={TYPE_ICONS[type]}
size="16"
color="tealx"
strokeColor="tealx"
/>
}
size="default"
className="bg-tealx-lightest text-tealx mr-2 cursor-default avatar-card-list-item"
/>
</Tooltip>
);
}
const MetricListItem: React.FC<Props> = ({
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<Props> = ({
cancelText: 'No',
onOk: async () => {
await metricStore.delete(metric);
}
},
});
}
if (key === 'rename') {
@ -132,29 +152,34 @@ const MetricListItem: React.FC<Props> = ({
} 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: <EditOutlined />,
label: "Rename"
label: 'Rename',
},
{
key: "delete",
key: 'delete',
icon: <DeleteOutlined />,
label: "Delete"
}
]
label: 'Delete',
},
];
switch (renderColumn) {
case 'title':
return (
<>
<div className="flex items-center cursor-pointer" onClick={onItemClick}>
<div
className="flex items-center cursor-pointer"
onClick={inLibrary ? undefined : onItemClick}
>
<MetricTypeIcon type={metric.metricType} />
<div className="capitalize-first link block">{metric.name}</div>
<div className={cn('capitalize-first block', inLibrary ? '' : 'link')}>{metric.name}</div>
</div>
{renderModal()}
</>
@ -165,7 +190,11 @@ const MetricListItem: React.FC<Props> = ({
return (
<div className="flex items-center">
<Tag className="rounded-lg" bordered={false}>
{metric.isPublic ? <TeamOutlined className="mr-2" /> : <LockOutlined className="mr-2" />}
{metric.isPublic ? (
<TeamOutlined className="mr-2" />
) : (
<LockOutlined className="mr-2" />
)}
{metric.isPublic ? 'Team' : 'Private'}
</Tag>
</div>
@ -176,13 +205,18 @@ const MetricListItem: React.FC<Props> = ({
case 'options':
return (
<>
<div className='flex justify-end'>
<Dropdown
menu={{ items: menuItems, onClick: onMenuClick }}
trigger={['click']}
>
<Button type="text" icon={<MoreOutlined />} />
</Dropdown>
<div className="flex justify-end pr-4">
<Dropdown
menu={{ items: menuItems, onClick: onMenuClick }}
trigger={['click']}
>
<Button
id={'ignore-prop'}
icon={<EllipsisVertical size={16} />}
className="btn-cards-list-item-more-options"
type="text"
/>
</Dropdown>
</div>
{renderModal()}
</>

View file

@ -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<string | null>(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<string, any> = {
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 (
<Segmented onChange={onChange} options={options} value={selectedCategory} />
);
}
export default observer(MetricTypeSelector);

View file

@ -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 (
<div>
<div className='flex items-center justify-between px-6'>
<div className='flex items-baseline mr-3'>
<PageTitle title='Cards' className='' />
</div>
<div className='ml-auto flex items-center'>
<Button type='primary'
onClick={() => setShowAddCardModal(true)}
icon={<PlusOutlined />}
>Create Card</Button>
<div className='ml-4 w-1/4' style={{ minWidth: 300 }}>
<MetricsSearch />
</div>
</div>
</div>
useEffect(() => {
metricStore.updateKey('sort', { by: 'desc' });
}, [metricStore]);
const handleMenuClick = ({ key }) => {
metricStore.updateKey('filter', { ...filter, type: key });
};
<div className='border-y px-6 py-1 mt-2 flex items-center w-full justify-between'>
<div className='items-center flex gap-4'>
<Select
options={[{ label: 'All Types', value: 'all' }, ...DROPDOWN_OPTIONS]}
name='type'
defaultValue={filter.type}
onChange={({ value }) =>
metricStore.updateKey('filter', { ...filter, type: value.value })
}
plain={true}
isSearchable={true}
/>
const menu = (
<Menu onClick={handleMenuClick}>
{options.map((option) => (
<Menu.Item key={option.key}>{option.label}</Menu.Item>
))}
</Menu>
);
<DashboardDropdown
plain={false}
onChange={(value: any) =>
metricStore.updateKey('filter', { ...filter, dashboard: value })
}
/>
</div>
<div className='flex items-center gap-6'>
<ListViewToggler />
{/* <Toggler
label='My Cards'
checked={filter.showMine}
name='test'
className='font-medium mr-2'
onChange={() =>
metricStore.updateKey('filter', { ...filter, showMine: !filter.showMine })
}
/> */}
</div>
<NewDashboardModal
onClose={() => setShowAddCardModal(false)}
open={showAddCardModal}
isCreatingNewCard={true}
/>
</div>
return (
<div>
<div className="flex items-center justify-between pr-4">
<div className="flex items-center gap-2 ps-4">
<PageTitle title="Cards" className="cursor-default" />
<Space>
<Dropdown overlay={menu} trigger={['click']} className="">
<Button type="text" size="small" className="mt-1">
{options.find(opt => opt.key === filter.type)?.label || 'Select Type'}
<DownOutlined />
</Button>
</Dropdown>
</Space>
</div>
);
<div className="ml-auto flex items-center gap-3">
<Popover
arrow={false}
overlayInnerStyle={{ padding: 0, borderRadius: '0.75rem' }}
content={<AddCardSection fit inCards />}
trigger="click"
>
<Button
type="primary"
icon={<PlusOutlined />}
className="btn-create-card"
>
Create Card
</Button>
</Popover>
<Space>
<MetricsSearch />
</Space>
</div>
</div>
</div>
);
}
export default observer(MetricViewHeader);
function DashboardDropdown({ onChange, plain = false }: { plain?: boolean; onChange: any }) {
const { dashboardStore, metricStore } = useStore();
const dashboardOptions = dashboardStore.dashboards.map((i: any) => ({
key: i.id,
label: i.name,
value: i.dashboardId
}));
return (
<Select
isSearchable={true}
placeholder='Filter by Dashboard'
plain={plain}
options={dashboardOptions}
value={metricStore.filter.dashboard}
onChange={({ value }: any) => onChange(value)}
isMulti={true}
color='black'
/>
);
}
function ListViewToggler() {
const { metricStore } = useStore();
const listView = useObserver(() => metricStore.listView);
return (
<div className='flex items-center'>
<Segmented
size='small'
options={[
{
label: <div className={'flex items-center gap-2'}>
<Icon name={'list-alt'} color={'inherit'} />
<div>List</div>
</div>,
value: 'list'
},
{
label: <div className={'flex items-center gap-2'}>
<Icon name={'grid'} color={'inherit'} />
<div>Grid</div>
</div>,
value: 'grid'
}
]}
onChange={(val) => {
metricStore.updateKey('listView', val === 'list')
}}
value={listView ? 'list' : 'grid'}
/>
</div>
);
}

View file

@ -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) {
</Modal.Header>
<Modal.Content className="p-4 pb-20">
<div className="border">
<MetricsList siteId={siteId} onSelectionChange={onSelectionChange} />
<MetricsList siteId={siteId} onSelectionChange={onSelectionChange} inLibrary />
</div>
</Modal.Content>
<Modal.Footer>
@ -61,12 +62,11 @@ export default observer(MetricsLibraryModal);
function MetricSearch({ onChange }: any) {
return (
<div className="relative">
<Icon name="search" className="absolute top-0 bottom-0 ml-2 m-auto" size="16" />
<input
<Input.Search
name="dashboardsSearch"
className="bg-white p-2 border border-borderColor-gray-light-shade rounded w-full pl-10"
placeholder="Filter by title or owner"
onChange={onChange}
className={'rounded-lg'}
/>
</div>
);

View file

@ -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<number>) => void;
toggleAll?: (e: any) => void;
disableSelection?: boolean;
allSelected?: boolean;
existingCardIds?: number[];
showOwn?: boolean;
toggleOwn: () => void;
inLibrary?: boolean;
}
const ListView: React.FC<Props> = (props: Props) => {
@ -32,8 +37,7 @@ const ListView: React.FC<Props> = (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: 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: Props) => {
const columns = [
{
title: (
<div className="flex items-center">
{!disableSelection && (
<Checkbox
name="slack"
className="mr-4"
checked={allSelected}
onClick={toggleAll}
/>
)}
<span>Title</span>
</div>
),
title: 'Title',
dataIndex: 'name',
key: 'title',
className: 'cap-first',
className: 'cap-first pl-4',
sorter: true,
width: '25%',
render: (text: string, metric: Metric) => (
<MetricListItem
key={metric.metricId}
metric={metric}
siteId={siteId}
disableSelection={disableSelection}
selected={selectedList.includes(metric.metricId)}
toggleSelection={(e: any) => {
e.stopPropagation();
toggleSelection && toggleSelection(metric.metricId);
}}
inLibrary={inLibrary}
disableSelection={!inLibrary}
renderColumn="title"
/>
)
@ -121,7 +110,7 @@ const ListView: React.FC<Props> = (props: Props) => {
dataIndex: 'owner',
key: 'owner',
className: 'capitalize',
width: '30%',
width: '25%',
sorter: true,
render: (text: string, metric: Metric) => (
<MetricListItem
@ -137,7 +126,7 @@ const ListView: React.FC<Props> = (props: Props) => {
dataIndex: 'lastModified',
key: 'lastModified',
sorter: true,
width: '16.67%',
width: '25%',
render: (text: string, metric: Metric) => (
<MetricListItem
key={metric.metricId}
@ -147,35 +136,27 @@ const ListView: React.FC<Props> = (props: Props) => {
/>
)
},
// {
// title: 'Visibility',
// dataIndex: 'visibility',
// key: 'visibility',
// width: '10%',
// render: (text: string, metric: Metric) => (
// <MetricListItem
// key={metric.metricId}
// metric={metric}
// siteId={siteId}
// renderColumn="visibility"
// />
// )
// },
{
title: '',
key: 'options',
className: 'text-right',
width: '5%',
render: (text: string, metric: Metric) => (
<MetricListItem
key={metric.metricId}
metric={metric}
siteId={siteId}
renderColumn="options"
/>
)
}
];
if (!inLibrary) {
columns.push({
title: '',
key: 'options',
className: 'text-right',
width: '5%',
render: (text: string, metric: Metric) => (
<MetricListItem
key={metric.metricId}
metric={metric}
siteId={siteId}
renderColumn="options"
/>
)
})
} else {
columns.forEach(col => {
col.width = '31%';
})
}
return (
<Table
@ -183,26 +164,20 @@ const ListView: React.FC<Props> = (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={() => (
// <div className="flex justify-end">
// <Checkbox name="slack" checked={allSelected} onClick={toggleAll}>
// Select All
// </Checkbox>
// </div>
// )}
pagination={{
current: pagination.current,
pageSize: pagination.pageSize,
@ -211,7 +186,8 @@ const ListView: React.FC<Props> = (props: Props) => {
className: 'px-4',
showLessItems: true,
showTotal: () => totalMessage,
showQuickJumper: true
size: 'small',
simple: 'true',
}}
/>
);

View file

@ -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<any>([]);
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 (
<Loader loading={loading}>
<NoContent
show={lenth === 0}
show={length === 0}
title={
<div className="flex flex-col items-center justify-center">
<AnimatedSVG name={ICONS.NO_CARDS} size={60} />
<div className="text-center mt-4 text-lg font-medium">
{metricsSearch !== '' ? 'No matching results' : 'You haven\'t created any cards yet'}
{metricsSearch !== ''
? 'No matching results'
: "You haven't created any cards yet"}
</div>
</div>
}
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 ? (
<ListView
disableSelection={!onSelectionChange}
siteId={siteId}
list={cards}
inLibrary={inLibrary}
selectedList={selectedMetrics}
existingCardIds={existingCardIds}
toggleSelection={toggleMetricSelection}
allSelected={cards.length === selectedMetrics.length}
showOwn={showOwn}
toggleOwn={toggleOwn}
toggleAll={({ target: { checked, name } }) =>
setSelectedMetrics(checked ? cards.map((i: any) => i.metricId).slice(0, 30 - existingCardIds!.length) : [])
}
@ -87,8 +117,8 @@ function MetricsList({
<div className="w-full flex items-center justify-between py-4 px-6 border-t">
<div className="">
Showing{' '}
<span className="font-semibold">{Math.min(cards.length, metricStore.pageSize)}</span> out
of <span className="font-semibold">{cards.length}</span> cards
<span className="font-medium">{Math.min(cards.length, metricStore.pageSize)}</span> out
of <span className="font-medium">{cards.length}</span> cards
</div>
<Pagination
page={metricStore.page}

View file

@ -27,7 +27,7 @@ function MetricsSearch() {
value={query}
allowClear
name="metricsSearch"
className="w-full"
className="w-full input-search-card"
placeholder="Filter by title or owner"
onChange={write}
/>

View file

@ -11,7 +11,9 @@ function MetricsView({ siteId }: Props) {
return (
<div style={{ maxWidth: '1360px', margin: 'auto' }} className="bg-white rounded-lg shadow-sm pt-4 border">
<MetricViewHeader siteId={siteId} />
<div className='pt-3'>
<MetricsList siteId={siteId} />
</div>
</div>
);
}

View file

@ -64,8 +64,8 @@ function SessionsModal(props: Props) {
<div className='w-full flex items-center justify-between p-4 absolute bottom-0 bg-white'>
<div className='text-disabled-text'>
Showing <span
className='font-semibold'>{Math.min(length, 10)}</span> out of{' '}
<span className='font-semibold'>{total}</span> Issues
className='font-medium'>{Math.min(length, 10)}</span> out of{' '}
<span className='font-medium'>{total}</span> Issues
</div>
<Pagination
page={page}

View file

@ -0,0 +1,35 @@
import React from 'react'
import { Progress, Button } from 'antd';
import { Icon } from 'UI';
function LongLoader({ onClick }: { onClick: () => void }) {
return (
<div className={'flex flex-col gap-2 items-center justify-center'} style={{ height: 240 }}>
<div className={'font-semibold flex gap-2 items-center'}>
<Icon name="info-circle" size={16} />
<div>Processing data...</div>
</div>
<div style={{ width: 180 }}>
<Progress
percent={40}
strokeColor={{
'0%': '#394EFF',
'100%': '#394EFF'
}}
showInfo={false}
/>
</div>
<div>
This is taking longer than expected.
</div>
<div>
Use sample data to speed up query and get a faster response.
</div>
<Button onClick={onClick}>
Use Sample Data
</Button>
</div>
)
}
export default LongLoader;

View file

@ -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<any>();
const isMounted = useIsMounted();
const [data, setData] = useState<any>(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<any>();
const isMounted = useIsMounted();
const [compData, setCompData] = useState<any>(null);
const [enabledRows, setEnabledRows] = useState<string[]>([]);
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 <FunnelWidget metric={metric} data={data} isWidget={isSaved || isTemplate}/>;
}
if (metricType === 'predefined' || metricType === ERRORS) {
const defaultMetric = metric.data.chart && metric.data.chart.length === 0 ? metricWithData : metric;
return <WidgetPredefinedChart isTemplate={isTemplate} metric={defaultMetric} data={data}
predefinedKey={metric.metricOf}/>;
}
if (metricType === TIMESERIES) {
if (viewType === 'lineChart') {
return (
<CustomMetricLineChart
data={data}
colors={colors}
params={params}
onClick={onChartClick}
label={metric.metricOf === 'sessionCount' ? 'Number of Sessions' : 'Number of Users'}
/>
);
} else if (viewType === 'progress') {
return (
<CustomMetricPercentage
data={data[0]}
colors={colors}
params={params}
/>
);
}
}
if (metricType === TABLE) {
if (metricOf === FilterKey.SESSIONS) {
return (
<CustomMetricTableSessions
metric={metric}
data={data}
isTemplate={isTemplate}
isEdit={!isSaved && !isTemplate}
/>
);
}
if (metricOf === FilterKey.ERRORS) {
return (
<CustomMetricTableErrors
metric={metric}
data={data}
// isTemplate={isTemplate}
isEdit={!isSaved && !isTemplate}
/>
);
}
if (viewType === TABLE) {
return (
<SessionsBy
metric={metric}
data={data[0]}
onClick={onChartClick}
isTemplate={isTemplate}
/>
);
} else if (viewType === 'pieChart') {
return (
<CustomMetricPieChart
metric={metric}
data={data[0]}
colors={colors}
// params={params}
onClick={onChartClick}
/>
);
}
}
if (metricType === HEATMAP) {
if (!props.isPreview) {
return metric.thumbnail ? (
<div style={{height: '229px', overflow: 'hidden', marginBottom: '10px'}}>
<img src={metric.thumbnail} alt='clickmap thumbnail'/>
</div>
) : (
<div className="flex items-center relative justify-center" style={{ height: '229px'}}>
<Icon name="info-circle" className="mr-2" size="14" />
No data available for the selected period.
</div>
);
}
return (
<ClickMapCard />
);
}
if (metricType === INSIGHTS) {
return <InsightsCard data={data} />;
}
if (metricType === USER_PATH && data && data.links) {
// return <PathAnalysis data={data}/>;
return <SankeyChart
height={props.isPreview ? 500 : 240}
data={data}
onChartClick={(filters: any) => {
dashboardStore.drillDownFilter.merge({filters, page: 1});
}}/>;
}
if (metricType === RETENTION) {
if (viewType === 'trend') {
return (
<CustomMetricLineChart
data={data}
colors={colors}
params={params}
onClick={onChartClick}
/>
);
} else if (viewType === 'cohort') {
return (
<CohortCard data={data[0]}/>
);
}
}
return <div>Unknown metric type</div>;
};
return (
<div ref={ref}>
<Loader loading={loading} style={{height: `240px`}}>
<div style={{minHeight: 240}}>{renderChart()}</div>
</Loader>
</div>
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 (
<FunnelTable data={data} compData={compData} />
)
}
if (viewType === 'metric') {
const values: {
value: number;
compData?: number;
series: string;
valueLabel?: string;
}[] = [
{
value: data.funnel.totalConversionsPercentage,
compData: compData
? compData.funnel.totalConversionsPercentage
: undefined,
series: 'Dynamic',
valueLabel: '%'
},
];
return (
<BugNumChart
values={values}
inGrid={!props.isPreview}
colors={colors}
hideLegend
onClick={onChartClick}
label={
'Conversion'
}
/>
);
}
return (
<FunnelWidget
metric={_metric}
data={data}
compData={compData}
isWidget={isSaved || isTemplate}
/>
);
}
if (metricType === 'predefined' || metricType === ERRORS) {
const defaultMetric =
_metric.data.chart && _metric.data.chart.length === 0
? metricWithData
: metric;
return (
<WidgetPredefinedChart
isTemplate={isTemplate}
metric={defaultMetric}
data={data}
predefinedKey={_metric.metricOf}
/>
);
}
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 (
<div className='pt-3'>
<LineChart
chartName={_metric.name}
inGrid={!props.isPreview}
data={chartData}
compData={compDataCopy}
onClick={onChartClick}
label={
_metric.metricOf === 'sessionCount'
? 'Number of Sessions'
: 'Number of Users'
}
/>
</div>
);
}
if (viewType === 'areaChart') {
return (
<div className='pt-3'>
<LineChart
isArea
chartName={_metric.name}
data={chartData}
inGrid={!props.isPreview}
onClick={onChartClick}
label={
_metric.metricOf === 'sessionCount'
? 'Number of Sessions'
: 'Number of Users'
}
/>
</div>
);
}
if (viewType === 'barChart') {
return (
<div className='pt-3'>
<BarChart
inGrid={!props.isPreview}
data={chartData}
compData={compDataCopy}
params={params}
colors={colors}
onClick={onChartClick}
label={
_metric.metricOf === 'sessionCount'
? 'Number of Sessions'
: 'Number of Users'
}
/>
</div>
);
}
if (viewType === 'progressChart') {
return (
<ColumnChart
inGrid={!props.isPreview}
horizontal
data={chartData}
compData={compDataCopy}
params={params}
colors={colors}
onSeriesFocus={onFocus}
label={
_metric.metricOf === 'sessionCount'
? 'Number of Sessions'
: 'Number of Users'
}
/>
);
}
if (viewType === 'pieChart') {
return (
<div className='pt-3'>
<PieChart
inGrid={!props.isPreview}
data={chartData}
onSeriesFocus={onFocus}
label={
_metric.metricOf === 'sessionCount'
? 'Number of Sessions'
: 'Number of Users'
}
/>
</div>
);
}
if (viewType === 'progress') {
return (
<CustomMetricPercentage
inGrid={!props.isPreview}
data={data[0]}
colors={colors}
params={params}
label={
_metric.metricOf === 'sessionCount'
? 'Number of Sessions'
: 'Number of Users'
}
/>
);
}
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 (
<BugNumChart
values={values}
inGrid={!props.isPreview}
colors={colors}
onSeriesFocus={onFocus}
label={
_metric.metricOf === 'sessionCount'
? 'Number of Sessions'
: 'Number of Users'
}
/>
);
}
}
if (metricType === TABLE) {
if (metricOf === FilterKey.SESSIONS) {
return (
<CustomMetricTableSessions
metric={_metric}
data={data}
isTemplate={isTemplate}
isEdit={!isSaved && !isTemplate}
/>
);
}
if (metricOf === FilterKey.ERRORS) {
return (
<CustomMetricTableErrors
metric={_metric}
data={data}
// isTemplate={isTemplate}
isEdit={!isSaved && !isTemplate}
/>
);
}
if (viewType === TABLE) {
return (
<SessionsBy
metric={_metric}
data={data}
onClick={onChartClick}
isTemplate={isTemplate}
/>
);
}
}
if (metricType === HEATMAP) {
if (!props.isPreview) {
return _metric.thumbnail ? (
<div
style={{
height: '229px',
overflow: 'hidden',
marginBottom: '10px',
}}
>
<img src={_metric.thumbnail} alt="clickmap thumbnail" />
</div>
) : (
<div
className="flex items-center relative justify-center"
style={{ height: '229px' }}
>
<Icon name="info-circle" className="mr-2" size="14" />
No data available for the selected period.
</div>
);
}
return <ClickMapCard />;
}
if (metricType === INSIGHTS) {
return <InsightsCard data={data} />;
}
if (metricType === USER_PATH && data && data.links) {
const usedData = _metric.hideExcess ? filterMinorPaths(data) : data;
return (
<SankeyChart
height={props.isPreview ? 500 : 240}
data={usedData}
onChartClick={(filters: any) => {
dashboardStore.drillDownFilter.merge({ filters, page: 1 });
}}
/>
);
}
if (metricType === RETENTION) {
if (viewType === 'trend') {
return (
<LineChart
data={data}
colors={colors}
params={params}
onClick={onChartClick}
/>
);
} else if (viewType === 'cohort') {
return <CohortCard data={data[0]} />;
}
}
console.log('Unknown metric type', metricType);
return <div>Unknown metric type</div>;
}, [data, compData, enabledRows, _metric]);
const showTable = _metric.metricType === TIMESERIES && (props.isPreview || _metric.viewType === TABLE)
return (
<div ref={ref}>
{loading ? stale ? <LongLoader onClick={loadSample} /> : <Loader loading={loading} style={{ height: `240px` }} /> : (
<div style={{ minHeight: props.isPreview ? undefined : 240 }}>
{renderChart()}
{showTable ? (
<WidgetDatatable
compData={compData}
inBuilder={props.isPreview}
defaultOpen={true}
data={data}
enabledRows={enabledRows}
setEnabledRows={setEnabledRows}
metric={_metric}
/>
) : null}
</div>
)}
</div>
);
}
export default observer(WidgetChart);

View file

@ -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: <span className="font-medium">Series</span>,
dataIndex: 'seriesName',
key: 'seriesName',
sorter: (a, b) => a.seriesName.localeCompare(b.seriesName),
fixed: 'left',
},
{
title: <span className="font-medium">Avg.</span>,
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<TableProps['columns']>(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<string, any>[] = [];
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: <span className={'font-medium'}>{name}</span>,
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 (
<div className={cn('relative -mx-4 px-2', showTable ? '' : '')}>
{!isTableOnlyMode && (
<div className="flex gap-2">
<Divider
style={{
borderColor: showTable ? '#efefef' : 'transparent',
borderStyle: 'dashed',
}}
variant="dashed"
>
<Button
icon={showTable ? <EyeOff size={16} /> : <Eye size={16} />}
size={'small'}
type={'default'}
onClick={() => setShowTable(!showTable)}
className="btn-show-hide-table"
>
{showTable ? 'Hide Table' : 'Show Table'}
</Button>
</Divider>
</div>
)}
{showTable || isTableOnlyMode ? (
<div className={'relative pb-2'}>
<Table
columns={tableProps}
dataSource={tableData}
pagination={false}
rowSelection={rowSelection}
size={'small'}
scroll={{ x: 'max-content' }}
/>
{props.inBuilder ? (
<TableExporter
tableData={tableData}
tableColumns={tableProps}
filename={props.metric.name}
/>
) : null}
</div>
) : null}
</div>
);
}
export default WidgetDatatable;

View file

@ -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 (
<Dropdown menu={menuProps} trigger={['click']}>
<Button type='text' variant='text' size='small' className='btn-granularity'>
<span>{selected}</span>
<DownOutlined />
</Button>
</Dropdown>
)
}
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;

View file

@ -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 (
<Space>
{label && <span className="mr-1 color-gray-medium">{label}</span>}
<SelectDateRange
period={period}
onChange={onChangePeriod}
right={true}
isAnt={true}
useButtonStyle={true}
const updateInstComparison = (range: [start: string, end?: string] | null) => {
metricStore.instance.setComparisonRange(range);
metricStore.instance.updateKey('hasChanged', true)
}
return (
<Space>
{label && <span className="mr-1 color-gray-medium">{label}</span>}
<SelectDateRange
period={period}
onChange={onChangePeriod}
isAnt={true}
useButtonStyle={true}
/>
{hasGranularSettings ? (
<>
{hasGranularity ? (
<RangeGranularity
period={period}
density={density}
onDensityChange={onDensityChange}
/>
</Space>
);
) : null}
{hasComparison ?
<SelectDateRange
period={period}
compPeriod={compPeriod}
onChange={onChangePeriod}
onChangeComparison={onChangeComparison}
right={true}
isAnt={true}
useButtonStyle={true}
comparison={true}
updateInstComparison={updateInstComparison}
/>
: null}
</>
) : null}
</Space>
);
}
export default WidgetDateRange;
export default observer(WidgetDateRange);

View file

@ -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 ? <PredefinedMessage /> : (
return isPredefined ? (
<PredefinedMessage />
) : (
<Space direction="vertical" className="w-full">
<AdditionalFilters />
{/*{!hasFilters && (<DefineSteps metric={metric} excludeFilterKeys={excludeFilterKeys} />)}*/}
{/*{hasFilters && (<FilterSection metric={metric} excludeFilterKeys={excludeFilterKeys} />)}*/}
<FilterSection metric={metric} excludeFilterKeys={excludeFilterKeys} />
<FilterSection
layout={layout}
metric={metric}
excludeCategory={excludeCategory}
excludeFilterKeys={excludeFilterKeys}
/>
</Space>
);
}
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<Record<number, boolean>>({});
function DefineSteps({ metric, excludeFilterKeys }: any) {
return (
<div className="px-4 py-2 bg-white rounded-lg shadow-sm flex items-center">
<Typography.Text strong>Filter</Typography.Text>
<AddStepButton excludeFilterKeys={excludeFilterKeys} series={metric.series[0]} />
</div>
);
}
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<number, boolean> = {};
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) => (
<div className="mb-2" key={series.name}>
<div className="mb-2 rounded-xl" key={series.name}>
<FilterSeries
isHeatmap={isHeatMap}
canExclude={isPathAnalysis}
supportsEmpty={!isClickMap && !isPathAnalysis}
removeEvents={isPathAnalysis}
supportsEmpty={!isHeatMap && !isPathAnalysis}
excludeFilterKeys={excludeFilterKeys}
excludeCategory={excludeCategory}
observeChanges={() => 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}
/>
</div>
))
}
{!isSingleSeries && canAddSeries && (
<Card styles={{ body: { padding: '4px' } }} className="rounded-full shadow-sm">
))}
{isSingleSeries ? null :
<div className={'mx-auto flex items-center gap-2 w-fit'}>
<Tooltip title={canAddSeries ? '' : 'Maximum of 3 series reached.'}>
<Button
onClick={() => {
if (!canAddSeries) return;
metric.addSeries();
}}
disabled={!canAddSeries}
size="small"
type="primary"
icon={<PlusIcon size={16} />}
>
Add Series
</Button>
</Tooltip>
<Button
type="link"
onClick={() => {
metric.addSeries();
}}
disabled={!canAddSeries}
size="small"
className="block w-full"
size={'small'}
type={'text'}
icon={
<ChevronUp
size={16}
className={allCollapsed ? 'rotate-180' : ''}
/>
}
onClick={allCollapsed ? expandAll : collapseAll}
>
<Space>
<AudioWaveform size={16} />
New Chart Series
</Space>
{allCollapsed ? 'Expand' : 'Collapse'} All
</Button>
</Card>
)}
</div>
}
</>
);
});
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 (
<Card styles={{ body: { padding: '20px 20px' } }}>
<Card styles={{ body: { padding: '20px 20px' } }} className="rounded-lg">
<Form.Item>
<Space>
<Select
name="startType"
options={[
{ value: 'start', label: 'With Start Point' },
{ value: 'end', label: 'With End Point' }
]}
defaultValue={metric.startType}
onChange={writeOption}
placeholder="All Issues"
/>
<span className="mx-3">showing</span>
<Select
name="metricValue"
options={metricValueOptions}
value={metric.metricValue}
isMulti={true}
onChange={writeOption}
placeholder="All Issues"
/>
</Space>
</Form.Item>
<Form.Item label={metric.startType === 'start' ? 'Start Point' : 'End Point'} className="mb-0">
<FilterItem
hideDelete
filter={metric.startPoint}
allowedFilterKeys={[FilterKey.LOCATION, FilterKey.CLICK, FilterKey.INPUT, FilterKey.CUSTOM]}
onUpdate={val => metric.updateStartPoint(val)}
onRemoveFilter={() => {
}}
/>
<div className="flex flex-wrap gap-2 items-center justify-start">
<span className="font-medium">User journeys with: </span>
<div className="flex sm:flex-wrap lg:flex-nowrap gap-2 items-start">
<Select
className="w-36 rounded-xl"
name="startType"
options={[
{ value: 'start', label: 'Start Point' },
{ value: 'end', label: 'End Point' },
]}
defaultValue={metric.startType || 'start'}
onChange={(value) => writeOption({ name: 'startType', value })}
placeholder="Select Start Type"
size="small"
/>
<span className="text-neutral-400 mt-.5">showing</span>
<Select
mode="multiple"
className="min-w-36 rounded-xl"
allowClear
name="metricValue"
options={metricValueOptions}
value={metric.metricValue || []}
onChange={(value) => writeOption({ name: 'metricValue', value })}
placeholder="Select Metrics"
size="small"
/>
</div>
</div>
</Form.Item>
<div className="flex items-center">
<Form.Item
label={
metric.startType === 'start'
? 'Specify Start Point'
: 'Specify End Point'
}
className="m0-0 font-medium p-0 h-fit"
>
<span className="font-normal">
<FilterItem
hideDelete
filter={metric.startPoint}
allowedFilterKeys={[
FilterKey.LOCATION,
FilterKey.CLICK,
FilterKey.INPUT,
FilterKey.CUSTOM,
]}
onUpdate={(val) => metric.updateStartPoint(val)}
onRemoveFilter={() => {}}
/>
</span>
</Form.Item>
</div>
</Card>
);
});
@ -184,6 +278,7 @@ const InsightsFilter = observer(({ metric, writeOption }: any) => {
onChange={writeOption}
isMulti
placeholder="All Categories"
allowClear
/>
</Space>
</Form.Item>
@ -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 && <PathAnalysisFilter metric={metric} writeOption={writeOption} />}
{metric.metricType === INSIGHTS && <InsightsFilter metric={metric} writeOption={writeOption} />}
{metric.metricType === USER_PATH && (
<PathAnalysisFilter metric={metric} writeOption={writeOption} />
)}
{metric.metricType === INSIGHTS && (
<InsightsFilter metric={metric} writeOption={writeOption} />
)}
</>
);
});
const PredefinedMessage = () => (
<Alert message="Drilldown or filtering isn't supported on this legacy card." type="warning" showIcon closable
className="border-transparent rounded-lg" />
<Alert
message="Drilldown or filtering isn't supported on this legacy card."
type="warning"
showIcon
closable
className="border-transparent rounded-lg"
/>
);

View file

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

View file

@ -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<any>(null)
const [editing, setEditing] = useState(false);
const [name, setName] = useState(props.name);
const ref = useRef<any>(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 (
<div className="flex items-center">
{ editing ? (
{editing ? (
<Input
ref={ ref }
ref={ref}
name="name"
value={name}
onChange={write}
onBlur={() => onBlur()}
onKeyDown={onKeyDown}
onFocus={() => setEditing(true)}
maxLength={80}
className="bg-white text-2xl ps-2 rounded-lg h-8"
/>
) : (
// @ts-ignore
<Tooltip delay={200} title="Double click to edit" disabled={!canEdit}>
<Tooltip delay={200} title="Click to edit" disabled={!canEdit}>
<div
onDoubleClick={() => 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}
</div>
</Tooltip>
)}
{ canEdit && <div className="ml-3 cursor-pointer" onClick={() => setEditing(true)}>
<Tooltip title='Rename' placement='bottom'>
<Icon name="pencil" size="16" />
</Tooltip>
</div> }
</div>
);
}
export default WidgetName;
export default WidgetName;

View file

@ -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 (
<div>
<div className={'flex items-center gap-2'}>
{metric.metricType === USER_PATH && (
<a
href="#"
onClick={(e) => {
e.preventDefault();
metric.update({ hideExcess: !metric.hideExcess });
metric.updateKey('hasChanged', true);
}}
>
<Space>
<Switch checked={metric.hideExcess} size="small" />
<span className="mr-4 color-gray-medium">
Hide Minor Paths
</span>
<span className="mr-4 color-gray-medium">Hide Minor Paths</span>
</Space>
</a>
)}
{(metric.metricType === FUNNEL || metric.metricType === TABLE) && metric.metricOf != FilterKey.USERID && metric.metricOf != FilterKey.ERRORS && (
<Select
defaultValue={metric.metricFormat}
onChange={handleChange}
variant="borderless"
options={[
{ value: 'sessionCount', label: 'Sessions' },
{ value: 'userCount', label: 'Users' }
]}
/>
)}
{metric.metricType === TIMESERIES && <SeriesTypeOptions metric={metric} />}
{(metric.metricType === FUNNEL || metric.metricType === TABLE) &&
metric.metricOf !== FilterKey.USERID &&
metric.metricOf !== FilterKey.ERRORS && (
<Dropdown
trigger={['click']}
menu={{
selectable: true,
items: [
{ key: 'sessionCount', label: 'All Sessions' },
{ key: 'userCount', label: 'Unique Users' },
],
onClick: (info: { key: string }) => handleChange(info.key),
}}
{metric.metricType === HEATMAP ? (
<ClickMapRagePicker />
) : null}
>
<Button type="text" variant="text" size="small">
{metric.metricFormat === 'sessionCount'
? 'All Sessions'
: 'Unique Users'}
<DownOutlined className="text-sm" />
</Button>
</Dropdown>
)}
{hasViewTypes && <WidgetViewTypeOptions metric={metric} />}
{metric.metricType === HEATMAP && <ClickMapRagePicker />}
</div>
);
}
const SeriesTypeOptions = observer(({ metric }: { metric: any }) => {
const items = {
sessionCount: 'Total Sessions',
userCount: 'Unique Users',
};
const chartIcons = {
sessionCount: <Library size={16} strokeWidth={1} />,
userCount: <Users size={16} strokeWidth={1} />,
} as const;
return (
<Dropdown
trigger={['click']}
menu={{
selectable: true,
items: Object.entries(items).map(([key, name]) => ({
key,
label: (
<div className={'flex items-center gap-2'}>
<>
{chartIcons[key]}
<div>{name}</div>
</>
</div>
),
})),
onClick: ({ key }: any) => {
metric.updateKey('metricOf', key);
metric.updateKey('hasChanged', true);
},
}}
>
<Button
type="text"
variant="text"
size="small"
className="btn-aggregator"
>
<Space>
{chartIcons[metric.metricOf]}
<div>{items[metric.metricOf] || 'Total Sessions'}</div>
<DownOutlined className="text-sm" />
</Space>
</Button>
</Dropdown>
);
});
const WidgetViewTypeOptions = observer(({ metric }: { metric: any }) => {
const chartTypes = {
lineChart: 'Line',
areaChart: 'Stacked Area',
barChart: 'Column',
progressChart: 'Bar',
columnChart: 'Horizontal Bar',
pieChart: 'Pie',
metric: 'Metric',
table: 'Table',
};
const funnelChartTypes = {
chart: 'Funnel Bar',
columnChart: 'Funnel Column',
metric: 'Metric',
table: 'Table',
}
const usedChartTypes = metric.metricType === FUNNEL ? funnelChartTypes : chartTypes;
const chartIcons = {
lineChart: <ChartLine size={16} strokeWidth={1} /> ,
barChart: <ChartColumn size={16} strokeWidth={1} />,
areaChart: <ChartArea size={16} strokeWidth={1} />,
pieChart: <ChartPie size={16} strokeWidth={1} />,
progressChart: <ChartBar size={16} strokeWidth={1} />,
metric: <Hash size={16} strokeWidth={1} />,
table: <Table size={16} strokeWidth={1} />,
// funnel specific
columnChart: <ChartColumnBig size={16} strokeWidth={1} />,
chart: <ChartBarBig size={16} strokeWidth={1} />,
};
const allowedTypes = {
[TIMESERIES]: [
'lineChart',
'areaChart',
'barChart',
'progressChart',
'pieChart',
'metric',
'table',
],
[FUNNEL]: ['chart', 'columnChart', 'metric', 'table'],
};
return (
<Dropdown
trigger={['click']}
menu={{
selectable: true,
items: allowedTypes[metric.metricType].map((key) => ({
key,
label: (
<div className="flex gap-2 items-center">
{chartIcons[key]}
<div>{usedChartTypes[key]}</div>
</div>
),
})),
onClick: ({ key }: any) => {
metric.updateKey('viewType', key);
metric.updateKey('hasChanged', true);
},
}}
>
<Button
type="text"
variant="text"
size="small"
className="btn-visualization-type"
>
<Space>
{chartIcons[metric.viewType]}
<div>{usedChartTypes[metric.viewType]}</div>
<DownOutlined className="text-sm " />
</Space>
</Button>
</Dropdown>
);
});
export default observer(WidgetOptions);

View file

@ -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 (
<>
<div
className={cn(className, 'bg-white rounded-xl border shadow-sm mt-0')}
>
<div className="flex items-center justify-between px-4 pt-2">
<h2 className="text-xl">{props.name}</h2>
<div className="flex items-center">
<div className="flex items-center gap-2 px-4 py-2 border-b">
<WidgetDateRange
label=""
hasGranularSettings={hasGranularSettings}
hasGranularity={hasGranularity}
hasComparison={hasComparison}
presetComparison={presetComparison}
/>
<div className="ml-auto">
<WidgetOptions />
{/*{metric.metricType === USER_PATH && (*/}
{/* <a*/}
{/* href="#"*/}
{/* onClick={(e) => {*/}
{/* e.preventDefault();*/}
{/* metric.update({ hideExcess: !metric.hideExcess });*/}
{/* }}*/}
{/* >*/}
{/* <Space>*/}
{/* <Switch checked={metric.hideExcess} size="small" />*/}
{/* <span className="mr-4 color-gray-medium">*/}
{/* Hide Minor Paths*/}
{/* </span>*/}
{/* </Space>*/}
{/* </a>*/}
{/*)}*/}
{/*{isTimeSeries && (*/}
{/* <>*/}
{/* <span className="mr-4 color-gray-medium">Visualization</span>*/}
{/* <SegmentSelection*/}
{/* name="viewType"*/}
{/* className="my-3"*/}
{/* primary*/}
{/* size="small"*/}
{/* onSelect={ changeViewType }*/}
{/* value={{ value: metric.viewType }}*/}
{/* list={ [*/}
{/* { value: 'lineChart', name: 'Chart', icon: 'graph-up-arrow' },*/}
{/* { value: 'progress', name: 'Progress', icon: 'hash' },*/}
{/* ]}*/}
{/* />*/}
{/* </>*/}
{/*)}*/}
{/*{!disableVisualization && isTable && (*/}
{/* <>*/}
{/* <span className="mr-4 color-gray-medium">Visualization</span>*/}
{/* <SegmentSelection*/}
{/* name="viewType"*/}
{/* className="my-3"*/}
{/* primary={true}*/}
{/* size="small"*/}
{/* onSelect={ changeViewType }*/}
{/* value={{ value: metric.viewType }}*/}
{/* list={[*/}
{/* { value: 'table', name: 'Table', icon: 'table' },*/}
{/* { value: 'pieChart', name: 'Chart', icon: 'pie-chart-fill' },*/}
{/* ]}*/}
{/* disabledMessage="Chart view is not supported"*/}
{/* />*/}
{/* </>*/}
{/*)}*/}
{/*{isRetention && (*/}
{/* <>*/}
{/* <span className="mr-4 color-gray-medium">Visualization</span>*/}
{/* <SegmentSelection*/}
{/* name="viewType"*/}
{/* className="my-3"*/}
{/* primary={true}*/}
{/* size="small"*/}
{/* onSelect={ changeViewType }*/}
{/* value={{ value: metric.viewType }}*/}
{/* list={[*/}
{/* { value: 'trend', name: 'Trend', icon: 'graph-up-arrow' },*/}
{/* { value: 'cohort', name: 'Cohort', icon: 'dice-3' },*/}
{/* ]}*/}
{/* disabledMessage="Chart view is not supported"*/}
{/* />*/}
{/*</>*/}
{/*)}*/}
{/* add to dashboard */}
{/*{metric.exists() && (*/}
{/* <AddToDashboardButton metricId={metric.metricId}/>*/}
{/*)}*/}
</div>
</div>
<div className="pt-0">
<div className="py-4">
<WidgetWrapper
widget={metric}
isPreview={true}

View file

@ -1,6 +1,7 @@
import React, { useEffect, useState } from 'react';
import { NoContent, Loader, Pagination, Button } from 'UI';
import Select from 'Shared/Select';
import { NoContent, Loader, Pagination } from 'UI';
import {Button, Tag, Tooltip, Dropdown, notification} from 'antd';
import {UndoOutlined, DownOutlined} from '@ant-design/icons'
import cn from 'classnames';
import { useStore } from 'App/mstore';
import SessionItem from 'Shared/SessionItem';
@ -11,20 +12,22 @@ import useIsMounted from 'App/hooks/useIsMounted';
import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG';
import { numberWithCommas } from 'App/utils';
import { HEATMAP } from 'App/constants/card';
import { Tag } from 'antd';
interface Props {
className?: string;
}
function WidgetSessions(props: Props) {
const listRef = React.useRef<HTMLDivElement>(null);
const { className = '' } = props;
const [activeSeries, setActiveSeries] = useState('all');
const [data, setData] = useState<any>([]);
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: (
<div onClick={() => setActiveSeries(option.value)}>
{option.label}
</div>
)
}));
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 (
<div className={cn(className, 'bg-white p-3 pb-0 rounded-lg shadow-sm border mt-3')}>
<div className={cn(className, 'bg-white p-3 pb-0 rounded-xl shadow-sm border mt-3')}>
<div className="flex items-center justify-between">
<div>
<div className="flex items-baseline">
<div className="flex items-baseline gap-2">
<h2 className="text-xl">{metricStore.clickMapSearch ? 'Clicks' : 'Sessions'}</h2>
<div className="ml-2 color-gray-medium">
{metricStore.clickMapLabel ? `on "${metricStore.clickMapLabel}" ` : null}
between <span className="font-medium color-gray-darkest">{startTime}</span> and{' '}
<span className="font-medium color-gray-darkest">{endTime}</span>{' '}
</div>
{hasFilters && <Tooltip title='Clear Drilldown' placement='top'><Button type='text' size='small' onClick={clearFilters}><UndoOutlined /></Button></Tooltip>}
</div>
{hasFilters && widget.metricType === 'table' &&
<div className="py-2"><Tag closable onClose={clearFilters}>{filterText}</Tag></div>}
{hasFilters && widget.metricType === 'table' && <div className="py-2"><Tag closable onClose={clearFilters}>{filterText}</Tag></div>}
</div>
<div className="flex items-center gap-4">
{hasFilters && <Button variant="text-primary" onClick={clearFilters}>Clear Filters</Button>}
{widget.metricType !== 'table' && widget.metricType !== HEATMAP && (
<div className="flex items-center ml-6">
<span className="mr-2 color-gray-medium">Filter by Series</span>
<Select options={seriesOptions} defaultValue={'all'} onChange={writeOption} plain />
</div>
)}
{widget.metricType !== 'table' && widget.metricType !== HEATMAP && (
<div className="flex items-center ml-6">
<span className="mr-2 color-gray-medium">Filter by Series</span>
<Dropdown
menu={{
items: seriesDropdownItems,
selectable: true,
selectedKeys: [activeSeries]
}}
trigger={['click']}
>
<Button type="text" size='small'>
{seriesOptions.find(option => option.value === activeSeries)?.label || 'Select Series'}
<DownOutlined />
</Button>
</Dropdown>
</div>
)}
</div>
</div>
<div className="mt-3">
<div className="mt-3" >
<Loader loading={loading}>
<NoContent
title={
@ -164,7 +213,7 @@ function WidgetSessions(props: Props) {
</React.Fragment>
))}
<div className="flex items-center justify-between p-5">
<div className="flex items-center justify-between p-5" ref={listRef}>
<div>
Showing{' '}
<span className="font-medium">

View file

@ -1,98 +1,83 @@
import {useHistory} from "react-router";
import {useStore} from "App/mstore";
import {useObserver} from "mobx-react-lite";
import {Button, Dropdown, MenuProps, message, Modal} from "antd";
import {BellIcon, EllipsisVertical, TrashIcon} from "lucide-react";
import {toast} from "react-toastify";
import React from "react";
import {useModal} from "Components/ModalContext";
import AlertFormModal from "Components/Alerts/AlertFormModal/AlertFormModal";
import { useHistory } from 'react-router';
import { useStore } from 'App/mstore';
import { observer } from 'mobx-react-lite';
import { Button, Dropdown, MenuProps, Modal } from 'antd';
import { BellIcon, EllipsisVertical, Grid2x2Plus, TrashIcon } from 'lucide-react';
import { toast } from 'react-toastify';
import React from 'react';
import { useModal } from 'Components/ModalContext';
import AlertFormModal from 'Components/Alerts/AlertFormModal/AlertFormModal';
import { showAddToDashboardModal } from 'Components/Dashboard/components/AddToDashboardButton';
const CardViewMenu = () => {
const history = useHistory();
const {alertsStore, dashboardStore, metricStore} = useStore();
const widget = useObserver(() => metricStore.instance);
const {openModal, closeModal} = useModal();
const history = useHistory();
const { alertsStore, metricStore, dashboardStore } = useStore();
const widget = metricStore.instance;
const { openModal, closeModal } = useModal();
const showAlertModal = () => {
const seriesId = widget.series[0] && widget.series[0].seriesId || '';
alertsStore.init({query: {left: seriesId}})
openModal(<AlertFormModal
onClose={closeModal}
/>, {
// title: 'Set Alerts',
placement: 'right',
width: 620,
const showAlertModal = () => {
const seriesId = (widget.series[0] && widget.series[0].seriesId) || '';
alertsStore.init({ query: { left: seriesId } });
openModal(<AlertFormModal onClose={closeModal} />, {
placement: 'right',
width: 620,
});
};
const items: MenuProps['items'] = [
{
key: 'add-to-dashboard',
label: 'Add to Dashboard',
icon: <Grid2x2Plus size={16} />,
disabled: !widget.exists(),
onClick: () => showAddToDashboardModal(widget.metricId, dashboardStore),
},
{
key: 'alert',
label: 'Set Alerts',
icon: <BellIcon size={16} />,
disabled: !widget.exists() || widget.metricType === 'predefined',
onClick: showAlertModal,
},
{
key: 'remove',
label: 'Delete',
icon: <TrashIcon size={15} />,
disabled: !widget.exists(),
onClick: () => {
Modal.confirm({
title: 'Confirm Card Deletion',
icon: null,
content:
'Are you sure you want to remove this card? This action is permanent and cannot be undone.',
footer: (_, { OkBtn, CancelBtn }) => (
<>
<CancelBtn />
<OkBtn />
</>
),
onOk: () => {
metricStore
.delete(widget)
.then(() => {
history.goBack();
})
.catch(() => {
toast.error('Failed to remove card');
});
},
});
}
},
},
];
const items: MenuProps['items'] = [
{
key: 'alert',
label: "Set Alerts",
icon: <BellIcon size={16}/>,
disabled: !widget.exists() || widget.metricType === 'predefined',
onClick: showAlertModal,
},
{
key: 'remove',
label: 'Delete',
icon: <TrashIcon size={16}/>,
onClick: () => {
Modal.confirm({
title: 'Confirm Card Deletion',
icon: null,
content:'Are you sure you want to remove this card? This action is permanent and cannot be undone.',
footer: (_, {OkBtn, CancelBtn}) => (
<>
<CancelBtn/>
<OkBtn/>
</>
),
onOk: () => {
metricStore.delete(widget).then(r => {
history.goBack();
}).catch(() => {
toast.error('Failed to remove card');
});
},
})
}
},
];
const onClick: MenuProps['onClick'] = ({key}) => {
if (key === 'alert') {
message.info('Set Alerts');
} else if (key === 'remove') {
Modal.confirm({
title: 'Are you sure you want to remove this card?',
icon: null,
// content: 'Bla bla ...',
footer: (_, {OkBtn, CancelBtn}) => (
<>
<CancelBtn/>
<OkBtn/>
</>
),
onOk: () => {
metricStore.delete(widget).then(r => {
history.goBack();
}).catch(() => {
toast.error('Failed to remove card');
});
},
})
}
};
return (
<div className="flex items-center justify-between">
<Dropdown menu={{items}}>
<Button icon={<EllipsisVertical size={16}/>}/>
</Dropdown>
</div>
);
return (
<div className="flex items-center justify-between">
<Dropdown menu={{ items }}>
<Button type='text' icon={<EllipsisVertical size={16} />} className='btn-card-options' />
</Dropdown>
</div>
);
};
export default CardViewMenu;
export default observer(CardViewMenu);

View file

@ -3,7 +3,7 @@ import { useStore } from 'App/mstore';
import { Loader, NoContent } from 'UI';
import WidgetPreview from '../WidgetPreview';
import WidgetSessions from '../WidgetSessions';
import { useObserver } from 'mobx-react-lite';
import { observer } from 'mobx-react-lite';
import { dashboardMetricDetails, metricDetails, withSiteId } from 'App/routes';
import Breadcrumb from 'Shared/Breadcrumb';
import { FilterKey } from 'Types/filter/filterType';
@ -16,14 +16,16 @@ import {
FUNNEL,
INSIGHTS,
USER_PATH,
RETENTION
RETENTION,
} from 'App/constants/card';
import CardUserList from '../CardUserList/CardUserList';
import WidgetViewHeader from 'Components/Dashboard/components/WidgetView/WidgetViewHeader';
import WidgetFormNew from 'Components/Dashboard/components/WidgetForm/WidgetFormNew';
import { Space } from 'antd';
import { Space, Segmented, Tooltip } from 'antd';
import { renderClickmapThumbnail } from 'Components/Dashboard/components/WidgetForm/renderMap';
import Widget from 'App/mstore/types/widget';
import { LayoutPanelTop, LayoutPanelLeft } from 'lucide-react';
import cn from 'classnames'
interface Props {
history: any;
@ -31,19 +33,27 @@ interface Props {
siteId: any;
}
const LAYOUT_KEY = '$__layout__$'
function getDefaultState() {
const layout = localStorage.getItem(LAYOUT_KEY)
return layout || 'flex-row'
}
function WidgetView(props: Props) {
const [layout, setLayout] = useState(getDefaultState);
const {
match: {
params: { siteId, dashboardId, metricId }
}
params: { siteId, dashboardId, metricId },
},
} = props;
const { metricStore, dashboardStore } = useStore();
const widget = useObserver(() => metricStore.instance);
const loading = useObserver(() => metricStore.isLoading);
const { metricStore, dashboardStore, settingsStore } = useStore();
const widget = metricStore.instance;
const loading = metricStore.isLoading;
const [expanded, setExpanded] = useState(!metricId || metricId === 'create');
const hasChanged = useObserver(() => widget.hasChanged);
const dashboards = useObserver(() => dashboardStore.dashboards);
const dashboard = useObserver(() => dashboards.find((d: any) => d.dashboardId == dashboardId));
const hasChanged = widget.hasChanged;
const dashboards = dashboardStore.dashboards;
const dashboard = dashboards.find((d: any) => d.dashboardId == dashboardId);
const dashboardName = dashboard ? dashboard.name : null;
const [metricNotFound, setMetricNotFound] = useState(false);
const history = useHistory();
@ -58,7 +68,16 @@ function WidgetView(props: Props) {
}
});
} else {
metricStore.init();
if (!metricStore.instance) {
metricStore.init();
}
}
const wasCollapsed = settingsStore.menuCollapsed;
settingsStore.updateMenuCollapsed(true)
return () => {
if (!wasCollapsed) {
settingsStore.updateMenuCollapsed(false)
}
}
}, []);
@ -81,24 +100,37 @@ function WidgetView(props: Props) {
if (wasCreating) {
if (parseInt(dashboardId, 10) > 0) {
history.replace(
withSiteId(dashboardMetricDetails(dashboardId, savedMetric.metricId), siteId)
withSiteId(
dashboardMetricDetails(dashboardId, savedMetric.metricId),
siteId
)
);
void dashboardStore.addWidgetToDashboard(
dashboardStore.getDashboard(parseInt(dashboardId, 10))!,
[savedMetric.metricId]
);
} else {
history.replace(withSiteId(metricDetails(savedMetric.metricId), siteId));
history.replace(
withSiteId(metricDetails(savedMetric.metricId), siteId)
);
}
}
};
return useObserver(() => (
const updateLayout = (layout: string) => {
localStorage.setItem(LAYOUT_KEY, layout)
setLayout(layout)
}
return (
<Loader loading={loading}>
<Prompt
when={hasChanged}
message={(location: any) => {
if (location.pathname.includes('/metrics/') || location.pathname.includes('/metric/')) {
if (
location.pathname.includes('/metrics/') ||
location.pathname.includes('/metric/')
) {
return true;
}
return 'You have unsaved changes. Are you sure you want to leave?';
@ -110,9 +142,11 @@ function WidgetView(props: Props) {
items={[
{
label: dashboardName ? dashboardName : 'Cards',
to: dashboardId ? withSiteId('/dashboard/' + dashboardId, siteId) : withSiteId('/metrics', siteId)
to: dashboardId
? withSiteId('/dashboard/' + dashboardId, siteId)
: withSiteId('/metrics', siteId),
},
{ label: widget.name }
{ label: widget.name },
]}
/>
<NoContent
@ -125,25 +159,70 @@ function WidgetView(props: Props) {
}
>
<Space direction="vertical" className="w-full" size={14}>
<WidgetViewHeader onSave={onSave} undoChanges={undoChanges} />
<WidgetFormNew />
<WidgetPreview name={widget.name} isEditing={expanded} />
<WidgetViewHeader
onSave={onSave}
undoChanges={undoChanges}
layoutControl={
<Segmented
size='small'
value={layout}
onChange={updateLayout}
options={[
{
value: 'flex-row',
icon: (
<Tooltip title="Horizontal Layout">
<LayoutPanelLeft size={16} />
</Tooltip>
)
},
{
value: 'flex-col',
icon: (
<Tooltip title="Vertical Layout">
<LayoutPanelTop size={16} />
</Tooltip>
)
},
{
value: 'flex-row-reverse',
icon: (
<Tooltip title="Reversed Horizontal Layout">
<div className={'rotate-180'}><LayoutPanelLeft size={16} /></div>
</Tooltip>
)
}
]}
/>
}
/>
<div className={cn('flex gap-4', layout)}>
<div className={layout.startsWith('flex-row') ? 'w-1/3 ' : 'w-full'}>
<WidgetFormNew layout={layout} />
</div>
<div className={layout.startsWith('flex-row') ? 'w-2/3' : 'w-full'}>
<WidgetPreview name={widget.name} isEditing={expanded} />
{widget.metricOf !== FilterKey.SESSIONS && widget.metricOf !== FilterKey.ERRORS && (
(widget.metricType === TABLE
|| widget.metricType === TIMESERIES
|| widget.metricType === HEATMAP
|| widget.metricType === INSIGHTS
|| widget.metricType === FUNNEL
|| widget.metricType === USER_PATH) ?
<WidgetSessions /> : null
)}
{widget.metricType === RETENTION && <CardUserList />}
{widget.metricOf !== FilterKey.SESSIONS &&
widget.metricOf !== FilterKey.ERRORS &&
(widget.metricType === TABLE ||
widget.metricType === TIMESERIES ||
widget.metricType === HEATMAP ||
widget.metricType === INSIGHTS ||
widget.metricType === FUNNEL ||
widget.metricType === USER_PATH ? (
<WidgetSessions />
) : null)}
{widget.metricType === RETENTION && <CardUserList />}
</div>
</div>
</Space>
</NoContent>
</div>
</Loader>
));
);
}
export default WidgetView;
export default observer(WidgetView);

View file

@ -1,49 +1,78 @@
import React from 'react';
import cn from "classnames";
import WidgetName from "Components/Dashboard/components/WidgetName";
import {useStore} from "App/mstore";
import {useObserver} from "mobx-react-lite";
import AddToDashboardButton from "Components/Dashboard/components/AddToDashboardButton";
import WidgetDateRange from "Components/Dashboard/components/WidgetDateRange/WidgetDateRange";
import {Button, Space} from "antd";
import CardViewMenu from "Components/Dashboard/components/WidgetView/CardViewMenu";
import cn from 'classnames';
import WidgetName from 'Components/Dashboard/components/WidgetName';
import { useStore } from 'App/mstore';
import { observer } from 'mobx-react-lite';
import { Button, Space, Tooltip } from 'antd';
import CardViewMenu from 'Components/Dashboard/components/WidgetView/CardViewMenu';
import { Link2 } from 'lucide-react'
import copy from 'copy-to-clipboard';
interface Props {
onClick?: () => void;
onSave: () => void;
undoChanges?: () => void;
onClick?: () => void;
onSave: () => void;
undoChanges: () => void;
layoutControl?: React.ReactNode;
}
function WidgetViewHeader({onClick, onSave, undoChanges}: Props) {
const {metricStore, dashboardStore} = useStore();
const widget = useObserver(() => metricStore.instance);
const defaultText = 'Copy link to clipboard'
return (
<div
className={cn('flex justify-between items-center')}
onClick={onClick}
>
<h1 className="mb-0 text-2xl mr-4 min-w-fit">
<WidgetName name={widget.name}
onUpdate={(name) => metricStore.merge({name})}
canEdit={true}
/>
</h1>
<Space>
<WidgetDateRange label=""/>
<AddToDashboardButton metricId={widget.metricId}/>
<Button
type="primary"
onClick={onSave}
loading={metricStore.isSaving}
disabled={metricStore.isSaving || !widget.hasChanged}
>
Update
</Button>
<CardViewMenu/>
</Space>
</div>
);
function WidgetViewHeader({ onClick, onSave, layoutControl }: Props) {
const [tooltipText, setTooltipText] = React.useState(defaultText);
const { metricStore } = useStore();
const widget = metricStore.instance;
const handleSave = () => {
onSave();
};
const copyUrl = () => {
const url = window.location.href;
copy(url)
setTooltipText('Link copied to clipboard!');
setTimeout(() => setTooltipText(defaultText), 2000);
}
return (
<div
className={cn(
'flex justify-between items-center bg-white rounded-lg shadow-sm px-4 ps-2 py-2 border border-gray-lighter input-card-title'
)}
onClick={onClick}
>
<h1 className="mb-0 text-2xl mr-4 min-w-fit ">
<WidgetName
name={widget.name}
onUpdate={(name) => {
metricStore.merge({ name });
}}
canEdit={true}
/>
</h1>
<Space>
<Button
type={
metricStore.isSaving || (widget.exists() && !widget.hasChanged) ? 'text' : 'primary'
}
onClick={handleSave}
loading={metricStore.isSaving}
disabled={metricStore.isSaving || (widget.exists() && !widget.hasChanged)}
className='font-medium btn-update-card'
size='small'
>
{widget.exists() ? 'Update' : 'Create'}
</Button>
{/* <MetricTypeSelector /> */}
<Tooltip title={tooltipText}>
<Button type='text' className='btn-copy-card-url' disabled={!widget.exists()} onClick={copyUrl} icon={<Link2 size={16} strokeWidth={1}/> }></Button>
</Tooltip>
{layoutControl}
<CardViewMenu />
</Space>
</div>
);
}
export default WidgetViewHeader;
export default observer(WidgetViewHeader);

View file

@ -12,16 +12,15 @@ interface Props {
}
function AlertButton(props: Props) {
const {seriesId} = props;
const {dashboardStore, alertsStore} = useStore();
const {openModal, closeModal} = useModal();
const { seriesId, initAlert } = props;
const { alertsStore } = useStore();
const { openModal, closeModal } = useModal();
const onClick = () => {
// dashboardStore.toggleAlertModal(true);
alertsStore.init({query: {left: seriesId}})
initAlert?.();
alertsStore.init({ query: { left: seriesId } })
openModal(<AlertFormModal
onClose={closeModal}
/>, {
// title: 'Set Alerts',
placement: 'right',
width: 620,
});

View file

@ -7,12 +7,12 @@ import { useStore } from 'App/mstore';
import { withRouter, RouteComponentProps } from 'react-router-dom';
import { withSiteId, dashboardMetricDetails } from 'App/routes';
import TemplateOverlay from './TemplateOverlay';
import AlertButton from './AlertButton';
import stl from './widgetWrapper.module.css';
import { FilterKey } from 'App/types/filter/filterType';
import { TIMESERIES } from "App/constants/card";
import { TIMESERIES } from 'App/constants/card';
const WidgetChart = lazy(() => import('Components/Dashboard/components/WidgetChart'));
const WidgetChart = lazy(
() => import('Components/Dashboard/components/WidgetChart')
);
interface Props {
className?: string;
@ -74,30 +74,37 @@ function WidgetWrapper(props: Props & RouteComponentProps) {
});
const onDelete = async () => {
dashboardStore.deleteDashboardWidget(dashboard?.dashboardId!, widget.widgetId);
dashboardStore.deleteDashboardWidget(
dashboard?.dashboardId!,
widget.widgetId
);
};
const onChartClick = () => {
if (!isSaved || isPredefined) return;
props.history.push(
withSiteId(dashboardMetricDetails(dashboard?.dashboardId, widget.metricId), siteId)
withSiteId(
dashboardMetricDetails(dashboard?.dashboardId, widget.metricId),
siteId
)
);
};
const ref: any = useRef(null);
const dragDropRef: any = dragRef(dropRef(ref));
const dragDropRef: any = isPreview ? null : dragRef(dropRef(ref));
const addOverlay =
isTemplate ||
(!isPredefined &&
isSaved &&
isSaved &&
widget.metricOf !== FilterKey.ERRORS &&
widget.metricOf !== FilterKey.SESSIONS);
return (
<div
className={cn(
'relative rounded bg-white border group rounded-lg',
'relative bg-white border group rounded-lg',
'col-span-' + widget.config.col,
{ 'hover:shadow-border-gray': !isTemplate && isSaved },
{ 'hover:shadow-border-main': isTemplate }
@ -106,73 +113,39 @@ function WidgetWrapper(props: Props & RouteComponentProps) {
userSelect: 'none',
opacity: isDragging ? 0.5 : 1,
borderColor:
(canDrop && isOver) || active ? '#394EFF' : isPreview ? 'transparent' : '#EEEEEE',
(canDrop && isOver) || active
? '#394EFF'
: isPreview
? 'transparent'
: '#EEEEEE',
}}
ref={dragDropRef}
onClick={props.onClick ? props.onClick : () => {}}
id={`widget-${widget.widgetId}`}
id={`widget-${widget.metricId}`}
>
{!isTemplate && isSaved && isPredefined && (
<div
className={cn(
stl.drillDownMessage,
'disabled text-gray text-sm invisible group-hover:visible'
)}
>
{'Cannot drill down system provided metrics'}
</div>
{addOverlay && (
<TemplateOverlay onClick={onChartClick} isTemplate={isTemplate} />
)}
{addOverlay && <TemplateOverlay onClick={onChartClick} isTemplate={isTemplate} />}
<div
className={cn('p-3 pb-4 flex items-center justify-between', {
'cursor-move': !isTemplate && isSaved,
})}
>
{!props.hideName ? (
{!props.hideName ? (
<div
className={cn('p-3 pb-4 flex items-center justify-between', {
'cursor-move': !isTemplate && isSaved,
})}
>
<div className="capitalize-first w-full font-medium">
<TextEllipsis text={widget.name} />
</div>
) : null}
{isSaved && (
<div className="flex items-center" id="no-print">
{!isPredefined && isTimeSeries && !isGridView && (
<>
<AlertButton seriesId={widget.series[0] && widget.series[0].seriesId} />
<div className="mx-2" />
</>
)}
{!isTemplate && !isGridView && (
<ItemMenu
items={[
{
text:
widget.metricType === 'predefined'
? 'Cannot edit system generated metrics'
: 'Edit',
onClick: onChartClick,
disabled: widget.metricType === 'predefined',
},
{
text: 'Hide',
onClick: onDelete,
},
]}
/>
)}
</div>
)}
</div>
<div className="px-4" onClick={onChartClick}>
<WidgetChart
isPreview={isPreview}
metric={widget}
isTemplate={isTemplate}
isSaved={isSaved}
/>
</div>
) : null}
<div className="px-4" onClick={onChartClick}>
<WidgetChart
isPreview={isPreview}
metric={widget}
isTemplate={isTemplate}
isSaved={isSaved}
/>
</div>
</div>
);
}

View file

@ -36,7 +36,7 @@ interface Props {
}
function WidgetWrapperNew(props: Props & RouteComponentProps) {
const { dashboardStore } = useStore();
const { dashboardStore, metricStore } = useStore();
const {
isWidget = false,
active = false,
@ -94,11 +94,13 @@ function WidgetWrapperNew(props: Props & RouteComponentProps) {
widget.metricOf !== FilterKey.ERRORS &&
widget.metricOf !== FilterKey.SESSIONS);
const beforeAlertInit = () => {
metricStore.init(widget)
}
return (
<Card
className={cn(
'relative group rounded-lg hover:border-teal transition-all duration-200',
'col-span-' + widget.config.col,
'relative group rounded-lg hover:border-teal transition-all duration-200 w-full',
{ 'hover:shadow-sm': !isTemplate && isWidget },
)}
style={{
@ -109,12 +111,12 @@ function WidgetWrapperNew(props: Props & RouteComponentProps) {
}}
ref={dragDropRef}
onClick={props.onClick ? props.onClick : () => null}
id={`widget-${widget.widgetId}`}
id={`widget-${widget.metricId}`}
title={!props.hideName ? widget.name : null}
extra={[
<div className="flex items-center" id="no-print">
{!isPredefined && isTimeSeries && !isGridView && (
<AlertButton seriesId={widget.series[0] && widget.series[0].seriesId} />
<AlertButton initAlert={beforeAlertInit} seriesId={widget.series[0] && widget.series[0].seriesId} />
)}
{showMenu && (

View file

@ -4,77 +4,149 @@ import FunnelStepText from './FunnelStepText';
import { Icon } from 'UI';
import { Space } from 'antd';
import { Styles } from 'Components/Dashboard/Widgets/common';
import cn from 'classnames';
interface Props {
filter: any;
compData?: any;
index?: number;
focusStage?: (index: number, isFocused: boolean) => void;
focusedFilter?: number | null;
metricLabel?: string;
isHorizontal?: boolean;
}
function FunnelBar(props: Props) {
const { filter, index, focusStage, focusedFilter, metricLabel = 'Sessions' } = props;
const { filter, index, focusStage, focusedFilter, compData, isHorizontal } = props;
const isFocused = focusedFilter && index ? focusedFilter === index - 1 : false;
const isFocused =
focusedFilter && index ? focusedFilter === index - 1 : false;
return (
<div className="w-full mb-4">
<FunnelStepText filter={filter} />
<div className="w-full mb-2">
<FunnelStepText filter={filter} isHorizontal={isHorizontal} />
<div className={isHorizontal ? 'flex gap-1' : 'flex flex-col'}>
<FunnelBarData
data={props.filter}
isHorizontal={isHorizontal}
isComp={false}
index={index}
isFocused={isFocused}
focusStage={focusStage}
/>
{compData ? (
<FunnelBarData
data={props.compData}
isHorizontal={isHorizontal}
isComp
index={index}
isFocused={isFocused}
focusStage={focusStage}
/>
) : null}
</div>
</div>
);
}
function FunnelBarData({
data,
isComp,
isFocused,
focusStage,
index,
isHorizontal,
}: {
data: any;
isComp?: boolean;
isFocused?: boolean;
focusStage?: (index: number, isComparison: boolean) => void;
index?: number;
isHorizontal?: boolean;
}) {
const vertFillBarStyle = {
width: `${data.completedPercentageTotal}%`,
height: '100%',
backgroundColor: isComp ? Styles.compareColors[2] : Styles.compareColors[1],
};
const horizontalFillBarStyle = {
width: '100%',
height: `${data.completedPercentageTotal}%`,
backgroundColor: isComp ? Styles.compareColors[2] : Styles.compareColors[1],
}
const vertEmptyBarStyle = {
width: `${100.1 - data.completedPercentageTotal}%`,
height: '100%',
background: isFocused
? 'rgba(204, 0, 0, 0.3)'
: 'repeating-linear-gradient(325deg, lightgray, lightgray 1px, #FFF1F0 1px, #FFF1F0 6px)',
cursor: 'pointer',
}
const horizontalEmptyBarStyle = {
height: `${100.1 - data.completedPercentageTotal}%`,
width: '100%',
background: isFocused
? 'rgba(204, 0, 0, 0.3)'
: 'repeating-linear-gradient(325deg, lightgray, lightgray 1px, #FFF1F0 1px, #FFF1F0 6px)',
cursor: 'pointer',
}
const fillBarStyle = isHorizontal ? horizontalFillBarStyle : vertFillBarStyle;
const emptyBarStyle = isHorizontal ? horizontalEmptyBarStyle : vertEmptyBarStyle
return (
<div>
<div
className={isHorizontal ? 'rounded-t' : ''}
style={{
height: '25px',
width: '99.8%',
height: isHorizontal ? '210px' : '21px',
width: isHorizontal ? '200px' : '99.8%',
backgroundColor: '#f5f5f5',
position: 'relative',
borderRadius: '.5rem',
overflow: 'hidden'
borderRadius: isHorizontal ? undefined : '.5rem',
overflow: 'hidden',
opacity: isComp ? 0.7 : 1,
display: 'flex',
flexDirection: isHorizontal ? 'column-reverse' : 'row',
}}
>
<div
className="flex items-center"
style={{
width: `${filter.completedPercentageTotal}%`,
position: 'absolute',
top: 0,
left: 0,
bottom: 0,
backgroundColor: Styles.compareColors[1]
}}
className={cn("flex", isHorizontal ? 'justify-center items-start pt-1' : 'justify-end items-center pr-1')}
style={fillBarStyle}
>
<div className="color-white absolute right-0 flex items-center font-medium mr-2 leading-3">
{filter.completedPercentageTotal}%
<div className="color-white flex items-center font-medium leading-3">
{data.completedPercentageTotal}%
</div>
</div>
<div
style={{
width: `${100.1 - filter.completedPercentageTotal}%`,
position: 'absolute',
top: 0,
right: 0,
bottom: 0,
backgroundColor: isFocused ? 'rgba(204, 0, 0, 0.3)' : '#fff0f0',
cursor: 'pointer'
}}
onClick={() => focusStage?.(index! - 1, filter.isActive)}
className={'hover:opacity-75'}
style={emptyBarStyle}
onClick={() => focusStage?.(index! - 1, isComp)}
className={'hover:opacity-70'}
/>
</div>
<div className="flex justify-between py-2">
<div
className={cn('flex justify-between', isComp ? 'opacity-60' : '')}
>
{/* @ts-ignore */}
<div className="flex items-center">
<Icon name="arrow-right-short" size="20" color="green" />
<span className="mx-1">{filter.count} {metricLabel}</span>
<span className="color-gray-medium text-sm">
({filter.completedPercentage}%) Completed
{`${data.completedPercentage}% . ${data.count}`}
</span>
</div>
{index && index > 1 && (
<Space className="items-center">
<Icon name="caret-down-fill" color={filter.droppedCount > 0 ? 'red' : 'gray-light'} size={16} />
<Icon
name="caret-down-fill"
color={data.droppedCount > 0 ? 'red' : 'gray-light'}
size={16}
/>
<span
className={'mx-1 ' + (filter.droppedCount > 0 ? 'color-red' : 'disabled')}>{filter.droppedCount} {metricLabel}</span>
<span
className={'text-sm ' + (filter.droppedCount > 0 ? 'color-red' : 'disabled')}>({filter.droppedPercentage}%) Dropped</span>
className={
'mr-1 text-sm' + (data.droppedCount > 0 ? 'color-red' : 'disabled')
}
>
{data.droppedCount} Skipped
</span>
</Space>
)}
</div>
@ -86,7 +158,7 @@ export function UxTFunnelBar(props: Props) {
const { filter } = props;
return (
<div className="w-full mb-4">
<div className="w-full mb-2">
<div className={'font-medium'}>{filter.title}</div>
<div
style={{
@ -95,22 +167,28 @@ export function UxTFunnelBar(props: Props) {
backgroundColor: '#f5f5f5',
position: 'relative',
borderRadius: '.5rem',
overflow: 'hidden'
overflow: 'hidden',
}}
>
<div
className="flex items-center"
style={{
width: `${(filter.completed / (filter.completed + filter.skipped)) * 100}%`,
width: `${
(filter.completed / (filter.completed + filter.skipped)) * 100
}%`,
position: 'absolute',
top: 0,
left: 0,
bottom: 0,
backgroundColor: '#6272FF'
backgroundColor: '#6272FF',
}}
>
<div className="color-white absolute right-0 flex items-center font-medium mr-2 leading-3">
{((filter.completed / (filter.completed + filter.skipped)) * 100).toFixed(1)}%
<div className="color-white absolute right-0 flex items-center font-medium mr-1 leading-3 text-sm">
{(
(filter.completed / (filter.completed + filter.skipped)) *
100
).toFixed(1)}
%
</div>
</div>
</div>
@ -119,22 +197,22 @@ export function UxTFunnelBar(props: Props) {
<div className={'flex items-center gap-4'}>
<div className="flex items-center">
<Icon name="arrow-right-short" size="20" color="green" />
<span className="mx-1 font-medium">{filter.completed}</span><span>completed this step</span>
<span className="mx-1 font-medium">{filter.completed}</span>
<span>completed this step</span>
</div>
<div className={'flex items-center'}>
<Icon name="clock" size="16" />
<span className="mx-1 font-medium">
{durationFormatted(filter.avgCompletionTime)}
</span>
<span>
avg. completion time
</span>
<span>avg. completion time</span>
</div>
</div>
{/* @ts-ignore */}
<div className="flex items-center">
<Icon name="caret-down-fill" color="red" size={16} />
<span className="font-medium mx-1">{filter.skipped}</span><span> skipped</span>
<span className="font-medium mx-1">{filter.skipped}</span>
<span> skipped</span>
</div>
</div>
</div>
@ -142,11 +220,3 @@ export function UxTFunnelBar(props: Props) {
}
export default FunnelBar;
const calculatePercentage = (completed: number, dropped: number) => {
const total = completed + dropped;
if (dropped === 0) return 100;
if (total === 0) return 0;
return Math.round((completed / dropped) * 100);
};

View file

@ -2,12 +2,14 @@ import React from 'react';
interface Props {
filter: any;
isHorizontal?: boolean;
}
function FunnelStepText(props: Props) {
const { filter } = props;
const total = filter.value.length;
const additionalStyle = props.isHorizontal ? { whiteSpace: 'nowrap', maxWidth: 210, textOverflow: 'ellipsis', overflow: 'hidden' } : {};
return (
<div className="mb-2 color-gray-medium">
<div className="color-gray-medium" style={additionalStyle}>
<span className="color-gray-darkest">{filter.label}</span>
<span className="mx-1">{filter.operator}</span>
{filter.value.map((value: any, index: number) => (

View file

@ -0,0 +1,128 @@
import React from 'react';
import { Table, Tooltip } from 'antd';
import type { TableProps } from 'antd';
import Widget from 'App/mstore/types/widget';
import Funnel from 'App/mstore/types/funnel';
import { ItemMenu } from 'UI';
import { EllipsisVertical, FileDown } from 'lucide-react';
import { exportAntCsv } from '../../../utils';
interface Props {
metric?: Widget;
data: { funnel: Funnel };
compData: { funnel: Funnel };
}
function FunnelTable(props: Props) {
const tableData = [
{
conversion: props.data.funnel.totalConversionsPercentage,
},
];
const tableProps: TableProps['columns'] = [
{
title: 'Conversion %',
dataIndex: 'conversion',
key: 'conversion',
fixed: 'left',
width: 140,
render: (text: string, _, index) => (
<div className={'w-full justify-between flex'}>
<div>Overall {index > 0 ? '(previous)' : ''}</div>
<div>{text}%</div>
</div>
),
},
];
React.useEffect(() => {
const funnel = props.data.funnel;
funnel.stages.forEach((st, ind) => {
const title = `${st.label} ${st.operator} ${st.value.join(' or ')}`;
const wrappedTitle =
title.length > 40 ? title.slice(0, 40) + '...' : title;
tableProps.push({
title: wrappedTitle,
dataIndex: 'st_' + ind,
key: 'st_' + ind,
ellipsis: true,
width: 120,
});
tableData[0]['st_' + ind] = st.count;
});
if (props.compData) {
tableData.push({
conversion: props.compData.funnel.totalConversionsPercentage,
})
const compFunnel = props.compData.funnel;
compFunnel.stages.forEach((st, ind) => {
tableData[1]['st_' + ind] = st.count;
});
}
}, [props.data]);
return (
<div className={'-mx-4 px-2'}>
<div className={'mt-2 relative'}>
<Table
bordered
columns={tableProps}
dataSource={tableData}
pagination={false}
size={'middle'}
scroll={{ x: 'max-content' }}
rowClassName={(_, index) => (
index > 0 ? 'opacity-70' : ''
)}
/>
<TableExporter
tableColumns={tableProps}
tableData={tableData}
filename={props.metric?.name || 'funnel'}
top={'top-1'}
/>
</div>
</div>
);
}
export function TableExporter({
tableData,
tableColumns,
filename,
top,
right,
}: {
tableData: any;
tableColumns: any;
filename: string;
top?: string;
right?: string;
}) {
const onClick = () => exportAntCsv(tableColumns, tableData, filename);
return (
<Tooltip title='Export Data to CSV'>
<div
className={`absolute ${top ? top : 'top-0'} ${
right ? right : '-right-1'
}`}
>
<ItemMenu
items={[{ icon: 'download', text: 'Export to CSV', onClick }]}
bold
customTrigger={
<div
className={
'flex items-center justify-center bg-gradient-to-r from-[#fafafa] to-neutral-200 cursor-pointer rounded-lg h-[38px] w-[38px] btn-export-table-data'
}
>
<EllipsisVertical size={16} />
</div>
}
/>
</div>
</Tooltip>
);
}
export default FunnelTable;

View file

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

View file

@ -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<number | null>(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 (
<NoContent
style={{ minHeight: 220 }}
@ -79,20 +98,21 @@ function FunnelWidget(props: Props) {
}
show={!stages || stages.length === 0}
>
<div className="w-full">
{!isWidget && (
stages.map((filter: any, index: any) => (
<div className={cn('w-full border-b -mx-4 px-4', isHorizontal ? 'overflow-x-scroll custom-scrollbar flex gap-2 justify-around' : '')}>
{!isWidget &&
shownStages.map((stage: any, index: any) => (
<Stage
key={index}
isHorizontal={isHorizontal}
index={index + 1}
isWidget={isWidget}
stage={filter}
stage={stage.data}
compData={stage.compData}
focusStage={focusStage}
focusedFilter={focusedFilter}
metricLabel={metricLabel}
/>
))
)}
))}
{isWidget && (
<>
@ -110,38 +130,56 @@ function FunnelWidget(props: Props) {
</>
)}
</div>
<div className="flex items-center pb-4">
<div className="flex items-center">
<span className="text-base font-medium mr-2">Lost conversion</span>
<Tooltip title={`${funnel.lostConversions} Sessions ${funnel.lostConversionsPercentage}%`}>
<Tag bordered={false} color="red" className="text-lg font-medium rounded-lg">
{funnel.lostConversions}
</Tag>
</Tooltip>
</div>
<div className="mx-3" />
<div className="flex items-center py-2 gap-2">
<div className="flex items-center">
<span className="text-base font-medium mr-2">Total conversion</span>
<Tooltip title={`${funnel.totalConversions} Sessions ${funnel.totalConversionsPercentage}%`}>
<Tag bordered={false} color="green" className="text-lg font-medium rounded-lg">
<Tooltip
title={`${funnel.totalConversions} Sessions ${funnel.totalConversionsPercentage}%`}
>
<Tag
bordered={false}
color="#F5F8FF"
className="text-lg rounded-lg !text-black"
>
{funnel.totalConversions}
</Tag>
</Tooltip>
</div>
<div className="flex items-center">
<span className="text-base font-medium mr-2">Lost conversion</span>
<Tooltip
title={`${funnel.lostConversions} Sessions ${funnel.lostConversionsPercentage}%`}
>
<Tag
bordered={false}
color="#FFEFEF"
className="text-lg rounded-lg !text-black"
>
{funnel.lostConversions}
</Tag>
</Tooltip>
</div>
</div>
{funnel.totalDropDueToIssues > 0 && <div className="flex items-center mb-2"><Icon name="magic" /> <span
className="ml-2">{funnel.totalDropDueToIssues} sessions dropped due to issues.</span></div>}
{funnel.totalDropDueToIssues > 0 && (
<div className="flex items-center mb-2">
<Icon name="magic" />{' '}
<span className="ml-2">
{funnel.totalDropDueToIssues} sessions dropped due to issues.
</span>
</div>
)}
</NoContent>
);
}
export const EmptyStage = observer(({ total }: any) => {
return (
<div className={cn('flex items-center mb-4 pb-3', stl.step)}>
<div className={cn('flex items-center mb-4 pb-3 relative border-b -mx-4 px-4 pt-2')}>
<IndexNumber index={0} />
<div
className="w-fit px-2 border border-teal py-1 text-center justify-center bg-teal-lightest flex items-center rounded-full color-teal"
style={{ width: '100px' }}>
style={{ width: '100px' }}
>
{`+${total} ${total > 1 ? 'steps' : 'step'}`}
</div>
<div className="border-b w-full border-dashed"></div>
@ -149,39 +187,35 @@ export const EmptyStage = observer(({ total }: any) => {
);
});
export const Stage = observer(({ metricLabel, stage, index, isWidget, uxt, focusStage, focusedFilter }: any) => {
return stage ? (
<div
className={cn('flex items-start', stl.step, { [stl['step-disabled']]: !stage.isActive })}
>
<IndexNumber index={index} />
{!uxt ? <Funnelbar metricLabel={metricLabel} index={index} filter={stage} focusStage={focusStage}
focusedFilter={focusedFilter} /> : <UxTFunnelBar filter={stage} />}
{/*{!isWidget && !uxt && <BarActions bar={stage} />}*/}
</div>
) : (
<></>
);
});
export const Stage = observer(({
metricLabel,
stage,
index,
uxt,
focusStage,
focusedFilter,
compData,
isHorizontal,
}: any) => {
return stage ? (
<div
className={cn(
'flex items-start relative pt-2',
{ [stl['step-disabled']]: !stage.isActive },
)}
>
<IndexNumber index={index} />
{!uxt ? <Funnelbar isHorizontal={isHorizontal} compData={compData} metricLabel={metricLabel} index={index} filter={stage} focusStage={focusStage} focusedFilter={focusedFilter} /> : <UxTFunnelBar filter={stage} />}
</div>
) : null
})
export const IndexNumber = observer(({ index }: any) => {
return (
<div
className="z-10 w-6 h-6 border shrink-0 mr-4 text-sm rounded-full bg-gray-lightest flex items-center justify-center leading-3">
{index === 0 ? <Icon size="14" color="gray-dark" name="list" /> : index}
</div>
);
});
const BarActions = observer(({ bar }: any) => {
return (
<div className="self-end flex items-center justify-center ml-4" style={{ marginBottom: '49px' }}>
<button onClick={() => bar.updateKey('isActive', !bar.isActive)}>
<Icon name="eye-slash-fill" color={bar.isActive ? 'gray-light' : 'gray-darkest'} size="22" />
</button>
</div>
);
});
return (
<div className="z-10 w-6 h-6 border shrink-0 mr-4 text-sm rounded-full bg-gray-lightest flex items-center justify-center leading-3">
{index === 0 ? <Icon size="14" color="gray-dark" name="list" /> : index}
</div>
);
})
export default observer(FunnelWidget);

View file

@ -17,7 +17,10 @@ function HealthModal({
}: {
getHealth: () => void;
isLoading: boolean;
healthResponse: { overallHealth: boolean; healthMap: Record<string, IServiceStats> };
healthResponse: {
overallHealth: boolean;
healthMap: Record<string, IServiceStats>;
};
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 (
<div
@ -64,7 +67,9 @@ function HealthModal({
transform: 'translate(-50%, -50%)',
}}
onClick={(e) => e.stopPropagation()}
className={'flex flex-col bg-white rounded border border-figmaColors-divider'}
className={
'flex flex-col bg-white rounded border border-figmaColors-divider'
}
>
<div
className={
@ -83,46 +88,64 @@ function HealthModal({
</div>
<Loader loading={isLoading}>
<div className={'flex w-full'}>
<div className={'flex flex-col h-full'} style={{ flex: 1 }}>
{isLoading ? null
: Object.keys(healthResponse.healthMap).map((service) => (
<React.Fragment key={service}>
<Category
onClick={() => setSelectedService(service)}
healthOk={healthResponse.healthMap[service].healthOk}
name={healthResponse.healthMap[service].name}
isSelectable
isSelected={selectedService === service}
{healthResponse ? (
<>
<div className={'flex w-full'}>
<div className={'flex flex-col h-full'} style={{ flex: 1 }}>
{isLoading
? null
: Object.keys(healthResponse.healthMap).map((service) => (
<React.Fragment key={service}>
<Category
onClick={() => setSelectedService(service)}
healthOk={
healthResponse.healthMap[service].healthOk
}
name={healthResponse.healthMap[service].name}
isSelectable
isSelected={selectedService === service}
/>
</React.Fragment>
))}
</div>
<div
className={
'bg-gray-lightest border-l w-fit border-figmaColors-divider overflow-y-scroll relative'
}
style={{ flex: 2, height: 420 }}
>
{isLoading ? null : selectedService ? (
<ServiceStatus
service={healthResponse.healthMap[selectedService]}
/>
</React.Fragment>
))}
) : (
<img src={slide} width={392} />
)}
</div>
</div>
{isSetup ? (
<div
className={
'p-4 mt-auto w-full border-t border-figmaColors-divider'
}
>
<Button
disabled={!healthResponse?.overallHealth}
loading={isLoading}
variant={'primary'}
className={'ml-auto'}
onClick={() => setPassed?.()}
>
Create Account
</Button>
</div>
) : null}
</>
) : (
<div className={'w-full h-full flex items-center justify-center'}>
<div>Error while fetching data...</div>
</div>
<div
className={
'bg-gray-lightest border-l w-fit border-figmaColors-divider overflow-y-scroll relative'
}
style={{ flex: 2, height: 420 }}
>
{isLoading ? null : selectedService ? (
<ServiceStatus service={healthResponse.healthMap[selectedService]} />
) : <img src={slide} width={392} />
}
</div>
</div>
{isSetup ? (
<div className={'p-4 mt-auto w-full border-t border-figmaColors-divider'}>
<Button
disabled={!healthResponse?.overallHealth}
loading={isLoading}
variant={'primary'}
className={'ml-auto'}
onClick={() => setPassed?.()}
>
Create Account
</Button>
</div>
) : null}
)}
</Loader>
<Footer isSetup={isSetup} />
</div>
@ -137,7 +160,10 @@ function ServiceStatus({ service }: { service: Record<string, any> }) {
<div className={'border rounded border-light-gray'}>
{Object.keys(subservices).map((subservice: string) => (
<React.Fragment key={subservice}>
<SubserviceHealth name={subservice} subservice={subservices[subservice]} />
<SubserviceHealth
name={subservice}
subservice={subservices[subservice]}
/>
</React.Fragment>
))}
</div>

View file

@ -315,6 +315,7 @@ function PanelComponent({
/>
{summaryChecked ? (
<Segmented
size='small'
value={zoomTab}
onChange={(val) => setZoomTab(val)}
options={[

View file

@ -58,7 +58,7 @@ const PerformanceGraph = React.memo((props: Props) => {
{disabled ? (
<div
className={
'flex justify-center'
'flex justify-start'
}
>
<div className={'text-xs text-neutral-400 ps-2'}>

View file

@ -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'}
>
<div className={'text-disabled-text'}>@{shortDurationFromMs(pointer.time)}</div>
<div className={'text-secondary'}>@{shortDurationFromMs(pointer.time)}</div>
<RenderLineData type={type} item={pointer} />
</div>
))}

View file

@ -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"
/>
</Tooltip>
<AssistSearchField />
<AssistSearchActions />
</div>
<div className="flex self-end items-center gap-2" w-full>
<span className="color-gray-medium">Sort By</span>

View file

@ -33,9 +33,14 @@ const Signup: React.FC<SignupProps> = ({ 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(() => {

View file

@ -50,7 +50,7 @@ const SpotsListHeader = observer(
<div className={'flex items-center justify-between w-full'}>
<div className="flex gap-1 items-center">
<h1 className={'text-2xl capitalize mr-2'}>Spot List</h1>
<ReloadButton buttonSize={'small'} onClick={onRefresh} iconSize={16} />
<ReloadButton buttonSize={'small'} onClick={onRefresh} iconSize={14} />
</div>
{tenantHasSpots ? (

View file

@ -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)
</div>
);
}
return WrapperClass;
return observer(WrapperClass);
}

View file

@ -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 (
<Button onClick={handleBackClick} type={'text'} icon={<ArrowLeftOutlined />} />
)
}
return (
<Button type="text" onClick={handleBackClick} icon={<LeftOutlined />} className="px-1 pe-2 me-2 gap-1">
Back

View file

@ -1,9 +1,8 @@
import React from 'react';
import { Icon, Input, Button } from 'UI';
import { Icon, Input } from 'UI';
import cn from 'classnames';
import FilterList from 'Shared/Filters/FilterList';
import { FilterList } from 'Shared/Filters/FilterList';
import { observer } from 'mobx-react-lite';
import FilterSelection from 'Shared/Filters/FilterSelection';
import { Typography } from 'antd';
import { BranchesOutlined } from '@ant-design/icons';
@ -84,29 +83,16 @@ function ConditionSetComponent({
onRemoveFilter={onRemoveFilter}
onChangeEventsOrder={onChangeEventsOrder}
hideEventsOrder
onAddFilter={onAddFilter}
excludeFilterKeys={excludeFilterKeys}
readonly={readonly}
isConditional={isConditional}
borderless
/>
{readonly && !conditions.filter?.filters?.length ? (
<div className={'p-2'}>No conditions</div>
) : null}
</div>
{readonly ? null : (
<div className={'px-2'}>
<FilterSelection
isConditional={isConditional}
filter={undefined}
onFilterClick={onAddFilter}
excludeFilterKeys={excludeFilterKeys}
isMobile={isMobile}
>
<Button variant="text-primary" icon="plus">
Add Condition
</Button>
</FilterSelection>
</div>
)}
</div>
<div className={'px-4 py-2 flex items-center gap-2 border-t'}>
<span>{bottomLine1}</span>

View file

@ -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<string | null>(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 (
<div className={styles.wrapper}>
<div className={`${styles.body} h-fit`}>
@ -84,41 +102,51 @@ function DateRangePopup(props: any) {
isOpen
maxDate={new Date()}
value={rangeForDisplay}
calendarProps={{
tileDisabled: props.isTileDisabled,
selectRange: props.singleDay ? false : true,
}}
/>
</div>
</div>
<div className="flex items-center justify-between py-2 px-3">
<div className="flex items-center gap-2">
<label>From: </label>
<span>{range.start.toFormat(isUSLocale ? "MM/dd" : "dd/MM")} </span>
<TimePicker
format={isUSLocale ? 'hh:mm a' : "HH:mm"}
value={range.start}
onChange={setRangeTimeStart}
needConfirm={false}
showNow={false}
style={{ width: isUSLocale ? 102 : 76 }}
/>
<label>To: </label>
<span>{range.end.toFormat(isUSLocale ? "MM/dd" : "dd/MM")} </span>
<TimePicker
format={isUSLocale ? 'hh:mm a' : "HH:mm"}
value={range.end}
onChange={setRangeTimeEnd}
needConfirm={false}
showNow={false}
style={{ width: isUSLocale ? 102 : 76 }}
/>
</div>
{props.singleDay ? (
<div>
Compare from {range.start.toFormat('MMM dd, yyyy')}
</div>
) : (
<div className="flex items-center gap-2">
<label>From: </label>
<span>{range.start.toFormat(isUSLocale ? 'MM/dd' : 'dd/MM')} </span>
<TimePicker
format={isUSLocale ? 'hh:mm a' : 'HH:mm'}
value={range.start}
onChange={setRangeTimeStart}
needConfirm={false}
showNow={false}
style={{ width: isUSLocale ? 102 : 76 }}
/>
<label>To: </label>
<span>{range.end.toFormat(isUSLocale ? 'MM/dd' : 'dd/MM')} </span>
<TimePicker
format={isUSLocale ? 'hh:mm a' : 'HH:mm'}
value={range.end}
onChange={setRangeTimeEnd}
needConfirm={false}
showNow={false}
style={{ width: isUSLocale ? 102 : 76 }}
/>
</div>
)}
<div className="flex items-center">
<Button onClick={onCancel}>{"Cancel"}</Button>
<Button onClick={onCancel}>{'Cancel'}</Button>
<Button
type="primary"
className="ml-2"
onClick={onApply}
disabled={!range}
>
{"Apply"}
{'Apply'}
</Button>
</div>
</div>
@ -126,4 +154,4 @@ function DateRangePopup(props: any) {
);
}
export default DateRangePopup;
export default DateRangePopup;

View file

@ -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 (
<Dropdown menu={menuProps} className={'px-2 py-1'}>
{useButtonStyle ? (
<div
className={
'flex items-center gap-2 border border-gray-light rounded cursor-pointer'
}
>
{leftIcon}
<span>{label}</span>
{rightIcon}
</div>
) : (
<div className={'cursor-pointer flex items-center gap-2'}>
{leftIcon}
<span>{label}</span>
{rightIcon}
</div>
)}
</Dropdown>
);
}
export default AntlikeDropdown;

View file

@ -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<HTMLDivElement>(null);
const [isTruncated, setIsTruncated] = useState(false);
useEffect(() => {
if (textRef.current) {
setIsTruncated(textRef.current.scrollWidth > textRef.current.offsetWidth);
}
}, [text]);
return (
<Tooltip title={isTruncated ? text : ''}>
<div
ref={textRef}
className="truncate"
style={{
maxWidth,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{text}
</div>
</Tooltip>
);
}
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<string[]>(
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 (
<OutsideClickDetectingDiv
className={cn(
'absolute left-0 mt-2 p-4 bg-white rounded-xl shadow border-gray-light z-10'
)}
style={{ width: 360, minHeight: 100, top: '100%' }}
onClickOutside={() => {
onClose();
}}
>
<Input.Search
value={query}
onFocus={handleFocus}
loading={isLoading}
onChange={(e) => handleInputChange(e.target.value)}
placeholder={placeholder}
className="rounded-lg"
/>
<Loader loading={isLoading}>
<>
<div
className={
'flex flex-col gap-2 overflow-y-auto py-2 overflow-x-hidden text-ellipsis'
}
style={{ maxHeight: 200 }}
>
{sortedOptions.map((item) => (
<div
key={item.value}
onClick={() => onSelectOption(item)}
className={
'cursor-pointer w-full py-1 hover:bg-active-blue rounded px-2'
}
>
<Checkbox checked={isSelected(item)} /> {item.label}
</div>
))}
</div>
{query.length ? (
<div className={'border-y border-y-gray-light py-2'}>
<div
className={
'whitespace-normal rounded cursor-pointer text-blue hover:bg-active-blue px-2 py-1'
}
onClick={applyQuery}
>
Apply {queryStr}
</div>
</div>
) : null}
</>
</Loader>
<div className={'flex gap-2 items-center pt-2'}>
<Button type={'primary'} onClick={applyValues} className="btn-apply-event-value">
Apply
</Button>
<Button onClick={onClose} className="btn-cancel-event-value">
Cancel
</Button>
</div>
</OutsideClickDetectingDiv>
);
}
// 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<HTMLDivElement>(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 (
<div
className={
'rounded-lg border border-gray-light px-2 relative w-full pr-4 whitespace-nowrap flex items-center bg-white hover:border-neutral-400'
}
style={{ height: 26 }}
ref={filterValueContainer}
>
<div
onClick={() => setTimeout(() => setShowValueModal(true), 0)}
className={'flex items-center gap-2 cursor-pointer'}
>
{!isEmpty ? (
<>
<TruncatedText
text={props.mapValues ? props.mapValues(props.value[0]) : props.value[0]}
maxWidth="8rem"
/>
{props.value.length > 1 && (
<>
<span className="text-neutral-500/90">or</span>
<TruncatedText
text={props.mapValues ? props.mapValues(props.value[1]) : props.value[1]}
maxWidth="8rem"
/>
{props.value.length > 2 && (
<TruncatedText
text={`+ ${props.value.length - 1} More`}
maxWidth="8rem"
/>
)}
</>
)}
</>
) : (
<div className="text-neutral-500/90">
{props.placeholder ? props.placeholder : 'Select value(s)'}
</div>
)}
</div>
{showValueModal ? (
<props.modalRenderer
{...props.modalProps}
params={props.params}
onClose={onClose}
onApply={onApply}
values={props.value}
/>
) : null}
</div>
);
}

View file

@ -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<string, any>
}
const FilterAutoComplete: React.FC<Props> = ({
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<any>(null);
const inputRef = useRef<any>(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 <AutocompleteModal
values={values}
onClose={onClose}
onApply={onApply}
handleFocus={handleFocus}
loadOptions={handleInputChange}
options={options}
isLoading={loading}
placeholder={placeholder}
/>
}
);
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 <AutoCompleteContainer {...props} modalRenderer={FilterAutoComplete} />
}
return (
<div className="relative flex items-center">
<div className={cn(stl.wrapper, 'relative')}>
<input
ref={inputRef}
className="w-full rounded px-2 no-focus"
value={query}
onChange={(e: ChangeEvent<HTMLInputElement>) => handleInputChange(e.target.value)}
onClick={handleFocus}
onFocus={handleFocus}
onBlur={handleBlur}
placeholder={placeholder}
onKeyDown={(e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
inputRef.current.blur();
}
}}
/>
{loading && (
<div
className="absolute top-0 right-0"
style={{
marginTop: '5px',
marginRight: !showCloseButton || (showCloseButton && !showOrButton) ? '34px' : '62px'
}}
>
<Icon name="spinner" className="animate-spin" size="14" />
</div>
)}
<Select
ref={selectRef}
options={selectOptionsArr}
value={selected}
onChange={(e) => handleChange(e as { value: string })}
menuIsOpen={initialFocus && menuIsOpen}
menuPlacement="auto"
styles={dropdownStyles}
components={{
Control: () => null
}}
/>
<div className={stl.right}>
{showCloseButton && (
<div onClick={onRemoveValue}>
<Icon name="close" size="12" />
</div>
)}
{showOrButton && (
<div onClick={onAddValue} className="color-teal">
<span className="px-1">or</span>
</div>
)}
</div>
</div>
{!showOrButton && !hideOrText && <div className="ml-3">or</div>}
</div>
);
};
export default observer(FilterAutoComplete);
export default AutoCompleteController;

View file

@ -1,90 +1,61 @@
import React, { useState, useEffect } from 'react';
import { Icon } from 'UI';
import stl from './FilterAutoCompleteLocal.module.css';
import { Input } from 'antd';
import { AutocompleteModal, AutoCompleteContainer } from 'Shared/Filters/FilterAutoComplete/AutocompleteModal';
interface Props {
showOrButton?: boolean;
showCloseButton?: boolean;
onRemoveValue?: () => void;
onAddValue?: () => void;
onRemoveValue?: (index: number) => void;
onAddValue?: (index: number) => void;
placeholder?: string;
onSelect: (e, item) => void;
onSelect: (e: any, item: Record<string, any>, index: number) => void;
value: any;
icon?: string;
type?: string;
isMultilple?: boolean;
isMultiple?: boolean;
allowDecimals?: boolean;
modalProps?: Record<string, any>;
onApplyValues: (values: string[]) => void;
}
function FilterAutoCompleteLocal(props: Props) {
function FilterAutoCompleteLocal(props: { params: any, values: string[], onClose: () => void, onApply: (values: string[]) => void, placeholder?: string }) {
const {
showCloseButton = false,
placeholder = 'Enter',
showOrButton = false,
onRemoveValue = () => null,
onAddValue = () => null,
value = '',
icon = null,
type = "text",
isMultilple = true,
allowDecimals = true,
params = {},
onClose,
onApply,
placeholder = 'Enter',
values,
} = props;
const [showModal, setShowModal] = useState(true)
const [query, setQuery] = useState(value);
const onInputChange = (e) => {
if(allowDecimals) {
const value = e.target.value;
setQuery(value);
props.onSelect(null, value);
} else {
const value = e.target.value.replace(/[^\d]/, "");
if (+value !== 0) {
setQuery(value);
props.onSelect(null, value);
}
}
};
useEffect(() => {
setQuery(value);
}, [value])
const onBlur = (e) => {
setTimeout(() => { setShowModal(false) }, 200)
props.onSelect(e, { value: query })
}
const handleKeyDown = (e) => {
if (e.key === 'Enter') {
props.onSelect(e, { value: query })
}
}
return (
<div className="relative flex items-center">
<div className={stl.wrapper}>
<input
name="query"
onInput={ onInputChange }
// onBlur={ onBlur }
onFocus={ () => setShowModal(true)}
value={ query }
autoFocus={ true }
type={ type }
placeholder={ placeholder }
onKeyDown={handleKeyDown}
/>
<div
className={stl.right}
>
{ showCloseButton && <div onClick={onRemoveValue}><Icon name="close" size="12" /></div> }
{ showOrButton && <div onClick={onAddValue} className="color-teal"><span className="px-1">or</span></div> }
</div>
</div>
{ !showOrButton && isMultilple && <div className="ml-3">or</div> }
</div>
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 <AutocompleteModal
values={values}
onClose={onClose}
onApply={onApplyValues}
loadOptions={splitValues}
options={options}
isLoading={false}
placeholder={placeholder}
commaQuery
/>
}
export default FilterAutoCompleteLocal;
function FilterLocalController(props: Props) {
return <AutoCompleteContainer {...props} modalRenderer={FilterAutoCompleteLocal} />
}
export default FilterLocalController;

View file

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

View file

@ -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<string>;
excludeCategory?: Array<string>;
allowedFilterKeys?: Array<string>;
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 (
<div className="flex items-center w-full">
<div className="flex items-start w-full">
<div className="flex items-center w-full flex-wrap">
{!isFilter && !hideIndex && filterIndex >= 0 && (
<div
className="mt-1 flex-shrink-0 border w-6 h-6 text-xs flex items-center justify-center rounded-full bg-gray-light-shade mr-2">
className="flex-shrink-0 w-6 h-6 text-xs flex items-center justify-center rounded-full bg-gray-lighter mr-2">
<span>{filterIndex + 1}</span>
</div>
)}
<FilterSelection
filter={filter}
mode={props.isFilter ? 'filters' : 'events'}
onFilterClick={replaceFilter}
allowedFilterKeys={allowedFilterKeys}
excludeFilterKeys={excludeFilterKeys}
excludeCategory={excludeCategory}
disabled={disableDelete || props.readonly}
/>
{/* Filter with Source */}
{filter.hasSource && (
<>
<FilterOperator
options={filter.sourceOperatorOptions}
onChange={onSourceOperatorChange}
className="mx-2 flex-shrink-0"
value={filter.sourceOperator}
isDisabled={filter.operatorDisabled || props.readonly}
/>
<FilterSource filter={filter} onUpdate={props.onUpdate}/>
</>
)}
<div className={cn('flex items-center flex-wrap', isReversed ? 'flex-row-reverse ml-2' : 'flex-row')}>
{/* Filter with Source */}
{filter.hasSource && (
<>
<FilterOperator
options={filter.sourceOperatorOptions}
onChange={onSourceOperatorChange}
className="mx-2 flex-shrink-0 btn-event-operator"
value={filter.sourceOperator}
isDisabled={filter.operatorDisabled || props.readonly}
/>
<FilterSource filter={filter} onUpdate={props.onUpdate}/>
</>
)}
{/* Filter values */}
{!isSubFilter && filter.operatorOptions && (
<>
<FilterOperator
options={filter.operatorOptions}
onChange={onOperatorChange}
className="mx-2 flex-shrink-0"
value={filter.operator}
isDisabled={filter.operatorDisabled || props.readonly}
/>
{canShowValues && (
<>
{props.readonly ? (
<div
className={'rounded bg-active-blue px-2 py-1 ml-2 whitespace-nowrap overflow-hidden text-clip'}
>
{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(', ')}
</div>
) : (
<FilterValue isConditional={isConditional} filter={filter} onUpdate={props.onUpdate}/>
)}
</>
)}
</>
)}
{/* Filter values */}
{!isSubFilter && filter.operatorOptions && (
<>
<FilterOperator
options={filter.operatorOptions}
onChange={onOperatorChange}
className="mx-2 flex-shrink-0 btn-sub-event-operator"
value={filter.operator}
isDisabled={filter.operatorDisabled || props.readonly}
/>
{canShowValues && (
<>
{props.readonly ? (
<div
className={'rounded bg-active-blue px-2 py-1 ml-2 whitespace-nowrap overflow-hidden text-clip hover:border-neutral-400'}
>
{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(', ')}
</div>
) : (
<FilterValue isConditional={isConditional} filter={filter} onUpdate={props.onUpdate}/>
)}
</>
)}
</>
)}
</div>
{/* filters */}
{isSubFilter && (
@ -156,6 +164,7 @@ function FilterItem(props: Props) {
type="text"
onClick={props.onRemoveFilter}
size="small"
className='btn-remove-step mt-2'
>
<CircleMinus size={14} />
</Button>

View file

@ -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 <div className="flex items-center gap-2">
<div
className="color-gray-medium text-sm"
style={{textDecoration: "underline dotted"}}
const selected = menuItems.find(
(item) => item.key === filter.eventsOrder
)?.label;
return (
<div className="flex items-center gap-2">
<Tooltip
title="Select the operator to be applied between events."
placement="bottom"
>
<Tooltip
title={`Select the operator to be applied between events in your search.`}
>
<div>Events Order</div>
</Tooltip>
</div>
<div className="text-neutral-500/90 text-sm font-normal cursor-default">Events Order</div>
</Tooltip>
<Segmented
size={"small"}
className="text-sm"
onChange={(v) => onChange(null, options.find((i) => i.value === v))}
value={filter.eventsOrder}
options={options}
/>
</div>;
});
<Dropdown
menu={{ items: menuItems, onClick }}
trigger={['click']}
placement="bottomRight"
className="text-sm rounded-lg px-1 py-0.5 btn-events-order "
data-event="btn-events-order"
>
<Button size={'small'} type='text'>{selected || 'Select'} </Button>
</Dropdown>
</div>
);
}
);
export default EventsOrder;

View file

@ -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<string>;
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<string>;
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 (
<div
className={cn(
'bg-white',
borderless ? '' : 'pt-2 px-4 rounded-xl border border-gray-lighter'
)}
style={{
borderBottomLeftRadius: props.mergeDown ? 0 : undefined,
borderBottomRightRadius: props.mergeDown ? 0 : undefined,
borderTopLeftRadius: props.mergeUp ? 0 : undefined,
borderTopRightRadius: props.mergeUp ? 0 : undefined,
}}
>
<div className={'flex items-center py-2'} style={{ gap: '0.65rem' }}>
<div className="font-medium">Filters</div>
<FilterSelection
mode={'filters'}
filter={undefined}
onFilterClick={onAddFilter}
disabled={readonly}
excludeFilterKeys={excludeFilterKeys}
excludeCategory={excludeCategory}
>
<Button
icon={<Filter size={16} strokeWidth={1} />}
type="default"
size={'small'}
className='btn-add-filter'
>
Add
</Button>
</FilterSelection>
</div>
{filters.map((filter: any, filterIndex: any) =>
!filter.isEvent ? (
<div
key={`${filter.key}-${filterIndex}`}
className={'py-2 hover:bg-active-blue px-5 '}
style={{
marginLeft: '-1.25rem',
width: 'calc(100% + 2.5rem)',
}}
>
<FilterItem
key={filterIndex}
readonly={props.readonly}
isFilter={true}
filterIndex={filterIndex}
filter={filter}
onUpdate={(filter) => props.onUpdateFilter(filterIndex, filter)}
onRemoveFilter={() => onRemoveFilter(filterIndex)}
excludeFilterKeys={excludeFilterKeys}
isConditional={isConditional}
/>
</div>
) : null
)}
</div>
);
});
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<Record<string, any>>({
i: null,
position: null,
});
const [draggedInd, setDraggedItem] = React.useState<number | null>(null);
let rowIndex = 0;
const cannotDeleteFilter = hasEvents && !supportsEmpty;
const handleDragOverEv = (event: Record<string, any>, 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<Record<string, any>>({
i: null,
position: null,
});
const [draggedInd, setDraggedItem] = React.useState<number | null>(null);
const handleDragStart = React.useCallback((
ev: Record<string, any>,
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<string, any>, 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<string, any>) => {
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<string, any>, 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 (
<div className="flex flex-col">
{hasEvents && (
<>
<div className="flex items-center mb-2">
<div className="text-sm color-gray-medium mr-auto">
{filter.eventsHeader || 'EVENTS'}
</div>
const handleDrop = React.useCallback(
(event: Record<string, any>) => {
event.preventDefault();
if (draggedInd === null) return;
const newItems = filters;
const newPosition = calculateNewPosition(
draggedInd,
hoveredItem.i,
hoveredItem.position
);
<Space>
{!hideEventsOrder && <EventsOrder filter={filter}
onChange={props.onChangeEventsOrder}/>}
{actions && actions.map((action, index) => (
<div key={index}>{action}</div>
))}
</Space>
</div>
<div className={'flex flex-col'}>
{filters.map((filter: any, filterIndex: number) =>
filter.isEvent ? (
<div
style={{
pointerEvents: 'unset',
paddingTop:
hoveredItem.i === filterIndex &&
hoveredItem.position === 'top'
? '1.5rem'
: '0.5rem',
paddingBottom:
hoveredItem.i === filterIndex &&
hoveredItem.position === 'bottom'
? '1.5rem'
: '0.5rem',
marginLeft: '-1.25rem',
width: 'calc(100% + 2.5rem)',
}}
className={
'hover:bg-active-blue px-5 gap-2 items-center flex'
}
id={`${filter.key}-${filterIndex}`}
onDragOver={(e) => handleDragOverEv(e, filterIndex)}
onDrop={(e) => handleDrop(e)}
key={`${filter.key}-${filterIndex}`}
>
{!!props.onFilterMove && eventsNum > 1 ? (
<div
className={'p-2 cursor-grab'}
draggable={!!props.onFilterMove}
onDragStart={(e) =>
handleDragStart(
e,
filterIndex,
`${filter.key}-${filterIndex}`
)
}
>
<GripHorizontal size={16}/>
</div>
) : null}
<FilterItem
filterIndex={rowIndex++}
filter={filter}
onUpdate={(filter) =>
props.onUpdateFilter(filterIndex, filter)
}
onRemoveFilter={() => onRemoveFilter(filterIndex)}
saveRequestPayloads={saveRequestPayloads}
disableDelete={cannotDeleteFilter}
excludeFilterKeys={excludeFilterKeys}
readonly={props.readonly}
isConditional={isConditional}
/>
</div>
) : null
)}
</div>
<div className="mb-2"/>
</>
)}
const reorderedItem = newItems.splice(draggedInd, 1)[0];
newItems.splice(newPosition, 0, reorderedItem);
{hasFilters && (
<>
{hasEvents && <div className="border-t -mx-5 mb-4"/>}
<div className="mb-2 text-sm color-gray-medium mr-auto">FILTERS</div>
{filters.map((filter: any, filterIndex: any) =>
!filter.isEvent ? (
<div className={'py-2 hover:bg-active-blue px-5'} style={{
marginLeft: '-1.25rem',
width: 'calc(100% + 2.5rem)',
}}>
<FilterItem
key={filterIndex}
readonly={props.readonly}
isFilter={true}
filterIndex={filterIndex}
filter={filter}
onUpdate={(filter) => props.onUpdateFilter(filterIndex, filter)}
onRemoveFilter={() => onRemoveFilter(filterIndex)}
excludeFilterKeys={excludeFilterKeys}
isConditional={isConditional}
/>
</div>
) : 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 (
<div
className={
'border-b border-b-gray-lighter pt-2 px-4 rounded-xl bg-white border border-gray-lighter'
}
style={{
borderBottomLeftRadius: props.mergeDown ? 0 : undefined,
borderBottomRightRadius: props.mergeDown ? 0 : undefined,
borderTopLeftRadius: props.mergeUp ? 0 : undefined,
borderTopRightRadius: props.mergeUp ? 0 : undefined,
marginBottom: props.mergeDown ? '-1px' : undefined,
}}
>
<div className="flex items-center mb-2 gap-2">
<div className="font-medium">Events</div>
{cannotAdd ? null : (
<FilterSelection
mode={'events'}
filter={undefined}
onFilterClick={onAddFilter}
excludeCategory={excludeCategory}
>
<Button
icon={<Plus size={16} strokeWidth={1} />}
type="default"
size={'small'}
className='btn-add-event'
>
Add
</Button>
</FilterSelection>
)}
<div className={'ml-auto'}>
{!hideEventsOrder && (
<EventsOrder filter={filter} onChange={props.onChangeEventsOrder} />
)}
{actions &&
actions.map((action, index) => <div key={index}>{action}</div>)}
</div>
);
}
export default observer(FilterList);
</div>
<div className={'flex flex-col '}>
{filters.map((filter: any, filterIndex: number) =>
filter.isEvent ? (
<div
className={cn(
'hover:bg-active-blue px-5 pe-3 gap-2 items-center flex',
{
'bg-[#f6f6f6]': hoveredItem.i === filterIndex,
}
)}
style={{
pointerEvents: 'unset',
paddingTop:
hoveredItem.i === filterIndex && hoveredItem.position === 'top'
? ''
: '',
paddingBottom:
hoveredItem.i === filterIndex && hoveredItem.position === 'bottom'
? ''
: '',
marginLeft: '-1rem',
width: 'calc(100% + 2rem)',
alignItems: 'start',
borderTop:
hoveredItem.i === filterIndex && hoveredItem.position === 'top'
? '1px dashed #888'
: undefined,
borderBottom:
hoveredItem.i === filterIndex && hoveredItem.position === 'bottom'
? '1px dashed #888'
: undefined,
}}
id={`${filter.key}-${filterIndex}`}
onDragOver={(e) => handleDragOverEv(e, filterIndex)}
onDrop={(e) => handleDrop(e)}
key={`${filter.key}-${filterIndex}`}
>
{!!props.onFilterMove && eventsNum > 1 ? (
<div
className={
'cursor-grab text-neutral-500/90 hover:bg-white px-1 mt-2.5 rounded-lg'
}
draggable={!!props.onFilterMove}
onDragStart={(e) =>
handleDragStart(e, filterIndex, `${filter.key}-${filterIndex}`)
}
onDragEnd={() => {
setHoveredItem({ i: null, position: null });
setDraggedItem(null);
}}
style={{
cursor: draggedInd !== null ? 'grabbing' : 'grab',
}}
>
<GripVertical size={16} />
</div>
) : null}
<FilterItem
filterIndex={rowIndex++}
filter={filter}
onUpdate={(filter) => props.onUpdateFilter(filterIndex, filter)}
onRemoveFilter={() => onRemoveFilter(filterIndex)}
saveRequestPayloads={saveRequestPayloads}
disableDelete={cannotDeleteFilter}
excludeFilterKeys={excludeFilterKeys}
readonly={props.readonly}
isConditional={isConditional}
excludeCategory={excludeCategory}
/>
</div>
) : null
)}
</div>
</div>
);
});

View file

@ -1 +1 @@
export { default } from './FilterList';
export { FilterList, EventsList } from './FilterList';

View file

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

View file

@ -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]: <Pointer size={18} />,
[FilterKey.LOCATION]: <Navigation size={18} />,
[FilterKey.INPUT]: <RectangleEllipsis size={18} />,
[FilterKey.CUSTOM]: <Code size={18} />,
[FilterKey.FETCH]: <ArrowUpDown size={18} />,
[FilterKey.GRAPHQL]: <Network size={18} />,
[FilterKey.STATEACTION]: <RectangleEllipsis size={18} />,
[FilterKey.ERROR]: <OctagonAlert size={18} />,
[FilterKey.ISSUE]: <CircleAlert size={18} />,
[FilterKey.FETCH_FAILED]: <Code size={18} />,
[FilterKey.DOM_COMPLETE]: <ArrowUpDown size={18} />,
[FilterKey.LARGEST_CONTENTFUL_PAINT_TIME]: <Network size={18} />,
[FilterKey.TTFB]: <Timer size={18} />,
[FilterKey.AVG_CPU_LOAD]: <Cpu size={18} />,
[FilterKey.AVG_MEMORY_USAGE]: <MemoryStick size={18} />,
[FilterKey.USERID]: <SquareUser size={18} />,
[FilterKey.USERANONYMOUSID]: <VenetianMask size={18} />,
[FilterKey.USER_CITY]: <Pin size={18} />,
[FilterKey.USER_STATE]: <MapPin size={18} />,
[FilterKey.USER_COUNTRY]: <Earth size={18} />,
[FilterKey.USER_DEVICE]: <Code size={18} />,
[FilterKey.USER_OS]: <AppWindow size={18} />,
[FilterKey.USER_BROWSER]: <Chrome size={18} />,
[FilterKey.PLATFORM]: <MonitorSmartphone size={18} />,
[FilterKey.REVID]: <FileStack size={18} />,
[FilterKey.REFERRER]: <Workflow size={18} />,
[FilterKey.DURATION]: <Clock2 size={18} />,
[FilterKey.TAGGED_ELEMENT]: <SquareMousePointer size={18} />,
[FilterKey.METADATA]: <ContactRound size={18} />,
[FilterKey.UTM_SOURCE]: <CornerDownRight size={18} />,
[FilterKey.UTM_MEDIUM]: <Layers size={18} />,
[FilterKey.UTM_CAMPAIGN]: <Megaphone size={18} />,
[FilterKey.FEATURE_FLAG]: <Flag size={18} />
export const IconMap = {
[FilterKey.CLICK]: <Pointer size={14}/>,
[FilterKey.LOCATION]: <Navigation size={14} />,
[FilterKey.INPUT]: <RectangleEllipsis size={14} />,
[FilterKey.CUSTOM]: <Code size={14} />,
[FilterKey.FETCH]: <ArrowUpDown size={14} />,
[FilterKey.GRAPHQL]: <Network size={14} />,
[FilterKey.STATEACTION]: <RectangleEllipsis size={14} />,
[FilterKey.ERROR]: <OctagonAlert size={14} />,
[FilterKey.ISSUE]: <CircleAlert size={14} />,
[FilterKey.FETCH_FAILED]: <Code size={14} />,
[FilterKey.DOM_COMPLETE]: <ArrowUpDown size={14} />,
[FilterKey.LARGEST_CONTENTFUL_PAINT_TIME]: <Network size={14} />,
[FilterKey.TTFB]: <Timer size={14} />,
[FilterKey.AVG_CPU_LOAD]: <Cpu size={14} />,
[FilterKey.AVG_MEMORY_USAGE]: <MemoryStick size={14} />,
[FilterKey.USERID]: <SquareUser size={14} />,
[FilterKey.USERANONYMOUSID]: <VenetianMask size={14} />,
[FilterKey.USER_CITY]: <Pin size={14} />,
[FilterKey.USER_STATE]: <MapPin size={14} />,
[FilterKey.USER_COUNTRY]: <Earth size={14} />,
[FilterKey.USER_DEVICE]: <Code size={14} />,
[FilterKey.USER_OS]: <AppWindow size={14} />,
[FilterKey.USER_BROWSER]: <Chrome size={14} />,
[FilterKey.PLATFORM]: <MonitorSmartphone size={14} />,
[FilterKey.REVID]: <FileStack size={14} />,
[FilterKey.REFERRER]: <Workflow size={14} />,
[FilterKey.DURATION]: <Clock2 size={14} />,
[FilterKey.TAGGED_ELEMENT]: <SquareMousePointer size={14} />,
[FilterKey.METADATA]: <ContactRound size={14} />,
[FilterKey.UTM_SOURCE]: <CornerDownRight size={14} />,
[FilterKey.UTM_MEDIUM]: <Layers size={14} />,
[FilterKey.UTM_CAMPAIGN]: <Megaphone size={14} />,
[FilterKey.FEATURE_FLAG]: <Flag size={14} />,
};
function filterJson(
jsonObj: Record<string, any>,
excludeKeys: string[] = [],
allowedFilterKeys: string[] = []
excludeCategory: string[] = [],
allowedFilterKeys: string[] = [],
mode: 'filters' | 'events'
): Record<string, any> {
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<string>;
excludeCategory?: Array<string>;
allowedFilterKeys?: Array<string>;
isConditional?: boolean;
isMobile?: boolean;
mode: 'filters' | 'events';
}
export const getNewIcon = (filter: Record<string, any>) => {
if (filter.icon?.includes('metadata')) {
return IconMap[FilterKey.METADATA];
}
// @ts-ignore
if (IconMap[filter.key]) {
// @ts-ignore
return IconMap[filter.key];
} else return <Icon name={filter.icon} size={16} />;
};
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<string, any>) => {
if (filter.icon?.includes('metadata')) {
return IconMap[FilterKey.METADATA];
}
// @ts-ignore
if (IconMap[filter.key]) {
// @ts-ignore
return IconMap[filter.key];
} else return <Icon name={filter.icon} size={16} />;
};
const displayedFilters =
category === 'All'
? Object.entries(matchingFilters).flatMap(([category, filters]) =>
filters.map((f: any) => ({ ...f, category }))
)
: matchingFilters[category];
console.log(displayedFilters)
return (
<div
className={stl.wrapper}
style={{ width: '480px', maxHeight: '380px', overflowY: 'auto', borderRadius: '.5rem' }}
style={{ width: '560px', maxHeight: '380px' }}
>
<div
className={searchQuery && !isResultEmpty ? 'mb-6' : ''}
style={{ columns: matchingCategories.length > 1 ? 'auto 200px' : 1 }}
>
{matchingCategories.map((key) => {
return (
<Input
className={'mb-4 rounded-xl text-lg font-medium placeholder:text-lg placeholder:font-medium placeholder:text-neutral-300'}
placeholder={'Search'}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
<div className={'flex gap-2 items-start'}>
<div className={'flex flex-col gap-1'}>
{matchingCategories.map((key) => (
<div
className="mb-6 flex flex-col gap-2 break-inside-avoid"
key={key}
onClick={() => 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' : '')}
>
<div className="uppercase font-medium mb-1 color-gray-medium tracking-widest text-sm">
{key}
</div>
<div>
{matchingFilters[key] &&
matchingFilters[key].map((filter: Record<string, any>) => (
<div
key={filter.label}
className={cn(
stl.optionItem,
'flex items-center py-2 cursor-pointer -mx-2 px-2 gap-2 rounded-lg hover:shadow-sm'
)}
onClick={() => onFilterClick({ ...filter, value: [''] })}
>
{getNewIcon(filter)}
<span>{filter.label}</span>
</div>
))}
</div>
{key}
</div>
);
})}
))}
</div>
<div
className={'flex flex-col gap-1 overflow-y-auto w-full'}
style={{ maxHeight: 300, flex: 2 }}
>
{displayedFilters.length
? displayedFilters.map((filter: Record<string, any>) => (
<div
key={filter.label}
className={cn(
'flex items-center p-2 cursor-pointer gap-1 rounded-lg hover:bg-active-blue'
)}
onClick={() => parseAndAdd({ ...filter })}
>
{filter.category ? <div style={{ width: 100 }} className={'text-neutral-500/90 w-full flex justify-between items-center'}>
<span>{filter.subCategory ? filter.subCategory : filter.category}</span>
<ChevronRight size={14} />
</div> : null}
<div className={'flex items-center gap-2'}>
<span className='text-neutral-500/90 text-xs'>{getNewIcon(filter)}</span>
<span>{filter.label}</span>
</div>
</div>
))
: null}
</div>
</div>
{showSearchList && (
<Loader loading={fetchingFilterSearchList}>

Some files were not shown because too many files have changed in this diff Show more