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, onDelete,
style = {height: "calc('100vh - 40px')"}, style = {height: "calc('100vh - 40px')"},
} = props; } = props;
const {alertsStore} = useStore() const {alertsStore, metricStore} = useStore()
const { const {
triggerOptions, triggerOptions: allTriggerSeries,
loading, loading,
} = alertsStore } = 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 instance = alertsStore.instance
const deleting = loading const deleting = loading

View file

@ -1,7 +1,6 @@
import React from 'react'; import React from 'react';
import { Button } from 'antd'; import { Button } from 'antd';
import { useModal } from 'App/components/Modal'; import { useModal } from 'App/components/Modal';
import SessionSearchField from 'Shared/SessionSearchField';
import { MODULES } from 'Components/Client/Modules'; import { MODULES } from 'Components/Client/Modules';
import AssistStats from '../../AssistStats'; import AssistStats from '../../AssistStats';
@ -9,7 +8,7 @@ import Recordings from '../RecordingsList/Recordings';
import { useStore } from 'App/mstore'; import { useStore } from 'App/mstore';
import { observer } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite';
function AssistSearchField() { function AssistSearchActions() {
const { searchStoreLive, userStore } = useStore(); const { searchStoreLive, userStore } = useStore();
const modules = userStore.account.settings?.modules ?? []; const modules = userStore.account.settings?.modules ?? [];
const isEnterprise = userStore.isEnterprise const isEnterprise = userStore.isEnterprise
@ -27,9 +26,6 @@ function AssistSearchField() {
}; };
return ( return (
<div className="flex items-center w-full gap-2"> <div className="flex items-center w-full gap-2">
<div style={{ width: '60%' }}>
<SessionSearchField />
</div>
{isEnterprise && modules.includes(MODULES.OFFLINE_RECORDINGS) {isEnterprise && modules.includes(MODULES.OFFLINE_RECORDINGS)
? <Button type="primary" ghost onClick={showRecords}>Training Videos</Button> : null ? <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 React from 'react';
import LiveSessionList from 'Shared/LiveSessionList'; import LiveSessionList from 'Shared/LiveSessionList';
import LiveSessionSearch from 'Shared/LiveSessionSearch'; import LiveSessionSearch from 'Shared/LiveSessionSearch';
import AssistSearchField from './AssistSearchField'; import AssistSearchActions from './AssistSearchActions';
import usePageTitle from '@/hooks/usePageTitle'; import usePageTitle from '@/hooks/usePageTitle';
function AssistView() { function AssistView() {
usePageTitle('Co-Browse - OpenReplay'); usePageTitle('Co-Browse - OpenReplay');
return ( return (
<div className="w-full mx-auto" style={{ maxWidth: '1360px'}}> <div className="w-full mx-auto" style={{ maxWidth: '1360px'}}>
<AssistSearchField /> <AssistSearchActions />
<LiveSessionSearch /> <LiveSessionSearch />
<div className="my-4" /> <div className="my-4" />
<LiveSessionList /> <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="h-full flex flex-col gap-4">
<div className="flex flex-row gap-2 items-center p-3"> <div className="flex flex-row gap-2 items-center p-3">
<Input <Input
placeholder="Search" placeholder="Search projects"
// onSearch={handleSearch} // onSearch={handleSearch}
prefix={<SearchOutlined />} prefix={<SearchOutlined />}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
@ -73,7 +73,7 @@ const ProjectList: React.FC = () => {
mode="inline" mode="inline"
onClick={onClick} onClick={onClick}
selectedKeys={[String(projectsStore.config.pid)]} selectedKeys={[String(projectsStore.config.pid)]}
className="w-full !bg-white !border-0 " className="w-full !bg-white !border-0"
inlineIndent={11} inlineIndent={11}
items={menuItems} items={menuItems}
/> />

View file

@ -17,7 +17,7 @@ interface Props {
function CardSessionsByList({ list, selected, paginated, onClickHandler = () => null, metric, total }: Props) { function CardSessionsByList({ list, selected, paginated, onClickHandler = () => null, metric, total }: Props) {
const { dashboardStore, metricStore, sessionStore } = useStore(); const { dashboardStore, metricStore, sessionStore } = useStore();
const drillDownPeriod = dashboardStore.drillDownPeriod; const drillDownPeriod = dashboardStore.drillDownPeriod;
const params = { density: 70 }; const params = { density: 35 };
const metricParams = { ...params }; const metricParams = { ...params };
const [loading, setLoading] = React.useState(false); const [loading, setLoading] = React.useState(false);
const data = paginated ? metric?.data[0]?.values : list; 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 sessionId = metricStore.instance.data.sessionId;
const url = metricStore.instance.data.path; 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(() => { 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, { useState } from 'react';
import React from 'react'
import { ResponsiveContainer, Tooltip } from 'recharts'; import { ResponsiveContainer, Tooltip } from 'recharts';
import { PieChart, Pie, Cell } from 'recharts'; import { PieChart, Pie, Cell, Legend } from 'recharts';
import { Styles } from '../../common'; import { Styles } from '../../common';
import { NoContent } from 'UI'; import { NoContent } from 'UI';
import { filtersMap } from 'Types/filter/newFilter'; import { filtersMap } from 'Types/filter/newFilter';
import { numberWithCommas } from 'App/utils'; import { numberWithCommas } from 'App/utils';
import CustomTooltip from '../CustomChartTooltip';
interface Props { interface Props {
metric: any, metric: {
data: any; metricOf: string;
colors: any; metricType: string;
onClick?: (filters) => void; };
data: {
chart: any[];
namesMap: string[];
};
colors: any;
onClick?: (filters) => void;
inGrid?: boolean;
} }
function CustomMetricPieChart(props: Props) { function CustomMetricPieChart(props: Props) {
const { metric, data = { values: [] }, onClick = () => null } = props; const { metric, data, onClick = () => null, inGrid } = props;
const onClickHandler = (event) => { const [hoveredSeries, setHoveredSeries] = useState<string | null>(null);
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); const onClickHandler = (event) => {
onClick(filters); 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); const handleMouseOver = (name: string) => setHoveredSeries(name);
const handleMouseLeave = () => setHoveredSeries(null);
if (percentage<3){ const getTotalForSeries = (series: string) =>
return null; data.chart ? data.chart.reduce((acc, curr) => acc + curr[series], 0) : 0;
}
return( const values = data.namesMap.map((k) => ({
<line x1={x1} y1={y1} x2={x2} y2={y2} stroke="#3EAAAF" strokeWidth={1} /> name: k,
) value: getTotalForSeries(k),
}} }));
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> const highest = values.reduce(
<div className="text-sm color-gray-medium">Top 5 </div> (acc, curr) => (acc.value > curr.value ? acc : curr),
</NoContent> { 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 compareColorsx = ["#222F99", "#2E3ECC", "#394EFF", "#6171FF", "#8895FF", "#B0B8FF", "#D7DCFF"].reverse();
const customMetricColors = ['#394EFF', '#3EAAAF', '#565D97']; const customMetricColors = ['#394EFF', '#3EAAAF', '#565D97'];
const colorsPie = colors.concat(["#DDDDDD"]); const colorsPie = colors.concat(["#DDDDDD"]);
const safeColors = ['#394EFF', '#3EAAAF', '#9276da', '#ceba64', "#bc6f9d", '#966fbc', '#64ce86', '#e06da3', '#6dabe0'];
const countView = count => { const countView = count => {
const isMoreThanK = count >= 1000; const isMoreThanK = count >= 1000;
@ -22,6 +23,7 @@ export default {
colorsx, colorsx,
compareColors, compareColors,
compareColorsx, compareColorsx,
safeColors,
lineColor: '#2A7B7F', lineColor: '#2A7B7F',
lineColorCompare: '#394EFF', lineColorCompare: '#394EFF',
strokeColor: compareColors[0], strokeColor: compareColors[0],
@ -29,13 +31,13 @@ export default {
axisLine: {stroke: '#CCCCCC'}, axisLine: {stroke: '#CCCCCC'},
interval: 0, interval: 0,
dataKey: "time", dataKey: "time",
tick: {fill: '#999999', fontSize: 9}, tick: {fill: '#000000', fontSize: 9},
tickLine: {stroke: '#CCCCCC'}, tickLine: {stroke: '#CCCCCC'},
strokeWidth: 0.5 strokeWidth: 0.5
}, },
yaxis: { yaxis: {
axisLine: {stroke: '#CCCCCC'}, axisLine: {stroke: '#CCCCCC'},
tick: {fill: '#999999', fontSize: 9}, tick: {fill: '#000000', fontSize: 9},
tickLine: {stroke: '#CCCCCC'}, tickLine: {stroke: '#CCCCCC'},
}, },
axisLabelLeft: { axisLabelLeft: {
@ -50,8 +52,8 @@ export default {
tickFormatterBytes: val => Math.round(val / 1024 / 1024), tickFormatterBytes: val => Math.round(val / 1024 / 1024),
chartMargins: {left: 0, right: 20, top: 10, bottom: 5}, chartMargins: {left: 0, right: 20, top: 10, bottom: 5},
tooltip: { tooltip: {
cursor: { wrapperStyle: {
fill: '#f6f6f6' zIndex: 999,
}, },
contentStyle: { contentStyle: {
padding: '5px', padding: '5px',
@ -73,6 +75,9 @@ export default {
lineHeight: '0.75rem', lineHeight: '0.75rem',
color: '#000', color: '#000',
fontSize: '12px' fontSize: '12px'
},
cursor: {
fill: '#eee'
} }
}, },
gradientDef: () => ( 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 React from 'react';
import {Grid2x2Check} from "lucide-react" import { Grid2x2Check } from 'lucide-react';
import {Button, Modal} from "antd"; import { Button, Modal } from 'antd';
import Select from "Shared/Select/Select"; import Select from 'Shared/Select/Select';
import {Form} from "UI"; import { Form } from 'UI';
import {useStore} from "App/mstore"; import { useStore } from 'App/mstore';
interface Props { interface Props {
metricId: string; metricId: string;
} }
function AddToDashboardButton({metricId}: Props) { export const showAddToDashboardModal = (metricId: string, dashboardStore: any) => {
const {dashboardStore} = useStore(); const dashboardOptions = dashboardStore.dashboards.map((i: any) => ({
const dashboardOptions = dashboardStore.dashboards.map((i: any) => ({ key: i.id,
key: i.id, label: i.name,
label: i.name, value: i.dashboardId,
value: i.dashboardId, }));
})); let selectedId = dashboardOptions[0]?.value;
const [selectedId, setSelectedId] = React.useState(dashboardOptions[0]?.value);
const onSave = (close: any) => { const onSave = (close: any) => {
const dashboard = dashboardStore.getDashboard(selectedId) const dashboard = dashboardStore.getDashboard(selectedId);
if (dashboard) { if (dashboard) {
dashboardStore.addWidgetToDashboard(dashboard, [metricId]).then(close) dashboardStore.addWidgetToDashboard(dashboard, [metricId]).then(close);
}
} }
};
const onClick = () => { Modal.confirm({
Modal.confirm({ title: 'Add to selected dashboard',
title: 'Add to selected dashboard', icon: null,
icon: null, content: (
content: ( <Form.Field>
<Form.Field> <Select
<Select options={dashboardOptions}
options={dashboardOptions} defaultValue={selectedId}
defaultValue={dashboardOptions[0].value} onChange={({ value }: any) => (selectedId = value.value)}
onChange={({value}: any) => setSelectedId(value.value)} />
/> </Form.Field>
</Form.Field> ),
), cancelText: 'Cancel',
cancelText: 'Cancel', onOk: onSave,
onOk: onSave, okText: 'Add',
okText: 'Add', footer: (_, { OkBtn, CancelBtn }) => (
footer: (_, {OkBtn, CancelBtn}) => ( <>
<> <CancelBtn />
<CancelBtn/> <OkBtn />
<OkBtn/> </>
</> ),
), });
}) };
}
return ( const AddToDashboardButton = ({ metricId }: Props) => {
<Button const { dashboardStore } = useStore();
type="default"
onClick={onClick} return (
icon={<Grid2x2Check size={18}/>} <Button
> type="default"
Add to Dashboard onClick={() => showAddToDashboardModal(metricId, dashboardStore)}
</Button> icon={<Grid2x2Check size={18} />}
); >
} Add to Dashboard
</Button>
);
};
export default AddToDashboardButton; 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='w-full flex items-center justify-between pt-4 px-6'>
<div className=''> <div className=''>
Showing <span className='font-semibold'>{Math.min(list.length, pageSize)}</span> out of{' '} Showing <span className='font-medium'>{Math.min(list.length, pageSize)}</span> out of{' '}
<span className='font-semibold'>{list.length}</span> Alerts <span className='font-medium'>{list.length}</span> Alerts
</div> </div>
<Pagination <Pagination
page={page} page={page}

View file

@ -76,6 +76,7 @@ const NewAlert = (props: IProps) => {
triggerOptions, triggerOptions,
loading, loading,
} = alertsStore } = alertsStore
const deleting = loading const deleting = loading
const webhooks = settingsStore.webhooks const webhooks = settingsStore.webhooks
const fetchWebhooks = settingsStore.fetchWebhooks 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="w-full flex items-center justify-between pt-4">
<div className="text-disabled-text"> <div className="text-disabled-text">
Showing <span className="font-semibold">{Math.min(data.length, pageSize)}</span> out of{' '} Showing <span className="font-medium">{Math.min(data.length, pageSize)}</span> out of{' '}
<span className="font-semibold">{data.length}</span> Issues <span className="font-medium">{data.length}</span> Issues
</div> </div>
<Pagination <Pagination
page={metricStore.sessionsPage} page={metricStore.sessionsPage}

View file

@ -1,25 +1,45 @@
import React from "react"; import React from 'react';
import {PlusOutlined} from "@ant-design/icons"; import { PlusOutlined } from '@ant-design/icons';
import NewDashboardModal from "Components/Dashboard/components/DashboardList/NewDashModal"; import { Button } from 'antd';
import {Button} from "antd"; import { useStore } from 'App/mstore';
import { useHistory } from 'react-router-dom';
interface Props { interface Props {
disabled?: boolean; disabled?: boolean;
} }
function CreateDashboardButton({disabled = false}: Props) { function CreateDashboardButton({ disabled }: Props) {
const [showModal, setShowModal] = React.useState(false); const [dashboardCreating, setDashboardCreating] = React.useState(false);
const { projectsStore, dashboardStore } = useStore();
const siteId = projectsStore.siteId;
const history = useHistory();
return <> const createNewDashboard = async () => {
<Button setDashboardCreating(true);
icon={<PlusOutlined/>} dashboardStore.initDashboard();
type="primary" await dashboardStore
onClick={() => setShowModal(true)} .save(dashboardStore.dashboardInstance)
> .then(async (syncedDashboard) => {
Create Dashboard dashboardStore.selectDashboardById(syncedDashboard.dashboardId);
</Button> history.push(`/${siteId}/dashboard/${syncedDashboard.dashboardId}`);
<NewDashboardModal onClose={() => setShowModal(false)} open={showModal}/> })
</>; .finally(() => {
setDashboardCreating(false);
});
};
return (
<>
<Button
loading={dashboardCreating}
icon={<PlusOutlined />}
disabled={disabled}
type="primary"
onClick={createNewDashboard}
>
Create Dashboard
</Button>
</>
);
} }
export default CreateDashboardButton; export default CreateDashboardButton;

View file

@ -1,40 +1,31 @@
import React from 'react'; 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 { withSiteId } from 'App/routes';
import { withRouter, RouteComponentProps } from 'react-router-dom'; 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 SelectDateRange from 'Shared/SelectDateRange';
import { useStore } from 'App/mstore'; import { useStore } from 'App/mstore';
import { useModal } from 'App/components/Modal';
import DashboardOptions from '../DashboardOptions'; import DashboardOptions from '../DashboardOptions';
import withModal from 'App/components/Modal/withModal'; import withModal from 'App/components/Modal/withModal';
import { observer } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite';
import DashboardEditModal from '../DashboardEditModal'; 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 { interface IProps {
dashboardId: string;
siteId: string; siteId: string;
renderReport?: any; renderReport?: any;
} }
type Props = IProps & RouteComponentProps; type Props = IProps & RouteComponentProps;
const MAX_CARDS = 29;
function DashboardHeader(props: Props) { function DashboardHeader(props: Props) {
const { siteId, dashboardId } = props; const { siteId } = props;
const { dashboardStore } = useStore(); const { dashboardStore } = useStore();
const { showModal } = useModal();
const [focusTitle, setFocusedInput] = React.useState(true); const [focusTitle, setFocusedInput] = React.useState(true);
const [showEditModal, setShowEditModal] = React.useState(false); const [showEditModal, setShowEditModal] = React.useState(false);
const period = dashboardStore.period; const period = dashboardStore.period;
const dashboard: any = dashboardStore.selectedDashboard; const dashboard: any = dashboardStore.selectedDashboard;
const canAddMore: boolean = dashboard?.widgets?.length <= MAX_CARDS;
const onEdit = (isTitle: boolean) => { const onEdit = (isTitle: boolean) => {
dashboardStore.initDashboard(dashboard); dashboardStore.initDashboard(dashboard);
@ -47,7 +38,7 @@ function DashboardHeader(props: Props) {
await confirm({ await confirm({
header: 'Delete Dashboard', header: 'Delete Dashboard',
confirmButton: 'Yes, delete', 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(() => { dashboardStore.deleteDashboard(dashboard).then(() => {
@ -56,32 +47,26 @@ function DashboardHeader(props: Props) {
} }
}; };
return ( return (
<div> <>
<DashboardEditModal <DashboardEditModal
show={showEditModal} show={showEditModal}
closeHandler={() => setShowEditModal(false)} closeHandler={() => setShowEditModal(false)}
focusTitle={focusTitle} focusTitle={focusTitle}
/> />
<div className="flex items-center mb-2 justify-between">
<div className="flex items-center" style={{ flex: 3 }}>
<BackButton siteId={siteId} /> <div className="flex items-center justify-between px-4 pt-4 bg-white">
<div className="flex items-center gap-2" style={{ flex: 3 }}>
{/* <Breadcrumb <BackButton siteId={siteId} compact />
items={[
{
label: 'Back',
to: withSiteId('/dashboard', siteId),
},
{label: (dashboard && dashboard.name) || ''},
]}
/> */}
<PageTitle <PageTitle
title={ title={
// @ts-ignore // @ts-ignore
<Tooltip delay={0} title="Double click to edit" placement="bottom"> <Tooltip
delay={0}
title="Double click to edit"
placement="bottom"
>
{dashboard?.name} {dashboard?.name}
</Tooltip> </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" className="mr-3 select-none border-b border-b-borderColor-transparent hover:border-dashed hover:border-gray-medium cursor-pointer"
/> />
</div> </div>
<div className="flex items-center gap-2" style={{ flex: 1, justifyContent: 'end' }}> <div
<CreateCardButton disabled={canAddMore} /> 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 <DashboardOptions
className="flex items-center flex-shrink-0 justify-end dashboardDataPeriodSelector" editHandler={onEdit}
style={{ width: 'fit-content' }} deleteHandler={onDelete}
> renderReport={props.renderReport}
<SelectDateRange isTitlePresent={!!dashboard?.description}
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>
</div> </div>
</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 { import {
Empty, Empty,
Switch, Switch,
@ -7,18 +9,16 @@ import {
Tag, Tag,
Tooltip, Tooltip,
Typography, Typography,
Dropdown,
Button,
} from 'antd'; } from 'antd';
import { observer } from 'mobx-react-lite'; import { LockOutlined, TeamOutlined, MoreOutlined } from '@ant-design/icons';
import React from 'react';
import { useHistory } from 'react-router';
import { checkForRecent } from 'App/date'; import { checkForRecent } from 'App/date';
import { useStore } from 'App/mstore'; import { useStore } from 'App/mstore';
import Dashboard from 'App/mstore/types/dashboard'; import Dashboard from 'App/mstore/types/dashboard';
import { dashboardSelected, withSiteId } from 'App/routes'; import { dashboardSelected, withSiteId } from 'App/routes';
import CreateDashboardButton from 'Components/Dashboard/components/CreateDashboardButton'; 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 AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG';
import DashboardEditModal from '../DashboardEditModal'; import DashboardEditModal from '../DashboardEditModal';
@ -26,6 +26,7 @@ import DashboardEditModal from '../DashboardEditModal';
function DashboardList() { function DashboardList() {
const { dashboardStore, projectsStore } = useStore(); const { dashboardStore, projectsStore } = useStore();
const siteId = projectsStore.siteId; const siteId = projectsStore.siteId;
const optionsRef = React.useRef<HTMLDivElement>(null);
const [focusTitle, setFocusedInput] = React.useState(true); const [focusTitle, setFocusedInput] = React.useState(true);
const [showEditModal, setShowEditModal] = React.useState(false); const [showEditModal, setShowEditModal] = React.useState(false);
@ -103,6 +104,7 @@ function DashboardList() {
} }
checkedChildren={'Team'} checkedChildren={'Team'}
unCheckedChildren={'Private'} unCheckedChildren={'Private'}
className="toggle-team-private"
/> />
</Tooltip> </Tooltip>
</div> </div>
@ -121,23 +123,52 @@ function DashboardList() {
}, },
{ {
title: 'Options', title: '',
dataIndex: 'dashboardId', dataIndex: 'dashboardId',
width: '5%', width: '5%',
onCell: () => ({ onClick: (e) => e.stopPropagation() }),
render: (id) => ( render: (id) => (
<ItemMenu <div onClick={(e) => e.stopPropagation()}>
bold <Dropdown
items={[ arrow={false}
{ icon: 'pencil', text: 'Rename', onClick: () => onEdit(id, true) }, trigger={['click']}
{ className={'ignore-prop-dp'}
icon: 'users', menu={{
text: 'Visibility & Access', items: [
onClick: () => onEdit(id, false), {
}, icon: <Icon name={'pencil'} />,
{ icon: 'trash', text: 'Delete', onClick: () => onDelete(id) }, 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) => showTotal: (total, range) =>
`Showing ${range[0]}-${range[1]} of ${total} items`, `Showing ${range[0]}-${range[1]} of ${total} items`,
size: 'small', size: 'small',
simple: 'true',
className: 'px-4 pr-8 mb-0',
}} }}
onRow={(record) => ({ 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); dashboardStore.selectDashboardById(record.dashboardId);
const path = withSiteId( const path = withSiteId(
dashboardSelected(record.dashboardId), dashboardSelected(record.dashboardId),

View file

@ -28,7 +28,7 @@ function DashboardSearch() {
value={query} value={query}
allowClear allowClear
name="dashboardsSearch" name="dashboardsSearch"
className="w-full" className="w-full btn-search-dashboard"
placeholder="Filter by dashboard title" placeholder="Filter by dashboard title"
onChange={write} onChange={write}
onSearch={(value) => dashboardStore.updateKey('filter', { ...dashboardStore.filter, query: value })} onSearch={(value) => dashboardStore.updateKey('filter', { ...dashboardStore.filter, query: value })}

View file

@ -9,24 +9,13 @@ import {
HEATMAP, HEATMAP,
ERRORS, ERRORS,
FUNNEL, FUNNEL,
INSIGHTS,
TABLE, TABLE,
TIMESERIES, TIMESERIES,
USER_PATH, USER_PATH,
PERFORMANCE, } from "App/constants/card";
} from 'App/constants/card';
import { FilterKey } from 'Types/filter/filterType'; import { FilterKey } from 'Types/filter/filterType';
import { BarChart, TrendingUp, SearchSlash } from 'lucide-react'; 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 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 HeatmapsExample from 'Components/Dashboard/components/DashboardList/NewDashModal/Examples/HeatmapsExample';
import ByReferrer from 'Components/Dashboard/components/DashboardList/NewDashModal/Examples/SessionsBy/ByRferrer'; import ByReferrer from 'Components/Dashboard/components/DashboardList/NewDashModal/Examples/SessionsBy/ByRferrer';
import ByFetch from 'Components/Dashboard/components/DashboardList/NewDashModal/Examples/SessionsBy/ByFecth'; import ByFetch from 'Components/Dashboard/components/DashboardList/NewDashModal/Examples/SessionsBy/ByFecth';
@ -60,7 +49,7 @@ export interface CardType {
export const CARD_LIST: CardType[] = [ export const CARD_LIST: CardType[] = [
{ {
title: 'Funnel', title: 'Untitled Funnel',
key: FUNNEL, key: FUNNEL,
cardType: FUNNEL, cardType: FUNNEL,
category: CARD_CATEGORIES[0].key, category: CARD_CATEGORIES[0].key,
@ -92,7 +81,7 @@ export const CARD_LIST: CardType[] = [
} }
}, },
{ {
title: 'Heatmaps', title: 'Untitled Heatmaps',
key: HEATMAP, key: HEATMAP,
cardType: HEATMAP, cardType: HEATMAP,
metricOf: 'heatMapUrl', metricOf: 'heatMapUrl',
@ -100,14 +89,14 @@ export const CARD_LIST: CardType[] = [
example: HeatmapsExample example: HeatmapsExample
}, },
{ {
title: 'Path Finder', title: 'Untitled Journey',
key: USER_PATH, key: USER_PATH,
cardType: USER_PATH, cardType: USER_PATH,
category: CARD_CATEGORIES[0].key, category: CARD_CATEGORIES[0].key,
example: ExamplePath example: ExamplePath
}, },
{ {
title: 'Sessions Trend', title: 'Untitled Trend',
key: TIMESERIES, key: TIMESERIES,
cardType: TIMESERIES, cardType: TIMESERIES,
metricOf: 'sessionCount', metricOf: 'sessionCount',
@ -122,7 +111,7 @@ export const CARD_LIST: CardType[] = [
example: ExampleTrend example: ExampleTrend
}, },
{ {
title: 'Users Trend', title: 'Untitled Users Trend',
key: TIMESERIES + '_userCount', key: TIMESERIES + '_userCount',
cardType: TIMESERIES, cardType: TIMESERIES,
metricOf: 'userCount', metricOf: 'userCount',
@ -140,7 +129,7 @@ export const CARD_LIST: CardType[] = [
// Web analytics // Web analytics
{ {
title: 'Top Users', title: 'Untitled Top Users',
key: FilterKey.USERID, key: FilterKey.USERID,
cardType: TABLE, cardType: TABLE,
metricOf: FilterKey.USERID, metricOf: FilterKey.USERID,
@ -149,7 +138,7 @@ export const CARD_LIST: CardType[] = [
}, },
{ {
title: 'Top Browsers', title: 'Untitled Top Browsers',
key: FilterKey.USER_BROWSER, key: FilterKey.USER_BROWSER,
cardType: TABLE, cardType: TABLE,
metricOf: FilterKey.USER_BROWSER, metricOf: FilterKey.USER_BROWSER,
@ -165,7 +154,7 @@ export const CARD_LIST: CardType[] = [
// example: BySystem, // example: BySystem,
// }, // },
{ {
title: 'Top Countries', title: 'Untitled Top Countries',
key: FilterKey.USER_COUNTRY, key: FilterKey.USER_COUNTRY,
cardType: TABLE, cardType: TABLE,
metricOf: FilterKey.USER_COUNTRY, metricOf: FilterKey.USER_COUNTRY,
@ -174,7 +163,7 @@ export const CARD_LIST: CardType[] = [
}, },
{ {
title: 'Top Devices', title: 'Untitled Top Devices',
key: FilterKey.USER_DEVICE, key: FilterKey.USER_DEVICE,
cardType: TABLE, cardType: TABLE,
metricOf: FilterKey.USER_DEVICE, metricOf: FilterKey.USER_DEVICE,
@ -182,7 +171,7 @@ export const CARD_LIST: CardType[] = [
example: BySystem example: BySystem
}, },
{ {
title: 'Top Pages', title: 'Untitled Top Pages',
key: FilterKey.LOCATION, key: FilterKey.LOCATION,
cardType: TABLE, cardType: TABLE,
metricOf: FilterKey.LOCATION, metricOf: FilterKey.LOCATION,
@ -191,7 +180,7 @@ export const CARD_LIST: CardType[] = [
}, },
{ {
title: 'Top Referrer', title: 'Untitled Top Referrer',
key: FilterKey.REFERRER, key: FilterKey.REFERRER,
cardType: TABLE, cardType: TABLE,
metricOf: FilterKey.REFERRER, metricOf: FilterKey.REFERRER,
@ -201,7 +190,7 @@ export const CARD_LIST: CardType[] = [
// Monitors // Monitors
{ {
title: 'Table of Errors', title: 'Untitled Table of Errors',
key: FilterKey.ERRORS, key: FilterKey.ERRORS,
cardType: TABLE, cardType: TABLE,
metricOf: FilterKey.ERRORS, metricOf: FilterKey.ERRORS,
@ -216,7 +205,7 @@ export const CARD_LIST: CardType[] = [
example: TableOfErrors example: TableOfErrors
}, },
{ {
title: 'Top Network Requests', title: 'Untitled Top Network Requests',
key: FilterKey.FETCH, key: FilterKey.FETCH,
cardType: TABLE, cardType: TABLE,
metricOf: FilterKey.FETCH, metricOf: FilterKey.FETCH,
@ -224,7 +213,7 @@ export const CARD_LIST: CardType[] = [
example: ByFetch example: ByFetch
}, },
{ {
title: 'Sessions with 4xx/5xx Requests', title: 'Untitled Sessions with 4xx/5xx Requests',
key: TIMESERIES + '_4xx_requests', key: TIMESERIES + '_4xx_requests',
cardType: TIMESERIES, cardType: TIMESERIES,
metricOf: 'sessionCount', metricOf: 'sessionCount',
@ -258,7 +247,7 @@ export const CARD_LIST: CardType[] = [
example: ExampleTrend example: ExampleTrend
}, },
{ {
title: 'Sessions with Slow Network Requests', title: 'Untitled Sessions with Slow Network Requests',
key: TIMESERIES + '_slow_network_requests', key: TIMESERIES + '_slow_network_requests',
cardType: TIMESERIES, cardType: TIMESERIES,
metricOf: 'sessionCount', metricOf: 'sessionCount',

View file

@ -57,7 +57,7 @@ function AreaChartCard(props: Props) {
margin={Styles.chartMargins} margin={Styles.chartMargins}
> >
{gradientDef} {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}/> <XAxis {...Styles.xaxis} dataKey="time" interval={3}/>
<YAxis <YAxis
{...Styles.yaxis} {...Styles.yaxis}

View file

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

View file

@ -1,31 +1,57 @@
import React from 'react'; import React from 'react';
import { ItemMenu } from 'UI';
import { observer } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite';
import { useStore } from "App/mstore"; import { useStore } from 'App/mstore';
import { ENTERPRISE_REQUEIRED } from 'App/constants'; import { ENTERPRISE_REQUEIRED } from 'App/constants';
import { Dropdown, Button } from 'antd';
import { EllipsisVertical } from 'lucide-react';
import { Icon } from 'UI';
interface Props { interface Props {
editHandler: (isTitle: boolean) => void; editHandler: (isTitle: boolean) => void;
deleteHandler: any; deleteHandler: any;
renderReport: any; renderReport: any;
} }
function DashboardOptions(props: Props) { function DashboardOptions(props: Props) {
const { userStore } = useStore(); const { userStore } = useStore();
const isEnterprise = userStore.isEnterprise; const isEnterprise = userStore.isEnterprise;
const { editHandler, deleteHandler, renderReport } = props; 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 }
]
return ( const menu = {
<ItemMenu items: [
bold {
items={menuItems} 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); export default observer(DashboardOptions);

View file

@ -5,7 +5,7 @@ import { observer } from 'mobx-react-lite';
import React from 'react'; import React from 'react';
import colors from 'tailwindcss/colors'; 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 aiSpinner from 'App/lottie/aiSpinner.json';
import { useStore } from 'App/mstore'; import { useStore } from 'App/mstore';
import { Icon, Input } from 'UI'; 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"}> {!inModal ? <div className={"flex items-center mb-2 gap-2"}>
<Icon name={"sparkles"} size={16} /> <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> : null}
<div style={gradientBox}> <div style={gradientBox}>
<Input <Input
@ -114,7 +114,7 @@ function Loader() {
return ( return (
<div <div
className={ 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 }}> <div style={{ width: 150, height: 150 }}>

View file

@ -98,7 +98,7 @@ function DashboardView(props: Props) {
const isSaas = /app\.openreplay\.com/.test(originStr); const isSaas = /app\.openreplay\.com/.test(originStr);
return ( return (
<Loader loading={loading}> <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 */} {/* @ts-ignore */}
<DashboardHeader renderReport={props.renderReport} siteId={siteId} dashboardId={dashboardId}/> <DashboardHeader renderReport={props.renderReport} siteId={siteId} dashboardId={dashboardId}/>
{isSaas ? <AiQuery /> : null} {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 className="py-4 border-t px-8 bg-white w-full flex items-center justify-between">
<div> <div>
{'Selected '} {'Selected '}
<span className="font-semibold">{selectedWidgetIds.length}</span> <span className="font-medium">{selectedWidgetIds.length}</span>
{' out of '} {' out of '}
<span className="font-semibold">{metrics ? metrics.length : 0}</span> <span className="font-medium">{metrics ? metrics.length : 0}</span>
</div> </div>
<Button variant="primary" disabled={selectedWidgetIds.length === 0} onClick={onSave}> <Button variant="primary" disabled={selectedWidgetIds.length === 0} onClick={onSave}>
Add Selected 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 className="py-4 border-t px-8 bg-white w-full flex items-center justify-between">
<div> <div>
{'Selected '} {'Selected '}
<span className="font-semibold">{selectedWidgetIds.length}</span> <span className="font-medium">{selectedWidgetIds.length}</span>
{' out of '} {' out of '}
<span className="font-semibold">{totalMetricCount}</span> <span className="font-medium">{totalMetricCount}</span>
</div> </div>
<Button variant="primary" disabled={selectedWidgetIds.length === 0} onClick={onSave}> <Button variant="primary" disabled={selectedWidgetIds.length === 0} onClick={onSave}>
Add Selected Add Selected

View file

@ -1,10 +1,12 @@
import React from 'react'; import React from 'react';
import { useStore } from 'App/mstore'; import { useStore } from 'App/mstore';
import WidgetWrapperNew from 'Components/Dashboard/components/WidgetWrapper/WidgetWrapperNew'; import WidgetWrapperNew from 'Components/Dashboard/components/WidgetWrapper/WidgetWrapperNew';
import { Empty } from 'antd'; import { observer } from 'mobx-react-lite';
import { NoContent, Loader } from 'UI'; import AddCardSection from '../AddCardSection/AddCardSection';
import { useObserver } from 'mobx-react-lite'; import cn from 'classnames';
import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG'; import { Button, Popover, Tooltip } from 'antd'
import { PlusOutlined } from '@ant-design/icons'
import { Loader } from 'UI';
interface Props { interface Props {
siteId: string; siteId: string;
@ -16,56 +18,85 @@ interface Props {
function DashboardWidgetGrid(props: Props) { function DashboardWidgetGrid(props: Props) {
const { dashboardId, siteId } = props; const { dashboardId, siteId } = props;
const { dashboardStore } = useStore(); const { dashboardStore } = useStore();
const loading = useObserver(() => dashboardStore.isLoading); const loading = dashboardStore.isLoading;
const dashboard = dashboardStore.selectedDashboard; const dashboard = dashboardStore.selectedDashboard;
const list = useObserver(() => dashboard?.widgets); const list = dashboard?.widgets;
return useObserver(() => ( return (
<Loader loading={loading}> <Loader loading={loading}>
{ {list?.length === 0 ? (
list?.length === 0 ? ( <div
<div className="bg-white rounded-lg shadow-sm p-5"> className={'flex-1 flex justify-center items-center pt-10'}
<NoContent style={{ minHeight: 620 }}
show={true} >
icon="no-metrics-chart" <AddCardSection />
title={ </div>
<div className="text-center"> ) : (
<div className='mb-4'> <div
<AnimatedSVG name={ICONS.NO_RESULTS} size={60} /> className="pb-10 px-4 pt-2 grid gap-2 rounded grid-cols-4 items-start "
</div> id={props.id}
<div className="text-xl font-medium mb-2"> >
There are no cards in this dashboard {list?.map((item: any, index: any) => (
</div> <GridItem
<div className="text-base font-normal"> key={item.widgetId}
Create a card by clicking the "Add Card" button to visualize insights here. item={item}
</div> index={index}
</div> dashboard={dashboard}
} dashboardId={dashboardId}
siteId={siteId}
/> />
</div> ))}
) : ( </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>
)
}
</Loader> </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 ( 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 ? ( {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> <div className="text-sm color-gray-medium mr-auto mb-2">EXCLUDES</div>
{filter.excludes.map((f: any, index: number) => ( {filter.excludes.map((f: any, index: number) => (
<FilterItem <FilterItem

View file

@ -1,182 +1,250 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import FilterList from 'Shared/Filters/FilterList'; import { EventsList, FilterList } from 'Shared/Filters/FilterList';
import SeriesName from './SeriesName'; import SeriesName from './SeriesName';
import cn from 'classnames'; import cn from 'classnames';
import {observer} from 'mobx-react-lite'; import { observer } from 'mobx-react-lite';
import ExcludeFilters from './ExcludeFilters'; import ExcludeFilters from './ExcludeFilters';
import AddStepButton from "Components/Dashboard/components/FilterSeries/AddStepButton"; import { Button, Space } from 'antd';
import {Button, Space} from "antd"; import { ChevronDown, ChevronUp, Trash } from 'lucide-react';
import {ChevronDown, ChevronUp, Trash} from "lucide-react";
const FilterCountLabels = observer(
const FilterCountLabels = observer((props: { filters: any, toggleExpand: any }) => { (props: { filters: any; toggleExpand: any }) => {
const events = props.filters.filter((i: any) => i && i.isEvent).length; const events = props.filters.filter((i: any) => i && i.isEvent).length;
const filters = 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> <Space>
{events > 0 && ( {events > 0 && (
<Button type="primary" ghost size="small" onClick={props.toggleExpand}> <Button
{`${events} Event${events > 1 ? 's' : ''}`} type="text"
</Button> size="small"
)} onClick={props.toggleExpand}
className='btn-series-event-count'
>
{`${events} Event${events > 1 ? 's' : ''}`}
</Button>
)}
{filters > 0 && ( {filters > 0 && (
<Button type="primary" ghost size="small" onClick={props.toggleExpand}> <Button
{`${filters} Filter${filters > 1 ? 's' : ''}`} type="text"
</Button> size="small"
)} onClick={props.toggleExpand}
className='btn-series-filter-count'
>
{`${filters} Filter${filters > 1 ? 's' : ''}`}
</Button>
)}
</Space> </Space>
</div>; </div>
}); );
}
);
const FilterSeriesHeader = observer((props: { const FilterSeriesHeader = observer(
expanded: boolean, (props: {
hidden: boolean, expanded: boolean;
seriesIndex: number, hidden: boolean;
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 {
seriesIndex: number; seriesIndex: number;
series: any; series: any;
onRemoveSeries: (seriesIndex: any) => void; onRemove: (seriesIndex: any) => void;
canDelete?: boolean; canDelete: boolean | undefined;
supportsEmpty?: boolean; toggleExpand: () => void;
hideHeader?: boolean; onChange: () => void;
emptyMessage?: any; }) => {
observeChanges?: () => void; const onUpdate = (name: any) => {
excludeFilterKeys?: Array<string>; props.series.update('name', name);
canExclude?: boolean; props.onChange();
expandable?: boolean; };
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) { function FilterSeries(props: Props) {
const { const {
observeChanges = () => { observeChanges = () => {},
}, canDelete,
canDelete, hideHeader = false,
hideHeader = false, emptyMessage = 'Add an event or filter step to define the series.',
emptyMessage = 'Add an event or filter step to define the series.', supportsEmpty = true,
supportsEmpty = true, excludeFilterKeys = [],
excludeFilterKeys = [], canExclude = false,
canExclude = false, expandable = false,
expandable = false isHeatmap,
} = props; removeEvents,
const [expanded, setExpanded] = useState(!expandable); collapseState,
const {series, seriesIndex} = props; onToggleCollapse,
const [prevLength, setPrevLength] = useState(0); excludeCategory
} = props;
const expanded = !collapseState
const setExpanded = onToggleCollapse
const { series, seriesIndex } = props;
useEffect(() => { const onUpdateFilter = (filterIndex: any, filter: any) => {
if (series.filter.filters.length === 1 && prevLength === 0 && seriesIndex === 0) { series.filter.updateFilter(filterIndex, filter);
setExpanded(true); observeChanges();
} };
setPrevLength(series.filter.filters.length);
}, [series.filter.filters.length]);
const onUpdateFilter = (filterIndex: any, filter: any) => { const onFilterMove = (newFilters: any) => {
series.filter.updateFilter(filterIndex, filter); series.filter.replaceFilters(newFilters.toArray());
observeChanges(); observeChanges();
}; };
const onFilterMove = (newFilters: any) => { const onChangeEventsOrder = (_: any, { name, value }: any) => {
series.filter.replaceFilters(newFilters.toArray()) series.filter.updateKey(name, value);
observeChanges(); observeChanges();
} };
const onChangeEventsOrder = (_: any, {name, value}: any) => { const onRemoveFilter = (filterIndex: any) => {
series.filter.updateKey(name, value); series.filter.removeFilter(filterIndex);
observeChanges(); observeChanges();
}; };
const onRemoveFilter = (filterIndex: any) => { const onAddFilter = (filter: any) => {
series.filter.removeFilter(filterIndex); series.filter.addFilter(filter);
observeChanges(); observeChanges();
}; }
return ( return (
<div className="border rounded-lg shadow-sm bg-white"> <div>
{canExclude && <ExcludeFilters filter={series.filter}/>} {canExclude && <ExcludeFilters filter={series.filter} />}
{!hideHeader && ( {!hideHeader && (
<FilterSeriesHeader hidden={hideHeader} <FilterSeriesHeader
seriesIndex={seriesIndex} hidden={hideHeader}
series={series} seriesIndex={seriesIndex}
onRemove={props.onRemoveSeries} onChange={observeChanges}
canDelete={canDelete} series={series}
expanded={expanded} onRemove={props.onRemoveSeries}
toggleExpand={() => setExpanded(!expanded)}/> 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 && ( {expanded ? (
<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> {removeEvents ? null :
<Button size="small" <EventsList
icon={expanded ? <ChevronUp size={16}/> : <ChevronDown size={16}/>}/> filter={series.filter}
</Space> onUpdateFilter={onUpdateFilter}
)} onRemoveFilter={onRemoveFilter}
onChangeEventsOrder={onChangeEventsOrder}
{expanded && ( supportsEmpty={supportsEmpty}
<> onFilterMove={onFilterMove}
<div className="p-5"> excludeFilterKeys={excludeFilterKeys}
{series.filter.filters.length > 0 ? ( onAddFilter={onAddFilter}
<FilterList mergeUp={!hideHeader}
filter={series.filter} mergeDown
onUpdateFilter={onUpdateFilter} cannotAdd={isHeatmap}
onRemoveFilter={onRemoveFilter} excludeCategory={excludeCategory}
onChangeEventsOrder={onChangeEventsOrder} />
supportsEmpty={supportsEmpty} }
onFilterMove={onFilterMove} <FilterList
excludeFilterKeys={excludeFilterKeys} filter={series.filter}
// actions={[ onUpdateFilter={onUpdateFilter}
// expandable && ( onRemoveFilter={onRemoveFilter}
// <Button onClick={() => setExpanded(!expanded)} onChangeEventsOrder={onChangeEventsOrder}
// size="small" supportsEmpty={supportsEmpty}
// icon={expanded ? <ChevronUp size={16}/> : <ChevronDown size={16}/>}/> onFilterMove={onFilterMove}
// ) excludeFilterKeys={excludeFilterKeys}
// ]} onAddFilter={onAddFilter}
/> mergeUp={!removeEvents}
) : ( excludeCategory={excludeCategory}
<div className="color-gray-medium">{emptyMessage}</div> />
)} </>
</div> ) : null}
<div className="border-t h-12 flex items-center"> </div>
<div className="-mx-4 px-5"> );
<AddStepButton excludeFilterKeys={excludeFilterKeys} series={series}/>
</div>
</div>
</>
)}
</div>
);
} }
export default observer(FilterSeries); export default observer(FilterSeries);

View file

@ -1,61 +1,71 @@
import React, { useState, useRef, useEffect } from 'react'; import React, { useState, useRef, useEffect } from 'react';
import { Icon } from 'UI'; import { Input, Tooltip } from 'antd';
import {Input, Tooltip} from 'antd';
interface Props { interface Props {
name: string; name: string;
onUpdate: (name) => void; onUpdate: (name: string) => void;
onChange: () => void;
seriesIndex?: number; seriesIndex?: number;
} }
function SeriesName(props: Props) { function SeriesName(props: Props) {
const { seriesIndex = 1 } = props; const { seriesIndex = 1 } = props;
const [editing, setEditing] = useState(false) const [editing, setEditing] = useState(false);
const [name, setName] = useState(props.name) const [name, setName] = useState(props.name);
const ref = useRef<any>(null) const ref = useRef<any>(null);
const write = ({ target: { value, name } }) => { const write = ({ target: { value } }) => {
setName(value) setName(value);
} props.onChange();
};
const onBlur = () => { const onBlur = () => {
setEditing(false) setEditing(false);
props.onUpdate(name) props.onUpdate(name);
} };
const onKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
setEditing(false);
props.onUpdate(name);
}
};
useEffect(() => { useEffect(() => {
if (editing) { if (editing) {
ref.current.focus() ref.current.focus();
} }
}, [editing]) }, [editing]);
useEffect(() => { useEffect(() => {
setName(props.name) setName(props.name);
}, [props.name]) }, [props.name]);
// const { name } = props;
return ( return (
<div className="flex items-center"> <div className="flex items-center">
{ editing ? ( {editing ? (
<Input <Input
ref={ ref } ref={ref}
name="name" name="name"
value={name} value={name}
// readOnly={!editing}
onChange={write} onChange={write}
onBlur={onBlur} onBlur={onBlur}
onFocus={() => setEditing(true)} onKeyDown={onKeyDown}
className='bg-white' 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> <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)}
<div className="ml-3 cursor-pointer " onClick={() => setEditing(true)}> data-event='input-rename-series'
<Tooltip title='Rename' placement='bottom'> >
<Icon name="pencil" size="14" /> {name && name.trim() === '' ? 'Series ' + (seriesIndex + 1) : name}
</div>
</Tooltip> </Tooltip>
</div> )}
</div> </div>
); );
} }

View file

@ -1,46 +1,66 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Icon, Modal } from 'UI'; import { Icon } from 'UI';
import { Tooltip, Input, Button, Dropdown, Menu, Tag, Modal as AntdModal, Form, Avatar } from 'antd'; import {
import { TeamOutlined, LockOutlined, EditOutlined, DeleteOutlined, MoreOutlined } from '@ant-design/icons'; 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 { RouteComponentProps } from 'react-router-dom';
import { withSiteId } from 'App/routes'; 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 { useStore } from 'App/mstore';
import { observer } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { useHistory } from 'react-router'; import { useHistory } from 'react-router';
import { EllipsisVertical } from 'lucide-react';
import cn from 'classnames'
interface Props extends RouteComponentProps { interface Props extends RouteComponentProps {
metric: any; metric: any;
siteId: string; siteId: string;
selected?: boolean;
toggleSelection?: any; toggleSelection?: any;
disableSelection?: boolean; disableSelection?: boolean;
renderColumn: string; renderColumn: string;
inLibrary?: boolean;
} }
function MetricTypeIcon({ type }: any) { function MetricTypeIcon({ type }: any) {
const [card, setCard] = useState<any>('');
useEffect(() => {
const t = TYPES.find((i) => i.slug === type);
setCard(t || {});
}, [type]);
return ( return (
<Tooltip title={<div className="capitalize">{card.title}</div>}> <Tooltip title={<div className="capitalize">{TYPE_NAMES[type]}</div>}>
<Avatar src={card.icon && <Icon name={card.icon} size="16" color="tealx" />} size="small" className="bg-tealx-lightest mr-2" /> <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> </Tooltip>
); );
} }
const MetricListItem: React.FC<Props> = ({ const MetricListItem: React.FC<Props> = ({
metric, metric,
siteId, siteId,
toggleSelection = () => { toggleSelection = () => {},
}, disableSelection = false,
disableSelection = false, renderColumn,
renderColumn inLibrary,
}) => { }) => {
const history = useHistory(); const history = useHistory();
const { metricStore } = useStore(); const { metricStore } = useStore();
const [isEdit, setIsEdit] = useState(false); const [isEdit, setIsEdit] = useState(false);
@ -67,7 +87,7 @@ const MetricListItem: React.FC<Props> = ({
cancelText: 'No', cancelText: 'No',
onOk: async () => { onOk: async () => {
await metricStore.delete(metric); await metricStore.delete(metric);
} },
}); });
} }
if (key === 'rename') { if (key === 'rename') {
@ -132,29 +152,34 @@ const MetricListItem: React.FC<Props> = ({
} else if (diffDays <= 3) { } else if (diffDays <= 3) {
return `${diffDays} days ago at ${formatTime(date)}`; return `${diffDays} days ago at ${formatTime(date)}`;
} else { } 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 = [ const menuItems = [
{ {
key: "rename", key: 'rename',
icon: <EditOutlined />, icon: <EditOutlined />,
label: "Rename" label: 'Rename',
}, },
{ {
key: "delete", key: 'delete',
icon: <DeleteOutlined />, icon: <DeleteOutlined />,
label: "Delete" label: 'Delete',
} },
] ];
switch (renderColumn) { switch (renderColumn) {
case 'title': case 'title':
return ( 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} /> <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> </div>
{renderModal()} {renderModal()}
</> </>
@ -165,7 +190,11 @@ const MetricListItem: React.FC<Props> = ({
return ( return (
<div className="flex items-center"> <div className="flex items-center">
<Tag className="rounded-lg" bordered={false}> <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'} {metric.isPublic ? 'Team' : 'Private'}
</Tag> </Tag>
</div> </div>
@ -176,13 +205,18 @@ const MetricListItem: React.FC<Props> = ({
case 'options': case 'options':
return ( return (
<> <>
<div className='flex justify-end'> <div className="flex justify-end pr-4">
<Dropdown <Dropdown
menu={{ items: menuItems, onClick: onMenuClick }} menu={{ items: menuItems, onClick: onMenuClick }}
trigger={['click']} trigger={['click']}
> >
<Button type="text" icon={<MoreOutlined />} /> <Button
</Dropdown> id={'ignore-prop'}
icon={<EllipsisVertical size={16} />}
className="btn-cards-list-item-more-options"
type="text"
/>
</Dropdown>
</div> </div>
{renderModal()} {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 React, { useEffect } from 'react';
import { PageTitle, Toggler, Icon } from "UI"; import { PageTitle } from 'UI';
import { Segmented, Button } from 'antd'; import { Button, Popover, Space, Dropdown, Menu } from 'antd';
import { PlusOutlined } from '@ant-design/icons'; import { PlusOutlined, DownOutlined } from '@ant-design/icons';
import AddCardSection from '../AddCardSection/AddCardSection';
import MetricsSearch from '../MetricsSearch'; import MetricsSearch from '../MetricsSearch';
import Select from 'Shared/Select';
import { useStore } from 'App/mstore'; 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 { 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 options = [
const { metricStore } = useStore(); {
const filter = metricStore.filter; key: 'all',
const { showModal } = useModal(); label: 'All Types',
const [showAddCardModal, setShowAddCardModal] = React.useState(false); },
...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' function MetricViewHeader() {
useEffect(() => { const { metricStore } = useStore();
metricStore.updateKey('sort', { by: 'desc' }); const filter = metricStore.filter;
}, [metricStore]);
return ( useEffect(() => {
<div> metricStore.updateKey('sort', { by: 'desc' });
<div className='flex items-center justify-between px-6'> }, [metricStore]);
<div className='flex items-baseline mr-3'> const handleMenuClick = ({ key }) => {
<PageTitle title='Cards' className='' /> metricStore.updateKey('filter', { ...filter, type: key });
</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>
<div className='border-y px-6 py-1 mt-2 flex items-center w-full justify-between'> const menu = (
<div className='items-center flex gap-4'> <Menu onClick={handleMenuClick}>
<Select {options.map((option) => (
options={[{ label: 'All Types', value: 'all' }, ...DROPDOWN_OPTIONS]} <Menu.Item key={option.key}>{option.label}</Menu.Item>
name='type' ))}
defaultValue={filter.type} </Menu>
onChange={({ value }) => );
metricStore.updateKey('filter', { ...filter, type: value.value })
}
plain={true}
isSearchable={true}
/>
<DashboardDropdown return (
plain={false} <div>
onChange={(value: any) => <div className="flex items-center justify-between pr-4">
metricStore.updateKey('filter', { ...filter, dashboard: value }) <div className="flex items-center gap-2 ps-4">
} <PageTitle title="Cards" className="cursor-default" />
/> <Space>
</div> <Dropdown overlay={menu} trigger={['click']} className="">
<Button type="text" size="small" className="mt-1">
<div className='flex items-center gap-6'> {options.find(opt => opt.key === filter.type)?.label || 'Select Type'}
<ListViewToggler /> <DownOutlined />
</Button>
{/* <Toggler </Dropdown>
label='My Cards' </Space>
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>
</div> </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); 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 { useStore } from 'App/mstore';
import { observer } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite';
import FooterContent from './FooterContent'; import FooterContent from './FooterContent';
import { Input } from 'antd'
interface Props { interface Props {
dashboardId?: number; dashboardId?: number;
@ -46,7 +47,7 @@ function MetricsLibraryModal(props: Props) {
</Modal.Header> </Modal.Header>
<Modal.Content className="p-4 pb-20"> <Modal.Content className="p-4 pb-20">
<div className="border"> <div className="border">
<MetricsList siteId={siteId} onSelectionChange={onSelectionChange} /> <MetricsList siteId={siteId} onSelectionChange={onSelectionChange} inLibrary />
</div> </div>
</Modal.Content> </Modal.Content>
<Modal.Footer> <Modal.Footer>
@ -61,12 +62,11 @@ export default observer(MetricsLibraryModal);
function MetricSearch({ onChange }: any) { function MetricSearch({ onChange }: any) {
return ( return (
<div className="relative"> <div className="relative">
<Icon name="search" className="absolute top-0 bottom-0 ml-2 m-auto" size="16" /> <Input.Search
<input
name="dashboardsSearch" name="dashboardsSearch"
className="bg-white p-2 border border-borderColor-gray-light-shade rounded w-full pl-10"
placeholder="Filter by title or owner" placeholder="Filter by title or owner"
onChange={onChange} onChange={onChange}
className={'rounded-lg'}
/> />
</div> </div>
); );

View file

@ -1,28 +1,33 @@
import React, { useState, useMemo } from 'react'; 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 MetricListItem from '../MetricListItem';
import { TablePaginationConfig, SorterResult } from 'antd/lib/table/interface'; import { TablePaginationConfig, SorterResult } from 'antd/lib/table/interface';
import Widget from 'App/mstore/types/widget'; import Widget from 'App/mstore/types/widget';
import { LockOutlined, TeamOutlined } from "@ant-design/icons";
import classNames from 'classnames';
const { Text } = Typography; const { Text } = Typography;
// interface Metric { interface Metric {
// metricId: number; metricId: number;
// name: string; name: string;
// owner: string; owner: string;
// lastModified: string; lastModified: string;
// visibility: string; visibility: string;
// } }
interface Props { interface Props {
list: Widget[]; list: Widget[];
siteId: string; siteId: string;
selectedList: number[]; selectedList: number[];
toggleSelection?: (metricId: number) => void; toggleSelection?: (metricId: number | Array<number>) => void;
toggleAll?: (e: any) => void; toggleAll?: (e: any) => void;
disableSelection?: boolean; disableSelection?: boolean;
allSelected?: boolean; allSelected?: boolean;
existingCardIds?: number[]; existingCardIds?: number[];
showOwn?: boolean;
toggleOwn: () => void;
inLibrary?: boolean;
} }
const ListView: React.FC<Props> = (props: Props) => { const ListView: React.FC<Props> = (props: Props) => {
@ -32,8 +37,7 @@ const ListView: React.FC<Props> = (props: Props) => {
selectedList, selectedList,
toggleSelection, toggleSelection,
disableSelection = false, disableSelection = false,
allSelected = false, inLibrary = false
toggleAll
} = props; } = props;
const [sorter, setSorter] = useState<{ field: string; order: 'ascend' | 'descend' }>({ const [sorter, setSorter] = useState<{ field: string; order: 'ascend' | 'descend' }>({
field: 'lastModified', field: 'lastModified',
@ -66,7 +70,7 @@ const ListView: React.FC<Props> = (props: Props) => {
const paginatedData = useMemo(() => { const paginatedData = useMemo(() => {
const start = (pagination.current! - 1) * pagination.pageSize!; const start = (pagination.current! - 1) * pagination.pageSize!;
const end = start + 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]); }, [sortedData, pagination]);
const handleTableChange = ( const handleTableChange = (
@ -84,34 +88,19 @@ const ListView: React.FC<Props> = (props: Props) => {
const columns = [ const columns = [
{ {
title: ( title: 'Title',
<div className="flex items-center">
{!disableSelection && (
<Checkbox
name="slack"
className="mr-4"
checked={allSelected}
onClick={toggleAll}
/>
)}
<span>Title</span>
</div>
),
dataIndex: 'name', dataIndex: 'name',
key: 'title', key: 'title',
className: 'cap-first', className: 'cap-first pl-4',
sorter: true, sorter: true,
width: '25%',
render: (text: string, metric: Metric) => ( render: (text: string, metric: Metric) => (
<MetricListItem <MetricListItem
key={metric.metricId} key={metric.metricId}
metric={metric} metric={metric}
siteId={siteId} siteId={siteId}
disableSelection={disableSelection} inLibrary={inLibrary}
selected={selectedList.includes(metric.metricId)} disableSelection={!inLibrary}
toggleSelection={(e: any) => {
e.stopPropagation();
toggleSelection && toggleSelection(metric.metricId);
}}
renderColumn="title" renderColumn="title"
/> />
) )
@ -121,7 +110,7 @@ const ListView: React.FC<Props> = (props: Props) => {
dataIndex: 'owner', dataIndex: 'owner',
key: 'owner', key: 'owner',
className: 'capitalize', className: 'capitalize',
width: '30%', width: '25%',
sorter: true, sorter: true,
render: (text: string, metric: Metric) => ( render: (text: string, metric: Metric) => (
<MetricListItem <MetricListItem
@ -137,7 +126,7 @@ const ListView: React.FC<Props> = (props: Props) => {
dataIndex: 'lastModified', dataIndex: 'lastModified',
key: 'lastModified', key: 'lastModified',
sorter: true, sorter: true,
width: '16.67%', width: '25%',
render: (text: string, metric: Metric) => ( render: (text: string, metric: Metric) => (
<MetricListItem <MetricListItem
key={metric.metricId} 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 ( return (
<Table <Table
@ -183,26 +164,20 @@ const ListView: React.FC<Props> = (props: Props) => {
dataSource={paginatedData} dataSource={paginatedData}
rowKey="metricId" rowKey="metricId"
onChange={handleTableChange} onChange={handleTableChange}
size='middle' onRow={inLibrary ? (record) => ({
onClick: () => disableSelection ? null : toggleSelection?.(record.metricId)
}) : undefined}
rowSelection={ rowSelection={
!disableSelection !disableSelection
? { ? {
selectedRowKeys: selectedList.map((id: number) => id.toString()), selectedRowKeys: selectedList,
onChange: (selectedRowKeys) => { onChange: (selectedRowKeys) => {
selectedRowKeys.forEach((key: any) => { toggleSelection(selectedRowKeys);
toggleSelection && toggleSelection(parseInt(key)); },
}); columnWidth: 16,
}
} }
: undefined : undefined
} }
// footer={() => (
// <div className="flex justify-end">
// <Checkbox name="slack" checked={allSelected} onClick={toggleAll}>
// Select All
// </Checkbox>
// </div>
// )}
pagination={{ pagination={{
current: pagination.current, current: pagination.current,
pageSize: pagination.pageSize, pageSize: pagination.pageSize,
@ -211,7 +186,8 @@ const ListView: React.FC<Props> = (props: Props) => {
className: 'px-4', className: 'px-4',
showLessItems: true, showLessItems: true,
showTotal: () => totalMessage, 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 React, { useEffect, useMemo, useState } from 'react';
import { NoContent, Pagination, Icon, Loader } from 'UI'; import { NoContent, Loader } from 'UI';
import { useStore } from 'App/mstore'; import { useStore } from 'App/mstore';
import { sliceListPerPage } from 'App/utils'; import { sliceListPerPage } from 'App/utils';
import GridView from './GridView'; import GridView from './GridView';
@ -8,24 +8,37 @@ import ListView from './ListView';
import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG'; import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG';
function MetricsList({ function MetricsList({
siteId, siteId,
onSelectionChange onSelectionChange,
}: { inLibrary,
}: {
siteId: string; siteId: string;
onSelectionChange?: (selected: any[]) => void; onSelectionChange?: (selected: any[]) => void;
inLibrary?: boolean;
}) { }) {
const { metricStore, dashboardStore } = useStore(); const { metricStore, dashboardStore } = useStore();
const metricsSearch = metricStore.filter.query; const metricsSearch = metricStore.filter.query;
const listView = useObserver(() => metricStore.listView); const listView = inLibrary ? true : metricStore.listView;
const [selectedMetrics, setSelectedMetrics] = useState<any>([]); const [selectedMetrics, setSelectedMetrics] = useState<any>([]);
const dashboard = dashboardStore.selectedDashboard; const dashboard = dashboardStore.selectedDashboard;
const existingCardIds = useMemo(() => dashboard?.widgets?.map(i => parseInt(i.metricId)), [dashboard]); const existingCardIds = useMemo(
const cards = useMemo(() => !!onSelectionChange ? metricStore.filteredCards.filter(i => !existingCardIds?.includes(parseInt(i.metricId))) : metricStore.filteredCards, [metricStore.filteredCards]); () => 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; const loading = metricStore.isLoading;
useEffect(() => { useEffect(() => {
metricStore.fetchList(); void metricStore.fetchList();
}, []); }, []);
useEffect(() => { useEffect(() => {
@ -36,42 +49,59 @@ function MetricsList({
}, [selectedMetrics]); }, [selectedMetrics]);
const toggleMetricSelection = (id: any) => { const toggleMetricSelection = (id: any) => {
if (Array.isArray(id)) {
setSelectedMetrics(id);
return
}
if (selectedMetrics.includes(id)) { if (selectedMetrics.includes(id)) {
setSelectedMetrics(selectedMetrics.filter((i: number) => i !== id)); setSelectedMetrics((prev) => prev.filter((i: number) => i !== id));
} else { } else {
setSelectedMetrics([...selectedMetrics, id]); setSelectedMetrics((prev) => [...prev, id]);
} }
}; };
const lenth = cards.length; const length = cards.length;
useEffect(() => { useEffect(() => {
metricStore.updateKey('sessionsPage', 1); metricStore.updateKey('sessionsPage', 1);
}, []); }, []);
const showOwn = metricStore.filter.showMine;
const toggleOwn = () => {
metricStore.updateKey('showMine', !showOwn);
}
return ( return (
<Loader loading={loading}> <Loader loading={loading}>
<NoContent <NoContent
show={lenth === 0} show={length === 0}
title={ title={
<div className="flex flex-col items-center justify-center"> <div className="flex flex-col items-center justify-center">
<AnimatedSVG name={ICONS.NO_CARDS} size={60} /> <AnimatedSVG name={ICONS.NO_CARDS} size={60} />
<div className="text-center mt-4 text-lg font-medium"> <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>
</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 ? (
<ListView <ListView
disableSelection={!onSelectionChange} disableSelection={!onSelectionChange}
siteId={siteId} siteId={siteId}
list={cards} list={cards}
inLibrary={inLibrary}
selectedList={selectedMetrics} selectedList={selectedMetrics}
existingCardIds={existingCardIds} existingCardIds={existingCardIds}
toggleSelection={toggleMetricSelection} toggleSelection={toggleMetricSelection}
allSelected={cards.length === selectedMetrics.length} allSelected={cards.length === selectedMetrics.length}
showOwn={showOwn}
toggleOwn={toggleOwn}
toggleAll={({ target: { checked, name } }) => toggleAll={({ target: { checked, name } }) =>
setSelectedMetrics(checked ? cards.map((i: any) => i.metricId).slice(0, 30 - existingCardIds!.length) : []) 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="w-full flex items-center justify-between py-4 px-6 border-t">
<div className=""> <div className="">
Showing{' '} Showing{' '}
<span className="font-semibold">{Math.min(cards.length, metricStore.pageSize)}</span> out <span className="font-medium">{Math.min(cards.length, metricStore.pageSize)}</span> out
of <span className="font-semibold">{cards.length}</span> cards of <span className="font-medium">{cards.length}</span> cards
</div> </div>
<Pagination <Pagination
page={metricStore.page} page={metricStore.page}

View file

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

View file

@ -11,7 +11,9 @@ function MetricsView({ siteId }: Props) {
return ( return (
<div style={{ maxWidth: '1360px', margin: 'auto' }} className="bg-white rounded-lg shadow-sm pt-4 border"> <div style={{ maxWidth: '1360px', margin: 'auto' }} className="bg-white rounded-lg shadow-sm pt-4 border">
<MetricViewHeader siteId={siteId} /> <MetricViewHeader siteId={siteId} />
<div className='pt-3'>
<MetricsList siteId={siteId} /> <MetricsList siteId={siteId} />
</div>
</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='w-full flex items-center justify-between p-4 absolute bottom-0 bg-white'>
<div className='text-disabled-text'> <div className='text-disabled-text'>
Showing <span Showing <span
className='font-semibold'>{Math.min(length, 10)}</span> out of{' '} className='font-medium'>{Math.min(length, 10)}</span> out of{' '}
<span className='font-semibold'>{total}</span> Issues <span className='font-medium'>{total}</span> Issues
</div> </div>
<Pagination <Pagination
page={page} 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 React, { useState, useRef, useEffect } from 'react';
import CustomMetricLineChart from 'App/components/Dashboard/Widgets/CustomMetricsWidgets/CustomMetricLineChart'; 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 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 {Styles} from 'App/components/Dashboard/Widgets/common'; import { observer } from 'mobx-react-lite';
import {observer} from 'mobx-react-lite';
import { Icon, Loader } from 'UI'; 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 WidgetPredefinedChart from '../WidgetPredefinedChart';
import CustomMetricOverviewChart from 'App/components/Dashboard/Widgets/CustomMetricsWidgets/CustomMetricOverviewChart'; import { getStartAndEndTimestampsByDensity } from 'Types/dashboard/helper';
import {getStartAndEndTimestampsByDensity} from 'Types/dashboard/helper'; import { debounce } from 'App/utils';
import {debounce} from 'App/utils';
import useIsMounted from 'App/hooks/useIsMounted'; import useIsMounted from 'App/hooks/useIsMounted';
import {FilterKey} from 'Types/filter/filterType'; import { FilterKey } from 'Types/filter/filterType';
import { import {
TIMESERIES, TIMESERIES,
TABLE, TABLE,
HEATMAP, HEATMAP,
FUNNEL, FUNNEL,
ERRORS, ERRORS,
INSIGHTS, INSIGHTS,
USER_PATH, USER_PATH,
RETENTION RETENTION,
} from 'App/constants/card'; } from 'App/constants/card';
import FunnelWidget from 'App/components/Funnels/FunnelWidget'; import FunnelWidget from 'App/components/Funnels/FunnelWidget';
import CustomMetricTableSessions from 'App/components/Dashboard/Widgets/CustomMetricsWidgets/CustomMetricTableSessions'; import CustomMetricTableSessions from 'App/components/Dashboard/Widgets/CustomMetricsWidgets/CustomMetricTableSessions';
import CustomMetricTableErrors from 'App/components/Dashboard/Widgets/CustomMetricsWidgets/CustomMetricTableErrors'; import CustomMetricTableErrors from 'App/components/Dashboard/Widgets/CustomMetricsWidgets/CustomMetricTableErrors';
import ClickMapCard from 'App/components/Dashboard/Widgets/CustomMetricsWidgets/ClickMapCard'; import ClickMapCard from 'App/components/Dashboard/Widgets/CustomMetricsWidgets/ClickMapCard';
import InsightsCard from 'App/components/Dashboard/Widgets/CustomMetricsWidgets/InsightsCard'; 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 CohortCard from '../../Widgets/CustomMetricsWidgets/CohortCard';
import SessionsBy from "Components/Dashboard/Widgets/CustomMetricsWidgets/SessionsBy"; import SessionsBy from 'Components/Dashboard/Widgets/CustomMetricsWidgets/SessionsBy';
import { useInView } from "react-intersection-observer"; import { useInView } from 'react-intersection-observer';
import LongLoader from "./LongLoader";
interface Props { interface Props {
metric: any; metric: any;
isSaved?: boolean; isSaved?: boolean;
isTemplate?: boolean; isTemplate?: boolean;
isPreview?: boolean; isPreview?: boolean;
} }
function WidgetChart(props: Props) { function WidgetChart(props: Props) {
const { ref, inView } = useInView({ const { ref, inView } = useInView({
triggerOnce: true, triggerOnce: true,
rootMargin: "200px 0px", rootMargin: '200px 0px',
}); });
const {isSaved = false, metric, isTemplate} = props; const { isSaved = false, metric, isTemplate } = props;
const {dashboardStore, metricStore, sessionStore} = useStore(); const { dashboardStore, metricStore } = useStore();
const _metric: any = metricStore.instance; const _metric: any = props.isPreview ? metricStore.instance : props.metric;
const period = dashboardStore.period; const data = _metric.data;
const drillDownPeriod = dashboardStore.drillDownPeriod; const period = dashboardStore.period;
const drillDownFilter = dashboardStore.drillDownFilter; const drillDownPeriod = dashboardStore.drillDownPeriod;
const colors = Styles.customMetricColors; const drillDownFilter = dashboardStore.drillDownFilter;
const [loading, setLoading] = useState(true); const colors = Styles.safeColors;
const params = {density: 70}; const [loading, setLoading] = useState(true);
const metricParams = {...params}; const [stale, setStale] = useState(false);
const prevMetricRef = useRef<any>(); const params = { density: dashboardStore.selectedDensity };
const isMounted = useIsMounted(); const metricParams = _metric.params;
const [data, setData] = useState<any>(metric.data); 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'; useEffect(() => {
const isPieChart = metric.metricType === 'table' && metric.viewType === 'pieChart'; return () => {
dashboardStore.setComparisonPeriod(null, _metric.metricId);
useEffect(() => { dashboardStore.resetDrillDownFilter();
return () => {
dashboardStore.resetDrillDownFilter();
};
}, []);
const onChartClick = (event: any) => {
if (event) {
if (isTableWidget || isPieChart) { // get the filter of clicked row
const periodTimestamps = drillDownPeriod.toTimestamps();
drillDownFilter.merge({
filters: event,
startTimestamp: periodTimestamps.startTimestamp,
endTimestamp: periodTimestamps.endTimestamp
});
} else { // get the filter of clicked chart point
const payload = event.activePayload[0].payload;
const timestamp = payload.timestamp;
const periodTimestamps = getStartAndEndTimestampsByDensity(timestamp, drillDownPeriod.start, drillDownPeriod.end, params.density);
drillDownFilter.merge({
startTimestamp: periodTimestamps.startTimestamp,
endTimestamp: periodTimestamps.endTimestamp
});
}
}
}; };
}, []);
const depsString = JSON.stringify({ useEffect(() => {
..._metric.series, ..._metric.excludes, ..._metric.startPoint, if (!data.chart) return;
hideExcess: _metric.hideExcess const series = data.chart[0]
}); ? Object.keys(data.chart[0]).filter(
const fetchMetricChartData = (metric: any, payload: any, isSaved: any, period: any) => { (key) => key !== 'time' && key !== 'timestamp'
if (!isMounted()) return; )
setLoading(true); : [];
dashboardStore.fetchMetricChartData(metric, payload, isSaved, period).then((res: any) => { if (series.length) {
if (isMounted()) setData(res); setEnabledRows(series);
}).finally(() => { }
setLoading(false); }, [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), []); drillDownFilter.merge({
const loadPage = () => { startTimestamp: periodTimestamps.startTimestamp,
if (!inView) return; endTimestamp: periodTimestamps.endTimestamp,
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]);
const loadSample = () => console.log('clicked')
const renderChart = () => { const depsString = JSON.stringify({
const {metricType, viewType, metricOf} = metric; ..._metric.series,
const metricWithData = {...metric, data}; ..._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) { const debounceRequest: any = React.useCallback(
return <FunnelWidget metric={metric} data={data} isWidget={isSaved || isTemplate}/>; debounce(fetchMetricChartData, 500),
} []
);
if (metricType === 'predefined' || metricType === ERRORS) { const loadPage = () => {
const defaultMetric = metric.data.chart && metric.data.chart.length === 0 ? metricWithData : metric; if (!inView) return;
return <WidgetPredefinedChart isTemplate={isTemplate} metric={defaultMetric} data={data} if (prevMetricRef.current && prevMetricRef.current.name !== _metric.name) {
predefinedKey={metric.metricOf}/>; prevMetricRef.current = _metric;
} return;
}
if (metricType === TIMESERIES) { prevMetricRef.current = _metric;
if (viewType === 'lineChart') { const timestmaps = drillDownPeriod.toTimestamps();
return ( const payload = isSaved
<CustomMetricLineChart ? { ...metricParams }
data={data} : { ...params, ...timestmaps, ..._metric.toJson() };
colors={colors} debounceRequest(
params={params} _metric,
onClick={onChartClick} payload,
label={metric.metricOf === 'sessionCount' ? 'Number of Sessions' : 'Number of Users'} isSaved,
/> !isSaved ? drillDownPeriod : period
);
} 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 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); 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 React from 'react';
import SelectDateRange from 'Shared/SelectDateRange'; import SelectDateRange from 'Shared/SelectDateRange';
import {useStore} from 'App/mstore'; import { useStore } from 'App/mstore';
import {useObserver} from 'mobx-react-lite'; import { observer } from 'mobx-react-lite';
import {Space} from "antd"; 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({ function WidgetDateRange({
label = 'Time Range', label = 'Time Range',
}: any) { hasGranularSettings = false,
const {dashboardStore} = useStore(); hasGranularity = false,
const period = useObserver(() => dashboardStore.drillDownPeriod); hasComparison = false,
const drillDownFilter = useObserver(() => dashboardStore.drillDownFilter); 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) => { const onChangePeriod = (period: any) => {
dashboardStore.setDrillDownPeriod(period); dashboardStore.setDrillDownPeriod(period);
const periodTimestamps = period.toTimestamps(); const periodTimestamps = period.toTimestamps();
drillDownFilter.merge({ drillDownFilter.merge({
startTimestamp: periodTimestamps.startTimestamp, startTimestamp: periodTimestamps.startTimestamp,
endTimestamp: periodTimestamps.endTimestamp, 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 ( const updateInstComparison = (range: [start: string, end?: string] | null) => {
<Space> metricStore.instance.setComparisonRange(range);
{label && <span className="mr-1 color-gray-medium">{label}</span>} metricStore.instance.updateKey('hasChanged', true)
<SelectDateRange }
period={period}
onChange={onChangePeriod} return (
right={true} <Space>
isAnt={true} {label && <span className="mr-1 color-gray-medium">{label}</span>}
useButtonStyle={true} <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 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 { useStore } from 'App/mstore';
import { eventKeys } from 'Types/filter/newFilter'; import { eventKeys } from 'Types/filter/newFilter';
import { import {
@ -9,89 +9,147 @@ import {
INSIGHTS, INSIGHTS,
RETENTION, RETENTION,
TABLE, TABLE,
USER_PATH USER_PATH,
} from 'App/constants/card'; } from 'App/constants/card';
import FilterSeries from 'Components/Dashboard/components/FilterSeries/FilterSeries'; import FilterSeries from 'Components/Dashboard/components/FilterSeries/FilterSeries';
import { issueCategories, metricOf } from 'App/constants/filterOptions'; import { issueCategories } from 'App/constants/filterOptions';
import { AudioWaveform, ChevronDown, ChevronUp, PlusIcon } from 'lucide-react'; import { PlusIcon, ChevronUp } from 'lucide-react';
import { observer } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite';
import AddStepButton from 'Components/Dashboard/components/FilterSeries/AddStepButton';
import FilterItem from 'Shared/Filters/FilterItem'; import FilterItem from 'Shared/Filters/FilterItem';
import { FilterKey } from 'Types/filter/filterType'; import { FilterKey, FilterCategory } from 'Types/filter/filterType';
import Select from 'Shared/Select';
function WidgetFormNew() { const getExcludedKeys = (metricType: string) => {
const { metricStore, dashboardStore, aiFiltersStore } = useStore(); 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 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 isPredefined = metric.metricType === ERRORS;
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
return isPredefined ? <PredefinedMessage /> : ( return isPredefined ? (
<PredefinedMessage />
) : (
<Space direction="vertical" className="w-full"> <Space direction="vertical" className="w-full">
<AdditionalFilters /> <AdditionalFilters />
{/*{!hasFilters && (<DefineSteps metric={metric} excludeFilterKeys={excludeFilterKeys} />)}*/} <FilterSection
{/*{hasFilters && (<FilterSection metric={metric} excludeFilterKeys={excludeFilterKeys} />)}*/} layout={layout}
<FilterSection metric={metric} excludeFilterKeys={excludeFilterKeys} /> metric={metric}
excludeCategory={excludeCategory}
excludeFilterKeys={excludeFilterKeys}
/>
</Space> </Space>
); );
} }
export default observer(WidgetFormNew); 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) { React.useEffect(() => {
return ( const defaultSeriesCollapseState: Record<number, boolean> = {};
<div className="px-4 py-2 bg-white rounded-lg shadow-sm flex items-center"> metric.series.forEach((s: any) => {
<Typography.Text strong>Filter</Typography.Text> defaultSeriesCollapseState[s.seriesId] = defaultSeriesCollapseState[
<AddStepButton excludeFilterKeys={excludeFilterKeys} series={metric.series[0]} /> s.seriesId
</div> ]
); ? defaultSeriesCollapseState[s.seriesId]
} : allOpen
? false
: defaultClosed.current;
const FilterSection = observer(({ metric, excludeFilterKeys }: any) => { });
// const timeseriesOptions = metricOf.filter((i) => i.type === 'timeseries'); setSeriesCollapseState(defaultSeriesCollapseState);
// const tableOptions = metricOf.filter((i) => i.type === 'table'); }, [metric.series]);
const isTable = metric.metricType === TABLE; const isTable = metric.metricType === TABLE;
const isClickMap = metric.metricType === HEATMAP; const isHeatMap = metric.metricType === HEATMAP;
const isFunnel = metric.metricType === FUNNEL; const isFunnel = metric.metricType === FUNNEL;
const isInsights = metric.metricType === INSIGHTS; const isInsights = metric.metricType === INSIGHTS;
const isPathAnalysis = metric.metricType === USER_PATH; const isPathAnalysis = metric.metricType === USER_PATH;
const isRetention = metric.metricType === RETENTION; const isRetention = metric.metricType === RETENTION;
const canAddSeries = metric.series.length < 3; 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 isSingleSeries =
isTable ||
isFunnel ||
isHeatMap ||
isInsights ||
isRetention ||
isPathAnalysis;
// const onAddFilter = (filter: any) => { const collapseAll = () => {
// metric.series[0].filter.addFilter(filter); setSeriesCollapseState((seriesCollapseState) => {
// metric.updateKey('hasChanged', true) 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 ( return (
<> <>
{ {metric.series.length > 0 &&
metric.series.length > 0 && metric.series metric.series
.slice(0, isSingleSeries ? 1 : metric.series.length) .slice(0, isSingleSeries ? 1 : metric.series.length)
.map((series: any, index: number) => ( .map((series: any, index: number) => (
<div className="mb-2" key={series.name}> <div className="mb-2 rounded-xl" key={series.name}>
<FilterSeries <FilterSeries
isHeatmap={isHeatMap}
canExclude={isPathAnalysis} canExclude={isPathAnalysis}
supportsEmpty={!isClickMap && !isPathAnalysis} removeEvents={isPathAnalysis}
supportsEmpty={!isHeatMap && !isPathAnalysis}
excludeFilterKeys={excludeFilterKeys} excludeFilterKeys={excludeFilterKeys}
excludeCategory={excludeCategory}
observeChanges={() => metric.updateKey('hasChanged', true)} observeChanges={() => metric.updateKey('hasChanged', true)}
hideHeader={isTable || isClickMap || isInsights || isPathAnalysis || isFunnel} hideHeader={
isTable ||
isHeatMap ||
isInsights ||
isPathAnalysis ||
isFunnel
}
seriesIndex={index} seriesIndex={index}
series={series} series={series}
onRemoveSeries={() => metric.removeSeries(index)} onRemoveSeries={() => metric.removeSeries(index)}
canDelete={metric.series.length > 1} canDelete={metric.series.length > 1}
collapseState={seriesCollapseState[series.seriesId]}
onToggleCollapse={() => {
setSeriesCollapseState((seriesCollapseState) => ({
...seriesCollapseState,
[series.seriesId]: !seriesCollapseState[series.seriesId],
}));
}}
emptyMessage={ emptyMessage={
isTable isTable
? 'Filter data using any event or attribute. Use Add Step button below to do so.' ? '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} expandable={isSingleSeries}
/> />
</div> </div>
)) ))}
} {isSingleSeries ? null :
<div className={'mx-auto flex items-center gap-2 w-fit'}>
{!isSingleSeries && canAddSeries && ( <Tooltip title={canAddSeries ? '' : 'Maximum of 3 series reached.'}>
<Card styles={{ body: { padding: '4px' } }} className="rounded-full shadow-sm"> <Button
onClick={() => {
if (!canAddSeries) return;
metric.addSeries();
}}
disabled={!canAddSeries}
size="small"
type="primary"
icon={<PlusIcon size={16} />}
>
Add Series
</Button>
</Tooltip>
<Button <Button
type="link" size={'small'}
onClick={() => { type={'text'}
metric.addSeries(); icon={
}} <ChevronUp
disabled={!canAddSeries} size={16}
size="small" className={allCollapsed ? 'rotate-180' : ''}
className="block w-full" />
}
onClick={allCollapsed ? expandAll : collapseAll}
> >
<Space> {allCollapsed ? 'Expand' : 'Collapse'} All
<AudioWaveform size={16} />
New Chart Series
</Space>
</Button> </Button>
</Card> </div>
)} }
</> </>
); );
}); });
const PathAnalysisFilter = observer(({ metric, writeOption }: any) => { const PathAnalysisFilter = observer(({ metric, writeOption }: any) => {
const metricValueOptions = [ const metricValueOptions = [
{ value: 'location', label: 'Pages' }, { value: 'location', label: 'Pages' },
{ value: 'click', label: 'Clicks' }, { value: 'click', label: 'Clicks' },
{ value: 'input', label: 'Input' }, { value: 'input', label: 'Input' },
{ value: 'custom', label: 'Custom' } { value: 'custom', label: 'Custom' },
]; ];
return ( return (
<Card styles={{ body: { padding: '20px 20px' } }}> <Card styles={{ body: { padding: '20px 20px' } }} className="rounded-lg">
<Form.Item> <Form.Item>
<Space> <div className="flex flex-wrap gap-2 items-center justify-start">
<Select <span className="font-medium">User journeys with: </span>
name="startType"
options={[ <div className="flex sm:flex-wrap lg:flex-nowrap gap-2 items-start">
{ value: 'start', label: 'With Start Point' }, <Select
{ value: 'end', label: 'With End Point' } className="w-36 rounded-xl"
]} name="startType"
defaultValue={metric.startType} options={[
onChange={writeOption} { value: 'start', label: 'Start Point' },
placeholder="All Issues" { value: 'end', label: 'End Point' },
/> ]}
<span className="mx-3">showing</span> defaultValue={metric.startType || 'start'}
<Select onChange={(value) => writeOption({ name: 'startType', value })}
name="metricValue" placeholder="Select Start Type"
options={metricValueOptions} size="small"
value={metric.metricValue} />
isMulti={true}
onChange={writeOption} <span className="text-neutral-400 mt-.5">showing</span>
placeholder="All Issues"
/> <Select
</Space> mode="multiple"
</Form.Item> className="min-w-36 rounded-xl"
<Form.Item label={metric.startType === 'start' ? 'Start Point' : 'End Point'} className="mb-0"> allowClear
<FilterItem name="metricValue"
hideDelete options={metricValueOptions}
filter={metric.startPoint} value={metric.metricValue || []}
allowedFilterKeys={[FilterKey.LOCATION, FilterKey.CLICK, FilterKey.INPUT, FilterKey.CUSTOM]} onChange={(value) => writeOption({ name: 'metricValue', value })}
onUpdate={val => metric.updateStartPoint(val)} placeholder="Select Metrics"
onRemoveFilter={() => { size="small"
}} />
/> </div>
</div>
</Form.Item> </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> </Card>
); );
}); });
@ -184,6 +278,7 @@ const InsightsFilter = observer(({ metric, writeOption }: any) => {
onChange={writeOption} onChange={writeOption}
isMulti isMulti
placeholder="All Categories" placeholder="All Categories"
allowClear
/> />
</Space> </Space>
</Form.Item> </Form.Item>
@ -192,7 +287,7 @@ const InsightsFilter = observer(({ metric, writeOption }: any) => {
}); });
const AdditionalFilters = observer(() => { const AdditionalFilters = observer(() => {
const { metricStore, dashboardStore, aiFiltersStore } = useStore(); const { metricStore } = useStore();
const metric: any = metricStore.instance; const metric: any = metricStore.instance;
const writeOption = ({ value, name }: { value: any; name: any }) => { const writeOption = ({ value, name }: { value: any; name: any }) => {
@ -203,14 +298,22 @@ const AdditionalFilters = observer(() => {
return ( return (
<> <>
{metric.metricType === USER_PATH && <PathAnalysisFilter metric={metric} writeOption={writeOption} />} {metric.metricType === USER_PATH && (
{metric.metricType === INSIGHTS && <InsightsFilter metric={metric} writeOption={writeOption} />} <PathAnalysisFilter metric={metric} writeOption={writeOption} />
)}
{metric.metricType === INSIGHTS && (
<InsightsFilter metric={metric} writeOption={writeOption} />
)}
</> </>
); );
}); });
const PredefinedMessage = () => ( const PredefinedMessage = () => (
<Alert message="Drilldown or filtering isn't supported on this legacy card." type="warning" showIcon closable <Alert
className="border-transparent rounded-lg" /> 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) { function MetricTypeDropdown(props: Props) {
const { metricStore, userStore } = useStore(); const { metricStore, userStore } = useStore();
const isEnterprise = userStore.isEnterprise;
const metric: any = metricStore.instance; const metric: any = metricStore.instance;
const isEnterprise = userStore.isEnterprise;
const options = React.useMemo(() => { const options = React.useMemo(() => {
return DROPDOWN_OPTIONS.map((option: any) => { 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) => { const onChange = (type: string) => {
metricStore.changeType(type); metricStore.changeType(type);
}; };
@ -51,7 +39,7 @@ function MetricTypeDropdown(props: Props) {
value={ value={
DROPDOWN_OPTIONS.find((i: any) => i.value === metric.metricType) || DROPDOWN_OPTIONS[0] DROPDOWN_OPTIONS.find((i: any) => i.value === metric.metricType) || DROPDOWN_OPTIONS[0]
} }
onChange={props.onSelect} onChange={({ value }) => onChange(value.value)}
components={{ components={{
SingleValue: ({ children, ...props }: any) => { SingleValue: ({ children, ...props }: any) => {
const { const {

View file

@ -1,90 +1,77 @@
import React, { useState, useRef, useEffect } from 'react'; import React, { useState, useRef, useEffect } from 'react';
import { Icon, Tooltip } from 'UI'; import { Input, Tooltip } from 'antd';
import { Input } from 'antd';
import cn from 'classnames'; import cn from 'classnames';
interface Props { interface Props {
name: string; name: string;
onUpdate: (name: any) => void; onUpdate: (name: string) => void;
seriesIndex?: number; seriesIndex?: number;
canEdit?: boolean canEdit?: boolean;
} }
function WidgetName(props: Props) { function WidgetName(props: Props) {
const { canEdit = true } = props; const { canEdit = true } = props;
const [editing, setEditing] = useState(false) const [editing, setEditing] = useState(false);
const [name, setName] = useState(props.name) const [name, setName] = useState(props.name);
const ref = useRef<any>(null) const ref = useRef<any>(null);
const write = ({ target: { value } }) => { const write = ({ target: { value } }) => {
setName(value) setName(value);
} };
const onBlur = (nameInput?: string) => { const onBlur = (nameInput?: string) => {
setEditing(false) setEditing(false);
const toUpdate = nameInput || name const toUpdate = nameInput || name;
props.onUpdate(toUpdate && toUpdate.trim() === '' ? 'New Widget' : toUpdate) 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(() => { useEffect(() => {
if (editing) { if (editing) {
ref.current.focus() ref.current.focus();
} }
}, [editing]) }, [editing]);
useEffect(() => { useEffect(() => {
setName(props.name) setName(props.name);
}, [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])
return ( return (
<div className="flex items-center"> <div className="flex items-center">
{ editing ? ( {editing ? (
<Input <Input
ref={ ref } ref={ref}
name="name" name="name"
value={name} value={name}
onChange={write} onChange={write}
onBlur={() => onBlur()} onBlur={() => onBlur()}
onKeyDown={onKeyDown}
onFocus={() => setEditing(true)} onFocus={() => setEditing(true)}
maxLength={80} maxLength={80}
className="bg-white text-2xl ps-2 rounded-lg h-8"
/> />
) : ( ) : (
// @ts-ignore // @ts-ignore
<Tooltip delay={200} title="Double click to edit" disabled={!canEdit}> <Tooltip delay={200} title="Click to edit" disabled={!canEdit}>
<div <div
onDoubleClick={() => setEditing(true)} onClick={() => setEditing(true)}
className={ className={cn(
cn( "text-2xl h-8 flex items-center p-2 rounded-lg",
"text-2xl h-8 flex items-center border-transparent", canEdit && 'cursor-pointer select-none ps-2 hover:bg-teal/10'
canEdit && 'cursor-pointer select-none border-b border-b-borderColor-transparent hover:border-dotted hover:border-gray-medium' )}
)
}
> >
{ name } {name}
</div> </div>
</Tooltip> </Tooltip>
)} )}
{ canEdit && <div className="ml-3 cursor-pointer" onClick={() => setEditing(true)}>
<Tooltip title='Rename' placement='bottom'>
<Icon name="pencil" size="16" />
</Tooltip>
</div> }
</div> </div>
); );
} }

View file

@ -1,60 +1,214 @@
import React from 'react'; import React from 'react';
import { FUNNEL, HEATMAP, TABLE, USER_PATH } from 'App/constants/card'; import {
import { Select, Space, Switch } from 'antd'; 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 { useStore } from 'App/mstore';
import ClickMapRagePicker from 'Components/Dashboard/components/ClickMapRagePicker/ClickMapRagePicker'; import ClickMapRagePicker from 'Components/Dashboard/components/ClickMapRagePicker/ClickMapRagePicker';
import { FilterKey } from 'Types/filter/filterType'; import { FilterKey } from 'Types/filter/filterType';
import { observer } from 'mobx-react-lite'; 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() {
const { metricStore } = useStore();
}
function WidgetOptions(props: Props) {
const { metricStore, dashboardStore } = useStore();
const metric: any = metricStore.instance; const metric: any = metricStore.instance;
const handleChange = (value: any) => { const handleChange = (value: any) => {
metric.update({ metricFormat: value }); metric.update({ metricFormat: value });
metric.updateKey('hasChanged', true);
}; };
// const hasSeriesTypes = [TIMESERIES, FUNNEL, TABLE].includes(metric.metricType);
const hasViewTypes = [TIMESERIES, FUNNEL].includes(metric.metricType);
return ( return (
<div> <div className={'flex items-center gap-2'}>
{metric.metricType === USER_PATH && ( {metric.metricType === USER_PATH && (
<a <a
href="#" href="#"
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
metric.update({ hideExcess: !metric.hideExcess }); metric.update({ hideExcess: !metric.hideExcess });
metric.updateKey('hasChanged', true);
}} }}
> >
<Space> <Space>
<Switch checked={metric.hideExcess} size="small" /> <Switch checked={metric.hideExcess} size="small" />
<span className="mr-4 color-gray-medium"> <span className="mr-4 color-gray-medium">Hide Minor Paths</span>
Hide Minor Paths
</span>
</Space> </Space>
</a> </a>
)} )}
{(metric.metricType === FUNNEL || metric.metricType === TABLE) && metric.metricOf != FilterKey.USERID && metric.metricOf != FilterKey.ERRORS && ( {metric.metricType === TIMESERIES && <SeriesTypeOptions metric={metric} />}
<Select {(metric.metricType === FUNNEL || metric.metricType === TABLE) &&
defaultValue={metric.metricFormat} metric.metricOf !== FilterKey.USERID &&
onChange={handleChange} metric.metricOf !== FilterKey.ERRORS && (
variant="borderless" <Dropdown
options={[ trigger={['click']}
{ value: 'sessionCount', label: 'Sessions' }, menu={{
{ value: 'userCount', label: 'Users' } selectable: true,
]} items: [
/> { key: 'sessionCount', label: 'All Sessions' },
)} { key: 'userCount', label: 'Unique Users' },
],
onClick: (info: { key: string }) => handleChange(info.key),
}}
{metric.metricType === HEATMAP ? ( >
<ClickMapRagePicker /> <Button type="text" variant="text" size="small">
) : null} {metric.metricFormat === 'sessionCount'
? 'All Sessions'
: 'Unique Users'}
<DownOutlined className="text-sm" />
</Button>
</Dropdown>
)}
{hasViewTypes && <WidgetViewTypeOptions metric={metric} />}
{metric.metricType === HEATMAP && <ClickMapRagePicker />}
</div> </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); export default observer(WidgetOptions);

View file

@ -1,11 +1,9 @@
import { Button, Space, Switch } from 'antd';
import cn from 'classnames'; import cn from 'classnames';
import { observer } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite';
import React from 'react'; import React from 'react';
import WidgetDateRange from "Components/Dashboard/components/WidgetDateRange/WidgetDateRange";
import { HEATMAP, USER_PATH } from 'App/constants/card';
import { useStore } from 'App/mstore'; import { useStore } from 'App/mstore';
import ClickMapRagePicker from 'Components/Dashboard/components/ClickMapRagePicker'; import { FUNNEL, TIMESERIES } from "App/constants/card";
import WidgetWrapper from '../WidgetWrapper'; import WidgetWrapper from '../WidgetWrapper';
import WidgetOptions from 'Components/Dashboard/components/WidgetOptions'; import WidgetOptions from 'Components/Dashboard/components/WidgetOptions';
@ -18,101 +16,32 @@ interface Props {
function WidgetPreview(props: Props) { function WidgetPreview(props: Props) {
const { className = '' } = props; const { className = '' } = props;
const { metricStore, dashboardStore } = useStore(); const { metricStore } = useStore();
const metric: any = metricStore.instance; 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 ( return (
<> <>
<div <div
className={cn(className, 'bg-white rounded-xl border shadow-sm mt-0')} className={cn(className, 'bg-white rounded-xl border shadow-sm mt-0')}
> >
<div className="flex items-center justify-between px-4 pt-2"> <div className="flex items-center gap-2 px-4 py-2 border-b">
<h2 className="text-xl">{props.name}</h2> <WidgetDateRange
<div className="flex items-center"> label=""
hasGranularSettings={hasGranularSettings}
hasGranularity={hasGranularity}
hasComparison={hasComparison}
presetComparison={presetComparison}
/>
<div className="ml-auto">
<WidgetOptions /> <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> </div>
<div className="pt-0"> <div className="py-4">
<WidgetWrapper <WidgetWrapper
widget={metric} widget={metric}
isPreview={true} isPreview={true}

View file

@ -1,6 +1,7 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { NoContent, Loader, Pagination, Button } from 'UI'; import { NoContent, Loader, Pagination } from 'UI';
import Select from 'Shared/Select'; import {Button, Tag, Tooltip, Dropdown, notification} from 'antd';
import {UndoOutlined, DownOutlined} from '@ant-design/icons'
import cn from 'classnames'; import cn from 'classnames';
import { useStore } from 'App/mstore'; import { useStore } from 'App/mstore';
import SessionItem from 'Shared/SessionItem'; import SessionItem from 'Shared/SessionItem';
@ -11,20 +12,22 @@ import useIsMounted from 'App/hooks/useIsMounted';
import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG'; import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG';
import { numberWithCommas } from 'App/utils'; import { numberWithCommas } from 'App/utils';
import { HEATMAP } from 'App/constants/card'; import { HEATMAP } from 'App/constants/card';
import { Tag } from 'antd';
interface Props { interface Props {
className?: string; className?: string;
} }
function WidgetSessions(props: Props) { function WidgetSessions(props: Props) {
const listRef = React.useRef<HTMLDivElement>(null);
const { className = '' } = props; const { className = '' } = props;
const [activeSeries, setActiveSeries] = useState('all'); const [activeSeries, setActiveSeries] = useState('all');
const [data, setData] = useState<any>([]); const [data, setData] = useState<any>([]);
const isMounted = useIsMounted(); const isMounted = useIsMounted();
const [loading, setLoading] = useState(false); 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 { dashboardStore, metricStore, sessionStore, customFieldStore } = useStore();
const focusedSeries = metricStore.focusedSeriesName;
const filter = dashboardStore.drillDownFilter; const filter = dashboardStore.drillDownFilter;
const widget = metricStore.instance; const widget = metricStore.instance;
const startTime = DateTime.fromMillis(filter.startTimestamp).toFormat('LLL dd, yyyy HH:mm'); 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 filterText = filter.filters.length > 0 ? filter.filters[0].value : '';
const metaList = customFieldStore.list.map((i: any) => i.key); 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(() => { useEffect(() => {
if (!data) return; if (!widget.series) return;
const seriesOptions = data.map((item: any) => ({ const seriesOptions = widget.series.map((item: any) => ({
label: item.seriesName, label: item.name,
value: item.seriesId value: item.seriesId
})); }));
setSeriesOptions([{ label: 'All', value: 'all' }, ...seriesOptions]); setSeriesOptions([{ label: 'All', value: 'all' }, ...seriesOptions]);
}, [data]); }, [widget.series]);
const fetchSessions = (metricId: any, filter: any) => { const fetchSessions = (metricId: any, filter: any) => {
if (!isMounted()) return; if (!isMounted()) return;
@ -52,6 +63,17 @@ function WidgetSessions(props: Props) {
.fetchSessions(metricId, filter) .fetchSessions(metricId, filter)
.then((res: any) => { .then((res: any) => {
setData(res); 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(() => { .finally(() => {
setLoading(false); setLoading(false);
@ -89,9 +111,10 @@ function WidgetSessions(props: Props) {
}; };
debounceClickMapSearch(customFilter); debounceClickMapSearch(customFilter);
} else { } else {
const usedSeries = focusedSeries ? widget.series.filter((s) => s.name === focusedSeries) : widget.series;
debounceRequest(widget.metricId, { debounceRequest(widget.metricId, {
...filter, ...filter,
series: widget.series.map((s) => s.toJson()), series: usedSeries.map((s) => s.toJson()),
page: metricStore.sessionsPage, page: metricStore.sessionsPage,
limit: metricStore.sessionsPageSize limit: metricStore.sessionsPageSize
}); });
@ -106,9 +129,23 @@ function WidgetSessions(props: Props) {
filter.filters, filter.filters,
depsString, depsString,
metricStore.clickMapSearch, metricStore.clickMapSearch,
activeSeries focusedSeries
]); ]);
useEffect(loadData, [metricStore.sessionsPage]); 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 = () => { const clearFilters = () => {
metricStore.updateKey('sessionsPage', 1); metricStore.updateKey('sessionsPage', 1);
@ -116,34 +153,46 @@ function WidgetSessions(props: Props) {
}; };
return ( 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 className="flex items-center justify-between">
<div> <div>
<div className="flex items-baseline"> <div className="flex items-baseline gap-2">
<h2 className="text-xl">{metricStore.clickMapSearch ? 'Clicks' : 'Sessions'}</h2> <h2 className="text-xl">{metricStore.clickMapSearch ? 'Clicks' : 'Sessions'}</h2>
<div className="ml-2 color-gray-medium"> <div className="ml-2 color-gray-medium">
{metricStore.clickMapLabel ? `on "${metricStore.clickMapLabel}" ` : null} {metricStore.clickMapLabel ? `on "${metricStore.clickMapLabel}" ` : null}
between <span className="font-medium color-gray-darkest">{startTime}</span> and{' '} between <span className="font-medium color-gray-darkest">{startTime}</span> and{' '}
<span className="font-medium color-gray-darkest">{endTime}</span>{' '} <span className="font-medium color-gray-darkest">{endTime}</span>{' '}
</div> </div>
{hasFilters && <Tooltip title='Clear Drilldown' placement='top'><Button type='text' size='small' onClick={clearFilters}><UndoOutlined /></Button></Tooltip>}
</div> </div>
{hasFilters && widget.metricType === 'table' && {hasFilters && widget.metricType === 'table' && <div className="py-2"><Tag closable onClose={clearFilters}>{filterText}</Tag></div>}
<div className="py-2"><Tag closable onClose={clearFilters}>{filterText}</Tag></div>}
</div> </div>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
{hasFilters && <Button variant="text-primary" onClick={clearFilters}>Clear Filters</Button>} {widget.metricType !== 'table' && widget.metricType !== HEATMAP && (
{widget.metricType !== 'table' && widget.metricType !== HEATMAP && ( <div className="flex items-center ml-6">
<div className="flex items-center ml-6"> <span className="mr-2 color-gray-medium">Filter by Series</span>
<span className="mr-2 color-gray-medium">Filter by Series</span> <Dropdown
<Select options={seriesOptions} defaultValue={'all'} onChange={writeOption} plain /> menu={{
</div> 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> </div>
<div className="mt-3"> <div className="mt-3" >
<Loader loading={loading}> <Loader loading={loading}>
<NoContent <NoContent
title={ title={
@ -164,7 +213,7 @@ function WidgetSessions(props: Props) {
</React.Fragment> </React.Fragment>
))} ))}
<div className="flex items-center justify-between p-5"> <div className="flex items-center justify-between p-5" ref={listRef}>
<div> <div>
Showing{' '} Showing{' '}
<span className="font-medium"> <span className="font-medium">

View file

@ -1,98 +1,83 @@
import {useHistory} from "react-router"; import { useHistory } from 'react-router';
import {useStore} from "App/mstore"; import { useStore } from 'App/mstore';
import {useObserver} from "mobx-react-lite"; import { observer } from 'mobx-react-lite';
import {Button, Dropdown, MenuProps, message, Modal} from "antd"; import { Button, Dropdown, MenuProps, Modal } from 'antd';
import {BellIcon, EllipsisVertical, TrashIcon} from "lucide-react"; import { BellIcon, EllipsisVertical, Grid2x2Plus, TrashIcon } from 'lucide-react';
import {toast} from "react-toastify"; import { toast } from 'react-toastify';
import React from "react"; import React from 'react';
import {useModal} from "Components/ModalContext"; import { useModal } from 'Components/ModalContext';
import AlertFormModal from "Components/Alerts/AlertFormModal/AlertFormModal"; import AlertFormModal from 'Components/Alerts/AlertFormModal/AlertFormModal';
import { showAddToDashboardModal } from 'Components/Dashboard/components/AddToDashboardButton';
const CardViewMenu = () => { const CardViewMenu = () => {
const history = useHistory(); const history = useHistory();
const {alertsStore, dashboardStore, metricStore} = useStore(); const { alertsStore, metricStore, dashboardStore } = useStore();
const widget = useObserver(() => metricStore.instance); const widget = metricStore.instance;
const {openModal, closeModal} = useModal(); const { openModal, closeModal } = useModal();
const showAlertModal = () => { const showAlertModal = () => {
const seriesId = widget.series[0] && widget.series[0].seriesId || ''; const seriesId = (widget.series[0] && widget.series[0].seriesId) || '';
alertsStore.init({query: {left: seriesId}}) alertsStore.init({ query: { left: seriesId } });
openModal(<AlertFormModal openModal(<AlertFormModal onClose={closeModal} />, {
onClose={closeModal} placement: 'right',
/>, { width: 620,
// title: 'Set Alerts', });
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'] = [ return (
{ <div className="flex items-center justify-between">
key: 'alert', <Dropdown menu={{ items }}>
label: "Set Alerts", <Button type='text' icon={<EllipsisVertical size={16} />} className='btn-card-options' />
icon: <BellIcon size={16}/>, </Dropdown>
disabled: !widget.exists() || widget.metricType === 'predefined', </div>
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>
);
}; };
export default CardViewMenu; export default observer(CardViewMenu);

View file

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

View file

@ -1,49 +1,78 @@
import React from 'react'; import React from 'react';
import cn from "classnames"; import cn from 'classnames';
import WidgetName from "Components/Dashboard/components/WidgetName"; import WidgetName from 'Components/Dashboard/components/WidgetName';
import {useStore} from "App/mstore"; import { useStore } from 'App/mstore';
import {useObserver} from "mobx-react-lite"; import { observer } from 'mobx-react-lite';
import AddToDashboardButton from "Components/Dashboard/components/AddToDashboardButton";
import WidgetDateRange from "Components/Dashboard/components/WidgetDateRange/WidgetDateRange"; import { Button, Space, Tooltip } from 'antd';
import {Button, Space} from "antd"; import CardViewMenu from 'Components/Dashboard/components/WidgetView/CardViewMenu';
import CardViewMenu from "Components/Dashboard/components/WidgetView/CardViewMenu"; import { Link2 } from 'lucide-react'
import copy from 'copy-to-clipboard';
interface Props { interface Props {
onClick?: () => void; onClick?: () => void;
onSave: () => void; onSave: () => void;
undoChanges?: () => void; undoChanges: () => void;
layoutControl?: React.ReactNode;
} }
function WidgetViewHeader({onClick, onSave, undoChanges}: Props) { const defaultText = 'Copy link to clipboard'
const {metricStore, dashboardStore} = useStore();
const widget = useObserver(() => metricStore.instance);
return ( function WidgetViewHeader({ onClick, onSave, layoutControl }: Props) {
<div const [tooltipText, setTooltipText] = React.useState(defaultText);
className={cn('flex justify-between items-center')} const { metricStore } = useStore();
onClick={onClick} const widget = metricStore.instance;
>
<h1 className="mb-0 text-2xl mr-4 min-w-fit"> const handleSave = () => {
<WidgetName name={widget.name} onSave();
onUpdate={(name) => metricStore.merge({name})} };
canEdit={true}
/> const copyUrl = () => {
</h1> const url = window.location.href;
<Space> copy(url)
<WidgetDateRange label=""/> setTooltipText('Link copied to clipboard!');
<AddToDashboardButton metricId={widget.metricId}/> setTimeout(() => setTooltipText(defaultText), 2000);
<Button }
type="primary" return (
onClick={onSave} <div
loading={metricStore.isSaving} className={cn(
disabled={metricStore.isSaving || !widget.hasChanged} 'flex justify-between items-center bg-white rounded-lg shadow-sm px-4 ps-2 py-2 border border-gray-lighter input-card-title'
> )}
Update onClick={onClick}
</Button> >
<CardViewMenu/> <h1 className="mb-0 text-2xl mr-4 min-w-fit ">
</Space> <WidgetName
</div> 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) { function AlertButton(props: Props) {
const {seriesId} = props; const { seriesId, initAlert } = props;
const {dashboardStore, alertsStore} = useStore(); const { alertsStore } = useStore();
const {openModal, closeModal} = useModal(); const { openModal, closeModal } = useModal();
const onClick = () => { const onClick = () => {
// dashboardStore.toggleAlertModal(true); initAlert?.();
alertsStore.init({query: {left: seriesId}}) alertsStore.init({ query: { left: seriesId } })
openModal(<AlertFormModal openModal(<AlertFormModal
onClose={closeModal} onClose={closeModal}
/>, { />, {
// title: 'Set Alerts',
placement: 'right', placement: 'right',
width: 620, width: 620,
}); });

View file

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

View file

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

View file

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

View file

@ -1,6 +1,7 @@
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import Widget from 'App/mstore/types/widget'; 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 cn from 'classnames';
import stl from './FunnelWidget.module.css'; import stl from './FunnelWidget.module.css';
import { observer } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite';
@ -11,15 +12,16 @@ import { useStore } from '@/mstore';
import Filter from '@/mstore/types/filter'; import Filter from '@/mstore/types/filter';
interface Props { interface Props {
metric?: Widget; metric?: Widget;
isWidget?: boolean; isWidget?: boolean;
data: any; data: { funnel: Funnel };
compData: { funnel: Funnel };
} }
function FunnelWidget(props: Props) { function FunnelWidget(props: Props) {
const { dashboardStore, searchStore } = useStore(); const { dashboardStore, searchStore } = useStore();
const [focusedFilter, setFocusedFilter] = React.useState<number | null>(null); 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 funnel = data.funnel || { stages: [] };
const totalSteps = funnel.stages.length; const totalSteps = funnel.stages.length;
const stages = isWidget ? [...funnel.stages.slice(0, 1), funnel.stages[funnel.stages.length - 1]] : funnel.stages; 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 metricLabel = metric?.metricFormat == 'userCount' ? 'Users' : 'Sessions';
const drillDownFilter = dashboardStore.drillDownFilter; const drillDownFilter = dashboardStore.drillDownFilter;
const drillDownPeriod = dashboardStore.drillDownPeriod; const drillDownPeriod = dashboardStore.drillDownPeriod;
const comparisonPeriod = metric ? dashboardStore.comparisonPeriods[metric.metricId] : undefined
const metricFilters = metric?.series[0]?.filter.filters || []; 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 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({ drillDownFilter.merge({
filters: filter.toJson().filters, filters: filter.toJson().filters,
startTimestamp: periodTimestamps.startTimestamp, 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) => { funnel.stages.forEach((s, i) => {
// turning on all filters if one was focused already // turning on all filters if one was focused already
if (focusedFilter === index) { 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 ( return (
<NoContent <NoContent
style={{ minHeight: 220 }} style={{ minHeight: 220 }}
@ -79,20 +98,21 @@ function FunnelWidget(props: Props) {
} }
show={!stages || stages.length === 0} show={!stages || stages.length === 0}
> >
<div className="w-full"> <div className={cn('w-full border-b -mx-4 px-4', isHorizontal ? 'overflow-x-scroll custom-scrollbar flex gap-2 justify-around' : '')}>
{!isWidget && ( {!isWidget &&
stages.map((filter: any, index: any) => ( shownStages.map((stage: any, index: any) => (
<Stage <Stage
key={index} key={index}
isHorizontal={isHorizontal}
index={index + 1} index={index + 1}
isWidget={isWidget} isWidget={isWidget}
stage={filter} stage={stage.data}
compData={stage.compData}
focusStage={focusStage} focusStage={focusStage}
focusedFilter={focusedFilter} focusedFilter={focusedFilter}
metricLabel={metricLabel} metricLabel={metricLabel}
/> />
)) ))}
)}
{isWidget && ( {isWidget && (
<> <>
@ -110,38 +130,56 @@ function FunnelWidget(props: Props) {
</> </>
)} )}
</div> </div>
<div className="flex items-center pb-4"> <div className="flex items-center py-2 gap-2">
<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"> <div className="flex items-center">
<span className="text-base font-medium mr-2">Total conversion</span> <span className="text-base font-medium mr-2">Total conversion</span>
<Tooltip title={`${funnel.totalConversions} Sessions ${funnel.totalConversionsPercentage}%`}> <Tooltip
<Tag bordered={false} color="green" className="text-lg font-medium rounded-lg"> title={`${funnel.totalConversions} Sessions ${funnel.totalConversionsPercentage}%`}
>
<Tag
bordered={false}
color="#F5F8FF"
className="text-lg rounded-lg !text-black"
>
{funnel.totalConversions} {funnel.totalConversions}
</Tag> </Tag>
</Tooltip> </Tooltip>
</div> </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> </div>
{funnel.totalDropDueToIssues > 0 && <div className="flex items-center mb-2"><Icon name="magic" /> <span {funnel.totalDropDueToIssues > 0 && (
className="ml-2">{funnel.totalDropDueToIssues} sessions dropped due to issues.</span></div>} <div className="flex items-center mb-2">
<Icon name="magic" />{' '}
<span className="ml-2">
{funnel.totalDropDueToIssues} sessions dropped due to issues.
</span>
</div>
)}
</NoContent> </NoContent>
); );
} }
export const EmptyStage = observer(({ total }: any) => { export const EmptyStage = observer(({ total }: any) => {
return ( 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} /> <IndexNumber index={0} />
<div <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" 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'}`} {`+${total} ${total > 1 ? 'steps' : 'step'}`}
</div> </div>
<div className="border-b w-full border-dashed"></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) => { export const Stage = observer(({
return stage ? ( metricLabel,
<div stage,
className={cn('flex items-start', stl.step, { [stl['step-disabled']]: !stage.isActive })} index,
> uxt,
<IndexNumber index={index} /> focusStage,
{!uxt ? <Funnelbar metricLabel={metricLabel} index={index} filter={stage} focusStage={focusStage} focusedFilter,
focusedFilter={focusedFilter} /> : <UxTFunnelBar filter={stage} />} compData,
{/*{!isWidget && !uxt && <BarActions bar={stage} />}*/} isHorizontal,
</div> }: 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) => { export const IndexNumber = observer(({ index }: any) => {
return ( return (
<div <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">
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}
{index === 0 ? <Icon size="14" color="gray-dark" name="list" /> : index} </div>
</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>
);
});
export default observer(FunnelWidget); export default observer(FunnelWidget);

View file

@ -17,7 +17,10 @@ function HealthModal({
}: { }: {
getHealth: () => void; getHealth: () => void;
isLoading: boolean; isLoading: boolean;
healthResponse: { overallHealth: boolean; healthMap: Record<string, IServiceStats> }; healthResponse: {
overallHealth: boolean;
healthMap: Record<string, IServiceStats>;
};
setShowModal: (isOpen: boolean) => void; setShowModal: (isOpen: boolean) => void;
setPassed?: () => void; setPassed?: () => void;
}) { }) {
@ -39,7 +42,7 @@ function HealthModal({
setShowModal(false); setShowModal(false);
}; };
const isSetup = document.location.pathname.includes('/signup') const isSetup = document.location.pathname.includes('/signup');
return ( return (
<div <div
@ -64,7 +67,9 @@ function HealthModal({
transform: 'translate(-50%, -50%)', transform: 'translate(-50%, -50%)',
}} }}
onClick={(e) => e.stopPropagation()} 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 <div
className={ className={
@ -83,46 +88,64 @@ function HealthModal({
</div> </div>
<Loader loading={isLoading}> <Loader loading={isLoading}>
<div className={'flex w-full'}> {healthResponse ? (
<div className={'flex flex-col h-full'} style={{ flex: 1 }}> <>
{isLoading ? null <div className={'flex w-full'}>
: Object.keys(healthResponse.healthMap).map((service) => ( <div className={'flex flex-col h-full'} style={{ flex: 1 }}>
<React.Fragment key={service}> {isLoading
<Category ? null
onClick={() => setSelectedService(service)} : Object.keys(healthResponse.healthMap).map((service) => (
healthOk={healthResponse.healthMap[service].healthOk} <React.Fragment key={service}>
name={healthResponse.healthMap[service].name} <Category
isSelectable onClick={() => setSelectedService(service)}
isSelected={selectedService === 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>
<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> </Loader>
<Footer isSetup={isSetup} /> <Footer isSetup={isSetup} />
</div> </div>
@ -137,7 +160,10 @@ function ServiceStatus({ service }: { service: Record<string, any> }) {
<div className={'border rounded border-light-gray'}> <div className={'border rounded border-light-gray'}>
{Object.keys(subservices).map((subservice: string) => ( {Object.keys(subservices).map((subservice: string) => (
<React.Fragment key={subservice}> <React.Fragment key={subservice}>
<SubserviceHealth name={subservice} subservice={subservices[subservice]} /> <SubserviceHealth
name={subservice}
subservice={subservices[subservice]}
/>
</React.Fragment> </React.Fragment>
))} ))}
</div> </div>

View file

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

View file

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

View file

@ -158,7 +158,7 @@ function GroupedIssue({
onClick={createEventClickHandler(pointer, type)} onClick={createEventClickHandler(pointer, type)}
className={'flex items-center gap-2 mb-1 cursor-pointer border-b border-transparent hover:border-gray-lightest'} 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} /> <RenderLineData type={type} item={pointer} />
</div> </div>
))} ))}

View file

@ -7,7 +7,7 @@ import { KEYS } from 'Types/filter/customFilter';
import { capitalize } from 'App/utils'; import { capitalize } from 'App/utils';
import { useStore } from 'App/mstore'; import { useStore } from 'App/mstore';
import { observer } from 'mobx-react-lite'; 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 LiveSessionSearch from 'Shared/LiveSessionSearch';
import cn from 'classnames'; import cn from 'classnames';
import Session from 'App/mstore/types/session'; import Session from 'App/mstore/types/session';
@ -70,7 +70,7 @@ function AssistSessionsModal(props: ConnectProps) {
icon="arrow-repeat" icon="arrow-repeat"
/> />
</Tooltip> </Tooltip>
<AssistSearchField /> <AssistSearchActions />
</div> </div>
<div className="flex self-end items-center gap-2" w-full> <div className="flex self-end items-center gap-2" w-full>
<span className="color-gray-medium">Sort By</span> <span className="color-gray-medium">Sort By</span>

View file

@ -33,9 +33,14 @@ const Signup: React.FC<SignupProps> = ({ history }) => {
const getHealth = async () => { const getHealth = async () => {
setHealthStatusLoading(true); setHealthStatusLoading(true);
const { healthMap } = await getHealthRequest(true); try {
setHealthStatus(healthMap); const { healthMap } = await getHealthRequest(true);
setHealthStatusLoading(false); setHealthStatus(healthMap);
} catch (e) {
console.error(e);
} finally {
setHealthStatusLoading(false);
}
}; };
useEffect(() => { useEffect(() => {

View file

@ -50,7 +50,7 @@ const SpotsListHeader = observer(
<div className={'flex items-center justify-between w-full'}> <div className={'flex items-center justify-between w-full'}>
<div className="flex gap-1 items-center"> <div className="flex gap-1 items-center">
<h1 className={'text-2xl capitalize mr-2'}>Spot List</h1> <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> </div>
{tenantHasSpots ? ( {tenantHasSpots ? (

View file

@ -1,7 +1,7 @@
import { useStore } from "App/mstore"; import { useStore } from "App/mstore";
import React from 'react'; import React from 'react';
import { NoPermission, NoSessionPermission } from 'UI'; import { NoPermission, NoSessionPermission } from 'UI';
import { observer } from 'mobx-react-lite'
export default (requiredPermissions, className, isReplay = false, andEd = true) => (BaseComponent) => { export default (requiredPermissions, className, isReplay = false, andEd = true) => (BaseComponent) => {
@ -26,5 +26,5 @@ export default (requiredPermissions, className, isReplay = false, andEd = true)
</div> </div>
); );
} }
return WrapperClass; return observer(WrapperClass);
} }

View file

@ -1,16 +1,20 @@
import React from 'react'; import React from 'react';
import { Button } from 'antd'; import { Button } from 'antd';
import { useHistory } from 'react-router-dom'; 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 history = useHistory();
const siteId = location.pathname.split('/')[1]; const siteId = location.pathname.split('/')[1];
const handleBackClick = () => { const handleBackClick = () => {
history.push(`/${siteId}/dashboard`); history.push(`/${siteId}/dashboard`);
}; };
if (compact) {
return (
<Button onClick={handleBackClick} type={'text'} icon={<ArrowLeftOutlined />} />
)
}
return ( return (
<Button type="text" onClick={handleBackClick} icon={<LeftOutlined />} className="px-1 pe-2 me-2 gap-1"> <Button type="text" onClick={handleBackClick} icon={<LeftOutlined />} className="px-1 pe-2 me-2 gap-1">
Back Back

View file

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

View file

@ -17,12 +17,26 @@ import { DateTime, Interval } from 'luxon';
import styles from './dateRangePopup.module.css'; import styles from './dateRangePopup.module.css';
function DateRangePopup(props: any) { 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 [value, setValue] = React.useState<string | null>(null);
const selectCustomRange = (range) => { const selectCustomRange = (range) => {
const updatedRange = Interval.fromDateTimes(DateTime.fromJSDate(range[0]), DateTime.fromJSDate(range[1])); let newRange;
setRange(updatedRange); 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); setValue(CUSTOM_RANGE);
}; };
@ -53,8 +67,12 @@ function DateRangePopup(props: any) {
}; };
const { onCancel } = props; const { onCancel } = props;
const isUSLocale = navigator.language === 'en-US' || navigator.language.startsWith('en-US'); const isUSLocale =
const rangeForDisplay = [range.start!.startOf('day').ts, range.end!.startOf('day').ts] 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 ( return (
<div className={styles.wrapper}> <div className={styles.wrapper}>
<div className={`${styles.body} h-fit`}> <div className={`${styles.body} h-fit`}>
@ -84,41 +102,51 @@ function DateRangePopup(props: any) {
isOpen isOpen
maxDate={new Date()} maxDate={new Date()}
value={rangeForDisplay} value={rangeForDisplay}
calendarProps={{
tileDisabled: props.isTileDisabled,
selectRange: props.singleDay ? false : true,
}}
/> />
</div> </div>
</div> </div>
<div className="flex items-center justify-between py-2 px-3"> <div className="flex items-center justify-between py-2 px-3">
<div className="flex items-center gap-2"> {props.singleDay ? (
<label>From: </label> <div>
<span>{range.start.toFormat(isUSLocale ? "MM/dd" : "dd/MM")} </span> Compare from {range.start.toFormat('MMM dd, yyyy')}
<TimePicker </div>
format={isUSLocale ? 'hh:mm a' : "HH:mm"} ) : (
value={range.start} <div className="flex items-center gap-2">
onChange={setRangeTimeStart} <label>From: </label>
needConfirm={false} <span>{range.start.toFormat(isUSLocale ? 'MM/dd' : 'dd/MM')} </span>
showNow={false} <TimePicker
style={{ width: isUSLocale ? 102 : 76 }} format={isUSLocale ? 'hh:mm a' : 'HH:mm'}
/> value={range.start}
<label>To: </label> onChange={setRangeTimeStart}
<span>{range.end.toFormat(isUSLocale ? "MM/dd" : "dd/MM")} </span> needConfirm={false}
<TimePicker showNow={false}
format={isUSLocale ? 'hh:mm a' : "HH:mm"} style={{ width: isUSLocale ? 102 : 76 }}
value={range.end} />
onChange={setRangeTimeEnd} <label>To: </label>
needConfirm={false} <span>{range.end.toFormat(isUSLocale ? 'MM/dd' : 'dd/MM')} </span>
showNow={false} <TimePicker
style={{ width: isUSLocale ? 102 : 76 }} format={isUSLocale ? 'hh:mm a' : 'HH:mm'}
/> value={range.end}
</div> onChange={setRangeTimeEnd}
needConfirm={false}
showNow={false}
style={{ width: isUSLocale ? 102 : 76 }}
/>
</div>
)}
<div className="flex items-center"> <div className="flex items-center">
<Button onClick={onCancel}>{"Cancel"}</Button> <Button onClick={onCancel}>{'Cancel'}</Button>
<Button <Button
type="primary" type="primary"
className="ml-2" className="ml-2"
onClick={onApply} onClick={onApply}
disabled={!range} disabled={!range}
> >
{"Apply"} {'Apply'}
</Button> </Button>
</div> </div>
</div> </div>

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 React, {
import { Icon } from 'UI'; useState,
import APIClient from 'App/api_client'; useEffect,
useCallback,
useRef,
} from 'react';
import { debounce } from 'App/utils'; 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 { useStore } from 'App/mstore';
import { observer } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite';
import { searchService} from 'App/services'; import { searchService } from 'App/services';
import { AutocompleteModal, AutoCompleteContainer } from './AutocompleteModal';
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'
};
}
};
type FilterParam = { [key: string]: any }; type FilterParam = { [key: string]: any };
function processKey(input: FilterParam): FilterParam { function processKey(input: FilterParam): FilterParam {
const result: FilterParam = {}; const result: FilterParam = {};
for (const key in input) { 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); result[key] = input[key].substring(1);
} else { } else {
result[key] = input[key]; result[key] = input[key];
@ -123,191 +31,114 @@ function processKey(input: FilterParam): FilterParam {
interface Props { interface Props {
showOrButton?: boolean; showOrButton?: boolean;
showCloseButton?: boolean; showCloseButton?: boolean;
onRemoveValue?: () => void; onRemoveValue?: (ind: number) => void;
onAddValue?: () => void; onAddValue?: (ind: number) => void;
endpoint?: string; endpoint?: string;
method?: string; method?: string;
params?: any; params?: any;
headerText?: string; headerText?: string;
placeholder?: string; placeholder?: string;
onSelect: (e: any, item: any) => void; onSelect: (e: any, item: any, index: number) => void;
value: any; value: any;
icon?: string; icon?: string;
hideOrText?: boolean; hideOrText?: boolean;
onApplyValues: (values: string[]) => void;
modalProps?: Record<string, any>
} }
const FilterAutoComplete: React.FC<Props> = ({ const FilterAutoComplete = observer(
showCloseButton = false, ({
placeholder = 'Type to search', params = {},
method = 'GET', onClose,
showOrButton = false, onApply,
endpoint = '', values,
params = {}, placeholder,
value = '', }: { params: any, values: string[], onClose: () => void, onApply: (values: string[]) => void, placeholder?: string }) => {
hideOrText = false, const [options, setOptions] = useState<{ value: string; label: string }[]>(
onSelect, []
onRemoveValue, );
onAddValue const [initialFocus, setInitialFocus] = useState(false);
}: Props) => { const [loading, setLoading] = useState(false);
const [loading, setLoading] = useState(false); const { filterStore } = useStore();
const [options, setOptions] = useState<{ value: string; label: string }[]>([]); const _params = processKey(params);
const [query, setQuery] = useState(value); const filterKey = `${_params.type}${_params.key || ''}`;
const [menuIsOpen, setMenuIsOpen] = useState(false); const topValues = filterStore.topValues[filterKey] || [];
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 loadTopValues = () => { const loadTopValues = async () => {
setTopValuesLoading(true); setLoading(true)
filterStore.fetchTopValues(_params.type, _params.key).finally(() => { await filterStore.fetchTopValues(_params.type, _params.key);
setTopValuesLoading(false); setLoading(false)
setLoading(false); };
});
};
useEffect(() => { useEffect(() => {
if (topValues.length > 0) { if (topValues.length > 0) {
const mappedValues = topValues.map((i) => ({ value: i.value, label: i.value })); const mappedValues = topValues.map((i) => ({
setOptions(mappedValues); value: i.value,
if (!query.length && initialFocus) { label: i.value,
setMenuIsOpen(true); }));
setOptions(mappedValues);
} }
} }, [topValues, initialFocus]);
}, [topValues, initialFocus, query.length]);
useEffect(loadTopValues, [_params.type]); useEffect(() => { void loadTopValues() }, [_params.type]);
useEffect(() => { const loadOptions = async (
setQuery(value); inputValue: string,
}, [value]); ) => {
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) => { const debouncedLoadOptions = useCallback(debounce(loadOptions, 500), [
if (!inputValue.length) { params,
const mappedValues = topValues.map((i) => ({ value: i.value, label: i.value })); topValues,
setOptions(mappedValues); ]);
callback(mappedValues);
setLoading(false);
return;
}
try { const handleInputChange = (newValue: string) => {
// const response = await new APIClient()[method.toLowerCase()](endpoint, { ..._params, q: inputValue }); setInitialFocus(true);
const data = await searchService.fetchAutoCompleteValues({ ..._params, q: inputValue }) debouncedLoadOptions(newValue);
// 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 debouncedLoadOptions = useCallback(debounce(loadOptions, 1000), [params, topValues]); const handleFocus = () => {
setInitialFocus(true);
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);
setOptions(topValues.map((i) => ({ value: i.value, label: i.value }))); setOptions(topValues.map((i) => ({ value: i.value, label: i.value })));
} else { };
setMenuIsOpen(true);
}
};
const handleBlur = () => { return <AutocompleteModal
setMenuIsOpen(false); values={values}
setInitialFocus(false); onClose={onClose}
if (query !== previousQuery) { onApply={onApply}
onSelect(null, query); handleFocus={handleFocus}
} loadOptions={handleInputChange}
setPreviousQuery(query); options={options}
}; isLoading={loading}
placeholder={placeholder}
/>
}
);
const selected = value ? options.find((i) => i.value === query) : null; function AutoCompleteController(props: Props) {
const uniqueOptions = options.filter((i) => i.value !== query); return <AutoCompleteContainer {...props} modalRenderer={FilterAutoComplete} />
const selectOptionsArr = query.length ? [{ value: query, label: query }, ...uniqueOptions] : options; }
return ( export default AutoCompleteController;
<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);

View file

@ -1,90 +1,61 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { Icon } from 'UI'; import { Icon } from 'UI';
import stl from './FilterAutoCompleteLocal.module.css'; import stl from './FilterAutoCompleteLocal.module.css';
import { Input } from 'antd';
import { AutocompleteModal, AutoCompleteContainer } from 'Shared/Filters/FilterAutoComplete/AutocompleteModal';
interface Props { interface Props {
showOrButton?: boolean; showOrButton?: boolean;
showCloseButton?: boolean; showCloseButton?: boolean;
onRemoveValue?: () => void; onRemoveValue?: (index: number) => void;
onAddValue?: () => void; onAddValue?: (index: number) => void;
placeholder?: string; placeholder?: string;
onSelect: (e, item) => void; onSelect: (e: any, item: Record<string, any>, index: number) => void;
value: any; value: any;
icon?: string; icon?: string;
type?: string; type?: string;
isMultilple?: boolean; isMultiple?: boolean;
allowDecimals?: 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 { const {
showCloseButton = false, params = {},
placeholder = 'Enter', onClose,
showOrButton = false, onApply,
onRemoveValue = () => null, placeholder = 'Enter',
onAddValue = () => null, values,
value = '',
icon = null,
type = "text",
isMultilple = true,
allowDecimals = true,
} = props; } = props;
const [showModal, setShowModal] = useState(true) const [options, setOptions] = useState<{ value: string; label: string }[]>(
const [query, setQuery] = useState(value); values.filter(val => val.length).map((value) => ({ value, label: 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 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 React from 'react';
import styles from './FilterDuration.module.css'; import styles from './FilterDuration.module.css';
import { Input } from 'UI' import { Input } from 'antd'
const fromMs = value => value ? `${ value / 1000 / 60 }` : '' const fromMs = value => value ? `${ value / 1000 / 60 }` : ''
const toMs = value => value !== '' ? value * 1000 * 60 : null 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 { FilterKey, FilterType } from 'App/types/filter/filterType';
import SubFilterItem from '../SubFilterItem'; import SubFilterItem from '../SubFilterItem';
import { CircleMinus } from 'lucide-react'; import { CircleMinus } from 'lucide-react';
import cn from 'classnames'
interface Props { interface Props {
filterIndex?: number; filterIndex?: number;
@ -17,6 +18,7 @@ interface Props {
saveRequestPayloads?: boolean; saveRequestPayloads?: boolean;
disableDelete?: boolean; disableDelete?: boolean;
excludeFilterKeys?: Array<string>; excludeFilterKeys?: Array<string>;
excludeCategory?: Array<string>;
allowedFilterKeys?: Array<string>; allowedFilterKeys?: Array<string>;
readonly?: boolean; readonly?: boolean;
hideIndex?: boolean; hideIndex?: boolean;
@ -34,6 +36,7 @@ function FilterItem(props: Props) {
hideDelete = false, hideDelete = false,
allowedFilterKeys = [], allowedFilterKeys = [],
excludeFilterKeys = [], excludeFilterKeys = [],
excludeCategory = [],
isConditional, isConditional,
hideIndex = false, hideIndex = false,
} = props; } = props;
@ -42,7 +45,7 @@ function FilterItem(props: Props) {
const replaceFilter = (filter: any) => { const replaceFilter = (filter: any) => {
props.onUpdate({ props.onUpdate({
...filter, ...filter,
value: [''], value: filter.value,
filters: filter.filters ? filter.filters.map((i: any) => ({...i, 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 ( return (
<div className="flex items-center w-full"> <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 && ( {!isFilter && !hideIndex && filterIndex >= 0 && (
<div <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> <span>{filterIndex + 1}</span>
</div> </div>
)} )}
<FilterSelection <FilterSelection
filter={filter} filter={filter}
mode={props.isFilter ? 'filters' : 'events'}
onFilterClick={replaceFilter} onFilterClick={replaceFilter}
allowedFilterKeys={allowedFilterKeys} allowedFilterKeys={allowedFilterKeys}
excludeFilterKeys={excludeFilterKeys} excludeFilterKeys={excludeFilterKeys}
excludeCategory={excludeCategory}
disabled={disableDelete || props.readonly} disabled={disableDelete || props.readonly}
/> />
{/* Filter with Source */} <div className={cn('flex items-center flex-wrap', isReversed ? 'flex-row-reverse ml-2' : 'flex-row')}>
{filter.hasSource && ( {/* Filter with Source */}
<> {filter.hasSource && (
<FilterOperator <>
options={filter.sourceOperatorOptions} <FilterOperator
onChange={onSourceOperatorChange} options={filter.sourceOperatorOptions}
className="mx-2 flex-shrink-0" onChange={onSourceOperatorChange}
value={filter.sourceOperator} className="mx-2 flex-shrink-0 btn-event-operator"
isDisabled={filter.operatorDisabled || props.readonly} value={filter.sourceOperator}
/> isDisabled={filter.operatorDisabled || props.readonly}
<FilterSource filter={filter} onUpdate={props.onUpdate}/> />
</> <FilterSource filter={filter} onUpdate={props.onUpdate}/>
)} </>
)}
{/* Filter values */} {/* Filter values */}
{!isSubFilter && filter.operatorOptions && ( {!isSubFilter && filter.operatorOptions && (
<> <>
<FilterOperator <FilterOperator
options={filter.operatorOptions} options={filter.operatorOptions}
onChange={onOperatorChange} onChange={onOperatorChange}
className="mx-2 flex-shrink-0" className="mx-2 flex-shrink-0 btn-sub-event-operator"
value={filter.operator} value={filter.operator}
isDisabled={filter.operatorDisabled || props.readonly} isDisabled={filter.operatorDisabled || props.readonly}
/> />
{canShowValues && ( {canShowValues && (
<> <>
{props.readonly ? ( {props.readonly ? (
<div <div
className={'rounded bg-active-blue px-2 py-1 ml-2 whitespace-nowrap overflow-hidden text-clip'} 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) => { {filter.value.map((val: string) => {
return filter.options && filter.options.length return filter.options && filter.options.length
? filter.options[filter.options.findIndex((i: any) => i.value === val)]?.label ?? val ? filter.options[filter.options.findIndex((i: any) => i.value === val)]?.label ?? val
: val : val
}).join(', ')} }).join(', ')}
</div> </div>
) : ( ) : (
<FilterValue isConditional={isConditional} filter={filter} onUpdate={props.onUpdate}/> <FilterValue isConditional={isConditional} filter={filter} onUpdate={props.onUpdate}/>
)} )}
</> </>
)} )}
</> </>
)} )}
</div>
{/* filters */} {/* filters */}
{isSubFilter && ( {isSubFilter && (
@ -156,6 +164,7 @@ function FilterItem(props: Props) {
type="text" type="text"
onClick={props.onRemoveFilter} onClick={props.onRemoveFilter}
size="small" size="small"
className='btn-remove-step mt-2'
> >
<CircleMinus size={14} /> <CircleMinus size={14} />
</Button> </Button>

View file

@ -1,55 +1,57 @@
import {observer} from "mobx-react-lite"; import React from 'react';
import {Tooltip} from "UI"; import { observer } from 'mobx-react-lite';
import {Segmented} from "antd"; import { Dropdown, Button, Tooltip } from 'antd';
import React from "react";
const EventsOrder = observer((props: { const EventsOrder = observer(
onChange: (e: any, v: any) => void, (props: { onChange: (e: any, v: any) => void; filter: any }) => {
filter: any, const { filter, onChange } = props;
}) => {
const {filter, onChange} = props;
const eventsOrderSupport = filter.eventsOrderSupport; const eventsOrderSupport = filter.eventsOrderSupport;
const options = [
{ const menuItems = [
name: 'eventsOrder', {
label: 'THEN', key: 'then',
value: 'then', label: 'THEN',
disabled: eventsOrderSupport && !eventsOrderSupport.includes('then'), disabled: eventsOrderSupport && !eventsOrderSupport.includes('then'),
}, },
{ {
name: 'eventsOrder', key: 'and',
label: 'AND', label: 'AND',
value: 'and', disabled: eventsOrderSupport && !eventsOrderSupport.includes('and'),
disabled: eventsOrderSupport && !eventsOrderSupport.includes('and'), },
}, {
{ key: 'or',
name: 'eventsOrder', label: 'OR',
label: 'OR', disabled: eventsOrderSupport && !eventsOrderSupport.includes('or'),
value: '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"> const selected = menuItems.find(
<div (item) => item.key === filter.eventsOrder
className="color-gray-medium text-sm" )?.label;
style={{textDecoration: "underline dotted"}} return (
<div className="flex items-center gap-2">
<Tooltip
title="Select the operator to be applied between events."
placement="bottom"
> >
<Tooltip <div className="text-neutral-500/90 text-sm font-normal cursor-default">Events Order</div>
title={`Select the operator to be applied between events in your search.`} </Tooltip>
>
<div>Events Order</div>
</Tooltip>
</div>
<Segmented <Dropdown
size={"small"} menu={{ items: menuItems, onClick }}
className="text-sm" trigger={['click']}
onChange={(v) => onChange(null, options.find((i) => i.value === v))} placement="bottomRight"
value={filter.eventsOrder} className="text-sm rounded-lg px-1 py-0.5 btn-events-order "
options={options} data-event="btn-events-order"
/> >
</div>; <Button size={'small'} type='text'>{selected || 'Select'} </Button>
}); </Dropdown>
</div>
);
}
);
export default EventsOrder; export default EventsOrder;

View file

@ -1,223 +1,315 @@
import {Space} from 'antd'; import { GripVertical, Plus, Filter } from 'lucide-react';
import {List} from 'immutable'; import { observer } from 'mobx-react-lite';
import {GripHorizontal} from 'lucide-react'; import React, { useEffect } from 'react';
import {observer} from 'mobx-react-lite'; import { Button } from 'antd';
import React, {useEffect} from 'react'; import cn from 'classnames';
import FilterItem from '../FilterItem'; 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 { interface Props {
filter?: any; // event/filter filter?: any;
onUpdateFilter: (filterIndex: any, filter: any) => void; onUpdateFilter: (filterIndex: any, filter: any) => void;
onFilterMove?: (filters: any) => void; onFilterMove?: (filters: any) => void;
onRemoveFilter: (filterIndex: any) => void; onRemoveFilter: (filterIndex: any) => void;
onChangeEventsOrder: (e: any, {name, value}: any) => void; onChangeEventsOrder: (e: any, { name, value }: any) => void;
hideEventsOrder?: boolean; hideEventsOrder?: boolean;
observeChanges?: () => void; observeChanges?: () => void;
saveRequestPayloads?: boolean; saveRequestPayloads?: boolean;
supportsEmpty?: boolean; supportsEmpty?: boolean;
readonly?: boolean; readonly?: boolean;
excludeFilterKeys?: Array<string>; excludeFilterKeys?: Array<string>;
isConditional?: boolean; excludeCategory?: string[];
actions?: React.ReactNode[]; isConditional?: boolean;
actions?: React.ReactNode[];
onAddFilter: (filter: any) => void;
mergeDown?: boolean;
mergeUp?: boolean;
borderless?: boolean;
cannotAdd?: boolean;
} }
function FilterList(props: Props) { export const FilterList = observer((props: Props) => {
const { const {
observeChanges = () => { observeChanges = () => {},
}, filter,
filter, excludeFilterKeys = [],
hideEventsOrder = false, isConditional,
saveRequestPayloads, onAddFilter,
supportsEmpty = true, readonly,
excludeFilterKeys = [], borderless,
isConditional, excludeCategory,
actions = [] } = props;
} = props;
const filters = filter.filters; const filters = filter.filters;
const hasEvents = filters.filter((i: any) => i.isEvent).length > 0; useEffect(observeChanges, [filters]);
const hasFilters = filters.filter((i: any) => !i.isEvent).length > 0;
let rowIndex = 0; const onRemoveFilter = (filterIndex: any) => {
const cannotDeleteFilter = hasEvents && !supportsEmpty; 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) => { const filters = filter.filters;
props.onRemoveFilter(filterIndex); const hasEvents = filters.filter((i: any) => i.isEvent).length > 0;
};
const [hoveredItem, setHoveredItem] = React.useState<Record<string, any>>({ let rowIndex = 0;
i: null, const cannotDeleteFilter = hasEvents && !supportsEmpty;
position: null,
});
const [draggedInd, setDraggedItem] = React.useState<number | null>(null);
const handleDragOverEv = (event: Record<string, any>, i: number) => { useEffect(observeChanges, [filters]);
event.preventDefault();
const target = event.currentTarget.getBoundingClientRect();
const hoverMiddleY = (target.bottom - target.top) / 2;
const hoverClientY = event.clientY - target.top;
const position = hoverClientY < hoverMiddleY ? 'top' : 'bottom'; const onRemoveFilter = (filterIndex: any) => {
setHoveredItem({position, i}); props.onRemoveFilter(filterIndex);
}; };
const calculateNewPosition = React.useCallback( const [hoveredItem, setHoveredItem] = React.useState<Record<string, any>>({
(draggedInd: number, hoveredIndex: number, hoveredPosition: string) => { i: null,
if (hoveredPosition === 'bottom') { position: null,
hoveredIndex++; });
} const [draggedInd, setDraggedItem] = React.useState<number | null>(null);
return draggedInd < hoveredIndex ? hoveredIndex - 1 : hoveredIndex;
},
[]
);
const handleDragStart = React.useCallback(( const handleDragOverEv = (event: Record<string, any>, i: number) => {
ev: Record<string, any>, event.preventDefault();
index: number, const target = event.currentTarget.getBoundingClientRect();
elId: string const hoverMiddleY = (target.bottom - target.top) / 2;
) => { const hoverClientY = event.clientY - target.top;
ev.dataTransfer.setData("text/plain", index.toString());
setDraggedItem(index);
const el = document.getElementById(elId);
if (el) {
ev.dataTransfer.setDragImage(el, 0, 0);
}
}, [])
const handleDrop = React.useCallback( const position = hoverClientY < hoverMiddleY ? 'top' : 'bottom';
(event: Record<string, any>) => { setHoveredItem({ position, i });
event.preventDefault(); };
if (draggedInd === null) return;
const newItems = filters.toArray();
const newPosition = calculateNewPosition(
draggedInd,
hoveredItem.i,
hoveredItem.position
);
const reorderedItem = newItems.splice(draggedInd, 1)[0]; const calculateNewPosition = React.useCallback(
newItems.splice(newPosition, 0, reorderedItem); (draggedInd: number, hoveredIndex: number, hoveredPosition: string) => {
if (hoveredPosition === 'bottom') {
hoveredIndex++;
}
return draggedInd < hoveredIndex ? hoveredIndex - 1 : hoveredIndex;
},
[]
);
props.onFilterMove?.(List(newItems)); const handleDragStart = React.useCallback(
setHoveredItem({i: null, position: null}); (ev: Record<string, any>, index: number, elId: string) => {
setDraggedItem(null); ev.dataTransfer.setData('text/plain', index.toString());
}, setDraggedItem(index);
[draggedInd, hoveredItem, filters, props.onFilterMove] const el = document.getElementById(elId);
); if (el) {
ev.dataTransfer.setDragImage(el, 0, 0);
}
},
[]
);
const eventsNum = filters.filter((i: any) => i.isEvent).size const handleDrop = React.useCallback(
return ( (event: Record<string, any>) => {
<div className="flex flex-col"> event.preventDefault();
{hasEvents && ( if (draggedInd === null) return;
<> const newItems = filters;
<div className="flex items-center mb-2"> const newPosition = calculateNewPosition(
<div className="text-sm color-gray-medium mr-auto"> draggedInd,
{filter.eventsHeader || 'EVENTS'} hoveredItem.i,
</div> hoveredItem.position
);
<Space> const reorderedItem = newItems.splice(draggedInd, 1)[0];
{!hideEventsOrder && <EventsOrder filter={filter} newItems.splice(newPosition, 0, reorderedItem);
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"/>
</>
)}
{hasFilters && ( props.onFilterMove?.(newItems);
<> setHoveredItem({ i: null, position: null });
{hasEvents && <div className="border-t -mx-5 mb-4"/>} setDraggedItem(null);
<div className="mb-2 text-sm color-gray-medium mr-auto">FILTERS</div> },
{filters.map((filter: any, filterIndex: any) => [draggedInd, hoveredItem, filters, props.onFilterMove]
!filter.isEvent ? ( );
<div className={'py-2 hover:bg-active-blue px-5'} style={{
marginLeft: '-1.25rem', const eventsNum = filters.filter((i: any) => i.isEvent).length;
width: 'calc(100% + 2.5rem)', return (
}}> <div
<FilterItem className={
key={filterIndex} 'border-b border-b-gray-lighter pt-2 px-4 rounded-xl bg-white border border-gray-lighter'
readonly={props.readonly} }
isFilter={true} style={{
filterIndex={filterIndex} borderBottomLeftRadius: props.mergeDown ? 0 : undefined,
filter={filter} borderBottomRightRadius: props.mergeDown ? 0 : undefined,
onUpdate={(filter) => props.onUpdateFilter(filterIndex, filter)} borderTopLeftRadius: props.mergeUp ? 0 : undefined,
onRemoveFilter={() => onRemoveFilter(filterIndex)} borderTopRightRadius: props.mergeUp ? 0 : undefined,
excludeFilterKeys={excludeFilterKeys} marginBottom: props.mergeDown ? '-1px' : undefined,
isConditional={isConditional} }}
/> >
</div> <div className="flex items-center mb-2 gap-2">
) : null <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> </div>
); </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,
}}
export default observer(FilterList); 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-radius: .5rem;
border: solid thin $gray-light; border: solid thin $gray-light;
padding: 20px; padding: 20px;
overflow: hidden;
overflow-y: auto;
box-shadow: 0 2px 2px 0 $gray-light; box-shadow: 0 2px 2px 0 $gray-light;
} }
.optionItem { .optionItem {

View file

@ -7,11 +7,14 @@ import {
CircleAlert, CircleAlert,
Clock2, Clock2,
Code, Code,
ContactRound, CornerDownRight, ContactRound,
CornerDownRight,
Cpu, Cpu,
Earth, Earth,
FileStack, Layers, FileStack,
MapPin, Megaphone, Layers,
MapPin,
Megaphone,
MemoryStick, MemoryStick,
MonitorSmartphone, MonitorSmartphone,
Navigation, Navigation,
@ -25,63 +28,70 @@ import {
Timer, Timer,
VenetianMask, VenetianMask,
Workflow, Workflow,
Flag Flag,
ChevronRight,
} from 'lucide-react'; } from 'lucide-react';
import React from 'react'; import React from 'react';
import { Icon, Loader } from 'UI'; import { Icon, Loader } from 'UI';
import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG'; 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 stl from './FilterModal.module.css';
import { observer } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite';
import { useStore } from 'App/mstore'; import { useStore } from 'App/mstore';
const IconMap = { export const IconMap = {
[FilterKey.CLICK]: <Pointer size={18} />, [FilterKey.CLICK]: <Pointer size={14}/>,
[FilterKey.LOCATION]: <Navigation size={18} />, [FilterKey.LOCATION]: <Navigation size={14} />,
[FilterKey.INPUT]: <RectangleEllipsis size={18} />, [FilterKey.INPUT]: <RectangleEllipsis size={14} />,
[FilterKey.CUSTOM]: <Code size={18} />, [FilterKey.CUSTOM]: <Code size={14} />,
[FilterKey.FETCH]: <ArrowUpDown size={18} />, [FilterKey.FETCH]: <ArrowUpDown size={14} />,
[FilterKey.GRAPHQL]: <Network size={18} />, [FilterKey.GRAPHQL]: <Network size={14} />,
[FilterKey.STATEACTION]: <RectangleEllipsis size={18} />, [FilterKey.STATEACTION]: <RectangleEllipsis size={14} />,
[FilterKey.ERROR]: <OctagonAlert size={18} />, [FilterKey.ERROR]: <OctagonAlert size={14} />,
[FilterKey.ISSUE]: <CircleAlert size={18} />, [FilterKey.ISSUE]: <CircleAlert size={14} />,
[FilterKey.FETCH_FAILED]: <Code size={18} />, [FilterKey.FETCH_FAILED]: <Code size={14} />,
[FilterKey.DOM_COMPLETE]: <ArrowUpDown size={18} />, [FilterKey.DOM_COMPLETE]: <ArrowUpDown size={14} />,
[FilterKey.LARGEST_CONTENTFUL_PAINT_TIME]: <Network size={18} />, [FilterKey.LARGEST_CONTENTFUL_PAINT_TIME]: <Network size={14} />,
[FilterKey.TTFB]: <Timer size={18} />, [FilterKey.TTFB]: <Timer size={14} />,
[FilterKey.AVG_CPU_LOAD]: <Cpu size={18} />, [FilterKey.AVG_CPU_LOAD]: <Cpu size={14} />,
[FilterKey.AVG_MEMORY_USAGE]: <MemoryStick size={18} />, [FilterKey.AVG_MEMORY_USAGE]: <MemoryStick size={14} />,
[FilterKey.USERID]: <SquareUser size={18} />, [FilterKey.USERID]: <SquareUser size={14} />,
[FilterKey.USERANONYMOUSID]: <VenetianMask size={18} />, [FilterKey.USERANONYMOUSID]: <VenetianMask size={14} />,
[FilterKey.USER_CITY]: <Pin size={18} />, [FilterKey.USER_CITY]: <Pin size={14} />,
[FilterKey.USER_STATE]: <MapPin size={18} />, [FilterKey.USER_STATE]: <MapPin size={14} />,
[FilterKey.USER_COUNTRY]: <Earth size={18} />, [FilterKey.USER_COUNTRY]: <Earth size={14} />,
[FilterKey.USER_DEVICE]: <Code size={18} />, [FilterKey.USER_DEVICE]: <Code size={14} />,
[FilterKey.USER_OS]: <AppWindow size={18} />, [FilterKey.USER_OS]: <AppWindow size={14} />,
[FilterKey.USER_BROWSER]: <Chrome size={18} />, [FilterKey.USER_BROWSER]: <Chrome size={14} />,
[FilterKey.PLATFORM]: <MonitorSmartphone size={18} />, [FilterKey.PLATFORM]: <MonitorSmartphone size={14} />,
[FilterKey.REVID]: <FileStack size={18} />, [FilterKey.REVID]: <FileStack size={14} />,
[FilterKey.REFERRER]: <Workflow size={18} />, [FilterKey.REFERRER]: <Workflow size={14} />,
[FilterKey.DURATION]: <Clock2 size={18} />, [FilterKey.DURATION]: <Clock2 size={14} />,
[FilterKey.TAGGED_ELEMENT]: <SquareMousePointer size={18} />, [FilterKey.TAGGED_ELEMENT]: <SquareMousePointer size={14} />,
[FilterKey.METADATA]: <ContactRound size={18} />, [FilterKey.METADATA]: <ContactRound size={14} />,
[FilterKey.UTM_SOURCE]: <CornerDownRight size={18} />, [FilterKey.UTM_SOURCE]: <CornerDownRight size={14} />,
[FilterKey.UTM_MEDIUM]: <Layers size={18} />, [FilterKey.UTM_MEDIUM]: <Layers size={14} />,
[FilterKey.UTM_CAMPAIGN]: <Megaphone size={18} />, [FilterKey.UTM_CAMPAIGN]: <Megaphone size={14} />,
[FilterKey.FEATURE_FLAG]: <Flag size={18} /> [FilterKey.FEATURE_FLAG]: <Flag size={14} />,
}; };
function filterJson( function filterJson(
jsonObj: Record<string, any>, jsonObj: Record<string, any>,
excludeKeys: string[] = [], excludeKeys: string[] = [],
allowedFilterKeys: string[] = [] excludeCategory: string[] = [],
allowedFilterKeys: string[] = [],
mode: 'filters' | 'events'
): Record<string, any> { ): Record<string, any> {
return Object.fromEntries( return Object.fromEntries(
Object.entries(jsonObj) Object.entries(jsonObj)
.map(([key, value]) => { .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 (excludeKeys.includes(i.key)) return false;
if (mode === 'events' && !i.isEvent) return false;
if (mode === 'filters' && i.isEvent) return false;
return !( return !(
allowedFilterKeys.length > 0 && !allowedFilterKeys.includes(i.key) allowedFilterKeys.length > 0 && !allowedFilterKeys.includes(i.key)
); );
@ -102,8 +112,8 @@ export const getMatchingEntries = (
if (lowerCaseQuery.length === 0) if (lowerCaseQuery.length === 0)
return { return {
matchingCategories: Object.keys(filters), matchingCategories: ['All', ...Object.keys(filters)],
matchingFilters: filters matchingFilters: filters,
}; };
Object.keys(filters).forEach((name) => { Object.keys(filters).forEach((name) => {
@ -120,7 +130,7 @@ export const getMatchingEntries = (
} }
}); });
return { matchingCategories, matchingFilters }; return { matchingCategories: ['All', ...matchingCategories], matchingFilters };
}; };
interface Props { interface Props {
@ -131,42 +141,79 @@ interface Props {
isMainSearch?: boolean; isMainSearch?: boolean;
searchQuery?: string; searchQuery?: string;
excludeFilterKeys?: Array<string>; excludeFilterKeys?: Array<string>;
excludeCategory?: Array<string>;
allowedFilterKeys?: Array<string>; allowedFilterKeys?: Array<string>;
isConditional?: boolean; isConditional?: boolean;
isMobile?: 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) { function FilterModal(props: Props) {
const { const {
isLive, isLive,
onFilterClick = () => null, onFilterClick = () => null,
isMainSearch = false, isMainSearch = false,
searchQuery = '',
excludeFilterKeys = [], excludeFilterKeys = [],
excludeCategory = [],
allowedFilterKeys = [], allowedFilterKeys = [],
isConditional, isConditional,
mode,
} = props; } = props;
const [searchQuery, setSearchQuery] = React.useState('');
const [category, setCategory] = React.useState('All');
const { searchStore, searchStoreLive, projectsStore } = useStore(); const { searchStore, searchStoreLive, projectsStore } = useStore();
const isMobile = projectsStore.active?.platform === 'ios'; // TODO - should be using mobile once the app is changed 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 conditionalFilters = searchStore.filterListConditional;
const mobileConditionalFilters = searchStore.filterListMobileConditional; const mobileConditionalFilters = searchStore.filterListMobileConditional;
const showSearchList = isMainSearch && searchQuery.length > 0; const showSearchList = isMainSearch && searchQuery.length > 0;
const filterSearchList = isLive ? searchStoreLive.filterSearchList : searchStore.filterSearchList; const filterSearchList = isLive
const fetchingFilterSearchList = isLive ? searchStoreLive.loadingFilterSearch : searchStore.loadingFilterSearch; ? 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 onFilterSearchClick = (filter: any) => {
const _filter = { ...filtersMap[filter.type] }; const _filter = { ...filtersMap[filter.type] };
_filter.value = [filter.value]; _filter.value = [filter.value];
onFilterClick(_filter); parseAndAdd(_filter);
}; };
const filterJsonObj = isConditional const filterJsonObj = isConditional
? isMobile ? mobileConditionalFilters : conditionalFilters ? isMobile
? mobileConditionalFilters
: conditionalFilters
: filters; : filters;
const { matchingCategories, matchingFilters } = getMatchingEntries( const { matchingCategories, matchingFilters } = getMatchingEntries(
searchQuery, searchQuery,
filterJson(filterJsonObj, excludeFilterKeys, allowedFilterKeys) filterJson(filterJsonObj, excludeFilterKeys, excludeCategory, allowedFilterKeys, mode)
); );
const isResultEmpty = const isResultEmpty =
@ -174,53 +221,63 @@ function FilterModal(props: Props) {
matchingCategories.length === 0 && matchingCategories.length === 0 &&
Object.keys(matchingFilters).length === 0; Object.keys(matchingFilters).length === 0;
const getNewIcon = (filter: Record<string, any>) => { const displayedFilters =
if (filter.icon?.includes('metadata')) { category === 'All'
return IconMap[FilterKey.METADATA]; ? Object.entries(matchingFilters).flatMap(([category, filters]) =>
} filters.map((f: any) => ({ ...f, category }))
// @ts-ignore )
if (IconMap[filter.key]) { : matchingFilters[category];
// @ts-ignore
return IconMap[filter.key];
} else return <Icon name={filter.icon} size={16} />; console.log(displayedFilters)
};
return ( return (
<div <div
className={stl.wrapper} className={stl.wrapper}
style={{ width: '480px', maxHeight: '380px', overflowY: 'auto', borderRadius: '.5rem' }} style={{ width: '560px', maxHeight: '380px' }}
> >
<div <Input
className={searchQuery && !isResultEmpty ? 'mb-6' : ''} className={'mb-4 rounded-xl text-lg font-medium placeholder:text-lg placeholder:font-medium placeholder:text-neutral-300'}
style={{ columns: matchingCategories.length > 1 ? 'auto 200px' : 1 }} placeholder={'Search'}
> value={searchQuery}
{matchingCategories.map((key) => { onChange={(e) => setSearchQuery(e.target.value)}
return ( />
<div className={'flex gap-2 items-start'}>
<div className={'flex flex-col gap-1'}>
{matchingCategories.map((key) => (
<div <div
className="mb-6 flex flex-col gap-2 break-inside-avoid"
key={key} 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}
{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>
</div> </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> </div>
{showSearchList && ( {showSearchList && (
<Loader loading={fetchingFilterSearchList}> <Loader loading={fetchingFilterSearchList}>

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