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:
parent
954e811be0
commit
622d0a7dfa
203 changed files with 8759 additions and 4599 deletions
|
|
@ -52,11 +52,22 @@ function AlertForm(props) {
|
|||
onDelete,
|
||||
style = {height: "calc('100vh - 40px')"},
|
||||
} = props;
|
||||
const {alertsStore} = useStore()
|
||||
const {alertsStore, metricStore} = useStore()
|
||||
const {
|
||||
triggerOptions,
|
||||
triggerOptions: allTriggerSeries,
|
||||
loading,
|
||||
} = alertsStore
|
||||
|
||||
const triggerOptions = metricStore.instance.series.length > 0 ? allTriggerSeries.filter(s => {
|
||||
return metricStore.instance.series.findIndex(ms => ms.seriesId === s.value) !== -1
|
||||
}).map(v => {
|
||||
const labelArr = v.label.split('.')
|
||||
labelArr.shift()
|
||||
return {
|
||||
...v,
|
||||
label: labelArr.join('.')
|
||||
}
|
||||
}) : allTriggerSeries
|
||||
const instance = alertsStore.instance
|
||||
const deleting = loading
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import React from 'react';
|
||||
import { Button } from 'antd';
|
||||
import { useModal } from 'App/components/Modal';
|
||||
import SessionSearchField from 'Shared/SessionSearchField';
|
||||
import { MODULES } from 'Components/Client/Modules';
|
||||
|
||||
import AssistStats from '../../AssistStats';
|
||||
|
|
@ -9,7 +8,7 @@ import Recordings from '../RecordingsList/Recordings';
|
|||
import { useStore } from 'App/mstore';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
|
||||
function AssistSearchField() {
|
||||
function AssistSearchActions() {
|
||||
const { searchStoreLive, userStore } = useStore();
|
||||
const modules = userStore.account.settings?.modules ?? [];
|
||||
const isEnterprise = userStore.isEnterprise
|
||||
|
|
@ -27,9 +26,6 @@ function AssistSearchField() {
|
|||
};
|
||||
return (
|
||||
<div className="flex items-center w-full gap-2">
|
||||
<div style={{ width: '60%' }}>
|
||||
<SessionSearchField />
|
||||
</div>
|
||||
{isEnterprise && modules.includes(MODULES.OFFLINE_RECORDINGS)
|
||||
? <Button type="primary" ghost onClick={showRecords}>Training Videos</Button> : null
|
||||
}
|
||||
|
|
@ -50,4 +46,4 @@ function AssistSearchField() {
|
|||
);
|
||||
}
|
||||
|
||||
export default observer(AssistSearchField);
|
||||
export default observer(AssistSearchActions);
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from './AssistSearchActions'
|
||||
|
|
@ -1 +0,0 @@
|
|||
export { default } from './AssistSearchField'
|
||||
|
|
@ -1,14 +1,14 @@
|
|||
import React from 'react';
|
||||
import LiveSessionList from 'Shared/LiveSessionList';
|
||||
import LiveSessionSearch from 'Shared/LiveSessionSearch';
|
||||
import AssistSearchField from './AssistSearchField';
|
||||
import AssistSearchActions from './AssistSearchActions';
|
||||
import usePageTitle from '@/hooks/usePageTitle';
|
||||
|
||||
function AssistView() {
|
||||
usePageTitle('Co-Browse - OpenReplay');
|
||||
return (
|
||||
<div className="w-full mx-auto" style={{ maxWidth: '1360px'}}>
|
||||
<AssistSearchField />
|
||||
<AssistSearchActions />
|
||||
<LiveSessionSearch />
|
||||
<div className="my-4" />
|
||||
<LiveSessionList />
|
||||
|
|
|
|||
95
frontend/app/components/Charts/BarChart.tsx
Normal file
95
frontend/app/components/Charts/BarChart.tsx
Normal 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;
|
||||
101
frontend/app/components/Charts/ColumnChart.tsx
Normal file
101
frontend/app/components/Charts/ColumnChart.tsx
Normal 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;
|
||||
102
frontend/app/components/Charts/LineChart.tsx
Normal file
102
frontend/app/components/Charts/LineChart.tsx
Normal 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;
|
||||
123
frontend/app/components/Charts/PieChart.tsx
Normal file
123
frontend/app/components/Charts/PieChart.tsx
Normal 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;
|
||||
254
frontend/app/components/Charts/SankeyChart.tsx
Normal file
254
frontend/app/components/Charts/SankeyChart.tsx
Normal 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;
|
||||
129
frontend/app/components/Charts/barUtils.ts
Normal file
129
frontend/app/components/Charts/barUtils.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
93
frontend/app/components/Charts/init.ts
Normal file
93
frontend/app/components/Charts/init.ts
Normal 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 };
|
||||
31
frontend/app/components/Charts/pieUtils.ts
Normal file
31
frontend/app/components/Charts/pieUtils.ts
Normal 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];
|
||||
}
|
||||
60
frontend/app/components/Charts/sankeyUtils.ts
Normal file
60
frontend/app/components/Charts/sankeyUtils.ts
Normal 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;
|
||||
}
|
||||
|
||||
346
frontend/app/components/Charts/utils.ts
Normal file
346
frontend/app/components/Charts/utils.ts
Normal 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 partner’s value from some global map
|
||||
|
||||
const partnerVal = (window as any).__seriesValueMap?.[uuid]?.[
|
||||
partnerName
|
||||
]?.[dataIndex];
|
||||
|
||||
const categoryLabel = (window as any).__categoryMap[uuid]
|
||||
? (window as any).__categoryMap[uuid][dataIndex]
|
||||
: dataIndex;
|
||||
|
||||
const firstTs = isPrevious ? comparisonTimestamp : timestamp;
|
||||
const secondTs = isPrevious ? timestamp : comparisonTimestamp;
|
||||
let tooltipContent = `
|
||||
<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[];
|
||||
};
|
||||
}
|
||||
|
|
@ -57,7 +57,7 @@ const ProjectList: React.FC = () => {
|
|||
<div className="h-full flex flex-col gap-4">
|
||||
<div className="flex flex-row gap-2 items-center p-3">
|
||||
<Input
|
||||
placeholder="Search"
|
||||
placeholder="Search projects"
|
||||
// onSearch={handleSearch}
|
||||
prefix={<SearchOutlined />}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
|
|
@ -73,7 +73,7 @@ const ProjectList: React.FC = () => {
|
|||
mode="inline"
|
||||
onClick={onClick}
|
||||
selectedKeys={[String(projectsStore.config.pid)]}
|
||||
className="w-full !bg-white !border-0 "
|
||||
className="w-full !bg-white !border-0"
|
||||
inlineIndent={11}
|
||||
items={menuItems}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ interface Props {
|
|||
function CardSessionsByList({ list, selected, paginated, onClickHandler = () => null, metric, total }: Props) {
|
||||
const { dashboardStore, metricStore, sessionStore } = useStore();
|
||||
const drillDownPeriod = dashboardStore.drillDownPeriod;
|
||||
const params = { density: 70 };
|
||||
const params = { density: 35 };
|
||||
const metricParams = { ...params };
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const data = paginated ? metric?.data[0]?.values : list;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -15,7 +15,7 @@ function ClickMapCard() {
|
|||
|
||||
const sessionId = metricStore.instance.data.sessionId;
|
||||
const url = metricStore.instance.data.path;
|
||||
const operator = metricStore.instance.series[0].filter.filters[0].operator
|
||||
const operator = metricStore.instance.series[0]?.filter.filters[0]?.operator ? metricStore.instance.series[0].filter.filters[0].operator : 'startsWith'
|
||||
|
||||
|
||||
React.useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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
|
||||
|
|
@ -1 +0,0 @@
|
|||
export { default } from './CustomMetricLineChart';
|
||||
|
|
@ -1,54 +1,89 @@
|
|||
//@ts-nocheck
|
||||
import React from 'react'
|
||||
import React, { useState } from 'react';
|
||||
import { ResponsiveContainer, Tooltip } from 'recharts';
|
||||
import { PieChart, Pie, Cell } from 'recharts';
|
||||
import { PieChart, Pie, Cell, Legend } from 'recharts';
|
||||
import { Styles } from '../../common';
|
||||
import { NoContent } from 'UI';
|
||||
import { filtersMap } from 'Types/filter/newFilter';
|
||||
import { numberWithCommas } from 'App/utils';
|
||||
import CustomTooltip from '../CustomChartTooltip';
|
||||
|
||||
interface Props {
|
||||
metric: any,
|
||||
data: any;
|
||||
metric: {
|
||||
metricOf: string;
|
||||
metricType: string;
|
||||
};
|
||||
data: {
|
||||
chart: any[];
|
||||
namesMap: string[];
|
||||
};
|
||||
colors: any;
|
||||
onClick?: (filters) => void;
|
||||
inGrid?: boolean;
|
||||
}
|
||||
|
||||
function CustomMetricPieChart(props: Props) {
|
||||
const { metric, data = { values: [] }, onClick = () => null } = props;
|
||||
const { metric, data, onClick = () => null, inGrid } = props;
|
||||
|
||||
const [hoveredSeries, setHoveredSeries] = useState<string | null>(null);
|
||||
|
||||
const onClickHandler = (event) => {
|
||||
if (event && !event.payload.group) {
|
||||
const filters = Array<any>();
|
||||
let filter = { ...filtersMap[metric.metricOf] }
|
||||
filter.value = [event.payload.name]
|
||||
filter.type = filter.key
|
||||
delete filter.key
|
||||
delete filter.operatorOptions
|
||||
delete filter.category
|
||||
delete filter.icon
|
||||
delete filter.label
|
||||
delete filter.options
|
||||
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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseOver = (name: string) => setHoveredSeries(name);
|
||||
const handleMouseLeave = () => setHoveredSeries(null);
|
||||
|
||||
const getTotalForSeries = (series: string) =>
|
||||
data.chart ? data.chart.reduce((acc, curr) => acc + curr[series], 0) : 0;
|
||||
|
||||
const values = data.namesMap.map((k) => ({
|
||||
name: k,
|
||||
value: getTotalForSeries(k),
|
||||
}));
|
||||
|
||||
const highest = values.reduce(
|
||||
(acc, curr) => (acc.value > curr.value ? acc : curr),
|
||||
{ name: '', value: 0 }
|
||||
);
|
||||
|
||||
return (
|
||||
<NoContent size="small" title="No data available" show={!data.values || data.values.length === 0} style={{ minHeight: '240px'}}>
|
||||
<ResponsiveContainer height={ 220 } width="100%">
|
||||
<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={data.values}
|
||||
dataKey="sessionCount"
|
||||
nameKey="name"
|
||||
isAnimationActive={false}
|
||||
data={values}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
// innerRadius={40}
|
||||
outerRadius={70}
|
||||
// fill={colors[0]}
|
||||
cy="60%"
|
||||
innerRadius={60}
|
||||
outerRadius={100}
|
||||
activeIndex={1}
|
||||
onClick={onClickHandler}
|
||||
onMouseOver={({ name }) => handleMouseOver(name)}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
labelLine={({
|
||||
cx,
|
||||
cy,
|
||||
|
|
@ -65,15 +100,22 @@ function CustomMetricPieChart(props: Props) {
|
|||
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 percentage = (value * 100) / highest.value;
|
||||
|
||||
if (percentage<3){
|
||||
if (percentage < 3) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return(
|
||||
<line x1={x1} y1={y1} x2={x2} y2={y2} stroke="#3EAAAF" strokeWidth={1} />
|
||||
)
|
||||
return (
|
||||
<line
|
||||
x1={x1}
|
||||
y1={y1}
|
||||
x2={x2}
|
||||
y2={y2}
|
||||
stroke="#3EAAAF"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
label={({
|
||||
cx,
|
||||
|
|
@ -82,16 +124,16 @@ function CustomMetricPieChart(props: Props) {
|
|||
innerRadius,
|
||||
outerRadius,
|
||||
value,
|
||||
index
|
||||
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';
|
||||
const percentage = (value / highest.value) * 100;
|
||||
let name = values[index].name || 'Unidentified';
|
||||
name = name.length > 20 ? name.substring(0, 20) + '...' : name;
|
||||
if (percentage<3){
|
||||
if (percentage < 3) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
|
|
@ -100,27 +142,26 @@ function CustomMetricPieChart(props: Props) {
|
|||
y={y}
|
||||
fontWeight="400"
|
||||
fontSize="12px"
|
||||
// fontFamily="'Source Sans Pro', 'Roboto', 'Helvetica Neue', 'Helvetica', 'Arial', 'sans-serif'"
|
||||
textAnchor={x > cx ? "start" : "end"}
|
||||
textAnchor={x > cx ? 'start' : 'end'}
|
||||
dominantBaseline="central"
|
||||
fill='#666'
|
||||
fill="#666"
|
||||
>
|
||||
{name || 'Unidentified'} {numberWithCommas(value)}
|
||||
{numberWithCommas(value)}
|
||||
</text>
|
||||
);
|
||||
}}
|
||||
>
|
||||
{data && data.values && data.values.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={Styles.colorsPie[index % Styles.colorsPie.length]} />
|
||||
{values.map((entry, index) => (
|
||||
<Cell
|
||||
key={`cell-${index}`}
|
||||
fill={Styles.safeColors[index % Styles.safeColors.length]}
|
||||
/>
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip {...Styles.tooltip} />
|
||||
</PieChart>
|
||||
|
||||
</ResponsiveContainer>
|
||||
<div className="text-sm color-gray-medium">Top 5 </div>
|
||||
</NoContent>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export default CustomMetricPieChart;
|
||||
|
|
@ -8,6 +8,7 @@ const compareColors = ['#192EDB', '#6272FF', '#808DFF', '#B3BBFF', '#C9CFFF'];
|
|||
const compareColorsx = ["#222F99", "#2E3ECC", "#394EFF", "#6171FF", "#8895FF", "#B0B8FF", "#D7DCFF"].reverse();
|
||||
const customMetricColors = ['#394EFF', '#3EAAAF', '#565D97'];
|
||||
const colorsPie = colors.concat(["#DDDDDD"]);
|
||||
const safeColors = ['#394EFF', '#3EAAAF', '#9276da', '#ceba64', "#bc6f9d", '#966fbc', '#64ce86', '#e06da3', '#6dabe0'];
|
||||
|
||||
const countView = count => {
|
||||
const isMoreThanK = count >= 1000;
|
||||
|
|
@ -22,6 +23,7 @@ export default {
|
|||
colorsx,
|
||||
compareColors,
|
||||
compareColorsx,
|
||||
safeColors,
|
||||
lineColor: '#2A7B7F',
|
||||
lineColorCompare: '#394EFF',
|
||||
strokeColor: compareColors[0],
|
||||
|
|
@ -29,13 +31,13 @@ export default {
|
|||
axisLine: {stroke: '#CCCCCC'},
|
||||
interval: 0,
|
||||
dataKey: "time",
|
||||
tick: {fill: '#999999', fontSize: 9},
|
||||
tick: {fill: '#000000', fontSize: 9},
|
||||
tickLine: {stroke: '#CCCCCC'},
|
||||
strokeWidth: 0.5
|
||||
},
|
||||
yaxis: {
|
||||
axisLine: {stroke: '#CCCCCC'},
|
||||
tick: {fill: '#999999', fontSize: 9},
|
||||
tick: {fill: '#000000', fontSize: 9},
|
||||
tickLine: {stroke: '#CCCCCC'},
|
||||
},
|
||||
axisLabelLeft: {
|
||||
|
|
@ -50,8 +52,8 @@ export default {
|
|||
tickFormatterBytes: val => Math.round(val / 1024 / 1024),
|
||||
chartMargins: {left: 0, right: 20, top: 10, bottom: 5},
|
||||
tooltip: {
|
||||
cursor: {
|
||||
fill: '#f6f6f6'
|
||||
wrapperStyle: {
|
||||
zIndex: 999,
|
||||
},
|
||||
contentStyle: {
|
||||
padding: '5px',
|
||||
|
|
@ -73,6 +75,9 @@ export default {
|
|||
lineHeight: '0.75rem',
|
||||
color: '#000',
|
||||
fontSize: '12px'
|
||||
},
|
||||
cursor: {
|
||||
fill: '#eee'
|
||||
}
|
||||
},
|
||||
gradientDef: () => (
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -1,31 +1,31 @@
|
|||
// Components/Dashboard/components/AddToDashboardButton.tsx
|
||||
|
||||
import React from 'react';
|
||||
import {Grid2x2Check} from "lucide-react"
|
||||
import {Button, Modal} from "antd";
|
||||
import Select from "Shared/Select/Select";
|
||||
import {Form} from "UI";
|
||||
import {useStore} from "App/mstore";
|
||||
import { Grid2x2Check } from 'lucide-react';
|
||||
import { Button, Modal } from 'antd';
|
||||
import Select from 'Shared/Select/Select';
|
||||
import { Form } from 'UI';
|
||||
import { useStore } from 'App/mstore';
|
||||
|
||||
interface Props {
|
||||
metricId: string;
|
||||
}
|
||||
|
||||
function AddToDashboardButton({metricId}: Props) {
|
||||
const {dashboardStore} = useStore();
|
||||
export const showAddToDashboardModal = (metricId: string, dashboardStore: any) => {
|
||||
const dashboardOptions = dashboardStore.dashboards.map((i: any) => ({
|
||||
key: i.id,
|
||||
label: i.name,
|
||||
value: i.dashboardId,
|
||||
}));
|
||||
const [selectedId, setSelectedId] = React.useState(dashboardOptions[0]?.value);
|
||||
let selectedId = dashboardOptions[0]?.value;
|
||||
|
||||
const onSave = (close: any) => {
|
||||
const dashboard = dashboardStore.getDashboard(selectedId)
|
||||
const dashboard = dashboardStore.getDashboard(selectedId);
|
||||
if (dashboard) {
|
||||
dashboardStore.addWidgetToDashboard(dashboard, [metricId]).then(close)
|
||||
}
|
||||
dashboardStore.addWidgetToDashboard(dashboard, [metricId]).then(close);
|
||||
}
|
||||
};
|
||||
|
||||
const onClick = () => {
|
||||
Modal.confirm({
|
||||
title: 'Add to selected dashboard',
|
||||
icon: null,
|
||||
|
|
@ -33,32 +33,35 @@ function AddToDashboardButton({metricId}: Props) {
|
|||
<Form.Field>
|
||||
<Select
|
||||
options={dashboardOptions}
|
||||
defaultValue={dashboardOptions[0].value}
|
||||
onChange={({value}: any) => setSelectedId(value.value)}
|
||||
defaultValue={selectedId}
|
||||
onChange={({ value }: any) => (selectedId = value.value)}
|
||||
/>
|
||||
</Form.Field>
|
||||
),
|
||||
cancelText: 'Cancel',
|
||||
onOk: onSave,
|
||||
okText: 'Add',
|
||||
footer: (_, {OkBtn, CancelBtn}) => (
|
||||
footer: (_, { OkBtn, CancelBtn }) => (
|
||||
<>
|
||||
<CancelBtn/>
|
||||
<OkBtn/>
|
||||
<CancelBtn />
|
||||
<OkBtn />
|
||||
</>
|
||||
),
|
||||
})
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const AddToDashboardButton = ({ metricId }: Props) => {
|
||||
const { dashboardStore } = useStore();
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="default"
|
||||
onClick={onClick}
|
||||
icon={<Grid2x2Check size={18}/>}
|
||||
onClick={() => showAddToDashboardModal(metricId, dashboardStore)}
|
||||
icon={<Grid2x2Check size={18} />}
|
||||
>
|
||||
Add to Dashboard
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default AddToDashboardButton;
|
||||
|
|
@ -60,8 +60,8 @@ function AlertsList({ siteId }: Props) {
|
|||
|
||||
<div className='w-full flex items-center justify-between pt-4 px-6'>
|
||||
<div className=''>
|
||||
Showing <span className='font-semibold'>{Math.min(list.length, pageSize)}</span> out of{' '}
|
||||
<span className='font-semibold'>{list.length}</span> Alerts
|
||||
Showing <span className='font-medium'>{Math.min(list.length, pageSize)}</span> out of{' '}
|
||||
<span className='font-medium'>{list.length}</span> Alerts
|
||||
</div>
|
||||
<Pagination
|
||||
page={page}
|
||||
|
|
|
|||
|
|
@ -76,6 +76,7 @@ const NewAlert = (props: IProps) => {
|
|||
triggerOptions,
|
||||
loading,
|
||||
} = alertsStore
|
||||
|
||||
const deleting = loading
|
||||
const webhooks = settingsStore.webhooks
|
||||
const fetchWebhooks = settingsStore.fetchWebhooks
|
||||
|
|
|
|||
|
|
@ -59,8 +59,8 @@ function CardUserList(props: RouteComponentProps<Props>) {
|
|||
|
||||
<div className="w-full flex items-center justify-between pt-4">
|
||||
<div className="text-disabled-text">
|
||||
Showing <span className="font-semibold">{Math.min(data.length, pageSize)}</span> out of{' '}
|
||||
<span className="font-semibold">{data.length}</span> Issues
|
||||
Showing <span className="font-medium">{Math.min(data.length, pageSize)}</span> out of{' '}
|
||||
<span className="font-medium">{data.length}</span> Issues
|
||||
</div>
|
||||
<Pagination
|
||||
page={metricStore.sessionsPage}
|
||||
|
|
|
|||
|
|
@ -1,25 +1,45 @@
|
|||
import React from "react";
|
||||
import {PlusOutlined} from "@ant-design/icons";
|
||||
import NewDashboardModal from "Components/Dashboard/components/DashboardList/NewDashModal";
|
||||
import {Button} from "antd";
|
||||
import React from 'react';
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
import { Button } from 'antd';
|
||||
import { useStore } from 'App/mstore';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
|
||||
interface Props {
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function CreateDashboardButton({disabled = false}: Props) {
|
||||
const [showModal, setShowModal] = React.useState(false);
|
||||
function CreateDashboardButton({ disabled }: Props) {
|
||||
const [dashboardCreating, setDashboardCreating] = React.useState(false);
|
||||
const { projectsStore, dashboardStore } = useStore();
|
||||
const siteId = projectsStore.siteId;
|
||||
const history = useHistory();
|
||||
|
||||
return <>
|
||||
const createNewDashboard = async () => {
|
||||
setDashboardCreating(true);
|
||||
dashboardStore.initDashboard();
|
||||
await dashboardStore
|
||||
.save(dashboardStore.dashboardInstance)
|
||||
.then(async (syncedDashboard) => {
|
||||
dashboardStore.selectDashboardById(syncedDashboard.dashboardId);
|
||||
history.push(`/${siteId}/dashboard/${syncedDashboard.dashboardId}`);
|
||||
})
|
||||
.finally(() => {
|
||||
setDashboardCreating(false);
|
||||
});
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
icon={<PlusOutlined/>}
|
||||
loading={dashboardCreating}
|
||||
icon={<PlusOutlined />}
|
||||
disabled={disabled}
|
||||
type="primary"
|
||||
onClick={() => setShowModal(true)}
|
||||
onClick={createNewDashboard}
|
||||
>
|
||||
Create Dashboard
|
||||
</Button>
|
||||
<NewDashboardModal onClose={() => setShowModal(false)} open={showModal}/>
|
||||
</>;
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default CreateDashboardButton;
|
||||
|
|
|
|||
|
|
@ -1,40 +1,31 @@
|
|||
import React from 'react';
|
||||
//import {Breadcrumb} from 'Shared/Breadcrumb';
|
||||
import BackButton from '../../../shared/Breadcrumb/BackButton';
|
||||
import BackButton from 'Shared/Breadcrumb/BackButton';
|
||||
import { withSiteId } from 'App/routes';
|
||||
import { withRouter, RouteComponentProps } from 'react-router-dom';
|
||||
import { Button, PageTitle, confirm, Tooltip } from 'UI';
|
||||
import { PageTitle, confirm } from 'UI';
|
||||
import { Tooltip } from 'antd';
|
||||
import SelectDateRange from 'Shared/SelectDateRange';
|
||||
import { useStore } from 'App/mstore';
|
||||
import { useModal } from 'App/components/Modal';
|
||||
import DashboardOptions from '../DashboardOptions';
|
||||
import withModal from 'App/components/Modal/withModal';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import DashboardEditModal from '../DashboardEditModal';
|
||||
import CreateDashboardButton from 'Components/Dashboard/components/CreateDashboardButton';
|
||||
import CreateCard from 'Components/Dashboard/components/DashboardList/NewDashModal/CreateCard';
|
||||
import CreateCardButton from 'Components/Dashboard/components/CreateCardButton';
|
||||
|
||||
interface IProps {
|
||||
dashboardId: string;
|
||||
siteId: string;
|
||||
renderReport?: any;
|
||||
}
|
||||
|
||||
|
||||
type Props = IProps & RouteComponentProps;
|
||||
const MAX_CARDS = 29;
|
||||
|
||||
function DashboardHeader(props: Props) {
|
||||
const { siteId, dashboardId } = props;
|
||||
const { siteId } = props;
|
||||
const { dashboardStore } = useStore();
|
||||
const { showModal } = useModal();
|
||||
const [focusTitle, setFocusedInput] = React.useState(true);
|
||||
const [showEditModal, setShowEditModal] = React.useState(false);
|
||||
const period = dashboardStore.period;
|
||||
|
||||
const dashboard: any = dashboardStore.selectedDashboard;
|
||||
const canAddMore: boolean = dashboard?.widgets?.length <= MAX_CARDS;
|
||||
|
||||
const onEdit = (isTitle: boolean) => {
|
||||
dashboardStore.initDashboard(dashboard);
|
||||
|
|
@ -47,7 +38,7 @@ function DashboardHeader(props: Props) {
|
|||
await confirm({
|
||||
header: 'Delete Dashboard',
|
||||
confirmButton: 'Yes, delete',
|
||||
confirmation: `Are you sure you want to permanently delete this Dashboard?`
|
||||
confirmation: `Are you sure you want to permanently delete this Dashboard?`,
|
||||
})
|
||||
) {
|
||||
dashboardStore.deleteDashboard(dashboard).then(() => {
|
||||
|
|
@ -56,32 +47,26 @@ function DashboardHeader(props: Props) {
|
|||
}
|
||||
};
|
||||
return (
|
||||
<div>
|
||||
<>
|
||||
<DashboardEditModal
|
||||
show={showEditModal}
|
||||
closeHandler={() => setShowEditModal(false)}
|
||||
focusTitle={focusTitle}
|
||||
/>
|
||||
|
||||
<div className="flex items-center mb-2 justify-between">
|
||||
<div className="flex items-center" style={{ flex: 3 }}>
|
||||
|
||||
<BackButton siteId={siteId} />
|
||||
|
||||
{/* <Breadcrumb
|
||||
items={[
|
||||
{
|
||||
label: 'Back',
|
||||
to: withSiteId('/dashboard', siteId),
|
||||
},
|
||||
{label: (dashboard && dashboard.name) || ''},
|
||||
]}
|
||||
/> */}
|
||||
<div className="flex items-center justify-between px-4 pt-4 bg-white">
|
||||
<div className="flex items-center gap-2" style={{ flex: 3 }}>
|
||||
<BackButton siteId={siteId} compact />
|
||||
|
||||
<PageTitle
|
||||
title={
|
||||
// @ts-ignore
|
||||
<Tooltip delay={0} title="Double click to edit" placement="bottom">
|
||||
<Tooltip
|
||||
delay={0}
|
||||
title="Double click to edit"
|
||||
placement="bottom"
|
||||
>
|
||||
{dashboard?.name}
|
||||
</Tooltip>
|
||||
}
|
||||
|
|
@ -89,12 +74,9 @@ function DashboardHeader(props: Props) {
|
|||
className="mr-3 select-none border-b border-b-borderColor-transparent hover:border-dashed hover:border-gray-medium cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2" style={{ flex: 1, justifyContent: 'end' }}>
|
||||
<CreateCardButton disabled={canAddMore} />
|
||||
|
||||
<div
|
||||
className="flex items-center flex-shrink-0 justify-end dashboardDataPeriodSelector"
|
||||
style={{ width: 'fit-content' }}
|
||||
className="flex items-center gap-2"
|
||||
style={{ flex: 1, justifyContent: 'end' }}
|
||||
>
|
||||
<SelectDateRange
|
||||
style={{ width: '300px' }}
|
||||
|
|
@ -104,9 +86,7 @@ function DashboardHeader(props: Props) {
|
|||
isAnt={true}
|
||||
useButtonStyle={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center flex-shrink-0">
|
||||
<DashboardOptions
|
||||
editHandler={onEdit}
|
||||
deleteHandler={onDelete}
|
||||
|
|
@ -115,19 +95,7 @@ function DashboardHeader(props: Props) {
|
|||
/>
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import { LockOutlined, TeamOutlined } from '@ant-design/icons';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import React from 'react';
|
||||
import { useHistory } from 'react-router';
|
||||
import {
|
||||
Empty,
|
||||
Switch,
|
||||
|
|
@ -7,18 +9,16 @@ import {
|
|||
Tag,
|
||||
Tooltip,
|
||||
Typography,
|
||||
Dropdown,
|
||||
Button,
|
||||
} from 'antd';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import React from 'react';
|
||||
import { useHistory } from 'react-router';
|
||||
|
||||
import { LockOutlined, TeamOutlined, MoreOutlined } from '@ant-design/icons';
|
||||
import { checkForRecent } from 'App/date';
|
||||
import { useStore } from 'App/mstore';
|
||||
import Dashboard from 'App/mstore/types/dashboard';
|
||||
import { dashboardSelected, withSiteId } from 'App/routes';
|
||||
import CreateDashboardButton from 'Components/Dashboard/components/CreateDashboardButton';
|
||||
import { ItemMenu, confirm } from 'UI';
|
||||
|
||||
import { Icon, confirm } from 'UI';
|
||||
import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG';
|
||||
|
||||
import DashboardEditModal from '../DashboardEditModal';
|
||||
|
|
@ -26,6 +26,7 @@ import DashboardEditModal from '../DashboardEditModal';
|
|||
function DashboardList() {
|
||||
const { dashboardStore, projectsStore } = useStore();
|
||||
const siteId = projectsStore.siteId;
|
||||
const optionsRef = React.useRef<HTMLDivElement>(null);
|
||||
const [focusTitle, setFocusedInput] = React.useState(true);
|
||||
const [showEditModal, setShowEditModal] = React.useState(false);
|
||||
|
||||
|
|
@ -103,6 +104,7 @@ function DashboardList() {
|
|||
}
|
||||
checkedChildren={'Team'}
|
||||
unCheckedChildren={'Private'}
|
||||
className="toggle-team-private"
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
|
@ -121,23 +123,52 @@ function DashboardList() {
|
|||
},
|
||||
|
||||
{
|
||||
title: 'Options',
|
||||
title: '',
|
||||
dataIndex: 'dashboardId',
|
||||
width: '5%',
|
||||
onCell: () => ({ onClick: (e) => e.stopPropagation() }),
|
||||
render: (id) => (
|
||||
<ItemMenu
|
||||
bold
|
||||
items={[
|
||||
{ icon: 'pencil', text: 'Rename', onClick: () => onEdit(id, true) },
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<Dropdown
|
||||
arrow={false}
|
||||
trigger={['click']}
|
||||
className={'ignore-prop-dp'}
|
||||
menu={{
|
||||
items: [
|
||||
{
|
||||
icon: 'users',
|
||||
text: 'Visibility & Access',
|
||||
onClick: () => onEdit(id, false),
|
||||
icon: <Icon name={'pencil'} />,
|
||||
key: 'rename',
|
||||
label: 'Rename',
|
||||
},
|
||||
{ icon: 'trash', text: 'Delete', onClick: () => onDelete(id) },
|
||||
]}
|
||||
{
|
||||
icon: <Icon name={'users'} />,
|
||||
key: 'access',
|
||||
label: 'Visibility & Access',
|
||||
},
|
||||
{
|
||||
icon: <Icon name={'trash'} />,
|
||||
key: 'delete',
|
||||
label: 'Delete',
|
||||
},
|
||||
],
|
||||
onClick: async ({ key }) => {
|
||||
if (key === 'rename') {
|
||||
onEdit(id, true);
|
||||
} else if (key === 'access') {
|
||||
onEdit(id, false);
|
||||
} else if (key === 'delete') {
|
||||
await onDelete(id);
|
||||
}
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
id={'ignore-prop'}
|
||||
icon={<MoreOutlined />}
|
||||
type="text"
|
||||
className="btn-dashboards-list-item-more-options"
|
||||
/>
|
||||
</Dropdown>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
|
@ -198,9 +229,22 @@ function DashboardList() {
|
|||
showTotal: (total, range) =>
|
||||
`Showing ${range[0]}-${range[1]} of ${total} items`,
|
||||
size: 'small',
|
||||
simple: 'true',
|
||||
className: 'px-4 pr-8 mb-0',
|
||||
}}
|
||||
onRow={(record) => ({
|
||||
onClick: () => {
|
||||
onClick: (e) => {
|
||||
const possibleDropdown =
|
||||
document.querySelector('.ant-dropdown-menu');
|
||||
const btn = document.querySelector('#ignore-prop');
|
||||
if (
|
||||
e.target.classList.contains('lucide') ||
|
||||
e.target.id === 'ignore-prop' ||
|
||||
possibleDropdown?.contains(e.target) ||
|
||||
btn?.contains(e.target)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
dashboardStore.selectDashboardById(record.dashboardId);
|
||||
const path = withSiteId(
|
||||
dashboardSelected(record.dashboardId),
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ function DashboardSearch() {
|
|||
value={query}
|
||||
allowClear
|
||||
name="dashboardsSearch"
|
||||
className="w-full"
|
||||
className="w-full btn-search-dashboard"
|
||||
placeholder="Filter by dashboard title"
|
||||
onChange={write}
|
||||
onSearch={(value) => dashboardStore.updateKey('filter', { ...dashboardStore.filter, query: value })}
|
||||
|
|
|
|||
|
|
@ -9,24 +9,13 @@ import {
|
|||
HEATMAP,
|
||||
ERRORS,
|
||||
FUNNEL,
|
||||
INSIGHTS,
|
||||
TABLE,
|
||||
TIMESERIES,
|
||||
USER_PATH,
|
||||
PERFORMANCE,
|
||||
} from 'App/constants/card';
|
||||
} from "App/constants/card";
|
||||
import { FilterKey } from 'Types/filter/filterType';
|
||||
import { BarChart, TrendingUp, SearchSlash } from 'lucide-react';
|
||||
import ByIssues from 'Components/Dashboard/components/DashboardList/NewDashModal/Examples/SessionsBy/ByIssues';
|
||||
import InsightsExample from 'Components/Dashboard/components/DashboardList/NewDashModal/Examples/InsightsExample';
|
||||
import ByUser from 'Components/Dashboard/components/DashboardList/NewDashModal/Examples/SessionsBy/ByUser';
|
||||
import BarChartCard from 'Components/Dashboard/components/DashboardList/NewDashModal/Examples/BarChart';
|
||||
import SpeedIndexByLocationExample
|
||||
from 'Components/Dashboard/components/DashboardList/NewDashModal/Examples/SpeedIndexByLocationExample';
|
||||
import CallsWithErrorsExample
|
||||
from 'Components/Dashboard/components/DashboardList/NewDashModal/Examples/CallsWithErrorsExample';
|
||||
import SlowestDomains
|
||||
from 'Components/Dashboard/components/DashboardList/NewDashModal/Examples/SessionsBy/SlowestDomains';
|
||||
import HeatmapsExample from 'Components/Dashboard/components/DashboardList/NewDashModal/Examples/HeatmapsExample';
|
||||
import ByReferrer from 'Components/Dashboard/components/DashboardList/NewDashModal/Examples/SessionsBy/ByRferrer';
|
||||
import ByFetch from 'Components/Dashboard/components/DashboardList/NewDashModal/Examples/SessionsBy/ByFecth';
|
||||
|
|
@ -60,7 +49,7 @@ export interface CardType {
|
|||
|
||||
export const CARD_LIST: CardType[] = [
|
||||
{
|
||||
title: 'Funnel',
|
||||
title: 'Untitled Funnel',
|
||||
key: FUNNEL,
|
||||
cardType: FUNNEL,
|
||||
category: CARD_CATEGORIES[0].key,
|
||||
|
|
@ -92,7 +81,7 @@ export const CARD_LIST: CardType[] = [
|
|||
}
|
||||
},
|
||||
{
|
||||
title: 'Heatmaps',
|
||||
title: 'Untitled Heatmaps',
|
||||
key: HEATMAP,
|
||||
cardType: HEATMAP,
|
||||
metricOf: 'heatMapUrl',
|
||||
|
|
@ -100,14 +89,14 @@ export const CARD_LIST: CardType[] = [
|
|||
example: HeatmapsExample
|
||||
},
|
||||
{
|
||||
title: 'Path Finder',
|
||||
title: 'Untitled Journey',
|
||||
key: USER_PATH,
|
||||
cardType: USER_PATH,
|
||||
category: CARD_CATEGORIES[0].key,
|
||||
example: ExamplePath
|
||||
},
|
||||
{
|
||||
title: 'Sessions Trend',
|
||||
title: 'Untitled Trend',
|
||||
key: TIMESERIES,
|
||||
cardType: TIMESERIES,
|
||||
metricOf: 'sessionCount',
|
||||
|
|
@ -122,7 +111,7 @@ export const CARD_LIST: CardType[] = [
|
|||
example: ExampleTrend
|
||||
},
|
||||
{
|
||||
title: 'Users Trend',
|
||||
title: 'Untitled Users Trend',
|
||||
key: TIMESERIES + '_userCount',
|
||||
cardType: TIMESERIES,
|
||||
metricOf: 'userCount',
|
||||
|
|
@ -140,7 +129,7 @@ export const CARD_LIST: CardType[] = [
|
|||
|
||||
// Web analytics
|
||||
{
|
||||
title: 'Top Users',
|
||||
title: 'Untitled Top Users',
|
||||
key: FilterKey.USERID,
|
||||
cardType: TABLE,
|
||||
metricOf: FilterKey.USERID,
|
||||
|
|
@ -149,7 +138,7 @@ export const CARD_LIST: CardType[] = [
|
|||
},
|
||||
|
||||
{
|
||||
title: 'Top Browsers',
|
||||
title: 'Untitled Top Browsers',
|
||||
key: FilterKey.USER_BROWSER,
|
||||
cardType: TABLE,
|
||||
metricOf: FilterKey.USER_BROWSER,
|
||||
|
|
@ -165,7 +154,7 @@ export const CARD_LIST: CardType[] = [
|
|||
// example: BySystem,
|
||||
// },
|
||||
{
|
||||
title: 'Top Countries',
|
||||
title: 'Untitled Top Countries',
|
||||
key: FilterKey.USER_COUNTRY,
|
||||
cardType: TABLE,
|
||||
metricOf: FilterKey.USER_COUNTRY,
|
||||
|
|
@ -174,7 +163,7 @@ export const CARD_LIST: CardType[] = [
|
|||
},
|
||||
|
||||
{
|
||||
title: 'Top Devices',
|
||||
title: 'Untitled Top Devices',
|
||||
key: FilterKey.USER_DEVICE,
|
||||
cardType: TABLE,
|
||||
metricOf: FilterKey.USER_DEVICE,
|
||||
|
|
@ -182,7 +171,7 @@ export const CARD_LIST: CardType[] = [
|
|||
example: BySystem
|
||||
},
|
||||
{
|
||||
title: 'Top Pages',
|
||||
title: 'Untitled Top Pages',
|
||||
key: FilterKey.LOCATION,
|
||||
cardType: TABLE,
|
||||
metricOf: FilterKey.LOCATION,
|
||||
|
|
@ -191,7 +180,7 @@ export const CARD_LIST: CardType[] = [
|
|||
},
|
||||
|
||||
{
|
||||
title: 'Top Referrer',
|
||||
title: 'Untitled Top Referrer',
|
||||
key: FilterKey.REFERRER,
|
||||
cardType: TABLE,
|
||||
metricOf: FilterKey.REFERRER,
|
||||
|
|
@ -201,7 +190,7 @@ export const CARD_LIST: CardType[] = [
|
|||
|
||||
// Monitors
|
||||
{
|
||||
title: 'Table of Errors',
|
||||
title: 'Untitled Table of Errors',
|
||||
key: FilterKey.ERRORS,
|
||||
cardType: TABLE,
|
||||
metricOf: FilterKey.ERRORS,
|
||||
|
|
@ -216,7 +205,7 @@ export const CARD_LIST: CardType[] = [
|
|||
example: TableOfErrors
|
||||
},
|
||||
{
|
||||
title: 'Top Network Requests',
|
||||
title: 'Untitled Top Network Requests',
|
||||
key: FilterKey.FETCH,
|
||||
cardType: TABLE,
|
||||
metricOf: FilterKey.FETCH,
|
||||
|
|
@ -224,7 +213,7 @@ export const CARD_LIST: CardType[] = [
|
|||
example: ByFetch
|
||||
},
|
||||
{
|
||||
title: 'Sessions with 4xx/5xx Requests',
|
||||
title: 'Untitled Sessions with 4xx/5xx Requests',
|
||||
key: TIMESERIES + '_4xx_requests',
|
||||
cardType: TIMESERIES,
|
||||
metricOf: 'sessionCount',
|
||||
|
|
@ -258,7 +247,7 @@ export const CARD_LIST: CardType[] = [
|
|||
example: ExampleTrend
|
||||
},
|
||||
{
|
||||
title: 'Sessions with Slow Network Requests',
|
||||
title: 'Untitled Sessions with Slow Network Requests',
|
||||
key: TIMESERIES + '_slow_network_requests',
|
||||
cardType: TIMESERIES,
|
||||
metricOf: 'sessionCount',
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ function AreaChartCard(props: Props) {
|
|||
margin={Styles.chartMargins}
|
||||
>
|
||||
{gradientDef}
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#EEEEEE"/>
|
||||
<CartesianGrid strokeDasharray="1 3" vertical={false} stroke="rgba(0,0,0,1.5)"/>
|
||||
<XAxis {...Styles.xaxis} dataKey="time" interval={3}/>
|
||||
<YAxis
|
||||
{...Styles.yaxis}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
import React from 'react';
|
||||
|
||||
import ExCard from './ExCard';
|
||||
import AreaChartCard from "Components/Dashboard/components/DashboardList/NewDashModal/Examples/AreaChartCard";
|
||||
import CustomMetricLineChart from "Components/Dashboard/Widgets/CustomMetricsWidgets/CustomMetricLineChart";
|
||||
import LineChart from 'App/components/Charts/LineChart'
|
||||
import {Styles} from "Components/Dashboard/Widgets/common";
|
||||
|
||||
interface Props {
|
||||
|
|
@ -24,7 +23,7 @@ function ExampleTrend(props: Props) {
|
|||
}
|
||||
>
|
||||
{/*<AreaChartCard data={props.data} label={props.data?.label}/>*/}
|
||||
<CustomMetricLineChart
|
||||
<LineChart
|
||||
data={props.data}
|
||||
colors={Styles.compareColors}
|
||||
params={{
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import React from 'react';
|
||||
import { ItemMenu } from 'UI';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { useStore } from "App/mstore";
|
||||
import { useStore } from 'App/mstore';
|
||||
import { ENTERPRISE_REQUEIRED } from 'App/constants';
|
||||
import { Dropdown, Button } from 'antd';
|
||||
import { EllipsisVertical } from 'lucide-react';
|
||||
import { Icon } from 'UI';
|
||||
|
||||
interface Props {
|
||||
editHandler: (isTitle: boolean) => void;
|
||||
|
|
@ -13,18 +15,42 @@ function DashboardOptions(props: Props) {
|
|||
const { userStore } = useStore();
|
||||
const isEnterprise = userStore.isEnterprise;
|
||||
const { editHandler, deleteHandler, renderReport } = props;
|
||||
const menuItems = [
|
||||
{ icon: 'pencil', text: 'Rename', onClick: () => editHandler(true) },
|
||||
{ icon: 'users', text: 'Visibility & Access', onClick: editHandler },
|
||||
{ icon: 'trash', text: 'Delete', onClick: deleteHandler },
|
||||
{ icon: 'pdf-download', text: 'Download Report', onClick: renderReport, disabled: !isEnterprise, tooltipTitle: ENTERPRISE_REQUEIRED }
|
||||
]
|
||||
|
||||
const menu = {
|
||||
items: [
|
||||
{
|
||||
icon: <Icon name={'pencil'} />,
|
||||
key: 'rename',
|
||||
label: 'Rename',
|
||||
onClick: () => editHandler(true),
|
||||
},
|
||||
{
|
||||
icon: <Icon name={'users'} />,
|
||||
key: 'visibility',
|
||||
label: 'Visibility & Access',
|
||||
onClick: editHandler,
|
||||
},
|
||||
{
|
||||
icon: <Icon name={'trash'} />,
|
||||
key: 'delete',
|
||||
label: 'Delete',
|
||||
onClick: deleteHandler,
|
||||
},
|
||||
{
|
||||
icon: <Icon name={'pdf-download'} />,
|
||||
key: 'download',
|
||||
label: 'Download Report',
|
||||
onClick: renderReport,
|
||||
disabled: !isEnterprise,
|
||||
tooltipTitle: ENTERPRISE_REQUEIRED,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return (
|
||||
<ItemMenu
|
||||
bold
|
||||
items={menuItems}
|
||||
/>
|
||||
<Dropdown menu={menu}>
|
||||
<Button id={'ignore-prop'} icon={<EllipsisVertical size={16} />} />
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { observer } from 'mobx-react-lite';
|
|||
import React from 'react';
|
||||
import colors from 'tailwindcss/colors';
|
||||
|
||||
import { gradientBox } from 'App/components/shared/SessionSearchField/AiSessionSearchField';
|
||||
import { gradientBox } from 'App/components/shared/SessionFilters/AiSessionSearchField';
|
||||
import aiSpinner from 'App/lottie/aiSpinner.json';
|
||||
import { useStore } from 'App/mstore';
|
||||
import { Icon, Input } from 'UI';
|
||||
|
|
@ -41,7 +41,7 @@ const InputBox = observer(({ inModal }: { inModal?: boolean }) => {
|
|||
<>
|
||||
{!inModal ? <div className={"flex items-center mb-2 gap-2"}>
|
||||
<Icon name={"sparkles"} size={16} />
|
||||
<div className={"font-semibold"}>What would you like to visualize?</div>
|
||||
<div className={"font-medium"}>What would you like to visualize?</div>
|
||||
</div> : null}
|
||||
<div style={gradientBox}>
|
||||
<Input
|
||||
|
|
@ -114,7 +114,7 @@ function Loader() {
|
|||
return (
|
||||
<div
|
||||
className={
|
||||
'flex items-center justify-center flex-col font-semibold text-xl min-h-80'
|
||||
'flex items-center justify-center flex-col font-medium text-xl min-h-80'
|
||||
}
|
||||
>
|
||||
<div style={{ width: 150, height: 150 }}>
|
||||
|
|
|
|||
|
|
@ -98,7 +98,7 @@ function DashboardView(props: Props) {
|
|||
const isSaas = /app\.openreplay\.com/.test(originStr);
|
||||
return (
|
||||
<Loader loading={loading}>
|
||||
<div style={{maxWidth: '1360px', margin: 'auto'}}>
|
||||
<div style={{maxWidth: '1360px', margin: 'auto'}} className={'rounded-lg shadow-sm overflow-hidden bg-white border'}>
|
||||
{/* @ts-ignore */}
|
||||
<DashboardHeader renderReport={props.renderReport} siteId={siteId} dashboardId={dashboardId}/>
|
||||
{isSaas ? <AiQuery /> : null}
|
||||
|
|
|
|||
|
|
@ -96,9 +96,9 @@ function AddMetric({ history, siteId, title, description }: IProps) {
|
|||
<div className="py-4 border-t px-8 bg-white w-full flex items-center justify-between">
|
||||
<div>
|
||||
{'Selected '}
|
||||
<span className="font-semibold">{selectedWidgetIds.length}</span>
|
||||
<span className="font-medium">{selectedWidgetIds.length}</span>
|
||||
{' out of '}
|
||||
<span className="font-semibold">{metrics ? metrics.length : 0}</span>
|
||||
<span className="font-medium">{metrics ? metrics.length : 0}</span>
|
||||
</div>
|
||||
<Button variant="primary" disabled={selectedWidgetIds.length === 0} onClick={onSave}>
|
||||
Add Selected
|
||||
|
|
|
|||
|
|
@ -141,9 +141,9 @@ function AddPredefinedMetric({ history, siteId, title, description }: IProps) {
|
|||
<div className="py-4 border-t px-8 bg-white w-full flex items-center justify-between">
|
||||
<div>
|
||||
{'Selected '}
|
||||
<span className="font-semibold">{selectedWidgetIds.length}</span>
|
||||
<span className="font-medium">{selectedWidgetIds.length}</span>
|
||||
{' out of '}
|
||||
<span className="font-semibold">{totalMetricCount}</span>
|
||||
<span className="font-medium">{totalMetricCount}</span>
|
||||
</div>
|
||||
<Button variant="primary" disabled={selectedWidgetIds.length === 0} onClick={onSave}>
|
||||
Add Selected
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
import React from 'react';
|
||||
import { useStore } from 'App/mstore';
|
||||
import WidgetWrapperNew from 'Components/Dashboard/components/WidgetWrapper/WidgetWrapperNew';
|
||||
import { Empty } from 'antd';
|
||||
import { NoContent, Loader } from 'UI';
|
||||
import { useObserver } from 'mobx-react-lite';
|
||||
import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import AddCardSection from '../AddCardSection/AddCardSection';
|
||||
import cn from 'classnames';
|
||||
import { Button, Popover, Tooltip } from 'antd'
|
||||
import { PlusOutlined } from '@ant-design/icons'
|
||||
import { Loader } from 'UI';
|
||||
|
||||
interface Props {
|
||||
siteId: string;
|
||||
|
|
@ -16,37 +18,51 @@ interface Props {
|
|||
function DashboardWidgetGrid(props: Props) {
|
||||
const { dashboardId, siteId } = props;
|
||||
const { dashboardStore } = useStore();
|
||||
const loading = useObserver(() => dashboardStore.isLoading);
|
||||
const loading = dashboardStore.isLoading;
|
||||
const dashboard = dashboardStore.selectedDashboard;
|
||||
const list = useObserver(() => dashboard?.widgets);
|
||||
const list = dashboard?.widgets;
|
||||
|
||||
return useObserver(() => (
|
||||
return (
|
||||
<Loader loading={loading}>
|
||||
{
|
||||
list?.length === 0 ? (
|
||||
<div className="bg-white rounded-lg shadow-sm p-5">
|
||||
<NoContent
|
||||
show={true}
|
||||
icon="no-metrics-chart"
|
||||
title={
|
||||
<div className="text-center">
|
||||
<div className='mb-4'>
|
||||
<AnimatedSVG name={ICONS.NO_RESULTS} size={60} />
|
||||
</div>
|
||||
<div className="text-xl font-medium mb-2">
|
||||
There are no cards in this dashboard
|
||||
</div>
|
||||
<div className="text-base font-normal">
|
||||
Create a card by clicking the "Add Card" button to visualize insights here.
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
{list?.length === 0 ? (
|
||||
<div
|
||||
className={'flex-1 flex justify-center items-center pt-10'}
|
||||
style={{ minHeight: 620 }}
|
||||
>
|
||||
<AddCardSection />
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 grid-cols-4 items-start pb-10" id={props.id}>
|
||||
<div
|
||||
className="pb-10 px-4 pt-2 grid gap-2 rounded grid-cols-4 items-start "
|
||||
id={props.id}
|
||||
>
|
||||
{list?.map((item: any, index: any) => (
|
||||
<React.Fragment key={item.widgetId}>
|
||||
<GridItem
|
||||
key={item.widgetId}
|
||||
item={item}
|
||||
index={index}
|
||||
dashboard={dashboard}
|
||||
dashboardId={dashboardId}
|
||||
siteId={siteId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Loader>
|
||||
);
|
||||
}
|
||||
|
||||
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}
|
||||
|
|
@ -59,13 +75,28 @@ function DashboardWidgetGrid(props: Props) {
|
|||
showMenu={true}
|
||||
isSaved={true}
|
||||
/>
|
||||
</React.Fragment>
|
||||
))}
|
||||
<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>
|
||||
)
|
||||
}
|
||||
</Loader>
|
||||
));
|
||||
}
|
||||
|
||||
export default DashboardWidgetGrid;
|
||||
|
||||
export default observer(DashboardWidgetGrid);
|
||||
|
|
|
|||
|
|
@ -28,9 +28,9 @@ function ExcludeFilters(props: Props) {
|
|||
};
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-center border-b", { 'p-5' : hasExcludes, 'px-2': !hasExcludes })}>
|
||||
<div className={cn("flex items-center mb-2")}>
|
||||
{filter.excludes.length > 0 ? (
|
||||
<div className="flex items-center mb-2 flex-col">
|
||||
<div className="flex items-center mb-2 bg-white rounded-xl flex-col px-4 py-2 w-full">
|
||||
<div className="text-sm color-gray-medium mr-auto mb-2">EXCLUDES</div>
|
||||
{filter.excludes.map((f: any, index: number) => (
|
||||
<FilterItem
|
||||
|
|
|
|||
|
|
@ -1,69 +1,112 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import FilterList from 'Shared/Filters/FilterList';
|
||||
import { EventsList, FilterList } from 'Shared/Filters/FilterList';
|
||||
import SeriesName from './SeriesName';
|
||||
import cn from 'classnames';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import ExcludeFilters from './ExcludeFilters';
|
||||
import AddStepButton from "Components/Dashboard/components/FilterSeries/AddStepButton";
|
||||
import {Button, Space} from "antd";
|
||||
import {ChevronDown, ChevronUp, Trash} from "lucide-react";
|
||||
import { Button, Space } from 'antd';
|
||||
import { ChevronDown, ChevronUp, Trash } from 'lucide-react';
|
||||
|
||||
|
||||
const FilterCountLabels = observer((props: { filters: any, toggleExpand: any }) => {
|
||||
const FilterCountLabels = observer(
|
||||
(props: { filters: any; toggleExpand: any }) => {
|
||||
const events = props.filters.filter((i: any) => i && i.isEvent).length;
|
||||
const filters = props.filters.filter((i: any) => i && !i.isEvent).length;
|
||||
return <div className="flex items-center">
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<Space>
|
||||
{events > 0 && (
|
||||
<Button type="primary" ghost size="small" onClick={props.toggleExpand}>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
onClick={props.toggleExpand}
|
||||
className='btn-series-event-count'
|
||||
>
|
||||
{`${events} Event${events > 1 ? 's' : ''}`}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{filters > 0 && (
|
||||
<Button type="primary" ghost size="small" onClick={props.toggleExpand}>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
onClick={props.toggleExpand}
|
||||
className='btn-series-filter-count'
|
||||
>
|
||||
{`${filters} Filter${filters > 1 ? 's' : ''}`}
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
</div>;
|
||||
});
|
||||
|
||||
const FilterSeriesHeader = observer((props: {
|
||||
expanded: boolean,
|
||||
hidden: boolean,
|
||||
seriesIndex: number,
|
||||
series: any,
|
||||
onRemove: (seriesIndex: any) => void,
|
||||
canDelete: boolean | undefined,
|
||||
toggleExpand: () => void
|
||||
}) => {
|
||||
|
||||
const onUpdate = (name: any) => {
|
||||
props.series.update('name', name)
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <div className={cn("border-b px-5 h-12 flex items-center relative", {hidden: props.hidden})}>
|
||||
);
|
||||
|
||||
const FilterSeriesHeader = observer(
|
||||
(props: {
|
||||
expanded: boolean;
|
||||
hidden: boolean;
|
||||
seriesIndex: number;
|
||||
series: any;
|
||||
onRemove: (seriesIndex: any) => void;
|
||||
canDelete: boolean | undefined;
|
||||
toggleExpand: () => void;
|
||||
onChange: () => void;
|
||||
}) => {
|
||||
const onUpdate = (name: any) => {
|
||||
props.series.update('name', name);
|
||||
props.onChange();
|
||||
};
|
||||
return (
|
||||
<div
|
||||
className={cn('px-4 ps-2 h-12 flex items-center relative bg-white border-gray-lighter border-t border-l border-r rounded-t-xl', {
|
||||
hidden: props.hidden,
|
||||
'rounded-b-xl': !props.expanded,
|
||||
})}
|
||||
>
|
||||
<Space className="mr-auto" size={30}>
|
||||
<SeriesName
|
||||
seriesIndex={props.seriesIndex}
|
||||
name={props.series.name}
|
||||
onUpdate={onUpdate}
|
||||
onChange={() => null}
|
||||
/>
|
||||
{!props.expanded &&
|
||||
<FilterCountLabels filters={props.series.filter.filters} toggleExpand={props.toggleExpand}/>}
|
||||
</Space>
|
||||
|
||||
<Space>
|
||||
<Button onClick={props.onRemove}
|
||||
{!props.expanded && (
|
||||
<FilterCountLabels
|
||||
filters={props.series.filter.filters}
|
||||
toggleExpand={props.toggleExpand}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
onClick={props.onRemove}
|
||||
size="small"
|
||||
disabled={!props.canDelete}
|
||||
icon={<Trash size={14}/>}/>
|
||||
<Button onClick={props.toggleExpand}
|
||||
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}/>}/>
|
||||
icon={
|
||||
props.expanded ? (
|
||||
<ChevronUp size={16} />
|
||||
) : (
|
||||
<ChevronDown size={16} />
|
||||
)
|
||||
}
|
||||
type='text'
|
||||
className='btn-toggle-series'
|
||||
/>
|
||||
</Space>
|
||||
</div>;
|
||||
})
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
interface Props {
|
||||
seriesIndex: number;
|
||||
|
|
@ -75,32 +118,34 @@ interface Props {
|
|||
emptyMessage?: any;
|
||||
observeChanges?: () => void;
|
||||
excludeFilterKeys?: Array<string>;
|
||||
excludeCategory?: string[]
|
||||
canExclude?: boolean;
|
||||
expandable?: boolean;
|
||||
isHeatmap?: boolean;
|
||||
removeEvents?: boolean;
|
||||
collapseState: boolean;
|
||||
onToggleCollapse: () => void;
|
||||
}
|
||||
|
||||
function FilterSeries(props: Props) {
|
||||
const {
|
||||
observeChanges = () => {
|
||||
},
|
||||
observeChanges = () => {},
|
||||
canDelete,
|
||||
hideHeader = false,
|
||||
emptyMessage = 'Add an event or filter step to define the series.',
|
||||
supportsEmpty = true,
|
||||
excludeFilterKeys = [],
|
||||
canExclude = false,
|
||||
expandable = false
|
||||
expandable = false,
|
||||
isHeatmap,
|
||||
removeEvents,
|
||||
collapseState,
|
||||
onToggleCollapse,
|
||||
excludeCategory
|
||||
} = props;
|
||||
const [expanded, setExpanded] = useState(!expandable);
|
||||
const {series, seriesIndex} = props;
|
||||
const [prevLength, setPrevLength] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (series.filter.filters.length === 1 && prevLength === 0 && seriesIndex === 0) {
|
||||
setExpanded(true);
|
||||
}
|
||||
setPrevLength(series.filter.filters.length);
|
||||
}, [series.filter.filters.length]);
|
||||
const expanded = !collapseState
|
||||
const setExpanded = onToggleCollapse
|
||||
const { series, seriesIndex } = props;
|
||||
|
||||
const onUpdateFilter = (filterIndex: any, filter: any) => {
|
||||
series.filter.updateFilter(filterIndex, filter);
|
||||
|
|
@ -108,11 +153,11 @@ function FilterSeries(props: Props) {
|
|||
};
|
||||
|
||||
const onFilterMove = (newFilters: any) => {
|
||||
series.filter.replaceFilters(newFilters.toArray())
|
||||
series.filter.replaceFilters(newFilters.toArray());
|
||||
observeChanges();
|
||||
}
|
||||
};
|
||||
|
||||
const onChangeEventsOrder = (_: any, {name, value}: any) => {
|
||||
const onChangeEventsOrder = (_: any, { name, value }: any) => {
|
||||
series.filter.updateKey(name, value);
|
||||
observeChanges();
|
||||
};
|
||||
|
|
@ -122,32 +167,68 @@ function FilterSeries(props: Props) {
|
|||
observeChanges();
|
||||
};
|
||||
|
||||
const onAddFilter = (filter: any) => {
|
||||
series.filter.addFilter(filter);
|
||||
observeChanges();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border rounded-lg shadow-sm bg-white">
|
||||
{canExclude && <ExcludeFilters filter={series.filter}/>}
|
||||
<div>
|
||||
{canExclude && <ExcludeFilters filter={series.filter} />}
|
||||
|
||||
{!hideHeader && (
|
||||
<FilterSeriesHeader hidden={hideHeader}
|
||||
<FilterSeriesHeader
|
||||
hidden={hideHeader}
|
||||
seriesIndex={seriesIndex}
|
||||
onChange={observeChanges}
|
||||
series={series}
|
||||
onRemove={props.onRemoveSeries}
|
||||
canDelete={canDelete}
|
||||
expanded={expanded}
|
||||
toggleExpand={() => setExpanded(!expanded)}/>
|
||||
toggleExpand={() => setExpanded(!expanded)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{expandable && (
|
||||
<Space className="justify-between w-full px-5 py-2 cursor-pointer" onClick={() => setExpanded(!expanded)}>
|
||||
<div>{!expanded && <FilterCountLabels filters={series.filter.filters} toggleExpand={() => setExpanded(!expanded)}/>}</div>
|
||||
<Button size="small"
|
||||
icon={expanded ? <ChevronUp size={16}/> : <ChevronDown size={16}/>}/>
|
||||
{!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>
|
||||
)}
|
||||
|
||||
{expanded && (
|
||||
{expanded ? (
|
||||
<>
|
||||
<div className="p-5">
|
||||
{series.filter.filters.length > 0 ? (
|
||||
{removeEvents ? null :
|
||||
<EventsList
|
||||
filter={series.filter}
|
||||
onUpdateFilter={onUpdateFilter}
|
||||
onRemoveFilter={onRemoveFilter}
|
||||
onChangeEventsOrder={onChangeEventsOrder}
|
||||
supportsEmpty={supportsEmpty}
|
||||
onFilterMove={onFilterMove}
|
||||
excludeFilterKeys={excludeFilterKeys}
|
||||
onAddFilter={onAddFilter}
|
||||
mergeUp={!hideHeader}
|
||||
mergeDown
|
||||
cannotAdd={isHeatmap}
|
||||
excludeCategory={excludeCategory}
|
||||
/>
|
||||
}
|
||||
<FilterList
|
||||
filter={series.filter}
|
||||
onUpdateFilter={onUpdateFilter}
|
||||
|
|
@ -156,25 +237,12 @@ function FilterSeries(props: Props) {
|
|||
supportsEmpty={supportsEmpty}
|
||||
onFilterMove={onFilterMove}
|
||||
excludeFilterKeys={excludeFilterKeys}
|
||||
// actions={[
|
||||
// expandable && (
|
||||
// <Button onClick={() => setExpanded(!expanded)}
|
||||
// size="small"
|
||||
// icon={expanded ? <ChevronUp size={16}/> : <ChevronDown size={16}/>}/>
|
||||
// )
|
||||
// ]}
|
||||
onAddFilter={onAddFilter}
|
||||
mergeUp={!removeEvents}
|
||||
excludeCategory={excludeCategory}
|
||||
/>
|
||||
) : (
|
||||
<div className="color-gray-medium">{emptyMessage}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="border-t h-12 flex items-center">
|
||||
<div className="-mx-4 px-5">
|
||||
<AddStepButton excludeFilterKeys={excludeFilterKeys} series={series}/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,61 +1,71 @@
|
|||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { Icon } from 'UI';
|
||||
import {Input, Tooltip} from 'antd';
|
||||
import { Input, Tooltip } from 'antd';
|
||||
|
||||
interface Props {
|
||||
name: string;
|
||||
onUpdate: (name) => void;
|
||||
onUpdate: (name: string) => void;
|
||||
onChange: () => void;
|
||||
seriesIndex?: number;
|
||||
}
|
||||
|
||||
function SeriesName(props: Props) {
|
||||
const { seriesIndex = 1 } = props;
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [name, setName] = useState(props.name)
|
||||
const ref = useRef<any>(null)
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [name, setName] = useState(props.name);
|
||||
const ref = useRef<any>(null);
|
||||
|
||||
const write = ({ target: { value, name } }) => {
|
||||
setName(value)
|
||||
}
|
||||
const write = ({ target: { value } }) => {
|
||||
setName(value);
|
||||
props.onChange();
|
||||
};
|
||||
|
||||
const onBlur = () => {
|
||||
setEditing(false)
|
||||
props.onUpdate(name)
|
||||
setEditing(false);
|
||||
props.onUpdate(name);
|
||||
};
|
||||
|
||||
const onKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
setEditing(false);
|
||||
props.onUpdate(name);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (editing) {
|
||||
ref.current.focus()
|
||||
ref.current.focus();
|
||||
}
|
||||
}, [editing])
|
||||
}, [editing]);
|
||||
|
||||
useEffect(() => {
|
||||
setName(props.name)
|
||||
}, [props.name])
|
||||
setName(props.name);
|
||||
}, [props.name]);
|
||||
|
||||
// const { name } = props;
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
{ editing ? (
|
||||
{editing ? (
|
||||
<Input
|
||||
ref={ ref }
|
||||
ref={ref}
|
||||
name="name"
|
||||
value={name}
|
||||
// readOnly={!editing}
|
||||
onChange={write}
|
||||
onBlur={onBlur}
|
||||
onFocus={() => setEditing(true)}
|
||||
className='bg-white'
|
||||
onKeyDown={onKeyDown}
|
||||
className="bg-white text-lg border-transparent rounded-lg font-medium ps-2 input-rename-series"
|
||||
maxLength={22}
|
||||
size='small'
|
||||
/>
|
||||
) : (
|
||||
<div className="text-base h-8 flex items-center border-transparent">{name && name.trim() === '' ? 'Series ' + (seriesIndex + 1) : name }</div>
|
||||
)}
|
||||
|
||||
|
||||
<div className="ml-3 cursor-pointer " onClick={() => setEditing(true)}>
|
||||
<Tooltip title='Rename' placement='bottom'>
|
||||
<Icon name="pencil" size="14" />
|
||||
</Tooltip>
|
||||
<Tooltip title="Click to rename">
|
||||
<div
|
||||
className="text-lg font-medium h-8 flex items-center border-transparent p-2 hover:bg-teal/10 cursor-pointer rounded-lg btn-input-rename-series"
|
||||
onClick={() => setEditing(true)}
|
||||
data-event='input-rename-series'
|
||||
>
|
||||
{name && name.trim() === '' ? 'Series ' + (seriesIndex + 1) : name}
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,34 +1,54 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import { Icon, Modal } from 'UI';
|
||||
import { Tooltip, Input, Button, Dropdown, Menu, Tag, Modal as AntdModal, Form, Avatar } from 'antd';
|
||||
import { TeamOutlined, LockOutlined, EditOutlined, DeleteOutlined, MoreOutlined } from '@ant-design/icons';
|
||||
import { Icon } from 'UI';
|
||||
import {
|
||||
Tooltip,
|
||||
Input,
|
||||
Button,
|
||||
Dropdown,
|
||||
Tag,
|
||||
Modal as AntdModal,
|
||||
Avatar,
|
||||
} from 'antd';
|
||||
import {
|
||||
TeamOutlined,
|
||||
LockOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { RouteComponentProps } from 'react-router-dom';
|
||||
import { withSiteId } from 'App/routes';
|
||||
import { TYPES } from 'App/constants/card';
|
||||
import { TYPE_ICONS, TYPE_NAMES } from 'App/constants/card';
|
||||
import { useStore } from 'App/mstore';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { toast } from 'react-toastify';
|
||||
import { useHistory } from 'react-router';
|
||||
import { EllipsisVertical } from 'lucide-react';
|
||||
import cn from 'classnames'
|
||||
|
||||
interface Props extends RouteComponentProps {
|
||||
metric: any;
|
||||
siteId: string;
|
||||
selected?: boolean;
|
||||
toggleSelection?: any;
|
||||
disableSelection?: boolean;
|
||||
renderColumn: string;
|
||||
inLibrary?: boolean;
|
||||
}
|
||||
|
||||
function MetricTypeIcon({ type }: any) {
|
||||
const [card, setCard] = useState<any>('');
|
||||
useEffect(() => {
|
||||
const t = TYPES.find((i) => i.slug === type);
|
||||
setCard(t || {});
|
||||
}, [type]);
|
||||
|
||||
return (
|
||||
<Tooltip title={<div className="capitalize">{card.title}</div>}>
|
||||
<Avatar src={card.icon && <Icon name={card.icon} size="16" color="tealx" />} size="small" className="bg-tealx-lightest mr-2" />
|
||||
<Tooltip title={<div className="capitalize">{TYPE_NAMES[type]}</div>}>
|
||||
<Avatar
|
||||
src={
|
||||
<Icon
|
||||
name={TYPE_ICONS[type]}
|
||||
size="16"
|
||||
color="tealx"
|
||||
strokeColor="tealx"
|
||||
/>
|
||||
}
|
||||
size="default"
|
||||
className="bg-tealx-lightest text-tealx mr-2 cursor-default avatar-card-list-item"
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
|
@ -36,11 +56,11 @@ function MetricTypeIcon({ type }: any) {
|
|||
const MetricListItem: React.FC<Props> = ({
|
||||
metric,
|
||||
siteId,
|
||||
toggleSelection = () => {
|
||||
},
|
||||
toggleSelection = () => {},
|
||||
disableSelection = false,
|
||||
renderColumn
|
||||
}) => {
|
||||
renderColumn,
|
||||
inLibrary,
|
||||
}) => {
|
||||
const history = useHistory();
|
||||
const { metricStore } = useStore();
|
||||
const [isEdit, setIsEdit] = useState(false);
|
||||
|
|
@ -67,7 +87,7 @@ const MetricListItem: React.FC<Props> = ({
|
|||
cancelText: 'No',
|
||||
onOk: async () => {
|
||||
await metricStore.delete(metric);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
if (key === 'rename') {
|
||||
|
|
@ -132,29 +152,34 @@ const MetricListItem: React.FC<Props> = ({
|
|||
} else if (diffDays <= 3) {
|
||||
return `${diffDays} days ago at ${formatTime(date)}`;
|
||||
} else {
|
||||
return `${date.getDate()}/${date.getMonth() + 1}/${date.getFullYear()} at ${formatTime(date)}`;
|
||||
return `${date.getDate()}/${
|
||||
date.getMonth() + 1
|
||||
}/${date.getFullYear()} at ${formatTime(date)}`;
|
||||
}
|
||||
};
|
||||
|
||||
const menuItems = [
|
||||
{
|
||||
key: "rename",
|
||||
key: 'rename',
|
||||
icon: <EditOutlined />,
|
||||
label: "Rename"
|
||||
label: 'Rename',
|
||||
},
|
||||
{
|
||||
key: "delete",
|
||||
key: 'delete',
|
||||
icon: <DeleteOutlined />,
|
||||
label: "Delete"
|
||||
}
|
||||
]
|
||||
label: 'Delete',
|
||||
},
|
||||
];
|
||||
switch (renderColumn) {
|
||||
case 'title':
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center cursor-pointer" onClick={onItemClick}>
|
||||
<div
|
||||
className="flex items-center cursor-pointer"
|
||||
onClick={inLibrary ? undefined : onItemClick}
|
||||
>
|
||||
<MetricTypeIcon type={metric.metricType} />
|
||||
<div className="capitalize-first link block">{metric.name}</div>
|
||||
<div className={cn('capitalize-first block', inLibrary ? '' : 'link')}>{metric.name}</div>
|
||||
</div>
|
||||
{renderModal()}
|
||||
</>
|
||||
|
|
@ -165,7 +190,11 @@ const MetricListItem: React.FC<Props> = ({
|
|||
return (
|
||||
<div className="flex items-center">
|
||||
<Tag className="rounded-lg" bordered={false}>
|
||||
{metric.isPublic ? <TeamOutlined className="mr-2" /> : <LockOutlined className="mr-2" />}
|
||||
{metric.isPublic ? (
|
||||
<TeamOutlined className="mr-2" />
|
||||
) : (
|
||||
<LockOutlined className="mr-2" />
|
||||
)}
|
||||
{metric.isPublic ? 'Team' : 'Private'}
|
||||
</Tag>
|
||||
</div>
|
||||
|
|
@ -176,12 +205,17 @@ const MetricListItem: React.FC<Props> = ({
|
|||
case 'options':
|
||||
return (
|
||||
<>
|
||||
<div className='flex justify-end'>
|
||||
<div className="flex justify-end pr-4">
|
||||
<Dropdown
|
||||
menu={{ items: menuItems, onClick: onMenuClick }}
|
||||
trigger={['click']}
|
||||
>
|
||||
<Button type="text" icon={<MoreOutlined />} />
|
||||
<Button
|
||||
id={'ignore-prop'}
|
||||
icon={<EllipsisVertical size={16} />}
|
||||
className="btn-cards-list-item-more-options"
|
||||
type="text"
|
||||
/>
|
||||
</Dropdown>
|
||||
</div>
|
||||
{renderModal()}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -1,142 +1,88 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import { PageTitle, Toggler, Icon } from "UI";
|
||||
import { Segmented, Button } from 'antd';
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
import { PageTitle } from 'UI';
|
||||
import { Button, Popover, Space, Dropdown, Menu } from 'antd';
|
||||
import { PlusOutlined, DownOutlined } from '@ant-design/icons';
|
||||
import AddCardSection from '../AddCardSection/AddCardSection';
|
||||
import MetricsSearch from '../MetricsSearch';
|
||||
import Select from 'Shared/Select';
|
||||
import { useStore } from 'App/mstore';
|
||||
import { observer, useObserver } from 'mobx-react-lite';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { DROPDOWN_OPTIONS } from 'App/constants/card';
|
||||
import AddCardModal from 'Components/Dashboard/components/AddCardModal';
|
||||
import { useModal } from 'Components/Modal';
|
||||
import AddCardSelectionModal from "Components/Dashboard/components/AddCardSelectionModal";
|
||||
import NewDashboardModal from "Components/Dashboard/components/DashboardList/NewDashModal";
|
||||
|
||||
function MetricViewHeader({ siteId }: { siteId: string }) {
|
||||
const options = [
|
||||
{
|
||||
key: 'all',
|
||||
label: 'All Types',
|
||||
},
|
||||
...DROPDOWN_OPTIONS.map((option) => ({
|
||||
key: option.value,
|
||||
label: option.label,
|
||||
})),
|
||||
{
|
||||
key: 'monitors',
|
||||
label: 'Monitors',
|
||||
},
|
||||
{
|
||||
key: 'web_analytics',
|
||||
label: 'Web Analytics',
|
||||
},
|
||||
]
|
||||
|
||||
function MetricViewHeader() {
|
||||
const { metricStore } = useStore();
|
||||
const filter = metricStore.filter;
|
||||
const { showModal } = useModal();
|
||||
const [showAddCardModal, setShowAddCardModal] = React.useState(false);
|
||||
|
||||
// Set the default sort order to 'desc'
|
||||
useEffect(() => {
|
||||
metricStore.updateKey('sort', { by: 'desc' });
|
||||
}, [metricStore]);
|
||||
const handleMenuClick = ({ key }) => {
|
||||
metricStore.updateKey('filter', { ...filter, type: key });
|
||||
};
|
||||
|
||||
const menu = (
|
||||
<Menu onClick={handleMenuClick}>
|
||||
{options.map((option) => (
|
||||
<Menu.Item key={option.key}>{option.label}</Menu.Item>
|
||||
))}
|
||||
</Menu>
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className='flex items-center justify-between px-6'>
|
||||
<div className='flex items-baseline mr-3'>
|
||||
<PageTitle title='Cards' className='' />
|
||||
<div className="flex items-center justify-between pr-4">
|
||||
<div className="flex items-center gap-2 ps-4">
|
||||
<PageTitle title="Cards" className="cursor-default" />
|
||||
<Space>
|
||||
<Dropdown overlay={menu} trigger={['click']} className="">
|
||||
<Button type="text" size="small" className="mt-1">
|
||||
{options.find(opt => opt.key === filter.type)?.label || 'Select Type'}
|
||||
<DownOutlined />
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</Space>
|
||||
</div>
|
||||
<div className='ml-auto flex items-center'>
|
||||
<Button type='primary'
|
||||
onClick={() => setShowAddCardModal(true)}
|
||||
<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 />}
|
||||
>Create Card</Button>
|
||||
<div className='ml-4 w-1/4' style={{ minWidth: 300 }}>
|
||||
className="btn-create-card"
|
||||
>
|
||||
Create Card
|
||||
</Button>
|
||||
</Popover>
|
||||
<Space>
|
||||
<MetricsSearch />
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='border-y px-6 py-1 mt-2 flex items-center w-full justify-between'>
|
||||
<div className='items-center flex gap-4'>
|
||||
<Select
|
||||
options={[{ label: 'All Types', value: 'all' }, ...DROPDOWN_OPTIONS]}
|
||||
name='type'
|
||||
defaultValue={filter.type}
|
||||
onChange={({ value }) =>
|
||||
metricStore.updateKey('filter', { ...filter, type: value.value })
|
||||
}
|
||||
plain={true}
|
||||
isSearchable={true}
|
||||
/>
|
||||
|
||||
<DashboardDropdown
|
||||
plain={false}
|
||||
onChange={(value: any) =>
|
||||
metricStore.updateKey('filter', { ...filter, dashboard: value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center gap-6'>
|
||||
<ListViewToggler />
|
||||
|
||||
{/* <Toggler
|
||||
label='My Cards'
|
||||
checked={filter.showMine}
|
||||
name='test'
|
||||
className='font-medium mr-2'
|
||||
onChange={() =>
|
||||
metricStore.updateKey('filter', { ...filter, showMine: !filter.showMine })
|
||||
}
|
||||
/> */}
|
||||
</div>
|
||||
|
||||
<NewDashboardModal
|
||||
onClose={() => setShowAddCardModal(false)}
|
||||
open={showAddCardModal}
|
||||
isCreatingNewCard={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(MetricViewHeader);
|
||||
|
||||
function DashboardDropdown({ onChange, plain = false }: { plain?: boolean; onChange: any }) {
|
||||
const { dashboardStore, metricStore } = useStore();
|
||||
const dashboardOptions = dashboardStore.dashboards.map((i: any) => ({
|
||||
key: i.id,
|
||||
label: i.name,
|
||||
value: i.dashboardId
|
||||
}));
|
||||
|
||||
return (
|
||||
<Select
|
||||
isSearchable={true}
|
||||
placeholder='Filter by Dashboard'
|
||||
plain={plain}
|
||||
options={dashboardOptions}
|
||||
value={metricStore.filter.dashboard}
|
||||
onChange={({ value }: any) => onChange(value)}
|
||||
isMulti={true}
|
||||
color='black'
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ListViewToggler() {
|
||||
const { metricStore } = useStore();
|
||||
const listView = useObserver(() => metricStore.listView);
|
||||
return (
|
||||
<div className='flex items-center'>
|
||||
<Segmented
|
||||
size='small'
|
||||
options={[
|
||||
{
|
||||
label: <div className={'flex items-center gap-2'}>
|
||||
<Icon name={'list-alt'} color={'inherit'} />
|
||||
<div>List</div>
|
||||
</div>,
|
||||
value: 'list'
|
||||
},
|
||||
{
|
||||
label: <div className={'flex items-center gap-2'}>
|
||||
<Icon name={'grid'} color={'inherit'} />
|
||||
<div>Grid</div>
|
||||
</div>,
|
||||
value: 'grid'
|
||||
}
|
||||
]}
|
||||
onChange={(val) => {
|
||||
metricStore.updateKey('listView', val === 'list')
|
||||
}}
|
||||
value={listView ? 'list' : 'grid'}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { Icon } from 'UI';
|
|||
import { useStore } from 'App/mstore';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import FooterContent from './FooterContent';
|
||||
import { Input } from 'antd'
|
||||
|
||||
interface Props {
|
||||
dashboardId?: number;
|
||||
|
|
@ -46,7 +47,7 @@ function MetricsLibraryModal(props: Props) {
|
|||
</Modal.Header>
|
||||
<Modal.Content className="p-4 pb-20">
|
||||
<div className="border">
|
||||
<MetricsList siteId={siteId} onSelectionChange={onSelectionChange} />
|
||||
<MetricsList siteId={siteId} onSelectionChange={onSelectionChange} inLibrary />
|
||||
</div>
|
||||
</Modal.Content>
|
||||
<Modal.Footer>
|
||||
|
|
@ -61,12 +62,11 @@ export default observer(MetricsLibraryModal);
|
|||
function MetricSearch({ onChange }: any) {
|
||||
return (
|
||||
<div className="relative">
|
||||
<Icon name="search" className="absolute top-0 bottom-0 ml-2 m-auto" size="16" />
|
||||
<input
|
||||
<Input.Search
|
||||
name="dashboardsSearch"
|
||||
className="bg-white p-2 border border-borderColor-gray-light-shade rounded w-full pl-10"
|
||||
placeholder="Filter by title or owner"
|
||||
onChange={onChange}
|
||||
className={'rounded-lg'}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,28 +1,33 @@
|
|||
import React, { useState, useMemo } from 'react';
|
||||
import { Checkbox, Table, Typography } from 'antd';
|
||||
import { Checkbox, Table, Typography, Switch, Tag, Tooltip } from 'antd';
|
||||
import MetricListItem from '../MetricListItem';
|
||||
import { TablePaginationConfig, SorterResult } from 'antd/lib/table/interface';
|
||||
import Widget from 'App/mstore/types/widget';
|
||||
import { LockOutlined, TeamOutlined } from "@ant-design/icons";
|
||||
import classNames from 'classnames';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
// interface Metric {
|
||||
// metricId: number;
|
||||
// name: string;
|
||||
// owner: string;
|
||||
// lastModified: string;
|
||||
// visibility: string;
|
||||
// }
|
||||
interface Metric {
|
||||
metricId: number;
|
||||
name: string;
|
||||
owner: string;
|
||||
lastModified: string;
|
||||
visibility: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
list: Widget[];
|
||||
siteId: string;
|
||||
selectedList: number[];
|
||||
toggleSelection?: (metricId: number) => void;
|
||||
toggleSelection?: (metricId: number | Array<number>) => void;
|
||||
toggleAll?: (e: any) => void;
|
||||
disableSelection?: boolean;
|
||||
allSelected?: boolean;
|
||||
existingCardIds?: number[];
|
||||
showOwn?: boolean;
|
||||
toggleOwn: () => void;
|
||||
inLibrary?: boolean;
|
||||
}
|
||||
|
||||
const ListView: React.FC<Props> = (props: Props) => {
|
||||
|
|
@ -32,8 +37,7 @@ const ListView: React.FC<Props> = (props: Props) => {
|
|||
selectedList,
|
||||
toggleSelection,
|
||||
disableSelection = false,
|
||||
allSelected = false,
|
||||
toggleAll
|
||||
inLibrary = false
|
||||
} = props;
|
||||
const [sorter, setSorter] = useState<{ field: string; order: 'ascend' | 'descend' }>({
|
||||
field: 'lastModified',
|
||||
|
|
@ -66,7 +70,7 @@ const ListView: React.FC<Props> = (props: Props) => {
|
|||
const paginatedData = useMemo(() => {
|
||||
const start = (pagination.current! - 1) * pagination.pageSize!;
|
||||
const end = start + pagination.pageSize!;
|
||||
return sortedData.slice(start, end);
|
||||
return sortedData.slice(start, end).map(metric => ({ ...metric, key: metric.metricId}));
|
||||
}, [sortedData, pagination]);
|
||||
|
||||
const handleTableChange = (
|
||||
|
|
@ -84,34 +88,19 @@ const ListView: React.FC<Props> = (props: Props) => {
|
|||
|
||||
const columns = [
|
||||
{
|
||||
title: (
|
||||
<div className="flex items-center">
|
||||
{!disableSelection && (
|
||||
<Checkbox
|
||||
name="slack"
|
||||
className="mr-4"
|
||||
checked={allSelected}
|
||||
onClick={toggleAll}
|
||||
/>
|
||||
)}
|
||||
<span>Title</span>
|
||||
</div>
|
||||
),
|
||||
title: 'Title',
|
||||
dataIndex: 'name',
|
||||
key: 'title',
|
||||
className: 'cap-first',
|
||||
className: 'cap-first pl-4',
|
||||
sorter: true,
|
||||
width: '25%',
|
||||
render: (text: string, metric: Metric) => (
|
||||
<MetricListItem
|
||||
key={metric.metricId}
|
||||
metric={metric}
|
||||
siteId={siteId}
|
||||
disableSelection={disableSelection}
|
||||
selected={selectedList.includes(metric.metricId)}
|
||||
toggleSelection={(e: any) => {
|
||||
e.stopPropagation();
|
||||
toggleSelection && toggleSelection(metric.metricId);
|
||||
}}
|
||||
inLibrary={inLibrary}
|
||||
disableSelection={!inLibrary}
|
||||
renderColumn="title"
|
||||
/>
|
||||
)
|
||||
|
|
@ -121,7 +110,7 @@ const ListView: React.FC<Props> = (props: Props) => {
|
|||
dataIndex: 'owner',
|
||||
key: 'owner',
|
||||
className: 'capitalize',
|
||||
width: '30%',
|
||||
width: '25%',
|
||||
sorter: true,
|
||||
render: (text: string, metric: Metric) => (
|
||||
<MetricListItem
|
||||
|
|
@ -137,7 +126,7 @@ const ListView: React.FC<Props> = (props: Props) => {
|
|||
dataIndex: 'lastModified',
|
||||
key: 'lastModified',
|
||||
sorter: true,
|
||||
width: '16.67%',
|
||||
width: '25%',
|
||||
render: (text: string, metric: Metric) => (
|
||||
<MetricListItem
|
||||
key={metric.metricId}
|
||||
|
|
@ -147,21 +136,9 @@ 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"
|
||||
// />
|
||||
// )
|
||||
// },
|
||||
{
|
||||
];
|
||||
if (!inLibrary) {
|
||||
columns.push({
|
||||
title: '',
|
||||
key: 'options',
|
||||
className: 'text-right',
|
||||
|
|
@ -174,8 +151,12 @@ const ListView: React.FC<Props> = (props: Props) => {
|
|||
renderColumn="options"
|
||||
/>
|
||||
)
|
||||
})
|
||||
} else {
|
||||
columns.forEach(col => {
|
||||
col.width = '31%';
|
||||
})
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<Table
|
||||
|
|
@ -183,26 +164,20 @@ const ListView: React.FC<Props> = (props: Props) => {
|
|||
dataSource={paginatedData}
|
||||
rowKey="metricId"
|
||||
onChange={handleTableChange}
|
||||
size='middle'
|
||||
onRow={inLibrary ? (record) => ({
|
||||
onClick: () => disableSelection ? null : toggleSelection?.(record.metricId)
|
||||
}) : undefined}
|
||||
rowSelection={
|
||||
!disableSelection
|
||||
? {
|
||||
selectedRowKeys: selectedList.map((id: number) => id.toString()),
|
||||
selectedRowKeys: selectedList,
|
||||
onChange: (selectedRowKeys) => {
|
||||
selectedRowKeys.forEach((key: any) => {
|
||||
toggleSelection && toggleSelection(parseInt(key));
|
||||
});
|
||||
}
|
||||
toggleSelection(selectedRowKeys);
|
||||
},
|
||||
columnWidth: 16,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
// footer={() => (
|
||||
// <div className="flex justify-end">
|
||||
// <Checkbox name="slack" checked={allSelected} onClick={toggleAll}>
|
||||
// Select All
|
||||
// </Checkbox>
|
||||
// </div>
|
||||
// )}
|
||||
pagination={{
|
||||
current: pagination.current,
|
||||
pageSize: pagination.pageSize,
|
||||
|
|
@ -211,7 +186,8 @@ const ListView: React.FC<Props> = (props: Props) => {
|
|||
className: 'px-4',
|
||||
showLessItems: true,
|
||||
showTotal: () => totalMessage,
|
||||
showQuickJumper: true
|
||||
size: 'small',
|
||||
simple: 'true',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { observer, useObserver } from 'mobx-react-lite';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { NoContent, Pagination, Icon, Loader } from 'UI';
|
||||
import { NoContent, Loader } from 'UI';
|
||||
import { useStore } from 'App/mstore';
|
||||
import { sliceListPerPage } from 'App/utils';
|
||||
import GridView from './GridView';
|
||||
|
|
@ -9,23 +9,36 @@ import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG';
|
|||
|
||||
function MetricsList({
|
||||
siteId,
|
||||
onSelectionChange
|
||||
}: {
|
||||
onSelectionChange,
|
||||
inLibrary,
|
||||
}: {
|
||||
siteId: string;
|
||||
onSelectionChange?: (selected: any[]) => void;
|
||||
inLibrary?: boolean;
|
||||
}) {
|
||||
const { metricStore, dashboardStore } = useStore();
|
||||
const metricsSearch = metricStore.filter.query;
|
||||
const listView = useObserver(() => metricStore.listView);
|
||||
const listView = inLibrary ? true : metricStore.listView;
|
||||
const [selectedMetrics, setSelectedMetrics] = useState<any>([]);
|
||||
|
||||
const dashboard = dashboardStore.selectedDashboard;
|
||||
const existingCardIds = useMemo(() => dashboard?.widgets?.map(i => parseInt(i.metricId)), [dashboard]);
|
||||
const cards = useMemo(() => !!onSelectionChange ? metricStore.filteredCards.filter(i => !existingCardIds?.includes(parseInt(i.metricId))) : metricStore.filteredCards, [metricStore.filteredCards]);
|
||||
const existingCardIds = useMemo(
|
||||
() => dashboard?.widgets?.map((i) => parseInt(i.metricId)),
|
||||
[dashboard]
|
||||
);
|
||||
const cards = useMemo(
|
||||
() =>
|
||||
!!onSelectionChange
|
||||
? metricStore.filteredCards.filter(
|
||||
(i) => !existingCardIds?.includes(parseInt(i.metricId))
|
||||
)
|
||||
: metricStore.filteredCards,
|
||||
[metricStore.filteredCards]
|
||||
);
|
||||
const loading = metricStore.isLoading;
|
||||
|
||||
useEffect(() => {
|
||||
metricStore.fetchList();
|
||||
void metricStore.fetchList();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -36,42 +49,59 @@ function MetricsList({
|
|||
}, [selectedMetrics]);
|
||||
|
||||
const toggleMetricSelection = (id: any) => {
|
||||
if (Array.isArray(id)) {
|
||||
setSelectedMetrics(id);
|
||||
return
|
||||
}
|
||||
if (selectedMetrics.includes(id)) {
|
||||
setSelectedMetrics(selectedMetrics.filter((i: number) => i !== id));
|
||||
setSelectedMetrics((prev) => prev.filter((i: number) => i !== id));
|
||||
} else {
|
||||
setSelectedMetrics([...selectedMetrics, id]);
|
||||
setSelectedMetrics((prev) => [...prev, id]);
|
||||
}
|
||||
};
|
||||
|
||||
const lenth = cards.length;
|
||||
const length = cards.length;
|
||||
|
||||
useEffect(() => {
|
||||
metricStore.updateKey('sessionsPage', 1);
|
||||
}, []);
|
||||
|
||||
const showOwn = metricStore.filter.showMine;
|
||||
const toggleOwn = () => {
|
||||
metricStore.updateKey('showMine', !showOwn);
|
||||
}
|
||||
return (
|
||||
<Loader loading={loading}>
|
||||
<NoContent
|
||||
show={lenth === 0}
|
||||
show={length === 0}
|
||||
title={
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<AnimatedSVG name={ICONS.NO_CARDS} size={60} />
|
||||
<div className="text-center mt-4 text-lg font-medium">
|
||||
{metricsSearch !== '' ? 'No matching results' : 'You haven\'t created any cards yet'}
|
||||
{metricsSearch !== ''
|
||||
? 'No matching results'
|
||||
: "You haven't created any cards yet"}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
subtext="Utilize cards to visualize key user interactions or product performance metrics."
|
||||
subtext={
|
||||
metricsSearch !== ''
|
||||
? ''
|
||||
: 'Utilize cards to visualize key user interactions or product performance metrics.'
|
||||
}
|
||||
>
|
||||
{listView ? (
|
||||
<ListView
|
||||
disableSelection={!onSelectionChange}
|
||||
siteId={siteId}
|
||||
list={cards}
|
||||
inLibrary={inLibrary}
|
||||
selectedList={selectedMetrics}
|
||||
existingCardIds={existingCardIds}
|
||||
toggleSelection={toggleMetricSelection}
|
||||
allSelected={cards.length === selectedMetrics.length}
|
||||
showOwn={showOwn}
|
||||
toggleOwn={toggleOwn}
|
||||
toggleAll={({ target: { checked, name } }) =>
|
||||
setSelectedMetrics(checked ? cards.map((i: any) => i.metricId).slice(0, 30 - existingCardIds!.length) : [])
|
||||
}
|
||||
|
|
@ -87,8 +117,8 @@ function MetricsList({
|
|||
<div className="w-full flex items-center justify-between py-4 px-6 border-t">
|
||||
<div className="">
|
||||
Showing{' '}
|
||||
<span className="font-semibold">{Math.min(cards.length, metricStore.pageSize)}</span> out
|
||||
of <span className="font-semibold">{cards.length}</span> cards
|
||||
<span className="font-medium">{Math.min(cards.length, metricStore.pageSize)}</span> out
|
||||
of <span className="font-medium">{cards.length}</span> cards
|
||||
</div>
|
||||
<Pagination
|
||||
page={metricStore.page}
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ function MetricsSearch() {
|
|||
value={query}
|
||||
allowClear
|
||||
name="metricsSearch"
|
||||
className="w-full"
|
||||
className="w-full input-search-card"
|
||||
placeholder="Filter by title or owner"
|
||||
onChange={write}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -11,8 +11,10 @@ function MetricsView({ siteId }: Props) {
|
|||
return (
|
||||
<div style={{ maxWidth: '1360px', margin: 'auto' }} className="bg-white rounded-lg shadow-sm pt-4 border">
|
||||
<MetricViewHeader siteId={siteId} />
|
||||
<div className='pt-3'>
|
||||
<MetricsList siteId={siteId} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -64,8 +64,8 @@ function SessionsModal(props: Props) {
|
|||
<div className='w-full flex items-center justify-between p-4 absolute bottom-0 bg-white'>
|
||||
<div className='text-disabled-text'>
|
||||
Showing <span
|
||||
className='font-semibold'>{Math.min(length, 10)}</span> out of{' '}
|
||||
<span className='font-semibold'>{total}</span> Issues
|
||||
className='font-medium'>{Math.min(length, 10)}</span> out of{' '}
|
||||
<span className='font-medium'>{total}</span> Issues
|
||||
</div>
|
||||
<Pagination
|
||||
page={page}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -1,17 +1,23 @@
|
|||
import React, {useState, useRef, useEffect} from 'react';
|
||||
import CustomMetricLineChart from 'App/components/Dashboard/Widgets/CustomMetricsWidgets/CustomMetricLineChart';
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import LineChart from 'App/components/Charts/LineChart'
|
||||
import BarChart from 'App/components/Charts/BarChart'
|
||||
import PieChart from 'App/components/Charts/PieChart'
|
||||
import ColumnChart from 'App/components/Charts/ColumnChart'
|
||||
import SankeyChart from 'Components/Charts/SankeyChart';
|
||||
|
||||
import CustomMetricPercentage from 'App/components/Dashboard/Widgets/CustomMetricsWidgets/CustomMetricPercentage';
|
||||
import CustomMetricPieChart from 'App/components/Dashboard/Widgets/CustomMetricsWidgets/CustomMetricPieChart';
|
||||
import {Styles} from 'App/components/Dashboard/Widgets/common';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import { Styles } from 'App/components/Dashboard/Widgets/common';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { Icon, Loader } from 'UI';
|
||||
import {useStore} from 'App/mstore';
|
||||
import { useStore } from 'App/mstore';
|
||||
import FunnelTable from "../../../Funnels/FunnelWidget/FunnelTable";
|
||||
import BugNumChart from '../../Widgets/CustomMetricsWidgets/BigNumChart';
|
||||
import WidgetDatatable from '../WidgetDatatable/WidgetDatatable';
|
||||
import WidgetPredefinedChart from '../WidgetPredefinedChart';
|
||||
import CustomMetricOverviewChart from 'App/components/Dashboard/Widgets/CustomMetricsWidgets/CustomMetricOverviewChart';
|
||||
import {getStartAndEndTimestampsByDensity} from 'Types/dashboard/helper';
|
||||
import {debounce} from 'App/utils';
|
||||
import { getStartAndEndTimestampsByDensity } from 'Types/dashboard/helper';
|
||||
import { debounce } from 'App/utils';
|
||||
import useIsMounted from 'App/hooks/useIsMounted';
|
||||
import {FilterKey} from 'Types/filter/filterType';
|
||||
import { FilterKey } from 'Types/filter/filterType';
|
||||
import {
|
||||
TIMESERIES,
|
||||
TABLE,
|
||||
|
|
@ -20,17 +26,18 @@ import {
|
|||
ERRORS,
|
||||
INSIGHTS,
|
||||
USER_PATH,
|
||||
RETENTION
|
||||
RETENTION,
|
||||
} from 'App/constants/card';
|
||||
import FunnelWidget from 'App/components/Funnels/FunnelWidget';
|
||||
import CustomMetricTableSessions from 'App/components/Dashboard/Widgets/CustomMetricsWidgets/CustomMetricTableSessions';
|
||||
import CustomMetricTableErrors from 'App/components/Dashboard/Widgets/CustomMetricsWidgets/CustomMetricTableErrors';
|
||||
import ClickMapCard from 'App/components/Dashboard/Widgets/CustomMetricsWidgets/ClickMapCard';
|
||||
import InsightsCard from 'App/components/Dashboard/Widgets/CustomMetricsWidgets/InsightsCard';
|
||||
import SankeyChart from 'Shared/Insights/SankeyChart';
|
||||
import { filterMinorPaths } from 'Shared/Insights/SankeyChart/utils'
|
||||
import CohortCard from '../../Widgets/CustomMetricsWidgets/CohortCard';
|
||||
import SessionsBy from "Components/Dashboard/Widgets/CustomMetricsWidgets/SessionsBy";
|
||||
import { useInView } from "react-intersection-observer";
|
||||
import SessionsBy from 'Components/Dashboard/Widgets/CustomMetricsWidgets/SessionsBy';
|
||||
import { useInView } from 'react-intersection-observer';
|
||||
import LongLoader from "./LongLoader";
|
||||
|
||||
interface Props {
|
||||
metric: any;
|
||||
|
|
@ -42,128 +49,402 @@ interface Props {
|
|||
function WidgetChart(props: Props) {
|
||||
const { ref, inView } = useInView({
|
||||
triggerOnce: true,
|
||||
rootMargin: "200px 0px",
|
||||
rootMargin: '200px 0px',
|
||||
});
|
||||
const {isSaved = false, metric, isTemplate} = props;
|
||||
const {dashboardStore, metricStore, sessionStore} = useStore();
|
||||
const _metric: any = metricStore.instance;
|
||||
const { isSaved = false, metric, isTemplate } = props;
|
||||
const { dashboardStore, metricStore } = useStore();
|
||||
const _metric: any = props.isPreview ? metricStore.instance : props.metric;
|
||||
const data = _metric.data;
|
||||
const period = dashboardStore.period;
|
||||
const drillDownPeriod = dashboardStore.drillDownPeriod;
|
||||
const drillDownFilter = dashboardStore.drillDownFilter;
|
||||
const colors = Styles.customMetricColors;
|
||||
const colors = Styles.safeColors;
|
||||
const [loading, setLoading] = useState(true);
|
||||
const params = {density: 70};
|
||||
const metricParams = {...params};
|
||||
const [stale, setStale] = useState(false);
|
||||
const params = { density: dashboardStore.selectedDensity };
|
||||
const metricParams = _metric.params;
|
||||
const prevMetricRef = useRef<any>();
|
||||
const isMounted = useIsMounted();
|
||||
const [data, setData] = useState<any>(metric.data);
|
||||
|
||||
const isTableWidget = metric.metricType === 'table' && metric.viewType === 'table';
|
||||
const isPieChart = metric.metricType === 'table' && metric.viewType === 'pieChart';
|
||||
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';
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
dashboardStore.setComparisonPeriod(null, _metric.metricId);
|
||||
dashboardStore.resetDrillDownFilter();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!data.chart) return;
|
||||
const series = data.chart[0]
|
||||
? Object.keys(data.chart[0]).filter(
|
||||
(key) => key !== 'time' && key !== 'timestamp'
|
||||
)
|
||||
: [];
|
||||
if (series.length) {
|
||||
setEnabledRows(series);
|
||||
}
|
||||
}, [data.chart]);
|
||||
|
||||
const onChartClick = (event: any) => {
|
||||
metricStore.setDrillDown(true);
|
||||
if (event) {
|
||||
if (isTableWidget || isPieChart) { // get the filter of clicked row
|
||||
if (isTableWidget || isPieChart) {
|
||||
// get the filter of clicked row
|
||||
const periodTimestamps = drillDownPeriod.toTimestamps();
|
||||
drillDownFilter.merge({
|
||||
filters: event,
|
||||
startTimestamp: periodTimestamps.startTimestamp,
|
||||
endTimestamp: periodTimestamps.endTimestamp
|
||||
endTimestamp: periodTimestamps.endTimestamp,
|
||||
});
|
||||
} else { // get the filter of clicked chart point
|
||||
} 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 periodTimestamps = getStartAndEndTimestampsByDensity(
|
||||
timestamp,
|
||||
drillDownPeriod.start,
|
||||
drillDownPeriod.end,
|
||||
params.density
|
||||
);
|
||||
|
||||
drillDownFilter.merge({
|
||||
startTimestamp: periodTimestamps.startTimestamp,
|
||||
endTimestamp: periodTimestamps.endTimestamp
|
||||
endTimestamp: periodTimestamps.endTimestamp,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const loadSample = () => console.log('clicked')
|
||||
|
||||
const depsString = JSON.stringify({
|
||||
..._metric.series, ..._metric.excludes, ..._metric.startPoint,
|
||||
hideExcess: _metric.hideExcess
|
||||
..._metric.series,
|
||||
..._metric.excludes,
|
||||
..._metric.startPoint,
|
||||
hideExcess: false,
|
||||
});
|
||||
const fetchMetricChartData = (metric: any, payload: any, isSaved: any, period: any) => {
|
||||
const fetchMetricChartData = (
|
||||
metric: any,
|
||||
payload: any,
|
||||
isSaved: any,
|
||||
period: any,
|
||||
isComparison?: boolean
|
||||
) => {
|
||||
if (!isMounted()) return;
|
||||
setLoading(true);
|
||||
dashboardStore.fetchMetricChartData(metric, payload, isSaved, period).then((res: any) => {
|
||||
if (isMounted()) setData(res);
|
||||
}).finally(() => {
|
||||
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);
|
||||
});
|
||||
};
|
||||
|
||||
const debounceRequest: any = React.useCallback(debounce(fetchMetricChartData, 500), []);
|
||||
const debounceRequest: any = React.useCallback(
|
||||
debounce(fetchMetricChartData, 500),
|
||||
[]
|
||||
);
|
||||
const loadPage = () => {
|
||||
if (!inView) return;
|
||||
if (prevMetricRef.current && prevMetricRef.current.name !== metric.name) {
|
||||
prevMetricRef.current = metric;
|
||||
if (prevMetricRef.current && prevMetricRef.current.name !== _metric.name) {
|
||||
prevMetricRef.current = _metric;
|
||||
return;
|
||||
}
|
||||
prevMetricRef.current = metric;
|
||||
prevMetricRef.current = _metric;
|
||||
const timestmaps = drillDownPeriod.toTimestamps();
|
||||
const payload = isSaved ? {...params} : {...metricParams, ...timestmaps, ...metric.toJson()};
|
||||
debounceRequest(metric, payload, isSaved, !isSaved ? drillDownPeriod : period);
|
||||
const payload = isSaved
|
||||
? { ...metricParams }
|
||||
: { ...params, ...timestmaps, ..._metric.toJson() };
|
||||
debounceRequest(
|
||||
_metric,
|
||||
payload,
|
||||
isSaved,
|
||||
!isSaved ? drillDownPeriod : period
|
||||
);
|
||||
};
|
||||
|
||||
const loadComparisonData = () => {
|
||||
if (!dashboardStore.comparisonPeriods[_metric.metricId]) return setCompData(null);
|
||||
|
||||
// TODO: remove after backend adds support for more view types
|
||||
const payload = {
|
||||
...params,
|
||||
..._metric.toJson(),
|
||||
viewType: 'lineChart',
|
||||
};
|
||||
fetchMetricChartData(
|
||||
_metric,
|
||||
payload,
|
||||
isSaved,
|
||||
dashboardStore.comparisonPeriods[_metric.metricId],
|
||||
true
|
||||
);
|
||||
};
|
||||
useEffect(() => {
|
||||
if (!inView || !props.isPreview) return;
|
||||
loadComparisonData();
|
||||
}, [
|
||||
dashboardStore.comparisonPeriods[_metric.metricId],
|
||||
_metric.metricId,
|
||||
inView,
|
||||
props.isPreview,
|
||||
drillDownPeriod,
|
||||
period,
|
||||
depsString,
|
||||
dashboardStore.selectedDensity,
|
||||
]);
|
||||
useEffect(() => {
|
||||
setCompData(null);
|
||||
_metric.updateKey('page', 1);
|
||||
_metric.updateKey()
|
||||
loadPage();
|
||||
}, [
|
||||
drillDownPeriod,
|
||||
period,
|
||||
depsString,
|
||||
metric.metricType,
|
||||
metric.metricOf,
|
||||
metric.viewType,
|
||||
metric.metricValue,
|
||||
metric.startType,
|
||||
metric.metricFormat,
|
||||
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 = () => {
|
||||
const {metricType, viewType, metricOf} = metric;
|
||||
const metricWithData = {...metric, data};
|
||||
const renderChart = React.useCallback(() => {
|
||||
const { metricType, metricOf } = _metric;
|
||||
const viewType = _metric.viewType;
|
||||
const metricWithData = { ..._metric, data };
|
||||
|
||||
if (metricType === FUNNEL) {
|
||||
return <FunnelWidget metric={metric} data={data} isWidget={isSaved || isTemplate}/>;
|
||||
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}/>;
|
||||
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 (
|
||||
<CustomMetricLineChart
|
||||
data={data}
|
||||
colors={colors}
|
||||
params={params}
|
||||
<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'}
|
||||
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'
|
||||
}
|
||||
/>
|
||||
);
|
||||
} else if (viewType === 'progress') {
|
||||
}
|
||||
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'
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -173,7 +454,7 @@ function WidgetChart(props: Props) {
|
|||
if (metricOf === FilterKey.SESSIONS) {
|
||||
return (
|
||||
<CustomMetricTableSessions
|
||||
metric={metric}
|
||||
metric={_metric}
|
||||
data={data}
|
||||
isTemplate={isTemplate}
|
||||
isEdit={!isSaved && !isTemplate}
|
||||
|
|
@ -183,7 +464,7 @@ function WidgetChart(props: Props) {
|
|||
if (metricOf === FilterKey.ERRORS) {
|
||||
return (
|
||||
<CustomMetricTableErrors
|
||||
metric={metric}
|
||||
metric={_metric}
|
||||
data={data}
|
||||
// isTemplate={isTemplate}
|
||||
isEdit={!isSaved && !isTemplate}
|
||||
|
|
@ -193,40 +474,37 @@ function WidgetChart(props: Props) {
|
|||
if (viewType === TABLE) {
|
||||
return (
|
||||
<SessionsBy
|
||||
metric={metric}
|
||||
data={data[0]}
|
||||
metric={_metric}
|
||||
data={data}
|
||||
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'/>
|
||||
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'}}>
|
||||
<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 />
|
||||
);
|
||||
return <ClickMapCard />;
|
||||
}
|
||||
|
||||
if (metricType === INSIGHTS) {
|
||||
|
|
@ -234,19 +512,22 @@ function WidgetChart(props: Props) {
|
|||
}
|
||||
|
||||
if (metricType === USER_PATH && data && data.links) {
|
||||
// return <PathAnalysis data={data}/>;
|
||||
return <SankeyChart
|
||||
const usedData = _metric.hideExcess ? filterMinorPaths(data) : data;
|
||||
return (
|
||||
<SankeyChart
|
||||
height={props.isPreview ? 500 : 240}
|
||||
data={data}
|
||||
data={usedData}
|
||||
onChartClick={(filters: any) => {
|
||||
dashboardStore.drillDownFilter.merge({filters, page: 1});
|
||||
}}/>;
|
||||
dashboardStore.drillDownFilter.merge({ filters, page: 1 });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (metricType === RETENTION) {
|
||||
if (viewType === 'trend') {
|
||||
return (
|
||||
<CustomMetricLineChart
|
||||
<LineChart
|
||||
data={data}
|
||||
colors={colors}
|
||||
params={params}
|
||||
|
|
@ -254,21 +535,36 @@ function WidgetChart(props: Props) {
|
|||
/>
|
||||
);
|
||||
} else if (viewType === 'cohort') {
|
||||
return (
|
||||
<CohortCard data={data[0]}/>
|
||||
);
|
||||
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}>
|
||||
<Loader loading={loading} style={{height: `240px`}}>
|
||||
<div style={{minHeight: 240}}>{renderChart()}</div>
|
||||
</Loader>
|
||||
{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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -1,15 +1,30 @@
|
|||
import React from 'react';
|
||||
import SelectDateRange from 'Shared/SelectDateRange';
|
||||
import {useStore} from 'App/mstore';
|
||||
import {useObserver} from 'mobx-react-lite';
|
||||
import {Space} from "antd";
|
||||
import { useStore } from 'App/mstore';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { Space } from 'antd';
|
||||
import RangeGranularity from "./RangeGranularity";
|
||||
import {
|
||||
CUSTOM_RANGE,
|
||||
DATE_RANGE_COMPARISON_OPTIONS,
|
||||
} from 'App/dateRange';
|
||||
import Period from 'Types/app/period';
|
||||
|
||||
function WidgetDateRange({
|
||||
label = 'Time Range',
|
||||
}: any) {
|
||||
const {dashboardStore} = useStore();
|
||||
const period = useObserver(() => dashboardStore.drillDownPeriod);
|
||||
const drillDownFilter = useObserver(() => dashboardStore.drillDownFilter);
|
||||
hasGranularSettings = false,
|
||||
hasGranularity = false,
|
||||
hasComparison = false,
|
||||
presetComparison = null,
|
||||
}: any) {
|
||||
const { dashboardStore, metricStore } = useStore();
|
||||
const density = dashboardStore.selectedDensity
|
||||
const onDensityChange = (density: number) => {
|
||||
dashboardStore.setDensity(density);
|
||||
}
|
||||
const period = dashboardStore.drillDownPeriod;
|
||||
const compPeriod = dashboardStore.comparisonPeriods[metricStore.instance.metricId];
|
||||
const drillDownFilter = dashboardStore.drillDownFilter;
|
||||
|
||||
const onChangePeriod = (period: any) => {
|
||||
dashboardStore.setDrillDownPeriod(period);
|
||||
|
|
@ -17,7 +32,50 @@ function WidgetDateRange({
|
|||
drillDownFilter.merge({
|
||||
startTimestamp: periodTimestamps.startTimestamp,
|
||||
endTimestamp: periodTimestamps.endTimestamp,
|
||||
})
|
||||
});
|
||||
};
|
||||
|
||||
const onChangeComparison = (period: any) => {
|
||||
dashboardStore.setComparisonPeriod(period, metricStore.instance.metricId);
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
if (presetComparison) {
|
||||
const option = DATE_RANGE_COMPARISON_OPTIONS.find((option: any) => option.value === presetComparison[0]);
|
||||
if (option) {
|
||||
// @ts-ignore
|
||||
const newPeriod = new Period({
|
||||
start: period.start,
|
||||
end: period.end,
|
||||
substract: option.value,
|
||||
});
|
||||
setTimeout(() => {
|
||||
onChangeComparison(newPeriod);
|
||||
}, 1)
|
||||
} else {
|
||||
const day = 86400000;
|
||||
const originalPeriodLength = Math.ceil(
|
||||
(period.end - period.start) / day
|
||||
);
|
||||
const start = presetComparison[0];
|
||||
const end = presetComparison[1] + originalPeriodLength * day;
|
||||
|
||||
// @ts-ignore
|
||||
const compRange = new Period({
|
||||
start,
|
||||
end,
|
||||
rangeName: CUSTOM_RANGE,
|
||||
});
|
||||
setTimeout(() => {
|
||||
onChangeComparison(compRange);
|
||||
}, 1)
|
||||
}
|
||||
}
|
||||
}, [presetComparison])
|
||||
|
||||
const updateInstComparison = (range: [start: string, end?: string] | null) => {
|
||||
metricStore.instance.setComparisonRange(range);
|
||||
metricStore.instance.updateKey('hasChanged', true)
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -26,12 +84,35 @@ function WidgetDateRange({
|
|||
<SelectDateRange
|
||||
period={period}
|
||||
onChange={onChangePeriod}
|
||||
right={true}
|
||||
isAnt={true}
|
||||
useButtonStyle={true}
|
||||
/>
|
||||
{hasGranularSettings ? (
|
||||
<>
|
||||
{hasGranularity ? (
|
||||
<RangeGranularity
|
||||
period={period}
|
||||
density={density}
|
||||
onDensityChange={onDensityChange}
|
||||
/>
|
||||
) : 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);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React from 'react';
|
||||
import { Card, Space, Typography, Button, Alert, Form } from 'antd';
|
||||
import { Card, Space, Button, Alert, Form, Select, Tooltip } from 'antd';
|
||||
import { useStore } from 'App/mstore';
|
||||
import { eventKeys } from 'Types/filter/newFilter';
|
||||
import {
|
||||
|
|
@ -9,89 +9,147 @@ import {
|
|||
INSIGHTS,
|
||||
RETENTION,
|
||||
TABLE,
|
||||
USER_PATH
|
||||
USER_PATH,
|
||||
} from 'App/constants/card';
|
||||
import FilterSeries from 'Components/Dashboard/components/FilterSeries/FilterSeries';
|
||||
import { issueCategories, metricOf } from 'App/constants/filterOptions';
|
||||
import { AudioWaveform, ChevronDown, ChevronUp, PlusIcon } from 'lucide-react';
|
||||
import { issueCategories } from 'App/constants/filterOptions';
|
||||
import { PlusIcon, ChevronUp } from 'lucide-react';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import AddStepButton from 'Components/Dashboard/components/FilterSeries/AddStepButton';
|
||||
import FilterItem from 'Shared/Filters/FilterItem';
|
||||
import { FilterKey } from 'Types/filter/filterType';
|
||||
import Select from 'Shared/Select';
|
||||
import { FilterKey, FilterCategory } from 'Types/filter/filterType';
|
||||
|
||||
function WidgetFormNew() {
|
||||
const { metricStore, dashboardStore, aiFiltersStore } = useStore();
|
||||
const getExcludedKeys = (metricType: string) => {
|
||||
switch (metricType) {
|
||||
case USER_PATH:
|
||||
case HEATMAP:
|
||||
return eventKeys;
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
const getExcludedCategories = (metricType: string) => {
|
||||
switch (metricType) {
|
||||
case USER_PATH:
|
||||
case FUNNEL:
|
||||
return [FilterCategory.DEVTOOLS]
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function WidgetFormNew({ layout }: { layout: string }) {
|
||||
const { metricStore } = useStore();
|
||||
const metric: any = metricStore.instance;
|
||||
const excludeFilterKeys = getExcludedKeys(metric.metricType);
|
||||
const excludeCategory = getExcludedCategories(metric.metricType);
|
||||
|
||||
const eventsLength = metric.series[0].filter.filters.filter((i: any) => i && i.isEvent).length;
|
||||
const filtersLength = metric.series[0].filter.filters.filter((i: any) => i && !i.isEvent).length;
|
||||
const isClickMap = metric.metricType === HEATMAP;
|
||||
const isPathAnalysis = metric.metricType === USER_PATH;
|
||||
const excludeFilterKeys = isClickMap || isPathAnalysis ? eventKeys : [];
|
||||
const hasFilters = filtersLength > 0 || eventsLength > 0;
|
||||
const isPredefined = metric.metricType === ERRORS
|
||||
const isPredefined = metric.metricType === ERRORS;
|
||||
|
||||
return isPredefined ? <PredefinedMessage /> : (
|
||||
return isPredefined ? (
|
||||
<PredefinedMessage />
|
||||
) : (
|
||||
<Space direction="vertical" className="w-full">
|
||||
<AdditionalFilters />
|
||||
{/*{!hasFilters && (<DefineSteps metric={metric} excludeFilterKeys={excludeFilterKeys} />)}*/}
|
||||
{/*{hasFilters && (<FilterSection metric={metric} excludeFilterKeys={excludeFilterKeys} />)}*/}
|
||||
<FilterSection metric={metric} excludeFilterKeys={excludeFilterKeys} />
|
||||
<FilterSection
|
||||
layout={layout}
|
||||
metric={metric}
|
||||
excludeCategory={excludeCategory}
|
||||
excludeFilterKeys={excludeFilterKeys}
|
||||
/>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(WidgetFormNew);
|
||||
|
||||
const FilterSection = observer(({ layout, metric, excludeFilterKeys, excludeCategory }: any) => {
|
||||
const allOpen = layout.startsWith('flex-row');
|
||||
const defaultClosed = React.useRef(!allOpen && metric.exists());
|
||||
const [seriesCollapseState, setSeriesCollapseState] = React.useState<Record<number, boolean>>({});
|
||||
|
||||
function DefineSteps({ metric, excludeFilterKeys }: any) {
|
||||
return (
|
||||
<div className="px-4 py-2 bg-white rounded-lg shadow-sm flex items-center">
|
||||
<Typography.Text strong>Filter</Typography.Text>
|
||||
<AddStepButton excludeFilterKeys={excludeFilterKeys} series={metric.series[0]} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
const FilterSection = observer(({ metric, excludeFilterKeys }: any) => {
|
||||
// const timeseriesOptions = metricOf.filter((i) => i.type === 'timeseries');
|
||||
// const tableOptions = metricOf.filter((i) => i.type === 'table');
|
||||
React.useEffect(() => {
|
||||
const defaultSeriesCollapseState: Record<number, boolean> = {};
|
||||
metric.series.forEach((s: any) => {
|
||||
defaultSeriesCollapseState[s.seriesId] = defaultSeriesCollapseState[
|
||||
s.seriesId
|
||||
]
|
||||
? defaultSeriesCollapseState[s.seriesId]
|
||||
: allOpen
|
||||
? false
|
||||
: defaultClosed.current;
|
||||
});
|
||||
setSeriesCollapseState(defaultSeriesCollapseState);
|
||||
}, [metric.series]);
|
||||
const isTable = metric.metricType === TABLE;
|
||||
const isClickMap = metric.metricType === HEATMAP;
|
||||
const isHeatMap = metric.metricType === HEATMAP;
|
||||
const isFunnel = metric.metricType === FUNNEL;
|
||||
const isInsights = metric.metricType === INSIGHTS;
|
||||
const isPathAnalysis = metric.metricType === USER_PATH;
|
||||
const isRetention = metric.metricType === RETENTION;
|
||||
const canAddSeries = metric.series.length < 3;
|
||||
const eventsLength = metric.series[0].filter.filters.filter((i: any) => i && i.isEvent).length;
|
||||
// const cannotSaveFunnel = isFunnel && (!metric.series[0] || eventsLength <= 1);
|
||||
|
||||
const isSingleSeries = isTable || isFunnel || isClickMap || isInsights || isRetention;
|
||||
const isSingleSeries =
|
||||
isTable ||
|
||||
isFunnel ||
|
||||
isHeatMap ||
|
||||
isInsights ||
|
||||
isRetention ||
|
||||
isPathAnalysis;
|
||||
|
||||
// const onAddFilter = (filter: any) => {
|
||||
// metric.series[0].filter.addFilter(filter);
|
||||
// metric.updateKey('hasChanged', true)
|
||||
// }
|
||||
const collapseAll = () => {
|
||||
setSeriesCollapseState((seriesCollapseState) => {
|
||||
const newState = { ...seriesCollapseState };
|
||||
Object.keys(newState).forEach((key) => {
|
||||
newState[key] = true;
|
||||
});
|
||||
return newState;
|
||||
});
|
||||
}
|
||||
const expandAll = () => {
|
||||
setSeriesCollapseState((seriesCollapseState) => {
|
||||
const newState = { ...seriesCollapseState };
|
||||
Object.keys(newState).forEach((key) => {
|
||||
newState[key] = false;
|
||||
});
|
||||
return newState;
|
||||
});
|
||||
}
|
||||
|
||||
const allCollapsed = Object.values(seriesCollapseState).every((v) => v);
|
||||
return (
|
||||
<>
|
||||
{
|
||||
metric.series.length > 0 && metric.series
|
||||
{metric.series.length > 0 &&
|
||||
metric.series
|
||||
.slice(0, isSingleSeries ? 1 : metric.series.length)
|
||||
.map((series: any, index: number) => (
|
||||
<div className="mb-2" key={series.name}>
|
||||
<div className="mb-2 rounded-xl" key={series.name}>
|
||||
<FilterSeries
|
||||
isHeatmap={isHeatMap}
|
||||
canExclude={isPathAnalysis}
|
||||
supportsEmpty={!isClickMap && !isPathAnalysis}
|
||||
removeEvents={isPathAnalysis}
|
||||
supportsEmpty={!isHeatMap && !isPathAnalysis}
|
||||
excludeFilterKeys={excludeFilterKeys}
|
||||
excludeCategory={excludeCategory}
|
||||
observeChanges={() => metric.updateKey('hasChanged', true)}
|
||||
hideHeader={isTable || isClickMap || isInsights || isPathAnalysis || isFunnel}
|
||||
hideHeader={
|
||||
isTable ||
|
||||
isHeatMap ||
|
||||
isInsights ||
|
||||
isPathAnalysis ||
|
||||
isFunnel
|
||||
}
|
||||
seriesIndex={index}
|
||||
series={series}
|
||||
onRemoveSeries={() => metric.removeSeries(index)}
|
||||
canDelete={metric.series.length > 1}
|
||||
collapseState={seriesCollapseState[series.seriesId]}
|
||||
onToggleCollapse={() => {
|
||||
setSeriesCollapseState((seriesCollapseState) => ({
|
||||
...seriesCollapseState,
|
||||
[series.seriesId]: !seriesCollapseState[series.seriesId],
|
||||
}));
|
||||
}}
|
||||
emptyMessage={
|
||||
isTable
|
||||
? 'Filter data using any event or attribute. Use Add Step button below to do so.'
|
||||
|
|
@ -100,74 +158,110 @@ const FilterSection = observer(({ metric, excludeFilterKeys }: any) => {
|
|||
expandable={isSingleSeries}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
|
||||
{!isSingleSeries && canAddSeries && (
|
||||
<Card styles={{ body: { padding: '4px' } }} className="rounded-full shadow-sm">
|
||||
))}
|
||||
{isSingleSeries ? null :
|
||||
<div className={'mx-auto flex items-center gap-2 w-fit'}>
|
||||
<Tooltip title={canAddSeries ? '' : 'Maximum of 3 series reached.'}>
|
||||
<Button
|
||||
type="link"
|
||||
onClick={() => {
|
||||
if (!canAddSeries) return;
|
||||
metric.addSeries();
|
||||
}}
|
||||
disabled={!canAddSeries}
|
||||
size="small"
|
||||
className="block w-full"
|
||||
type="primary"
|
||||
icon={<PlusIcon size={16} />}
|
||||
>
|
||||
<Space>
|
||||
<AudioWaveform size={16} />
|
||||
New Chart Series
|
||||
</Space>
|
||||
Add Series
|
||||
</Button>
|
||||
</Card>
|
||||
)}
|
||||
</Tooltip>
|
||||
<Button
|
||||
size={'small'}
|
||||
type={'text'}
|
||||
icon={
|
||||
<ChevronUp
|
||||
size={16}
|
||||
className={allCollapsed ? 'rotate-180' : ''}
|
||||
/>
|
||||
}
|
||||
onClick={allCollapsed ? expandAll : collapseAll}
|
||||
>
|
||||
{allCollapsed ? 'Expand' : 'Collapse'} All
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
const PathAnalysisFilter = observer(({ metric, writeOption }: any) => {
|
||||
const metricValueOptions = [
|
||||
{ value: 'location', label: 'Pages' },
|
||||
{ value: 'click', label: 'Clicks' },
|
||||
{ value: 'input', label: 'Input' },
|
||||
{ value: 'custom', label: 'Custom' }
|
||||
{ value: 'custom', label: 'Custom' },
|
||||
];
|
||||
return (
|
||||
<Card styles={{ body: { padding: '20px 20px' } }}>
|
||||
<Card styles={{ body: { padding: '20px 20px' } }} className="rounded-lg">
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<div className="flex flex-wrap gap-2 items-center justify-start">
|
||||
<span className="font-medium">User journeys with: </span>
|
||||
|
||||
<div className="flex sm:flex-wrap lg:flex-nowrap gap-2 items-start">
|
||||
<Select
|
||||
className="w-36 rounded-xl"
|
||||
name="startType"
|
||||
options={[
|
||||
{ value: 'start', label: 'With Start Point' },
|
||||
{ value: 'end', label: 'With End Point' }
|
||||
{ value: 'start', label: 'Start Point' },
|
||||
{ value: 'end', label: 'End Point' },
|
||||
]}
|
||||
defaultValue={metric.startType}
|
||||
onChange={writeOption}
|
||||
placeholder="All Issues"
|
||||
defaultValue={metric.startType || 'start'}
|
||||
onChange={(value) => writeOption({ name: 'startType', value })}
|
||||
placeholder="Select Start Type"
|
||||
size="small"
|
||||
/>
|
||||
<span className="mx-3">showing</span>
|
||||
|
||||
<span className="text-neutral-400 mt-.5">showing</span>
|
||||
|
||||
<Select
|
||||
mode="multiple"
|
||||
className="min-w-36 rounded-xl"
|
||||
allowClear
|
||||
name="metricValue"
|
||||
options={metricValueOptions}
|
||||
value={metric.metricValue}
|
||||
isMulti={true}
|
||||
onChange={writeOption}
|
||||
placeholder="All Issues"
|
||||
value={metric.metricValue || []}
|
||||
onChange={(value) => writeOption({ name: 'metricValue', value })}
|
||||
placeholder="Select Metrics"
|
||||
size="small"
|
||||
/>
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
</Form.Item>
|
||||
<Form.Item label={metric.startType === 'start' ? 'Start Point' : 'End Point'} className="mb-0">
|
||||
<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={() => {
|
||||
}}
|
||||
allowedFilterKeys={[
|
||||
FilterKey.LOCATION,
|
||||
FilterKey.CLICK,
|
||||
FilterKey.INPUT,
|
||||
FilterKey.CUSTOM,
|
||||
]}
|
||||
onUpdate={(val) => metric.updateStartPoint(val)}
|
||||
onRemoveFilter={() => {}}
|
||||
/>
|
||||
</span>
|
||||
</Form.Item>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
});
|
||||
|
|
@ -184,6 +278,7 @@ const InsightsFilter = observer(({ metric, writeOption }: any) => {
|
|||
onChange={writeOption}
|
||||
isMulti
|
||||
placeholder="All Categories"
|
||||
allowClear
|
||||
/>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
|
|
@ -192,7 +287,7 @@ const InsightsFilter = observer(({ metric, writeOption }: any) => {
|
|||
});
|
||||
|
||||
const AdditionalFilters = observer(() => {
|
||||
const { metricStore, dashboardStore, aiFiltersStore } = useStore();
|
||||
const { metricStore } = useStore();
|
||||
const metric: any = metricStore.instance;
|
||||
|
||||
const writeOption = ({ value, name }: { value: any; name: any }) => {
|
||||
|
|
@ -203,14 +298,22 @@ const AdditionalFilters = observer(() => {
|
|||
|
||||
return (
|
||||
<>
|
||||
{metric.metricType === USER_PATH && <PathAnalysisFilter metric={metric} writeOption={writeOption} />}
|
||||
{metric.metricType === INSIGHTS && <InsightsFilter metric={metric} writeOption={writeOption} />}
|
||||
{metric.metricType === USER_PATH && (
|
||||
<PathAnalysisFilter metric={metric} writeOption={writeOption} />
|
||||
)}
|
||||
{metric.metricType === INSIGHTS && (
|
||||
<InsightsFilter metric={metric} writeOption={writeOption} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
const PredefinedMessage = () => (
|
||||
<Alert message="Drilldown or filtering isn't supported on this legacy card." type="warning" showIcon closable
|
||||
className="border-transparent rounded-lg" />
|
||||
<Alert
|
||||
message="Drilldown or filtering isn't supported on this legacy card."
|
||||
type="warning"
|
||||
showIcon
|
||||
closable
|
||||
className="border-transparent rounded-lg"
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -14,8 +14,8 @@ interface Props {
|
|||
}
|
||||
function MetricTypeDropdown(props: Props) {
|
||||
const { metricStore, userStore } = useStore();
|
||||
const isEnterprise = userStore.isEnterprise;
|
||||
const metric: any = metricStore.instance;
|
||||
const isEnterprise = userStore.isEnterprise;
|
||||
|
||||
const options = React.useMemo(() => {
|
||||
return DROPDOWN_OPTIONS.map((option: any) => {
|
||||
|
|
@ -26,18 +26,6 @@ function MetricTypeDropdown(props: Props) {
|
|||
});
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
const queryCardType = props.query.get('type');
|
||||
if (queryCardType && options.length > 0 && metric.metricType) {
|
||||
const type: Option = options.find((i) => i.value === queryCardType) as Option;
|
||||
if (type.disabled) {
|
||||
return;
|
||||
}
|
||||
setTimeout(() => onChange(type.value), 0);
|
||||
}
|
||||
// setTimeout(() => onChange(USER_PATH), 0);
|
||||
}, []);
|
||||
|
||||
const onChange = (type: string) => {
|
||||
metricStore.changeType(type);
|
||||
};
|
||||
|
|
@ -51,7 +39,7 @@ function MetricTypeDropdown(props: Props) {
|
|||
value={
|
||||
DROPDOWN_OPTIONS.find((i: any) => i.value === metric.metricType) || DROPDOWN_OPTIONS[0]
|
||||
}
|
||||
onChange={props.onSelect}
|
||||
onChange={({ value }) => onChange(value.value)}
|
||||
components={{
|
||||
SingleValue: ({ children, ...props }: any) => {
|
||||
const {
|
||||
|
|
|
|||
|
|
@ -1,90 +1,77 @@
|
|||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { Icon, Tooltip } from 'UI';
|
||||
import { Input } from 'antd';
|
||||
import { Input, Tooltip } from 'antd';
|
||||
import cn from 'classnames';
|
||||
|
||||
interface Props {
|
||||
name: string;
|
||||
onUpdate: (name: any) => void;
|
||||
onUpdate: (name: string) => void;
|
||||
seriesIndex?: number;
|
||||
canEdit?: boolean
|
||||
canEdit?: boolean;
|
||||
}
|
||||
|
||||
function WidgetName(props: Props) {
|
||||
const { canEdit = true } = props;
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [name, setName] = useState(props.name)
|
||||
const ref = useRef<any>(null)
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [name, setName] = useState(props.name);
|
||||
const ref = useRef<any>(null);
|
||||
|
||||
const write = ({ target: { value } }) => {
|
||||
setName(value)
|
||||
}
|
||||
setName(value);
|
||||
};
|
||||
|
||||
const onBlur = (nameInput?: string) => {
|
||||
setEditing(false)
|
||||
const toUpdate = nameInput || name
|
||||
props.onUpdate(toUpdate && toUpdate.trim() === '' ? 'New Widget' : toUpdate)
|
||||
setEditing(false);
|
||||
const toUpdate = nameInput || name;
|
||||
props.onUpdate(toUpdate && toUpdate.trim() === '' ? 'New Widget' : toUpdate);
|
||||
};
|
||||
|
||||
const onKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
onBlur(name);
|
||||
}
|
||||
if (e.key === 'Escape' || e.key === 'Esc') {
|
||||
setEditing(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (editing) {
|
||||
ref.current.focus()
|
||||
ref.current.focus();
|
||||
}
|
||||
}, [editing])
|
||||
}, [editing]);
|
||||
|
||||
useEffect(() => {
|
||||
setName(props.name)
|
||||
}, [props.name])
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
onBlur(name)
|
||||
}
|
||||
if (e.key === 'Escape' || e.key === 'Esc') {
|
||||
setEditing(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', handler, false)
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handler, false)
|
||||
}
|
||||
}, [name])
|
||||
setName(props.name);
|
||||
}, [props.name]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
{ editing ? (
|
||||
{editing ? (
|
||||
<Input
|
||||
ref={ ref }
|
||||
ref={ref}
|
||||
name="name"
|
||||
value={name}
|
||||
onChange={write}
|
||||
onBlur={() => onBlur()}
|
||||
onKeyDown={onKeyDown}
|
||||
onFocus={() => setEditing(true)}
|
||||
maxLength={80}
|
||||
className="bg-white text-2xl ps-2 rounded-lg h-8"
|
||||
/>
|
||||
) : (
|
||||
// @ts-ignore
|
||||
<Tooltip delay={200} title="Double click to edit" disabled={!canEdit}>
|
||||
<Tooltip delay={200} title="Click to edit" disabled={!canEdit}>
|
||||
<div
|
||||
onDoubleClick={() => setEditing(true)}
|
||||
className={
|
||||
cn(
|
||||
"text-2xl h-8 flex items-center border-transparent",
|
||||
canEdit && 'cursor-pointer select-none border-b border-b-borderColor-transparent hover:border-dotted hover:border-gray-medium'
|
||||
)
|
||||
}
|
||||
onClick={() => setEditing(true)}
|
||||
className={cn(
|
||||
"text-2xl h-8 flex items-center p-2 rounded-lg",
|
||||
canEdit && 'cursor-pointer select-none ps-2 hover:bg-teal/10'
|
||||
)}
|
||||
>
|
||||
{ name }
|
||||
{name}
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
)}
|
||||
{ canEdit && <div className="ml-3 cursor-pointer" onClick={() => setEditing(true)}>
|
||||
<Tooltip title='Rename' placement='bottom'>
|
||||
<Icon name="pencil" size="16" />
|
||||
</Tooltip>
|
||||
</div> }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,60 +1,214 @@
|
|||
import React from 'react';
|
||||
import { FUNNEL, HEATMAP, TABLE, USER_PATH } from 'App/constants/card';
|
||||
import { Select, Space, Switch } from 'antd';
|
||||
import {
|
||||
FUNNEL,
|
||||
HEATMAP,
|
||||
TABLE,
|
||||
TIMESERIES,
|
||||
USER_PATH,
|
||||
} from 'App/constants/card';
|
||||
import { Select, Space, Switch, Dropdown, Button } from 'antd';
|
||||
import { DownOutlined } from '@ant-design/icons';
|
||||
import { useStore } from 'App/mstore';
|
||||
import ClickMapRagePicker from 'Components/Dashboard/components/ClickMapRagePicker/ClickMapRagePicker';
|
||||
import { FilterKey } from 'Types/filter/filterType';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import {
|
||||
ChartLine,
|
||||
ChartArea,
|
||||
ChartColumn,
|
||||
ChartBar,
|
||||
ChartPie,
|
||||
Table,
|
||||
Hash,
|
||||
Users,
|
||||
Library,
|
||||
ChartColumnBig,
|
||||
ChartBarBig,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface Props {
|
||||
|
||||
}
|
||||
|
||||
|
||||
function WidgetOptions(props: Props) {
|
||||
const { metricStore, dashboardStore } = useStore();
|
||||
function WidgetOptions() {
|
||||
const { metricStore } = useStore();
|
||||
const metric: any = metricStore.instance;
|
||||
|
||||
const handleChange = (value: any) => {
|
||||
metric.update({ metricFormat: value });
|
||||
metric.updateKey('hasChanged', true);
|
||||
};
|
||||
|
||||
// const hasSeriesTypes = [TIMESERIES, FUNNEL, TABLE].includes(metric.metricType);
|
||||
const hasViewTypes = [TIMESERIES, FUNNEL].includes(metric.metricType);
|
||||
return (
|
||||
<div>
|
||||
<div className={'flex items-center gap-2'}>
|
||||
{metric.metricType === USER_PATH && (
|
||||
<a
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
metric.update({ hideExcess: !metric.hideExcess });
|
||||
metric.updateKey('hasChanged', true);
|
||||
}}
|
||||
>
|
||||
<Space>
|
||||
<Switch checked={metric.hideExcess} size="small" />
|
||||
<span className="mr-4 color-gray-medium">
|
||||
Hide Minor Paths
|
||||
</span>
|
||||
<span className="mr-4 color-gray-medium">Hide Minor Paths</span>
|
||||
</Space>
|
||||
</a>
|
||||
)}
|
||||
|
||||
{(metric.metricType === FUNNEL || metric.metricType === TABLE) && metric.metricOf != FilterKey.USERID && metric.metricOf != FilterKey.ERRORS && (
|
||||
<Select
|
||||
defaultValue={metric.metricFormat}
|
||||
onChange={handleChange}
|
||||
variant="borderless"
|
||||
options={[
|
||||
{ value: 'sessionCount', label: 'Sessions' },
|
||||
{ value: 'userCount', label: 'Users' }
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
{metric.metricType === TIMESERIES && <SeriesTypeOptions metric={metric} />}
|
||||
{(metric.metricType === FUNNEL || metric.metricType === TABLE) &&
|
||||
metric.metricOf !== FilterKey.USERID &&
|
||||
metric.metricOf !== FilterKey.ERRORS && (
|
||||
<Dropdown
|
||||
trigger={['click']}
|
||||
menu={{
|
||||
selectable: true,
|
||||
items: [
|
||||
{ key: 'sessionCount', label: 'All Sessions' },
|
||||
{ key: 'userCount', label: 'Unique Users' },
|
||||
],
|
||||
onClick: (info: { key: string }) => handleChange(info.key),
|
||||
}}
|
||||
|
||||
{metric.metricType === HEATMAP ? (
|
||||
<ClickMapRagePicker />
|
||||
) : null}
|
||||
>
|
||||
<Button type="text" variant="text" size="small">
|
||||
{metric.metricFormat === 'sessionCount'
|
||||
? 'All Sessions'
|
||||
: 'Unique Users'}
|
||||
<DownOutlined className="text-sm" />
|
||||
</Button>
|
||||
</Dropdown>
|
||||
)}
|
||||
{hasViewTypes && <WidgetViewTypeOptions metric={metric} />}
|
||||
{metric.metricType === HEATMAP && <ClickMapRagePicker />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const SeriesTypeOptions = observer(({ metric }: { metric: any }) => {
|
||||
const items = {
|
||||
sessionCount: 'Total Sessions',
|
||||
userCount: 'Unique Users',
|
||||
};
|
||||
const chartIcons = {
|
||||
sessionCount: <Library size={16} strokeWidth={1} />,
|
||||
userCount: <Users size={16} strokeWidth={1} />,
|
||||
} as const;
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
trigger={['click']}
|
||||
menu={{
|
||||
selectable: true,
|
||||
items: Object.entries(items).map(([key, name]) => ({
|
||||
key,
|
||||
label: (
|
||||
<div className={'flex items-center gap-2'}>
|
||||
<>
|
||||
{chartIcons[key]}
|
||||
<div>{name}</div>
|
||||
</>
|
||||
</div>
|
||||
),
|
||||
})),
|
||||
onClick: ({ key }: any) => {
|
||||
metric.updateKey('metricOf', key);
|
||||
metric.updateKey('hasChanged', true);
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
variant="text"
|
||||
size="small"
|
||||
className="btn-aggregator"
|
||||
>
|
||||
<Space>
|
||||
{chartIcons[metric.metricOf]}
|
||||
<div>{items[metric.metricOf] || 'Total Sessions'}</div>
|
||||
<DownOutlined className="text-sm" />
|
||||
</Space>
|
||||
</Button>
|
||||
</Dropdown>
|
||||
);
|
||||
});
|
||||
|
||||
const WidgetViewTypeOptions = observer(({ metric }: { metric: any }) => {
|
||||
const chartTypes = {
|
||||
lineChart: 'Line',
|
||||
areaChart: 'Stacked Area',
|
||||
barChart: 'Column',
|
||||
progressChart: 'Bar',
|
||||
columnChart: 'Horizontal Bar',
|
||||
pieChart: 'Pie',
|
||||
metric: 'Metric',
|
||||
table: 'Table',
|
||||
};
|
||||
const funnelChartTypes = {
|
||||
chart: 'Funnel Bar',
|
||||
columnChart: 'Funnel Column',
|
||||
metric: 'Metric',
|
||||
table: 'Table',
|
||||
}
|
||||
const usedChartTypes = metric.metricType === FUNNEL ? funnelChartTypes : chartTypes;
|
||||
const chartIcons = {
|
||||
lineChart: <ChartLine size={16} strokeWidth={1} /> ,
|
||||
barChart: <ChartColumn size={16} strokeWidth={1} />,
|
||||
areaChart: <ChartArea size={16} strokeWidth={1} />,
|
||||
pieChart: <ChartPie size={16} strokeWidth={1} />,
|
||||
progressChart: <ChartBar size={16} strokeWidth={1} />,
|
||||
metric: <Hash size={16} strokeWidth={1} />,
|
||||
table: <Table size={16} strokeWidth={1} />,
|
||||
// funnel specific
|
||||
columnChart: <ChartColumnBig size={16} strokeWidth={1} />,
|
||||
chart: <ChartBarBig size={16} strokeWidth={1} />,
|
||||
};
|
||||
const allowedTypes = {
|
||||
[TIMESERIES]: [
|
||||
'lineChart',
|
||||
'areaChart',
|
||||
'barChart',
|
||||
'progressChart',
|
||||
'pieChart',
|
||||
'metric',
|
||||
'table',
|
||||
],
|
||||
[FUNNEL]: ['chart', 'columnChart', 'metric', 'table'],
|
||||
};
|
||||
return (
|
||||
<Dropdown
|
||||
trigger={['click']}
|
||||
menu={{
|
||||
selectable: true,
|
||||
items: allowedTypes[metric.metricType].map((key) => ({
|
||||
key,
|
||||
label: (
|
||||
<div className="flex gap-2 items-center">
|
||||
{chartIcons[key]}
|
||||
<div>{usedChartTypes[key]}</div>
|
||||
</div>
|
||||
),
|
||||
})),
|
||||
onClick: ({ key }: any) => {
|
||||
metric.updateKey('viewType', key);
|
||||
metric.updateKey('hasChanged', true);
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
variant="text"
|
||||
size="small"
|
||||
className="btn-visualization-type"
|
||||
>
|
||||
<Space>
|
||||
{chartIcons[metric.viewType]}
|
||||
<div>{usedChartTypes[metric.viewType]}</div>
|
||||
<DownOutlined className="text-sm " />
|
||||
</Space>
|
||||
</Button>
|
||||
</Dropdown>
|
||||
);
|
||||
});
|
||||
|
||||
export default observer(WidgetOptions);
|
||||
|
|
|
|||
|
|
@ -1,11 +1,9 @@
|
|||
import { Button, Space, Switch } from 'antd';
|
||||
import cn from 'classnames';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import React from 'react';
|
||||
|
||||
import { HEATMAP, USER_PATH } from 'App/constants/card';
|
||||
import WidgetDateRange from "Components/Dashboard/components/WidgetDateRange/WidgetDateRange";
|
||||
import { useStore } from 'App/mstore';
|
||||
import ClickMapRagePicker from 'Components/Dashboard/components/ClickMapRagePicker';
|
||||
import { FUNNEL, TIMESERIES } from "App/constants/card";
|
||||
|
||||
import WidgetWrapper from '../WidgetWrapper';
|
||||
import WidgetOptions from 'Components/Dashboard/components/WidgetOptions';
|
||||
|
|
@ -18,101 +16,32 @@ interface Props {
|
|||
|
||||
function WidgetPreview(props: Props) {
|
||||
const { className = '' } = props;
|
||||
const { metricStore, dashboardStore } = useStore();
|
||||
const { metricStore } = useStore();
|
||||
const metric: any = metricStore.instance;
|
||||
|
||||
const hasGranularSettings = [TIMESERIES, FUNNEL].includes(metric.metricType)
|
||||
const hasGranularity = ['lineChart', 'barChart', 'areaChart'].includes(metric.viewType);
|
||||
const hasComparison = metric.metricType === FUNNEL || ['lineChart', 'barChart', 'table', 'progressChart', 'metric'].includes(metric.viewType);
|
||||
// [rangeStart, rangeEnd] or [period_name] -- have to check options
|
||||
const presetComparison = metric.compareTo;
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn(className, 'bg-white rounded-xl border shadow-sm mt-0')}
|
||||
>
|
||||
<div className="flex items-center justify-between px-4 pt-2">
|
||||
<h2 className="text-xl">{props.name}</h2>
|
||||
<div className="flex items-center">
|
||||
<div className="flex items-center gap-2 px-4 py-2 border-b">
|
||||
<WidgetDateRange
|
||||
label=""
|
||||
hasGranularSettings={hasGranularSettings}
|
||||
hasGranularity={hasGranularity}
|
||||
hasComparison={hasComparison}
|
||||
presetComparison={presetComparison}
|
||||
/>
|
||||
<div className="ml-auto">
|
||||
<WidgetOptions />
|
||||
{/*{metric.metricType === USER_PATH && (*/}
|
||||
{/* <a*/}
|
||||
{/* href="#"*/}
|
||||
{/* onClick={(e) => {*/}
|
||||
{/* e.preventDefault();*/}
|
||||
{/* metric.update({ hideExcess: !metric.hideExcess });*/}
|
||||
{/* }}*/}
|
||||
{/* >*/}
|
||||
{/* <Space>*/}
|
||||
{/* <Switch checked={metric.hideExcess} size="small" />*/}
|
||||
{/* <span className="mr-4 color-gray-medium">*/}
|
||||
{/* Hide Minor Paths*/}
|
||||
{/* </span>*/}
|
||||
{/* </Space>*/}
|
||||
{/* </a>*/}
|
||||
{/*)}*/}
|
||||
|
||||
|
||||
{/*{isTimeSeries && (*/}
|
||||
{/* <>*/}
|
||||
{/* <span className="mr-4 color-gray-medium">Visualization</span>*/}
|
||||
{/* <SegmentSelection*/}
|
||||
{/* name="viewType"*/}
|
||||
{/* className="my-3"*/}
|
||||
{/* primary*/}
|
||||
{/* size="small"*/}
|
||||
{/* onSelect={ changeViewType }*/}
|
||||
{/* value={{ value: metric.viewType }}*/}
|
||||
{/* list={ [*/}
|
||||
{/* { value: 'lineChart', name: 'Chart', icon: 'graph-up-arrow' },*/}
|
||||
{/* { value: 'progress', name: 'Progress', icon: 'hash' },*/}
|
||||
{/* ]}*/}
|
||||
{/* />*/}
|
||||
{/* </>*/}
|
||||
{/*)}*/}
|
||||
|
||||
{/*{!disableVisualization && isTable && (*/}
|
||||
{/* <>*/}
|
||||
{/* <span className="mr-4 color-gray-medium">Visualization</span>*/}
|
||||
{/* <SegmentSelection*/}
|
||||
{/* name="viewType"*/}
|
||||
{/* className="my-3"*/}
|
||||
{/* primary={true}*/}
|
||||
{/* size="small"*/}
|
||||
{/* onSelect={ changeViewType }*/}
|
||||
{/* value={{ value: metric.viewType }}*/}
|
||||
{/* list={[*/}
|
||||
{/* { value: 'table', name: 'Table', icon: 'table' },*/}
|
||||
{/* { value: 'pieChart', name: 'Chart', icon: 'pie-chart-fill' },*/}
|
||||
{/* ]}*/}
|
||||
{/* disabledMessage="Chart view is not supported"*/}
|
||||
{/* />*/}
|
||||
{/* </>*/}
|
||||
{/*)}*/}
|
||||
|
||||
{/*{isRetention && (*/}
|
||||
{/* <>*/}
|
||||
{/* <span className="mr-4 color-gray-medium">Visualization</span>*/}
|
||||
{/* <SegmentSelection*/}
|
||||
{/* name="viewType"*/}
|
||||
{/* className="my-3"*/}
|
||||
{/* primary={true}*/}
|
||||
{/* size="small"*/}
|
||||
{/* onSelect={ changeViewType }*/}
|
||||
{/* value={{ value: metric.viewType }}*/}
|
||||
{/* list={[*/}
|
||||
{/* { value: 'trend', name: 'Trend', icon: 'graph-up-arrow' },*/}
|
||||
{/* { value: 'cohort', name: 'Cohort', icon: 'dice-3' },*/}
|
||||
{/* ]}*/}
|
||||
{/* disabledMessage="Chart view is not supported"*/}
|
||||
{/* />*/}
|
||||
{/*</>*/}
|
||||
{/*)}*/}
|
||||
|
||||
|
||||
|
||||
{/* add to dashboard */}
|
||||
{/*{metric.exists() && (*/}
|
||||
{/* <AddToDashboardButton metricId={metric.metricId}/>*/}
|
||||
{/*)}*/}
|
||||
</div>
|
||||
</div>
|
||||
<div className="pt-0">
|
||||
<div className="py-4">
|
||||
<WidgetWrapper
|
||||
widget={metric}
|
||||
isPreview={true}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import { NoContent, Loader, Pagination, Button } from 'UI';
|
||||
import Select from 'Shared/Select';
|
||||
import { NoContent, Loader, Pagination } from 'UI';
|
||||
import {Button, Tag, Tooltip, Dropdown, notification} from 'antd';
|
||||
import {UndoOutlined, DownOutlined} from '@ant-design/icons'
|
||||
import cn from 'classnames';
|
||||
import { useStore } from 'App/mstore';
|
||||
import SessionItem from 'Shared/SessionItem';
|
||||
|
|
@ -11,20 +12,22 @@ import useIsMounted from 'App/hooks/useIsMounted';
|
|||
import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG';
|
||||
import { numberWithCommas } from 'App/utils';
|
||||
import { HEATMAP } from 'App/constants/card';
|
||||
import { Tag } from 'antd';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function WidgetSessions(props: Props) {
|
||||
const listRef = React.useRef<HTMLDivElement>(null);
|
||||
const { className = '' } = props;
|
||||
const [activeSeries, setActiveSeries] = useState('all');
|
||||
const [data, setData] = useState<any>([]);
|
||||
const isMounted = useIsMounted();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const filteredSessions = getListSessionsBySeries(data, activeSeries);
|
||||
// all filtering done through series now
|
||||
const filteredSessions = getListSessionsBySeries(data, 'all');
|
||||
const { dashboardStore, metricStore, sessionStore, customFieldStore } = useStore();
|
||||
const focusedSeries = metricStore.focusedSeriesName;
|
||||
const filter = dashboardStore.drillDownFilter;
|
||||
const widget = metricStore.instance;
|
||||
const startTime = DateTime.fromMillis(filter.startTimestamp).toFormat('LLL dd, yyyy HH:mm');
|
||||
|
|
@ -34,15 +37,23 @@ function WidgetSessions(props: Props) {
|
|||
const filterText = filter.filters.length > 0 ? filter.filters[0].value : '';
|
||||
const metaList = customFieldStore.list.map((i: any) => i.key);
|
||||
|
||||
const writeOption = ({ value }: any) => setActiveSeries(value.value);
|
||||
const seriesDropdownItems = seriesOptions.map((option) => ({
|
||||
key: option.value,
|
||||
label: (
|
||||
<div onClick={() => setActiveSeries(option.value)}>
|
||||
{option.label}
|
||||
</div>
|
||||
)
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) return;
|
||||
const seriesOptions = data.map((item: any) => ({
|
||||
label: item.seriesName,
|
||||
if (!widget.series) return;
|
||||
const seriesOptions = widget.series.map((item: any) => ({
|
||||
label: item.name,
|
||||
value: item.seriesId
|
||||
}));
|
||||
setSeriesOptions([{ label: 'All', value: 'all' }, ...seriesOptions]);
|
||||
}, [data]);
|
||||
}, [widget.series]);
|
||||
|
||||
const fetchSessions = (metricId: any, filter: any) => {
|
||||
if (!isMounted()) return;
|
||||
|
|
@ -52,6 +63,17 @@ function WidgetSessions(props: Props) {
|
|||
.fetchSessions(metricId, filter)
|
||||
.then((res: any) => {
|
||||
setData(res);
|
||||
if (metricStore.drillDown) {
|
||||
setTimeout(() => {
|
||||
notification.open({
|
||||
placement: 'top',
|
||||
role: 'status',
|
||||
message: 'Sessions Refreshed!'
|
||||
})
|
||||
listRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
metricStore.setDrillDown(false);
|
||||
}, 0)
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
|
|
@ -89,9 +111,10 @@ function WidgetSessions(props: Props) {
|
|||
};
|
||||
debounceClickMapSearch(customFilter);
|
||||
} else {
|
||||
const usedSeries = focusedSeries ? widget.series.filter((s) => s.name === focusedSeries) : widget.series;
|
||||
debounceRequest(widget.metricId, {
|
||||
...filter,
|
||||
series: widget.series.map((s) => s.toJson()),
|
||||
series: usedSeries.map((s) => s.toJson()),
|
||||
page: metricStore.sessionsPage,
|
||||
limit: metricStore.sessionsPageSize
|
||||
});
|
||||
|
|
@ -106,9 +129,23 @@ function WidgetSessions(props: Props) {
|
|||
filter.filters,
|
||||
depsString,
|
||||
metricStore.clickMapSearch,
|
||||
activeSeries
|
||||
focusedSeries
|
||||
]);
|
||||
useEffect(loadData, [metricStore.sessionsPage]);
|
||||
useEffect(() => {
|
||||
if (activeSeries === 'all') {
|
||||
metricStore.setFocusedSeriesName(null);
|
||||
} else {
|
||||
metricStore.setFocusedSeriesName(seriesOptions.find((option) => option.value === activeSeries)?.label, false);
|
||||
}
|
||||
}, [activeSeries])
|
||||
useEffect(() => {
|
||||
if (focusedSeries) {
|
||||
setActiveSeries(seriesOptions.find((option) => option.label === focusedSeries)?.value || 'all');
|
||||
} else {
|
||||
setActiveSeries('all');
|
||||
}
|
||||
}, [focusedSeries])
|
||||
|
||||
const clearFilters = () => {
|
||||
metricStore.updateKey('sessionsPage', 1);
|
||||
|
|
@ -116,34 +153,46 @@ function WidgetSessions(props: Props) {
|
|||
};
|
||||
|
||||
return (
|
||||
<div className={cn(className, 'bg-white p-3 pb-0 rounded-lg shadow-sm border mt-3')}>
|
||||
<div className={cn(className, 'bg-white p-3 pb-0 rounded-xl shadow-sm border mt-3')}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-baseline">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<h2 className="text-xl">{metricStore.clickMapSearch ? 'Clicks' : 'Sessions'}</h2>
|
||||
<div className="ml-2 color-gray-medium">
|
||||
{metricStore.clickMapLabel ? `on "${metricStore.clickMapLabel}" ` : null}
|
||||
between <span className="font-medium color-gray-darkest">{startTime}</span> and{' '}
|
||||
<span className="font-medium color-gray-darkest">{endTime}</span>{' '}
|
||||
</div>
|
||||
{hasFilters && <Tooltip title='Clear Drilldown' placement='top'><Button type='text' size='small' onClick={clearFilters}><UndoOutlined /></Button></Tooltip>}
|
||||
</div>
|
||||
|
||||
{hasFilters && widget.metricType === 'table' &&
|
||||
<div className="py-2"><Tag closable onClose={clearFilters}>{filterText}</Tag></div>}
|
||||
{hasFilters && widget.metricType === 'table' && <div className="py-2"><Tag closable onClose={clearFilters}>{filterText}</Tag></div>}
|
||||
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
{hasFilters && <Button variant="text-primary" onClick={clearFilters}>Clear Filters</Button>}
|
||||
{widget.metricType !== 'table' && widget.metricType !== HEATMAP && (
|
||||
<div className="flex items-center ml-6">
|
||||
<span className="mr-2 color-gray-medium">Filter by Series</span>
|
||||
<Select options={seriesOptions} defaultValue={'all'} onChange={writeOption} plain />
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: seriesDropdownItems,
|
||||
selectable: true,
|
||||
selectedKeys: [activeSeries]
|
||||
}}
|
||||
trigger={['click']}
|
||||
>
|
||||
<Button type="text" size='small'>
|
||||
{seriesOptions.find(option => option.value === activeSeries)?.label || 'Select Series'}
|
||||
<DownOutlined />
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3">
|
||||
<div className="mt-3" >
|
||||
<Loader loading={loading}>
|
||||
<NoContent
|
||||
title={
|
||||
|
|
@ -164,7 +213,7 @@ function WidgetSessions(props: Props) {
|
|||
</React.Fragment>
|
||||
))}
|
||||
|
||||
<div className="flex items-center justify-between p-5">
|
||||
<div className="flex items-center justify-between p-5" ref={listRef}>
|
||||
<div>
|
||||
Showing{' '}
|
||||
<span className="font-medium">
|
||||
|
|
|
|||
|
|
@ -1,98 +1,83 @@
|
|||
import {useHistory} from "react-router";
|
||||
import {useStore} from "App/mstore";
|
||||
import {useObserver} from "mobx-react-lite";
|
||||
import {Button, Dropdown, MenuProps, message, Modal} from "antd";
|
||||
import {BellIcon, EllipsisVertical, TrashIcon} from "lucide-react";
|
||||
import {toast} from "react-toastify";
|
||||
import React from "react";
|
||||
import {useModal} from "Components/ModalContext";
|
||||
import AlertFormModal from "Components/Alerts/AlertFormModal/AlertFormModal";
|
||||
import { useHistory } from 'react-router';
|
||||
import { useStore } from 'App/mstore';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { Button, Dropdown, MenuProps, Modal } from 'antd';
|
||||
import { BellIcon, EllipsisVertical, Grid2x2Plus, TrashIcon } from 'lucide-react';
|
||||
import { toast } from 'react-toastify';
|
||||
import React from 'react';
|
||||
import { useModal } from 'Components/ModalContext';
|
||||
import AlertFormModal from 'Components/Alerts/AlertFormModal/AlertFormModal';
|
||||
import { showAddToDashboardModal } from 'Components/Dashboard/components/AddToDashboardButton';
|
||||
|
||||
const CardViewMenu = () => {
|
||||
const history = useHistory();
|
||||
const {alertsStore, dashboardStore, metricStore} = useStore();
|
||||
const widget = useObserver(() => metricStore.instance);
|
||||
const {openModal, closeModal} = useModal();
|
||||
const { alertsStore, metricStore, dashboardStore } = useStore();
|
||||
const widget = metricStore.instance;
|
||||
const { openModal, closeModal } = useModal();
|
||||
|
||||
const showAlertModal = () => {
|
||||
const seriesId = widget.series[0] && widget.series[0].seriesId || '';
|
||||
alertsStore.init({query: {left: seriesId}})
|
||||
openModal(<AlertFormModal
|
||||
onClose={closeModal}
|
||||
/>, {
|
||||
// title: 'Set Alerts',
|
||||
const seriesId = (widget.series[0] && widget.series[0].seriesId) || '';
|
||||
alertsStore.init({ query: { left: seriesId } });
|
||||
openModal(<AlertFormModal onClose={closeModal} />, {
|
||||
placement: 'right',
|
||||
width: 620,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const items: MenuProps['items'] = [
|
||||
{
|
||||
key: 'add-to-dashboard',
|
||||
label: 'Add to Dashboard',
|
||||
icon: <Grid2x2Plus size={16} />,
|
||||
disabled: !widget.exists(),
|
||||
onClick: () => showAddToDashboardModal(widget.metricId, dashboardStore),
|
||||
},
|
||||
{
|
||||
key: 'alert',
|
||||
label: "Set Alerts",
|
||||
icon: <BellIcon size={16}/>,
|
||||
label: 'Set Alerts',
|
||||
icon: <BellIcon size={16} />,
|
||||
disabled: !widget.exists() || widget.metricType === 'predefined',
|
||||
onClick: showAlertModal,
|
||||
},
|
||||
{
|
||||
key: 'remove',
|
||||
label: 'Delete',
|
||||
icon: <TrashIcon size={16}/>,
|
||||
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}) => (
|
||||
content:
|
||||
'Are you sure you want to remove this card? This action is permanent and cannot be undone.',
|
||||
footer: (_, { OkBtn, CancelBtn }) => (
|
||||
<>
|
||||
<CancelBtn/>
|
||||
<OkBtn/>
|
||||
<CancelBtn />
|
||||
<OkBtn />
|
||||
</>
|
||||
),
|
||||
onOk: () => {
|
||||
metricStore.delete(widget).then(r => {
|
||||
metricStore
|
||||
.delete(widget)
|
||||
.then(() => {
|
||||
history.goBack();
|
||||
}).catch(() => {
|
||||
})
|
||||
.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 menu={{ items }}>
|
||||
<Button type='text' icon={<EllipsisVertical size={16} />} className='btn-card-options' />
|
||||
</Dropdown>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CardViewMenu;
|
||||
export default observer(CardViewMenu);
|
||||
|
|
@ -3,7 +3,7 @@ import { useStore } from 'App/mstore';
|
|||
import { Loader, NoContent } from 'UI';
|
||||
import WidgetPreview from '../WidgetPreview';
|
||||
import WidgetSessions from '../WidgetSessions';
|
||||
import { useObserver } from 'mobx-react-lite';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { dashboardMetricDetails, metricDetails, withSiteId } from 'App/routes';
|
||||
import Breadcrumb from 'Shared/Breadcrumb';
|
||||
import { FilterKey } from 'Types/filter/filterType';
|
||||
|
|
@ -16,14 +16,16 @@ import {
|
|||
FUNNEL,
|
||||
INSIGHTS,
|
||||
USER_PATH,
|
||||
RETENTION
|
||||
RETENTION,
|
||||
} from 'App/constants/card';
|
||||
import CardUserList from '../CardUserList/CardUserList';
|
||||
import WidgetViewHeader from 'Components/Dashboard/components/WidgetView/WidgetViewHeader';
|
||||
import WidgetFormNew from 'Components/Dashboard/components/WidgetForm/WidgetFormNew';
|
||||
import { Space } from 'antd';
|
||||
import { Space, Segmented, Tooltip } from 'antd';
|
||||
import { renderClickmapThumbnail } from 'Components/Dashboard/components/WidgetForm/renderMap';
|
||||
import Widget from 'App/mstore/types/widget';
|
||||
import { LayoutPanelTop, LayoutPanelLeft } from 'lucide-react';
|
||||
import cn from 'classnames'
|
||||
|
||||
interface Props {
|
||||
history: any;
|
||||
|
|
@ -31,19 +33,27 @@ interface Props {
|
|||
siteId: any;
|
||||
}
|
||||
|
||||
const LAYOUT_KEY = '$__layout__$'
|
||||
|
||||
function getDefaultState() {
|
||||
const layout = localStorage.getItem(LAYOUT_KEY)
|
||||
return layout || 'flex-row'
|
||||
}
|
||||
|
||||
function WidgetView(props: Props) {
|
||||
const [layout, setLayout] = useState(getDefaultState);
|
||||
const {
|
||||
match: {
|
||||
params: { siteId, dashboardId, metricId }
|
||||
}
|
||||
params: { siteId, dashboardId, metricId },
|
||||
},
|
||||
} = props;
|
||||
const { metricStore, dashboardStore } = useStore();
|
||||
const widget = useObserver(() => metricStore.instance);
|
||||
const loading = useObserver(() => metricStore.isLoading);
|
||||
const { metricStore, dashboardStore, settingsStore } = useStore();
|
||||
const widget = metricStore.instance;
|
||||
const loading = metricStore.isLoading;
|
||||
const [expanded, setExpanded] = useState(!metricId || metricId === 'create');
|
||||
const hasChanged = useObserver(() => widget.hasChanged);
|
||||
const dashboards = useObserver(() => dashboardStore.dashboards);
|
||||
const dashboard = useObserver(() => dashboards.find((d: any) => d.dashboardId == dashboardId));
|
||||
const hasChanged = widget.hasChanged;
|
||||
const dashboards = dashboardStore.dashboards;
|
||||
const dashboard = dashboards.find((d: any) => d.dashboardId == dashboardId);
|
||||
const dashboardName = dashboard ? dashboard.name : null;
|
||||
const [metricNotFound, setMetricNotFound] = useState(false);
|
||||
const history = useHistory();
|
||||
|
|
@ -58,8 +68,17 @@ function WidgetView(props: Props) {
|
|||
}
|
||||
});
|
||||
} else {
|
||||
if (!metricStore.instance) {
|
||||
metricStore.init();
|
||||
}
|
||||
}
|
||||
const wasCollapsed = settingsStore.menuCollapsed;
|
||||
settingsStore.updateMenuCollapsed(true)
|
||||
return () => {
|
||||
if (!wasCollapsed) {
|
||||
settingsStore.updateMenuCollapsed(false)
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const undoChanges = () => {
|
||||
|
|
@ -81,24 +100,37 @@ function WidgetView(props: Props) {
|
|||
if (wasCreating) {
|
||||
if (parseInt(dashboardId, 10) > 0) {
|
||||
history.replace(
|
||||
withSiteId(dashboardMetricDetails(dashboardId, savedMetric.metricId), siteId)
|
||||
withSiteId(
|
||||
dashboardMetricDetails(dashboardId, savedMetric.metricId),
|
||||
siteId
|
||||
)
|
||||
);
|
||||
void dashboardStore.addWidgetToDashboard(
|
||||
dashboardStore.getDashboard(parseInt(dashboardId, 10))!,
|
||||
[savedMetric.metricId]
|
||||
);
|
||||
} else {
|
||||
history.replace(withSiteId(metricDetails(savedMetric.metricId), siteId));
|
||||
history.replace(
|
||||
withSiteId(metricDetails(savedMetric.metricId), siteId)
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return useObserver(() => (
|
||||
const updateLayout = (layout: string) => {
|
||||
localStorage.setItem(LAYOUT_KEY, layout)
|
||||
setLayout(layout)
|
||||
}
|
||||
|
||||
return (
|
||||
<Loader loading={loading}>
|
||||
<Prompt
|
||||
when={hasChanged}
|
||||
message={(location: any) => {
|
||||
if (location.pathname.includes('/metrics/') || location.pathname.includes('/metric/')) {
|
||||
if (
|
||||
location.pathname.includes('/metrics/') ||
|
||||
location.pathname.includes('/metric/')
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return 'You have unsaved changes. Are you sure you want to leave?';
|
||||
|
|
@ -110,9 +142,11 @@ function WidgetView(props: Props) {
|
|||
items={[
|
||||
{
|
||||
label: dashboardName ? dashboardName : 'Cards',
|
||||
to: dashboardId ? withSiteId('/dashboard/' + dashboardId, siteId) : withSiteId('/metrics', siteId)
|
||||
to: dashboardId
|
||||
? withSiteId('/dashboard/' + dashboardId, siteId)
|
||||
: withSiteId('/metrics', siteId),
|
||||
},
|
||||
{ label: widget.name }
|
||||
{ label: widget.name },
|
||||
]}
|
||||
/>
|
||||
<NoContent
|
||||
|
|
@ -125,25 +159,70 @@ function WidgetView(props: Props) {
|
|||
}
|
||||
>
|
||||
<Space direction="vertical" className="w-full" size={14}>
|
||||
<WidgetViewHeader onSave={onSave} undoChanges={undoChanges} />
|
||||
<WidgetFormNew />
|
||||
<WidgetViewHeader
|
||||
onSave={onSave}
|
||||
undoChanges={undoChanges}
|
||||
layoutControl={
|
||||
<Segmented
|
||||
size='small'
|
||||
value={layout}
|
||||
onChange={updateLayout}
|
||||
options={[
|
||||
{
|
||||
value: 'flex-row',
|
||||
icon: (
|
||||
<Tooltip title="Horizontal Layout">
|
||||
<LayoutPanelLeft size={16} />
|
||||
</Tooltip>
|
||||
)
|
||||
},
|
||||
{
|
||||
value: 'flex-col',
|
||||
icon: (
|
||||
<Tooltip title="Vertical Layout">
|
||||
<LayoutPanelTop size={16} />
|
||||
</Tooltip>
|
||||
)
|
||||
},
|
||||
{
|
||||
value: 'flex-row-reverse',
|
||||
icon: (
|
||||
<Tooltip title="Reversed Horizontal Layout">
|
||||
<div className={'rotate-180'}><LayoutPanelLeft size={16} /></div>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
]}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<div className={cn('flex gap-4', layout)}>
|
||||
<div className={layout.startsWith('flex-row') ? 'w-1/3 ' : 'w-full'}>
|
||||
<WidgetFormNew layout={layout} />
|
||||
</div>
|
||||
<div className={layout.startsWith('flex-row') ? 'w-2/3' : 'w-full'}>
|
||||
<WidgetPreview name={widget.name} isEditing={expanded} />
|
||||
|
||||
{widget.metricOf !== FilterKey.SESSIONS && widget.metricOf !== FilterKey.ERRORS && (
|
||||
(widget.metricType === TABLE
|
||||
|| widget.metricType === TIMESERIES
|
||||
|| widget.metricType === HEATMAP
|
||||
|| widget.metricType === INSIGHTS
|
||||
|| widget.metricType === FUNNEL
|
||||
|| widget.metricType === USER_PATH) ?
|
||||
<WidgetSessions /> : null
|
||||
)}
|
||||
{widget.metricOf !== FilterKey.SESSIONS &&
|
||||
widget.metricOf !== FilterKey.ERRORS &&
|
||||
(widget.metricType === TABLE ||
|
||||
widget.metricType === TIMESERIES ||
|
||||
widget.metricType === HEATMAP ||
|
||||
widget.metricType === INSIGHTS ||
|
||||
widget.metricType === FUNNEL ||
|
||||
widget.metricType === USER_PATH ? (
|
||||
<WidgetSessions />
|
||||
) : null)}
|
||||
{widget.metricType === RETENTION && <CardUserList />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</Space>
|
||||
</NoContent>
|
||||
</div>
|
||||
</Loader>
|
||||
));
|
||||
);
|
||||
}
|
||||
|
||||
export default WidgetView;
|
||||
export default observer(WidgetView);
|
||||
|
|
|
|||
|
|
@ -1,49 +1,78 @@
|
|||
import React from 'react';
|
||||
import cn from "classnames";
|
||||
import WidgetName from "Components/Dashboard/components/WidgetName";
|
||||
import {useStore} from "App/mstore";
|
||||
import {useObserver} from "mobx-react-lite";
|
||||
import AddToDashboardButton from "Components/Dashboard/components/AddToDashboardButton";
|
||||
import WidgetDateRange from "Components/Dashboard/components/WidgetDateRange/WidgetDateRange";
|
||||
import {Button, Space} from "antd";
|
||||
import CardViewMenu from "Components/Dashboard/components/WidgetView/CardViewMenu";
|
||||
import cn from 'classnames';
|
||||
import WidgetName from 'Components/Dashboard/components/WidgetName';
|
||||
import { useStore } from 'App/mstore';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
|
||||
import { Button, Space, Tooltip } from 'antd';
|
||||
import CardViewMenu from 'Components/Dashboard/components/WidgetView/CardViewMenu';
|
||||
import { Link2 } from 'lucide-react'
|
||||
import copy from 'copy-to-clipboard';
|
||||
|
||||
interface Props {
|
||||
onClick?: () => void;
|
||||
onSave: () => void;
|
||||
undoChanges?: () => void;
|
||||
undoChanges: () => void;
|
||||
layoutControl?: React.ReactNode;
|
||||
}
|
||||
|
||||
function WidgetViewHeader({onClick, onSave, undoChanges}: Props) {
|
||||
const {metricStore, dashboardStore} = useStore();
|
||||
const widget = useObserver(() => metricStore.instance);
|
||||
const defaultText = 'Copy link to clipboard'
|
||||
|
||||
function WidgetViewHeader({ onClick, onSave, layoutControl }: Props) {
|
||||
const [tooltipText, setTooltipText] = React.useState(defaultText);
|
||||
const { metricStore } = useStore();
|
||||
const widget = metricStore.instance;
|
||||
|
||||
const handleSave = () => {
|
||||
onSave();
|
||||
};
|
||||
|
||||
const copyUrl = () => {
|
||||
const url = window.location.href;
|
||||
copy(url)
|
||||
setTooltipText('Link copied to clipboard!');
|
||||
setTimeout(() => setTooltipText(defaultText), 2000);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className={cn('flex justify-between items-center')}
|
||||
className={cn(
|
||||
'flex justify-between items-center bg-white rounded-lg shadow-sm px-4 ps-2 py-2 border border-gray-lighter input-card-title'
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<h1 className="mb-0 text-2xl mr-4 min-w-fit">
|
||||
<WidgetName name={widget.name}
|
||||
onUpdate={(name) => metricStore.merge({name})}
|
||||
<h1 className="mb-0 text-2xl mr-4 min-w-fit ">
|
||||
<WidgetName
|
||||
name={widget.name}
|
||||
onUpdate={(name) => {
|
||||
metricStore.merge({ name });
|
||||
}}
|
||||
canEdit={true}
|
||||
/>
|
||||
</h1>
|
||||
<Space>
|
||||
<WidgetDateRange label=""/>
|
||||
<AddToDashboardButton metricId={widget.metricId}/>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={onSave}
|
||||
type={
|
||||
metricStore.isSaving || (widget.exists() && !widget.hasChanged) ? 'text' : 'primary'
|
||||
}
|
||||
onClick={handleSave}
|
||||
loading={metricStore.isSaving}
|
||||
disabled={metricStore.isSaving || !widget.hasChanged}
|
||||
disabled={metricStore.isSaving || (widget.exists() && !widget.hasChanged)}
|
||||
className='font-medium btn-update-card'
|
||||
size='small'
|
||||
>
|
||||
Update
|
||||
{widget.exists() ? 'Update' : 'Create'}
|
||||
</Button>
|
||||
<CardViewMenu/>
|
||||
|
||||
{/* <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);
|
||||
|
|
|
|||
|
|
@ -12,16 +12,15 @@ interface Props {
|
|||
}
|
||||
|
||||
function AlertButton(props: Props) {
|
||||
const {seriesId} = props;
|
||||
const {dashboardStore, alertsStore} = useStore();
|
||||
const {openModal, closeModal} = useModal();
|
||||
const { seriesId, initAlert } = props;
|
||||
const { alertsStore } = useStore();
|
||||
const { openModal, closeModal } = useModal();
|
||||
const onClick = () => {
|
||||
// dashboardStore.toggleAlertModal(true);
|
||||
alertsStore.init({query: {left: seriesId}})
|
||||
initAlert?.();
|
||||
alertsStore.init({ query: { left: seriesId } })
|
||||
openModal(<AlertFormModal
|
||||
onClose={closeModal}
|
||||
/>, {
|
||||
// title: 'Set Alerts',
|
||||
placement: 'right',
|
||||
width: 620,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,12 +7,12 @@ import { useStore } from 'App/mstore';
|
|||
import { withRouter, RouteComponentProps } from 'react-router-dom';
|
||||
import { withSiteId, dashboardMetricDetails } from 'App/routes';
|
||||
import TemplateOverlay from './TemplateOverlay';
|
||||
import AlertButton from './AlertButton';
|
||||
import stl from './widgetWrapper.module.css';
|
||||
import { FilterKey } from 'App/types/filter/filterType';
|
||||
import { TIMESERIES } from "App/constants/card";
|
||||
import { TIMESERIES } from 'App/constants/card';
|
||||
|
||||
const WidgetChart = lazy(() => import('Components/Dashboard/components/WidgetChart'));
|
||||
const WidgetChart = lazy(
|
||||
() => import('Components/Dashboard/components/WidgetChart')
|
||||
);
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
|
|
@ -74,19 +74,26 @@ function WidgetWrapper(props: Props & RouteComponentProps) {
|
|||
});
|
||||
|
||||
const onDelete = async () => {
|
||||
dashboardStore.deleteDashboardWidget(dashboard?.dashboardId!, widget.widgetId);
|
||||
dashboardStore.deleteDashboardWidget(
|
||||
dashboard?.dashboardId!,
|
||||
widget.widgetId
|
||||
);
|
||||
};
|
||||
|
||||
const onChartClick = () => {
|
||||
if (!isSaved || isPredefined) return;
|
||||
|
||||
props.history.push(
|
||||
withSiteId(dashboardMetricDetails(dashboard?.dashboardId, widget.metricId), siteId)
|
||||
withSiteId(
|
||||
dashboardMetricDetails(dashboard?.dashboardId, widget.metricId),
|
||||
siteId
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const ref: any = useRef(null);
|
||||
const dragDropRef: any = dragRef(dropRef(ref));
|
||||
const dragDropRef: any = isPreview ? null : dragRef(dropRef(ref));
|
||||
|
||||
const addOverlay =
|
||||
isTemplate ||
|
||||
(!isPredefined &&
|
||||
|
|
@ -97,7 +104,7 @@ function WidgetWrapper(props: Props & RouteComponentProps) {
|
|||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'relative rounded bg-white border group rounded-lg',
|
||||
'relative bg-white border group rounded-lg',
|
||||
'col-span-' + widget.config.col,
|
||||
{ 'hover:shadow-border-gray': !isTemplate && isSaved },
|
||||
{ 'hover:shadow-border-main': isTemplate }
|
||||
|
|
@ -106,64 +113,30 @@ function WidgetWrapper(props: Props & RouteComponentProps) {
|
|||
userSelect: 'none',
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
borderColor:
|
||||
(canDrop && isOver) || active ? '#394EFF' : isPreview ? 'transparent' : '#EEEEEE',
|
||||
(canDrop && isOver) || active
|
||||
? '#394EFF'
|
||||
: isPreview
|
||||
? 'transparent'
|
||||
: '#EEEEEE',
|
||||
}}
|
||||
ref={dragDropRef}
|
||||
onClick={props.onClick ? props.onClick : () => {}}
|
||||
id={`widget-${widget.widgetId}`}
|
||||
id={`widget-${widget.metricId}`}
|
||||
>
|
||||
{!isTemplate && isSaved && isPredefined && (
|
||||
<div
|
||||
className={cn(
|
||||
stl.drillDownMessage,
|
||||
'disabled text-gray text-sm invisible group-hover:visible'
|
||||
{addOverlay && (
|
||||
<TemplateOverlay onClick={onChartClick} isTemplate={isTemplate} />
|
||||
)}
|
||||
>
|
||||
{'Cannot drill down system provided metrics'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{addOverlay && <TemplateOverlay onClick={onChartClick} isTemplate={isTemplate} />}
|
||||
{!props.hideName ? (
|
||||
<div
|
||||
className={cn('p-3 pb-4 flex items-center justify-between', {
|
||||
'cursor-move': !isTemplate && isSaved,
|
||||
})}
|
||||
>
|
||||
{!props.hideName ? (
|
||||
<div className="capitalize-first w-full font-medium">
|
||||
<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
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ interface Props {
|
|||
}
|
||||
|
||||
function WidgetWrapperNew(props: Props & RouteComponentProps) {
|
||||
const { dashboardStore } = useStore();
|
||||
const { dashboardStore, metricStore } = useStore();
|
||||
const {
|
||||
isWidget = false,
|
||||
active = false,
|
||||
|
|
@ -94,11 +94,13 @@ function WidgetWrapperNew(props: Props & RouteComponentProps) {
|
|||
widget.metricOf !== FilterKey.ERRORS &&
|
||||
widget.metricOf !== FilterKey.SESSIONS);
|
||||
|
||||
const beforeAlertInit = () => {
|
||||
metricStore.init(widget)
|
||||
}
|
||||
return (
|
||||
<Card
|
||||
className={cn(
|
||||
'relative group rounded-lg hover:border-teal transition-all duration-200',
|
||||
'col-span-' + widget.config.col,
|
||||
'relative group rounded-lg hover:border-teal transition-all duration-200 w-full',
|
||||
{ 'hover:shadow-sm': !isTemplate && isWidget },
|
||||
)}
|
||||
style={{
|
||||
|
|
@ -109,12 +111,12 @@ function WidgetWrapperNew(props: Props & RouteComponentProps) {
|
|||
}}
|
||||
ref={dragDropRef}
|
||||
onClick={props.onClick ? props.onClick : () => null}
|
||||
id={`widget-${widget.widgetId}`}
|
||||
id={`widget-${widget.metricId}`}
|
||||
title={!props.hideName ? widget.name : null}
|
||||
extra={[
|
||||
<div className="flex items-center" id="no-print">
|
||||
{!isPredefined && isTimeSeries && !isGridView && (
|
||||
<AlertButton seriesId={widget.series[0] && widget.series[0].seriesId} />
|
||||
<AlertButton initAlert={beforeAlertInit} seriesId={widget.series[0] && widget.series[0].seriesId} />
|
||||
)}
|
||||
|
||||
{showMenu && (
|
||||
|
|
|
|||
|
|
@ -4,77 +4,149 @@ import FunnelStepText from './FunnelStepText';
|
|||
import { Icon } from 'UI';
|
||||
import { Space } from 'antd';
|
||||
import { Styles } from 'Components/Dashboard/Widgets/common';
|
||||
import cn from 'classnames';
|
||||
|
||||
interface Props {
|
||||
filter: any;
|
||||
compData?: any;
|
||||
index?: number;
|
||||
focusStage?: (index: number, isFocused: boolean) => void;
|
||||
focusedFilter?: number | null;
|
||||
metricLabel?: string;
|
||||
isHorizontal?: boolean;
|
||||
}
|
||||
|
||||
function FunnelBar(props: Props) {
|
||||
const { filter, index, focusStage, focusedFilter, metricLabel = 'Sessions' } = props;
|
||||
const { filter, index, focusStage, focusedFilter, compData, isHorizontal } = props;
|
||||
|
||||
const isFocused = focusedFilter && index ? focusedFilter === index - 1 : false;
|
||||
const isFocused =
|
||||
focusedFilter && index ? focusedFilter === index - 1 : false;
|
||||
return (
|
||||
<div className="w-full mb-4">
|
||||
<FunnelStepText filter={filter} />
|
||||
<div className="w-full mb-2">
|
||||
<FunnelStepText filter={filter} isHorizontal={isHorizontal} />
|
||||
<div className={isHorizontal ? 'flex gap-1' : 'flex flex-col'}>
|
||||
<FunnelBarData
|
||||
data={props.filter}
|
||||
isHorizontal={isHorizontal}
|
||||
isComp={false}
|
||||
index={index}
|
||||
isFocused={isFocused}
|
||||
focusStage={focusStage}
|
||||
/>
|
||||
{compData ? (
|
||||
<FunnelBarData
|
||||
data={props.compData}
|
||||
isHorizontal={isHorizontal}
|
||||
isComp
|
||||
index={index}
|
||||
isFocused={isFocused}
|
||||
focusStage={focusStage}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FunnelBarData({
|
||||
data,
|
||||
isComp,
|
||||
isFocused,
|
||||
focusStage,
|
||||
index,
|
||||
isHorizontal,
|
||||
}: {
|
||||
data: any;
|
||||
isComp?: boolean;
|
||||
isFocused?: boolean;
|
||||
focusStage?: (index: number, isComparison: boolean) => void;
|
||||
index?: number;
|
||||
isHorizontal?: boolean;
|
||||
}) {
|
||||
const vertFillBarStyle = {
|
||||
width: `${data.completedPercentageTotal}%`,
|
||||
height: '100%',
|
||||
backgroundColor: isComp ? Styles.compareColors[2] : Styles.compareColors[1],
|
||||
};
|
||||
const horizontalFillBarStyle = {
|
||||
width: '100%',
|
||||
height: `${data.completedPercentageTotal}%`,
|
||||
backgroundColor: isComp ? Styles.compareColors[2] : Styles.compareColors[1],
|
||||
}
|
||||
|
||||
const vertEmptyBarStyle = {
|
||||
width: `${100.1 - data.completedPercentageTotal}%`,
|
||||
height: '100%',
|
||||
background: isFocused
|
||||
? 'rgba(204, 0, 0, 0.3)'
|
||||
: 'repeating-linear-gradient(325deg, lightgray, lightgray 1px, #FFF1F0 1px, #FFF1F0 6px)',
|
||||
cursor: 'pointer',
|
||||
}
|
||||
const horizontalEmptyBarStyle = {
|
||||
height: `${100.1 - data.completedPercentageTotal}%`,
|
||||
width: '100%',
|
||||
background: isFocused
|
||||
? 'rgba(204, 0, 0, 0.3)'
|
||||
: 'repeating-linear-gradient(325deg, lightgray, lightgray 1px, #FFF1F0 1px, #FFF1F0 6px)',
|
||||
cursor: 'pointer',
|
||||
}
|
||||
|
||||
const fillBarStyle = isHorizontal ? horizontalFillBarStyle : vertFillBarStyle;
|
||||
const emptyBarStyle = isHorizontal ? horizontalEmptyBarStyle : vertEmptyBarStyle
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className={isHorizontal ? 'rounded-t' : ''}
|
||||
style={{
|
||||
height: '25px',
|
||||
width: '99.8%',
|
||||
height: isHorizontal ? '210px' : '21px',
|
||||
width: isHorizontal ? '200px' : '99.8%',
|
||||
backgroundColor: '#f5f5f5',
|
||||
position: 'relative',
|
||||
borderRadius: '.5rem',
|
||||
overflow: 'hidden'
|
||||
borderRadius: isHorizontal ? undefined : '.5rem',
|
||||
overflow: 'hidden',
|
||||
opacity: isComp ? 0.7 : 1,
|
||||
display: 'flex',
|
||||
flexDirection: isHorizontal ? 'column-reverse' : 'row',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex items-center"
|
||||
style={{
|
||||
width: `${filter.completedPercentageTotal}%`,
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: Styles.compareColors[1]
|
||||
}}
|
||||
className={cn("flex", isHorizontal ? 'justify-center items-start pt-1' : 'justify-end items-center pr-1')}
|
||||
style={fillBarStyle}
|
||||
>
|
||||
<div className="color-white absolute right-0 flex items-center font-medium mr-2 leading-3">
|
||||
{filter.completedPercentageTotal}%
|
||||
<div className="color-white flex items-center font-medium leading-3">
|
||||
{data.completedPercentageTotal}%
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
width: `${100.1 - filter.completedPercentageTotal}%`,
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: isFocused ? 'rgba(204, 0, 0, 0.3)' : '#fff0f0',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
onClick={() => focusStage?.(index! - 1, filter.isActive)}
|
||||
className={'hover:opacity-75'}
|
||||
style={emptyBarStyle}
|
||||
onClick={() => focusStage?.(index! - 1, isComp)}
|
||||
className={'hover:opacity-70'}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between py-2">
|
||||
<div
|
||||
className={cn('flex justify-between', isComp ? 'opacity-60' : '')}
|
||||
>
|
||||
{/* @ts-ignore */}
|
||||
<div className="flex items-center">
|
||||
<Icon name="arrow-right-short" size="20" color="green" />
|
||||
<span className="mx-1">{filter.count} {metricLabel}</span>
|
||||
<span className="color-gray-medium text-sm">
|
||||
({filter.completedPercentage}%) Completed
|
||||
{`${data.completedPercentage}% . ${data.count}`}
|
||||
</span>
|
||||
</div>
|
||||
{index && index > 1 && (
|
||||
<Space className="items-center">
|
||||
<Icon name="caret-down-fill" color={filter.droppedCount > 0 ? 'red' : 'gray-light'} size={16} />
|
||||
<Icon
|
||||
name="caret-down-fill"
|
||||
color={data.droppedCount > 0 ? 'red' : 'gray-light'}
|
||||
size={16}
|
||||
/>
|
||||
<span
|
||||
className={'mx-1 ' + (filter.droppedCount > 0 ? 'color-red' : 'disabled')}>{filter.droppedCount} {metricLabel}</span>
|
||||
<span
|
||||
className={'text-sm ' + (filter.droppedCount > 0 ? 'color-red' : 'disabled')}>({filter.droppedPercentage}%) Dropped</span>
|
||||
className={
|
||||
'mr-1 text-sm' + (data.droppedCount > 0 ? 'color-red' : 'disabled')
|
||||
}
|
||||
>
|
||||
{data.droppedCount} Skipped
|
||||
</span>
|
||||
</Space>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -86,7 +158,7 @@ export function UxTFunnelBar(props: Props) {
|
|||
const { filter } = props;
|
||||
|
||||
return (
|
||||
<div className="w-full mb-4">
|
||||
<div className="w-full mb-2">
|
||||
<div className={'font-medium'}>{filter.title}</div>
|
||||
<div
|
||||
style={{
|
||||
|
|
@ -95,22 +167,28 @@ export function UxTFunnelBar(props: Props) {
|
|||
backgroundColor: '#f5f5f5',
|
||||
position: 'relative',
|
||||
borderRadius: '.5rem',
|
||||
overflow: 'hidden'
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex items-center"
|
||||
style={{
|
||||
width: `${(filter.completed / (filter.completed + filter.skipped)) * 100}%`,
|
||||
width: `${
|
||||
(filter.completed / (filter.completed + filter.skipped)) * 100
|
||||
}%`,
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: '#6272FF'
|
||||
backgroundColor: '#6272FF',
|
||||
}}
|
||||
>
|
||||
<div className="color-white absolute right-0 flex items-center font-medium mr-2 leading-3">
|
||||
{((filter.completed / (filter.completed + filter.skipped)) * 100).toFixed(1)}%
|
||||
<div className="color-white absolute right-0 flex items-center font-medium mr-1 leading-3 text-sm">
|
||||
{(
|
||||
(filter.completed / (filter.completed + filter.skipped)) *
|
||||
100
|
||||
).toFixed(1)}
|
||||
%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -119,22 +197,22 @@ export function UxTFunnelBar(props: Props) {
|
|||
<div className={'flex items-center gap-4'}>
|
||||
<div className="flex items-center">
|
||||
<Icon name="arrow-right-short" size="20" color="green" />
|
||||
<span className="mx-1 font-medium">{filter.completed}</span><span>completed this step</span>
|
||||
<span className="mx-1 font-medium">{filter.completed}</span>
|
||||
<span>completed this step</span>
|
||||
</div>
|
||||
<div className={'flex items-center'}>
|
||||
<Icon name="clock" size="16" />
|
||||
<span className="mx-1 font-medium">
|
||||
{durationFormatted(filter.avgCompletionTime)}
|
||||
</span>
|
||||
<span>
|
||||
avg. completion time
|
||||
</span>
|
||||
<span>avg. completion time</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* @ts-ignore */}
|
||||
<div className="flex items-center">
|
||||
<Icon name="caret-down-fill" color="red" size={16} />
|
||||
<span className="font-medium mx-1">{filter.skipped}</span><span> skipped</span>
|
||||
<span className="font-medium mx-1">{filter.skipped}</span>
|
||||
<span> skipped</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -142,11 +220,3 @@ export function UxTFunnelBar(props: Props) {
|
|||
}
|
||||
|
||||
export default FunnelBar;
|
||||
|
||||
const calculatePercentage = (completed: number, dropped: number) => {
|
||||
const total = completed + dropped;
|
||||
if (dropped === 0) return 100;
|
||||
if (total === 0) return 0;
|
||||
|
||||
return Math.round((completed / dropped) * 100);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,12 +2,14 @@ import React from 'react';
|
|||
|
||||
interface Props {
|
||||
filter: any;
|
||||
isHorizontal?: boolean;
|
||||
}
|
||||
function FunnelStepText(props: Props) {
|
||||
const { filter } = props;
|
||||
const total = filter.value.length;
|
||||
const additionalStyle = props.isHorizontal ? { whiteSpace: 'nowrap', maxWidth: 210, textOverflow: 'ellipsis', overflow: 'hidden' } : {};
|
||||
return (
|
||||
<div className="mb-2 color-gray-medium">
|
||||
<div className="color-gray-medium" style={additionalStyle}>
|
||||
<span className="color-gray-darkest">{filter.label}</span>
|
||||
<span className="mx-1">{filter.operator}</span>
|
||||
{filter.value.map((value: any, index: number) => (
|
||||
|
|
|
|||
128
frontend/app/components/Funnels/FunnelWidget/FunnelTable.tsx
Normal file
128
frontend/app/components/Funnels/FunnelWidget/FunnelTable.tsx
Normal 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;
|
||||
|
|
@ -1,23 +1,3 @@
|
|||
.step {
|
||||
/* display: flex; */
|
||||
position: relative;
|
||||
transition: all 0.5s ease;
|
||||
&:before {
|
||||
content: '';
|
||||
border-left: 2px solid $gray-lightest;
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
bottom: 9px;
|
||||
left: 10px;
|
||||
/* width: 1px; */
|
||||
height: 100%;
|
||||
z-index: 0;
|
||||
}
|
||||
&:last-child:before {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.step-disabled {
|
||||
filter: grayscale(1);
|
||||
opacity: 0.8;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import Widget from 'App/mstore/types/widget';
|
||||
import Funnelbar, { UxTFunnelBar } from './FunnelBar';
|
||||
import Funnelbar, { UxTFunnelBar } from "./FunnelBar";
|
||||
import Funnel from 'App/mstore/types/funnel'
|
||||
import cn from 'classnames';
|
||||
import stl from './FunnelWidget.module.css';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
|
|
@ -13,13 +14,14 @@ import Filter from '@/mstore/types/filter';
|
|||
interface Props {
|
||||
metric?: Widget;
|
||||
isWidget?: boolean;
|
||||
data: any;
|
||||
data: { funnel: Funnel };
|
||||
compData: { funnel: Funnel };
|
||||
}
|
||||
|
||||
function FunnelWidget(props: Props) {
|
||||
const { dashboardStore, searchStore } = useStore();
|
||||
const [focusedFilter, setFocusedFilter] = React.useState<number | null>(null);
|
||||
const { isWidget = false, data, metric } = props;
|
||||
const { isWidget = false, data, metric, compData } = props;
|
||||
const funnel = data.funnel || { stages: [] };
|
||||
const totalSteps = funnel.stages.length;
|
||||
const stages = isWidget ? [...funnel.stages.slice(0, 1), funnel.stages[funnel.stages.length - 1]] : funnel.stages;
|
||||
|
|
@ -30,11 +32,12 @@ function FunnelWidget(props: Props) {
|
|||
const metricLabel = metric?.metricFormat == 'userCount' ? 'Users' : 'Sessions';
|
||||
const drillDownFilter = dashboardStore.drillDownFilter;
|
||||
const drillDownPeriod = dashboardStore.drillDownPeriod;
|
||||
const comparisonPeriod = metric ? dashboardStore.comparisonPeriods[metric.metricId] : undefined
|
||||
const metricFilters = metric?.series[0]?.filter.filters || [];
|
||||
|
||||
const applyDrillDown = (index: number) => {
|
||||
const applyDrillDown = (index: number, isComp?: boolean) => {
|
||||
const filter = new Filter().fromData({ filters: metricFilters.slice(0, index + 1) });
|
||||
const periodTimestamps = drillDownPeriod.toTimestamps();
|
||||
const periodTimestamps = isComp && index > -1 ? comparisonPeriod.toTimestamps() : drillDownPeriod.toTimestamps();
|
||||
drillDownFilter.merge({
|
||||
filters: filter.toJson().filters,
|
||||
startTimestamp: periodTimestamps.startTimestamp,
|
||||
|
|
@ -49,7 +52,7 @@ function FunnelWidget(props: Props) {
|
|||
};
|
||||
}, []);
|
||||
|
||||
const focusStage = (index: number) => {
|
||||
const focusStage = (index: number, isComp?: boolean) => {
|
||||
funnel.stages.forEach((s, i) => {
|
||||
// turning on all filters if one was focused already
|
||||
if (focusedFilter === index) {
|
||||
|
|
@ -65,9 +68,25 @@ function FunnelWidget(props: Props) {
|
|||
}
|
||||
});
|
||||
|
||||
applyDrillDown(focusedFilter === index ? -1 : index);
|
||||
applyDrillDown(focusedFilter === index ? -1 : index, isComp);
|
||||
};
|
||||
|
||||
const shownStages = React.useMemo(() => {
|
||||
const stages: { data: Funnel['stages'][0], compData?: Funnel['stages'][0] }[] = [];
|
||||
for (let i = 0; i < funnel.stages.length; i++) {
|
||||
const stage: any = { data: funnel.stages[i], compData: undefined }
|
||||
const compStage = compData?.funnel.stages[i];
|
||||
if (compStage) {
|
||||
stage.compData = compStage;
|
||||
}
|
||||
stages.push(stage)
|
||||
}
|
||||
|
||||
return stages;
|
||||
}, [data, compData])
|
||||
|
||||
const viewType = metric?.viewType;
|
||||
const isHorizontal = viewType === 'columnChart';
|
||||
return (
|
||||
<NoContent
|
||||
style={{ minHeight: 220 }}
|
||||
|
|
@ -79,20 +98,21 @@ function FunnelWidget(props: Props) {
|
|||
}
|
||||
show={!stages || stages.length === 0}
|
||||
>
|
||||
<div className="w-full">
|
||||
{!isWidget && (
|
||||
stages.map((filter: any, index: any) => (
|
||||
<div className={cn('w-full border-b -mx-4 px-4', isHorizontal ? 'overflow-x-scroll custom-scrollbar flex gap-2 justify-around' : '')}>
|
||||
{!isWidget &&
|
||||
shownStages.map((stage: any, index: any) => (
|
||||
<Stage
|
||||
key={index}
|
||||
isHorizontal={isHorizontal}
|
||||
index={index + 1}
|
||||
isWidget={isWidget}
|
||||
stage={filter}
|
||||
stage={stage.data}
|
||||
compData={stage.compData}
|
||||
focusStage={focusStage}
|
||||
focusedFilter={focusedFilter}
|
||||
metricLabel={metricLabel}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
))}
|
||||
|
||||
{isWidget && (
|
||||
<>
|
||||
|
|
@ -110,38 +130,56 @@ function FunnelWidget(props: Props) {
|
|||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center pb-4">
|
||||
<div className="flex items-center">
|
||||
<span className="text-base font-medium mr-2">Lost conversion</span>
|
||||
<Tooltip title={`${funnel.lostConversions} Sessions ${funnel.lostConversionsPercentage}%`}>
|
||||
<Tag bordered={false} color="red" className="text-lg font-medium rounded-lg">
|
||||
{funnel.lostConversions}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="mx-3" />
|
||||
<div className="flex items-center py-2 gap-2">
|
||||
<div className="flex items-center">
|
||||
<span className="text-base font-medium mr-2">Total conversion</span>
|
||||
<Tooltip title={`${funnel.totalConversions} Sessions ${funnel.totalConversionsPercentage}%`}>
|
||||
<Tag bordered={false} color="green" className="text-lg font-medium rounded-lg">
|
||||
<Tooltip
|
||||
title={`${funnel.totalConversions} Sessions ${funnel.totalConversionsPercentage}%`}
|
||||
>
|
||||
<Tag
|
||||
bordered={false}
|
||||
color="#F5F8FF"
|
||||
className="text-lg rounded-lg !text-black"
|
||||
>
|
||||
{funnel.totalConversions}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<span className="text-base font-medium mr-2">Lost conversion</span>
|
||||
<Tooltip
|
||||
title={`${funnel.lostConversions} Sessions ${funnel.lostConversionsPercentage}%`}
|
||||
>
|
||||
<Tag
|
||||
bordered={false}
|
||||
color="#FFEFEF"
|
||||
className="text-lg rounded-lg !text-black"
|
||||
>
|
||||
{funnel.lostConversions}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{funnel.totalDropDueToIssues > 0 && <div className="flex items-center mb-2"><Icon name="magic" /> <span
|
||||
className="ml-2">{funnel.totalDropDueToIssues} sessions dropped due to issues.</span></div>}
|
||||
</div>
|
||||
{funnel.totalDropDueToIssues > 0 && (
|
||||
<div className="flex items-center mb-2">
|
||||
<Icon name="magic" />{' '}
|
||||
<span className="ml-2">
|
||||
{funnel.totalDropDueToIssues} sessions dropped due to issues.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</NoContent>
|
||||
);
|
||||
}
|
||||
|
||||
export const EmptyStage = observer(({ total }: any) => {
|
||||
return (
|
||||
<div className={cn('flex items-center mb-4 pb-3', stl.step)}>
|
||||
<div className={cn('flex items-center mb-4 pb-3 relative border-b -mx-4 px-4 pt-2')}>
|
||||
<IndexNumber index={0} />
|
||||
<div
|
||||
className="w-fit px-2 border border-teal py-1 text-center justify-center bg-teal-lightest flex items-center rounded-full color-teal"
|
||||
style={{ width: '100px' }}>
|
||||
style={{ width: '100px' }}
|
||||
>
|
||||
{`+${total} ${total > 1 ? 'steps' : 'step'}`}
|
||||
</div>
|
||||
<div className="border-b w-full border-dashed"></div>
|
||||
|
|
@ -149,39 +187,35 @@ export const EmptyStage = observer(({ total }: any) => {
|
|||
);
|
||||
});
|
||||
|
||||
export const Stage = observer(({ metricLabel, stage, index, isWidget, uxt, focusStage, focusedFilter }: any) => {
|
||||
export const Stage = observer(({
|
||||
metricLabel,
|
||||
stage,
|
||||
index,
|
||||
uxt,
|
||||
focusStage,
|
||||
focusedFilter,
|
||||
compData,
|
||||
isHorizontal,
|
||||
}: any) => {
|
||||
return stage ? (
|
||||
<div
|
||||
className={cn('flex items-start', stl.step, { [stl['step-disabled']]: !stage.isActive })}
|
||||
className={cn(
|
||||
'flex items-start relative pt-2',
|
||||
{ [stl['step-disabled']]: !stage.isActive },
|
||||
)}
|
||||
>
|
||||
<IndexNumber index={index} />
|
||||
{!uxt ? <Funnelbar metricLabel={metricLabel} index={index} filter={stage} focusStage={focusStage}
|
||||
focusedFilter={focusedFilter} /> : <UxTFunnelBar filter={stage} />}
|
||||
{/*{!isWidget && !uxt && <BarActions bar={stage} />}*/}
|
||||
{!uxt ? <Funnelbar isHorizontal={isHorizontal} compData={compData} metricLabel={metricLabel} index={index} filter={stage} focusStage={focusStage} focusedFilter={focusedFilter} /> : <UxTFunnelBar filter={stage} />}
|
||||
</div>
|
||||
) : (
|
||||
<></>
|
||||
);
|
||||
});
|
||||
) : null
|
||||
})
|
||||
|
||||
export const IndexNumber = observer(({ index }: any) => {
|
||||
return (
|
||||
<div
|
||||
className="z-10 w-6 h-6 border shrink-0 mr-4 text-sm rounded-full bg-gray-lightest flex items-center justify-center leading-3">
|
||||
<div className="z-10 w-6 h-6 border shrink-0 mr-4 text-sm rounded-full bg-gray-lightest flex items-center justify-center leading-3">
|
||||
{index === 0 ? <Icon size="14" color="gray-dark" name="list" /> : index}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
const BarActions = observer(({ bar }: any) => {
|
||||
return (
|
||||
<div className="self-end flex items-center justify-center ml-4" style={{ marginBottom: '49px' }}>
|
||||
<button onClick={() => bar.updateKey('isActive', !bar.isActive)}>
|
||||
<Icon name="eye-slash-fill" color={bar.isActive ? 'gray-light' : 'gray-darkest'} size="22" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
})
|
||||
|
||||
export default observer(FunnelWidget);
|
||||
|
|
|
|||
|
|
@ -17,7 +17,10 @@ function HealthModal({
|
|||
}: {
|
||||
getHealth: () => void;
|
||||
isLoading: boolean;
|
||||
healthResponse: { overallHealth: boolean; healthMap: Record<string, IServiceStats> };
|
||||
healthResponse: {
|
||||
overallHealth: boolean;
|
||||
healthMap: Record<string, IServiceStats>;
|
||||
};
|
||||
setShowModal: (isOpen: boolean) => void;
|
||||
setPassed?: () => void;
|
||||
}) {
|
||||
|
|
@ -39,7 +42,7 @@ function HealthModal({
|
|||
setShowModal(false);
|
||||
};
|
||||
|
||||
const isSetup = document.location.pathname.includes('/signup')
|
||||
const isSetup = document.location.pathname.includes('/signup');
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -64,7 +67,9 @@ function HealthModal({
|
|||
transform: 'translate(-50%, -50%)',
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className={'flex flex-col bg-white rounded border border-figmaColors-divider'}
|
||||
className={
|
||||
'flex flex-col bg-white rounded border border-figmaColors-divider'
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
|
|
@ -83,14 +88,19 @@ function HealthModal({
|
|||
</div>
|
||||
|
||||
<Loader loading={isLoading}>
|
||||
{healthResponse ? (
|
||||
<>
|
||||
<div className={'flex w-full'}>
|
||||
<div className={'flex flex-col h-full'} style={{ flex: 1 }}>
|
||||
{isLoading ? null
|
||||
{isLoading
|
||||
? null
|
||||
: Object.keys(healthResponse.healthMap).map((service) => (
|
||||
<React.Fragment key={service}>
|
||||
<Category
|
||||
onClick={() => setSelectedService(service)}
|
||||
healthOk={healthResponse.healthMap[service].healthOk}
|
||||
healthOk={
|
||||
healthResponse.healthMap[service].healthOk
|
||||
}
|
||||
name={healthResponse.healthMap[service].name}
|
||||
isSelectable
|
||||
isSelected={selectedService === service}
|
||||
|
|
@ -105,13 +115,20 @@ function HealthModal({
|
|||
style={{ flex: 2, height: 420 }}
|
||||
>
|
||||
{isLoading ? null : selectedService ? (
|
||||
<ServiceStatus service={healthResponse.healthMap[selectedService]} />
|
||||
) : <img src={slide} width={392} />
|
||||
}
|
||||
<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'}>
|
||||
<div
|
||||
className={
|
||||
'p-4 mt-auto w-full border-t border-figmaColors-divider'
|
||||
}
|
||||
>
|
||||
<Button
|
||||
disabled={!healthResponse?.overallHealth}
|
||||
loading={isLoading}
|
||||
|
|
@ -123,6 +140,12 @@ function HealthModal({
|
|||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<div className={'w-full h-full flex items-center justify-center'}>
|
||||
<div>Error while fetching data...</div>
|
||||
</div>
|
||||
)}
|
||||
</Loader>
|
||||
<Footer isSetup={isSetup} />
|
||||
</div>
|
||||
|
|
@ -137,7 +160,10 @@ function ServiceStatus({ service }: { service: Record<string, any> }) {
|
|||
<div className={'border rounded border-light-gray'}>
|
||||
{Object.keys(subservices).map((subservice: string) => (
|
||||
<React.Fragment key={subservice}>
|
||||
<SubserviceHealth name={subservice} subservice={subservices[subservice]} />
|
||||
<SubserviceHealth
|
||||
name={subservice}
|
||||
subservice={subservices[subservice]}
|
||||
/>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -315,6 +315,7 @@ function PanelComponent({
|
|||
/>
|
||||
{summaryChecked ? (
|
||||
<Segmented
|
||||
size='small'
|
||||
value={zoomTab}
|
||||
onChange={(val) => setZoomTab(val)}
|
||||
options={[
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ const PerformanceGraph = React.memo((props: Props) => {
|
|||
{disabled ? (
|
||||
<div
|
||||
className={
|
||||
'flex justify-center'
|
||||
'flex justify-start'
|
||||
}
|
||||
>
|
||||
<div className={'text-xs text-neutral-400 ps-2'}>
|
||||
|
|
|
|||
|
|
@ -158,7 +158,7 @@ function GroupedIssue({
|
|||
onClick={createEventClickHandler(pointer, type)}
|
||||
className={'flex items-center gap-2 mb-1 cursor-pointer border-b border-transparent hover:border-gray-lightest'}
|
||||
>
|
||||
<div className={'text-disabled-text'}>@{shortDurationFromMs(pointer.time)}</div>
|
||||
<div className={'text-secondary'}>@{shortDurationFromMs(pointer.time)}</div>
|
||||
<RenderLineData type={type} item={pointer} />
|
||||
</div>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { KEYS } from 'Types/filter/customFilter';
|
|||
import { capitalize } from 'App/utils';
|
||||
import { useStore } from 'App/mstore';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import AssistSearchField from 'App/components/Assist/AssistSearchField';
|
||||
import AssistSearchField from 'App/components/Assist/AssistSearchActions';
|
||||
import LiveSessionSearch from 'Shared/LiveSessionSearch';
|
||||
import cn from 'classnames';
|
||||
import Session from 'App/mstore/types/session';
|
||||
|
|
@ -70,7 +70,7 @@ function AssistSessionsModal(props: ConnectProps) {
|
|||
icon="arrow-repeat"
|
||||
/>
|
||||
</Tooltip>
|
||||
<AssistSearchField />
|
||||
<AssistSearchActions />
|
||||
</div>
|
||||
<div className="flex self-end items-center gap-2" w-full>
|
||||
<span className="color-gray-medium">Sort By</span>
|
||||
|
|
|
|||
|
|
@ -33,9 +33,14 @@ const Signup: React.FC<SignupProps> = ({ history }) => {
|
|||
|
||||
const getHealth = async () => {
|
||||
setHealthStatusLoading(true);
|
||||
try {
|
||||
const { healthMap } = await getHealthRequest(true);
|
||||
setHealthStatus(healthMap);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setHealthStatusLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ const SpotsListHeader = observer(
|
|||
<div className={'flex items-center justify-between w-full'}>
|
||||
<div className="flex gap-1 items-center">
|
||||
<h1 className={'text-2xl capitalize mr-2'}>Spot List</h1>
|
||||
<ReloadButton buttonSize={'small'} onClick={onRefresh} iconSize={16} />
|
||||
<ReloadButton buttonSize={'small'} onClick={onRefresh} iconSize={14} />
|
||||
</div>
|
||||
|
||||
{tenantHasSpots ? (
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useStore } from "App/mstore";
|
||||
import React from 'react';
|
||||
import { NoPermission, NoSessionPermission } from 'UI';
|
||||
|
||||
import { observer } from 'mobx-react-lite'
|
||||
|
||||
|
||||
export default (requiredPermissions, className, isReplay = false, andEd = true) => (BaseComponent) => {
|
||||
|
|
@ -26,5 +26,5 @@ export default (requiredPermissions, className, isReplay = false, andEd = true)
|
|||
</div>
|
||||
);
|
||||
}
|
||||
return WrapperClass;
|
||||
return observer(WrapperClass);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,20 @@
|
|||
import React from 'react';
|
||||
import { Button } from 'antd';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { LeftOutlined } from '@ant-design/icons';
|
||||
import { LeftOutlined, ArrowLeftOutlined } from '@ant-design/icons';
|
||||
|
||||
function BackButton() {
|
||||
function BackButton({ compact }: { compact?: boolean }) {
|
||||
const history = useHistory();
|
||||
const siteId = location.pathname.split('/')[1];
|
||||
|
||||
const handleBackClick = () => {
|
||||
history.push(`/${siteId}/dashboard`);
|
||||
};
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<Button onClick={handleBackClick} type={'text'} icon={<ArrowLeftOutlined />} />
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Button type="text" onClick={handleBackClick} icon={<LeftOutlined />} className="px-1 pe-2 me-2 gap-1">
|
||||
Back
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
import React from 'react';
|
||||
import { Icon, Input, Button } from 'UI';
|
||||
import { Icon, Input } from 'UI';
|
||||
import cn from 'classnames';
|
||||
import FilterList from 'Shared/Filters/FilterList';
|
||||
import { FilterList } from 'Shared/Filters/FilterList';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import FilterSelection from 'Shared/Filters/FilterSelection';
|
||||
import { Typography } from 'antd';
|
||||
import { BranchesOutlined } from '@ant-design/icons';
|
||||
|
||||
|
|
@ -84,29 +83,16 @@ function ConditionSetComponent({
|
|||
onRemoveFilter={onRemoveFilter}
|
||||
onChangeEventsOrder={onChangeEventsOrder}
|
||||
hideEventsOrder
|
||||
onAddFilter={onAddFilter}
|
||||
excludeFilterKeys={excludeFilterKeys}
|
||||
readonly={readonly}
|
||||
isConditional={isConditional}
|
||||
borderless
|
||||
/>
|
||||
{readonly && !conditions.filter?.filters?.length ? (
|
||||
<div className={'p-2'}>No conditions</div>
|
||||
) : null}
|
||||
</div>
|
||||
{readonly ? null : (
|
||||
<div className={'px-2'}>
|
||||
<FilterSelection
|
||||
isConditional={isConditional}
|
||||
filter={undefined}
|
||||
onFilterClick={onAddFilter}
|
||||
excludeFilterKeys={excludeFilterKeys}
|
||||
isMobile={isMobile}
|
||||
>
|
||||
<Button variant="text-primary" icon="plus">
|
||||
Add Condition
|
||||
</Button>
|
||||
</FilterSelection>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={'px-4 py-2 flex items-center gap-2 border-t'}>
|
||||
<span>{bottomLine1}</span>
|
||||
|
|
|
|||
|
|
@ -17,12 +17,26 @@ import { DateTime, Interval } from 'luxon';
|
|||
import styles from './dateRangePopup.module.css';
|
||||
|
||||
function DateRangePopup(props: any) {
|
||||
const [range, setRange] = React.useState(props.selectedDateRange || Interval.fromDateTimes(DateTime.now(), DateTime.now()));
|
||||
const [range, setRange] = React.useState(
|
||||
props.selectedDateRange ||
|
||||
Interval.fromDateTimes(DateTime.now(), DateTime.now())
|
||||
);
|
||||
const [value, setValue] = React.useState<string | null>(null);
|
||||
|
||||
const selectCustomRange = (range) => {
|
||||
const updatedRange = Interval.fromDateTimes(DateTime.fromJSDate(range[0]), DateTime.fromJSDate(range[1]));
|
||||
setRange(updatedRange);
|
||||
let newRange;
|
||||
if (props.singleDay) {
|
||||
newRange = Interval.fromDateTimes(
|
||||
DateTime.fromJSDate(range),
|
||||
DateTime.fromJSDate(range)
|
||||
);
|
||||
} else {
|
||||
newRange = Interval.fromDateTimes(
|
||||
DateTime.fromJSDate(range[0]),
|
||||
DateTime.fromJSDate(range[1])
|
||||
);
|
||||
}
|
||||
setRange(newRange);
|
||||
setValue(CUSTOM_RANGE);
|
||||
};
|
||||
|
||||
|
|
@ -53,8 +67,12 @@ function DateRangePopup(props: any) {
|
|||
};
|
||||
|
||||
const { onCancel } = props;
|
||||
const isUSLocale = navigator.language === 'en-US' || navigator.language.startsWith('en-US');
|
||||
const rangeForDisplay = [range.start!.startOf('day').ts, range.end!.startOf('day').ts]
|
||||
const isUSLocale =
|
||||
navigator.language === 'en-US' || navigator.language.startsWith('en-US');
|
||||
|
||||
const rangeForDisplay = props.singleDay
|
||||
? range.start.ts
|
||||
: [range.start!.startOf('day').ts, range.end!.startOf('day').ts];
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<div className={`${styles.body} h-fit`}>
|
||||
|
|
@ -84,15 +102,24 @@ function DateRangePopup(props: any) {
|
|||
isOpen
|
||||
maxDate={new Date()}
|
||||
value={rangeForDisplay}
|
||||
calendarProps={{
|
||||
tileDisabled: props.isTileDisabled,
|
||||
selectRange: props.singleDay ? false : true,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-2 px-3">
|
||||
{props.singleDay ? (
|
||||
<div>
|
||||
Compare from {range.start.toFormat('MMM dd, yyyy')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<label>From: </label>
|
||||
<span>{range.start.toFormat(isUSLocale ? "MM/dd" : "dd/MM")} </span>
|
||||
<span>{range.start.toFormat(isUSLocale ? 'MM/dd' : 'dd/MM')} </span>
|
||||
<TimePicker
|
||||
format={isUSLocale ? 'hh:mm a' : "HH:mm"}
|
||||
format={isUSLocale ? 'hh:mm a' : 'HH:mm'}
|
||||
value={range.start}
|
||||
onChange={setRangeTimeStart}
|
||||
needConfirm={false}
|
||||
|
|
@ -100,9 +127,9 @@ function DateRangePopup(props: any) {
|
|||
style={{ width: isUSLocale ? 102 : 76 }}
|
||||
/>
|
||||
<label>To: </label>
|
||||
<span>{range.end.toFormat(isUSLocale ? "MM/dd" : "dd/MM")} </span>
|
||||
<span>{range.end.toFormat(isUSLocale ? 'MM/dd' : 'dd/MM')} </span>
|
||||
<TimePicker
|
||||
format={isUSLocale ? 'hh:mm a' : "HH:mm"}
|
||||
format={isUSLocale ? 'hh:mm a' : 'HH:mm'}
|
||||
value={range.end}
|
||||
onChange={setRangeTimeEnd}
|
||||
needConfirm={false}
|
||||
|
|
@ -110,15 +137,16 @@ function DateRangePopup(props: any) {
|
|||
style={{ width: isUSLocale ? 102 : 76 }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center">
|
||||
<Button onClick={onCancel}>{"Cancel"}</Button>
|
||||
<Button onClick={onCancel}>{'Cancel'}</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
className="ml-2"
|
||||
onClick={onApply}
|
||||
disabled={!range}
|
||||
>
|
||||
{"Apply"}
|
||||
{'Apply'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
36
frontend/app/components/shared/Dropdown/index.tsx
Normal file
36
frontend/app/components/shared/Dropdown/index.tsx
Normal 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;
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,117 +1,25 @@
|
|||
import React, { useState, useEffect, useCallback, useRef, ChangeEvent, KeyboardEvent } from 'react';
|
||||
import { Icon } from 'UI';
|
||||
import APIClient from 'App/api_client';
|
||||
import React, {
|
||||
useState,
|
||||
useEffect,
|
||||
useCallback,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import { debounce } from 'App/utils';
|
||||
import stl from './FilterAutoComplete.module.css';
|
||||
import colors from 'App/theme/colors';
|
||||
import Select from 'react-select';
|
||||
import cn from 'classnames';
|
||||
import { useStore } from 'App/mstore';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { searchService} from 'App/services';
|
||||
|
||||
const dropdownStyles = {
|
||||
option: (provided: any, state: any) => ({
|
||||
...provided,
|
||||
whiteSpace: 'nowrap',
|
||||
width: '100%',
|
||||
minWidth: 150,
|
||||
transition: 'all 0.3s',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
backgroundColor: state.isFocused ? colors['active-blue'] : 'transparent',
|
||||
color: state.isFocused ? colors.teal : 'black',
|
||||
fontSize: '14px',
|
||||
'&:hover': {
|
||||
transition: 'all 0.2s',
|
||||
backgroundColor: colors['active-blue']
|
||||
},
|
||||
'&:focus': {
|
||||
transition: 'all 0.2s',
|
||||
backgroundColor: colors['active-blue']
|
||||
}
|
||||
}),
|
||||
control: (provided: any) => {
|
||||
const obj = {
|
||||
...provided,
|
||||
border: 'solid thin transparent !important',
|
||||
backgroundColor: 'transparent',
|
||||
cursor: 'pointer',
|
||||
height: '26px',
|
||||
minHeight: '26px',
|
||||
borderRadius: '.5rem',
|
||||
boxShadow: 'none !important'
|
||||
};
|
||||
return obj;
|
||||
},
|
||||
valueContainer: (provided: any) => ({
|
||||
...provided,
|
||||
// paddingRight: '0px',
|
||||
width: 'fit-content',
|
||||
alignItems: 'center',
|
||||
height: '26px',
|
||||
padding: '0 3px'
|
||||
}),
|
||||
indicatorsContainer: (provided: any) => ({
|
||||
...provided,
|
||||
padding: '0px',
|
||||
height: '26px'
|
||||
}),
|
||||
menu: (provided: any, state: any) => ({
|
||||
...provided,
|
||||
top: 0,
|
||||
borderRadius: '3px',
|
||||
border: `1px solid ${colors['gray-light']}`,
|
||||
backgroundColor: '#fff',
|
||||
boxShadow: '1px 1px 1px rgba(0, 0, 0, 0.1)',
|
||||
position: 'absolute',
|
||||
width: 'unset',
|
||||
maxWidth: '300px',
|
||||
overflow: 'hidden',
|
||||
zIndex: 100
|
||||
}),
|
||||
menuList: (provided: any, state: any) => ({
|
||||
...provided,
|
||||
padding: 0
|
||||
}),
|
||||
noOptionsMessage: (provided: any) => ({
|
||||
...provided,
|
||||
whiteSpace: 'nowrap !important'
|
||||
// minWidth: 'fit-content',
|
||||
}),
|
||||
container: (provided: any) => ({
|
||||
...provided,
|
||||
top: '18px',
|
||||
position: 'absolute'
|
||||
}),
|
||||
input: (provided: any) => ({
|
||||
...provided,
|
||||
height: '22px',
|
||||
'& input:focus': {
|
||||
border: 'none !important'
|
||||
}
|
||||
}),
|
||||
singleValue: (provided: any, state: { isDisabled: any }) => {
|
||||
const opacity = state.isDisabled ? 0.5 : 1;
|
||||
const transition = 'opacity 300ms';
|
||||
|
||||
return {
|
||||
...provided,
|
||||
opacity,
|
||||
transition,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
height: '20px'
|
||||
};
|
||||
}
|
||||
};
|
||||
import { searchService } from 'App/services';
|
||||
import { AutocompleteModal, AutoCompleteContainer } from './AutocompleteModal';
|
||||
|
||||
type FilterParam = { [key: string]: any };
|
||||
|
||||
function processKey(input: FilterParam): FilterParam {
|
||||
const result: FilterParam = {};
|
||||
for (const key in input) {
|
||||
if (input.type === 'metadata' && typeof input[key] === 'string' && input[key].startsWith('_')) {
|
||||
if (
|
||||
input.type === 'metadata' &&
|
||||
typeof input[key] === 'string' &&
|
||||
input[key].startsWith('_')
|
||||
) {
|
||||
result[key] = input[key].substring(1);
|
||||
} else {
|
||||
result[key] = input[key];
|
||||
|
|
@ -123,86 +31,77 @@ function processKey(input: FilterParam): FilterParam {
|
|||
interface Props {
|
||||
showOrButton?: boolean;
|
||||
showCloseButton?: boolean;
|
||||
onRemoveValue?: () => void;
|
||||
onAddValue?: () => void;
|
||||
onRemoveValue?: (ind: number) => void;
|
||||
onAddValue?: (ind: number) => void;
|
||||
endpoint?: string;
|
||||
method?: string;
|
||||
params?: any;
|
||||
headerText?: string;
|
||||
placeholder?: string;
|
||||
onSelect: (e: any, item: any) => void;
|
||||
onSelect: (e: any, item: any, index: number) => void;
|
||||
value: any;
|
||||
icon?: string;
|
||||
hideOrText?: boolean;
|
||||
onApplyValues: (values: string[]) => void;
|
||||
modalProps?: Record<string, any>
|
||||
}
|
||||
|
||||
const FilterAutoComplete: React.FC<Props> = ({
|
||||
showCloseButton = false,
|
||||
placeholder = 'Type to search',
|
||||
method = 'GET',
|
||||
showOrButton = false,
|
||||
endpoint = '',
|
||||
const FilterAutoComplete = observer(
|
||||
({
|
||||
params = {},
|
||||
value = '',
|
||||
hideOrText = false,
|
||||
onSelect,
|
||||
onRemoveValue,
|
||||
onAddValue
|
||||
}: Props) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [options, setOptions] = useState<{ value: string; label: string }[]>([]);
|
||||
const [query, setQuery] = useState(value);
|
||||
const [menuIsOpen, setMenuIsOpen] = useState(false);
|
||||
onClose,
|
||||
onApply,
|
||||
values,
|
||||
placeholder,
|
||||
}: { params: any, values: string[], onClose: () => void, onApply: (values: string[]) => void, placeholder?: string }) => {
|
||||
const [options, setOptions] = useState<{ value: string; label: string }[]>(
|
||||
[]
|
||||
);
|
||||
const [initialFocus, setInitialFocus] = useState(false);
|
||||
const [previousQuery, setPreviousQuery] = useState(value);
|
||||
const selectRef = useRef<any>(null);
|
||||
const inputRef = useRef<any>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
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 = () => {
|
||||
setTopValuesLoading(true);
|
||||
filterStore.fetchTopValues(_params.type, _params.key).finally(() => {
|
||||
setTopValuesLoading(false);
|
||||
setLoading(false);
|
||||
});
|
||||
const loadTopValues = async () => {
|
||||
setLoading(true)
|
||||
await filterStore.fetchTopValues(_params.type, _params.key);
|
||||
setLoading(false)
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (topValues.length > 0) {
|
||||
const mappedValues = topValues.map((i) => ({ value: i.value, label: i.value }));
|
||||
const mappedValues = topValues.map((i) => ({
|
||||
value: i.value,
|
||||
label: i.value,
|
||||
}));
|
||||
setOptions(mappedValues);
|
||||
if (!query.length && initialFocus) {
|
||||
setMenuIsOpen(true);
|
||||
}
|
||||
}
|
||||
}, [topValues, initialFocus, query.length]);
|
||||
}, [topValues, initialFocus]);
|
||||
|
||||
useEffect(loadTopValues, [_params.type]);
|
||||
useEffect(() => { void loadTopValues() }, [_params.type]);
|
||||
|
||||
useEffect(() => {
|
||||
setQuery(value);
|
||||
}, [value]);
|
||||
|
||||
const loadOptions = async (inputValue: string, callback: (options: { value: string; label: string }[]) => void) => {
|
||||
const loadOptions = async (
|
||||
inputValue: string,
|
||||
) => {
|
||||
if (!inputValue.length) {
|
||||
const mappedValues = topValues.map((i) => ({ value: i.value, label: i.value }));
|
||||
const mappedValues = topValues.map((i) => ({
|
||||
value: i.value,
|
||||
label: i.value,
|
||||
}));
|
||||
setOptions(mappedValues);
|
||||
callback(mappedValues);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
// const response = await new APIClient()[method.toLowerCase()](endpoint, { ..._params, q: inputValue });
|
||||
const data = await searchService.fetchAutoCompleteValues({ ..._params, q: inputValue })
|
||||
// const data = await response.json();
|
||||
const _options = data.map((i: any) => ({ value: i.value, label: i.value })) || [];
|
||||
const data = await searchService.fetchAutoCompleteValues({
|
||||
..._params,
|
||||
q: inputValue,
|
||||
});
|
||||
const _options =
|
||||
data.map((i: any) => ({ value: i.value, label: i.value })) || [];
|
||||
setOptions(_options);
|
||||
callback(_options);
|
||||
} catch (e) {
|
||||
throw new Error(e);
|
||||
} finally {
|
||||
|
|
@ -210,104 +109,36 @@ const FilterAutoComplete: React.FC<Props> = ({
|
|||
}
|
||||
};
|
||||
|
||||
const debouncedLoadOptions = useCallback(debounce(loadOptions, 1000), [params, topValues]);
|
||||
const debouncedLoadOptions = useCallback(debounce(loadOptions, 500), [
|
||||
params,
|
||||
topValues,
|
||||
]);
|
||||
|
||||
const handleInputChange = (newValue: string) => {
|
||||
setLoading(true);
|
||||
setInitialFocus(true);
|
||||
setQuery(newValue);
|
||||
debouncedLoadOptions(newValue, () => {
|
||||
selectRef.current?.focus();
|
||||
});
|
||||
};
|
||||
|
||||
const handleChange = (item: { value: string }) => {
|
||||
setMenuIsOpen(false);
|
||||
setQuery(item.value);
|
||||
onSelect(null, item.value);
|
||||
debouncedLoadOptions(newValue);
|
||||
};
|
||||
|
||||
const handleFocus = () => {
|
||||
setInitialFocus(true);
|
||||
if (!query.length) {
|
||||
setLoading(topValuesLoading);
|
||||
setMenuIsOpen(!topValuesLoading && topValues.length > 0);
|
||||
setOptions(topValues.map((i) => ({ value: i.value, label: i.value })));
|
||||
} else {
|
||||
setMenuIsOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
setMenuIsOpen(false);
|
||||
setInitialFocus(false);
|
||||
if (query !== previousQuery) {
|
||||
onSelect(null, query);
|
||||
}
|
||||
setPreviousQuery(query);
|
||||
};
|
||||
|
||||
const selected = value ? options.find((i) => i.value === query) : null;
|
||||
const uniqueOptions = options.filter((i) => i.value !== query);
|
||||
const selectOptionsArr = query.length ? [{ value: query, label: query }, ...uniqueOptions] : options;
|
||||
|
||||
return (
|
||||
<div className="relative flex items-center">
|
||||
<div className={cn(stl.wrapper, 'relative')}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="w-full rounded px-2 no-focus"
|
||||
value={query}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => handleInputChange(e.target.value)}
|
||||
onClick={handleFocus}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
return <AutocompleteModal
|
||||
values={values}
|
||||
onClose={onClose}
|
||||
onApply={onApply}
|
||||
handleFocus={handleFocus}
|
||||
loadOptions={handleInputChange}
|
||||
options={options}
|
||||
isLoading={loading}
|
||||
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);
|
||||
function AutoCompleteController(props: Props) {
|
||||
return <AutoCompleteContainer {...props} modalRenderer={FilterAutoComplete} />
|
||||
}
|
||||
|
||||
export default AutoCompleteController;
|
||||
|
|
|
|||
|
|
@ -1,90 +1,61 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { Icon } from 'UI';
|
||||
import stl from './FilterAutoCompleteLocal.module.css';
|
||||
import { Input } from 'antd';
|
||||
import { AutocompleteModal, AutoCompleteContainer } from 'Shared/Filters/FilterAutoComplete/AutocompleteModal';
|
||||
|
||||
interface Props {
|
||||
showOrButton?: boolean;
|
||||
showCloseButton?: boolean;
|
||||
onRemoveValue?: () => void;
|
||||
onAddValue?: () => void;
|
||||
onRemoveValue?: (index: number) => void;
|
||||
onAddValue?: (index: number) => void;
|
||||
placeholder?: string;
|
||||
onSelect: (e, item) => void;
|
||||
onSelect: (e: any, item: Record<string, any>, index: number) => void;
|
||||
value: any;
|
||||
icon?: string;
|
||||
type?: string;
|
||||
isMultilple?: boolean;
|
||||
isMultiple?: boolean;
|
||||
allowDecimals?: boolean;
|
||||
modalProps?: Record<string, any>;
|
||||
onApplyValues: (values: string[]) => void;
|
||||
}
|
||||
|
||||
function FilterAutoCompleteLocal(props: Props) {
|
||||
function FilterAutoCompleteLocal(props: { params: any, values: string[], onClose: () => void, onApply: (values: string[]) => void, placeholder?: string }) {
|
||||
const {
|
||||
showCloseButton = false,
|
||||
params = {},
|
||||
onClose,
|
||||
onApply,
|
||||
placeholder = 'Enter',
|
||||
showOrButton = false,
|
||||
onRemoveValue = () => null,
|
||||
onAddValue = () => null,
|
||||
value = '',
|
||||
icon = null,
|
||||
type = "text",
|
||||
isMultilple = true,
|
||||
allowDecimals = true,
|
||||
values,
|
||||
} = props;
|
||||
const [showModal, setShowModal] = useState(true)
|
||||
const [query, setQuery] = useState(value);
|
||||
|
||||
const onInputChange = (e) => {
|
||||
if(allowDecimals) {
|
||||
const value = e.target.value;
|
||||
setQuery(value);
|
||||
props.onSelect(null, value);
|
||||
} else {
|
||||
const value = e.target.value.replace(/[^\d]/, "");
|
||||
if (+value !== 0) {
|
||||
setQuery(value);
|
||||
props.onSelect(null, value);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setQuery(value);
|
||||
}, [value])
|
||||
|
||||
const onBlur = (e) => {
|
||||
setTimeout(() => { setShowModal(false) }, 200)
|
||||
props.onSelect(e, { value: query })
|
||||
}
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
props.onSelect(e, { value: query })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex items-center">
|
||||
<div className={stl.wrapper}>
|
||||
<input
|
||||
name="query"
|
||||
onInput={ onInputChange }
|
||||
// onBlur={ onBlur }
|
||||
onFocus={ () => setShowModal(true)}
|
||||
value={ query }
|
||||
autoFocus={ true }
|
||||
type={ type }
|
||||
placeholder={ placeholder }
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
<div
|
||||
className={stl.right}
|
||||
>
|
||||
{ showCloseButton && <div onClick={onRemoveValue}><Icon name="close" size="12" /></div> }
|
||||
{ showOrButton && <div onClick={onAddValue} className="color-teal"><span className="px-1">or</span></div> }
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{ !showOrButton && isMultilple && <div className="ml-3">or</div> }
|
||||
</div>
|
||||
const [options, setOptions] = useState<{ value: string; label: string }[]>(
|
||||
values.filter(val => val.length).map((value) => ({ value, label: value }))
|
||||
);
|
||||
|
||||
const onApplyValues = (values: string[]) => {
|
||||
setOptions(values.map((value) => ({ value, label: value })));
|
||||
onApply(values);
|
||||
}
|
||||
|
||||
const splitValues = (value: string) => {
|
||||
const values = value.split(',').filter(v => v.length)
|
||||
setOptions(values.map((value) => ({ value, label: value })));
|
||||
}
|
||||
|
||||
return <AutocompleteModal
|
||||
values={values}
|
||||
onClose={onClose}
|
||||
onApply={onApplyValues}
|
||||
loadOptions={splitValues}
|
||||
options={options}
|
||||
isLoading={false}
|
||||
placeholder={placeholder}
|
||||
commaQuery
|
||||
/>
|
||||
}
|
||||
|
||||
export default FilterAutoCompleteLocal;
|
||||
function FilterLocalController(props: Props) {
|
||||
return <AutoCompleteContainer {...props} modalRenderer={FilterAutoCompleteLocal} />
|
||||
}
|
||||
|
||||
export default FilterLocalController;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import React from 'react';
|
||||
import styles from './FilterDuration.module.css';
|
||||
import { Input } from 'UI'
|
||||
import { Input } from 'antd'
|
||||
|
||||
const fromMs = value => value ? `${ value / 1000 / 60 }` : ''
|
||||
const toMs = value => value !== '' ? value * 1000 * 60 : null
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import FilterSource from '../FilterSource';
|
|||
import { FilterKey, FilterType } from 'App/types/filter/filterType';
|
||||
import SubFilterItem from '../SubFilterItem';
|
||||
import { CircleMinus } from 'lucide-react';
|
||||
import cn from 'classnames'
|
||||
|
||||
interface Props {
|
||||
filterIndex?: number;
|
||||
|
|
@ -17,6 +18,7 @@ interface Props {
|
|||
saveRequestPayloads?: boolean;
|
||||
disableDelete?: boolean;
|
||||
excludeFilterKeys?: Array<string>;
|
||||
excludeCategory?: Array<string>;
|
||||
allowedFilterKeys?: Array<string>;
|
||||
readonly?: boolean;
|
||||
hideIndex?: boolean;
|
||||
|
|
@ -34,6 +36,7 @@ function FilterItem(props: Props) {
|
|||
hideDelete = false,
|
||||
allowedFilterKeys = [],
|
||||
excludeFilterKeys = [],
|
||||
excludeCategory = [],
|
||||
isConditional,
|
||||
hideIndex = false,
|
||||
} = props;
|
||||
|
|
@ -42,7 +45,7 @@ function FilterItem(props: Props) {
|
|||
const replaceFilter = (filter: any) => {
|
||||
props.onUpdate({
|
||||
...filter,
|
||||
value: [''],
|
||||
value: filter.value,
|
||||
filters: filter.filters ? filter.filters.map((i: any) => ({...i, value: ['']})) : [],
|
||||
});
|
||||
};
|
||||
|
|
@ -67,30 +70,34 @@ function FilterItem(props: Props) {
|
|||
});
|
||||
};
|
||||
|
||||
const isReversed = filter.key === FilterKey.TAGGED_ELEMENT
|
||||
return (
|
||||
<div className="flex items-center w-full">
|
||||
<div className="flex items-start w-full">
|
||||
<div className="flex items-center w-full flex-wrap">
|
||||
{!isFilter && !hideIndex && filterIndex >= 0 && (
|
||||
<div
|
||||
className="mt-1 flex-shrink-0 border w-6 h-6 text-xs flex items-center justify-center rounded-full bg-gray-light-shade mr-2">
|
||||
className="flex-shrink-0 w-6 h-6 text-xs flex items-center justify-center rounded-full bg-gray-lighter mr-2">
|
||||
<span>{filterIndex + 1}</span>
|
||||
</div>
|
||||
)}
|
||||
<FilterSelection
|
||||
filter={filter}
|
||||
mode={props.isFilter ? 'filters' : 'events'}
|
||||
onFilterClick={replaceFilter}
|
||||
allowedFilterKeys={allowedFilterKeys}
|
||||
excludeFilterKeys={excludeFilterKeys}
|
||||
excludeCategory={excludeCategory}
|
||||
disabled={disableDelete || props.readonly}
|
||||
/>
|
||||
|
||||
<div className={cn('flex items-center flex-wrap', isReversed ? 'flex-row-reverse ml-2' : 'flex-row')}>
|
||||
{/* Filter with Source */}
|
||||
{filter.hasSource && (
|
||||
<>
|
||||
<FilterOperator
|
||||
options={filter.sourceOperatorOptions}
|
||||
onChange={onSourceOperatorChange}
|
||||
className="mx-2 flex-shrink-0"
|
||||
className="mx-2 flex-shrink-0 btn-event-operator"
|
||||
value={filter.sourceOperator}
|
||||
isDisabled={filter.operatorDisabled || props.readonly}
|
||||
/>
|
||||
|
|
@ -104,7 +111,7 @@ function FilterItem(props: Props) {
|
|||
<FilterOperator
|
||||
options={filter.operatorOptions}
|
||||
onChange={onOperatorChange}
|
||||
className="mx-2 flex-shrink-0"
|
||||
className="mx-2 flex-shrink-0 btn-sub-event-operator"
|
||||
value={filter.operator}
|
||||
isDisabled={filter.operatorDisabled || props.readonly}
|
||||
/>
|
||||
|
|
@ -112,7 +119,7 @@ function FilterItem(props: Props) {
|
|||
<>
|
||||
{props.readonly ? (
|
||||
<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) => {
|
||||
return filter.options && filter.options.length
|
||||
|
|
@ -127,6 +134,7 @@ function FilterItem(props: Props) {
|
|||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* filters */}
|
||||
{isSubFilter && (
|
||||
|
|
@ -156,6 +164,7 @@ function FilterItem(props: Props) {
|
|||
type="text"
|
||||
onClick={props.onRemoveFilter}
|
||||
size="small"
|
||||
className='btn-remove-step mt-2'
|
||||
>
|
||||
<CircleMinus size={14} />
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -1,55 +1,57 @@
|
|||
import {observer} from "mobx-react-lite";
|
||||
import {Tooltip} from "UI";
|
||||
import {Segmented} from "antd";
|
||||
import React from "react";
|
||||
import React from 'react';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { Dropdown, Button, Tooltip } from 'antd';
|
||||
|
||||
const EventsOrder = observer((props: {
|
||||
onChange: (e: any, v: any) => void,
|
||||
filter: any,
|
||||
}) => {
|
||||
const {filter, onChange} = props;
|
||||
const EventsOrder = observer(
|
||||
(props: { onChange: (e: any, v: any) => void; filter: any }) => {
|
||||
const { filter, onChange } = props;
|
||||
const eventsOrderSupport = filter.eventsOrderSupport;
|
||||
const options = [
|
||||
|
||||
const menuItems = [
|
||||
{
|
||||
name: 'eventsOrder',
|
||||
key: 'then',
|
||||
label: 'THEN',
|
||||
value: 'then',
|
||||
disabled: eventsOrderSupport && !eventsOrderSupport.includes('then'),
|
||||
},
|
||||
{
|
||||
name: 'eventsOrder',
|
||||
key: 'and',
|
||||
label: 'AND',
|
||||
value: 'and',
|
||||
disabled: eventsOrderSupport && !eventsOrderSupport.includes('and'),
|
||||
},
|
||||
{
|
||||
name: 'eventsOrder',
|
||||
key: 'or',
|
||||
label: '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">
|
||||
<div
|
||||
className="color-gray-medium text-sm"
|
||||
style={{textDecoration: "underline dotted"}}
|
||||
>
|
||||
const selected = menuItems.find(
|
||||
(item) => item.key === filter.eventsOrder
|
||||
)?.label;
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Tooltip
|
||||
title={`Select the operator to be applied between events in your search.`}
|
||||
title="Select the operator to be applied between events."
|
||||
placement="bottom"
|
||||
>
|
||||
<div>Events Order</div>
|
||||
<div className="text-neutral-500/90 text-sm font-normal cursor-default">Events Order</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<Segmented
|
||||
size={"small"}
|
||||
className="text-sm"
|
||||
onChange={(v) => onChange(null, options.find((i) => i.value === v))}
|
||||
value={filter.eventsOrder}
|
||||
options={options}
|
||||
/>
|
||||
</div>;
|
||||
});
|
||||
<Dropdown
|
||||
menu={{ items: menuItems, onClick }}
|
||||
trigger={['click']}
|
||||
placement="bottomRight"
|
||||
className="text-sm rounded-lg px-1 py-0.5 btn-events-order "
|
||||
data-event="btn-events-order"
|
||||
>
|
||||
<Button size={'small'} type='text'>{selected || 'Select'} </Button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export default EventsOrder;
|
||||
|
|
|
|||
|
|
@ -1,44 +1,130 @@
|
|||
import {Space} from 'antd';
|
||||
import {List} from 'immutable';
|
||||
import {GripHorizontal} from 'lucide-react';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import React, {useEffect} from 'react';
|
||||
|
||||
import { GripVertical, Plus, Filter } from 'lucide-react';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import React, { useEffect } from 'react';
|
||||
import { Button } from 'antd';
|
||||
import cn from 'classnames';
|
||||
import FilterItem from '../FilterItem';
|
||||
import EventsOrder from "Shared/Filters/FilterList/EventsOrder";
|
||||
import EventsOrder from 'Shared/Filters/FilterList/EventsOrder';
|
||||
import FilterSelection from '../FilterSelection/FilterSelection';
|
||||
|
||||
interface Props {
|
||||
filter?: any; // event/filter
|
||||
filter?: any;
|
||||
onUpdateFilter: (filterIndex: any, filter: any) => void;
|
||||
onFilterMove?: (filters: any) => void;
|
||||
onRemoveFilter: (filterIndex: any) => void;
|
||||
onChangeEventsOrder: (e: any, {name, value}: any) => void;
|
||||
onChangeEventsOrder: (e: any, { name, value }: any) => void;
|
||||
hideEventsOrder?: boolean;
|
||||
observeChanges?: () => void;
|
||||
saveRequestPayloads?: boolean;
|
||||
supportsEmpty?: boolean;
|
||||
readonly?: boolean;
|
||||
excludeFilterKeys?: Array<string>;
|
||||
excludeCategory?: string[];
|
||||
isConditional?: boolean;
|
||||
actions?: React.ReactNode[];
|
||||
onAddFilter: (filter: any) => void;
|
||||
mergeDown?: boolean;
|
||||
mergeUp?: boolean;
|
||||
borderless?: boolean;
|
||||
cannotAdd?: boolean;
|
||||
}
|
||||
|
||||
function FilterList(props: Props) {
|
||||
export const FilterList = observer((props: Props) => {
|
||||
const {
|
||||
observeChanges = () => {
|
||||
},
|
||||
observeChanges = () => {},
|
||||
filter,
|
||||
excludeFilterKeys = [],
|
||||
isConditional,
|
||||
onAddFilter,
|
||||
readonly,
|
||||
borderless,
|
||||
excludeCategory,
|
||||
} = props;
|
||||
|
||||
const filters = filter.filters;
|
||||
useEffect(observeChanges, [filters]);
|
||||
|
||||
const onRemoveFilter = (filterIndex: any) => {
|
||||
props.onRemoveFilter(filterIndex);
|
||||
};
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'bg-white',
|
||||
borderless ? '' : 'pt-2 px-4 rounded-xl border border-gray-lighter'
|
||||
)}
|
||||
style={{
|
||||
borderBottomLeftRadius: props.mergeDown ? 0 : undefined,
|
||||
borderBottomRightRadius: props.mergeDown ? 0 : undefined,
|
||||
borderTopLeftRadius: props.mergeUp ? 0 : undefined,
|
||||
borderTopRightRadius: props.mergeUp ? 0 : undefined,
|
||||
}}
|
||||
>
|
||||
<div className={'flex items-center py-2'} style={{ gap: '0.65rem' }}>
|
||||
<div className="font-medium">Filters</div>
|
||||
<FilterSelection
|
||||
mode={'filters'}
|
||||
filter={undefined}
|
||||
onFilterClick={onAddFilter}
|
||||
disabled={readonly}
|
||||
excludeFilterKeys={excludeFilterKeys}
|
||||
excludeCategory={excludeCategory}
|
||||
>
|
||||
<Button
|
||||
icon={<Filter size={16} strokeWidth={1} />}
|
||||
type="default"
|
||||
size={'small'}
|
||||
className='btn-add-filter'
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</FilterSelection>
|
||||
</div>
|
||||
{filters.map((filter: any, filterIndex: any) =>
|
||||
!filter.isEvent ? (
|
||||
<div
|
||||
key={`${filter.key}-${filterIndex}`}
|
||||
className={'py-2 hover:bg-active-blue px-5 '}
|
||||
style={{
|
||||
marginLeft: '-1.25rem',
|
||||
width: 'calc(100% + 2.5rem)',
|
||||
}}
|
||||
>
|
||||
<FilterItem
|
||||
key={filterIndex}
|
||||
readonly={props.readonly}
|
||||
isFilter={true}
|
||||
filterIndex={filterIndex}
|
||||
filter={filter}
|
||||
onUpdate={(filter) => props.onUpdateFilter(filterIndex, filter)}
|
||||
onRemoveFilter={() => onRemoveFilter(filterIndex)}
|
||||
excludeFilterKeys={excludeFilterKeys}
|
||||
isConditional={isConditional}
|
||||
/>
|
||||
</div>
|
||||
) : null
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export const EventsList = observer((props: Props) => {
|
||||
const {
|
||||
observeChanges = () => {},
|
||||
filter,
|
||||
hideEventsOrder = false,
|
||||
saveRequestPayloads,
|
||||
supportsEmpty = true,
|
||||
excludeFilterKeys = [],
|
||||
isConditional,
|
||||
actions = []
|
||||
actions = [],
|
||||
onAddFilter,
|
||||
cannotAdd,
|
||||
excludeCategory,
|
||||
} = props;
|
||||
|
||||
const filters = filter.filters;
|
||||
const hasEvents = filters.filter((i: any) => i.isEvent).length > 0;
|
||||
const hasFilters = filters.filter((i: any) => !i.isEvent).length > 0;
|
||||
|
||||
let rowIndex = 0;
|
||||
const cannotDeleteFilter = hasEvents && !supportsEmpty;
|
||||
|
|
@ -62,7 +148,7 @@ function FilterList(props: Props) {
|
|||
const hoverClientY = event.clientY - target.top;
|
||||
|
||||
const position = hoverClientY < hoverMiddleY ? 'top' : 'bottom';
|
||||
setHoveredItem({position, i});
|
||||
setHoveredItem({ position, i });
|
||||
};
|
||||
|
||||
const calculateNewPosition = React.useCallback(
|
||||
|
|
@ -75,24 +161,23 @@ function FilterList(props: Props) {
|
|||
[]
|
||||
);
|
||||
|
||||
const handleDragStart = React.useCallback((
|
||||
ev: Record<string, any>,
|
||||
index: number,
|
||||
elId: string
|
||||
) => {
|
||||
ev.dataTransfer.setData("text/plain", index.toString());
|
||||
const handleDragStart = React.useCallback(
|
||||
(ev: Record<string, any>, index: number, elId: string) => {
|
||||
ev.dataTransfer.setData('text/plain', index.toString());
|
||||
setDraggedItem(index);
|
||||
const el = document.getElementById(elId);
|
||||
if (el) {
|
||||
ev.dataTransfer.setDragImage(el, 0, 0);
|
||||
}
|
||||
}, [])
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleDrop = React.useCallback(
|
||||
(event: Record<string, any>) => {
|
||||
event.preventDefault();
|
||||
if (draggedInd === null) return;
|
||||
const newItems = filters.toArray();
|
||||
const newItems = filters;
|
||||
const newPosition = calculateNewPosition(
|
||||
draggedInd,
|
||||
hoveredItem.i,
|
||||
|
|
@ -102,53 +187,88 @@ function FilterList(props: Props) {
|
|||
const reorderedItem = newItems.splice(draggedInd, 1)[0];
|
||||
newItems.splice(newPosition, 0, reorderedItem);
|
||||
|
||||
props.onFilterMove?.(List(newItems));
|
||||
setHoveredItem({i: null, position: null});
|
||||
props.onFilterMove?.(newItems);
|
||||
setHoveredItem({ i: null, position: null });
|
||||
setDraggedItem(null);
|
||||
},
|
||||
[draggedInd, hoveredItem, filters, props.onFilterMove]
|
||||
);
|
||||
|
||||
const eventsNum = filters.filter((i: any) => i.isEvent).size
|
||||
const eventsNum = filters.filter((i: any) => i.isEvent).length;
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
{hasEvents && (
|
||||
<>
|
||||
<div className="flex items-center mb-2">
|
||||
<div className="text-sm color-gray-medium mr-auto">
|
||||
{filter.eventsHeader || 'EVENTS'}
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
'border-b border-b-gray-lighter pt-2 px-4 rounded-xl bg-white border border-gray-lighter'
|
||||
}
|
||||
style={{
|
||||
borderBottomLeftRadius: props.mergeDown ? 0 : undefined,
|
||||
borderBottomRightRadius: props.mergeDown ? 0 : undefined,
|
||||
borderTopLeftRadius: props.mergeUp ? 0 : undefined,
|
||||
borderTopRightRadius: props.mergeUp ? 0 : undefined,
|
||||
marginBottom: props.mergeDown ? '-1px' : undefined,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center mb-2 gap-2">
|
||||
<div className="font-medium">Events</div>
|
||||
{cannotAdd ? null : (
|
||||
<FilterSelection
|
||||
mode={'events'}
|
||||
filter={undefined}
|
||||
onFilterClick={onAddFilter}
|
||||
excludeCategory={excludeCategory}
|
||||
>
|
||||
<Button
|
||||
icon={<Plus size={16} strokeWidth={1} />}
|
||||
type="default"
|
||||
size={'small'}
|
||||
className='btn-add-event'
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</FilterSelection>
|
||||
)}
|
||||
|
||||
<Space>
|
||||
{!hideEventsOrder && <EventsOrder filter={filter}
|
||||
onChange={props.onChangeEventsOrder}/>}
|
||||
{actions && actions.map((action, index) => (
|
||||
<div key={index}>{action}</div>
|
||||
))}
|
||||
</Space>
|
||||
<div className={'ml-auto'}>
|
||||
{!hideEventsOrder && (
|
||||
<EventsOrder filter={filter} onChange={props.onChangeEventsOrder} />
|
||||
)}
|
||||
{actions &&
|
||||
actions.map((action, index) => <div key={index}>{action}</div>)}
|
||||
</div>
|
||||
<div className={'flex flex-col'}>
|
||||
</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'
|
||||
? '1.5rem'
|
||||
: '0.5rem',
|
||||
hoveredItem.i === filterIndex && hoveredItem.position === 'top'
|
||||
? ''
|
||||
: '',
|
||||
paddingBottom:
|
||||
hoveredItem.i === filterIndex &&
|
||||
hoveredItem.position === 'bottom'
|
||||
? '1.5rem'
|
||||
: '0.5rem',
|
||||
marginLeft: '-1.25rem',
|
||||
width: 'calc(100% + 2.5rem)',
|
||||
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,
|
||||
}}
|
||||
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)}
|
||||
|
|
@ -156,68 +276,40 @@ function FilterList(props: Props) {
|
|||
>
|
||||
{!!props.onFilterMove && eventsNum > 1 ? (
|
||||
<div
|
||||
className={'p-2 cursor-grab'}
|
||||
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}`
|
||||
)
|
||||
handleDragStart(e, filterIndex, `${filter.key}-${filterIndex}`)
|
||||
}
|
||||
onDragEnd={() => {
|
||||
setHoveredItem({ i: null, position: null });
|
||||
setDraggedItem(null);
|
||||
}}
|
||||
style={{
|
||||
cursor: draggedInd !== null ? 'grabbing' : 'grab',
|
||||
}}
|
||||
>
|
||||
<GripHorizontal size={16}/>
|
||||
<GripVertical size={16} />
|
||||
</div>
|
||||
) : null}
|
||||
<FilterItem
|
||||
filterIndex={rowIndex++}
|
||||
filter={filter}
|
||||
onUpdate={(filter) =>
|
||||
props.onUpdateFilter(filterIndex, 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 className="mb-2"/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{hasFilters && (
|
||||
<>
|
||||
{hasEvents && <div className="border-t -mx-5 mb-4"/>}
|
||||
<div className="mb-2 text-sm color-gray-medium mr-auto">FILTERS</div>
|
||||
{filters.map((filter: any, filterIndex: any) =>
|
||||
!filter.isEvent ? (
|
||||
<div className={'py-2 hover:bg-active-blue px-5'} style={{
|
||||
marginLeft: '-1.25rem',
|
||||
width: 'calc(100% + 2.5rem)',
|
||||
}}>
|
||||
<FilterItem
|
||||
key={filterIndex}
|
||||
readonly={props.readonly}
|
||||
isFilter={true}
|
||||
filterIndex={filterIndex}
|
||||
filter={filter}
|
||||
onUpdate={(filter) => props.onUpdateFilter(filterIndex, filter)}
|
||||
onRemoveFilter={() => onRemoveFilter(filterIndex)}
|
||||
excludeFilterKeys={excludeFilterKeys}
|
||||
isConditional={isConditional}
|
||||
/>
|
||||
</div>
|
||||
) : null
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(FilterList);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
export { default } from './FilterList';
|
||||
export { FilterList, EventsList } from './FilterList';
|
||||
|
|
@ -2,8 +2,6 @@
|
|||
border-radius: .5rem;
|
||||
border: solid thin $gray-light;
|
||||
padding: 20px;
|
||||
overflow: hidden;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 2px 2px 0 $gray-light;
|
||||
}
|
||||
.optionItem {
|
||||
|
|
|
|||
|
|
@ -7,11 +7,14 @@ import {
|
|||
CircleAlert,
|
||||
Clock2,
|
||||
Code,
|
||||
ContactRound, CornerDownRight,
|
||||
ContactRound,
|
||||
CornerDownRight,
|
||||
Cpu,
|
||||
Earth,
|
||||
FileStack, Layers,
|
||||
MapPin, Megaphone,
|
||||
FileStack,
|
||||
Layers,
|
||||
MapPin,
|
||||
Megaphone,
|
||||
MemoryStick,
|
||||
MonitorSmartphone,
|
||||
Navigation,
|
||||
|
|
@ -25,63 +28,70 @@ import {
|
|||
Timer,
|
||||
VenetianMask,
|
||||
Workflow,
|
||||
Flag
|
||||
Flag,
|
||||
ChevronRight,
|
||||
} from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { Icon, Loader } from 'UI';
|
||||
import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG';
|
||||
import { Input } from 'antd';
|
||||
|
||||
import { FilterKey } from 'Types/filter/filterType';
|
||||
import { FilterCategory, FilterKey, FilterType } from "Types/filter/filterType";
|
||||
import stl from './FilterModal.module.css';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { useStore } from 'App/mstore';
|
||||
|
||||
const IconMap = {
|
||||
[FilterKey.CLICK]: <Pointer size={18} />,
|
||||
[FilterKey.LOCATION]: <Navigation size={18} />,
|
||||
[FilterKey.INPUT]: <RectangleEllipsis size={18} />,
|
||||
[FilterKey.CUSTOM]: <Code size={18} />,
|
||||
[FilterKey.FETCH]: <ArrowUpDown size={18} />,
|
||||
[FilterKey.GRAPHQL]: <Network size={18} />,
|
||||
[FilterKey.STATEACTION]: <RectangleEllipsis size={18} />,
|
||||
[FilterKey.ERROR]: <OctagonAlert size={18} />,
|
||||
[FilterKey.ISSUE]: <CircleAlert size={18} />,
|
||||
[FilterKey.FETCH_FAILED]: <Code size={18} />,
|
||||
[FilterKey.DOM_COMPLETE]: <ArrowUpDown size={18} />,
|
||||
[FilterKey.LARGEST_CONTENTFUL_PAINT_TIME]: <Network size={18} />,
|
||||
[FilterKey.TTFB]: <Timer size={18} />,
|
||||
[FilterKey.AVG_CPU_LOAD]: <Cpu size={18} />,
|
||||
[FilterKey.AVG_MEMORY_USAGE]: <MemoryStick size={18} />,
|
||||
[FilterKey.USERID]: <SquareUser size={18} />,
|
||||
[FilterKey.USERANONYMOUSID]: <VenetianMask size={18} />,
|
||||
[FilterKey.USER_CITY]: <Pin size={18} />,
|
||||
[FilterKey.USER_STATE]: <MapPin size={18} />,
|
||||
[FilterKey.USER_COUNTRY]: <Earth size={18} />,
|
||||
[FilterKey.USER_DEVICE]: <Code size={18} />,
|
||||
[FilterKey.USER_OS]: <AppWindow size={18} />,
|
||||
[FilterKey.USER_BROWSER]: <Chrome size={18} />,
|
||||
[FilterKey.PLATFORM]: <MonitorSmartphone size={18} />,
|
||||
[FilterKey.REVID]: <FileStack size={18} />,
|
||||
[FilterKey.REFERRER]: <Workflow size={18} />,
|
||||
[FilterKey.DURATION]: <Clock2 size={18} />,
|
||||
[FilterKey.TAGGED_ELEMENT]: <SquareMousePointer size={18} />,
|
||||
[FilterKey.METADATA]: <ContactRound size={18} />,
|
||||
[FilterKey.UTM_SOURCE]: <CornerDownRight size={18} />,
|
||||
[FilterKey.UTM_MEDIUM]: <Layers size={18} />,
|
||||
[FilterKey.UTM_CAMPAIGN]: <Megaphone size={18} />,
|
||||
[FilterKey.FEATURE_FLAG]: <Flag size={18} />
|
||||
export const IconMap = {
|
||||
[FilterKey.CLICK]: <Pointer size={14}/>,
|
||||
[FilterKey.LOCATION]: <Navigation size={14} />,
|
||||
[FilterKey.INPUT]: <RectangleEllipsis size={14} />,
|
||||
[FilterKey.CUSTOM]: <Code size={14} />,
|
||||
[FilterKey.FETCH]: <ArrowUpDown size={14} />,
|
||||
[FilterKey.GRAPHQL]: <Network size={14} />,
|
||||
[FilterKey.STATEACTION]: <RectangleEllipsis size={14} />,
|
||||
[FilterKey.ERROR]: <OctagonAlert size={14} />,
|
||||
[FilterKey.ISSUE]: <CircleAlert size={14} />,
|
||||
[FilterKey.FETCH_FAILED]: <Code size={14} />,
|
||||
[FilterKey.DOM_COMPLETE]: <ArrowUpDown size={14} />,
|
||||
[FilterKey.LARGEST_CONTENTFUL_PAINT_TIME]: <Network size={14} />,
|
||||
[FilterKey.TTFB]: <Timer size={14} />,
|
||||
[FilterKey.AVG_CPU_LOAD]: <Cpu size={14} />,
|
||||
[FilterKey.AVG_MEMORY_USAGE]: <MemoryStick size={14} />,
|
||||
[FilterKey.USERID]: <SquareUser size={14} />,
|
||||
[FilterKey.USERANONYMOUSID]: <VenetianMask size={14} />,
|
||||
[FilterKey.USER_CITY]: <Pin size={14} />,
|
||||
[FilterKey.USER_STATE]: <MapPin size={14} />,
|
||||
[FilterKey.USER_COUNTRY]: <Earth size={14} />,
|
||||
[FilterKey.USER_DEVICE]: <Code size={14} />,
|
||||
[FilterKey.USER_OS]: <AppWindow size={14} />,
|
||||
[FilterKey.USER_BROWSER]: <Chrome size={14} />,
|
||||
[FilterKey.PLATFORM]: <MonitorSmartphone size={14} />,
|
||||
[FilterKey.REVID]: <FileStack size={14} />,
|
||||
[FilterKey.REFERRER]: <Workflow size={14} />,
|
||||
[FilterKey.DURATION]: <Clock2 size={14} />,
|
||||
[FilterKey.TAGGED_ELEMENT]: <SquareMousePointer size={14} />,
|
||||
[FilterKey.METADATA]: <ContactRound size={14} />,
|
||||
[FilterKey.UTM_SOURCE]: <CornerDownRight size={14} />,
|
||||
[FilterKey.UTM_MEDIUM]: <Layers size={14} />,
|
||||
[FilterKey.UTM_CAMPAIGN]: <Megaphone size={14} />,
|
||||
[FilterKey.FEATURE_FLAG]: <Flag size={14} />,
|
||||
};
|
||||
|
||||
function filterJson(
|
||||
jsonObj: Record<string, any>,
|
||||
excludeKeys: string[] = [],
|
||||
allowedFilterKeys: string[] = []
|
||||
excludeCategory: string[] = [],
|
||||
allowedFilterKeys: string[] = [],
|
||||
mode: 'filters' | 'events'
|
||||
): Record<string, any> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(jsonObj)
|
||||
.map(([key, value]) => {
|
||||
const arr = value.filter((i: { key: string }) => {
|
||||
const arr = value.filter((i: { key: string, isEvent: boolean, category: string }) => {
|
||||
if (excludeCategory.includes(i.category)) return false;
|
||||
if (excludeKeys.includes(i.key)) return false;
|
||||
if (mode === 'events' && !i.isEvent) return false;
|
||||
if (mode === 'filters' && i.isEvent) return false;
|
||||
return !(
|
||||
allowedFilterKeys.length > 0 && !allowedFilterKeys.includes(i.key)
|
||||
);
|
||||
|
|
@ -102,8 +112,8 @@ export const getMatchingEntries = (
|
|||
|
||||
if (lowerCaseQuery.length === 0)
|
||||
return {
|
||||
matchingCategories: Object.keys(filters),
|
||||
matchingFilters: filters
|
||||
matchingCategories: ['All', ...Object.keys(filters)],
|
||||
matchingFilters: filters,
|
||||
};
|
||||
|
||||
Object.keys(filters).forEach((name) => {
|
||||
|
|
@ -120,7 +130,7 @@ export const getMatchingEntries = (
|
|||
}
|
||||
});
|
||||
|
||||
return { matchingCategories, matchingFilters };
|
||||
return { matchingCategories: ['All', ...matchingCategories], matchingFilters };
|
||||
};
|
||||
|
||||
interface Props {
|
||||
|
|
@ -131,50 +141,14 @@ interface Props {
|
|||
isMainSearch?: boolean;
|
||||
searchQuery?: string;
|
||||
excludeFilterKeys?: Array<string>;
|
||||
excludeCategory?: Array<string>;
|
||||
allowedFilterKeys?: Array<string>;
|
||||
isConditional?: boolean;
|
||||
isMobile?: boolean;
|
||||
mode: 'filters' | 'events';
|
||||
}
|
||||
|
||||
function FilterModal(props: Props) {
|
||||
const {
|
||||
isLive,
|
||||
onFilterClick = () => null,
|
||||
isMainSearch = false,
|
||||
searchQuery = '',
|
||||
excludeFilterKeys = [],
|
||||
allowedFilterKeys = [],
|
||||
isConditional,
|
||||
} = props;
|
||||
const { searchStore, searchStoreLive, projectsStore } = useStore();
|
||||
const isMobile = projectsStore.active?.platform === 'ios'; // TODO - should be using mobile once the app is changed
|
||||
const filters = isLive ? searchStoreLive.filterListLive : (isMobile ? searchStore.filterListMobile : searchStoreLive.filterList);
|
||||
const conditionalFilters = searchStore.filterListConditional;
|
||||
const mobileConditionalFilters = searchStore.filterListMobileConditional;
|
||||
const showSearchList = isMainSearch && searchQuery.length > 0;
|
||||
const filterSearchList = isLive ? searchStoreLive.filterSearchList : searchStore.filterSearchList;
|
||||
const fetchingFilterSearchList = isLive ? searchStoreLive.loadingFilterSearch : searchStore.loadingFilterSearch;
|
||||
|
||||
const onFilterSearchClick = (filter: any) => {
|
||||
const _filter = { ...filtersMap[filter.type] };
|
||||
_filter.value = [filter.value];
|
||||
onFilterClick(_filter);
|
||||
};
|
||||
|
||||
const filterJsonObj = isConditional
|
||||
? isMobile ? mobileConditionalFilters : conditionalFilters
|
||||
: filters;
|
||||
const { matchingCategories, matchingFilters } = getMatchingEntries(
|
||||
searchQuery,
|
||||
filterJson(filterJsonObj, excludeFilterKeys, allowedFilterKeys)
|
||||
);
|
||||
|
||||
const isResultEmpty =
|
||||
(!filterSearchList || Object.keys(filterSearchList).length === 0) &&
|
||||
matchingCategories.length === 0 &&
|
||||
Object.keys(matchingFilters).length === 0;
|
||||
|
||||
const getNewIcon = (filter: Record<string, any>) => {
|
||||
export const getNewIcon = (filter: Record<string, any>) => {
|
||||
if (filter.icon?.includes('metadata')) {
|
||||
return IconMap[FilterKey.METADATA];
|
||||
}
|
||||
|
|
@ -183,44 +157,127 @@ function FilterModal(props: Props) {
|
|||
// @ts-ignore
|
||||
return IconMap[filter.key];
|
||||
} else return <Icon name={filter.icon} size={16} />;
|
||||
};
|
||||
|
||||
function FilterModal(props: Props) {
|
||||
const {
|
||||
isLive,
|
||||
onFilterClick = () => null,
|
||||
isMainSearch = false,
|
||||
excludeFilterKeys = [],
|
||||
excludeCategory = [],
|
||||
allowedFilterKeys = [],
|
||||
isConditional,
|
||||
mode,
|
||||
} = props;
|
||||
const [searchQuery, setSearchQuery] = React.useState('');
|
||||
const [category, setCategory] = React.useState('All');
|
||||
const { searchStore, searchStoreLive, projectsStore } = useStore();
|
||||
const isMobile = projectsStore.active?.platform === 'ios'; // TODO - should be using mobile once the app is changed
|
||||
const filters = isLive
|
||||
? searchStoreLive.filterListLive
|
||||
: isMobile
|
||||
? searchStore.filterListMobile
|
||||
: searchStoreLive.filterList;
|
||||
const conditionalFilters = searchStore.filterListConditional;
|
||||
const mobileConditionalFilters = searchStore.filterListMobileConditional;
|
||||
const showSearchList = isMainSearch && searchQuery.length > 0;
|
||||
const filterSearchList = isLive
|
||||
? searchStoreLive.filterSearchList
|
||||
: searchStore.filterSearchList;
|
||||
const fetchingFilterSearchList = isLive
|
||||
? searchStoreLive.loadingFilterSearch
|
||||
: searchStore.loadingFilterSearch;
|
||||
|
||||
const parseAndAdd = (filter) => {
|
||||
if (filter.category === FilterCategory.EVENTS && filter.key.startsWith('_')) {
|
||||
filter.value = [filter.key.substring(1)];
|
||||
filter.key = FilterKey.CUSTOM;
|
||||
filter.label = 'Custom Events'
|
||||
}
|
||||
if (filter.type === FilterType.ISSUE && filter.key.startsWith(`${FilterKey.ISSUE}_`)) {
|
||||
filter.key = FilterKey.ISSUE;
|
||||
}
|
||||
onFilterClick(filter)
|
||||
}
|
||||
const onFilterSearchClick = (filter: any) => {
|
||||
const _filter = { ...filtersMap[filter.type] };
|
||||
_filter.value = [filter.value];
|
||||
parseAndAdd(_filter);
|
||||
};
|
||||
|
||||
const filterJsonObj = isConditional
|
||||
? isMobile
|
||||
? mobileConditionalFilters
|
||||
: conditionalFilters
|
||||
: filters;
|
||||
const { matchingCategories, matchingFilters } = getMatchingEntries(
|
||||
searchQuery,
|
||||
filterJson(filterJsonObj, excludeFilterKeys, excludeCategory, allowedFilterKeys, mode)
|
||||
);
|
||||
|
||||
const isResultEmpty =
|
||||
(!filterSearchList || Object.keys(filterSearchList).length === 0) &&
|
||||
matchingCategories.length === 0 &&
|
||||
Object.keys(matchingFilters).length === 0;
|
||||
|
||||
const displayedFilters =
|
||||
category === 'All'
|
||||
? Object.entries(matchingFilters).flatMap(([category, filters]) =>
|
||||
filters.map((f: any) => ({ ...f, category }))
|
||||
)
|
||||
: matchingFilters[category];
|
||||
|
||||
|
||||
console.log(displayedFilters)
|
||||
return (
|
||||
<div
|
||||
className={stl.wrapper}
|
||||
style={{ width: '480px', maxHeight: '380px', overflowY: 'auto', borderRadius: '.5rem' }}
|
||||
style={{ width: '560px', maxHeight: '380px' }}
|
||||
>
|
||||
<Input
|
||||
className={'mb-4 rounded-xl text-lg font-medium placeholder:text-lg placeholder:font-medium placeholder:text-neutral-300'}
|
||||
placeholder={'Search'}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
<div className={'flex gap-2 items-start'}>
|
||||
<div className={'flex flex-col gap-1'}>
|
||||
{matchingCategories.map((key) => (
|
||||
<div
|
||||
className={searchQuery && !isResultEmpty ? 'mb-6' : ''}
|
||||
style={{ columns: matchingCategories.length > 1 ? 'auto 200px' : 1 }}
|
||||
>
|
||||
{matchingCategories.map((key) => {
|
||||
return (
|
||||
<div
|
||||
className="mb-6 flex flex-col gap-2 break-inside-avoid"
|
||||
key={key}
|
||||
onClick={() => setCategory(key)}
|
||||
className={cn('rounded-xl px-4 py-2 hover:bg-active-blue capitalize cursor-pointer font-medium', key === category ? 'bg-active-blue text-teal' : '')}
|
||||
>
|
||||
<div className="uppercase font-medium mb-1 color-gray-medium tracking-widest text-sm">
|
||||
{key}
|
||||
</div>
|
||||
<div>
|
||||
{matchingFilters[key] &&
|
||||
matchingFilters[key].map((filter: Record<string, any>) => (
|
||||
<div
|
||||
key={filter.label}
|
||||
className={cn(
|
||||
stl.optionItem,
|
||||
'flex items-center py-2 cursor-pointer -mx-2 px-2 gap-2 rounded-lg hover:shadow-sm'
|
||||
)}
|
||||
onClick={() => onFilterClick({ ...filter, value: [''] })}
|
||||
>
|
||||
{getNewIcon(filter)}
|
||||
<span>{filter.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
className={'flex flex-col gap-1 overflow-y-auto w-full'}
|
||||
style={{ maxHeight: 300, flex: 2 }}
|
||||
>
|
||||
{displayedFilters.length
|
||||
? displayedFilters.map((filter: Record<string, any>) => (
|
||||
<div
|
||||
key={filter.label}
|
||||
className={cn(
|
||||
'flex items-center p-2 cursor-pointer gap-1 rounded-lg hover:bg-active-blue'
|
||||
)}
|
||||
onClick={() => parseAndAdd({ ...filter })}
|
||||
>
|
||||
{filter.category ? <div style={{ width: 100 }} className={'text-neutral-500/90 w-full flex justify-between items-center'}>
|
||||
<span>{filter.subCategory ? filter.subCategory : filter.category}</span>
|
||||
<ChevronRight size={14} />
|
||||
</div> : null}
|
||||
<div className={'flex items-center gap-2'}>
|
||||
<span className='text-neutral-500/90 text-xs'>{getNewIcon(filter)}</span>
|
||||
<span>{filter.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
: null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{showSearchList && (
|
||||
<Loader loading={fetchingFilterSearchList}>
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue