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,
|
onDelete,
|
||||||
style = {height: "calc('100vh - 40px')"},
|
style = {height: "calc('100vh - 40px')"},
|
||||||
} = props;
|
} = props;
|
||||||
const {alertsStore} = useStore()
|
const {alertsStore, metricStore} = useStore()
|
||||||
const {
|
const {
|
||||||
triggerOptions,
|
triggerOptions: allTriggerSeries,
|
||||||
loading,
|
loading,
|
||||||
} = alertsStore
|
} = alertsStore
|
||||||
|
|
||||||
|
const triggerOptions = metricStore.instance.series.length > 0 ? allTriggerSeries.filter(s => {
|
||||||
|
return metricStore.instance.series.findIndex(ms => ms.seriesId === s.value) !== -1
|
||||||
|
}).map(v => {
|
||||||
|
const labelArr = v.label.split('.')
|
||||||
|
labelArr.shift()
|
||||||
|
return {
|
||||||
|
...v,
|
||||||
|
label: labelArr.join('.')
|
||||||
|
}
|
||||||
|
}) : allTriggerSeries
|
||||||
const instance = alertsStore.instance
|
const instance = alertsStore.instance
|
||||||
const deleting = loading
|
const deleting = loading
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Button } from 'antd';
|
import { Button } from 'antd';
|
||||||
import { useModal } from 'App/components/Modal';
|
import { useModal } from 'App/components/Modal';
|
||||||
import SessionSearchField from 'Shared/SessionSearchField';
|
|
||||||
import { MODULES } from 'Components/Client/Modules';
|
import { MODULES } from 'Components/Client/Modules';
|
||||||
|
|
||||||
import AssistStats from '../../AssistStats';
|
import AssistStats from '../../AssistStats';
|
||||||
|
|
@ -9,7 +8,7 @@ import Recordings from '../RecordingsList/Recordings';
|
||||||
import { useStore } from 'App/mstore';
|
import { useStore } from 'App/mstore';
|
||||||
import { observer } from 'mobx-react-lite';
|
import { observer } from 'mobx-react-lite';
|
||||||
|
|
||||||
function AssistSearchField() {
|
function AssistSearchActions() {
|
||||||
const { searchStoreLive, userStore } = useStore();
|
const { searchStoreLive, userStore } = useStore();
|
||||||
const modules = userStore.account.settings?.modules ?? [];
|
const modules = userStore.account.settings?.modules ?? [];
|
||||||
const isEnterprise = userStore.isEnterprise
|
const isEnterprise = userStore.isEnterprise
|
||||||
|
|
@ -27,9 +26,6 @@ function AssistSearchField() {
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center w-full gap-2">
|
<div className="flex items-center w-full gap-2">
|
||||||
<div style={{ width: '60%' }}>
|
|
||||||
<SessionSearchField />
|
|
||||||
</div>
|
|
||||||
{isEnterprise && modules.includes(MODULES.OFFLINE_RECORDINGS)
|
{isEnterprise && modules.includes(MODULES.OFFLINE_RECORDINGS)
|
||||||
? <Button type="primary" ghost onClick={showRecords}>Training Videos</Button> : null
|
? <Button type="primary" ghost onClick={showRecords}>Training Videos</Button> : null
|
||||||
}
|
}
|
||||||
|
|
@ -50,4 +46,4 @@ function AssistSearchField() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default observer(AssistSearchField);
|
export default observer(AssistSearchActions);
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
export { default } from './AssistSearchActions'
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
export { default } from './AssistSearchField'
|
|
||||||
|
|
@ -1,14 +1,14 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import LiveSessionList from 'Shared/LiveSessionList';
|
import LiveSessionList from 'Shared/LiveSessionList';
|
||||||
import LiveSessionSearch from 'Shared/LiveSessionSearch';
|
import LiveSessionSearch from 'Shared/LiveSessionSearch';
|
||||||
import AssistSearchField from './AssistSearchField';
|
import AssistSearchActions from './AssistSearchActions';
|
||||||
import usePageTitle from '@/hooks/usePageTitle';
|
import usePageTitle from '@/hooks/usePageTitle';
|
||||||
|
|
||||||
function AssistView() {
|
function AssistView() {
|
||||||
usePageTitle('Co-Browse - OpenReplay');
|
usePageTitle('Co-Browse - OpenReplay');
|
||||||
return (
|
return (
|
||||||
<div className="w-full mx-auto" style={{ maxWidth: '1360px'}}>
|
<div className="w-full mx-auto" style={{ maxWidth: '1360px'}}>
|
||||||
<AssistSearchField />
|
<AssistSearchActions />
|
||||||
<LiveSessionSearch />
|
<LiveSessionSearch />
|
||||||
<div className="my-4" />
|
<div className="my-4" />
|
||||||
<LiveSessionList />
|
<LiveSessionList />
|
||||||
|
|
|
||||||
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="h-full flex flex-col gap-4">
|
||||||
<div className="flex flex-row gap-2 items-center p-3">
|
<div className="flex flex-row gap-2 items-center p-3">
|
||||||
<Input
|
<Input
|
||||||
placeholder="Search"
|
placeholder="Search projects"
|
||||||
// onSearch={handleSearch}
|
// onSearch={handleSearch}
|
||||||
prefix={<SearchOutlined />}
|
prefix={<SearchOutlined />}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ interface Props {
|
||||||
function CardSessionsByList({ list, selected, paginated, onClickHandler = () => null, metric, total }: Props) {
|
function CardSessionsByList({ list, selected, paginated, onClickHandler = () => null, metric, total }: Props) {
|
||||||
const { dashboardStore, metricStore, sessionStore } = useStore();
|
const { dashboardStore, metricStore, sessionStore } = useStore();
|
||||||
const drillDownPeriod = dashboardStore.drillDownPeriod;
|
const drillDownPeriod = dashboardStore.drillDownPeriod;
|
||||||
const params = { density: 70 };
|
const params = { density: 35 };
|
||||||
const metricParams = { ...params };
|
const metricParams = { ...params };
|
||||||
const [loading, setLoading] = React.useState(false);
|
const [loading, setLoading] = React.useState(false);
|
||||||
const data = paginated ? metric?.data[0]?.values : list;
|
const data = paginated ? metric?.data[0]?.values : list;
|
||||||
|
|
|
||||||
|
|
@ -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 sessionId = metricStore.instance.data.sessionId;
|
||||||
const url = metricStore.instance.data.path;
|
const url = metricStore.instance.data.path;
|
||||||
const operator = metricStore.instance.series[0].filter.filters[0].operator
|
const operator = metricStore.instance.series[0]?.filter.filters[0]?.operator ? metricStore.instance.series[0].filter.filters[0].operator : 'startsWith'
|
||||||
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
|
|
||||||
|
|
@ -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, { useState } from 'react';
|
||||||
import React from 'react'
|
|
||||||
import { ResponsiveContainer, Tooltip } from 'recharts';
|
import { ResponsiveContainer, Tooltip } from 'recharts';
|
||||||
import { PieChart, Pie, Cell } from 'recharts';
|
import { PieChart, Pie, Cell, Legend } from 'recharts';
|
||||||
import { Styles } from '../../common';
|
import { Styles } from '../../common';
|
||||||
import { NoContent } from 'UI';
|
import { NoContent } from 'UI';
|
||||||
import { filtersMap } from 'Types/filter/newFilter';
|
import { filtersMap } from 'Types/filter/newFilter';
|
||||||
import { numberWithCommas } from 'App/utils';
|
import { numberWithCommas } from 'App/utils';
|
||||||
|
import CustomTooltip from '../CustomChartTooltip';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
metric: any,
|
metric: {
|
||||||
data: any;
|
metricOf: string;
|
||||||
|
metricType: string;
|
||||||
|
};
|
||||||
|
data: {
|
||||||
|
chart: any[];
|
||||||
|
namesMap: string[];
|
||||||
|
};
|
||||||
colors: any;
|
colors: any;
|
||||||
onClick?: (filters) => void;
|
onClick?: (filters) => void;
|
||||||
|
inGrid?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function CustomMetricPieChart(props: Props) {
|
function CustomMetricPieChart(props: Props) {
|
||||||
const { metric, data = { values: [] }, onClick = () => null } = props;
|
const { metric, data, onClick = () => null, inGrid } = props;
|
||||||
|
|
||||||
|
const [hoveredSeries, setHoveredSeries] = useState<string | null>(null);
|
||||||
|
|
||||||
const onClickHandler = (event) => {
|
const onClickHandler = (event) => {
|
||||||
if (event && !event.payload.group) {
|
if (event && !event.payload.group) {
|
||||||
const filters = Array<any>();
|
const filters = Array<any>();
|
||||||
let filter = { ...filtersMap[metric.metricOf] }
|
let filter = { ...filtersMap[metric.metricOf] };
|
||||||
filter.value = [event.payload.name]
|
filter.value = [event.payload.name];
|
||||||
filter.type = filter.key
|
filter.type = filter.key;
|
||||||
delete filter.key
|
delete filter.key;
|
||||||
delete filter.operatorOptions
|
delete filter.operatorOptions;
|
||||||
delete filter.category
|
delete filter.category;
|
||||||
delete filter.icon
|
delete filter.icon;
|
||||||
delete filter.label
|
delete filter.label;
|
||||||
delete filter.options
|
delete filter.options;
|
||||||
|
|
||||||
filters.push(filter);
|
filters.push(filter);
|
||||||
onClick(filters);
|
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 (
|
return (
|
||||||
<NoContent size="small" title="No data available" show={!data.values || data.values.length === 0} style={{ minHeight: '240px'}}>
|
<NoContent
|
||||||
<ResponsiveContainer height={ 220 } width="100%">
|
size="small"
|
||||||
|
title="No data available"
|
||||||
|
show={!data.chart || data.chart.length === 0}
|
||||||
|
style={{ minHeight: '240px' }}
|
||||||
|
>
|
||||||
|
<ResponsiveContainer height={240} width="100%">
|
||||||
<PieChart>
|
<PieChart>
|
||||||
|
<Legend iconType={'triangle'} wrapperStyle={{ top: inGrid ? undefined : -18 }} />
|
||||||
|
<Tooltip
|
||||||
|
content={<CustomTooltip hoveredSeries={hoveredSeries} />}
|
||||||
|
/>
|
||||||
<Pie
|
<Pie
|
||||||
isAnimationActive={false}
|
isAnimationActive={false}
|
||||||
data={data.values}
|
data={values}
|
||||||
dataKey="sessionCount"
|
|
||||||
nameKey="name"
|
|
||||||
cx="50%"
|
cx="50%"
|
||||||
cy="50%"
|
cy="60%"
|
||||||
// innerRadius={40}
|
innerRadius={60}
|
||||||
outerRadius={70}
|
outerRadius={100}
|
||||||
// fill={colors[0]}
|
|
||||||
activeIndex={1}
|
activeIndex={1}
|
||||||
onClick={onClickHandler}
|
onClick={onClickHandler}
|
||||||
|
onMouseOver={({ name }) => handleMouseOver(name)}
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
labelLine={({
|
labelLine={({
|
||||||
cx,
|
cx,
|
||||||
cy,
|
cy,
|
||||||
|
|
@ -65,15 +100,22 @@ function CustomMetricPieChart(props: Props) {
|
||||||
let x1 = cx + radius2 * Math.cos(-midAngle * RADIAN);
|
let x1 = cx + radius2 * Math.cos(-midAngle * RADIAN);
|
||||||
let y1 = cy + radius2 * Math.sin(-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 null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<line x1={x1} y1={y1} x2={x2} y2={y2} stroke="#3EAAAF" strokeWidth={1} />
|
<line
|
||||||
)
|
x1={x1}
|
||||||
|
y1={y1}
|
||||||
|
x2={x2}
|
||||||
|
y2={y2}
|
||||||
|
stroke="#3EAAAF"
|
||||||
|
strokeWidth={1}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
label={({
|
label={({
|
||||||
cx,
|
cx,
|
||||||
|
|
@ -82,14 +124,14 @@ function CustomMetricPieChart(props: Props) {
|
||||||
innerRadius,
|
innerRadius,
|
||||||
outerRadius,
|
outerRadius,
|
||||||
value,
|
value,
|
||||||
index
|
index,
|
||||||
}) => {
|
}) => {
|
||||||
const RADIAN = Math.PI / 180;
|
const RADIAN = Math.PI / 180;
|
||||||
let radius = 20 + innerRadius + (outerRadius - innerRadius);
|
let radius = 20 + innerRadius + (outerRadius - innerRadius);
|
||||||
let x = cx + radius * Math.cos(-midAngle * RADIAN);
|
let x = cx + radius * Math.cos(-midAngle * RADIAN);
|
||||||
let y = cy + radius * Math.sin(-midAngle * RADIAN);
|
let y = cy + radius * Math.sin(-midAngle * RADIAN);
|
||||||
const percentage = (value / data.values.reduce((a, b) => a + b.sessionCount, 0)) * 100;
|
const percentage = (value / highest.value) * 100;
|
||||||
let name = data.values[index].name || 'Unidentified';
|
let name = values[index].name || 'Unidentified';
|
||||||
name = name.length > 20 ? name.substring(0, 20) + '...' : name;
|
name = name.length > 20 ? name.substring(0, 20) + '...' : name;
|
||||||
if (percentage < 3) {
|
if (percentage < 3) {
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -100,27 +142,26 @@ function CustomMetricPieChart(props: Props) {
|
||||||
y={y}
|
y={y}
|
||||||
fontWeight="400"
|
fontWeight="400"
|
||||||
fontSize="12px"
|
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"
|
dominantBaseline="central"
|
||||||
fill='#666'
|
fill="#666"
|
||||||
>
|
>
|
||||||
{name || 'Unidentified'} {numberWithCommas(value)}
|
{numberWithCommas(value)}
|
||||||
</text>
|
</text>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{data && data.values && data.values.map((entry, index) => (
|
{values.map((entry, index) => (
|
||||||
<Cell key={`cell-${index}`} fill={Styles.colorsPie[index % Styles.colorsPie.length]} />
|
<Cell
|
||||||
|
key={`cell-${index}`}
|
||||||
|
fill={Styles.safeColors[index % Styles.safeColors.length]}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</Pie>
|
</Pie>
|
||||||
<Tooltip {...Styles.tooltip} />
|
|
||||||
</PieChart>
|
</PieChart>
|
||||||
|
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
<div className="text-sm color-gray-medium">Top 5 </div>
|
|
||||||
</NoContent>
|
</NoContent>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default CustomMetricPieChart;
|
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 compareColorsx = ["#222F99", "#2E3ECC", "#394EFF", "#6171FF", "#8895FF", "#B0B8FF", "#D7DCFF"].reverse();
|
||||||
const customMetricColors = ['#394EFF', '#3EAAAF', '#565D97'];
|
const customMetricColors = ['#394EFF', '#3EAAAF', '#565D97'];
|
||||||
const colorsPie = colors.concat(["#DDDDDD"]);
|
const colorsPie = colors.concat(["#DDDDDD"]);
|
||||||
|
const safeColors = ['#394EFF', '#3EAAAF', '#9276da', '#ceba64', "#bc6f9d", '#966fbc', '#64ce86', '#e06da3', '#6dabe0'];
|
||||||
|
|
||||||
const countView = count => {
|
const countView = count => {
|
||||||
const isMoreThanK = count >= 1000;
|
const isMoreThanK = count >= 1000;
|
||||||
|
|
@ -22,6 +23,7 @@ export default {
|
||||||
colorsx,
|
colorsx,
|
||||||
compareColors,
|
compareColors,
|
||||||
compareColorsx,
|
compareColorsx,
|
||||||
|
safeColors,
|
||||||
lineColor: '#2A7B7F',
|
lineColor: '#2A7B7F',
|
||||||
lineColorCompare: '#394EFF',
|
lineColorCompare: '#394EFF',
|
||||||
strokeColor: compareColors[0],
|
strokeColor: compareColors[0],
|
||||||
|
|
@ -29,13 +31,13 @@ export default {
|
||||||
axisLine: {stroke: '#CCCCCC'},
|
axisLine: {stroke: '#CCCCCC'},
|
||||||
interval: 0,
|
interval: 0,
|
||||||
dataKey: "time",
|
dataKey: "time",
|
||||||
tick: {fill: '#999999', fontSize: 9},
|
tick: {fill: '#000000', fontSize: 9},
|
||||||
tickLine: {stroke: '#CCCCCC'},
|
tickLine: {stroke: '#CCCCCC'},
|
||||||
strokeWidth: 0.5
|
strokeWidth: 0.5
|
||||||
},
|
},
|
||||||
yaxis: {
|
yaxis: {
|
||||||
axisLine: {stroke: '#CCCCCC'},
|
axisLine: {stroke: '#CCCCCC'},
|
||||||
tick: {fill: '#999999', fontSize: 9},
|
tick: {fill: '#000000', fontSize: 9},
|
||||||
tickLine: {stroke: '#CCCCCC'},
|
tickLine: {stroke: '#CCCCCC'},
|
||||||
},
|
},
|
||||||
axisLabelLeft: {
|
axisLabelLeft: {
|
||||||
|
|
@ -50,8 +52,8 @@ export default {
|
||||||
tickFormatterBytes: val => Math.round(val / 1024 / 1024),
|
tickFormatterBytes: val => Math.round(val / 1024 / 1024),
|
||||||
chartMargins: {left: 0, right: 20, top: 10, bottom: 5},
|
chartMargins: {left: 0, right: 20, top: 10, bottom: 5},
|
||||||
tooltip: {
|
tooltip: {
|
||||||
cursor: {
|
wrapperStyle: {
|
||||||
fill: '#f6f6f6'
|
zIndex: 999,
|
||||||
},
|
},
|
||||||
contentStyle: {
|
contentStyle: {
|
||||||
padding: '5px',
|
padding: '5px',
|
||||||
|
|
@ -73,6 +75,9 @@ export default {
|
||||||
lineHeight: '0.75rem',
|
lineHeight: '0.75rem',
|
||||||
color: '#000',
|
color: '#000',
|
||||||
fontSize: '12px'
|
fontSize: '12px'
|
||||||
|
},
|
||||||
|
cursor: {
|
||||||
|
fill: '#eee'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
gradientDef: () => (
|
gradientDef: () => (
|
||||||
|
|
|
||||||
|
|
@ -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 React from 'react';
|
||||||
import {Grid2x2Check} from "lucide-react"
|
import { Grid2x2Check } from 'lucide-react';
|
||||||
import {Button, Modal} from "antd";
|
import { Button, Modal } from 'antd';
|
||||||
import Select from "Shared/Select/Select";
|
import Select from 'Shared/Select/Select';
|
||||||
import {Form} from "UI";
|
import { Form } from 'UI';
|
||||||
import {useStore} from "App/mstore";
|
import { useStore } from 'App/mstore';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
metricId: string;
|
metricId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function AddToDashboardButton({metricId}: Props) {
|
export const showAddToDashboardModal = (metricId: string, dashboardStore: any) => {
|
||||||
const {dashboardStore} = useStore();
|
|
||||||
const dashboardOptions = dashboardStore.dashboards.map((i: any) => ({
|
const dashboardOptions = dashboardStore.dashboards.map((i: any) => ({
|
||||||
key: i.id,
|
key: i.id,
|
||||||
label: i.name,
|
label: i.name,
|
||||||
value: i.dashboardId,
|
value: i.dashboardId,
|
||||||
}));
|
}));
|
||||||
const [selectedId, setSelectedId] = React.useState(dashboardOptions[0]?.value);
|
let selectedId = dashboardOptions[0]?.value;
|
||||||
|
|
||||||
const onSave = (close: any) => {
|
const onSave = (close: any) => {
|
||||||
const dashboard = dashboardStore.getDashboard(selectedId)
|
const dashboard = dashboardStore.getDashboard(selectedId);
|
||||||
if (dashboard) {
|
if (dashboard) {
|
||||||
dashboardStore.addWidgetToDashboard(dashboard, [metricId]).then(close)
|
dashboardStore.addWidgetToDashboard(dashboard, [metricId]).then(close);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const onClick = () => {
|
|
||||||
Modal.confirm({
|
Modal.confirm({
|
||||||
title: 'Add to selected dashboard',
|
title: 'Add to selected dashboard',
|
||||||
icon: null,
|
icon: null,
|
||||||
|
|
@ -33,8 +33,8 @@ function AddToDashboardButton({metricId}: Props) {
|
||||||
<Form.Field>
|
<Form.Field>
|
||||||
<Select
|
<Select
|
||||||
options={dashboardOptions}
|
options={dashboardOptions}
|
||||||
defaultValue={dashboardOptions[0].value}
|
defaultValue={selectedId}
|
||||||
onChange={({value}: any) => setSelectedId(value.value)}
|
onChange={({ value }: any) => (selectedId = value.value)}
|
||||||
/>
|
/>
|
||||||
</Form.Field>
|
</Form.Field>
|
||||||
),
|
),
|
||||||
|
|
@ -47,18 +47,21 @@ function AddToDashboardButton({metricId}: Props) {
|
||||||
<OkBtn />
|
<OkBtn />
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
})
|
});
|
||||||
}
|
};
|
||||||
|
|
||||||
|
const AddToDashboardButton = ({ metricId }: Props) => {
|
||||||
|
const { dashboardStore } = useStore();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
type="default"
|
type="default"
|
||||||
onClick={onClick}
|
onClick={() => showAddToDashboardModal(metricId, dashboardStore)}
|
||||||
icon={<Grid2x2Check size={18} />}
|
icon={<Grid2x2Check size={18} />}
|
||||||
>
|
>
|
||||||
Add to Dashboard
|
Add to Dashboard
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export default AddToDashboardButton;
|
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='w-full flex items-center justify-between pt-4 px-6'>
|
||||||
<div className=''>
|
<div className=''>
|
||||||
Showing <span className='font-semibold'>{Math.min(list.length, pageSize)}</span> out of{' '}
|
Showing <span className='font-medium'>{Math.min(list.length, pageSize)}</span> out of{' '}
|
||||||
<span className='font-semibold'>{list.length}</span> Alerts
|
<span className='font-medium'>{list.length}</span> Alerts
|
||||||
</div>
|
</div>
|
||||||
<Pagination
|
<Pagination
|
||||||
page={page}
|
page={page}
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,7 @@ const NewAlert = (props: IProps) => {
|
||||||
triggerOptions,
|
triggerOptions,
|
||||||
loading,
|
loading,
|
||||||
} = alertsStore
|
} = alertsStore
|
||||||
|
|
||||||
const deleting = loading
|
const deleting = loading
|
||||||
const webhooks = settingsStore.webhooks
|
const webhooks = settingsStore.webhooks
|
||||||
const fetchWebhooks = settingsStore.fetchWebhooks
|
const fetchWebhooks = settingsStore.fetchWebhooks
|
||||||
|
|
|
||||||
|
|
@ -59,8 +59,8 @@ function CardUserList(props: RouteComponentProps<Props>) {
|
||||||
|
|
||||||
<div className="w-full flex items-center justify-between pt-4">
|
<div className="w-full flex items-center justify-between pt-4">
|
||||||
<div className="text-disabled-text">
|
<div className="text-disabled-text">
|
||||||
Showing <span className="font-semibold">{Math.min(data.length, pageSize)}</span> out of{' '}
|
Showing <span className="font-medium">{Math.min(data.length, pageSize)}</span> out of{' '}
|
||||||
<span className="font-semibold">{data.length}</span> Issues
|
<span className="font-medium">{data.length}</span> Issues
|
||||||
</div>
|
</div>
|
||||||
<Pagination
|
<Pagination
|
||||||
page={metricStore.sessionsPage}
|
page={metricStore.sessionsPage}
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,45 @@
|
||||||
import React from "react";
|
import React from 'react';
|
||||||
import {PlusOutlined} from "@ant-design/icons";
|
import { PlusOutlined } from '@ant-design/icons';
|
||||||
import NewDashboardModal from "Components/Dashboard/components/DashboardList/NewDashModal";
|
import { Button } from 'antd';
|
||||||
import {Button} from "antd";
|
import { useStore } from 'App/mstore';
|
||||||
|
import { useHistory } from 'react-router-dom';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function CreateDashboardButton({disabled = false}: Props) {
|
function CreateDashboardButton({ disabled }: Props) {
|
||||||
const [showModal, setShowModal] = React.useState(false);
|
const [dashboardCreating, setDashboardCreating] = React.useState(false);
|
||||||
|
const { projectsStore, dashboardStore } = useStore();
|
||||||
|
const siteId = projectsStore.siteId;
|
||||||
|
const history = useHistory();
|
||||||
|
|
||||||
return <>
|
const createNewDashboard = async () => {
|
||||||
|
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
|
<Button
|
||||||
|
loading={dashboardCreating}
|
||||||
icon={<PlusOutlined />}
|
icon={<PlusOutlined />}
|
||||||
|
disabled={disabled}
|
||||||
type="primary"
|
type="primary"
|
||||||
onClick={() => setShowModal(true)}
|
onClick={createNewDashboard}
|
||||||
>
|
>
|
||||||
Create Dashboard
|
Create Dashboard
|
||||||
</Button>
|
</Button>
|
||||||
<NewDashboardModal onClose={() => setShowModal(false)} open={showModal}/>
|
</>
|
||||||
</>;
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default CreateDashboardButton;
|
export default CreateDashboardButton;
|
||||||
|
|
|
||||||
|
|
@ -1,40 +1,31 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
//import {Breadcrumb} from 'Shared/Breadcrumb';
|
import BackButton from 'Shared/Breadcrumb/BackButton';
|
||||||
import BackButton from '../../../shared/Breadcrumb/BackButton';
|
|
||||||
import { withSiteId } from 'App/routes';
|
import { withSiteId } from 'App/routes';
|
||||||
import { withRouter, RouteComponentProps } from 'react-router-dom';
|
import { withRouter, RouteComponentProps } from 'react-router-dom';
|
||||||
import { Button, PageTitle, confirm, Tooltip } from 'UI';
|
import { PageTitle, confirm } from 'UI';
|
||||||
|
import { Tooltip } from 'antd';
|
||||||
import SelectDateRange from 'Shared/SelectDateRange';
|
import SelectDateRange from 'Shared/SelectDateRange';
|
||||||
import { useStore } from 'App/mstore';
|
import { useStore } from 'App/mstore';
|
||||||
import { useModal } from 'App/components/Modal';
|
|
||||||
import DashboardOptions from '../DashboardOptions';
|
import DashboardOptions from '../DashboardOptions';
|
||||||
import withModal from 'App/components/Modal/withModal';
|
import withModal from 'App/components/Modal/withModal';
|
||||||
import { observer } from 'mobx-react-lite';
|
import { observer } from 'mobx-react-lite';
|
||||||
import DashboardEditModal from '../DashboardEditModal';
|
import DashboardEditModal from '../DashboardEditModal';
|
||||||
import CreateDashboardButton from 'Components/Dashboard/components/CreateDashboardButton';
|
|
||||||
import CreateCard from 'Components/Dashboard/components/DashboardList/NewDashModal/CreateCard';
|
|
||||||
import CreateCardButton from 'Components/Dashboard/components/CreateCardButton';
|
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
dashboardId: string;
|
|
||||||
siteId: string;
|
siteId: string;
|
||||||
renderReport?: any;
|
renderReport?: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
type Props = IProps & RouteComponentProps;
|
type Props = IProps & RouteComponentProps;
|
||||||
const MAX_CARDS = 29;
|
|
||||||
|
|
||||||
function DashboardHeader(props: Props) {
|
function DashboardHeader(props: Props) {
|
||||||
const { siteId, dashboardId } = props;
|
const { siteId } = props;
|
||||||
const { dashboardStore } = useStore();
|
const { dashboardStore } = useStore();
|
||||||
const { showModal } = useModal();
|
|
||||||
const [focusTitle, setFocusedInput] = React.useState(true);
|
const [focusTitle, setFocusedInput] = React.useState(true);
|
||||||
const [showEditModal, setShowEditModal] = React.useState(false);
|
const [showEditModal, setShowEditModal] = React.useState(false);
|
||||||
const period = dashboardStore.period;
|
const period = dashboardStore.period;
|
||||||
|
|
||||||
const dashboard: any = dashboardStore.selectedDashboard;
|
const dashboard: any = dashboardStore.selectedDashboard;
|
||||||
const canAddMore: boolean = dashboard?.widgets?.length <= MAX_CARDS;
|
|
||||||
|
|
||||||
const onEdit = (isTitle: boolean) => {
|
const onEdit = (isTitle: boolean) => {
|
||||||
dashboardStore.initDashboard(dashboard);
|
dashboardStore.initDashboard(dashboard);
|
||||||
|
|
@ -47,7 +38,7 @@ function DashboardHeader(props: Props) {
|
||||||
await confirm({
|
await confirm({
|
||||||
header: 'Delete Dashboard',
|
header: 'Delete Dashboard',
|
||||||
confirmButton: 'Yes, delete',
|
confirmButton: 'Yes, delete',
|
||||||
confirmation: `Are you sure you want to permanently delete this Dashboard?`
|
confirmation: `Are you sure you want to permanently delete this Dashboard?`,
|
||||||
})
|
})
|
||||||
) {
|
) {
|
||||||
dashboardStore.deleteDashboard(dashboard).then(() => {
|
dashboardStore.deleteDashboard(dashboard).then(() => {
|
||||||
|
|
@ -56,32 +47,26 @@ function DashboardHeader(props: Props) {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<div>
|
<>
|
||||||
<DashboardEditModal
|
<DashboardEditModal
|
||||||
show={showEditModal}
|
show={showEditModal}
|
||||||
closeHandler={() => setShowEditModal(false)}
|
closeHandler={() => setShowEditModal(false)}
|
||||||
focusTitle={focusTitle}
|
focusTitle={focusTitle}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex items-center mb-2 justify-between">
|
|
||||||
<div className="flex items-center" style={{ flex: 3 }}>
|
|
||||||
|
|
||||||
<BackButton siteId={siteId} />
|
<div className="flex items-center justify-between px-4 pt-4 bg-white">
|
||||||
|
<div className="flex items-center gap-2" style={{ flex: 3 }}>
|
||||||
{/* <Breadcrumb
|
<BackButton siteId={siteId} compact />
|
||||||
items={[
|
|
||||||
{
|
|
||||||
label: 'Back',
|
|
||||||
to: withSiteId('/dashboard', siteId),
|
|
||||||
},
|
|
||||||
{label: (dashboard && dashboard.name) || ''},
|
|
||||||
]}
|
|
||||||
/> */}
|
|
||||||
|
|
||||||
<PageTitle
|
<PageTitle
|
||||||
title={
|
title={
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
<Tooltip delay={0} title="Double click to edit" placement="bottom">
|
<Tooltip
|
||||||
|
delay={0}
|
||||||
|
title="Double click to edit"
|
||||||
|
placement="bottom"
|
||||||
|
>
|
||||||
{dashboard?.name}
|
{dashboard?.name}
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
}
|
}
|
||||||
|
|
@ -89,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"
|
className="mr-3 select-none border-b border-b-borderColor-transparent hover:border-dashed hover:border-gray-medium cursor-pointer"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2" style={{ flex: 1, justifyContent: 'end' }}>
|
|
||||||
<CreateCardButton disabled={canAddMore} />
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="flex items-center flex-shrink-0 justify-end dashboardDataPeriodSelector"
|
className="flex items-center gap-2"
|
||||||
style={{ width: 'fit-content' }}
|
style={{ flex: 1, justifyContent: 'end' }}
|
||||||
>
|
>
|
||||||
<SelectDateRange
|
<SelectDateRange
|
||||||
style={{ width: '300px' }}
|
style={{ width: '300px' }}
|
||||||
|
|
@ -104,9 +86,7 @@ function DashboardHeader(props: Props) {
|
||||||
isAnt={true}
|
isAnt={true}
|
||||||
useButtonStyle={true}
|
useButtonStyle={true}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center flex-shrink-0">
|
|
||||||
<DashboardOptions
|
<DashboardOptions
|
||||||
editHandler={onEdit}
|
editHandler={onEdit}
|
||||||
deleteHandler={onDelete}
|
deleteHandler={onDelete}
|
||||||
|
|
@ -115,19 +95,7 @@ function DashboardHeader(props: Props) {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
<div className="pb-4">
|
|
||||||
{/* @ts-ignore */}
|
|
||||||
<Tooltip arrow title="Double click to edit" placement="top" className="w-fit !block">
|
|
||||||
<h2
|
|
||||||
className="my-2 font-normal w-fit text-disabled-text border-b border-b-borderColor-transparent hover:border-dotted hover:border-gray-medium cursor-pointer"
|
|
||||||
onDoubleClick={() => onEdit(false)}
|
|
||||||
>
|
|
||||||
{/* {dashboard?.description || 'Describe the purpose of this dashboard'} */}
|
|
||||||
</h2>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
import { LockOutlined, TeamOutlined } from '@ant-design/icons';
|
import { observer } from 'mobx-react-lite';
|
||||||
|
import React from 'react';
|
||||||
|
import { useHistory } from 'react-router';
|
||||||
import {
|
import {
|
||||||
Empty,
|
Empty,
|
||||||
Switch,
|
Switch,
|
||||||
|
|
@ -7,18 +9,16 @@ import {
|
||||||
Tag,
|
Tag,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Typography,
|
Typography,
|
||||||
|
Dropdown,
|
||||||
|
Button,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import { observer } from 'mobx-react-lite';
|
import { LockOutlined, TeamOutlined, MoreOutlined } from '@ant-design/icons';
|
||||||
import React from 'react';
|
|
||||||
import { useHistory } from 'react-router';
|
|
||||||
|
|
||||||
import { checkForRecent } from 'App/date';
|
import { checkForRecent } from 'App/date';
|
||||||
import { useStore } from 'App/mstore';
|
import { useStore } from 'App/mstore';
|
||||||
import Dashboard from 'App/mstore/types/dashboard';
|
import Dashboard from 'App/mstore/types/dashboard';
|
||||||
import { dashboardSelected, withSiteId } from 'App/routes';
|
import { dashboardSelected, withSiteId } from 'App/routes';
|
||||||
import CreateDashboardButton from 'Components/Dashboard/components/CreateDashboardButton';
|
import CreateDashboardButton from 'Components/Dashboard/components/CreateDashboardButton';
|
||||||
import { ItemMenu, confirm } from 'UI';
|
import { Icon, confirm } from 'UI';
|
||||||
|
|
||||||
import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG';
|
import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG';
|
||||||
|
|
||||||
import DashboardEditModal from '../DashboardEditModal';
|
import DashboardEditModal from '../DashboardEditModal';
|
||||||
|
|
@ -26,6 +26,7 @@ import DashboardEditModal from '../DashboardEditModal';
|
||||||
function DashboardList() {
|
function DashboardList() {
|
||||||
const { dashboardStore, projectsStore } = useStore();
|
const { dashboardStore, projectsStore } = useStore();
|
||||||
const siteId = projectsStore.siteId;
|
const siteId = projectsStore.siteId;
|
||||||
|
const optionsRef = React.useRef<HTMLDivElement>(null);
|
||||||
const [focusTitle, setFocusedInput] = React.useState(true);
|
const [focusTitle, setFocusedInput] = React.useState(true);
|
||||||
const [showEditModal, setShowEditModal] = React.useState(false);
|
const [showEditModal, setShowEditModal] = React.useState(false);
|
||||||
|
|
||||||
|
|
@ -103,6 +104,7 @@ function DashboardList() {
|
||||||
}
|
}
|
||||||
checkedChildren={'Team'}
|
checkedChildren={'Team'}
|
||||||
unCheckedChildren={'Private'}
|
unCheckedChildren={'Private'}
|
||||||
|
className="toggle-team-private"
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -121,23 +123,52 @@ function DashboardList() {
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
title: 'Options',
|
title: '',
|
||||||
dataIndex: 'dashboardId',
|
dataIndex: 'dashboardId',
|
||||||
width: '5%',
|
width: '5%',
|
||||||
onCell: () => ({ onClick: (e) => e.stopPropagation() }),
|
|
||||||
render: (id) => (
|
render: (id) => (
|
||||||
<ItemMenu
|
<div onClick={(e) => e.stopPropagation()}>
|
||||||
bold
|
<Dropdown
|
||||||
items={[
|
arrow={false}
|
||||||
{ icon: 'pencil', text: 'Rename', onClick: () => onEdit(id, true) },
|
trigger={['click']}
|
||||||
|
className={'ignore-prop-dp'}
|
||||||
|
menu={{
|
||||||
|
items: [
|
||||||
{
|
{
|
||||||
icon: 'users',
|
icon: <Icon name={'pencil'} />,
|
||||||
text: 'Visibility & Access',
|
key: 'rename',
|
||||||
onClick: () => onEdit(id, false),
|
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) =>
|
showTotal: (total, range) =>
|
||||||
`Showing ${range[0]}-${range[1]} of ${total} items`,
|
`Showing ${range[0]}-${range[1]} of ${total} items`,
|
||||||
size: 'small',
|
size: 'small',
|
||||||
|
simple: 'true',
|
||||||
|
className: 'px-4 pr-8 mb-0',
|
||||||
}}
|
}}
|
||||||
onRow={(record) => ({
|
onRow={(record) => ({
|
||||||
onClick: () => {
|
onClick: (e) => {
|
||||||
|
const possibleDropdown =
|
||||||
|
document.querySelector('.ant-dropdown-menu');
|
||||||
|
const btn = document.querySelector('#ignore-prop');
|
||||||
|
if (
|
||||||
|
e.target.classList.contains('lucide') ||
|
||||||
|
e.target.id === 'ignore-prop' ||
|
||||||
|
possibleDropdown?.contains(e.target) ||
|
||||||
|
btn?.contains(e.target)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
dashboardStore.selectDashboardById(record.dashboardId);
|
dashboardStore.selectDashboardById(record.dashboardId);
|
||||||
const path = withSiteId(
|
const path = withSiteId(
|
||||||
dashboardSelected(record.dashboardId),
|
dashboardSelected(record.dashboardId),
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ function DashboardSearch() {
|
||||||
value={query}
|
value={query}
|
||||||
allowClear
|
allowClear
|
||||||
name="dashboardsSearch"
|
name="dashboardsSearch"
|
||||||
className="w-full"
|
className="w-full btn-search-dashboard"
|
||||||
placeholder="Filter by dashboard title"
|
placeholder="Filter by dashboard title"
|
||||||
onChange={write}
|
onChange={write}
|
||||||
onSearch={(value) => dashboardStore.updateKey('filter', { ...dashboardStore.filter, query: value })}
|
onSearch={(value) => dashboardStore.updateKey('filter', { ...dashboardStore.filter, query: value })}
|
||||||
|
|
|
||||||
|
|
@ -9,24 +9,13 @@ import {
|
||||||
HEATMAP,
|
HEATMAP,
|
||||||
ERRORS,
|
ERRORS,
|
||||||
FUNNEL,
|
FUNNEL,
|
||||||
INSIGHTS,
|
|
||||||
TABLE,
|
TABLE,
|
||||||
TIMESERIES,
|
TIMESERIES,
|
||||||
USER_PATH,
|
USER_PATH,
|
||||||
PERFORMANCE,
|
} from "App/constants/card";
|
||||||
} from 'App/constants/card';
|
|
||||||
import { FilterKey } from 'Types/filter/filterType';
|
import { FilterKey } from 'Types/filter/filterType';
|
||||||
import { BarChart, TrendingUp, SearchSlash } from 'lucide-react';
|
import { BarChart, TrendingUp, SearchSlash } from 'lucide-react';
|
||||||
import ByIssues from 'Components/Dashboard/components/DashboardList/NewDashModal/Examples/SessionsBy/ByIssues';
|
|
||||||
import InsightsExample from 'Components/Dashboard/components/DashboardList/NewDashModal/Examples/InsightsExample';
|
|
||||||
import ByUser from 'Components/Dashboard/components/DashboardList/NewDashModal/Examples/SessionsBy/ByUser';
|
import ByUser from 'Components/Dashboard/components/DashboardList/NewDashModal/Examples/SessionsBy/ByUser';
|
||||||
import BarChartCard from 'Components/Dashboard/components/DashboardList/NewDashModal/Examples/BarChart';
|
|
||||||
import SpeedIndexByLocationExample
|
|
||||||
from 'Components/Dashboard/components/DashboardList/NewDashModal/Examples/SpeedIndexByLocationExample';
|
|
||||||
import CallsWithErrorsExample
|
|
||||||
from 'Components/Dashboard/components/DashboardList/NewDashModal/Examples/CallsWithErrorsExample';
|
|
||||||
import SlowestDomains
|
|
||||||
from 'Components/Dashboard/components/DashboardList/NewDashModal/Examples/SessionsBy/SlowestDomains';
|
|
||||||
import HeatmapsExample from 'Components/Dashboard/components/DashboardList/NewDashModal/Examples/HeatmapsExample';
|
import HeatmapsExample from 'Components/Dashboard/components/DashboardList/NewDashModal/Examples/HeatmapsExample';
|
||||||
import ByReferrer from 'Components/Dashboard/components/DashboardList/NewDashModal/Examples/SessionsBy/ByRferrer';
|
import ByReferrer from 'Components/Dashboard/components/DashboardList/NewDashModal/Examples/SessionsBy/ByRferrer';
|
||||||
import ByFetch from 'Components/Dashboard/components/DashboardList/NewDashModal/Examples/SessionsBy/ByFecth';
|
import ByFetch from 'Components/Dashboard/components/DashboardList/NewDashModal/Examples/SessionsBy/ByFecth';
|
||||||
|
|
@ -60,7 +49,7 @@ export interface CardType {
|
||||||
|
|
||||||
export const CARD_LIST: CardType[] = [
|
export const CARD_LIST: CardType[] = [
|
||||||
{
|
{
|
||||||
title: 'Funnel',
|
title: 'Untitled Funnel',
|
||||||
key: FUNNEL,
|
key: FUNNEL,
|
||||||
cardType: FUNNEL,
|
cardType: FUNNEL,
|
||||||
category: CARD_CATEGORIES[0].key,
|
category: CARD_CATEGORIES[0].key,
|
||||||
|
|
@ -92,7 +81,7 @@ export const CARD_LIST: CardType[] = [
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Heatmaps',
|
title: 'Untitled Heatmaps',
|
||||||
key: HEATMAP,
|
key: HEATMAP,
|
||||||
cardType: HEATMAP,
|
cardType: HEATMAP,
|
||||||
metricOf: 'heatMapUrl',
|
metricOf: 'heatMapUrl',
|
||||||
|
|
@ -100,14 +89,14 @@ export const CARD_LIST: CardType[] = [
|
||||||
example: HeatmapsExample
|
example: HeatmapsExample
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Path Finder',
|
title: 'Untitled Journey',
|
||||||
key: USER_PATH,
|
key: USER_PATH,
|
||||||
cardType: USER_PATH,
|
cardType: USER_PATH,
|
||||||
category: CARD_CATEGORIES[0].key,
|
category: CARD_CATEGORIES[0].key,
|
||||||
example: ExamplePath
|
example: ExamplePath
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Sessions Trend',
|
title: 'Untitled Trend',
|
||||||
key: TIMESERIES,
|
key: TIMESERIES,
|
||||||
cardType: TIMESERIES,
|
cardType: TIMESERIES,
|
||||||
metricOf: 'sessionCount',
|
metricOf: 'sessionCount',
|
||||||
|
|
@ -122,7 +111,7 @@ export const CARD_LIST: CardType[] = [
|
||||||
example: ExampleTrend
|
example: ExampleTrend
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Users Trend',
|
title: 'Untitled Users Trend',
|
||||||
key: TIMESERIES + '_userCount',
|
key: TIMESERIES + '_userCount',
|
||||||
cardType: TIMESERIES,
|
cardType: TIMESERIES,
|
||||||
metricOf: 'userCount',
|
metricOf: 'userCount',
|
||||||
|
|
@ -140,7 +129,7 @@ export const CARD_LIST: CardType[] = [
|
||||||
|
|
||||||
// Web analytics
|
// Web analytics
|
||||||
{
|
{
|
||||||
title: 'Top Users',
|
title: 'Untitled Top Users',
|
||||||
key: FilterKey.USERID,
|
key: FilterKey.USERID,
|
||||||
cardType: TABLE,
|
cardType: TABLE,
|
||||||
metricOf: FilterKey.USERID,
|
metricOf: FilterKey.USERID,
|
||||||
|
|
@ -149,7 +138,7 @@ export const CARD_LIST: CardType[] = [
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
title: 'Top Browsers',
|
title: 'Untitled Top Browsers',
|
||||||
key: FilterKey.USER_BROWSER,
|
key: FilterKey.USER_BROWSER,
|
||||||
cardType: TABLE,
|
cardType: TABLE,
|
||||||
metricOf: FilterKey.USER_BROWSER,
|
metricOf: FilterKey.USER_BROWSER,
|
||||||
|
|
@ -165,7 +154,7 @@ export const CARD_LIST: CardType[] = [
|
||||||
// example: BySystem,
|
// example: BySystem,
|
||||||
// },
|
// },
|
||||||
{
|
{
|
||||||
title: 'Top Countries',
|
title: 'Untitled Top Countries',
|
||||||
key: FilterKey.USER_COUNTRY,
|
key: FilterKey.USER_COUNTRY,
|
||||||
cardType: TABLE,
|
cardType: TABLE,
|
||||||
metricOf: FilterKey.USER_COUNTRY,
|
metricOf: FilterKey.USER_COUNTRY,
|
||||||
|
|
@ -174,7 +163,7 @@ export const CARD_LIST: CardType[] = [
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
title: 'Top Devices',
|
title: 'Untitled Top Devices',
|
||||||
key: FilterKey.USER_DEVICE,
|
key: FilterKey.USER_DEVICE,
|
||||||
cardType: TABLE,
|
cardType: TABLE,
|
||||||
metricOf: FilterKey.USER_DEVICE,
|
metricOf: FilterKey.USER_DEVICE,
|
||||||
|
|
@ -182,7 +171,7 @@ export const CARD_LIST: CardType[] = [
|
||||||
example: BySystem
|
example: BySystem
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Top Pages',
|
title: 'Untitled Top Pages',
|
||||||
key: FilterKey.LOCATION,
|
key: FilterKey.LOCATION,
|
||||||
cardType: TABLE,
|
cardType: TABLE,
|
||||||
metricOf: FilterKey.LOCATION,
|
metricOf: FilterKey.LOCATION,
|
||||||
|
|
@ -191,7 +180,7 @@ export const CARD_LIST: CardType[] = [
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
title: 'Top Referrer',
|
title: 'Untitled Top Referrer',
|
||||||
key: FilterKey.REFERRER,
|
key: FilterKey.REFERRER,
|
||||||
cardType: TABLE,
|
cardType: TABLE,
|
||||||
metricOf: FilterKey.REFERRER,
|
metricOf: FilterKey.REFERRER,
|
||||||
|
|
@ -201,7 +190,7 @@ export const CARD_LIST: CardType[] = [
|
||||||
|
|
||||||
// Monitors
|
// Monitors
|
||||||
{
|
{
|
||||||
title: 'Table of Errors',
|
title: 'Untitled Table of Errors',
|
||||||
key: FilterKey.ERRORS,
|
key: FilterKey.ERRORS,
|
||||||
cardType: TABLE,
|
cardType: TABLE,
|
||||||
metricOf: FilterKey.ERRORS,
|
metricOf: FilterKey.ERRORS,
|
||||||
|
|
@ -216,7 +205,7 @@ export const CARD_LIST: CardType[] = [
|
||||||
example: TableOfErrors
|
example: TableOfErrors
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Top Network Requests',
|
title: 'Untitled Top Network Requests',
|
||||||
key: FilterKey.FETCH,
|
key: FilterKey.FETCH,
|
||||||
cardType: TABLE,
|
cardType: TABLE,
|
||||||
metricOf: FilterKey.FETCH,
|
metricOf: FilterKey.FETCH,
|
||||||
|
|
@ -224,7 +213,7 @@ export const CARD_LIST: CardType[] = [
|
||||||
example: ByFetch
|
example: ByFetch
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Sessions with 4xx/5xx Requests',
|
title: 'Untitled Sessions with 4xx/5xx Requests',
|
||||||
key: TIMESERIES + '_4xx_requests',
|
key: TIMESERIES + '_4xx_requests',
|
||||||
cardType: TIMESERIES,
|
cardType: TIMESERIES,
|
||||||
metricOf: 'sessionCount',
|
metricOf: 'sessionCount',
|
||||||
|
|
@ -258,7 +247,7 @@ export const CARD_LIST: CardType[] = [
|
||||||
example: ExampleTrend
|
example: ExampleTrend
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Sessions with Slow Network Requests',
|
title: 'Untitled Sessions with Slow Network Requests',
|
||||||
key: TIMESERIES + '_slow_network_requests',
|
key: TIMESERIES + '_slow_network_requests',
|
||||||
cardType: TIMESERIES,
|
cardType: TIMESERIES,
|
||||||
metricOf: 'sessionCount',
|
metricOf: 'sessionCount',
|
||||||
|
|
|
||||||
|
|
@ -57,7 +57,7 @@ function AreaChartCard(props: Props) {
|
||||||
margin={Styles.chartMargins}
|
margin={Styles.chartMargins}
|
||||||
>
|
>
|
||||||
{gradientDef}
|
{gradientDef}
|
||||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#EEEEEE"/>
|
<CartesianGrid strokeDasharray="1 3" vertical={false} stroke="rgba(0,0,0,1.5)"/>
|
||||||
<XAxis {...Styles.xaxis} dataKey="time" interval={3}/>
|
<XAxis {...Styles.xaxis} dataKey="time" interval={3}/>
|
||||||
<YAxis
|
<YAxis
|
||||||
{...Styles.yaxis}
|
{...Styles.yaxis}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import ExCard from './ExCard';
|
import ExCard from './ExCard';
|
||||||
import AreaChartCard from "Components/Dashboard/components/DashboardList/NewDashModal/Examples/AreaChartCard";
|
import LineChart from 'App/components/Charts/LineChart'
|
||||||
import CustomMetricLineChart from "Components/Dashboard/Widgets/CustomMetricsWidgets/CustomMetricLineChart";
|
|
||||||
import {Styles} from "Components/Dashboard/Widgets/common";
|
import {Styles} from "Components/Dashboard/Widgets/common";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
@ -24,7 +23,7 @@ function ExampleTrend(props: Props) {
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{/*<AreaChartCard data={props.data} label={props.data?.label}/>*/}
|
{/*<AreaChartCard data={props.data} label={props.data?.label}/>*/}
|
||||||
<CustomMetricLineChart
|
<LineChart
|
||||||
data={props.data}
|
data={props.data}
|
||||||
colors={Styles.compareColors}
|
colors={Styles.compareColors}
|
||||||
params={{
|
params={{
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { ItemMenu } from 'UI';
|
|
||||||
import { observer } from 'mobx-react-lite';
|
import { observer } from 'mobx-react-lite';
|
||||||
import { useStore } from "App/mstore";
|
import { useStore } from 'App/mstore';
|
||||||
import { ENTERPRISE_REQUEIRED } from 'App/constants';
|
import { ENTERPRISE_REQUEIRED } from 'App/constants';
|
||||||
|
import { Dropdown, Button } from 'antd';
|
||||||
|
import { EllipsisVertical } from 'lucide-react';
|
||||||
|
import { Icon } from 'UI';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
editHandler: (isTitle: boolean) => void;
|
editHandler: (isTitle: boolean) => void;
|
||||||
|
|
@ -13,18 +15,42 @@ function DashboardOptions(props: Props) {
|
||||||
const { userStore } = useStore();
|
const { userStore } = useStore();
|
||||||
const isEnterprise = userStore.isEnterprise;
|
const isEnterprise = userStore.isEnterprise;
|
||||||
const { editHandler, deleteHandler, renderReport } = props;
|
const { editHandler, deleteHandler, renderReport } = props;
|
||||||
const menuItems = [
|
|
||||||
{ icon: 'pencil', text: 'Rename', onClick: () => editHandler(true) },
|
const menu = {
|
||||||
{ icon: 'users', text: 'Visibility & Access', onClick: editHandler },
|
items: [
|
||||||
{ icon: 'trash', text: 'Delete', onClick: deleteHandler },
|
{
|
||||||
{ icon: 'pdf-download', text: 'Download Report', onClick: renderReport, disabled: !isEnterprise, tooltipTitle: ENTERPRISE_REQUEIRED }
|
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 (
|
return (
|
||||||
<ItemMenu
|
<Dropdown menu={menu}>
|
||||||
bold
|
<Button id={'ignore-prop'} icon={<EllipsisVertical size={16} />} />
|
||||||
items={menuItems}
|
</Dropdown>
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import { observer } from 'mobx-react-lite';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import colors from 'tailwindcss/colors';
|
import colors from 'tailwindcss/colors';
|
||||||
|
|
||||||
import { gradientBox } from 'App/components/shared/SessionSearchField/AiSessionSearchField';
|
import { gradientBox } from 'App/components/shared/SessionFilters/AiSessionSearchField';
|
||||||
import aiSpinner from 'App/lottie/aiSpinner.json';
|
import aiSpinner from 'App/lottie/aiSpinner.json';
|
||||||
import { useStore } from 'App/mstore';
|
import { useStore } from 'App/mstore';
|
||||||
import { Icon, Input } from 'UI';
|
import { Icon, Input } from 'UI';
|
||||||
|
|
@ -41,7 +41,7 @@ const InputBox = observer(({ inModal }: { inModal?: boolean }) => {
|
||||||
<>
|
<>
|
||||||
{!inModal ? <div className={"flex items-center mb-2 gap-2"}>
|
{!inModal ? <div className={"flex items-center mb-2 gap-2"}>
|
||||||
<Icon name={"sparkles"} size={16} />
|
<Icon name={"sparkles"} size={16} />
|
||||||
<div className={"font-semibold"}>What would you like to visualize?</div>
|
<div className={"font-medium"}>What would you like to visualize?</div>
|
||||||
</div> : null}
|
</div> : null}
|
||||||
<div style={gradientBox}>
|
<div style={gradientBox}>
|
||||||
<Input
|
<Input
|
||||||
|
|
@ -114,7 +114,7 @@ function Loader() {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
'flex items-center justify-center flex-col font-semibold text-xl min-h-80'
|
'flex items-center justify-center flex-col font-medium text-xl min-h-80'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div style={{ width: 150, height: 150 }}>
|
<div style={{ width: 150, height: 150 }}>
|
||||||
|
|
|
||||||
|
|
@ -98,7 +98,7 @@ function DashboardView(props: Props) {
|
||||||
const isSaas = /app\.openreplay\.com/.test(originStr);
|
const isSaas = /app\.openreplay\.com/.test(originStr);
|
||||||
return (
|
return (
|
||||||
<Loader loading={loading}>
|
<Loader loading={loading}>
|
||||||
<div style={{maxWidth: '1360px', margin: 'auto'}}>
|
<div style={{maxWidth: '1360px', margin: 'auto'}} className={'rounded-lg shadow-sm overflow-hidden bg-white border'}>
|
||||||
{/* @ts-ignore */}
|
{/* @ts-ignore */}
|
||||||
<DashboardHeader renderReport={props.renderReport} siteId={siteId} dashboardId={dashboardId}/>
|
<DashboardHeader renderReport={props.renderReport} siteId={siteId} dashboardId={dashboardId}/>
|
||||||
{isSaas ? <AiQuery /> : null}
|
{isSaas ? <AiQuery /> : null}
|
||||||
|
|
|
||||||
|
|
@ -96,9 +96,9 @@ function AddMetric({ history, siteId, title, description }: IProps) {
|
||||||
<div className="py-4 border-t px-8 bg-white w-full flex items-center justify-between">
|
<div className="py-4 border-t px-8 bg-white w-full flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
{'Selected '}
|
{'Selected '}
|
||||||
<span className="font-semibold">{selectedWidgetIds.length}</span>
|
<span className="font-medium">{selectedWidgetIds.length}</span>
|
||||||
{' out of '}
|
{' out of '}
|
||||||
<span className="font-semibold">{metrics ? metrics.length : 0}</span>
|
<span className="font-medium">{metrics ? metrics.length : 0}</span>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="primary" disabled={selectedWidgetIds.length === 0} onClick={onSave}>
|
<Button variant="primary" disabled={selectedWidgetIds.length === 0} onClick={onSave}>
|
||||||
Add Selected
|
Add Selected
|
||||||
|
|
|
||||||
|
|
@ -141,9 +141,9 @@ function AddPredefinedMetric({ history, siteId, title, description }: IProps) {
|
||||||
<div className="py-4 border-t px-8 bg-white w-full flex items-center justify-between">
|
<div className="py-4 border-t px-8 bg-white w-full flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
{'Selected '}
|
{'Selected '}
|
||||||
<span className="font-semibold">{selectedWidgetIds.length}</span>
|
<span className="font-medium">{selectedWidgetIds.length}</span>
|
||||||
{' out of '}
|
{' out of '}
|
||||||
<span className="font-semibold">{totalMetricCount}</span>
|
<span className="font-medium">{totalMetricCount}</span>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="primary" disabled={selectedWidgetIds.length === 0} onClick={onSave}>
|
<Button variant="primary" disabled={selectedWidgetIds.length === 0} onClick={onSave}>
|
||||||
Add Selected
|
Add Selected
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useStore } from 'App/mstore';
|
import { useStore } from 'App/mstore';
|
||||||
import WidgetWrapperNew from 'Components/Dashboard/components/WidgetWrapper/WidgetWrapperNew';
|
import WidgetWrapperNew from 'Components/Dashboard/components/WidgetWrapper/WidgetWrapperNew';
|
||||||
import { Empty } from 'antd';
|
import { observer } from 'mobx-react-lite';
|
||||||
import { NoContent, Loader } from 'UI';
|
import AddCardSection from '../AddCardSection/AddCardSection';
|
||||||
import { useObserver } from 'mobx-react-lite';
|
import cn from 'classnames';
|
||||||
import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG';
|
import { Button, Popover, Tooltip } from 'antd'
|
||||||
|
import { PlusOutlined } from '@ant-design/icons'
|
||||||
|
import { Loader } from 'UI';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
siteId: string;
|
siteId: string;
|
||||||
|
|
@ -16,37 +18,51 @@ interface Props {
|
||||||
function DashboardWidgetGrid(props: Props) {
|
function DashboardWidgetGrid(props: Props) {
|
||||||
const { dashboardId, siteId } = props;
|
const { dashboardId, siteId } = props;
|
||||||
const { dashboardStore } = useStore();
|
const { dashboardStore } = useStore();
|
||||||
const loading = useObserver(() => dashboardStore.isLoading);
|
const loading = dashboardStore.isLoading;
|
||||||
const dashboard = dashboardStore.selectedDashboard;
|
const dashboard = dashboardStore.selectedDashboard;
|
||||||
const list = useObserver(() => dashboard?.widgets);
|
const list = dashboard?.widgets;
|
||||||
|
|
||||||
return useObserver(() => (
|
return (
|
||||||
<Loader loading={loading}>
|
<Loader loading={loading}>
|
||||||
{
|
{list?.length === 0 ? (
|
||||||
list?.length === 0 ? (
|
<div
|
||||||
<div className="bg-white rounded-lg shadow-sm p-5">
|
className={'flex-1 flex justify-center items-center pt-10'}
|
||||||
<NoContent
|
style={{ minHeight: 620 }}
|
||||||
show={true}
|
>
|
||||||
icon="no-metrics-chart"
|
<AddCardSection />
|
||||||
title={
|
|
||||||
<div 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>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
</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) => (
|
{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
|
<WidgetWrapperNew
|
||||||
index={index}
|
index={index}
|
||||||
widget={item}
|
widget={item}
|
||||||
|
|
@ -59,13 +75,28 @@ function DashboardWidgetGrid(props: Props) {
|
||||||
showMenu={true}
|
showMenu={true}
|
||||||
isSaved={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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
</Loader>
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
export default DashboardWidgetGrid;
|
|
||||||
|
export default observer(DashboardWidgetGrid);
|
||||||
|
|
|
||||||
|
|
@ -28,9 +28,9 @@ function ExcludeFilters(props: Props) {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("flex items-center border-b", { 'p-5' : hasExcludes, 'px-2': !hasExcludes })}>
|
<div className={cn("flex items-center mb-2")}>
|
||||||
{filter.excludes.length > 0 ? (
|
{filter.excludes.length > 0 ? (
|
||||||
<div className="flex items-center mb-2 flex-col">
|
<div className="flex items-center mb-2 bg-white rounded-xl flex-col px-4 py-2 w-full">
|
||||||
<div className="text-sm color-gray-medium mr-auto mb-2">EXCLUDES</div>
|
<div className="text-sm color-gray-medium mr-auto mb-2">EXCLUDES</div>
|
||||||
{filter.excludes.map((f: any, index: number) => (
|
{filter.excludes.map((f: any, index: number) => (
|
||||||
<FilterItem
|
<FilterItem
|
||||||
|
|
|
||||||
|
|
@ -1,69 +1,112 @@
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import FilterList from 'Shared/Filters/FilterList';
|
import { EventsList, FilterList } from 'Shared/Filters/FilterList';
|
||||||
import SeriesName from './SeriesName';
|
import SeriesName from './SeriesName';
|
||||||
import cn from 'classnames';
|
import cn from 'classnames';
|
||||||
import { observer } from 'mobx-react-lite';
|
import { observer } from 'mobx-react-lite';
|
||||||
import ExcludeFilters from './ExcludeFilters';
|
import ExcludeFilters from './ExcludeFilters';
|
||||||
import AddStepButton from "Components/Dashboard/components/FilterSeries/AddStepButton";
|
import { Button, Space } from 'antd';
|
||||||
import {Button, Space} from "antd";
|
import { ChevronDown, ChevronUp, Trash } from 'lucide-react';
|
||||||
import {ChevronDown, ChevronUp, Trash} from "lucide-react";
|
|
||||||
|
|
||||||
|
const FilterCountLabels = observer(
|
||||||
const FilterCountLabels = observer((props: { filters: any, toggleExpand: any }) => {
|
(props: { filters: any; toggleExpand: any }) => {
|
||||||
const events = props.filters.filter((i: any) => i && i.isEvent).length;
|
const events = props.filters.filter((i: any) => i && i.isEvent).length;
|
||||||
const filters = props.filters.filter((i: any) => i && !i.isEvent).length;
|
const filters = props.filters.filter((i: any) => i && !i.isEvent).length;
|
||||||
return <div className="flex items-center">
|
return (
|
||||||
|
<div className="flex items-center">
|
||||||
<Space>
|
<Space>
|
||||||
{events > 0 && (
|
{events > 0 && (
|
||||||
<Button type="primary" ghost size="small" onClick={props.toggleExpand}>
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
onClick={props.toggleExpand}
|
||||||
|
className='btn-series-event-count'
|
||||||
|
>
|
||||||
{`${events} Event${events > 1 ? 's' : ''}`}
|
{`${events} Event${events > 1 ? 's' : ''}`}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{filters > 0 && (
|
{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' : ''}`}
|
{`${filters} Filter${filters > 1 ? 's' : ''}`}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</Space>
|
</Space>
|
||||||
</div>;
|
</div>
|
||||||
});
|
);
|
||||||
|
|
||||||
const FilterSeriesHeader = observer((props: {
|
|
||||||
expanded: boolean,
|
|
||||||
hidden: boolean,
|
|
||||||
seriesIndex: number,
|
|
||||||
series: any,
|
|
||||||
onRemove: (seriesIndex: any) => void,
|
|
||||||
canDelete: boolean | undefined,
|
|
||||||
toggleExpand: () => void
|
|
||||||
}) => {
|
|
||||||
|
|
||||||
const onUpdate = (name: any) => {
|
|
||||||
props.series.update('name', name)
|
|
||||||
}
|
}
|
||||||
return <div className={cn("border-b px-5 h-12 flex items-center relative", {hidden: props.hidden})}>
|
);
|
||||||
|
|
||||||
|
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}>
|
<Space className="mr-auto" size={30}>
|
||||||
<SeriesName
|
<SeriesName
|
||||||
seriesIndex={props.seriesIndex}
|
seriesIndex={props.seriesIndex}
|
||||||
name={props.series.name}
|
name={props.series.name}
|
||||||
onUpdate={onUpdate}
|
onUpdate={onUpdate}
|
||||||
|
onChange={() => null}
|
||||||
/>
|
/>
|
||||||
{!props.expanded &&
|
|
||||||
<FilterCountLabels filters={props.series.filter.filters} toggleExpand={props.toggleExpand}/>}
|
|
||||||
</Space>
|
</Space>
|
||||||
|
|
||||||
<Space>
|
<Space>
|
||||||
<Button onClick={props.onRemove}
|
{!props.expanded && (
|
||||||
|
<FilterCountLabels
|
||||||
|
filters={props.series.filter.filters}
|
||||||
|
toggleExpand={props.toggleExpand}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
onClick={props.onRemove}
|
||||||
size="small"
|
size="small"
|
||||||
disabled={!props.canDelete}
|
disabled={!props.canDelete}
|
||||||
icon={<Trash size={14}/>}/>
|
icon={<Trash size={14} />}
|
||||||
<Button onClick={props.toggleExpand}
|
type='text'
|
||||||
|
className={cn(
|
||||||
|
'btn-delete-series', 'disabled:hidden'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
onClick={props.toggleExpand}
|
||||||
size="small"
|
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>
|
</Space>
|
||||||
</div>;
|
</div>
|
||||||
})
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
seriesIndex: number;
|
seriesIndex: number;
|
||||||
|
|
@ -75,32 +118,34 @@ interface Props {
|
||||||
emptyMessage?: any;
|
emptyMessage?: any;
|
||||||
observeChanges?: () => void;
|
observeChanges?: () => void;
|
||||||
excludeFilterKeys?: Array<string>;
|
excludeFilterKeys?: Array<string>;
|
||||||
|
excludeCategory?: string[]
|
||||||
canExclude?: boolean;
|
canExclude?: boolean;
|
||||||
expandable?: boolean;
|
expandable?: boolean;
|
||||||
|
isHeatmap?: boolean;
|
||||||
|
removeEvents?: boolean;
|
||||||
|
collapseState: boolean;
|
||||||
|
onToggleCollapse: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function FilterSeries(props: Props) {
|
function FilterSeries(props: Props) {
|
||||||
const {
|
const {
|
||||||
observeChanges = () => {
|
observeChanges = () => {},
|
||||||
},
|
|
||||||
canDelete,
|
canDelete,
|
||||||
hideHeader = false,
|
hideHeader = false,
|
||||||
emptyMessage = 'Add an event or filter step to define the series.',
|
emptyMessage = 'Add an event or filter step to define the series.',
|
||||||
supportsEmpty = true,
|
supportsEmpty = true,
|
||||||
excludeFilterKeys = [],
|
excludeFilterKeys = [],
|
||||||
canExclude = false,
|
canExclude = false,
|
||||||
expandable = false
|
expandable = false,
|
||||||
|
isHeatmap,
|
||||||
|
removeEvents,
|
||||||
|
collapseState,
|
||||||
|
onToggleCollapse,
|
||||||
|
excludeCategory
|
||||||
} = props;
|
} = props;
|
||||||
const [expanded, setExpanded] = useState(!expandable);
|
const expanded = !collapseState
|
||||||
|
const setExpanded = onToggleCollapse
|
||||||
const { series, seriesIndex } = props;
|
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 onUpdateFilter = (filterIndex: any, filter: any) => {
|
const onUpdateFilter = (filterIndex: any, filter: any) => {
|
||||||
series.filter.updateFilter(filterIndex, filter);
|
series.filter.updateFilter(filterIndex, filter);
|
||||||
|
|
@ -108,9 +153,9 @@ function FilterSeries(props: Props) {
|
||||||
};
|
};
|
||||||
|
|
||||||
const onFilterMove = (newFilters: any) => {
|
const onFilterMove = (newFilters: any) => {
|
||||||
series.filter.replaceFilters(newFilters.toArray())
|
series.filter.replaceFilters(newFilters.toArray());
|
||||||
observeChanges();
|
observeChanges();
|
||||||
}
|
};
|
||||||
|
|
||||||
const onChangeEventsOrder = (_: any, { name, value }: any) => {
|
const onChangeEventsOrder = (_: any, { name, value }: any) => {
|
||||||
series.filter.updateKey(name, value);
|
series.filter.updateKey(name, value);
|
||||||
|
|
@ -122,32 +167,68 @@ function FilterSeries(props: Props) {
|
||||||
observeChanges();
|
observeChanges();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onAddFilter = (filter: any) => {
|
||||||
|
series.filter.addFilter(filter);
|
||||||
|
observeChanges();
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border rounded-lg shadow-sm bg-white">
|
<div>
|
||||||
{canExclude && <ExcludeFilters filter={series.filter} />}
|
{canExclude && <ExcludeFilters filter={series.filter} />}
|
||||||
|
|
||||||
{!hideHeader && (
|
{!hideHeader && (
|
||||||
<FilterSeriesHeader hidden={hideHeader}
|
<FilterSeriesHeader
|
||||||
|
hidden={hideHeader}
|
||||||
seriesIndex={seriesIndex}
|
seriesIndex={seriesIndex}
|
||||||
|
onChange={observeChanges}
|
||||||
series={series}
|
series={series}
|
||||||
onRemove={props.onRemoveSeries}
|
onRemove={props.onRemoveSeries}
|
||||||
canDelete={canDelete}
|
canDelete={canDelete}
|
||||||
expanded={expanded}
|
expanded={expanded}
|
||||||
toggleExpand={() => setExpanded(!expanded)}/>
|
toggleExpand={() => setExpanded(!expanded)}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{expandable && (
|
{!hideHeader && expandable && (
|
||||||
<Space className="justify-between w-full px-5 py-2 cursor-pointer" onClick={() => setExpanded(!expanded)}>
|
<Space
|
||||||
<div>{!expanded && <FilterCountLabels filters={series.filter.filters} toggleExpand={() => setExpanded(!expanded)}/>}</div>
|
className="justify-between w-full py-2 cursor-pointer"
|
||||||
<Button size="small"
|
onClick={() => setExpanded(!expanded)}
|
||||||
icon={expanded ? <ChevronUp size={16}/> : <ChevronDown size={16}/>}/>
|
>
|
||||||
|
<div>
|
||||||
|
{!expanded && (
|
||||||
|
<FilterCountLabels
|
||||||
|
filters={series.filter.filters}
|
||||||
|
toggleExpand={() => setExpanded(!expanded)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
icon={
|
||||||
|
expanded ? <ChevronUp size={16} /> : <ChevronDown size={16} />
|
||||||
|
}
|
||||||
|
/>
|
||||||
</Space>
|
</Space>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{expanded && (
|
{expanded ? (
|
||||||
<>
|
<>
|
||||||
<div className="p-5">
|
{removeEvents ? null :
|
||||||
{series.filter.filters.length > 0 ? (
|
<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
|
<FilterList
|
||||||
filter={series.filter}
|
filter={series.filter}
|
||||||
onUpdateFilter={onUpdateFilter}
|
onUpdateFilter={onUpdateFilter}
|
||||||
|
|
@ -156,25 +237,12 @@ function FilterSeries(props: Props) {
|
||||||
supportsEmpty={supportsEmpty}
|
supportsEmpty={supportsEmpty}
|
||||||
onFilterMove={onFilterMove}
|
onFilterMove={onFilterMove}
|
||||||
excludeFilterKeys={excludeFilterKeys}
|
excludeFilterKeys={excludeFilterKeys}
|
||||||
// actions={[
|
onAddFilter={onAddFilter}
|
||||||
// expandable && (
|
mergeUp={!removeEvents}
|
||||||
// <Button onClick={() => setExpanded(!expanded)}
|
excludeCategory={excludeCategory}
|
||||||
// size="small"
|
|
||||||
// icon={expanded ? <ChevronUp size={16}/> : <ChevronDown size={16}/>}/>
|
|
||||||
// )
|
|
||||||
// ]}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
|
||||||
<div className="color-gray-medium">{emptyMessage}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="border-t h-12 flex items-center">
|
|
||||||
<div className="-mx-4 px-5">
|
|
||||||
<AddStepButton excludeFilterKeys={excludeFilterKeys} series={series}/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
)}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,38 +1,46 @@
|
||||||
import React, { useState, useRef, useEffect } from 'react';
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
import { Icon } from 'UI';
|
|
||||||
import { Input, Tooltip } from 'antd';
|
import { Input, Tooltip } from 'antd';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
name: string;
|
name: string;
|
||||||
onUpdate: (name) => void;
|
onUpdate: (name: string) => void;
|
||||||
|
onChange: () => void;
|
||||||
seriesIndex?: number;
|
seriesIndex?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SeriesName(props: Props) {
|
function SeriesName(props: Props) {
|
||||||
const { seriesIndex = 1 } = props;
|
const { seriesIndex = 1 } = props;
|
||||||
const [editing, setEditing] = useState(false)
|
const [editing, setEditing] = useState(false);
|
||||||
const [name, setName] = useState(props.name)
|
const [name, setName] = useState(props.name);
|
||||||
const ref = useRef<any>(null)
|
const ref = useRef<any>(null);
|
||||||
|
|
||||||
const write = ({ target: { value, name } }) => {
|
const write = ({ target: { value } }) => {
|
||||||
setName(value)
|
setName(value);
|
||||||
}
|
props.onChange();
|
||||||
|
};
|
||||||
|
|
||||||
const onBlur = () => {
|
const onBlur = () => {
|
||||||
setEditing(false)
|
setEditing(false);
|
||||||
props.onUpdate(name)
|
props.onUpdate(name);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
setEditing(false);
|
||||||
|
props.onUpdate(name);
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (editing) {
|
if (editing) {
|
||||||
ref.current.focus()
|
ref.current.focus();
|
||||||
}
|
}
|
||||||
}, [editing])
|
}, [editing]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setName(props.name)
|
setName(props.name);
|
||||||
}, [props.name])
|
}, [props.name]);
|
||||||
|
|
||||||
// const { name } = props;
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
{editing ? (
|
{editing ? (
|
||||||
|
|
@ -40,22 +48,24 @@ function SeriesName(props: Props) {
|
||||||
ref={ref}
|
ref={ref}
|
||||||
name="name"
|
name="name"
|
||||||
value={name}
|
value={name}
|
||||||
// readOnly={!editing}
|
|
||||||
onChange={write}
|
onChange={write}
|
||||||
onBlur={onBlur}
|
onBlur={onBlur}
|
||||||
onFocus={() => setEditing(true)}
|
onKeyDown={onKeyDown}
|
||||||
className='bg-white'
|
className="bg-white text-lg border-transparent rounded-lg font-medium ps-2 input-rename-series"
|
||||||
|
maxLength={22}
|
||||||
|
size='small'
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-base h-8 flex items-center border-transparent">{name && name.trim() === '' ? 'Series ' + (seriesIndex + 1) : name }</div>
|
<Tooltip title="Click to rename">
|
||||||
)}
|
<div
|
||||||
|
className="text-lg font-medium h-8 flex items-center border-transparent p-2 hover:bg-teal/10 cursor-pointer rounded-lg btn-input-rename-series"
|
||||||
|
onClick={() => setEditing(true)}
|
||||||
<div className="ml-3 cursor-pointer " onClick={() => setEditing(true)}>
|
data-event='input-rename-series'
|
||||||
<Tooltip title='Rename' placement='bottom'>
|
>
|
||||||
<Icon name="pencil" size="14" />
|
{name && name.trim() === '' ? 'Series ' + (seriesIndex + 1) : name}
|
||||||
</Tooltip>
|
|
||||||
</div>
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,34 +1,54 @@
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { Icon, Modal } from 'UI';
|
import { Icon } from 'UI';
|
||||||
import { Tooltip, Input, Button, Dropdown, Menu, Tag, Modal as AntdModal, Form, Avatar } from 'antd';
|
import {
|
||||||
import { TeamOutlined, LockOutlined, EditOutlined, DeleteOutlined, MoreOutlined } from '@ant-design/icons';
|
Tooltip,
|
||||||
|
Input,
|
||||||
|
Button,
|
||||||
|
Dropdown,
|
||||||
|
Tag,
|
||||||
|
Modal as AntdModal,
|
||||||
|
Avatar,
|
||||||
|
} from 'antd';
|
||||||
|
import {
|
||||||
|
TeamOutlined,
|
||||||
|
LockOutlined,
|
||||||
|
EditOutlined,
|
||||||
|
DeleteOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
import { RouteComponentProps } from 'react-router-dom';
|
import { RouteComponentProps } from 'react-router-dom';
|
||||||
import { withSiteId } from 'App/routes';
|
import { withSiteId } from 'App/routes';
|
||||||
import { TYPES } from 'App/constants/card';
|
import { TYPE_ICONS, TYPE_NAMES } from 'App/constants/card';
|
||||||
import { useStore } from 'App/mstore';
|
import { useStore } from 'App/mstore';
|
||||||
import { observer } from 'mobx-react-lite';
|
import { observer } from 'mobx-react-lite';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
import { useHistory } from 'react-router';
|
import { useHistory } from 'react-router';
|
||||||
|
import { EllipsisVertical } from 'lucide-react';
|
||||||
|
import cn from 'classnames'
|
||||||
|
|
||||||
interface Props extends RouteComponentProps {
|
interface Props extends RouteComponentProps {
|
||||||
metric: any;
|
metric: any;
|
||||||
siteId: string;
|
siteId: string;
|
||||||
selected?: boolean;
|
|
||||||
toggleSelection?: any;
|
toggleSelection?: any;
|
||||||
disableSelection?: boolean;
|
disableSelection?: boolean;
|
||||||
renderColumn: string;
|
renderColumn: string;
|
||||||
|
inLibrary?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function MetricTypeIcon({ type }: any) {
|
function MetricTypeIcon({ type }: any) {
|
||||||
const [card, setCard] = useState<any>('');
|
|
||||||
useEffect(() => {
|
|
||||||
const t = TYPES.find((i) => i.slug === type);
|
|
||||||
setCard(t || {});
|
|
||||||
}, [type]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip title={<div className="capitalize">{card.title}</div>}>
|
<Tooltip title={<div className="capitalize">{TYPE_NAMES[type]}</div>}>
|
||||||
<Avatar src={card.icon && <Icon name={card.icon} size="16" color="tealx" />} size="small" className="bg-tealx-lightest mr-2" />
|
<Avatar
|
||||||
|
src={
|
||||||
|
<Icon
|
||||||
|
name={TYPE_ICONS[type]}
|
||||||
|
size="16"
|
||||||
|
color="tealx"
|
||||||
|
strokeColor="tealx"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
size="default"
|
||||||
|
className="bg-tealx-lightest text-tealx mr-2 cursor-default avatar-card-list-item"
|
||||||
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -36,10 +56,10 @@ function MetricTypeIcon({ type }: any) {
|
||||||
const MetricListItem: React.FC<Props> = ({
|
const MetricListItem: React.FC<Props> = ({
|
||||||
metric,
|
metric,
|
||||||
siteId,
|
siteId,
|
||||||
toggleSelection = () => {
|
toggleSelection = () => {},
|
||||||
},
|
|
||||||
disableSelection = false,
|
disableSelection = false,
|
||||||
renderColumn
|
renderColumn,
|
||||||
|
inLibrary,
|
||||||
}) => {
|
}) => {
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
const { metricStore } = useStore();
|
const { metricStore } = useStore();
|
||||||
|
|
@ -67,7 +87,7 @@ const MetricListItem: React.FC<Props> = ({
|
||||||
cancelText: 'No',
|
cancelText: 'No',
|
||||||
onOk: async () => {
|
onOk: async () => {
|
||||||
await metricStore.delete(metric);
|
await metricStore.delete(metric);
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (key === 'rename') {
|
if (key === 'rename') {
|
||||||
|
|
@ -132,29 +152,34 @@ const MetricListItem: React.FC<Props> = ({
|
||||||
} else if (diffDays <= 3) {
|
} else if (diffDays <= 3) {
|
||||||
return `${diffDays} days ago at ${formatTime(date)}`;
|
return `${diffDays} days ago at ${formatTime(date)}`;
|
||||||
} else {
|
} else {
|
||||||
return `${date.getDate()}/${date.getMonth() + 1}/${date.getFullYear()} at ${formatTime(date)}`;
|
return `${date.getDate()}/${
|
||||||
|
date.getMonth() + 1
|
||||||
|
}/${date.getFullYear()} at ${formatTime(date)}`;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const menuItems = [
|
const menuItems = [
|
||||||
{
|
{
|
||||||
key: "rename",
|
key: 'rename',
|
||||||
icon: <EditOutlined />,
|
icon: <EditOutlined />,
|
||||||
label: "Rename"
|
label: 'Rename',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "delete",
|
key: 'delete',
|
||||||
icon: <DeleteOutlined />,
|
icon: <DeleteOutlined />,
|
||||||
label: "Delete"
|
label: 'Delete',
|
||||||
}
|
},
|
||||||
]
|
];
|
||||||
switch (renderColumn) {
|
switch (renderColumn) {
|
||||||
case 'title':
|
case 'title':
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center cursor-pointer" onClick={onItemClick}>
|
<div
|
||||||
|
className="flex items-center cursor-pointer"
|
||||||
|
onClick={inLibrary ? undefined : onItemClick}
|
||||||
|
>
|
||||||
<MetricTypeIcon type={metric.metricType} />
|
<MetricTypeIcon type={metric.metricType} />
|
||||||
<div className="capitalize-first link block">{metric.name}</div>
|
<div className={cn('capitalize-first block', inLibrary ? '' : 'link')}>{metric.name}</div>
|
||||||
</div>
|
</div>
|
||||||
{renderModal()}
|
{renderModal()}
|
||||||
</>
|
</>
|
||||||
|
|
@ -165,7 +190,11 @@ const MetricListItem: React.FC<Props> = ({
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<Tag className="rounded-lg" bordered={false}>
|
<Tag className="rounded-lg" bordered={false}>
|
||||||
{metric.isPublic ? <TeamOutlined className="mr-2" /> : <LockOutlined className="mr-2" />}
|
{metric.isPublic ? (
|
||||||
|
<TeamOutlined className="mr-2" />
|
||||||
|
) : (
|
||||||
|
<LockOutlined className="mr-2" />
|
||||||
|
)}
|
||||||
{metric.isPublic ? 'Team' : 'Private'}
|
{metric.isPublic ? 'Team' : 'Private'}
|
||||||
</Tag>
|
</Tag>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -176,12 +205,17 @@ const MetricListItem: React.FC<Props> = ({
|
||||||
case 'options':
|
case 'options':
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className='flex justify-end'>
|
<div className="flex justify-end pr-4">
|
||||||
<Dropdown
|
<Dropdown
|
||||||
menu={{ items: menuItems, onClick: onMenuClick }}
|
menu={{ items: menuItems, onClick: onMenuClick }}
|
||||||
trigger={['click']}
|
trigger={['click']}
|
||||||
>
|
>
|
||||||
<Button type="text" icon={<MoreOutlined />} />
|
<Button
|
||||||
|
id={'ignore-prop'}
|
||||||
|
icon={<EllipsisVertical size={16} />}
|
||||||
|
className="btn-cards-list-item-more-options"
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
</div>
|
</div>
|
||||||
{renderModal()}
|
{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 React, { useEffect } from 'react';
|
||||||
import { PageTitle, Toggler, Icon } from "UI";
|
import { PageTitle } from 'UI';
|
||||||
import { Segmented, Button } from 'antd';
|
import { Button, Popover, Space, Dropdown, Menu } from 'antd';
|
||||||
import { PlusOutlined } from '@ant-design/icons';
|
import { PlusOutlined, DownOutlined } from '@ant-design/icons';
|
||||||
|
import AddCardSection from '../AddCardSection/AddCardSection';
|
||||||
import MetricsSearch from '../MetricsSearch';
|
import MetricsSearch from '../MetricsSearch';
|
||||||
import Select from 'Shared/Select';
|
|
||||||
import { useStore } from 'App/mstore';
|
import { useStore } from 'App/mstore';
|
||||||
import { observer, useObserver } from 'mobx-react-lite';
|
import { observer } from 'mobx-react-lite';
|
||||||
import { DROPDOWN_OPTIONS } from 'App/constants/card';
|
import { DROPDOWN_OPTIONS } from 'App/constants/card';
|
||||||
import AddCardModal from 'Components/Dashboard/components/AddCardModal';
|
|
||||||
import { useModal } from 'Components/Modal';
|
|
||||||
import AddCardSelectionModal from "Components/Dashboard/components/AddCardSelectionModal";
|
|
||||||
import NewDashboardModal from "Components/Dashboard/components/DashboardList/NewDashModal";
|
|
||||||
|
|
||||||
function MetricViewHeader({ siteId }: { siteId: string }) {
|
const options = [
|
||||||
|
{
|
||||||
|
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 { metricStore } = useStore();
|
||||||
const filter = metricStore.filter;
|
const filter = metricStore.filter;
|
||||||
const { showModal } = useModal();
|
|
||||||
const [showAddCardModal, setShowAddCardModal] = React.useState(false);
|
|
||||||
|
|
||||||
// Set the default sort order to 'desc'
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
metricStore.updateKey('sort', { by: 'desc' });
|
metricStore.updateKey('sort', { by: 'desc' });
|
||||||
}, [metricStore]);
|
}, [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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className='flex items-center justify-between px-6'>
|
<div className="flex items-center justify-between pr-4">
|
||||||
<div className='flex items-baseline mr-3'>
|
<div className="flex items-center gap-2 ps-4">
|
||||||
<PageTitle title='Cards' className='' />
|
<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>
|
||||||
<div className='ml-auto flex items-center'>
|
<div className="ml-auto flex items-center gap-3">
|
||||||
<Button type='primary'
|
<Popover
|
||||||
onClick={() => setShowAddCardModal(true)}
|
arrow={false}
|
||||||
|
overlayInnerStyle={{ padding: 0, borderRadius: '0.75rem' }}
|
||||||
|
content={<AddCardSection fit inCards />}
|
||||||
|
trigger="click"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
icon={<PlusOutlined />}
|
icon={<PlusOutlined />}
|
||||||
>Create Card</Button>
|
className="btn-create-card"
|
||||||
<div className='ml-4 w-1/4' style={{ minWidth: 300 }}>
|
>
|
||||||
|
Create Card
|
||||||
|
</Button>
|
||||||
|
</Popover>
|
||||||
|
<Space>
|
||||||
<MetricsSearch />
|
<MetricsSearch />
|
||||||
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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);
|
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 { useStore } from 'App/mstore';
|
||||||
import { observer } from 'mobx-react-lite';
|
import { observer } from 'mobx-react-lite';
|
||||||
import FooterContent from './FooterContent';
|
import FooterContent from './FooterContent';
|
||||||
|
import { Input } from 'antd'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
dashboardId?: number;
|
dashboardId?: number;
|
||||||
|
|
@ -46,7 +47,7 @@ function MetricsLibraryModal(props: Props) {
|
||||||
</Modal.Header>
|
</Modal.Header>
|
||||||
<Modal.Content className="p-4 pb-20">
|
<Modal.Content className="p-4 pb-20">
|
||||||
<div className="border">
|
<div className="border">
|
||||||
<MetricsList siteId={siteId} onSelectionChange={onSelectionChange} />
|
<MetricsList siteId={siteId} onSelectionChange={onSelectionChange} inLibrary />
|
||||||
</div>
|
</div>
|
||||||
</Modal.Content>
|
</Modal.Content>
|
||||||
<Modal.Footer>
|
<Modal.Footer>
|
||||||
|
|
@ -61,12 +62,11 @@ export default observer(MetricsLibraryModal);
|
||||||
function MetricSearch({ onChange }: any) {
|
function MetricSearch({ onChange }: any) {
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Icon name="search" className="absolute top-0 bottom-0 ml-2 m-auto" size="16" />
|
<Input.Search
|
||||||
<input
|
|
||||||
name="dashboardsSearch"
|
name="dashboardsSearch"
|
||||||
className="bg-white p-2 border border-borderColor-gray-light-shade rounded w-full pl-10"
|
|
||||||
placeholder="Filter by title or owner"
|
placeholder="Filter by title or owner"
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
|
className={'rounded-lg'}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,28 +1,33 @@
|
||||||
import React, { useState, useMemo } from 'react';
|
import React, { useState, useMemo } from 'react';
|
||||||
import { Checkbox, Table, Typography } from 'antd';
|
import { Checkbox, Table, Typography, Switch, Tag, Tooltip } from 'antd';
|
||||||
import MetricListItem from '../MetricListItem';
|
import MetricListItem from '../MetricListItem';
|
||||||
import { TablePaginationConfig, SorterResult } from 'antd/lib/table/interface';
|
import { TablePaginationConfig, SorterResult } from 'antd/lib/table/interface';
|
||||||
import Widget from 'App/mstore/types/widget';
|
import Widget from 'App/mstore/types/widget';
|
||||||
|
import { LockOutlined, TeamOutlined } from "@ant-design/icons";
|
||||||
|
import classNames from 'classnames';
|
||||||
|
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
|
|
||||||
// interface Metric {
|
interface Metric {
|
||||||
// metricId: number;
|
metricId: number;
|
||||||
// name: string;
|
name: string;
|
||||||
// owner: string;
|
owner: string;
|
||||||
// lastModified: string;
|
lastModified: string;
|
||||||
// visibility: string;
|
visibility: string;
|
||||||
// }
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
list: Widget[];
|
list: Widget[];
|
||||||
siteId: string;
|
siteId: string;
|
||||||
selectedList: number[];
|
selectedList: number[];
|
||||||
toggleSelection?: (metricId: number) => void;
|
toggleSelection?: (metricId: number | Array<number>) => void;
|
||||||
toggleAll?: (e: any) => void;
|
toggleAll?: (e: any) => void;
|
||||||
disableSelection?: boolean;
|
disableSelection?: boolean;
|
||||||
allSelected?: boolean;
|
allSelected?: boolean;
|
||||||
existingCardIds?: number[];
|
existingCardIds?: number[];
|
||||||
|
showOwn?: boolean;
|
||||||
|
toggleOwn: () => void;
|
||||||
|
inLibrary?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ListView: React.FC<Props> = (props: Props) => {
|
const ListView: React.FC<Props> = (props: Props) => {
|
||||||
|
|
@ -32,8 +37,7 @@ const ListView: React.FC<Props> = (props: Props) => {
|
||||||
selectedList,
|
selectedList,
|
||||||
toggleSelection,
|
toggleSelection,
|
||||||
disableSelection = false,
|
disableSelection = false,
|
||||||
allSelected = false,
|
inLibrary = false
|
||||||
toggleAll
|
|
||||||
} = props;
|
} = props;
|
||||||
const [sorter, setSorter] = useState<{ field: string; order: 'ascend' | 'descend' }>({
|
const [sorter, setSorter] = useState<{ field: string; order: 'ascend' | 'descend' }>({
|
||||||
field: 'lastModified',
|
field: 'lastModified',
|
||||||
|
|
@ -66,7 +70,7 @@ const ListView: React.FC<Props> = (props: Props) => {
|
||||||
const paginatedData = useMemo(() => {
|
const paginatedData = useMemo(() => {
|
||||||
const start = (pagination.current! - 1) * pagination.pageSize!;
|
const start = (pagination.current! - 1) * pagination.pageSize!;
|
||||||
const end = start + pagination.pageSize!;
|
const end = start + pagination.pageSize!;
|
||||||
return sortedData.slice(start, end);
|
return sortedData.slice(start, end).map(metric => ({ ...metric, key: metric.metricId}));
|
||||||
}, [sortedData, pagination]);
|
}, [sortedData, pagination]);
|
||||||
|
|
||||||
const handleTableChange = (
|
const handleTableChange = (
|
||||||
|
|
@ -84,34 +88,19 @@ const ListView: React.FC<Props> = (props: Props) => {
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{
|
{
|
||||||
title: (
|
title: 'Title',
|
||||||
<div className="flex items-center">
|
|
||||||
{!disableSelection && (
|
|
||||||
<Checkbox
|
|
||||||
name="slack"
|
|
||||||
className="mr-4"
|
|
||||||
checked={allSelected}
|
|
||||||
onClick={toggleAll}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<span>Title</span>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
dataIndex: 'name',
|
dataIndex: 'name',
|
||||||
key: 'title',
|
key: 'title',
|
||||||
className: 'cap-first',
|
className: 'cap-first pl-4',
|
||||||
sorter: true,
|
sorter: true,
|
||||||
|
width: '25%',
|
||||||
render: (text: string, metric: Metric) => (
|
render: (text: string, metric: Metric) => (
|
||||||
<MetricListItem
|
<MetricListItem
|
||||||
key={metric.metricId}
|
key={metric.metricId}
|
||||||
metric={metric}
|
metric={metric}
|
||||||
siteId={siteId}
|
siteId={siteId}
|
||||||
disableSelection={disableSelection}
|
inLibrary={inLibrary}
|
||||||
selected={selectedList.includes(metric.metricId)}
|
disableSelection={!inLibrary}
|
||||||
toggleSelection={(e: any) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
toggleSelection && toggleSelection(metric.metricId);
|
|
||||||
}}
|
|
||||||
renderColumn="title"
|
renderColumn="title"
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
@ -121,7 +110,7 @@ const ListView: React.FC<Props> = (props: Props) => {
|
||||||
dataIndex: 'owner',
|
dataIndex: 'owner',
|
||||||
key: 'owner',
|
key: 'owner',
|
||||||
className: 'capitalize',
|
className: 'capitalize',
|
||||||
width: '30%',
|
width: '25%',
|
||||||
sorter: true,
|
sorter: true,
|
||||||
render: (text: string, metric: Metric) => (
|
render: (text: string, metric: Metric) => (
|
||||||
<MetricListItem
|
<MetricListItem
|
||||||
|
|
@ -137,7 +126,7 @@ const ListView: React.FC<Props> = (props: Props) => {
|
||||||
dataIndex: 'lastModified',
|
dataIndex: 'lastModified',
|
||||||
key: 'lastModified',
|
key: 'lastModified',
|
||||||
sorter: true,
|
sorter: true,
|
||||||
width: '16.67%',
|
width: '25%',
|
||||||
render: (text: string, metric: Metric) => (
|
render: (text: string, metric: Metric) => (
|
||||||
<MetricListItem
|
<MetricListItem
|
||||||
key={metric.metricId}
|
key={metric.metricId}
|
||||||
|
|
@ -147,21 +136,9 @@ const ListView: React.FC<Props> = (props: Props) => {
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
// {
|
];
|
||||||
// title: 'Visibility',
|
if (!inLibrary) {
|
||||||
// dataIndex: 'visibility',
|
columns.push({
|
||||||
// key: 'visibility',
|
|
||||||
// width: '10%',
|
|
||||||
// render: (text: string, metric: Metric) => (
|
|
||||||
// <MetricListItem
|
|
||||||
// key={metric.metricId}
|
|
||||||
// metric={metric}
|
|
||||||
// siteId={siteId}
|
|
||||||
// renderColumn="visibility"
|
|
||||||
// />
|
|
||||||
// )
|
|
||||||
// },
|
|
||||||
{
|
|
||||||
title: '',
|
title: '',
|
||||||
key: 'options',
|
key: 'options',
|
||||||
className: 'text-right',
|
className: 'text-right',
|
||||||
|
|
@ -174,8 +151,12 @@ const ListView: React.FC<Props> = (props: Props) => {
|
||||||
renderColumn="options"
|
renderColumn="options"
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
columns.forEach(col => {
|
||||||
|
col.width = '31%';
|
||||||
|
})
|
||||||
}
|
}
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Table
|
<Table
|
||||||
|
|
@ -183,26 +164,20 @@ const ListView: React.FC<Props> = (props: Props) => {
|
||||||
dataSource={paginatedData}
|
dataSource={paginatedData}
|
||||||
rowKey="metricId"
|
rowKey="metricId"
|
||||||
onChange={handleTableChange}
|
onChange={handleTableChange}
|
||||||
size='middle'
|
onRow={inLibrary ? (record) => ({
|
||||||
|
onClick: () => disableSelection ? null : toggleSelection?.(record.metricId)
|
||||||
|
}) : undefined}
|
||||||
rowSelection={
|
rowSelection={
|
||||||
!disableSelection
|
!disableSelection
|
||||||
? {
|
? {
|
||||||
selectedRowKeys: selectedList.map((id: number) => id.toString()),
|
selectedRowKeys: selectedList,
|
||||||
onChange: (selectedRowKeys) => {
|
onChange: (selectedRowKeys) => {
|
||||||
selectedRowKeys.forEach((key: any) => {
|
toggleSelection(selectedRowKeys);
|
||||||
toggleSelection && toggleSelection(parseInt(key));
|
},
|
||||||
});
|
columnWidth: 16,
|
||||||
}
|
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
// footer={() => (
|
|
||||||
// <div className="flex justify-end">
|
|
||||||
// <Checkbox name="slack" checked={allSelected} onClick={toggleAll}>
|
|
||||||
// Select All
|
|
||||||
// </Checkbox>
|
|
||||||
// </div>
|
|
||||||
// )}
|
|
||||||
pagination={{
|
pagination={{
|
||||||
current: pagination.current,
|
current: pagination.current,
|
||||||
pageSize: pagination.pageSize,
|
pageSize: pagination.pageSize,
|
||||||
|
|
@ -211,7 +186,8 @@ const ListView: React.FC<Props> = (props: Props) => {
|
||||||
className: 'px-4',
|
className: 'px-4',
|
||||||
showLessItems: true,
|
showLessItems: true,
|
||||||
showTotal: () => totalMessage,
|
showTotal: () => totalMessage,
|
||||||
showQuickJumper: true
|
size: 'small',
|
||||||
|
simple: 'true',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { observer, useObserver } from 'mobx-react-lite';
|
import { observer } from 'mobx-react-lite';
|
||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
import { NoContent, Pagination, Icon, Loader } from 'UI';
|
import { NoContent, Loader } from 'UI';
|
||||||
import { useStore } from 'App/mstore';
|
import { useStore } from 'App/mstore';
|
||||||
import { sliceListPerPage } from 'App/utils';
|
import { sliceListPerPage } from 'App/utils';
|
||||||
import GridView from './GridView';
|
import GridView from './GridView';
|
||||||
|
|
@ -9,23 +9,36 @@ import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG';
|
||||||
|
|
||||||
function MetricsList({
|
function MetricsList({
|
||||||
siteId,
|
siteId,
|
||||||
onSelectionChange
|
onSelectionChange,
|
||||||
|
inLibrary,
|
||||||
}: {
|
}: {
|
||||||
siteId: string;
|
siteId: string;
|
||||||
onSelectionChange?: (selected: any[]) => void;
|
onSelectionChange?: (selected: any[]) => void;
|
||||||
|
inLibrary?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { metricStore, dashboardStore } = useStore();
|
const { metricStore, dashboardStore } = useStore();
|
||||||
const metricsSearch = metricStore.filter.query;
|
const metricsSearch = metricStore.filter.query;
|
||||||
const listView = useObserver(() => metricStore.listView);
|
const listView = inLibrary ? true : metricStore.listView;
|
||||||
const [selectedMetrics, setSelectedMetrics] = useState<any>([]);
|
const [selectedMetrics, setSelectedMetrics] = useState<any>([]);
|
||||||
|
|
||||||
const dashboard = dashboardStore.selectedDashboard;
|
const dashboard = dashboardStore.selectedDashboard;
|
||||||
const existingCardIds = useMemo(() => dashboard?.widgets?.map(i => parseInt(i.metricId)), [dashboard]);
|
const existingCardIds = useMemo(
|
||||||
const cards = useMemo(() => !!onSelectionChange ? metricStore.filteredCards.filter(i => !existingCardIds?.includes(parseInt(i.metricId))) : metricStore.filteredCards, [metricStore.filteredCards]);
|
() => dashboard?.widgets?.map((i) => parseInt(i.metricId)),
|
||||||
|
[dashboard]
|
||||||
|
);
|
||||||
|
const cards = useMemo(
|
||||||
|
() =>
|
||||||
|
!!onSelectionChange
|
||||||
|
? metricStore.filteredCards.filter(
|
||||||
|
(i) => !existingCardIds?.includes(parseInt(i.metricId))
|
||||||
|
)
|
||||||
|
: metricStore.filteredCards,
|
||||||
|
[metricStore.filteredCards]
|
||||||
|
);
|
||||||
const loading = metricStore.isLoading;
|
const loading = metricStore.isLoading;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
metricStore.fetchList();
|
void metricStore.fetchList();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -36,42 +49,59 @@ function MetricsList({
|
||||||
}, [selectedMetrics]);
|
}, [selectedMetrics]);
|
||||||
|
|
||||||
const toggleMetricSelection = (id: any) => {
|
const toggleMetricSelection = (id: any) => {
|
||||||
|
if (Array.isArray(id)) {
|
||||||
|
setSelectedMetrics(id);
|
||||||
|
return
|
||||||
|
}
|
||||||
if (selectedMetrics.includes(id)) {
|
if (selectedMetrics.includes(id)) {
|
||||||
setSelectedMetrics(selectedMetrics.filter((i: number) => i !== id));
|
setSelectedMetrics((prev) => prev.filter((i: number) => i !== id));
|
||||||
} else {
|
} else {
|
||||||
setSelectedMetrics([...selectedMetrics, id]);
|
setSelectedMetrics((prev) => [...prev, id]);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const lenth = cards.length;
|
const length = cards.length;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
metricStore.updateKey('sessionsPage', 1);
|
metricStore.updateKey('sessionsPage', 1);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const showOwn = metricStore.filter.showMine;
|
||||||
|
const toggleOwn = () => {
|
||||||
|
metricStore.updateKey('showMine', !showOwn);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<Loader loading={loading}>
|
<Loader loading={loading}>
|
||||||
<NoContent
|
<NoContent
|
||||||
show={lenth === 0}
|
show={length === 0}
|
||||||
title={
|
title={
|
||||||
<div className="flex flex-col items-center justify-center">
|
<div className="flex flex-col items-center justify-center">
|
||||||
<AnimatedSVG name={ICONS.NO_CARDS} size={60} />
|
<AnimatedSVG name={ICONS.NO_CARDS} size={60} />
|
||||||
<div className="text-center mt-4 text-lg font-medium">
|
<div className="text-center mt-4 text-lg font-medium">
|
||||||
{metricsSearch !== '' ? 'No matching results' : 'You haven\'t created any cards yet'}
|
{metricsSearch !== ''
|
||||||
|
? 'No matching results'
|
||||||
|
: "You haven't created any cards yet"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
subtext="Utilize cards to visualize key user interactions or product performance metrics."
|
subtext={
|
||||||
|
metricsSearch !== ''
|
||||||
|
? ''
|
||||||
|
: 'Utilize cards to visualize key user interactions or product performance metrics.'
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{listView ? (
|
{listView ? (
|
||||||
<ListView
|
<ListView
|
||||||
disableSelection={!onSelectionChange}
|
disableSelection={!onSelectionChange}
|
||||||
siteId={siteId}
|
siteId={siteId}
|
||||||
list={cards}
|
list={cards}
|
||||||
|
inLibrary={inLibrary}
|
||||||
selectedList={selectedMetrics}
|
selectedList={selectedMetrics}
|
||||||
existingCardIds={existingCardIds}
|
existingCardIds={existingCardIds}
|
||||||
toggleSelection={toggleMetricSelection}
|
toggleSelection={toggleMetricSelection}
|
||||||
allSelected={cards.length === selectedMetrics.length}
|
allSelected={cards.length === selectedMetrics.length}
|
||||||
|
showOwn={showOwn}
|
||||||
|
toggleOwn={toggleOwn}
|
||||||
toggleAll={({ target: { checked, name } }) =>
|
toggleAll={({ target: { checked, name } }) =>
|
||||||
setSelectedMetrics(checked ? cards.map((i: any) => i.metricId).slice(0, 30 - existingCardIds!.length) : [])
|
setSelectedMetrics(checked ? cards.map((i: any) => i.metricId).slice(0, 30 - existingCardIds!.length) : [])
|
||||||
}
|
}
|
||||||
|
|
@ -87,8 +117,8 @@ function MetricsList({
|
||||||
<div className="w-full flex items-center justify-between py-4 px-6 border-t">
|
<div className="w-full flex items-center justify-between py-4 px-6 border-t">
|
||||||
<div className="">
|
<div className="">
|
||||||
Showing{' '}
|
Showing{' '}
|
||||||
<span className="font-semibold">{Math.min(cards.length, metricStore.pageSize)}</span> out
|
<span className="font-medium">{Math.min(cards.length, metricStore.pageSize)}</span> out
|
||||||
of <span className="font-semibold">{cards.length}</span> cards
|
of <span className="font-medium">{cards.length}</span> cards
|
||||||
</div>
|
</div>
|
||||||
<Pagination
|
<Pagination
|
||||||
page={metricStore.page}
|
page={metricStore.page}
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ function MetricsSearch() {
|
||||||
value={query}
|
value={query}
|
||||||
allowClear
|
allowClear
|
||||||
name="metricsSearch"
|
name="metricsSearch"
|
||||||
className="w-full"
|
className="w-full input-search-card"
|
||||||
placeholder="Filter by title or owner"
|
placeholder="Filter by title or owner"
|
||||||
onChange={write}
|
onChange={write}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,10 @@ function MetricsView({ siteId }: Props) {
|
||||||
return (
|
return (
|
||||||
<div style={{ maxWidth: '1360px', margin: 'auto' }} className="bg-white rounded-lg shadow-sm pt-4 border">
|
<div style={{ maxWidth: '1360px', margin: 'auto' }} className="bg-white rounded-lg shadow-sm pt-4 border">
|
||||||
<MetricViewHeader siteId={siteId} />
|
<MetricViewHeader siteId={siteId} />
|
||||||
|
<div className='pt-3'>
|
||||||
<MetricsList siteId={siteId} />
|
<MetricsList siteId={siteId} />
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -64,8 +64,8 @@ function SessionsModal(props: Props) {
|
||||||
<div className='w-full flex items-center justify-between p-4 absolute bottom-0 bg-white'>
|
<div className='w-full flex items-center justify-between p-4 absolute bottom-0 bg-white'>
|
||||||
<div className='text-disabled-text'>
|
<div className='text-disabled-text'>
|
||||||
Showing <span
|
Showing <span
|
||||||
className='font-semibold'>{Math.min(length, 10)}</span> out of{' '}
|
className='font-medium'>{Math.min(length, 10)}</span> out of{' '}
|
||||||
<span className='font-semibold'>{total}</span> Issues
|
<span className='font-medium'>{total}</span> Issues
|
||||||
</div>
|
</div>
|
||||||
<Pagination
|
<Pagination
|
||||||
page={page}
|
page={page}
|
||||||
|
|
|
||||||
|
|
@ -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,13 +1,19 @@
|
||||||
import React, { useState, useRef, useEffect } from 'react';
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
import CustomMetricLineChart from 'App/components/Dashboard/Widgets/CustomMetricsWidgets/CustomMetricLineChart';
|
import LineChart from 'App/components/Charts/LineChart'
|
||||||
|
import BarChart from 'App/components/Charts/BarChart'
|
||||||
|
import PieChart from 'App/components/Charts/PieChart'
|
||||||
|
import ColumnChart from 'App/components/Charts/ColumnChart'
|
||||||
|
import SankeyChart from 'Components/Charts/SankeyChart';
|
||||||
|
|
||||||
import CustomMetricPercentage from 'App/components/Dashboard/Widgets/CustomMetricsWidgets/CustomMetricPercentage';
|
import CustomMetricPercentage from 'App/components/Dashboard/Widgets/CustomMetricsWidgets/CustomMetricPercentage';
|
||||||
import CustomMetricPieChart from 'App/components/Dashboard/Widgets/CustomMetricsWidgets/CustomMetricPieChart';
|
|
||||||
import { Styles } from 'App/components/Dashboard/Widgets/common';
|
import { Styles } from 'App/components/Dashboard/Widgets/common';
|
||||||
import { observer } from 'mobx-react-lite';
|
import { observer } from 'mobx-react-lite';
|
||||||
import { Icon, Loader } from 'UI';
|
import { Icon, Loader } from 'UI';
|
||||||
import { useStore } from 'App/mstore';
|
import { useStore } from 'App/mstore';
|
||||||
|
import FunnelTable from "../../../Funnels/FunnelWidget/FunnelTable";
|
||||||
|
import BugNumChart from '../../Widgets/CustomMetricsWidgets/BigNumChart';
|
||||||
|
import WidgetDatatable from '../WidgetDatatable/WidgetDatatable';
|
||||||
import WidgetPredefinedChart from '../WidgetPredefinedChart';
|
import WidgetPredefinedChart from '../WidgetPredefinedChart';
|
||||||
import CustomMetricOverviewChart from 'App/components/Dashboard/Widgets/CustomMetricsWidgets/CustomMetricOverviewChart';
|
|
||||||
import { getStartAndEndTimestampsByDensity } from 'Types/dashboard/helper';
|
import { getStartAndEndTimestampsByDensity } from 'Types/dashboard/helper';
|
||||||
import { debounce } from 'App/utils';
|
import { debounce } from 'App/utils';
|
||||||
import useIsMounted from 'App/hooks/useIsMounted';
|
import useIsMounted from 'App/hooks/useIsMounted';
|
||||||
|
|
@ -20,17 +26,18 @@ import {
|
||||||
ERRORS,
|
ERRORS,
|
||||||
INSIGHTS,
|
INSIGHTS,
|
||||||
USER_PATH,
|
USER_PATH,
|
||||||
RETENTION
|
RETENTION,
|
||||||
} from 'App/constants/card';
|
} from 'App/constants/card';
|
||||||
import FunnelWidget from 'App/components/Funnels/FunnelWidget';
|
import FunnelWidget from 'App/components/Funnels/FunnelWidget';
|
||||||
import CustomMetricTableSessions from 'App/components/Dashboard/Widgets/CustomMetricsWidgets/CustomMetricTableSessions';
|
import CustomMetricTableSessions from 'App/components/Dashboard/Widgets/CustomMetricsWidgets/CustomMetricTableSessions';
|
||||||
import CustomMetricTableErrors from 'App/components/Dashboard/Widgets/CustomMetricsWidgets/CustomMetricTableErrors';
|
import CustomMetricTableErrors from 'App/components/Dashboard/Widgets/CustomMetricsWidgets/CustomMetricTableErrors';
|
||||||
import ClickMapCard from 'App/components/Dashboard/Widgets/CustomMetricsWidgets/ClickMapCard';
|
import ClickMapCard from 'App/components/Dashboard/Widgets/CustomMetricsWidgets/ClickMapCard';
|
||||||
import InsightsCard from 'App/components/Dashboard/Widgets/CustomMetricsWidgets/InsightsCard';
|
import InsightsCard from 'App/components/Dashboard/Widgets/CustomMetricsWidgets/InsightsCard';
|
||||||
import SankeyChart from 'Shared/Insights/SankeyChart';
|
import { filterMinorPaths } from 'Shared/Insights/SankeyChart/utils'
|
||||||
import CohortCard from '../../Widgets/CustomMetricsWidgets/CohortCard';
|
import CohortCard from '../../Widgets/CustomMetricsWidgets/CohortCard';
|
||||||
import SessionsBy from "Components/Dashboard/Widgets/CustomMetricsWidgets/SessionsBy";
|
import SessionsBy from 'Components/Dashboard/Widgets/CustomMetricsWidgets/SessionsBy';
|
||||||
import { useInView } from "react-intersection-observer";
|
import { useInView } from 'react-intersection-observer';
|
||||||
|
import LongLoader from "./LongLoader";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
metric: any;
|
metric: any;
|
||||||
|
|
@ -42,128 +49,402 @@ interface Props {
|
||||||
function WidgetChart(props: Props) {
|
function WidgetChart(props: Props) {
|
||||||
const { ref, inView } = useInView({
|
const { ref, inView } = useInView({
|
||||||
triggerOnce: true,
|
triggerOnce: true,
|
||||||
rootMargin: "200px 0px",
|
rootMargin: '200px 0px',
|
||||||
});
|
});
|
||||||
const { isSaved = false, metric, isTemplate } = props;
|
const { isSaved = false, metric, isTemplate } = props;
|
||||||
const {dashboardStore, metricStore, sessionStore} = useStore();
|
const { dashboardStore, metricStore } = useStore();
|
||||||
const _metric: any = metricStore.instance;
|
const _metric: any = props.isPreview ? metricStore.instance : props.metric;
|
||||||
|
const data = _metric.data;
|
||||||
const period = dashboardStore.period;
|
const period = dashboardStore.period;
|
||||||
const drillDownPeriod = dashboardStore.drillDownPeriod;
|
const drillDownPeriod = dashboardStore.drillDownPeriod;
|
||||||
const drillDownFilter = dashboardStore.drillDownFilter;
|
const drillDownFilter = dashboardStore.drillDownFilter;
|
||||||
const colors = Styles.customMetricColors;
|
const colors = Styles.safeColors;
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const params = {density: 70};
|
const [stale, setStale] = useState(false);
|
||||||
const metricParams = {...params};
|
const params = { density: dashboardStore.selectedDensity };
|
||||||
|
const metricParams = _metric.params;
|
||||||
const prevMetricRef = useRef<any>();
|
const prevMetricRef = useRef<any>();
|
||||||
const isMounted = useIsMounted();
|
const isMounted = useIsMounted();
|
||||||
const [data, setData] = useState<any>(metric.data);
|
const [compData, setCompData] = useState<any>(null);
|
||||||
|
const [enabledRows, setEnabledRows] = useState<string[]>([]);
|
||||||
const isTableWidget = metric.metricType === 'table' && metric.viewType === 'table';
|
const isTableWidget =
|
||||||
const isPieChart = metric.metricType === 'table' && metric.viewType === 'pieChart';
|
_metric.metricType === 'table' && _metric.viewType === 'table';
|
||||||
|
const isPieChart =
|
||||||
|
_metric.metricType === 'table' && _metric.viewType === 'pieChart';
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
|
dashboardStore.setComparisonPeriod(null, _metric.metricId);
|
||||||
dashboardStore.resetDrillDownFilter();
|
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) => {
|
const onChartClick = (event: any) => {
|
||||||
|
metricStore.setDrillDown(true);
|
||||||
if (event) {
|
if (event) {
|
||||||
if (isTableWidget || isPieChart) { // get the filter of clicked row
|
if (isTableWidget || isPieChart) {
|
||||||
|
// get the filter of clicked row
|
||||||
const periodTimestamps = drillDownPeriod.toTimestamps();
|
const periodTimestamps = drillDownPeriod.toTimestamps();
|
||||||
drillDownFilter.merge({
|
drillDownFilter.merge({
|
||||||
filters: event,
|
filters: event,
|
||||||
startTimestamp: periodTimestamps.startTimestamp,
|
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 payload = event.activePayload[0].payload;
|
||||||
const timestamp = payload.timestamp;
|
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({
|
drillDownFilter.merge({
|
||||||
startTimestamp: periodTimestamps.startTimestamp,
|
startTimestamp: periodTimestamps.startTimestamp,
|
||||||
endTimestamp: periodTimestamps.endTimestamp
|
endTimestamp: periodTimestamps.endTimestamp,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const loadSample = () => console.log('clicked')
|
||||||
|
|
||||||
const depsString = JSON.stringify({
|
const depsString = JSON.stringify({
|
||||||
..._metric.series, ..._metric.excludes, ..._metric.startPoint,
|
..._metric.series,
|
||||||
hideExcess: _metric.hideExcess
|
..._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;
|
if (!isMounted()) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
dashboardStore.fetchMetricChartData(metric, payload, isSaved, period).then((res: any) => {
|
const tm = setTimeout(() => {
|
||||||
if (isMounted()) setData(res);
|
setStale(true)
|
||||||
}).finally(() => {
|
}, 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);
|
setLoading(false);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const debounceRequest: any = React.useCallback(debounce(fetchMetricChartData, 500), []);
|
const debounceRequest: any = React.useCallback(
|
||||||
|
debounce(fetchMetricChartData, 500),
|
||||||
|
[]
|
||||||
|
);
|
||||||
const loadPage = () => {
|
const loadPage = () => {
|
||||||
if (!inView) return;
|
if (!inView) return;
|
||||||
if (prevMetricRef.current && prevMetricRef.current.name !== metric.name) {
|
if (prevMetricRef.current && prevMetricRef.current.name !== _metric.name) {
|
||||||
prevMetricRef.current = metric;
|
prevMetricRef.current = _metric;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
prevMetricRef.current = metric;
|
prevMetricRef.current = _metric;
|
||||||
const timestmaps = drillDownPeriod.toTimestamps();
|
const timestmaps = drillDownPeriod.toTimestamps();
|
||||||
const payload = isSaved ? {...params} : {...metricParams, ...timestmaps, ...metric.toJson()};
|
const payload = isSaved
|
||||||
debounceRequest(metric, payload, isSaved, !isSaved ? drillDownPeriod : period);
|
? { ...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(() => {
|
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('page', 1);
|
||||||
|
_metric.updateKey()
|
||||||
loadPage();
|
loadPage();
|
||||||
}, [
|
}, [
|
||||||
drillDownPeriod,
|
drillDownPeriod,
|
||||||
period,
|
period,
|
||||||
depsString,
|
depsString,
|
||||||
metric.metricType,
|
dashboardStore.selectedDensity,
|
||||||
metric.metricOf,
|
_metric.metricType,
|
||||||
metric.viewType,
|
_metric.metricOf,
|
||||||
metric.metricValue,
|
_metric.metricValue,
|
||||||
metric.startType,
|
_metric.startType,
|
||||||
metric.metricFormat,
|
_metric.metricFormat,
|
||||||
inView,
|
inView,
|
||||||
]);
|
]);
|
||||||
useEffect(loadPage, [_metric.page]);
|
useEffect(loadPage, [_metric.page]);
|
||||||
|
|
||||||
|
const onFocus = (seriesName: string)=> {
|
||||||
|
metricStore.setFocusedSeriesName(seriesName);
|
||||||
|
metricStore.setDrillDown(true)
|
||||||
|
}
|
||||||
|
|
||||||
const renderChart = () => {
|
const renderChart = React.useCallback(() => {
|
||||||
const {metricType, viewType, metricOf} = metric;
|
const { metricType, metricOf } = _metric;
|
||||||
const metricWithData = {...metric, data};
|
const viewType = _metric.viewType;
|
||||||
|
const metricWithData = { ..._metric, data };
|
||||||
|
|
||||||
if (metricType === FUNNEL) {
|
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) {
|
if (metricType === 'predefined' || metricType === ERRORS) {
|
||||||
const defaultMetric = metric.data.chart && metric.data.chart.length === 0 ? metricWithData : metric;
|
const defaultMetric =
|
||||||
return <WidgetPredefinedChart isTemplate={isTemplate} metric={defaultMetric} data={data}
|
_metric.data.chart && _metric.data.chart.length === 0
|
||||||
predefinedKey={metric.metricOf}/>;
|
? metricWithData
|
||||||
|
: metric;
|
||||||
|
return (
|
||||||
|
<WidgetPredefinedChart
|
||||||
|
isTemplate={isTemplate}
|
||||||
|
metric={defaultMetric}
|
||||||
|
data={data}
|
||||||
|
predefinedKey={_metric.metricOf}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (metricType === TIMESERIES) {
|
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') {
|
if (viewType === 'lineChart') {
|
||||||
return (
|
return (
|
||||||
<CustomMetricLineChart
|
<div className='pt-3'>
|
||||||
data={data}
|
<LineChart
|
||||||
colors={colors}
|
chartName={_metric.name}
|
||||||
params={params}
|
inGrid={!props.isPreview}
|
||||||
|
data={chartData}
|
||||||
|
compData={compDataCopy}
|
||||||
onClick={onChartClick}
|
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 (
|
return (
|
||||||
<CustomMetricPercentage
|
<CustomMetricPercentage
|
||||||
|
inGrid={!props.isPreview}
|
||||||
data={data[0]}
|
data={data[0]}
|
||||||
colors={colors}
|
colors={colors}
|
||||||
params={params}
|
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) {
|
if (metricOf === FilterKey.SESSIONS) {
|
||||||
return (
|
return (
|
||||||
<CustomMetricTableSessions
|
<CustomMetricTableSessions
|
||||||
metric={metric}
|
metric={_metric}
|
||||||
data={data}
|
data={data}
|
||||||
isTemplate={isTemplate}
|
isTemplate={isTemplate}
|
||||||
isEdit={!isSaved && !isTemplate}
|
isEdit={!isSaved && !isTemplate}
|
||||||
|
|
@ -183,7 +464,7 @@ function WidgetChart(props: Props) {
|
||||||
if (metricOf === FilterKey.ERRORS) {
|
if (metricOf === FilterKey.ERRORS) {
|
||||||
return (
|
return (
|
||||||
<CustomMetricTableErrors
|
<CustomMetricTableErrors
|
||||||
metric={metric}
|
metric={_metric}
|
||||||
data={data}
|
data={data}
|
||||||
// isTemplate={isTemplate}
|
// isTemplate={isTemplate}
|
||||||
isEdit={!isSaved && !isTemplate}
|
isEdit={!isSaved && !isTemplate}
|
||||||
|
|
@ -193,40 +474,37 @@ function WidgetChart(props: Props) {
|
||||||
if (viewType === TABLE) {
|
if (viewType === TABLE) {
|
||||||
return (
|
return (
|
||||||
<SessionsBy
|
<SessionsBy
|
||||||
metric={metric}
|
metric={_metric}
|
||||||
data={data[0]}
|
data={data}
|
||||||
onClick={onChartClick}
|
onClick={onChartClick}
|
||||||
isTemplate={isTemplate}
|
isTemplate={isTemplate}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
} else if (viewType === 'pieChart') {
|
|
||||||
return (
|
|
||||||
<CustomMetricPieChart
|
|
||||||
metric={metric}
|
|
||||||
data={data[0]}
|
|
||||||
colors={colors}
|
|
||||||
// params={params}
|
|
||||||
onClick={onChartClick}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (metricType === HEATMAP) {
|
if (metricType === HEATMAP) {
|
||||||
if (!props.isPreview) {
|
if (!props.isPreview) {
|
||||||
return metric.thumbnail ? (
|
return _metric.thumbnail ? (
|
||||||
<div style={{height: '229px', overflow: 'hidden', marginBottom: '10px'}}>
|
<div
|
||||||
<img src={metric.thumbnail} alt='clickmap thumbnail'/>
|
style={{
|
||||||
|
height: '229px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
marginBottom: '10px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img src={_metric.thumbnail} alt="clickmap thumbnail" />
|
||||||
</div>
|
</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" />
|
<Icon name="info-circle" className="mr-2" size="14" />
|
||||||
No data available for the selected period.
|
No data available for the selected period.
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return <ClickMapCard />;
|
||||||
<ClickMapCard />
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (metricType === INSIGHTS) {
|
if (metricType === INSIGHTS) {
|
||||||
|
|
@ -234,19 +512,22 @@ function WidgetChart(props: Props) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (metricType === USER_PATH && data && data.links) {
|
if (metricType === USER_PATH && data && data.links) {
|
||||||
// return <PathAnalysis data={data}/>;
|
const usedData = _metric.hideExcess ? filterMinorPaths(data) : data;
|
||||||
return <SankeyChart
|
return (
|
||||||
|
<SankeyChart
|
||||||
height={props.isPreview ? 500 : 240}
|
height={props.isPreview ? 500 : 240}
|
||||||
data={data}
|
data={usedData}
|
||||||
onChartClick={(filters: any) => {
|
onChartClick={(filters: any) => {
|
||||||
dashboardStore.drillDownFilter.merge({ filters, page: 1 });
|
dashboardStore.drillDownFilter.merge({ filters, page: 1 });
|
||||||
}}/>;
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (metricType === RETENTION) {
|
if (metricType === RETENTION) {
|
||||||
if (viewType === 'trend') {
|
if (viewType === 'trend') {
|
||||||
return (
|
return (
|
||||||
<CustomMetricLineChart
|
<LineChart
|
||||||
data={data}
|
data={data}
|
||||||
colors={colors}
|
colors={colors}
|
||||||
params={params}
|
params={params}
|
||||||
|
|
@ -254,21 +535,36 @@ function WidgetChart(props: Props) {
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
} else if (viewType === 'cohort') {
|
} else if (viewType === 'cohort') {
|
||||||
return (
|
return <CohortCard data={data[0]} />;
|
||||||
<CohortCard data={data[0]}/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
console.log('Unknown metric type', metricType);
|
||||||
return <div>Unknown metric type</div>;
|
return <div>Unknown metric type</div>;
|
||||||
};
|
}, [data, compData, enabledRows, _metric]);
|
||||||
|
|
||||||
|
|
||||||
|
const showTable = _metric.metricType === TIMESERIES && (props.isPreview || _metric.viewType === TABLE)
|
||||||
return (
|
return (
|
||||||
<div ref={ref}>
|
<div ref={ref}>
|
||||||
<Loader loading={loading} style={{height: `240px`}}>
|
{loading ? stale ? <LongLoader onClick={loadSample} /> : <Loader loading={loading} style={{ height: `240px` }} /> : (
|
||||||
<div style={{minHeight: 240}}>{renderChart()}</div>
|
<div style={{ minHeight: props.isPreview ? undefined : 240 }}>
|
||||||
</Loader>
|
{renderChart()}
|
||||||
|
{showTable ? (
|
||||||
|
<WidgetDatatable
|
||||||
|
compData={compData}
|
||||||
|
inBuilder={props.isPreview}
|
||||||
|
defaultOpen={true}
|
||||||
|
data={data}
|
||||||
|
enabledRows={enabledRows}
|
||||||
|
setEnabledRows={setEnabledRows}
|
||||||
|
metric={_metric}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export default observer(WidgetChart);
|
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 React from 'react';
|
||||||
import SelectDateRange from 'Shared/SelectDateRange';
|
import SelectDateRange from 'Shared/SelectDateRange';
|
||||||
import { useStore } from 'App/mstore';
|
import { useStore } from 'App/mstore';
|
||||||
import {useObserver} from 'mobx-react-lite';
|
import { observer } from 'mobx-react-lite';
|
||||||
import {Space} from "antd";
|
import { Space } from 'antd';
|
||||||
|
import RangeGranularity from "./RangeGranularity";
|
||||||
|
import {
|
||||||
|
CUSTOM_RANGE,
|
||||||
|
DATE_RANGE_COMPARISON_OPTIONS,
|
||||||
|
} from 'App/dateRange';
|
||||||
|
import Period from 'Types/app/period';
|
||||||
|
|
||||||
function WidgetDateRange({
|
function WidgetDateRange({
|
||||||
label = 'Time Range',
|
label = 'Time Range',
|
||||||
|
hasGranularSettings = false,
|
||||||
|
hasGranularity = false,
|
||||||
|
hasComparison = false,
|
||||||
|
presetComparison = null,
|
||||||
}: any) {
|
}: any) {
|
||||||
const {dashboardStore} = useStore();
|
const { dashboardStore, metricStore } = useStore();
|
||||||
const period = useObserver(() => dashboardStore.drillDownPeriod);
|
const density = dashboardStore.selectedDensity
|
||||||
const drillDownFilter = useObserver(() => dashboardStore.drillDownFilter);
|
const onDensityChange = (density: number) => {
|
||||||
|
dashboardStore.setDensity(density);
|
||||||
|
}
|
||||||
|
const period = dashboardStore.drillDownPeriod;
|
||||||
|
const compPeriod = dashboardStore.comparisonPeriods[metricStore.instance.metricId];
|
||||||
|
const drillDownFilter = dashboardStore.drillDownFilter;
|
||||||
|
|
||||||
const onChangePeriod = (period: any) => {
|
const onChangePeriod = (period: any) => {
|
||||||
dashboardStore.setDrillDownPeriod(period);
|
dashboardStore.setDrillDownPeriod(period);
|
||||||
|
|
@ -17,7 +32,50 @@ function WidgetDateRange({
|
||||||
drillDownFilter.merge({
|
drillDownFilter.merge({
|
||||||
startTimestamp: periodTimestamps.startTimestamp,
|
startTimestamp: periodTimestamps.startTimestamp,
|
||||||
endTimestamp: periodTimestamps.endTimestamp,
|
endTimestamp: periodTimestamps.endTimestamp,
|
||||||
})
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onChangeComparison = (period: any) => {
|
||||||
|
dashboardStore.setComparisonPeriod(period, metricStore.instance.metricId);
|
||||||
|
}
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (presetComparison) {
|
||||||
|
const option = DATE_RANGE_COMPARISON_OPTIONS.find((option: any) => option.value === presetComparison[0]);
|
||||||
|
if (option) {
|
||||||
|
// @ts-ignore
|
||||||
|
const newPeriod = new Period({
|
||||||
|
start: period.start,
|
||||||
|
end: period.end,
|
||||||
|
substract: option.value,
|
||||||
|
});
|
||||||
|
setTimeout(() => {
|
||||||
|
onChangeComparison(newPeriod);
|
||||||
|
}, 1)
|
||||||
|
} else {
|
||||||
|
const day = 86400000;
|
||||||
|
const originalPeriodLength = Math.ceil(
|
||||||
|
(period.end - period.start) / day
|
||||||
|
);
|
||||||
|
const start = presetComparison[0];
|
||||||
|
const end = presetComparison[1] + originalPeriodLength * day;
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
const compRange = new Period({
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
rangeName: CUSTOM_RANGE,
|
||||||
|
});
|
||||||
|
setTimeout(() => {
|
||||||
|
onChangeComparison(compRange);
|
||||||
|
}, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [presetComparison])
|
||||||
|
|
||||||
|
const updateInstComparison = (range: [start: string, end?: string] | null) => {
|
||||||
|
metricStore.instance.setComparisonRange(range);
|
||||||
|
metricStore.instance.updateKey('hasChanged', true)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -26,12 +84,35 @@ function WidgetDateRange({
|
||||||
<SelectDateRange
|
<SelectDateRange
|
||||||
period={period}
|
period={period}
|
||||||
onChange={onChangePeriod}
|
onChange={onChangePeriod}
|
||||||
right={true}
|
|
||||||
isAnt={true}
|
isAnt={true}
|
||||||
useButtonStyle={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>
|
</Space>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default WidgetDateRange;
|
export default observer(WidgetDateRange);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Card, Space, Typography, Button, Alert, Form } from 'antd';
|
import { Card, Space, Button, Alert, Form, Select, Tooltip } from 'antd';
|
||||||
import { useStore } from 'App/mstore';
|
import { useStore } from 'App/mstore';
|
||||||
import { eventKeys } from 'Types/filter/newFilter';
|
import { eventKeys } from 'Types/filter/newFilter';
|
||||||
import {
|
import {
|
||||||
|
|
@ -9,89 +9,147 @@ import {
|
||||||
INSIGHTS,
|
INSIGHTS,
|
||||||
RETENTION,
|
RETENTION,
|
||||||
TABLE,
|
TABLE,
|
||||||
USER_PATH
|
USER_PATH,
|
||||||
} from 'App/constants/card';
|
} from 'App/constants/card';
|
||||||
import FilterSeries from 'Components/Dashboard/components/FilterSeries/FilterSeries';
|
import FilterSeries from 'Components/Dashboard/components/FilterSeries/FilterSeries';
|
||||||
import { issueCategories, metricOf } from 'App/constants/filterOptions';
|
import { issueCategories } from 'App/constants/filterOptions';
|
||||||
import { AudioWaveform, ChevronDown, ChevronUp, PlusIcon } from 'lucide-react';
|
import { PlusIcon, ChevronUp } from 'lucide-react';
|
||||||
import { observer } from 'mobx-react-lite';
|
import { observer } from 'mobx-react-lite';
|
||||||
import AddStepButton from 'Components/Dashboard/components/FilterSeries/AddStepButton';
|
|
||||||
import FilterItem from 'Shared/Filters/FilterItem';
|
import FilterItem from 'Shared/Filters/FilterItem';
|
||||||
import { FilterKey } from 'Types/filter/filterType';
|
import { FilterKey, FilterCategory } from 'Types/filter/filterType';
|
||||||
import Select from 'Shared/Select';
|
|
||||||
|
|
||||||
function WidgetFormNew() {
|
const getExcludedKeys = (metricType: string) => {
|
||||||
const { metricStore, dashboardStore, aiFiltersStore } = useStore();
|
switch (metricType) {
|
||||||
|
case USER_PATH:
|
||||||
|
case HEATMAP:
|
||||||
|
return eventKeys;
|
||||||
|
default:
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getExcludedCategories = (metricType: string) => {
|
||||||
|
switch (metricType) {
|
||||||
|
case USER_PATH:
|
||||||
|
case FUNNEL:
|
||||||
|
return [FilterCategory.DEVTOOLS]
|
||||||
|
default:
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function WidgetFormNew({ layout }: { layout: string }) {
|
||||||
|
const { metricStore } = useStore();
|
||||||
const metric: any = metricStore.instance;
|
const metric: any = metricStore.instance;
|
||||||
|
const excludeFilterKeys = getExcludedKeys(metric.metricType);
|
||||||
|
const excludeCategory = getExcludedCategories(metric.metricType);
|
||||||
|
|
||||||
const eventsLength = metric.series[0].filter.filters.filter((i: any) => i && i.isEvent).length;
|
const isPredefined = metric.metricType === ERRORS;
|
||||||
const filtersLength = metric.series[0].filter.filters.filter((i: any) => i && !i.isEvent).length;
|
|
||||||
const isClickMap = metric.metricType === HEATMAP;
|
|
||||||
const isPathAnalysis = metric.metricType === USER_PATH;
|
|
||||||
const excludeFilterKeys = isClickMap || isPathAnalysis ? eventKeys : [];
|
|
||||||
const hasFilters = filtersLength > 0 || eventsLength > 0;
|
|
||||||
const isPredefined = metric.metricType === ERRORS
|
|
||||||
|
|
||||||
return isPredefined ? <PredefinedMessage /> : (
|
return isPredefined ? (
|
||||||
|
<PredefinedMessage />
|
||||||
|
) : (
|
||||||
<Space direction="vertical" className="w-full">
|
<Space direction="vertical" className="w-full">
|
||||||
<AdditionalFilters />
|
<AdditionalFilters />
|
||||||
{/*{!hasFilters && (<DefineSteps metric={metric} excludeFilterKeys={excludeFilterKeys} />)}*/}
|
<FilterSection
|
||||||
{/*{hasFilters && (<FilterSection metric={metric} excludeFilterKeys={excludeFilterKeys} />)}*/}
|
layout={layout}
|
||||||
<FilterSection metric={metric} excludeFilterKeys={excludeFilterKeys} />
|
metric={metric}
|
||||||
|
excludeCategory={excludeCategory}
|
||||||
|
excludeFilterKeys={excludeFilterKeys}
|
||||||
|
/>
|
||||||
</Space>
|
</Space>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default observer(WidgetFormNew);
|
export default observer(WidgetFormNew);
|
||||||
|
|
||||||
|
const FilterSection = observer(({ layout, metric, excludeFilterKeys, excludeCategory }: any) => {
|
||||||
|
const allOpen = layout.startsWith('flex-row');
|
||||||
|
const defaultClosed = React.useRef(!allOpen && metric.exists());
|
||||||
|
const [seriesCollapseState, setSeriesCollapseState] = React.useState<Record<number, boolean>>({});
|
||||||
|
|
||||||
function DefineSteps({ metric, excludeFilterKeys }: any) {
|
React.useEffect(() => {
|
||||||
return (
|
const defaultSeriesCollapseState: Record<number, boolean> = {};
|
||||||
<div className="px-4 py-2 bg-white rounded-lg shadow-sm flex items-center">
|
metric.series.forEach((s: any) => {
|
||||||
<Typography.Text strong>Filter</Typography.Text>
|
defaultSeriesCollapseState[s.seriesId] = defaultSeriesCollapseState[
|
||||||
<AddStepButton excludeFilterKeys={excludeFilterKeys} series={metric.series[0]} />
|
s.seriesId
|
||||||
</div>
|
]
|
||||||
);
|
? defaultSeriesCollapseState[s.seriesId]
|
||||||
}
|
: allOpen
|
||||||
|
? false
|
||||||
|
: defaultClosed.current;
|
||||||
const FilterSection = observer(({ metric, excludeFilterKeys }: any) => {
|
});
|
||||||
// const timeseriesOptions = metricOf.filter((i) => i.type === 'timeseries');
|
setSeriesCollapseState(defaultSeriesCollapseState);
|
||||||
// const tableOptions = metricOf.filter((i) => i.type === 'table');
|
}, [metric.series]);
|
||||||
const isTable = metric.metricType === TABLE;
|
const isTable = metric.metricType === TABLE;
|
||||||
const isClickMap = metric.metricType === HEATMAP;
|
const isHeatMap = metric.metricType === HEATMAP;
|
||||||
const isFunnel = metric.metricType === FUNNEL;
|
const isFunnel = metric.metricType === FUNNEL;
|
||||||
const isInsights = metric.metricType === INSIGHTS;
|
const isInsights = metric.metricType === INSIGHTS;
|
||||||
const isPathAnalysis = metric.metricType === USER_PATH;
|
const isPathAnalysis = metric.metricType === USER_PATH;
|
||||||
const isRetention = metric.metricType === RETENTION;
|
const isRetention = metric.metricType === RETENTION;
|
||||||
const canAddSeries = metric.series.length < 3;
|
const canAddSeries = metric.series.length < 3;
|
||||||
const eventsLength = metric.series[0].filter.filters.filter((i: any) => i && i.isEvent).length;
|
|
||||||
// const cannotSaveFunnel = isFunnel && (!metric.series[0] || eventsLength <= 1);
|
|
||||||
|
|
||||||
const isSingleSeries = isTable || isFunnel || isClickMap || isInsights || isRetention;
|
const isSingleSeries =
|
||||||
|
isTable ||
|
||||||
|
isFunnel ||
|
||||||
|
isHeatMap ||
|
||||||
|
isInsights ||
|
||||||
|
isRetention ||
|
||||||
|
isPathAnalysis;
|
||||||
|
|
||||||
// const onAddFilter = (filter: any) => {
|
const collapseAll = () => {
|
||||||
// metric.series[0].filter.addFilter(filter);
|
setSeriesCollapseState((seriesCollapseState) => {
|
||||||
// metric.updateKey('hasChanged', true)
|
const newState = { ...seriesCollapseState };
|
||||||
// }
|
Object.keys(newState).forEach((key) => {
|
||||||
|
newState[key] = true;
|
||||||
|
});
|
||||||
|
return newState;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const expandAll = () => {
|
||||||
|
setSeriesCollapseState((seriesCollapseState) => {
|
||||||
|
const newState = { ...seriesCollapseState };
|
||||||
|
Object.keys(newState).forEach((key) => {
|
||||||
|
newState[key] = false;
|
||||||
|
});
|
||||||
|
return newState;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const allCollapsed = Object.values(seriesCollapseState).every((v) => v);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{
|
{metric.series.length > 0 &&
|
||||||
metric.series.length > 0 && metric.series
|
metric.series
|
||||||
.slice(0, isSingleSeries ? 1 : metric.series.length)
|
.slice(0, isSingleSeries ? 1 : metric.series.length)
|
||||||
.map((series: any, index: number) => (
|
.map((series: any, index: number) => (
|
||||||
<div className="mb-2" key={series.name}>
|
<div className="mb-2 rounded-xl" key={series.name}>
|
||||||
<FilterSeries
|
<FilterSeries
|
||||||
|
isHeatmap={isHeatMap}
|
||||||
canExclude={isPathAnalysis}
|
canExclude={isPathAnalysis}
|
||||||
supportsEmpty={!isClickMap && !isPathAnalysis}
|
removeEvents={isPathAnalysis}
|
||||||
|
supportsEmpty={!isHeatMap && !isPathAnalysis}
|
||||||
excludeFilterKeys={excludeFilterKeys}
|
excludeFilterKeys={excludeFilterKeys}
|
||||||
|
excludeCategory={excludeCategory}
|
||||||
observeChanges={() => metric.updateKey('hasChanged', true)}
|
observeChanges={() => metric.updateKey('hasChanged', true)}
|
||||||
hideHeader={isTable || isClickMap || isInsights || isPathAnalysis || isFunnel}
|
hideHeader={
|
||||||
|
isTable ||
|
||||||
|
isHeatMap ||
|
||||||
|
isInsights ||
|
||||||
|
isPathAnalysis ||
|
||||||
|
isFunnel
|
||||||
|
}
|
||||||
seriesIndex={index}
|
seriesIndex={index}
|
||||||
series={series}
|
series={series}
|
||||||
onRemoveSeries={() => metric.removeSeries(index)}
|
onRemoveSeries={() => metric.removeSeries(index)}
|
||||||
canDelete={metric.series.length > 1}
|
canDelete={metric.series.length > 1}
|
||||||
|
collapseState={seriesCollapseState[series.seriesId]}
|
||||||
|
onToggleCollapse={() => {
|
||||||
|
setSeriesCollapseState((seriesCollapseState) => ({
|
||||||
|
...seriesCollapseState,
|
||||||
|
[series.seriesId]: !seriesCollapseState[series.seriesId],
|
||||||
|
}));
|
||||||
|
}}
|
||||||
emptyMessage={
|
emptyMessage={
|
||||||
isTable
|
isTable
|
||||||
? 'Filter data using any event or attribute. Use Add Step button below to do so.'
|
? 'Filter data using any event or attribute. Use Add Step button below to do so.'
|
||||||
|
|
@ -100,74 +158,110 @@ const FilterSection = observer(({ metric, excludeFilterKeys }: any) => {
|
||||||
expandable={isSingleSeries}
|
expandable={isSingleSeries}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))
|
))}
|
||||||
}
|
{isSingleSeries ? null :
|
||||||
|
<div className={'mx-auto flex items-center gap-2 w-fit'}>
|
||||||
{!isSingleSeries && canAddSeries && (
|
<Tooltip title={canAddSeries ? '' : 'Maximum of 3 series reached.'}>
|
||||||
<Card styles={{ body: { padding: '4px' } }} className="rounded-full shadow-sm">
|
|
||||||
<Button
|
<Button
|
||||||
type="link"
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
if (!canAddSeries) return;
|
||||||
metric.addSeries();
|
metric.addSeries();
|
||||||
}}
|
}}
|
||||||
disabled={!canAddSeries}
|
disabled={!canAddSeries}
|
||||||
size="small"
|
size="small"
|
||||||
className="block w-full"
|
type="primary"
|
||||||
|
icon={<PlusIcon size={16} />}
|
||||||
>
|
>
|
||||||
<Space>
|
Add Series
|
||||||
<AudioWaveform size={16} />
|
|
||||||
New Chart Series
|
|
||||||
</Space>
|
|
||||||
</Button>
|
</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 PathAnalysisFilter = observer(({ metric, writeOption }: any) => {
|
||||||
const metricValueOptions = [
|
const metricValueOptions = [
|
||||||
{ value: 'location', label: 'Pages' },
|
{ value: 'location', label: 'Pages' },
|
||||||
{ value: 'click', label: 'Clicks' },
|
{ value: 'click', label: 'Clicks' },
|
||||||
{ value: 'input', label: 'Input' },
|
{ value: 'input', label: 'Input' },
|
||||||
{ value: 'custom', label: 'Custom' }
|
{ value: 'custom', label: 'Custom' },
|
||||||
];
|
];
|
||||||
return (
|
return (
|
||||||
<Card styles={{ body: { padding: '20px 20px' } }}>
|
<Card styles={{ body: { padding: '20px 20px' } }} className="rounded-lg">
|
||||||
<Form.Item>
|
<Form.Item>
|
||||||
<Space>
|
<div className="flex flex-wrap gap-2 items-center justify-start">
|
||||||
|
<span className="font-medium">User journeys with: </span>
|
||||||
|
|
||||||
|
<div className="flex sm:flex-wrap lg:flex-nowrap gap-2 items-start">
|
||||||
<Select
|
<Select
|
||||||
|
className="w-36 rounded-xl"
|
||||||
name="startType"
|
name="startType"
|
||||||
options={[
|
options={[
|
||||||
{ value: 'start', label: 'With Start Point' },
|
{ value: 'start', label: 'Start Point' },
|
||||||
{ value: 'end', label: 'With End Point' }
|
{ value: 'end', label: 'End Point' },
|
||||||
]}
|
]}
|
||||||
defaultValue={metric.startType}
|
defaultValue={metric.startType || 'start'}
|
||||||
onChange={writeOption}
|
onChange={(value) => writeOption({ name: 'startType', value })}
|
||||||
placeholder="All Issues"
|
placeholder="Select Start Type"
|
||||||
|
size="small"
|
||||||
/>
|
/>
|
||||||
<span className="mx-3">showing</span>
|
|
||||||
|
<span className="text-neutral-400 mt-.5">showing</span>
|
||||||
|
|
||||||
<Select
|
<Select
|
||||||
|
mode="multiple"
|
||||||
|
className="min-w-36 rounded-xl"
|
||||||
|
allowClear
|
||||||
name="metricValue"
|
name="metricValue"
|
||||||
options={metricValueOptions}
|
options={metricValueOptions}
|
||||||
value={metric.metricValue}
|
value={metric.metricValue || []}
|
||||||
isMulti={true}
|
onChange={(value) => writeOption({ name: 'metricValue', value })}
|
||||||
onChange={writeOption}
|
placeholder="Select Metrics"
|
||||||
placeholder="All Issues"
|
size="small"
|
||||||
/>
|
/>
|
||||||
</Space>
|
</div>
|
||||||
|
</div>
|
||||||
</Form.Item>
|
</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
|
<FilterItem
|
||||||
hideDelete
|
hideDelete
|
||||||
filter={metric.startPoint}
|
filter={metric.startPoint}
|
||||||
allowedFilterKeys={[FilterKey.LOCATION, FilterKey.CLICK, FilterKey.INPUT, FilterKey.CUSTOM]}
|
allowedFilterKeys={[
|
||||||
onUpdate={val => metric.updateStartPoint(val)}
|
FilterKey.LOCATION,
|
||||||
onRemoveFilter={() => {
|
FilterKey.CLICK,
|
||||||
}}
|
FilterKey.INPUT,
|
||||||
|
FilterKey.CUSTOM,
|
||||||
|
]}
|
||||||
|
onUpdate={(val) => metric.updateStartPoint(val)}
|
||||||
|
onRemoveFilter={() => {}}
|
||||||
/>
|
/>
|
||||||
|
</span>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
@ -184,6 +278,7 @@ const InsightsFilter = observer(({ metric, writeOption }: any) => {
|
||||||
onChange={writeOption}
|
onChange={writeOption}
|
||||||
isMulti
|
isMulti
|
||||||
placeholder="All Categories"
|
placeholder="All Categories"
|
||||||
|
allowClear
|
||||||
/>
|
/>
|
||||||
</Space>
|
</Space>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
@ -192,7 +287,7 @@ const InsightsFilter = observer(({ metric, writeOption }: any) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
const AdditionalFilters = observer(() => {
|
const AdditionalFilters = observer(() => {
|
||||||
const { metricStore, dashboardStore, aiFiltersStore } = useStore();
|
const { metricStore } = useStore();
|
||||||
const metric: any = metricStore.instance;
|
const metric: any = metricStore.instance;
|
||||||
|
|
||||||
const writeOption = ({ value, name }: { value: any; name: any }) => {
|
const writeOption = ({ value, name }: { value: any; name: any }) => {
|
||||||
|
|
@ -203,14 +298,22 @@ const AdditionalFilters = observer(() => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{metric.metricType === USER_PATH && <PathAnalysisFilter metric={metric} writeOption={writeOption} />}
|
{metric.metricType === USER_PATH && (
|
||||||
{metric.metricType === INSIGHTS && <InsightsFilter metric={metric} writeOption={writeOption} />}
|
<PathAnalysisFilter metric={metric} writeOption={writeOption} />
|
||||||
|
)}
|
||||||
|
{metric.metricType === INSIGHTS && (
|
||||||
|
<InsightsFilter metric={metric} writeOption={writeOption} />
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
const PredefinedMessage = () => (
|
const PredefinedMessage = () => (
|
||||||
<Alert message="Drilldown or filtering isn't supported on this legacy card." type="warning" showIcon closable
|
<Alert
|
||||||
className="border-transparent rounded-lg" />
|
message="Drilldown or filtering isn't supported on this legacy card."
|
||||||
|
type="warning"
|
||||||
|
showIcon
|
||||||
|
closable
|
||||||
|
className="border-transparent rounded-lg"
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,8 @@ interface Props {
|
||||||
}
|
}
|
||||||
function MetricTypeDropdown(props: Props) {
|
function MetricTypeDropdown(props: Props) {
|
||||||
const { metricStore, userStore } = useStore();
|
const { metricStore, userStore } = useStore();
|
||||||
const isEnterprise = userStore.isEnterprise;
|
|
||||||
const metric: any = metricStore.instance;
|
const metric: any = metricStore.instance;
|
||||||
|
const isEnterprise = userStore.isEnterprise;
|
||||||
|
|
||||||
const options = React.useMemo(() => {
|
const options = React.useMemo(() => {
|
||||||
return DROPDOWN_OPTIONS.map((option: any) => {
|
return DROPDOWN_OPTIONS.map((option: any) => {
|
||||||
|
|
@ -26,18 +26,6 @@ function MetricTypeDropdown(props: Props) {
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
const queryCardType = props.query.get('type');
|
|
||||||
if (queryCardType && options.length > 0 && metric.metricType) {
|
|
||||||
const type: Option = options.find((i) => i.value === queryCardType) as Option;
|
|
||||||
if (type.disabled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setTimeout(() => onChange(type.value), 0);
|
|
||||||
}
|
|
||||||
// setTimeout(() => onChange(USER_PATH), 0);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const onChange = (type: string) => {
|
const onChange = (type: string) => {
|
||||||
metricStore.changeType(type);
|
metricStore.changeType(type);
|
||||||
};
|
};
|
||||||
|
|
@ -51,7 +39,7 @@ function MetricTypeDropdown(props: Props) {
|
||||||
value={
|
value={
|
||||||
DROPDOWN_OPTIONS.find((i: any) => i.value === metric.metricType) || DROPDOWN_OPTIONS[0]
|
DROPDOWN_OPTIONS.find((i: any) => i.value === metric.metricType) || DROPDOWN_OPTIONS[0]
|
||||||
}
|
}
|
||||||
onChange={props.onSelect}
|
onChange={({ value }) => onChange(value.value)}
|
||||||
components={{
|
components={{
|
||||||
SingleValue: ({ children, ...props }: any) => {
|
SingleValue: ({ children, ...props }: any) => {
|
||||||
const {
|
const {
|
||||||
|
|
|
||||||
|
|
@ -1,55 +1,48 @@
|
||||||
import React, { useState, useRef, useEffect } from 'react';
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
import { Icon, Tooltip } from 'UI';
|
import { Input, Tooltip } from 'antd';
|
||||||
import { Input } from 'antd';
|
|
||||||
import cn from 'classnames';
|
import cn from 'classnames';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
name: string;
|
name: string;
|
||||||
onUpdate: (name: any) => void;
|
onUpdate: (name: string) => void;
|
||||||
seriesIndex?: number;
|
seriesIndex?: number;
|
||||||
canEdit?: boolean
|
canEdit?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function WidgetName(props: Props) {
|
function WidgetName(props: Props) {
|
||||||
const { canEdit = true } = props;
|
const { canEdit = true } = props;
|
||||||
const [editing, setEditing] = useState(false)
|
const [editing, setEditing] = useState(false);
|
||||||
const [name, setName] = useState(props.name)
|
const [name, setName] = useState(props.name);
|
||||||
const ref = useRef<any>(null)
|
const ref = useRef<any>(null);
|
||||||
|
|
||||||
const write = ({ target: { value } }) => {
|
const write = ({ target: { value } }) => {
|
||||||
setName(value)
|
setName(value);
|
||||||
}
|
};
|
||||||
|
|
||||||
const onBlur = (nameInput?: string) => {
|
const onBlur = (nameInput?: string) => {
|
||||||
setEditing(false)
|
setEditing(false);
|
||||||
const toUpdate = nameInput || name
|
const toUpdate = nameInput || name;
|
||||||
props.onUpdate(toUpdate && toUpdate.trim() === '' ? 'New Widget' : toUpdate)
|
props.onUpdate(toUpdate && toUpdate.trim() === '' ? 'New Widget' : toUpdate);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
onBlur(name);
|
||||||
}
|
}
|
||||||
|
if (e.key === 'Escape' || e.key === 'Esc') {
|
||||||
|
setEditing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (editing) {
|
if (editing) {
|
||||||
ref.current.focus()
|
ref.current.focus();
|
||||||
}
|
}
|
||||||
}, [editing])
|
}, [editing]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setName(props.name)
|
setName(props.name);
|
||||||
}, [props.name])
|
}, [props.name]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handler = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
onBlur(name)
|
|
||||||
}
|
|
||||||
if (e.key === 'Escape' || e.key === 'Esc') {
|
|
||||||
setEditing(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
document.addEventListener('keydown', handler, false)
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener('keydown', handler, false)
|
|
||||||
}
|
|
||||||
}, [name])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
|
|
@ -60,31 +53,25 @@ function WidgetName(props: Props) {
|
||||||
value={name}
|
value={name}
|
||||||
onChange={write}
|
onChange={write}
|
||||||
onBlur={() => onBlur()}
|
onBlur={() => onBlur()}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
onFocus={() => setEditing(true)}
|
onFocus={() => setEditing(true)}
|
||||||
maxLength={80}
|
maxLength={80}
|
||||||
|
className="bg-white text-2xl ps-2 rounded-lg h-8"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
<Tooltip delay={200} title="Double click to edit" disabled={!canEdit}>
|
<Tooltip delay={200} title="Click to edit" disabled={!canEdit}>
|
||||||
<div
|
<div
|
||||||
onDoubleClick={() => setEditing(true)}
|
onClick={() => setEditing(true)}
|
||||||
className={
|
className={cn(
|
||||||
cn(
|
"text-2xl h-8 flex items-center p-2 rounded-lg",
|
||||||
"text-2xl h-8 flex items-center border-transparent",
|
canEdit && 'cursor-pointer select-none ps-2 hover:bg-teal/10'
|
||||||
canEdit && 'cursor-pointer select-none border-b border-b-borderColor-transparent hover:border-dotted hover:border-gray-medium'
|
)}
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{name}
|
{name}
|
||||||
</div>
|
</div>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
)}
|
)}
|
||||||
{ canEdit && <div className="ml-3 cursor-pointer" onClick={() => setEditing(true)}>
|
|
||||||
<Tooltip title='Rename' placement='bottom'>
|
|
||||||
<Icon name="pencil" size="16" />
|
|
||||||
</Tooltip>
|
|
||||||
</div> }
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,60 +1,214 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { FUNNEL, HEATMAP, TABLE, USER_PATH } from 'App/constants/card';
|
import {
|
||||||
import { Select, Space, Switch } from 'antd';
|
FUNNEL,
|
||||||
|
HEATMAP,
|
||||||
|
TABLE,
|
||||||
|
TIMESERIES,
|
||||||
|
USER_PATH,
|
||||||
|
} from 'App/constants/card';
|
||||||
|
import { Select, Space, Switch, Dropdown, Button } from 'antd';
|
||||||
|
import { DownOutlined } from '@ant-design/icons';
|
||||||
import { useStore } from 'App/mstore';
|
import { useStore } from 'App/mstore';
|
||||||
import ClickMapRagePicker from 'Components/Dashboard/components/ClickMapRagePicker/ClickMapRagePicker';
|
import ClickMapRagePicker from 'Components/Dashboard/components/ClickMapRagePicker/ClickMapRagePicker';
|
||||||
import { FilterKey } from 'Types/filter/filterType';
|
import { FilterKey } from 'Types/filter/filterType';
|
||||||
import { observer } from 'mobx-react-lite';
|
import { observer } from 'mobx-react-lite';
|
||||||
|
import {
|
||||||
|
ChartLine,
|
||||||
|
ChartArea,
|
||||||
|
ChartColumn,
|
||||||
|
ChartBar,
|
||||||
|
ChartPie,
|
||||||
|
Table,
|
||||||
|
Hash,
|
||||||
|
Users,
|
||||||
|
Library,
|
||||||
|
ChartColumnBig,
|
||||||
|
ChartBarBig,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
interface Props {
|
function WidgetOptions() {
|
||||||
|
const { metricStore } = useStore();
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
function WidgetOptions(props: Props) {
|
|
||||||
const { metricStore, dashboardStore } = useStore();
|
|
||||||
const metric: any = metricStore.instance;
|
const metric: any = metricStore.instance;
|
||||||
|
|
||||||
const handleChange = (value: any) => {
|
const handleChange = (value: any) => {
|
||||||
metric.update({ metricFormat: value });
|
metric.update({ metricFormat: value });
|
||||||
|
metric.updateKey('hasChanged', true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// const hasSeriesTypes = [TIMESERIES, FUNNEL, TABLE].includes(metric.metricType);
|
||||||
|
const hasViewTypes = [TIMESERIES, FUNNEL].includes(metric.metricType);
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className={'flex items-center gap-2'}>
|
||||||
{metric.metricType === USER_PATH && (
|
{metric.metricType === USER_PATH && (
|
||||||
<a
|
<a
|
||||||
href="#"
|
href="#"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
metric.update({ hideExcess: !metric.hideExcess });
|
metric.update({ hideExcess: !metric.hideExcess });
|
||||||
|
metric.updateKey('hasChanged', true);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Space>
|
<Space>
|
||||||
<Switch checked={metric.hideExcess} size="small" />
|
<Switch checked={metric.hideExcess} size="small" />
|
||||||
<span className="mr-4 color-gray-medium">
|
<span className="mr-4 color-gray-medium">Hide Minor Paths</span>
|
||||||
Hide Minor Paths
|
|
||||||
</span>
|
|
||||||
</Space>
|
</Space>
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(metric.metricType === FUNNEL || metric.metricType === TABLE) && metric.metricOf != FilterKey.USERID && metric.metricOf != FilterKey.ERRORS && (
|
{metric.metricType === TIMESERIES && <SeriesTypeOptions metric={metric} />}
|
||||||
<Select
|
{(metric.metricType === FUNNEL || metric.metricType === TABLE) &&
|
||||||
defaultValue={metric.metricFormat}
|
metric.metricOf !== FilterKey.USERID &&
|
||||||
onChange={handleChange}
|
metric.metricOf !== FilterKey.ERRORS && (
|
||||||
variant="borderless"
|
<Dropdown
|
||||||
options={[
|
trigger={['click']}
|
||||||
{ value: 'sessionCount', label: 'Sessions' },
|
menu={{
|
||||||
{ value: 'userCount', label: 'Users' }
|
selectable: true,
|
||||||
]}
|
items: [
|
||||||
/>
|
{ key: 'sessionCount', label: 'All Sessions' },
|
||||||
)}
|
{ key: 'userCount', label: 'Unique Users' },
|
||||||
|
],
|
||||||
|
onClick: (info: { key: string }) => handleChange(info.key),
|
||||||
|
}}
|
||||||
|
|
||||||
{metric.metricType === HEATMAP ? (
|
>
|
||||||
<ClickMapRagePicker />
|
<Button type="text" variant="text" size="small">
|
||||||
) : null}
|
{metric.metricFormat === 'sessionCount'
|
||||||
|
? 'All Sessions'
|
||||||
|
: 'Unique Users'}
|
||||||
|
<DownOutlined className="text-sm" />
|
||||||
|
</Button>
|
||||||
|
</Dropdown>
|
||||||
|
)}
|
||||||
|
{hasViewTypes && <WidgetViewTypeOptions metric={metric} />}
|
||||||
|
{metric.metricType === HEATMAP && <ClickMapRagePicker />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SeriesTypeOptions = observer(({ metric }: { metric: any }) => {
|
||||||
|
const items = {
|
||||||
|
sessionCount: 'Total Sessions',
|
||||||
|
userCount: 'Unique Users',
|
||||||
|
};
|
||||||
|
const chartIcons = {
|
||||||
|
sessionCount: <Library size={16} strokeWidth={1} />,
|
||||||
|
userCount: <Users size={16} strokeWidth={1} />,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dropdown
|
||||||
|
trigger={['click']}
|
||||||
|
menu={{
|
||||||
|
selectable: true,
|
||||||
|
items: Object.entries(items).map(([key, name]) => ({
|
||||||
|
key,
|
||||||
|
label: (
|
||||||
|
<div className={'flex items-center gap-2'}>
|
||||||
|
<>
|
||||||
|
{chartIcons[key]}
|
||||||
|
<div>{name}</div>
|
||||||
|
</>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
onClick: ({ key }: any) => {
|
||||||
|
metric.updateKey('metricOf', key);
|
||||||
|
metric.updateKey('hasChanged', true);
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
variant="text"
|
||||||
|
size="small"
|
||||||
|
className="btn-aggregator"
|
||||||
|
>
|
||||||
|
<Space>
|
||||||
|
{chartIcons[metric.metricOf]}
|
||||||
|
<div>{items[metric.metricOf] || 'Total Sessions'}</div>
|
||||||
|
<DownOutlined className="text-sm" />
|
||||||
|
</Space>
|
||||||
|
</Button>
|
||||||
|
</Dropdown>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const WidgetViewTypeOptions = observer(({ metric }: { metric: any }) => {
|
||||||
|
const chartTypes = {
|
||||||
|
lineChart: 'Line',
|
||||||
|
areaChart: 'Stacked Area',
|
||||||
|
barChart: 'Column',
|
||||||
|
progressChart: 'Bar',
|
||||||
|
columnChart: 'Horizontal Bar',
|
||||||
|
pieChart: 'Pie',
|
||||||
|
metric: 'Metric',
|
||||||
|
table: 'Table',
|
||||||
|
};
|
||||||
|
const funnelChartTypes = {
|
||||||
|
chart: 'Funnel Bar',
|
||||||
|
columnChart: 'Funnel Column',
|
||||||
|
metric: 'Metric',
|
||||||
|
table: 'Table',
|
||||||
|
}
|
||||||
|
const usedChartTypes = metric.metricType === FUNNEL ? funnelChartTypes : chartTypes;
|
||||||
|
const chartIcons = {
|
||||||
|
lineChart: <ChartLine size={16} strokeWidth={1} /> ,
|
||||||
|
barChart: <ChartColumn size={16} strokeWidth={1} />,
|
||||||
|
areaChart: <ChartArea size={16} strokeWidth={1} />,
|
||||||
|
pieChart: <ChartPie size={16} strokeWidth={1} />,
|
||||||
|
progressChart: <ChartBar size={16} strokeWidth={1} />,
|
||||||
|
metric: <Hash size={16} strokeWidth={1} />,
|
||||||
|
table: <Table size={16} strokeWidth={1} />,
|
||||||
|
// funnel specific
|
||||||
|
columnChart: <ChartColumnBig size={16} strokeWidth={1} />,
|
||||||
|
chart: <ChartBarBig size={16} strokeWidth={1} />,
|
||||||
|
};
|
||||||
|
const allowedTypes = {
|
||||||
|
[TIMESERIES]: [
|
||||||
|
'lineChart',
|
||||||
|
'areaChart',
|
||||||
|
'barChart',
|
||||||
|
'progressChart',
|
||||||
|
'pieChart',
|
||||||
|
'metric',
|
||||||
|
'table',
|
||||||
|
],
|
||||||
|
[FUNNEL]: ['chart', 'columnChart', 'metric', 'table'],
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Dropdown
|
||||||
|
trigger={['click']}
|
||||||
|
menu={{
|
||||||
|
selectable: true,
|
||||||
|
items: allowedTypes[metric.metricType].map((key) => ({
|
||||||
|
key,
|
||||||
|
label: (
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
|
{chartIcons[key]}
|
||||||
|
<div>{usedChartTypes[key]}</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
onClick: ({ key }: any) => {
|
||||||
|
metric.updateKey('viewType', key);
|
||||||
|
metric.updateKey('hasChanged', true);
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
variant="text"
|
||||||
|
size="small"
|
||||||
|
className="btn-visualization-type"
|
||||||
|
>
|
||||||
|
<Space>
|
||||||
|
{chartIcons[metric.viewType]}
|
||||||
|
<div>{usedChartTypes[metric.viewType]}</div>
|
||||||
|
<DownOutlined className="text-sm " />
|
||||||
|
</Space>
|
||||||
|
</Button>
|
||||||
|
</Dropdown>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
export default observer(WidgetOptions);
|
export default observer(WidgetOptions);
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,9 @@
|
||||||
import { Button, Space, Switch } from 'antd';
|
|
||||||
import cn from 'classnames';
|
import cn from 'classnames';
|
||||||
import { observer } from 'mobx-react-lite';
|
import { observer } from 'mobx-react-lite';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import WidgetDateRange from "Components/Dashboard/components/WidgetDateRange/WidgetDateRange";
|
||||||
import { HEATMAP, USER_PATH } from 'App/constants/card';
|
|
||||||
import { useStore } from 'App/mstore';
|
import { useStore } from 'App/mstore';
|
||||||
import ClickMapRagePicker from 'Components/Dashboard/components/ClickMapRagePicker';
|
import { FUNNEL, TIMESERIES } from "App/constants/card";
|
||||||
|
|
||||||
import WidgetWrapper from '../WidgetWrapper';
|
import WidgetWrapper from '../WidgetWrapper';
|
||||||
import WidgetOptions from 'Components/Dashboard/components/WidgetOptions';
|
import WidgetOptions from 'Components/Dashboard/components/WidgetOptions';
|
||||||
|
|
@ -18,101 +16,32 @@ interface Props {
|
||||||
|
|
||||||
function WidgetPreview(props: Props) {
|
function WidgetPreview(props: Props) {
|
||||||
const { className = '' } = props;
|
const { className = '' } = props;
|
||||||
const { metricStore, dashboardStore } = useStore();
|
const { metricStore } = useStore();
|
||||||
const metric: any = metricStore.instance;
|
const metric: any = metricStore.instance;
|
||||||
|
|
||||||
|
const hasGranularSettings = [TIMESERIES, FUNNEL].includes(metric.metricType)
|
||||||
|
const hasGranularity = ['lineChart', 'barChart', 'areaChart'].includes(metric.viewType);
|
||||||
|
const hasComparison = metric.metricType === FUNNEL || ['lineChart', 'barChart', 'table', 'progressChart', 'metric'].includes(metric.viewType);
|
||||||
|
// [rangeStart, rangeEnd] or [period_name] -- have to check options
|
||||||
|
const presetComparison = metric.compareTo;
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
className={cn(className, 'bg-white rounded-xl border shadow-sm mt-0')}
|
className={cn(className, 'bg-white rounded-xl border shadow-sm mt-0')}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between px-4 pt-2">
|
<div className="flex items-center gap-2 px-4 py-2 border-b">
|
||||||
<h2 className="text-xl">{props.name}</h2>
|
<WidgetDateRange
|
||||||
<div className="flex items-center">
|
label=""
|
||||||
|
hasGranularSettings={hasGranularSettings}
|
||||||
|
hasGranularity={hasGranularity}
|
||||||
|
hasComparison={hasComparison}
|
||||||
|
presetComparison={presetComparison}
|
||||||
|
/>
|
||||||
|
<div className="ml-auto">
|
||||||
<WidgetOptions />
|
<WidgetOptions />
|
||||||
{/*{metric.metricType === USER_PATH && (*/}
|
|
||||||
{/* <a*/}
|
|
||||||
{/* href="#"*/}
|
|
||||||
{/* onClick={(e) => {*/}
|
|
||||||
{/* e.preventDefault();*/}
|
|
||||||
{/* metric.update({ hideExcess: !metric.hideExcess });*/}
|
|
||||||
{/* }}*/}
|
|
||||||
{/* >*/}
|
|
||||||
{/* <Space>*/}
|
|
||||||
{/* <Switch checked={metric.hideExcess} size="small" />*/}
|
|
||||||
{/* <span className="mr-4 color-gray-medium">*/}
|
|
||||||
{/* Hide Minor Paths*/}
|
|
||||||
{/* </span>*/}
|
|
||||||
{/* </Space>*/}
|
|
||||||
{/* </a>*/}
|
|
||||||
{/*)}*/}
|
|
||||||
|
|
||||||
|
|
||||||
{/*{isTimeSeries && (*/}
|
|
||||||
{/* <>*/}
|
|
||||||
{/* <span className="mr-4 color-gray-medium">Visualization</span>*/}
|
|
||||||
{/* <SegmentSelection*/}
|
|
||||||
{/* name="viewType"*/}
|
|
||||||
{/* className="my-3"*/}
|
|
||||||
{/* primary*/}
|
|
||||||
{/* size="small"*/}
|
|
||||||
{/* onSelect={ changeViewType }*/}
|
|
||||||
{/* value={{ value: metric.viewType }}*/}
|
|
||||||
{/* list={ [*/}
|
|
||||||
{/* { value: 'lineChart', name: 'Chart', icon: 'graph-up-arrow' },*/}
|
|
||||||
{/* { value: 'progress', name: 'Progress', icon: 'hash' },*/}
|
|
||||||
{/* ]}*/}
|
|
||||||
{/* />*/}
|
|
||||||
{/* </>*/}
|
|
||||||
{/*)}*/}
|
|
||||||
|
|
||||||
{/*{!disableVisualization && isTable && (*/}
|
|
||||||
{/* <>*/}
|
|
||||||
{/* <span className="mr-4 color-gray-medium">Visualization</span>*/}
|
|
||||||
{/* <SegmentSelection*/}
|
|
||||||
{/* name="viewType"*/}
|
|
||||||
{/* className="my-3"*/}
|
|
||||||
{/* primary={true}*/}
|
|
||||||
{/* size="small"*/}
|
|
||||||
{/* onSelect={ changeViewType }*/}
|
|
||||||
{/* value={{ value: metric.viewType }}*/}
|
|
||||||
{/* list={[*/}
|
|
||||||
{/* { value: 'table', name: 'Table', icon: 'table' },*/}
|
|
||||||
{/* { value: 'pieChart', name: 'Chart', icon: 'pie-chart-fill' },*/}
|
|
||||||
{/* ]}*/}
|
|
||||||
{/* disabledMessage="Chart view is not supported"*/}
|
|
||||||
{/* />*/}
|
|
||||||
{/* </>*/}
|
|
||||||
{/*)}*/}
|
|
||||||
|
|
||||||
{/*{isRetention && (*/}
|
|
||||||
{/* <>*/}
|
|
||||||
{/* <span className="mr-4 color-gray-medium">Visualization</span>*/}
|
|
||||||
{/* <SegmentSelection*/}
|
|
||||||
{/* name="viewType"*/}
|
|
||||||
{/* className="my-3"*/}
|
|
||||||
{/* primary={true}*/}
|
|
||||||
{/* size="small"*/}
|
|
||||||
{/* onSelect={ changeViewType }*/}
|
|
||||||
{/* value={{ value: metric.viewType }}*/}
|
|
||||||
{/* list={[*/}
|
|
||||||
{/* { value: 'trend', name: 'Trend', icon: 'graph-up-arrow' },*/}
|
|
||||||
{/* { value: 'cohort', name: 'Cohort', icon: 'dice-3' },*/}
|
|
||||||
{/* ]}*/}
|
|
||||||
{/* disabledMessage="Chart view is not supported"*/}
|
|
||||||
{/* />*/}
|
|
||||||
{/*</>*/}
|
|
||||||
{/*)}*/}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{/* add to dashboard */}
|
|
||||||
{/*{metric.exists() && (*/}
|
|
||||||
{/* <AddToDashboardButton metricId={metric.metricId}/>*/}
|
|
||||||
{/*)}*/}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="pt-0">
|
<div className="py-4">
|
||||||
<WidgetWrapper
|
<WidgetWrapper
|
||||||
widget={metric}
|
widget={metric}
|
||||||
isPreview={true}
|
isPreview={true}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { NoContent, Loader, Pagination, Button } from 'UI';
|
import { NoContent, Loader, Pagination } from 'UI';
|
||||||
import Select from 'Shared/Select';
|
import {Button, Tag, Tooltip, Dropdown, notification} from 'antd';
|
||||||
|
import {UndoOutlined, DownOutlined} from '@ant-design/icons'
|
||||||
import cn from 'classnames';
|
import cn from 'classnames';
|
||||||
import { useStore } from 'App/mstore';
|
import { useStore } from 'App/mstore';
|
||||||
import SessionItem from 'Shared/SessionItem';
|
import SessionItem from 'Shared/SessionItem';
|
||||||
|
|
@ -11,20 +12,22 @@ import useIsMounted from 'App/hooks/useIsMounted';
|
||||||
import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG';
|
import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG';
|
||||||
import { numberWithCommas } from 'App/utils';
|
import { numberWithCommas } from 'App/utils';
|
||||||
import { HEATMAP } from 'App/constants/card';
|
import { HEATMAP } from 'App/constants/card';
|
||||||
import { Tag } from 'antd';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function WidgetSessions(props: Props) {
|
function WidgetSessions(props: Props) {
|
||||||
|
const listRef = React.useRef<HTMLDivElement>(null);
|
||||||
const { className = '' } = props;
|
const { className = '' } = props;
|
||||||
const [activeSeries, setActiveSeries] = useState('all');
|
const [activeSeries, setActiveSeries] = useState('all');
|
||||||
const [data, setData] = useState<any>([]);
|
const [data, setData] = useState<any>([]);
|
||||||
const isMounted = useIsMounted();
|
const isMounted = useIsMounted();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const filteredSessions = getListSessionsBySeries(data, activeSeries);
|
// all filtering done through series now
|
||||||
|
const filteredSessions = getListSessionsBySeries(data, 'all');
|
||||||
const { dashboardStore, metricStore, sessionStore, customFieldStore } = useStore();
|
const { dashboardStore, metricStore, sessionStore, customFieldStore } = useStore();
|
||||||
|
const focusedSeries = metricStore.focusedSeriesName;
|
||||||
const filter = dashboardStore.drillDownFilter;
|
const filter = dashboardStore.drillDownFilter;
|
||||||
const widget = metricStore.instance;
|
const widget = metricStore.instance;
|
||||||
const startTime = DateTime.fromMillis(filter.startTimestamp).toFormat('LLL dd, yyyy HH:mm');
|
const startTime = DateTime.fromMillis(filter.startTimestamp).toFormat('LLL dd, yyyy HH:mm');
|
||||||
|
|
@ -34,15 +37,23 @@ function WidgetSessions(props: Props) {
|
||||||
const filterText = filter.filters.length > 0 ? filter.filters[0].value : '';
|
const filterText = filter.filters.length > 0 ? filter.filters[0].value : '';
|
||||||
const metaList = customFieldStore.list.map((i: any) => i.key);
|
const metaList = customFieldStore.list.map((i: any) => i.key);
|
||||||
|
|
||||||
const writeOption = ({ value }: any) => setActiveSeries(value.value);
|
const seriesDropdownItems = seriesOptions.map((option) => ({
|
||||||
|
key: option.value,
|
||||||
|
label: (
|
||||||
|
<div onClick={() => setActiveSeries(option.value)}>
|
||||||
|
{option.label}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!data) return;
|
if (!widget.series) return;
|
||||||
const seriesOptions = data.map((item: any) => ({
|
const seriesOptions = widget.series.map((item: any) => ({
|
||||||
label: item.seriesName,
|
label: item.name,
|
||||||
value: item.seriesId
|
value: item.seriesId
|
||||||
}));
|
}));
|
||||||
setSeriesOptions([{ label: 'All', value: 'all' }, ...seriesOptions]);
|
setSeriesOptions([{ label: 'All', value: 'all' }, ...seriesOptions]);
|
||||||
}, [data]);
|
}, [widget.series]);
|
||||||
|
|
||||||
const fetchSessions = (metricId: any, filter: any) => {
|
const fetchSessions = (metricId: any, filter: any) => {
|
||||||
if (!isMounted()) return;
|
if (!isMounted()) return;
|
||||||
|
|
@ -52,6 +63,17 @@ function WidgetSessions(props: Props) {
|
||||||
.fetchSessions(metricId, filter)
|
.fetchSessions(metricId, filter)
|
||||||
.then((res: any) => {
|
.then((res: any) => {
|
||||||
setData(res);
|
setData(res);
|
||||||
|
if (metricStore.drillDown) {
|
||||||
|
setTimeout(() => {
|
||||||
|
notification.open({
|
||||||
|
placement: 'top',
|
||||||
|
role: 'status',
|
||||||
|
message: 'Sessions Refreshed!'
|
||||||
|
})
|
||||||
|
listRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
metricStore.setDrillDown(false);
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|
@ -89,9 +111,10 @@ function WidgetSessions(props: Props) {
|
||||||
};
|
};
|
||||||
debounceClickMapSearch(customFilter);
|
debounceClickMapSearch(customFilter);
|
||||||
} else {
|
} else {
|
||||||
|
const usedSeries = focusedSeries ? widget.series.filter((s) => s.name === focusedSeries) : widget.series;
|
||||||
debounceRequest(widget.metricId, {
|
debounceRequest(widget.metricId, {
|
||||||
...filter,
|
...filter,
|
||||||
series: widget.series.map((s) => s.toJson()),
|
series: usedSeries.map((s) => s.toJson()),
|
||||||
page: metricStore.sessionsPage,
|
page: metricStore.sessionsPage,
|
||||||
limit: metricStore.sessionsPageSize
|
limit: metricStore.sessionsPageSize
|
||||||
});
|
});
|
||||||
|
|
@ -106,9 +129,23 @@ function WidgetSessions(props: Props) {
|
||||||
filter.filters,
|
filter.filters,
|
||||||
depsString,
|
depsString,
|
||||||
metricStore.clickMapSearch,
|
metricStore.clickMapSearch,
|
||||||
activeSeries
|
focusedSeries
|
||||||
]);
|
]);
|
||||||
useEffect(loadData, [metricStore.sessionsPage]);
|
useEffect(loadData, [metricStore.sessionsPage]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeSeries === 'all') {
|
||||||
|
metricStore.setFocusedSeriesName(null);
|
||||||
|
} else {
|
||||||
|
metricStore.setFocusedSeriesName(seriesOptions.find((option) => option.value === activeSeries)?.label, false);
|
||||||
|
}
|
||||||
|
}, [activeSeries])
|
||||||
|
useEffect(() => {
|
||||||
|
if (focusedSeries) {
|
||||||
|
setActiveSeries(seriesOptions.find((option) => option.label === focusedSeries)?.value || 'all');
|
||||||
|
} else {
|
||||||
|
setActiveSeries('all');
|
||||||
|
}
|
||||||
|
}, [focusedSeries])
|
||||||
|
|
||||||
const clearFilters = () => {
|
const clearFilters = () => {
|
||||||
metricStore.updateKey('sessionsPage', 1);
|
metricStore.updateKey('sessionsPage', 1);
|
||||||
|
|
@ -116,28 +153,40 @@ function WidgetSessions(props: Props) {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn(className, 'bg-white p-3 pb-0 rounded-lg shadow-sm border mt-3')}>
|
<div className={cn(className, 'bg-white p-3 pb-0 rounded-xl shadow-sm border mt-3')}>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-baseline">
|
<div className="flex items-baseline gap-2">
|
||||||
<h2 className="text-xl">{metricStore.clickMapSearch ? 'Clicks' : 'Sessions'}</h2>
|
<h2 className="text-xl">{metricStore.clickMapSearch ? 'Clicks' : 'Sessions'}</h2>
|
||||||
<div className="ml-2 color-gray-medium">
|
<div className="ml-2 color-gray-medium">
|
||||||
{metricStore.clickMapLabel ? `on "${metricStore.clickMapLabel}" ` : null}
|
{metricStore.clickMapLabel ? `on "${metricStore.clickMapLabel}" ` : null}
|
||||||
between <span className="font-medium color-gray-darkest">{startTime}</span> and{' '}
|
between <span className="font-medium color-gray-darkest">{startTime}</span> and{' '}
|
||||||
<span className="font-medium color-gray-darkest">{endTime}</span>{' '}
|
<span className="font-medium color-gray-darkest">{endTime}</span>{' '}
|
||||||
</div>
|
</div>
|
||||||
|
{hasFilters && <Tooltip title='Clear Drilldown' placement='top'><Button type='text' size='small' onClick={clearFilters}><UndoOutlined /></Button></Tooltip>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{hasFilters && widget.metricType === 'table' &&
|
{hasFilters && widget.metricType === 'table' && <div className="py-2"><Tag closable onClose={clearFilters}>{filterText}</Tag></div>}
|
||||||
<div className="py-2"><Tag closable onClose={clearFilters}>{filterText}</Tag></div>}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
{hasFilters && <Button variant="text-primary" onClick={clearFilters}>Clear Filters</Button>}
|
|
||||||
{widget.metricType !== 'table' && widget.metricType !== HEATMAP && (
|
{widget.metricType !== 'table' && widget.metricType !== HEATMAP && (
|
||||||
<div className="flex items-center ml-6">
|
<div className="flex items-center ml-6">
|
||||||
<span className="mr-2 color-gray-medium">Filter by Series</span>
|
<span className="mr-2 color-gray-medium">Filter by Series</span>
|
||||||
<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>
|
||||||
|
|
@ -164,7 +213,7 @@ function WidgetSessions(props: Props) {
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<div className="flex items-center justify-between p-5">
|
<div className="flex items-center justify-between p-5" ref={listRef}>
|
||||||
<div>
|
<div>
|
||||||
Showing{' '}
|
Showing{' '}
|
||||||
<span className="font-medium">
|
<span className="font-medium">
|
||||||
|
|
|
||||||
|
|
@ -1,35 +1,40 @@
|
||||||
import {useHistory} from "react-router";
|
import { useHistory } from 'react-router';
|
||||||
import {useStore} from "App/mstore";
|
import { useStore } from 'App/mstore';
|
||||||
import {useObserver} from "mobx-react-lite";
|
import { observer } from 'mobx-react-lite';
|
||||||
import {Button, Dropdown, MenuProps, message, Modal} from "antd";
|
import { Button, Dropdown, MenuProps, Modal } from 'antd';
|
||||||
import {BellIcon, EllipsisVertical, TrashIcon} from "lucide-react";
|
import { BellIcon, EllipsisVertical, Grid2x2Plus, TrashIcon } from 'lucide-react';
|
||||||
import {toast} from "react-toastify";
|
import { toast } from 'react-toastify';
|
||||||
import React from "react";
|
import React from 'react';
|
||||||
import {useModal} from "Components/ModalContext";
|
import { useModal } from 'Components/ModalContext';
|
||||||
import AlertFormModal from "Components/Alerts/AlertFormModal/AlertFormModal";
|
import AlertFormModal from 'Components/Alerts/AlertFormModal/AlertFormModal';
|
||||||
|
import { showAddToDashboardModal } from 'Components/Dashboard/components/AddToDashboardButton';
|
||||||
|
|
||||||
const CardViewMenu = () => {
|
const CardViewMenu = () => {
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
const {alertsStore, dashboardStore, metricStore} = useStore();
|
const { alertsStore, metricStore, dashboardStore } = useStore();
|
||||||
const widget = useObserver(() => metricStore.instance);
|
const widget = metricStore.instance;
|
||||||
const { openModal, closeModal } = useModal();
|
const { openModal, closeModal } = useModal();
|
||||||
|
|
||||||
const showAlertModal = () => {
|
const showAlertModal = () => {
|
||||||
const seriesId = widget.series[0] && widget.series[0].seriesId || '';
|
const seriesId = (widget.series[0] && widget.series[0].seriesId) || '';
|
||||||
alertsStore.init({query: {left: seriesId}})
|
alertsStore.init({ query: { left: seriesId } });
|
||||||
openModal(<AlertFormModal
|
openModal(<AlertFormModal onClose={closeModal} />, {
|
||||||
onClose={closeModal}
|
|
||||||
/>, {
|
|
||||||
// title: 'Set Alerts',
|
|
||||||
placement: 'right',
|
placement: 'right',
|
||||||
width: 620,
|
width: 620,
|
||||||
});
|
});
|
||||||
}
|
};
|
||||||
|
|
||||||
const items: MenuProps['items'] = [
|
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',
|
key: 'alert',
|
||||||
label: "Set Alerts",
|
label: 'Set Alerts',
|
||||||
icon: <BellIcon size={16} />,
|
icon: <BellIcon size={16} />,
|
||||||
disabled: !widget.exists() || widget.metricType === 'predefined',
|
disabled: !widget.exists() || widget.metricType === 'predefined',
|
||||||
onClick: showAlertModal,
|
onClick: showAlertModal,
|
||||||
|
|
@ -37,12 +42,14 @@ const CardViewMenu = () => {
|
||||||
{
|
{
|
||||||
key: 'remove',
|
key: 'remove',
|
||||||
label: 'Delete',
|
label: 'Delete',
|
||||||
icon: <TrashIcon size={16}/>,
|
icon: <TrashIcon size={15} />,
|
||||||
|
disabled: !widget.exists(),
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
Modal.confirm({
|
Modal.confirm({
|
||||||
title: 'Confirm Card Deletion',
|
title: 'Confirm Card Deletion',
|
||||||
icon: null,
|
icon: null,
|
||||||
content:'Are you sure you want to remove this card? This action is permanent and cannot be undone.',
|
content:
|
||||||
|
'Are you sure you want to remove this card? This action is permanent and cannot be undone.',
|
||||||
footer: (_, { OkBtn, CancelBtn }) => (
|
footer: (_, { OkBtn, CancelBtn }) => (
|
||||||
<>
|
<>
|
||||||
<CancelBtn />
|
<CancelBtn />
|
||||||
|
|
@ -50,49 +57,27 @@ const CardViewMenu = () => {
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
onOk: () => {
|
onOk: () => {
|
||||||
metricStore.delete(widget).then(r => {
|
metricStore
|
||||||
|
.delete(widget)
|
||||||
|
.then(() => {
|
||||||
history.goBack();
|
history.goBack();
|
||||||
}).catch(() => {
|
})
|
||||||
|
.catch(() => {
|
||||||
toast.error('Failed to remove card');
|
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 (
|
return (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Dropdown menu={{ items }}>
|
<Dropdown menu={{ items }}>
|
||||||
<Button icon={<EllipsisVertical size={16}/>}/>
|
<Button type='text' icon={<EllipsisVertical size={16} />} className='btn-card-options' />
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default CardViewMenu;
|
export default observer(CardViewMenu);
|
||||||
|
|
@ -3,7 +3,7 @@ import { useStore } from 'App/mstore';
|
||||||
import { Loader, NoContent } from 'UI';
|
import { Loader, NoContent } from 'UI';
|
||||||
import WidgetPreview from '../WidgetPreview';
|
import WidgetPreview from '../WidgetPreview';
|
||||||
import WidgetSessions from '../WidgetSessions';
|
import WidgetSessions from '../WidgetSessions';
|
||||||
import { useObserver } from 'mobx-react-lite';
|
import { observer } from 'mobx-react-lite';
|
||||||
import { dashboardMetricDetails, metricDetails, withSiteId } from 'App/routes';
|
import { dashboardMetricDetails, metricDetails, withSiteId } from 'App/routes';
|
||||||
import Breadcrumb from 'Shared/Breadcrumb';
|
import Breadcrumb from 'Shared/Breadcrumb';
|
||||||
import { FilterKey } from 'Types/filter/filterType';
|
import { FilterKey } from 'Types/filter/filterType';
|
||||||
|
|
@ -16,14 +16,16 @@ import {
|
||||||
FUNNEL,
|
FUNNEL,
|
||||||
INSIGHTS,
|
INSIGHTS,
|
||||||
USER_PATH,
|
USER_PATH,
|
||||||
RETENTION
|
RETENTION,
|
||||||
} from 'App/constants/card';
|
} from 'App/constants/card';
|
||||||
import CardUserList from '../CardUserList/CardUserList';
|
import CardUserList from '../CardUserList/CardUserList';
|
||||||
import WidgetViewHeader from 'Components/Dashboard/components/WidgetView/WidgetViewHeader';
|
import WidgetViewHeader from 'Components/Dashboard/components/WidgetView/WidgetViewHeader';
|
||||||
import WidgetFormNew from 'Components/Dashboard/components/WidgetForm/WidgetFormNew';
|
import WidgetFormNew from 'Components/Dashboard/components/WidgetForm/WidgetFormNew';
|
||||||
import { Space } from 'antd';
|
import { Space, Segmented, Tooltip } from 'antd';
|
||||||
import { renderClickmapThumbnail } from 'Components/Dashboard/components/WidgetForm/renderMap';
|
import { renderClickmapThumbnail } from 'Components/Dashboard/components/WidgetForm/renderMap';
|
||||||
import Widget from 'App/mstore/types/widget';
|
import Widget from 'App/mstore/types/widget';
|
||||||
|
import { LayoutPanelTop, LayoutPanelLeft } from 'lucide-react';
|
||||||
|
import cn from 'classnames'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
history: any;
|
history: any;
|
||||||
|
|
@ -31,19 +33,27 @@ interface Props {
|
||||||
siteId: any;
|
siteId: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const LAYOUT_KEY = '$__layout__$'
|
||||||
|
|
||||||
|
function getDefaultState() {
|
||||||
|
const layout = localStorage.getItem(LAYOUT_KEY)
|
||||||
|
return layout || 'flex-row'
|
||||||
|
}
|
||||||
|
|
||||||
function WidgetView(props: Props) {
|
function WidgetView(props: Props) {
|
||||||
|
const [layout, setLayout] = useState(getDefaultState);
|
||||||
const {
|
const {
|
||||||
match: {
|
match: {
|
||||||
params: { siteId, dashboardId, metricId }
|
params: { siteId, dashboardId, metricId },
|
||||||
}
|
},
|
||||||
} = props;
|
} = props;
|
||||||
const { metricStore, dashboardStore } = useStore();
|
const { metricStore, dashboardStore, settingsStore } = useStore();
|
||||||
const widget = useObserver(() => metricStore.instance);
|
const widget = metricStore.instance;
|
||||||
const loading = useObserver(() => metricStore.isLoading);
|
const loading = metricStore.isLoading;
|
||||||
const [expanded, setExpanded] = useState(!metricId || metricId === 'create');
|
const [expanded, setExpanded] = useState(!metricId || metricId === 'create');
|
||||||
const hasChanged = useObserver(() => widget.hasChanged);
|
const hasChanged = widget.hasChanged;
|
||||||
const dashboards = useObserver(() => dashboardStore.dashboards);
|
const dashboards = dashboardStore.dashboards;
|
||||||
const dashboard = useObserver(() => dashboards.find((d: any) => d.dashboardId == dashboardId));
|
const dashboard = dashboards.find((d: any) => d.dashboardId == dashboardId);
|
||||||
const dashboardName = dashboard ? dashboard.name : null;
|
const dashboardName = dashboard ? dashboard.name : null;
|
||||||
const [metricNotFound, setMetricNotFound] = useState(false);
|
const [metricNotFound, setMetricNotFound] = useState(false);
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
|
|
@ -58,8 +68,17 @@ function WidgetView(props: Props) {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
if (!metricStore.instance) {
|
||||||
metricStore.init();
|
metricStore.init();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
const wasCollapsed = settingsStore.menuCollapsed;
|
||||||
|
settingsStore.updateMenuCollapsed(true)
|
||||||
|
return () => {
|
||||||
|
if (!wasCollapsed) {
|
||||||
|
settingsStore.updateMenuCollapsed(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const undoChanges = () => {
|
const undoChanges = () => {
|
||||||
|
|
@ -81,24 +100,37 @@ function WidgetView(props: Props) {
|
||||||
if (wasCreating) {
|
if (wasCreating) {
|
||||||
if (parseInt(dashboardId, 10) > 0) {
|
if (parseInt(dashboardId, 10) > 0) {
|
||||||
history.replace(
|
history.replace(
|
||||||
withSiteId(dashboardMetricDetails(dashboardId, savedMetric.metricId), siteId)
|
withSiteId(
|
||||||
|
dashboardMetricDetails(dashboardId, savedMetric.metricId),
|
||||||
|
siteId
|
||||||
|
)
|
||||||
);
|
);
|
||||||
void dashboardStore.addWidgetToDashboard(
|
void dashboardStore.addWidgetToDashboard(
|
||||||
dashboardStore.getDashboard(parseInt(dashboardId, 10))!,
|
dashboardStore.getDashboard(parseInt(dashboardId, 10))!,
|
||||||
[savedMetric.metricId]
|
[savedMetric.metricId]
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
history.replace(withSiteId(metricDetails(savedMetric.metricId), siteId));
|
history.replace(
|
||||||
|
withSiteId(metricDetails(savedMetric.metricId), siteId)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return useObserver(() => (
|
const updateLayout = (layout: string) => {
|
||||||
|
localStorage.setItem(LAYOUT_KEY, layout)
|
||||||
|
setLayout(layout)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
<Loader loading={loading}>
|
<Loader loading={loading}>
|
||||||
<Prompt
|
<Prompt
|
||||||
when={hasChanged}
|
when={hasChanged}
|
||||||
message={(location: any) => {
|
message={(location: any) => {
|
||||||
if (location.pathname.includes('/metrics/') || location.pathname.includes('/metric/')) {
|
if (
|
||||||
|
location.pathname.includes('/metrics/') ||
|
||||||
|
location.pathname.includes('/metric/')
|
||||||
|
) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return 'You have unsaved changes. Are you sure you want to leave?';
|
return 'You have unsaved changes. Are you sure you want to leave?';
|
||||||
|
|
@ -110,9 +142,11 @@ function WidgetView(props: Props) {
|
||||||
items={[
|
items={[
|
||||||
{
|
{
|
||||||
label: dashboardName ? dashboardName : 'Cards',
|
label: dashboardName ? dashboardName : 'Cards',
|
||||||
to: dashboardId ? withSiteId('/dashboard/' + dashboardId, siteId) : withSiteId('/metrics', siteId)
|
to: dashboardId
|
||||||
|
? withSiteId('/dashboard/' + dashboardId, siteId)
|
||||||
|
: withSiteId('/metrics', siteId),
|
||||||
},
|
},
|
||||||
{ label: widget.name }
|
{ label: widget.name },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<NoContent
|
<NoContent
|
||||||
|
|
@ -125,25 +159,70 @@ function WidgetView(props: Props) {
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Space direction="vertical" className="w-full" size={14}>
|
<Space direction="vertical" className="w-full" size={14}>
|
||||||
<WidgetViewHeader onSave={onSave} undoChanges={undoChanges} />
|
<WidgetViewHeader
|
||||||
<WidgetFormNew />
|
onSave={onSave}
|
||||||
|
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} />
|
<WidgetPreview name={widget.name} isEditing={expanded} />
|
||||||
|
|
||||||
{widget.metricOf !== FilterKey.SESSIONS && widget.metricOf !== FilterKey.ERRORS && (
|
{widget.metricOf !== FilterKey.SESSIONS &&
|
||||||
(widget.metricType === TABLE
|
widget.metricOf !== FilterKey.ERRORS &&
|
||||||
|| widget.metricType === TIMESERIES
|
(widget.metricType === TABLE ||
|
||||||
|| widget.metricType === HEATMAP
|
widget.metricType === TIMESERIES ||
|
||||||
|| widget.metricType === INSIGHTS
|
widget.metricType === HEATMAP ||
|
||||||
|| widget.metricType === FUNNEL
|
widget.metricType === INSIGHTS ||
|
||||||
|| widget.metricType === USER_PATH) ?
|
widget.metricType === FUNNEL ||
|
||||||
<WidgetSessions /> : null
|
widget.metricType === USER_PATH ? (
|
||||||
)}
|
<WidgetSessions />
|
||||||
|
) : null)}
|
||||||
{widget.metricType === RETENTION && <CardUserList />}
|
{widget.metricType === RETENTION && <CardUserList />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
</Space>
|
</Space>
|
||||||
</NoContent>
|
</NoContent>
|
||||||
</div>
|
</div>
|
||||||
</Loader>
|
</Loader>
|
||||||
));
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default WidgetView;
|
export default observer(WidgetView);
|
||||||
|
|
|
||||||
|
|
@ -1,49 +1,78 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import cn from "classnames";
|
import cn from 'classnames';
|
||||||
import WidgetName from "Components/Dashboard/components/WidgetName";
|
import WidgetName from 'Components/Dashboard/components/WidgetName';
|
||||||
import {useStore} from "App/mstore";
|
import { useStore } from 'App/mstore';
|
||||||
import {useObserver} from "mobx-react-lite";
|
import { observer } from 'mobx-react-lite';
|
||||||
import AddToDashboardButton from "Components/Dashboard/components/AddToDashboardButton";
|
|
||||||
import WidgetDateRange from "Components/Dashboard/components/WidgetDateRange/WidgetDateRange";
|
import { Button, Space, Tooltip } from 'antd';
|
||||||
import {Button, Space} from "antd";
|
import CardViewMenu from 'Components/Dashboard/components/WidgetView/CardViewMenu';
|
||||||
import CardViewMenu from "Components/Dashboard/components/WidgetView/CardViewMenu";
|
import { Link2 } from 'lucide-react'
|
||||||
|
import copy from 'copy-to-clipboard';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
onSave: () => void;
|
onSave: () => void;
|
||||||
undoChanges?: () => void;
|
undoChanges: () => void;
|
||||||
|
layoutControl?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
function WidgetViewHeader({onClick, onSave, undoChanges}: Props) {
|
const defaultText = 'Copy link to clipboard'
|
||||||
const {metricStore, dashboardStore} = useStore();
|
|
||||||
const widget = useObserver(() => metricStore.instance);
|
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<div
|
<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}
|
onClick={onClick}
|
||||||
>
|
>
|
||||||
<h1 className="mb-0 text-2xl mr-4 min-w-fit ">
|
<h1 className="mb-0 text-2xl mr-4 min-w-fit ">
|
||||||
<WidgetName name={widget.name}
|
<WidgetName
|
||||||
onUpdate={(name) => metricStore.merge({name})}
|
name={widget.name}
|
||||||
|
onUpdate={(name) => {
|
||||||
|
metricStore.merge({ name });
|
||||||
|
}}
|
||||||
canEdit={true}
|
canEdit={true}
|
||||||
/>
|
/>
|
||||||
</h1>
|
</h1>
|
||||||
<Space>
|
<Space>
|
||||||
<WidgetDateRange label=""/>
|
|
||||||
<AddToDashboardButton metricId={widget.metricId}/>
|
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type={
|
||||||
onClick={onSave}
|
metricStore.isSaving || (widget.exists() && !widget.hasChanged) ? 'text' : 'primary'
|
||||||
|
}
|
||||||
|
onClick={handleSave}
|
||||||
loading={metricStore.isSaving}
|
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>
|
</Button>
|
||||||
|
|
||||||
|
{/* <MetricTypeSelector /> */}
|
||||||
|
|
||||||
|
<Tooltip title={tooltipText}>
|
||||||
|
<Button type='text' className='btn-copy-card-url' disabled={!widget.exists()} onClick={copyUrl} icon={<Link2 size={16} strokeWidth={1}/> }></Button>
|
||||||
|
</Tooltip>
|
||||||
|
{layoutControl}
|
||||||
<CardViewMenu />
|
<CardViewMenu />
|
||||||
</Space>
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default WidgetViewHeader;
|
export default observer(WidgetViewHeader);
|
||||||
|
|
|
||||||
|
|
@ -12,16 +12,15 @@ interface Props {
|
||||||
}
|
}
|
||||||
|
|
||||||
function AlertButton(props: Props) {
|
function AlertButton(props: Props) {
|
||||||
const {seriesId} = props;
|
const { seriesId, initAlert } = props;
|
||||||
const {dashboardStore, alertsStore} = useStore();
|
const { alertsStore } = useStore();
|
||||||
const { openModal, closeModal } = useModal();
|
const { openModal, closeModal } = useModal();
|
||||||
const onClick = () => {
|
const onClick = () => {
|
||||||
// dashboardStore.toggleAlertModal(true);
|
initAlert?.();
|
||||||
alertsStore.init({ query: { left: seriesId } })
|
alertsStore.init({ query: { left: seriesId } })
|
||||||
openModal(<AlertFormModal
|
openModal(<AlertFormModal
|
||||||
onClose={closeModal}
|
onClose={closeModal}
|
||||||
/>, {
|
/>, {
|
||||||
// title: 'Set Alerts',
|
|
||||||
placement: 'right',
|
placement: 'right',
|
||||||
width: 620,
|
width: 620,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -7,12 +7,12 @@ import { useStore } from 'App/mstore';
|
||||||
import { withRouter, RouteComponentProps } from 'react-router-dom';
|
import { withRouter, RouteComponentProps } from 'react-router-dom';
|
||||||
import { withSiteId, dashboardMetricDetails } from 'App/routes';
|
import { withSiteId, dashboardMetricDetails } from 'App/routes';
|
||||||
import TemplateOverlay from './TemplateOverlay';
|
import TemplateOverlay from './TemplateOverlay';
|
||||||
import AlertButton from './AlertButton';
|
|
||||||
import stl from './widgetWrapper.module.css';
|
|
||||||
import { FilterKey } from 'App/types/filter/filterType';
|
import { FilterKey } from 'App/types/filter/filterType';
|
||||||
import { TIMESERIES } from "App/constants/card";
|
import { TIMESERIES } from 'App/constants/card';
|
||||||
|
|
||||||
const WidgetChart = lazy(() => import('Components/Dashboard/components/WidgetChart'));
|
const WidgetChart = lazy(
|
||||||
|
() => import('Components/Dashboard/components/WidgetChart')
|
||||||
|
);
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
className?: string;
|
className?: string;
|
||||||
|
|
@ -74,19 +74,26 @@ function WidgetWrapper(props: Props & RouteComponentProps) {
|
||||||
});
|
});
|
||||||
|
|
||||||
const onDelete = async () => {
|
const onDelete = async () => {
|
||||||
dashboardStore.deleteDashboardWidget(dashboard?.dashboardId!, widget.widgetId);
|
dashboardStore.deleteDashboardWidget(
|
||||||
|
dashboard?.dashboardId!,
|
||||||
|
widget.widgetId
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onChartClick = () => {
|
const onChartClick = () => {
|
||||||
if (!isSaved || isPredefined) return;
|
if (!isSaved || isPredefined) return;
|
||||||
|
|
||||||
props.history.push(
|
props.history.push(
|
||||||
withSiteId(dashboardMetricDetails(dashboard?.dashboardId, widget.metricId), siteId)
|
withSiteId(
|
||||||
|
dashboardMetricDetails(dashboard?.dashboardId, widget.metricId),
|
||||||
|
siteId
|
||||||
|
)
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const ref: any = useRef(null);
|
const ref: any = useRef(null);
|
||||||
const dragDropRef: any = dragRef(dropRef(ref));
|
const dragDropRef: any = isPreview ? null : dragRef(dropRef(ref));
|
||||||
|
|
||||||
const addOverlay =
|
const addOverlay =
|
||||||
isTemplate ||
|
isTemplate ||
|
||||||
(!isPredefined &&
|
(!isPredefined &&
|
||||||
|
|
@ -97,7 +104,7 @@ function WidgetWrapper(props: Props & RouteComponentProps) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative rounded bg-white border group rounded-lg',
|
'relative bg-white border group rounded-lg',
|
||||||
'col-span-' + widget.config.col,
|
'col-span-' + widget.config.col,
|
||||||
{ 'hover:shadow-border-gray': !isTemplate && isSaved },
|
{ 'hover:shadow-border-gray': !isTemplate && isSaved },
|
||||||
{ 'hover:shadow-border-main': isTemplate }
|
{ 'hover:shadow-border-main': isTemplate }
|
||||||
|
|
@ -106,64 +113,30 @@ function WidgetWrapper(props: Props & RouteComponentProps) {
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
opacity: isDragging ? 0.5 : 1,
|
opacity: isDragging ? 0.5 : 1,
|
||||||
borderColor:
|
borderColor:
|
||||||
(canDrop && isOver) || active ? '#394EFF' : isPreview ? 'transparent' : '#EEEEEE',
|
(canDrop && isOver) || active
|
||||||
|
? '#394EFF'
|
||||||
|
: isPreview
|
||||||
|
? 'transparent'
|
||||||
|
: '#EEEEEE',
|
||||||
}}
|
}}
|
||||||
ref={dragDropRef}
|
ref={dragDropRef}
|
||||||
onClick={props.onClick ? props.onClick : () => {}}
|
onClick={props.onClick ? props.onClick : () => {}}
|
||||||
id={`widget-${widget.widgetId}`}
|
id={`widget-${widget.metricId}`}
|
||||||
>
|
>
|
||||||
{!isTemplate && isSaved && isPredefined && (
|
{addOverlay && (
|
||||||
<div
|
<TemplateOverlay onClick={onChartClick} isTemplate={isTemplate} />
|
||||||
className={cn(
|
|
||||||
stl.drillDownMessage,
|
|
||||||
'disabled text-gray text-sm invisible group-hover:visible'
|
|
||||||
)}
|
)}
|
||||||
>
|
{!props.hideName ? (
|
||||||
{'Cannot drill down system provided metrics'}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{addOverlay && <TemplateOverlay onClick={onChartClick} isTemplate={isTemplate} />}
|
|
||||||
<div
|
<div
|
||||||
className={cn('p-3 pb-4 flex items-center justify-between', {
|
className={cn('p-3 pb-4 flex items-center justify-between', {
|
||||||
'cursor-move': !isTemplate && isSaved,
|
'cursor-move': !isTemplate && isSaved,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{!props.hideName ? (
|
|
||||||
<div className="capitalize-first w-full font-medium">
|
<div className="capitalize-first w-full font-medium">
|
||||||
<TextEllipsis text={widget.name} />
|
<TextEllipsis text={widget.name} />
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
) : null}
|
) : 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}>
|
<div className="px-4" onClick={onChartClick}>
|
||||||
<WidgetChart
|
<WidgetChart
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ interface Props {
|
||||||
}
|
}
|
||||||
|
|
||||||
function WidgetWrapperNew(props: Props & RouteComponentProps) {
|
function WidgetWrapperNew(props: Props & RouteComponentProps) {
|
||||||
const { dashboardStore } = useStore();
|
const { dashboardStore, metricStore } = useStore();
|
||||||
const {
|
const {
|
||||||
isWidget = false,
|
isWidget = false,
|
||||||
active = false,
|
active = false,
|
||||||
|
|
@ -94,11 +94,13 @@ function WidgetWrapperNew(props: Props & RouteComponentProps) {
|
||||||
widget.metricOf !== FilterKey.ERRORS &&
|
widget.metricOf !== FilterKey.ERRORS &&
|
||||||
widget.metricOf !== FilterKey.SESSIONS);
|
widget.metricOf !== FilterKey.SESSIONS);
|
||||||
|
|
||||||
|
const beforeAlertInit = () => {
|
||||||
|
metricStore.init(widget)
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative group rounded-lg hover:border-teal transition-all duration-200',
|
'relative group rounded-lg hover:border-teal transition-all duration-200 w-full',
|
||||||
'col-span-' + widget.config.col,
|
|
||||||
{ 'hover:shadow-sm': !isTemplate && isWidget },
|
{ 'hover:shadow-sm': !isTemplate && isWidget },
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -109,12 +111,12 @@ function WidgetWrapperNew(props: Props & RouteComponentProps) {
|
||||||
}}
|
}}
|
||||||
ref={dragDropRef}
|
ref={dragDropRef}
|
||||||
onClick={props.onClick ? props.onClick : () => null}
|
onClick={props.onClick ? props.onClick : () => null}
|
||||||
id={`widget-${widget.widgetId}`}
|
id={`widget-${widget.metricId}`}
|
||||||
title={!props.hideName ? widget.name : null}
|
title={!props.hideName ? widget.name : null}
|
||||||
extra={[
|
extra={[
|
||||||
<div className="flex items-center" id="no-print">
|
<div className="flex items-center" id="no-print">
|
||||||
{!isPredefined && isTimeSeries && !isGridView && (
|
{!isPredefined && isTimeSeries && !isGridView && (
|
||||||
<AlertButton seriesId={widget.series[0] && widget.series[0].seriesId} />
|
<AlertButton initAlert={beforeAlertInit} seriesId={widget.series[0] && widget.series[0].seriesId} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showMenu && (
|
{showMenu && (
|
||||||
|
|
|
||||||
|
|
@ -4,77 +4,149 @@ import FunnelStepText from './FunnelStepText';
|
||||||
import { Icon } from 'UI';
|
import { Icon } from 'UI';
|
||||||
import { Space } from 'antd';
|
import { Space } from 'antd';
|
||||||
import { Styles } from 'Components/Dashboard/Widgets/common';
|
import { Styles } from 'Components/Dashboard/Widgets/common';
|
||||||
|
import cn from 'classnames';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
filter: any;
|
filter: any;
|
||||||
|
compData?: any;
|
||||||
index?: number;
|
index?: number;
|
||||||
focusStage?: (index: number, isFocused: boolean) => void;
|
focusStage?: (index: number, isFocused: boolean) => void;
|
||||||
focusedFilter?: number | null;
|
focusedFilter?: number | null;
|
||||||
metricLabel?: string;
|
metricLabel?: string;
|
||||||
|
isHorizontal?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function FunnelBar(props: Props) {
|
function FunnelBar(props: Props) {
|
||||||
const { filter, index, focusStage, focusedFilter, metricLabel = 'Sessions' } = props;
|
const { filter, index, focusStage, focusedFilter, compData, isHorizontal } = props;
|
||||||
|
|
||||||
const isFocused = focusedFilter && index ? focusedFilter === index - 1 : false;
|
const isFocused =
|
||||||
|
focusedFilter && index ? focusedFilter === index - 1 : false;
|
||||||
return (
|
return (
|
||||||
<div className="w-full mb-4">
|
<div className="w-full mb-2">
|
||||||
<FunnelStepText filter={filter} />
|
<FunnelStepText filter={filter} isHorizontal={isHorizontal} />
|
||||||
|
<div className={isHorizontal ? 'flex gap-1' : 'flex flex-col'}>
|
||||||
|
<FunnelBarData
|
||||||
|
data={props.filter}
|
||||||
|
isHorizontal={isHorizontal}
|
||||||
|
isComp={false}
|
||||||
|
index={index}
|
||||||
|
isFocused={isFocused}
|
||||||
|
focusStage={focusStage}
|
||||||
|
/>
|
||||||
|
{compData ? (
|
||||||
|
<FunnelBarData
|
||||||
|
data={props.compData}
|
||||||
|
isHorizontal={isHorizontal}
|
||||||
|
isComp
|
||||||
|
index={index}
|
||||||
|
isFocused={isFocused}
|
||||||
|
focusStage={focusStage}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FunnelBarData({
|
||||||
|
data,
|
||||||
|
isComp,
|
||||||
|
isFocused,
|
||||||
|
focusStage,
|
||||||
|
index,
|
||||||
|
isHorizontal,
|
||||||
|
}: {
|
||||||
|
data: any;
|
||||||
|
isComp?: boolean;
|
||||||
|
isFocused?: boolean;
|
||||||
|
focusStage?: (index: number, isComparison: boolean) => void;
|
||||||
|
index?: number;
|
||||||
|
isHorizontal?: boolean;
|
||||||
|
}) {
|
||||||
|
const vertFillBarStyle = {
|
||||||
|
width: `${data.completedPercentageTotal}%`,
|
||||||
|
height: '100%',
|
||||||
|
backgroundColor: isComp ? Styles.compareColors[2] : Styles.compareColors[1],
|
||||||
|
};
|
||||||
|
const horizontalFillBarStyle = {
|
||||||
|
width: '100%',
|
||||||
|
height: `${data.completedPercentageTotal}%`,
|
||||||
|
backgroundColor: isComp ? Styles.compareColors[2] : Styles.compareColors[1],
|
||||||
|
}
|
||||||
|
|
||||||
|
const vertEmptyBarStyle = {
|
||||||
|
width: `${100.1 - data.completedPercentageTotal}%`,
|
||||||
|
height: '100%',
|
||||||
|
background: isFocused
|
||||||
|
? 'rgba(204, 0, 0, 0.3)'
|
||||||
|
: 'repeating-linear-gradient(325deg, lightgray, lightgray 1px, #FFF1F0 1px, #FFF1F0 6px)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}
|
||||||
|
const horizontalEmptyBarStyle = {
|
||||||
|
height: `${100.1 - data.completedPercentageTotal}%`,
|
||||||
|
width: '100%',
|
||||||
|
background: isFocused
|
||||||
|
? 'rgba(204, 0, 0, 0.3)'
|
||||||
|
: 'repeating-linear-gradient(325deg, lightgray, lightgray 1px, #FFF1F0 1px, #FFF1F0 6px)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}
|
||||||
|
|
||||||
|
const fillBarStyle = isHorizontal ? horizontalFillBarStyle : vertFillBarStyle;
|
||||||
|
const emptyBarStyle = isHorizontal ? horizontalEmptyBarStyle : vertEmptyBarStyle
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
<div
|
<div
|
||||||
|
className={isHorizontal ? 'rounded-t' : ''}
|
||||||
style={{
|
style={{
|
||||||
height: '25px',
|
height: isHorizontal ? '210px' : '21px',
|
||||||
width: '99.8%',
|
width: isHorizontal ? '200px' : '99.8%',
|
||||||
backgroundColor: '#f5f5f5',
|
backgroundColor: '#f5f5f5',
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
borderRadius: '.5rem',
|
borderRadius: isHorizontal ? undefined : '.5rem',
|
||||||
overflow: 'hidden'
|
overflow: 'hidden',
|
||||||
|
opacity: isComp ? 0.7 : 1,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: isHorizontal ? 'column-reverse' : 'row',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="flex items-center"
|
className={cn("flex", isHorizontal ? 'justify-center items-start pt-1' : 'justify-end items-center pr-1')}
|
||||||
style={{
|
style={fillBarStyle}
|
||||||
width: `${filter.completedPercentageTotal}%`,
|
|
||||||
position: 'absolute',
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
bottom: 0,
|
|
||||||
backgroundColor: Styles.compareColors[1]
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div className="color-white absolute right-0 flex items-center font-medium mr-2 leading-3">
|
<div className="color-white flex items-center font-medium leading-3">
|
||||||
{filter.completedPercentageTotal}%
|
{data.completedPercentageTotal}%
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={emptyBarStyle}
|
||||||
width: `${100.1 - filter.completedPercentageTotal}%`,
|
onClick={() => focusStage?.(index! - 1, isComp)}
|
||||||
position: 'absolute',
|
className={'hover:opacity-70'}
|
||||||
top: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
backgroundColor: isFocused ? 'rgba(204, 0, 0, 0.3)' : '#fff0f0',
|
|
||||||
cursor: 'pointer'
|
|
||||||
}}
|
|
||||||
onClick={() => focusStage?.(index! - 1, filter.isActive)}
|
|
||||||
className={'hover:opacity-75'}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between py-2">
|
<div
|
||||||
|
className={cn('flex justify-between', isComp ? 'opacity-60' : '')}
|
||||||
|
>
|
||||||
{/* @ts-ignore */}
|
{/* @ts-ignore */}
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<Icon name="arrow-right-short" size="20" color="green" />
|
<Icon name="arrow-right-short" size="20" color="green" />
|
||||||
<span className="mx-1">{filter.count} {metricLabel}</span>
|
|
||||||
<span className="color-gray-medium text-sm">
|
<span className="color-gray-medium text-sm">
|
||||||
({filter.completedPercentage}%) Completed
|
{`${data.completedPercentage}% . ${data.count}`}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{index && index > 1 && (
|
{index && index > 1 && (
|
||||||
<Space className="items-center">
|
<Space className="items-center">
|
||||||
<Icon name="caret-down-fill" color={filter.droppedCount > 0 ? 'red' : 'gray-light'} size={16} />
|
<Icon
|
||||||
|
name="caret-down-fill"
|
||||||
|
color={data.droppedCount > 0 ? 'red' : 'gray-light'}
|
||||||
|
size={16}
|
||||||
|
/>
|
||||||
<span
|
<span
|
||||||
className={'mx-1 ' + (filter.droppedCount > 0 ? 'color-red' : 'disabled')}>{filter.droppedCount} {metricLabel}</span>
|
className={
|
||||||
<span
|
'mr-1 text-sm' + (data.droppedCount > 0 ? 'color-red' : 'disabled')
|
||||||
className={'text-sm ' + (filter.droppedCount > 0 ? 'color-red' : 'disabled')}>({filter.droppedPercentage}%) Dropped</span>
|
}
|
||||||
|
>
|
||||||
|
{data.droppedCount} Skipped
|
||||||
|
</span>
|
||||||
</Space>
|
</Space>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -86,7 +158,7 @@ export function UxTFunnelBar(props: Props) {
|
||||||
const { filter } = props;
|
const { filter } = props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full mb-4">
|
<div className="w-full mb-2">
|
||||||
<div className={'font-medium'}>{filter.title}</div>
|
<div className={'font-medium'}>{filter.title}</div>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -95,22 +167,28 @@ export function UxTFunnelBar(props: Props) {
|
||||||
backgroundColor: '#f5f5f5',
|
backgroundColor: '#f5f5f5',
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
borderRadius: '.5rem',
|
borderRadius: '.5rem',
|
||||||
overflow: 'hidden'
|
overflow: 'hidden',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="flex items-center"
|
className="flex items-center"
|
||||||
style={{
|
style={{
|
||||||
width: `${(filter.completed / (filter.completed + filter.skipped)) * 100}%`,
|
width: `${
|
||||||
|
(filter.completed / (filter.completed + filter.skipped)) * 100
|
||||||
|
}%`,
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: 0,
|
top: 0,
|
||||||
left: 0,
|
left: 0,
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
backgroundColor: '#6272FF'
|
backgroundColor: '#6272FF',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="color-white absolute right-0 flex items-center font-medium mr-2 leading-3">
|
<div className="color-white absolute right-0 flex items-center font-medium mr-1 leading-3 text-sm">
|
||||||
{((filter.completed / (filter.completed + filter.skipped)) * 100).toFixed(1)}%
|
{(
|
||||||
|
(filter.completed / (filter.completed + filter.skipped)) *
|
||||||
|
100
|
||||||
|
).toFixed(1)}
|
||||||
|
%
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -119,22 +197,22 @@ export function UxTFunnelBar(props: Props) {
|
||||||
<div className={'flex items-center gap-4'}>
|
<div className={'flex items-center gap-4'}>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<Icon name="arrow-right-short" size="20" color="green" />
|
<Icon name="arrow-right-short" size="20" color="green" />
|
||||||
<span className="mx-1 font-medium">{filter.completed}</span><span>completed this step</span>
|
<span className="mx-1 font-medium">{filter.completed}</span>
|
||||||
|
<span>completed this step</span>
|
||||||
</div>
|
</div>
|
||||||
<div className={'flex items-center'}>
|
<div className={'flex items-center'}>
|
||||||
<Icon name="clock" size="16" />
|
<Icon name="clock" size="16" />
|
||||||
<span className="mx-1 font-medium">
|
<span className="mx-1 font-medium">
|
||||||
{durationFormatted(filter.avgCompletionTime)}
|
{durationFormatted(filter.avgCompletionTime)}
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>avg. completion time</span>
|
||||||
avg. completion time
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* @ts-ignore */}
|
{/* @ts-ignore */}
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<Icon name="caret-down-fill" color="red" size={16} />
|
<Icon name="caret-down-fill" color="red" size={16} />
|
||||||
<span className="font-medium mx-1">{filter.skipped}</span><span> skipped</span>
|
<span className="font-medium mx-1">{filter.skipped}</span>
|
||||||
|
<span> skipped</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -142,11 +220,3 @@ export function UxTFunnelBar(props: Props) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default FunnelBar;
|
export default FunnelBar;
|
||||||
|
|
||||||
const calculatePercentage = (completed: number, dropped: number) => {
|
|
||||||
const total = completed + dropped;
|
|
||||||
if (dropped === 0) return 100;
|
|
||||||
if (total === 0) return 0;
|
|
||||||
|
|
||||||
return Math.round((completed / dropped) * 100);
|
|
||||||
};
|
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,14 @@ import React from 'react';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
filter: any;
|
filter: any;
|
||||||
|
isHorizontal?: boolean;
|
||||||
}
|
}
|
||||||
function FunnelStepText(props: Props) {
|
function FunnelStepText(props: Props) {
|
||||||
const { filter } = props;
|
const { filter } = props;
|
||||||
const total = filter.value.length;
|
const total = filter.value.length;
|
||||||
|
const additionalStyle = props.isHorizontal ? { whiteSpace: 'nowrap', maxWidth: 210, textOverflow: 'ellipsis', overflow: 'hidden' } : {};
|
||||||
return (
|
return (
|
||||||
<div className="mb-2 color-gray-medium">
|
<div className="color-gray-medium" style={additionalStyle}>
|
||||||
<span className="color-gray-darkest">{filter.label}</span>
|
<span className="color-gray-darkest">{filter.label}</span>
|
||||||
<span className="mx-1">{filter.operator}</span>
|
<span className="mx-1">{filter.operator}</span>
|
||||||
{filter.value.map((value: any, index: number) => (
|
{filter.value.map((value: any, index: number) => (
|
||||||
|
|
|
||||||
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 {
|
.step-disabled {
|
||||||
filter: grayscale(1);
|
filter: grayscale(1);
|
||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import React, { useEffect } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import Widget from 'App/mstore/types/widget';
|
import Widget from 'App/mstore/types/widget';
|
||||||
import Funnelbar, { UxTFunnelBar } from './FunnelBar';
|
import Funnelbar, { UxTFunnelBar } from "./FunnelBar";
|
||||||
|
import Funnel from 'App/mstore/types/funnel'
|
||||||
import cn from 'classnames';
|
import cn from 'classnames';
|
||||||
import stl from './FunnelWidget.module.css';
|
import stl from './FunnelWidget.module.css';
|
||||||
import { observer } from 'mobx-react-lite';
|
import { observer } from 'mobx-react-lite';
|
||||||
|
|
@ -13,13 +14,14 @@ import Filter from '@/mstore/types/filter';
|
||||||
interface Props {
|
interface Props {
|
||||||
metric?: Widget;
|
metric?: Widget;
|
||||||
isWidget?: boolean;
|
isWidget?: boolean;
|
||||||
data: any;
|
data: { funnel: Funnel };
|
||||||
|
compData: { funnel: Funnel };
|
||||||
}
|
}
|
||||||
|
|
||||||
function FunnelWidget(props: Props) {
|
function FunnelWidget(props: Props) {
|
||||||
const { dashboardStore, searchStore } = useStore();
|
const { dashboardStore, searchStore } = useStore();
|
||||||
const [focusedFilter, setFocusedFilter] = React.useState<number | null>(null);
|
const [focusedFilter, setFocusedFilter] = React.useState<number | null>(null);
|
||||||
const { isWidget = false, data, metric } = props;
|
const { isWidget = false, data, metric, compData } = props;
|
||||||
const funnel = data.funnel || { stages: [] };
|
const funnel = data.funnel || { stages: [] };
|
||||||
const totalSteps = funnel.stages.length;
|
const totalSteps = funnel.stages.length;
|
||||||
const stages = isWidget ? [...funnel.stages.slice(0, 1), funnel.stages[funnel.stages.length - 1]] : funnel.stages;
|
const stages = isWidget ? [...funnel.stages.slice(0, 1), funnel.stages[funnel.stages.length - 1]] : funnel.stages;
|
||||||
|
|
@ -30,11 +32,12 @@ function FunnelWidget(props: Props) {
|
||||||
const metricLabel = metric?.metricFormat == 'userCount' ? 'Users' : 'Sessions';
|
const metricLabel = metric?.metricFormat == 'userCount' ? 'Users' : 'Sessions';
|
||||||
const drillDownFilter = dashboardStore.drillDownFilter;
|
const drillDownFilter = dashboardStore.drillDownFilter;
|
||||||
const drillDownPeriod = dashboardStore.drillDownPeriod;
|
const drillDownPeriod = dashboardStore.drillDownPeriod;
|
||||||
|
const comparisonPeriod = metric ? dashboardStore.comparisonPeriods[metric.metricId] : undefined
|
||||||
const metricFilters = metric?.series[0]?.filter.filters || [];
|
const metricFilters = metric?.series[0]?.filter.filters || [];
|
||||||
|
|
||||||
const applyDrillDown = (index: number) => {
|
const applyDrillDown = (index: number, isComp?: boolean) => {
|
||||||
const filter = new Filter().fromData({ filters: metricFilters.slice(0, index + 1) });
|
const filter = new Filter().fromData({ filters: metricFilters.slice(0, index + 1) });
|
||||||
const periodTimestamps = drillDownPeriod.toTimestamps();
|
const periodTimestamps = isComp && index > -1 ? comparisonPeriod.toTimestamps() : drillDownPeriod.toTimestamps();
|
||||||
drillDownFilter.merge({
|
drillDownFilter.merge({
|
||||||
filters: filter.toJson().filters,
|
filters: filter.toJson().filters,
|
||||||
startTimestamp: periodTimestamps.startTimestamp,
|
startTimestamp: periodTimestamps.startTimestamp,
|
||||||
|
|
@ -49,7 +52,7 @@ function FunnelWidget(props: Props) {
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const focusStage = (index: number) => {
|
const focusStage = (index: number, isComp?: boolean) => {
|
||||||
funnel.stages.forEach((s, i) => {
|
funnel.stages.forEach((s, i) => {
|
||||||
// turning on all filters if one was focused already
|
// turning on all filters if one was focused already
|
||||||
if (focusedFilter === index) {
|
if (focusedFilter === index) {
|
||||||
|
|
@ -65,9 +68,25 @@ function FunnelWidget(props: Props) {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
applyDrillDown(focusedFilter === index ? -1 : index);
|
applyDrillDown(focusedFilter === index ? -1 : index, isComp);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const shownStages = React.useMemo(() => {
|
||||||
|
const stages: { data: Funnel['stages'][0], compData?: Funnel['stages'][0] }[] = [];
|
||||||
|
for (let i = 0; i < funnel.stages.length; i++) {
|
||||||
|
const stage: any = { data: funnel.stages[i], compData: undefined }
|
||||||
|
const compStage = compData?.funnel.stages[i];
|
||||||
|
if (compStage) {
|
||||||
|
stage.compData = compStage;
|
||||||
|
}
|
||||||
|
stages.push(stage)
|
||||||
|
}
|
||||||
|
|
||||||
|
return stages;
|
||||||
|
}, [data, compData])
|
||||||
|
|
||||||
|
const viewType = metric?.viewType;
|
||||||
|
const isHorizontal = viewType === 'columnChart';
|
||||||
return (
|
return (
|
||||||
<NoContent
|
<NoContent
|
||||||
style={{ minHeight: 220 }}
|
style={{ minHeight: 220 }}
|
||||||
|
|
@ -79,20 +98,21 @@ function FunnelWidget(props: Props) {
|
||||||
}
|
}
|
||||||
show={!stages || stages.length === 0}
|
show={!stages || stages.length === 0}
|
||||||
>
|
>
|
||||||
<div className="w-full">
|
<div className={cn('w-full border-b -mx-4 px-4', isHorizontal ? 'overflow-x-scroll custom-scrollbar flex gap-2 justify-around' : '')}>
|
||||||
{!isWidget && (
|
{!isWidget &&
|
||||||
stages.map((filter: any, index: any) => (
|
shownStages.map((stage: any, index: any) => (
|
||||||
<Stage
|
<Stage
|
||||||
key={index}
|
key={index}
|
||||||
|
isHorizontal={isHorizontal}
|
||||||
index={index + 1}
|
index={index + 1}
|
||||||
isWidget={isWidget}
|
isWidget={isWidget}
|
||||||
stage={filter}
|
stage={stage.data}
|
||||||
|
compData={stage.compData}
|
||||||
focusStage={focusStage}
|
focusStage={focusStage}
|
||||||
focusedFilter={focusedFilter}
|
focusedFilter={focusedFilter}
|
||||||
metricLabel={metricLabel}
|
metricLabel={metricLabel}
|
||||||
/>
|
/>
|
||||||
))
|
))}
|
||||||
)}
|
|
||||||
|
|
||||||
{isWidget && (
|
{isWidget && (
|
||||||
<>
|
<>
|
||||||
|
|
@ -110,38 +130,56 @@ function FunnelWidget(props: Props) {
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center pb-4">
|
<div className="flex items-center py-2 gap-2">
|
||||||
<div className="flex items-center">
|
|
||||||
<span className="text-base font-medium mr-2">Lost conversion</span>
|
|
||||||
<Tooltip title={`${funnel.lostConversions} Sessions ${funnel.lostConversionsPercentage}%`}>
|
|
||||||
<Tag bordered={false} color="red" className="text-lg font-medium rounded-lg">
|
|
||||||
{funnel.lostConversions}
|
|
||||||
</Tag>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
<div className="mx-3" />
|
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<span className="text-base font-medium mr-2">Total conversion</span>
|
<span className="text-base font-medium mr-2">Total conversion</span>
|
||||||
<Tooltip title={`${funnel.totalConversions} Sessions ${funnel.totalConversionsPercentage}%`}>
|
<Tooltip
|
||||||
<Tag bordered={false} color="green" className="text-lg font-medium rounded-lg">
|
title={`${funnel.totalConversions} Sessions ${funnel.totalConversionsPercentage}%`}
|
||||||
|
>
|
||||||
|
<Tag
|
||||||
|
bordered={false}
|
||||||
|
color="#F5F8FF"
|
||||||
|
className="text-lg rounded-lg !text-black"
|
||||||
|
>
|
||||||
{funnel.totalConversions}
|
{funnel.totalConversions}
|
||||||
</Tag>
|
</Tag>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<span className="text-base font-medium mr-2">Lost conversion</span>
|
||||||
|
<Tooltip
|
||||||
|
title={`${funnel.lostConversions} Sessions ${funnel.lostConversionsPercentage}%`}
|
||||||
|
>
|
||||||
|
<Tag
|
||||||
|
bordered={false}
|
||||||
|
color="#FFEFEF"
|
||||||
|
className="text-lg rounded-lg !text-black"
|
||||||
|
>
|
||||||
|
{funnel.lostConversions}
|
||||||
|
</Tag>
|
||||||
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
{funnel.totalDropDueToIssues > 0 && <div className="flex items-center mb-2"><Icon name="magic" /> <span
|
</div>
|
||||||
className="ml-2">{funnel.totalDropDueToIssues} sessions dropped due to issues.</span></div>}
|
{funnel.totalDropDueToIssues > 0 && (
|
||||||
|
<div className="flex items-center mb-2">
|
||||||
|
<Icon name="magic" />{' '}
|
||||||
|
<span className="ml-2">
|
||||||
|
{funnel.totalDropDueToIssues} sessions dropped due to issues.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</NoContent>
|
</NoContent>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const EmptyStage = observer(({ total }: any) => {
|
export const EmptyStage = observer(({ total }: any) => {
|
||||||
return (
|
return (
|
||||||
<div className={cn('flex items-center mb-4 pb-3', stl.step)}>
|
<div className={cn('flex items-center mb-4 pb-3 relative border-b -mx-4 px-4 pt-2')}>
|
||||||
<IndexNumber index={0} />
|
<IndexNumber index={0} />
|
||||||
<div
|
<div
|
||||||
className="w-fit px-2 border border-teal py-1 text-center justify-center bg-teal-lightest flex items-center rounded-full color-teal"
|
className="w-fit px-2 border border-teal py-1 text-center justify-center bg-teal-lightest flex items-center rounded-full color-teal"
|
||||||
style={{ width: '100px' }}>
|
style={{ width: '100px' }}
|
||||||
|
>
|
||||||
{`+${total} ${total > 1 ? 'steps' : 'step'}`}
|
{`+${total} ${total > 1 ? 'steps' : 'step'}`}
|
||||||
</div>
|
</div>
|
||||||
<div className="border-b w-full border-dashed"></div>
|
<div className="border-b w-full border-dashed"></div>
|
||||||
|
|
@ -149,39 +187,35 @@ export const EmptyStage = observer(({ total }: any) => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
export const Stage = observer(({ metricLabel, stage, index, isWidget, uxt, focusStage, focusedFilter }: any) => {
|
export const Stage = observer(({
|
||||||
|
metricLabel,
|
||||||
|
stage,
|
||||||
|
index,
|
||||||
|
uxt,
|
||||||
|
focusStage,
|
||||||
|
focusedFilter,
|
||||||
|
compData,
|
||||||
|
isHorizontal,
|
||||||
|
}: any) => {
|
||||||
return stage ? (
|
return stage ? (
|
||||||
<div
|
<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} />
|
<IndexNumber index={index} />
|
||||||
{!uxt ? <Funnelbar metricLabel={metricLabel} index={index} filter={stage} focusStage={focusStage}
|
{!uxt ? <Funnelbar isHorizontal={isHorizontal} compData={compData} metricLabel={metricLabel} index={index} filter={stage} focusStage={focusStage} focusedFilter={focusedFilter} /> : <UxTFunnelBar filter={stage} />}
|
||||||
focusedFilter={focusedFilter} /> : <UxTFunnelBar filter={stage} />}
|
|
||||||
{/*{!isWidget && !uxt && <BarActions bar={stage} />}*/}
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : null
|
||||||
<></>
|
})
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
export const IndexNumber = observer(({ index }: any) => {
|
export const IndexNumber = observer(({ index }: any) => {
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="z-10 w-6 h-6 border shrink-0 mr-4 text-sm rounded-full bg-gray-lightest flex items-center justify-center leading-3">
|
||||||
className="z-10 w-6 h-6 border shrink-0 mr-4 text-sm rounded-full bg-gray-lightest flex items-center justify-center leading-3">
|
|
||||||
{index === 0 ? <Icon size="14" color="gray-dark" name="list" /> : index}
|
{index === 0 ? <Icon size="14" color="gray-dark" name="list" /> : index}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
})
|
||||||
|
|
||||||
|
|
||||||
const BarActions = observer(({ bar }: any) => {
|
|
||||||
return (
|
|
||||||
<div className="self-end flex items-center justify-center ml-4" style={{ marginBottom: '49px' }}>
|
|
||||||
<button onClick={() => bar.updateKey('isActive', !bar.isActive)}>
|
|
||||||
<Icon name="eye-slash-fill" color={bar.isActive ? 'gray-light' : 'gray-darkest'} size="22" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
export default observer(FunnelWidget);
|
export default observer(FunnelWidget);
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,10 @@ function HealthModal({
|
||||||
}: {
|
}: {
|
||||||
getHealth: () => void;
|
getHealth: () => void;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
healthResponse: { overallHealth: boolean; healthMap: Record<string, IServiceStats> };
|
healthResponse: {
|
||||||
|
overallHealth: boolean;
|
||||||
|
healthMap: Record<string, IServiceStats>;
|
||||||
|
};
|
||||||
setShowModal: (isOpen: boolean) => void;
|
setShowModal: (isOpen: boolean) => void;
|
||||||
setPassed?: () => void;
|
setPassed?: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
|
@ -39,7 +42,7 @@ function HealthModal({
|
||||||
setShowModal(false);
|
setShowModal(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const isSetup = document.location.pathname.includes('/signup')
|
const isSetup = document.location.pathname.includes('/signup');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -64,7 +67,9 @@ function HealthModal({
|
||||||
transform: 'translate(-50%, -50%)',
|
transform: 'translate(-50%, -50%)',
|
||||||
}}
|
}}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
className={'flex flex-col bg-white rounded border border-figmaColors-divider'}
|
className={
|
||||||
|
'flex flex-col bg-white rounded border border-figmaColors-divider'
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
|
|
@ -83,14 +88,19 @@ function HealthModal({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Loader loading={isLoading}>
|
<Loader loading={isLoading}>
|
||||||
|
{healthResponse ? (
|
||||||
|
<>
|
||||||
<div className={'flex w-full'}>
|
<div className={'flex w-full'}>
|
||||||
<div className={'flex flex-col h-full'} style={{ flex: 1 }}>
|
<div className={'flex flex-col h-full'} style={{ flex: 1 }}>
|
||||||
{isLoading ? null
|
{isLoading
|
||||||
|
? null
|
||||||
: Object.keys(healthResponse.healthMap).map((service) => (
|
: Object.keys(healthResponse.healthMap).map((service) => (
|
||||||
<React.Fragment key={service}>
|
<React.Fragment key={service}>
|
||||||
<Category
|
<Category
|
||||||
onClick={() => setSelectedService(service)}
|
onClick={() => setSelectedService(service)}
|
||||||
healthOk={healthResponse.healthMap[service].healthOk}
|
healthOk={
|
||||||
|
healthResponse.healthMap[service].healthOk
|
||||||
|
}
|
||||||
name={healthResponse.healthMap[service].name}
|
name={healthResponse.healthMap[service].name}
|
||||||
isSelectable
|
isSelectable
|
||||||
isSelected={selectedService === service}
|
isSelected={selectedService === service}
|
||||||
|
|
@ -105,13 +115,20 @@ function HealthModal({
|
||||||
style={{ flex: 2, height: 420 }}
|
style={{ flex: 2, height: 420 }}
|
||||||
>
|
>
|
||||||
{isLoading ? null : selectedService ? (
|
{isLoading ? null : selectedService ? (
|
||||||
<ServiceStatus service={healthResponse.healthMap[selectedService]} />
|
<ServiceStatus
|
||||||
) : <img src={slide} width={392} />
|
service={healthResponse.healthMap[selectedService]}
|
||||||
}
|
/>
|
||||||
|
) : (
|
||||||
|
<img src={slide} width={392} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{isSetup ? (
|
{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
|
<Button
|
||||||
disabled={!healthResponse?.overallHealth}
|
disabled={!healthResponse?.overallHealth}
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
|
|
@ -123,6 +140,12 @@ function HealthModal({
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className={'w-full h-full flex items-center justify-center'}>
|
||||||
|
<div>Error while fetching data...</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</Loader>
|
</Loader>
|
||||||
<Footer isSetup={isSetup} />
|
<Footer isSetup={isSetup} />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -137,7 +160,10 @@ function ServiceStatus({ service }: { service: Record<string, any> }) {
|
||||||
<div className={'border rounded border-light-gray'}>
|
<div className={'border rounded border-light-gray'}>
|
||||||
{Object.keys(subservices).map((subservice: string) => (
|
{Object.keys(subservices).map((subservice: string) => (
|
||||||
<React.Fragment key={subservice}>
|
<React.Fragment key={subservice}>
|
||||||
<SubserviceHealth name={subservice} subservice={subservices[subservice]} />
|
<SubserviceHealth
|
||||||
|
name={subservice}
|
||||||
|
subservice={subservices[subservice]}
|
||||||
|
/>
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -315,6 +315,7 @@ function PanelComponent({
|
||||||
/>
|
/>
|
||||||
{summaryChecked ? (
|
{summaryChecked ? (
|
||||||
<Segmented
|
<Segmented
|
||||||
|
size='small'
|
||||||
value={zoomTab}
|
value={zoomTab}
|
||||||
onChange={(val) => setZoomTab(val)}
|
onChange={(val) => setZoomTab(val)}
|
||||||
options={[
|
options={[
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,7 @@ const PerformanceGraph = React.memo((props: Props) => {
|
||||||
{disabled ? (
|
{disabled ? (
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
'flex justify-center'
|
'flex justify-start'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className={'text-xs text-neutral-400 ps-2'}>
|
<div className={'text-xs text-neutral-400 ps-2'}>
|
||||||
|
|
|
||||||
|
|
@ -158,7 +158,7 @@ function GroupedIssue({
|
||||||
onClick={createEventClickHandler(pointer, type)}
|
onClick={createEventClickHandler(pointer, type)}
|
||||||
className={'flex items-center gap-2 mb-1 cursor-pointer border-b border-transparent hover:border-gray-lightest'}
|
className={'flex items-center gap-2 mb-1 cursor-pointer border-b border-transparent hover:border-gray-lightest'}
|
||||||
>
|
>
|
||||||
<div className={'text-disabled-text'}>@{shortDurationFromMs(pointer.time)}</div>
|
<div className={'text-secondary'}>@{shortDurationFromMs(pointer.time)}</div>
|
||||||
<RenderLineData type={type} item={pointer} />
|
<RenderLineData type={type} item={pointer} />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import { KEYS } from 'Types/filter/customFilter';
|
||||||
import { capitalize } from 'App/utils';
|
import { capitalize } from 'App/utils';
|
||||||
import { useStore } from 'App/mstore';
|
import { useStore } from 'App/mstore';
|
||||||
import { observer } from 'mobx-react-lite';
|
import { observer } from 'mobx-react-lite';
|
||||||
import AssistSearchField from 'App/components/Assist/AssistSearchField';
|
import AssistSearchField from 'App/components/Assist/AssistSearchActions';
|
||||||
import LiveSessionSearch from 'Shared/LiveSessionSearch';
|
import LiveSessionSearch from 'Shared/LiveSessionSearch';
|
||||||
import cn from 'classnames';
|
import cn from 'classnames';
|
||||||
import Session from 'App/mstore/types/session';
|
import Session from 'App/mstore/types/session';
|
||||||
|
|
@ -70,7 +70,7 @@ function AssistSessionsModal(props: ConnectProps) {
|
||||||
icon="arrow-repeat"
|
icon="arrow-repeat"
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<AssistSearchField />
|
<AssistSearchActions />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex self-end items-center gap-2" w-full>
|
<div className="flex self-end items-center gap-2" w-full>
|
||||||
<span className="color-gray-medium">Sort By</span>
|
<span className="color-gray-medium">Sort By</span>
|
||||||
|
|
|
||||||
|
|
@ -33,9 +33,14 @@ const Signup: React.FC<SignupProps> = ({ history }) => {
|
||||||
|
|
||||||
const getHealth = async () => {
|
const getHealth = async () => {
|
||||||
setHealthStatusLoading(true);
|
setHealthStatusLoading(true);
|
||||||
|
try {
|
||||||
const { healthMap } = await getHealthRequest(true);
|
const { healthMap } = await getHealthRequest(true);
|
||||||
setHealthStatus(healthMap);
|
setHealthStatus(healthMap);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
} finally {
|
||||||
setHealthStatusLoading(false);
|
setHealthStatusLoading(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,7 @@ const SpotsListHeader = observer(
|
||||||
<div className={'flex items-center justify-between w-full'}>
|
<div className={'flex items-center justify-between w-full'}>
|
||||||
<div className="flex gap-1 items-center">
|
<div className="flex gap-1 items-center">
|
||||||
<h1 className={'text-2xl capitalize mr-2'}>Spot List</h1>
|
<h1 className={'text-2xl capitalize mr-2'}>Spot List</h1>
|
||||||
<ReloadButton buttonSize={'small'} onClick={onRefresh} iconSize={16} />
|
<ReloadButton buttonSize={'small'} onClick={onRefresh} iconSize={14} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{tenantHasSpots ? (
|
{tenantHasSpots ? (
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useStore } from "App/mstore";
|
import { useStore } from "App/mstore";
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { NoPermission, NoSessionPermission } from 'UI';
|
import { NoPermission, NoSessionPermission } from 'UI';
|
||||||
|
import { observer } from 'mobx-react-lite'
|
||||||
|
|
||||||
|
|
||||||
export default (requiredPermissions, className, isReplay = false, andEd = true) => (BaseComponent) => {
|
export default (requiredPermissions, className, isReplay = false, andEd = true) => (BaseComponent) => {
|
||||||
|
|
@ -26,5 +26,5 @@ export default (requiredPermissions, className, isReplay = false, andEd = true)
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return WrapperClass;
|
return observer(WrapperClass);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,20 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Button } from 'antd';
|
import { Button } from 'antd';
|
||||||
import { useHistory } from 'react-router-dom';
|
import { useHistory } from 'react-router-dom';
|
||||||
import { LeftOutlined } from '@ant-design/icons';
|
import { LeftOutlined, ArrowLeftOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
function BackButton() {
|
function BackButton({ compact }: { compact?: boolean }) {
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
const siteId = location.pathname.split('/')[1];
|
const siteId = location.pathname.split('/')[1];
|
||||||
|
|
||||||
const handleBackClick = () => {
|
const handleBackClick = () => {
|
||||||
history.push(`/${siteId}/dashboard`);
|
history.push(`/${siteId}/dashboard`);
|
||||||
};
|
};
|
||||||
|
if (compact) {
|
||||||
|
return (
|
||||||
|
<Button onClick={handleBackClick} type={'text'} icon={<ArrowLeftOutlined />} />
|
||||||
|
)
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<Button type="text" onClick={handleBackClick} icon={<LeftOutlined />} className="px-1 pe-2 me-2 gap-1">
|
<Button type="text" onClick={handleBackClick} icon={<LeftOutlined />} className="px-1 pe-2 me-2 gap-1">
|
||||||
Back
|
Back
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Icon, Input, Button } from 'UI';
|
import { Icon, Input } from 'UI';
|
||||||
import cn from 'classnames';
|
import cn from 'classnames';
|
||||||
import FilterList from 'Shared/Filters/FilterList';
|
import { FilterList } from 'Shared/Filters/FilterList';
|
||||||
import { observer } from 'mobx-react-lite';
|
import { observer } from 'mobx-react-lite';
|
||||||
import FilterSelection from 'Shared/Filters/FilterSelection';
|
|
||||||
import { Typography } from 'antd';
|
import { Typography } from 'antd';
|
||||||
import { BranchesOutlined } from '@ant-design/icons';
|
import { BranchesOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
|
|
@ -84,29 +83,16 @@ function ConditionSetComponent({
|
||||||
onRemoveFilter={onRemoveFilter}
|
onRemoveFilter={onRemoveFilter}
|
||||||
onChangeEventsOrder={onChangeEventsOrder}
|
onChangeEventsOrder={onChangeEventsOrder}
|
||||||
hideEventsOrder
|
hideEventsOrder
|
||||||
|
onAddFilter={onAddFilter}
|
||||||
excludeFilterKeys={excludeFilterKeys}
|
excludeFilterKeys={excludeFilterKeys}
|
||||||
readonly={readonly}
|
readonly={readonly}
|
||||||
isConditional={isConditional}
|
isConditional={isConditional}
|
||||||
|
borderless
|
||||||
/>
|
/>
|
||||||
{readonly && !conditions.filter?.filters?.length ? (
|
{readonly && !conditions.filter?.filters?.length ? (
|
||||||
<div className={'p-2'}>No conditions</div>
|
<div className={'p-2'}>No conditions</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{readonly ? null : (
|
|
||||||
<div className={'px-2'}>
|
|
||||||
<FilterSelection
|
|
||||||
isConditional={isConditional}
|
|
||||||
filter={undefined}
|
|
||||||
onFilterClick={onAddFilter}
|
|
||||||
excludeFilterKeys={excludeFilterKeys}
|
|
||||||
isMobile={isMobile}
|
|
||||||
>
|
|
||||||
<Button variant="text-primary" icon="plus">
|
|
||||||
Add Condition
|
|
||||||
</Button>
|
|
||||||
</FilterSelection>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className={'px-4 py-2 flex items-center gap-2 border-t'}>
|
<div className={'px-4 py-2 flex items-center gap-2 border-t'}>
|
||||||
<span>{bottomLine1}</span>
|
<span>{bottomLine1}</span>
|
||||||
|
|
|
||||||
|
|
@ -17,12 +17,26 @@ import { DateTime, Interval } from 'luxon';
|
||||||
import styles from './dateRangePopup.module.css';
|
import styles from './dateRangePopup.module.css';
|
||||||
|
|
||||||
function DateRangePopup(props: any) {
|
function DateRangePopup(props: any) {
|
||||||
const [range, setRange] = React.useState(props.selectedDateRange || Interval.fromDateTimes(DateTime.now(), DateTime.now()));
|
const [range, setRange] = React.useState(
|
||||||
|
props.selectedDateRange ||
|
||||||
|
Interval.fromDateTimes(DateTime.now(), DateTime.now())
|
||||||
|
);
|
||||||
const [value, setValue] = React.useState<string | null>(null);
|
const [value, setValue] = React.useState<string | null>(null);
|
||||||
|
|
||||||
const selectCustomRange = (range) => {
|
const selectCustomRange = (range) => {
|
||||||
const updatedRange = Interval.fromDateTimes(DateTime.fromJSDate(range[0]), DateTime.fromJSDate(range[1]));
|
let newRange;
|
||||||
setRange(updatedRange);
|
if (props.singleDay) {
|
||||||
|
newRange = Interval.fromDateTimes(
|
||||||
|
DateTime.fromJSDate(range),
|
||||||
|
DateTime.fromJSDate(range)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
newRange = Interval.fromDateTimes(
|
||||||
|
DateTime.fromJSDate(range[0]),
|
||||||
|
DateTime.fromJSDate(range[1])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
setRange(newRange);
|
||||||
setValue(CUSTOM_RANGE);
|
setValue(CUSTOM_RANGE);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -53,8 +67,12 @@ function DateRangePopup(props: any) {
|
||||||
};
|
};
|
||||||
|
|
||||||
const { onCancel } = props;
|
const { onCancel } = props;
|
||||||
const isUSLocale = navigator.language === 'en-US' || navigator.language.startsWith('en-US');
|
const isUSLocale =
|
||||||
const rangeForDisplay = [range.start!.startOf('day').ts, range.end!.startOf('day').ts]
|
navigator.language === 'en-US' || navigator.language.startsWith('en-US');
|
||||||
|
|
||||||
|
const rangeForDisplay = props.singleDay
|
||||||
|
? range.start.ts
|
||||||
|
: [range.start!.startOf('day').ts, range.end!.startOf('day').ts];
|
||||||
return (
|
return (
|
||||||
<div className={styles.wrapper}>
|
<div className={styles.wrapper}>
|
||||||
<div className={`${styles.body} h-fit`}>
|
<div className={`${styles.body} h-fit`}>
|
||||||
|
|
@ -84,15 +102,24 @@ function DateRangePopup(props: any) {
|
||||||
isOpen
|
isOpen
|
||||||
maxDate={new Date()}
|
maxDate={new Date()}
|
||||||
value={rangeForDisplay}
|
value={rangeForDisplay}
|
||||||
|
calendarProps={{
|
||||||
|
tileDisabled: props.isTileDisabled,
|
||||||
|
selectRange: props.singleDay ? false : true,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between py-2 px-3">
|
<div className="flex items-center justify-between py-2 px-3">
|
||||||
|
{props.singleDay ? (
|
||||||
|
<div>
|
||||||
|
Compare from {range.start.toFormat('MMM dd, yyyy')}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<label>From: </label>
|
<label>From: </label>
|
||||||
<span>{range.start.toFormat(isUSLocale ? "MM/dd" : "dd/MM")} </span>
|
<span>{range.start.toFormat(isUSLocale ? 'MM/dd' : 'dd/MM')} </span>
|
||||||
<TimePicker
|
<TimePicker
|
||||||
format={isUSLocale ? 'hh:mm a' : "HH:mm"}
|
format={isUSLocale ? 'hh:mm a' : 'HH:mm'}
|
||||||
value={range.start}
|
value={range.start}
|
||||||
onChange={setRangeTimeStart}
|
onChange={setRangeTimeStart}
|
||||||
needConfirm={false}
|
needConfirm={false}
|
||||||
|
|
@ -100,9 +127,9 @@ function DateRangePopup(props: any) {
|
||||||
style={{ width: isUSLocale ? 102 : 76 }}
|
style={{ width: isUSLocale ? 102 : 76 }}
|
||||||
/>
|
/>
|
||||||
<label>To: </label>
|
<label>To: </label>
|
||||||
<span>{range.end.toFormat(isUSLocale ? "MM/dd" : "dd/MM")} </span>
|
<span>{range.end.toFormat(isUSLocale ? 'MM/dd' : 'dd/MM')} </span>
|
||||||
<TimePicker
|
<TimePicker
|
||||||
format={isUSLocale ? 'hh:mm a' : "HH:mm"}
|
format={isUSLocale ? 'hh:mm a' : 'HH:mm'}
|
||||||
value={range.end}
|
value={range.end}
|
||||||
onChange={setRangeTimeEnd}
|
onChange={setRangeTimeEnd}
|
||||||
needConfirm={false}
|
needConfirm={false}
|
||||||
|
|
@ -110,15 +137,16 @@ function DateRangePopup(props: any) {
|
||||||
style={{ width: isUSLocale ? 102 : 76 }}
|
style={{ width: isUSLocale ? 102 : 76 }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<Button onClick={onCancel}>{"Cancel"}</Button>
|
<Button onClick={onCancel}>{'Cancel'}</Button>
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
className="ml-2"
|
className="ml-2"
|
||||||
onClick={onApply}
|
onClick={onApply}
|
||||||
disabled={!range}
|
disabled={!range}
|
||||||
>
|
>
|
||||||
{"Apply"}
|
{'Apply'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
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 React, {
|
||||||
import { Icon } from 'UI';
|
useState,
|
||||||
import APIClient from 'App/api_client';
|
useEffect,
|
||||||
|
useCallback,
|
||||||
|
useRef,
|
||||||
|
} from 'react';
|
||||||
import { debounce } from 'App/utils';
|
import { debounce } from 'App/utils';
|
||||||
import stl from './FilterAutoComplete.module.css';
|
|
||||||
import colors from 'App/theme/colors';
|
|
||||||
import Select from 'react-select';
|
|
||||||
import cn from 'classnames';
|
|
||||||
import { useStore } from 'App/mstore';
|
import { useStore } from 'App/mstore';
|
||||||
import { observer } from 'mobx-react-lite';
|
import { observer } from 'mobx-react-lite';
|
||||||
import { searchService } from 'App/services';
|
import { searchService } from 'App/services';
|
||||||
|
import { AutocompleteModal, AutoCompleteContainer } from './AutocompleteModal';
|
||||||
const dropdownStyles = {
|
|
||||||
option: (provided: any, state: any) => ({
|
|
||||||
...provided,
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
width: '100%',
|
|
||||||
minWidth: 150,
|
|
||||||
transition: 'all 0.3s',
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
backgroundColor: state.isFocused ? colors['active-blue'] : 'transparent',
|
|
||||||
color: state.isFocused ? colors.teal : 'black',
|
|
||||||
fontSize: '14px',
|
|
||||||
'&:hover': {
|
|
||||||
transition: 'all 0.2s',
|
|
||||||
backgroundColor: colors['active-blue']
|
|
||||||
},
|
|
||||||
'&:focus': {
|
|
||||||
transition: 'all 0.2s',
|
|
||||||
backgroundColor: colors['active-blue']
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
control: (provided: any) => {
|
|
||||||
const obj = {
|
|
||||||
...provided,
|
|
||||||
border: 'solid thin transparent !important',
|
|
||||||
backgroundColor: 'transparent',
|
|
||||||
cursor: 'pointer',
|
|
||||||
height: '26px',
|
|
||||||
minHeight: '26px',
|
|
||||||
borderRadius: '.5rem',
|
|
||||||
boxShadow: 'none !important'
|
|
||||||
};
|
|
||||||
return obj;
|
|
||||||
},
|
|
||||||
valueContainer: (provided: any) => ({
|
|
||||||
...provided,
|
|
||||||
// paddingRight: '0px',
|
|
||||||
width: 'fit-content',
|
|
||||||
alignItems: 'center',
|
|
||||||
height: '26px',
|
|
||||||
padding: '0 3px'
|
|
||||||
}),
|
|
||||||
indicatorsContainer: (provided: any) => ({
|
|
||||||
...provided,
|
|
||||||
padding: '0px',
|
|
||||||
height: '26px'
|
|
||||||
}),
|
|
||||||
menu: (provided: any, state: any) => ({
|
|
||||||
...provided,
|
|
||||||
top: 0,
|
|
||||||
borderRadius: '3px',
|
|
||||||
border: `1px solid ${colors['gray-light']}`,
|
|
||||||
backgroundColor: '#fff',
|
|
||||||
boxShadow: '1px 1px 1px rgba(0, 0, 0, 0.1)',
|
|
||||||
position: 'absolute',
|
|
||||||
width: 'unset',
|
|
||||||
maxWidth: '300px',
|
|
||||||
overflow: 'hidden',
|
|
||||||
zIndex: 100
|
|
||||||
}),
|
|
||||||
menuList: (provided: any, state: any) => ({
|
|
||||||
...provided,
|
|
||||||
padding: 0
|
|
||||||
}),
|
|
||||||
noOptionsMessage: (provided: any) => ({
|
|
||||||
...provided,
|
|
||||||
whiteSpace: 'nowrap !important'
|
|
||||||
// minWidth: 'fit-content',
|
|
||||||
}),
|
|
||||||
container: (provided: any) => ({
|
|
||||||
...provided,
|
|
||||||
top: '18px',
|
|
||||||
position: 'absolute'
|
|
||||||
}),
|
|
||||||
input: (provided: any) => ({
|
|
||||||
...provided,
|
|
||||||
height: '22px',
|
|
||||||
'& input:focus': {
|
|
||||||
border: 'none !important'
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
singleValue: (provided: any, state: { isDisabled: any }) => {
|
|
||||||
const opacity = state.isDisabled ? 0.5 : 1;
|
|
||||||
const transition = 'opacity 300ms';
|
|
||||||
|
|
||||||
return {
|
|
||||||
...provided,
|
|
||||||
opacity,
|
|
||||||
transition,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
height: '20px'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
type FilterParam = { [key: string]: any };
|
type FilterParam = { [key: string]: any };
|
||||||
|
|
||||||
function processKey(input: FilterParam): FilterParam {
|
function processKey(input: FilterParam): FilterParam {
|
||||||
const result: FilterParam = {};
|
const result: FilterParam = {};
|
||||||
for (const key in input) {
|
for (const key in input) {
|
||||||
if (input.type === 'metadata' && typeof input[key] === 'string' && input[key].startsWith('_')) {
|
if (
|
||||||
|
input.type === 'metadata' &&
|
||||||
|
typeof input[key] === 'string' &&
|
||||||
|
input[key].startsWith('_')
|
||||||
|
) {
|
||||||
result[key] = input[key].substring(1);
|
result[key] = input[key].substring(1);
|
||||||
} else {
|
} else {
|
||||||
result[key] = input[key];
|
result[key] = input[key];
|
||||||
|
|
@ -123,86 +31,77 @@ function processKey(input: FilterParam): FilterParam {
|
||||||
interface Props {
|
interface Props {
|
||||||
showOrButton?: boolean;
|
showOrButton?: boolean;
|
||||||
showCloseButton?: boolean;
|
showCloseButton?: boolean;
|
||||||
onRemoveValue?: () => void;
|
onRemoveValue?: (ind: number) => void;
|
||||||
onAddValue?: () => void;
|
onAddValue?: (ind: number) => void;
|
||||||
endpoint?: string;
|
endpoint?: string;
|
||||||
method?: string;
|
method?: string;
|
||||||
params?: any;
|
params?: any;
|
||||||
headerText?: string;
|
headerText?: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
onSelect: (e: any, item: any) => void;
|
onSelect: (e: any, item: any, index: number) => void;
|
||||||
value: any;
|
value: any;
|
||||||
icon?: string;
|
icon?: string;
|
||||||
hideOrText?: boolean;
|
hideOrText?: boolean;
|
||||||
|
onApplyValues: (values: string[]) => void;
|
||||||
|
modalProps?: Record<string, any>
|
||||||
}
|
}
|
||||||
|
|
||||||
const FilterAutoComplete: React.FC<Props> = ({
|
const FilterAutoComplete = observer(
|
||||||
showCloseButton = false,
|
({
|
||||||
placeholder = 'Type to search',
|
|
||||||
method = 'GET',
|
|
||||||
showOrButton = false,
|
|
||||||
endpoint = '',
|
|
||||||
params = {},
|
params = {},
|
||||||
value = '',
|
onClose,
|
||||||
hideOrText = false,
|
onApply,
|
||||||
onSelect,
|
values,
|
||||||
onRemoveValue,
|
placeholder,
|
||||||
onAddValue
|
}: { params: any, values: string[], onClose: () => void, onApply: (values: string[]) => void, placeholder?: string }) => {
|
||||||
}: Props) => {
|
const [options, setOptions] = useState<{ value: string; label: string }[]>(
|
||||||
const [loading, setLoading] = useState(false);
|
[]
|
||||||
const [options, setOptions] = useState<{ value: string; label: string }[]>([]);
|
);
|
||||||
const [query, setQuery] = useState(value);
|
|
||||||
const [menuIsOpen, setMenuIsOpen] = useState(false);
|
|
||||||
const [initialFocus, setInitialFocus] = useState(false);
|
const [initialFocus, setInitialFocus] = useState(false);
|
||||||
const [previousQuery, setPreviousQuery] = useState(value);
|
const [loading, setLoading] = useState(false);
|
||||||
const selectRef = useRef<any>(null);
|
|
||||||
const inputRef = useRef<any>(null);
|
|
||||||
const { filterStore } = useStore();
|
const { filterStore } = useStore();
|
||||||
const _params = processKey(params);
|
const _params = processKey(params);
|
||||||
const filterKey = `${_params.type}${_params.key || ''}`;
|
const filterKey = `${_params.type}${_params.key || ''}`;
|
||||||
const topValues = filterStore.topValues[filterKey] || [];
|
const topValues = filterStore.topValues[filterKey] || [];
|
||||||
const [topValuesLoading, setTopValuesLoading] = useState(false);
|
|
||||||
|
|
||||||
const loadTopValues = () => {
|
const loadTopValues = async () => {
|
||||||
setTopValuesLoading(true);
|
setLoading(true)
|
||||||
filterStore.fetchTopValues(_params.type, _params.key).finally(() => {
|
await filterStore.fetchTopValues(_params.type, _params.key);
|
||||||
setTopValuesLoading(false);
|
setLoading(false)
|
||||||
setLoading(false);
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (topValues.length > 0) {
|
if (topValues.length > 0) {
|
||||||
const mappedValues = topValues.map((i) => ({ value: i.value, label: i.value }));
|
const mappedValues = topValues.map((i) => ({
|
||||||
|
value: i.value,
|
||||||
|
label: i.value,
|
||||||
|
}));
|
||||||
setOptions(mappedValues);
|
setOptions(mappedValues);
|
||||||
if (!query.length && initialFocus) {
|
|
||||||
setMenuIsOpen(true);
|
|
||||||
}
|
}
|
||||||
}
|
}, [topValues, initialFocus]);
|
||||||
}, [topValues, initialFocus, query.length]);
|
|
||||||
|
|
||||||
useEffect(loadTopValues, [_params.type]);
|
useEffect(() => { void loadTopValues() }, [_params.type]);
|
||||||
|
|
||||||
useEffect(() => {
|
const loadOptions = async (
|
||||||
setQuery(value);
|
inputValue: string,
|
||||||
}, [value]);
|
) => {
|
||||||
|
|
||||||
const loadOptions = async (inputValue: string, callback: (options: { value: string; label: string }[]) => void) => {
|
|
||||||
if (!inputValue.length) {
|
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);
|
setOptions(mappedValues);
|
||||||
callback(mappedValues);
|
|
||||||
setLoading(false);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
// const response = await new APIClient()[method.toLowerCase()](endpoint, { ..._params, q: inputValue });
|
const data = await searchService.fetchAutoCompleteValues({
|
||||||
const data = await searchService.fetchAutoCompleteValues({ ..._params, q: inputValue })
|
..._params,
|
||||||
// const data = await response.json();
|
q: inputValue,
|
||||||
const _options = data.map((i: any) => ({ value: i.value, label: i.value })) || [];
|
});
|
||||||
|
const _options =
|
||||||
|
data.map((i: any) => ({ value: i.value, label: i.value })) || [];
|
||||||
setOptions(_options);
|
setOptions(_options);
|
||||||
callback(_options);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error(e);
|
throw new Error(e);
|
||||||
} finally {
|
} 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) => {
|
const handleInputChange = (newValue: string) => {
|
||||||
setLoading(true);
|
|
||||||
setInitialFocus(true);
|
setInitialFocus(true);
|
||||||
setQuery(newValue);
|
debouncedLoadOptions(newValue);
|
||||||
debouncedLoadOptions(newValue, () => {
|
|
||||||
selectRef.current?.focus();
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleChange = (item: { value: string }) => {
|
|
||||||
setMenuIsOpen(false);
|
|
||||||
setQuery(item.value);
|
|
||||||
onSelect(null, item.value);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFocus = () => {
|
const handleFocus = () => {
|
||||||
setInitialFocus(true);
|
setInitialFocus(true);
|
||||||
if (!query.length) {
|
|
||||||
setLoading(topValuesLoading);
|
|
||||||
setMenuIsOpen(!topValuesLoading && topValues.length > 0);
|
|
||||||
setOptions(topValues.map((i) => ({ value: i.value, label: i.value })));
|
setOptions(topValues.map((i) => ({ value: i.value, label: i.value })));
|
||||||
} else {
|
|
||||||
setMenuIsOpen(true);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBlur = () => {
|
return <AutocompleteModal
|
||||||
setMenuIsOpen(false);
|
values={values}
|
||||||
setInitialFocus(false);
|
onClose={onClose}
|
||||||
if (query !== previousQuery) {
|
onApply={onApply}
|
||||||
onSelect(null, query);
|
handleFocus={handleFocus}
|
||||||
}
|
loadOptions={handleInputChange}
|
||||||
setPreviousQuery(query);
|
options={options}
|
||||||
};
|
isLoading={loading}
|
||||||
|
|
||||||
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}
|
|
||||||
placeholder={placeholder}
|
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 React, { useState, useEffect } from 'react';
|
||||||
import { Icon } from 'UI';
|
import { Icon } from 'UI';
|
||||||
import stl from './FilterAutoCompleteLocal.module.css';
|
import stl from './FilterAutoCompleteLocal.module.css';
|
||||||
|
import { Input } from 'antd';
|
||||||
|
import { AutocompleteModal, AutoCompleteContainer } from 'Shared/Filters/FilterAutoComplete/AutocompleteModal';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
showOrButton?: boolean;
|
showOrButton?: boolean;
|
||||||
showCloseButton?: boolean;
|
showCloseButton?: boolean;
|
||||||
onRemoveValue?: () => void;
|
onRemoveValue?: (index: number) => void;
|
||||||
onAddValue?: () => void;
|
onAddValue?: (index: number) => void;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
onSelect: (e, item) => void;
|
onSelect: (e: any, item: Record<string, any>, index: number) => void;
|
||||||
value: any;
|
value: any;
|
||||||
icon?: string;
|
icon?: string;
|
||||||
type?: string;
|
type?: string;
|
||||||
isMultilple?: boolean;
|
isMultiple?: boolean;
|
||||||
allowDecimals?: boolean;
|
allowDecimals?: boolean;
|
||||||
|
modalProps?: Record<string, any>;
|
||||||
|
onApplyValues: (values: string[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function FilterAutoCompleteLocal(props: Props) {
|
function FilterAutoCompleteLocal(props: { params: any, values: string[], onClose: () => void, onApply: (values: string[]) => void, placeholder?: string }) {
|
||||||
const {
|
const {
|
||||||
showCloseButton = false,
|
params = {},
|
||||||
|
onClose,
|
||||||
|
onApply,
|
||||||
placeholder = 'Enter',
|
placeholder = 'Enter',
|
||||||
showOrButton = false,
|
values,
|
||||||
onRemoveValue = () => null,
|
|
||||||
onAddValue = () => null,
|
|
||||||
value = '',
|
|
||||||
icon = null,
|
|
||||||
type = "text",
|
|
||||||
isMultilple = true,
|
|
||||||
allowDecimals = true,
|
|
||||||
} = props;
|
} = props;
|
||||||
const [showModal, setShowModal] = useState(true)
|
const [options, setOptions] = useState<{ value: string; label: string }[]>(
|
||||||
const [query, setQuery] = useState(value);
|
values.filter(val => val.length).map((value) => ({ value, label: value }))
|
||||||
|
|
||||||
const onInputChange = (e) => {
|
|
||||||
if(allowDecimals) {
|
|
||||||
const value = e.target.value;
|
|
||||||
setQuery(value);
|
|
||||||
props.onSelect(null, value);
|
|
||||||
} else {
|
|
||||||
const value = e.target.value.replace(/[^\d]/, "");
|
|
||||||
if (+value !== 0) {
|
|
||||||
setQuery(value);
|
|
||||||
props.onSelect(null, value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setQuery(value);
|
|
||||||
}, [value])
|
|
||||||
|
|
||||||
const onBlur = (e) => {
|
|
||||||
setTimeout(() => { setShowModal(false) }, 200)
|
|
||||||
props.onSelect(e, { value: query })
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleKeyDown = (e) => {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
props.onSelect(e, { value: query })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative flex items-center">
|
|
||||||
<div className={stl.wrapper}>
|
|
||||||
<input
|
|
||||||
name="query"
|
|
||||||
onInput={ onInputChange }
|
|
||||||
// onBlur={ onBlur }
|
|
||||||
onFocus={ () => setShowModal(true)}
|
|
||||||
value={ query }
|
|
||||||
autoFocus={ true }
|
|
||||||
type={ type }
|
|
||||||
placeholder={ placeholder }
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className={stl.right}
|
|
||||||
>
|
|
||||||
{ showCloseButton && <div onClick={onRemoveValue}><Icon name="close" size="12" /></div> }
|
|
||||||
{ showOrButton && <div onClick={onAddValue} className="color-teal"><span className="px-1">or</span></div> }
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{ !showOrButton && isMultilple && <div className="ml-3">or</div> }
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const onApplyValues = (values: string[]) => {
|
||||||
|
setOptions(values.map((value) => ({ value, label: value })));
|
||||||
|
onApply(values);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default FilterAutoCompleteLocal;
|
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
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
|
||||||
|
function FilterLocalController(props: Props) {
|
||||||
|
return <AutoCompleteContainer {...props} modalRenderer={FilterAutoCompleteLocal} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FilterLocalController;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import styles from './FilterDuration.module.css';
|
import styles from './FilterDuration.module.css';
|
||||||
import { Input } from 'UI'
|
import { Input } from 'antd'
|
||||||
|
|
||||||
const fromMs = value => value ? `${ value / 1000 / 60 }` : ''
|
const fromMs = value => value ? `${ value / 1000 / 60 }` : ''
|
||||||
const toMs = value => value !== '' ? value * 1000 * 60 : null
|
const toMs = value => value !== '' ? value * 1000 * 60 : null
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import FilterSource from '../FilterSource';
|
||||||
import { FilterKey, FilterType } from 'App/types/filter/filterType';
|
import { FilterKey, FilterType } from 'App/types/filter/filterType';
|
||||||
import SubFilterItem from '../SubFilterItem';
|
import SubFilterItem from '../SubFilterItem';
|
||||||
import { CircleMinus } from 'lucide-react';
|
import { CircleMinus } from 'lucide-react';
|
||||||
|
import cn from 'classnames'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
filterIndex?: number;
|
filterIndex?: number;
|
||||||
|
|
@ -17,6 +18,7 @@ interface Props {
|
||||||
saveRequestPayloads?: boolean;
|
saveRequestPayloads?: boolean;
|
||||||
disableDelete?: boolean;
|
disableDelete?: boolean;
|
||||||
excludeFilterKeys?: Array<string>;
|
excludeFilterKeys?: Array<string>;
|
||||||
|
excludeCategory?: Array<string>;
|
||||||
allowedFilterKeys?: Array<string>;
|
allowedFilterKeys?: Array<string>;
|
||||||
readonly?: boolean;
|
readonly?: boolean;
|
||||||
hideIndex?: boolean;
|
hideIndex?: boolean;
|
||||||
|
|
@ -34,6 +36,7 @@ function FilterItem(props: Props) {
|
||||||
hideDelete = false,
|
hideDelete = false,
|
||||||
allowedFilterKeys = [],
|
allowedFilterKeys = [],
|
||||||
excludeFilterKeys = [],
|
excludeFilterKeys = [],
|
||||||
|
excludeCategory = [],
|
||||||
isConditional,
|
isConditional,
|
||||||
hideIndex = false,
|
hideIndex = false,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
@ -42,7 +45,7 @@ function FilterItem(props: Props) {
|
||||||
const replaceFilter = (filter: any) => {
|
const replaceFilter = (filter: any) => {
|
||||||
props.onUpdate({
|
props.onUpdate({
|
||||||
...filter,
|
...filter,
|
||||||
value: [''],
|
value: filter.value,
|
||||||
filters: filter.filters ? filter.filters.map((i: any) => ({...i, value: ['']})) : [],
|
filters: filter.filters ? filter.filters.map((i: any) => ({...i, value: ['']})) : [],
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
@ -67,30 +70,34 @@ function FilterItem(props: Props) {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isReversed = filter.key === FilterKey.TAGGED_ELEMENT
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center w-full">
|
<div className="flex items-center w-full">
|
||||||
<div className="flex items-start w-full">
|
<div className="flex items-center w-full flex-wrap">
|
||||||
{!isFilter && !hideIndex && filterIndex >= 0 && (
|
{!isFilter && !hideIndex && filterIndex >= 0 && (
|
||||||
<div
|
<div
|
||||||
className="mt-1 flex-shrink-0 border w-6 h-6 text-xs flex items-center justify-center rounded-full bg-gray-light-shade mr-2">
|
className="flex-shrink-0 w-6 h-6 text-xs flex items-center justify-center rounded-full bg-gray-lighter mr-2">
|
||||||
<span>{filterIndex + 1}</span>
|
<span>{filterIndex + 1}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<FilterSelection
|
<FilterSelection
|
||||||
filter={filter}
|
filter={filter}
|
||||||
|
mode={props.isFilter ? 'filters' : 'events'}
|
||||||
onFilterClick={replaceFilter}
|
onFilterClick={replaceFilter}
|
||||||
allowedFilterKeys={allowedFilterKeys}
|
allowedFilterKeys={allowedFilterKeys}
|
||||||
excludeFilterKeys={excludeFilterKeys}
|
excludeFilterKeys={excludeFilterKeys}
|
||||||
|
excludeCategory={excludeCategory}
|
||||||
disabled={disableDelete || props.readonly}
|
disabled={disableDelete || props.readonly}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div className={cn('flex items-center flex-wrap', isReversed ? 'flex-row-reverse ml-2' : 'flex-row')}>
|
||||||
{/* Filter with Source */}
|
{/* Filter with Source */}
|
||||||
{filter.hasSource && (
|
{filter.hasSource && (
|
||||||
<>
|
<>
|
||||||
<FilterOperator
|
<FilterOperator
|
||||||
options={filter.sourceOperatorOptions}
|
options={filter.sourceOperatorOptions}
|
||||||
onChange={onSourceOperatorChange}
|
onChange={onSourceOperatorChange}
|
||||||
className="mx-2 flex-shrink-0"
|
className="mx-2 flex-shrink-0 btn-event-operator"
|
||||||
value={filter.sourceOperator}
|
value={filter.sourceOperator}
|
||||||
isDisabled={filter.operatorDisabled || props.readonly}
|
isDisabled={filter.operatorDisabled || props.readonly}
|
||||||
/>
|
/>
|
||||||
|
|
@ -104,7 +111,7 @@ function FilterItem(props: Props) {
|
||||||
<FilterOperator
|
<FilterOperator
|
||||||
options={filter.operatorOptions}
|
options={filter.operatorOptions}
|
||||||
onChange={onOperatorChange}
|
onChange={onOperatorChange}
|
||||||
className="mx-2 flex-shrink-0"
|
className="mx-2 flex-shrink-0 btn-sub-event-operator"
|
||||||
value={filter.operator}
|
value={filter.operator}
|
||||||
isDisabled={filter.operatorDisabled || props.readonly}
|
isDisabled={filter.operatorDisabled || props.readonly}
|
||||||
/>
|
/>
|
||||||
|
|
@ -112,7 +119,7 @@ function FilterItem(props: Props) {
|
||||||
<>
|
<>
|
||||||
{props.readonly ? (
|
{props.readonly ? (
|
||||||
<div
|
<div
|
||||||
className={'rounded bg-active-blue px-2 py-1 ml-2 whitespace-nowrap overflow-hidden text-clip'}
|
className={'rounded bg-active-blue px-2 py-1 ml-2 whitespace-nowrap overflow-hidden text-clip hover:border-neutral-400'}
|
||||||
>
|
>
|
||||||
{filter.value.map((val: string) => {
|
{filter.value.map((val: string) => {
|
||||||
return filter.options && filter.options.length
|
return filter.options && filter.options.length
|
||||||
|
|
@ -127,6 +134,7 @@ function FilterItem(props: Props) {
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* filters */}
|
{/* filters */}
|
||||||
{isSubFilter && (
|
{isSubFilter && (
|
||||||
|
|
@ -156,6 +164,7 @@ function FilterItem(props: Props) {
|
||||||
type="text"
|
type="text"
|
||||||
onClick={props.onRemoveFilter}
|
onClick={props.onRemoveFilter}
|
||||||
size="small"
|
size="small"
|
||||||
|
className='btn-remove-step mt-2'
|
||||||
>
|
>
|
||||||
<CircleMinus size={14} />
|
<CircleMinus size={14} />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
|
|
@ -1,55 +1,57 @@
|
||||||
import {observer} from "mobx-react-lite";
|
import React from 'react';
|
||||||
import {Tooltip} from "UI";
|
import { observer } from 'mobx-react-lite';
|
||||||
import {Segmented} from "antd";
|
import { Dropdown, Button, Tooltip } from 'antd';
|
||||||
import React from "react";
|
|
||||||
|
|
||||||
const EventsOrder = observer((props: {
|
const EventsOrder = observer(
|
||||||
onChange: (e: any, v: any) => void,
|
(props: { onChange: (e: any, v: any) => void; filter: any }) => {
|
||||||
filter: any,
|
|
||||||
}) => {
|
|
||||||
const { filter, onChange } = props;
|
const { filter, onChange } = props;
|
||||||
const eventsOrderSupport = filter.eventsOrderSupport;
|
const eventsOrderSupport = filter.eventsOrderSupport;
|
||||||
const options = [
|
|
||||||
|
const menuItems = [
|
||||||
{
|
{
|
||||||
name: 'eventsOrder',
|
key: 'then',
|
||||||
label: 'THEN',
|
label: 'THEN',
|
||||||
value: 'then',
|
|
||||||
disabled: eventsOrderSupport && !eventsOrderSupport.includes('then'),
|
disabled: eventsOrderSupport && !eventsOrderSupport.includes('then'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'eventsOrder',
|
key: 'and',
|
||||||
label: 'AND',
|
label: 'AND',
|
||||||
value: 'and',
|
|
||||||
disabled: eventsOrderSupport && !eventsOrderSupport.includes('and'),
|
disabled: eventsOrderSupport && !eventsOrderSupport.includes('and'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'eventsOrder',
|
key: 'or',
|
||||||
label: 'OR',
|
label: 'OR',
|
||||||
value: 'or',
|
|
||||||
disabled: eventsOrderSupport && !eventsOrderSupport.includes('or'),
|
disabled: eventsOrderSupport && !eventsOrderSupport.includes('or'),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
const onClick = ({ key }: any) => {
|
||||||
|
onChange(null, { name: 'eventsOrder', value: key, key });
|
||||||
|
};
|
||||||
|
|
||||||
return <div className="flex items-center gap-2">
|
const selected = menuItems.find(
|
||||||
<div
|
(item) => item.key === filter.eventsOrder
|
||||||
className="color-gray-medium text-sm"
|
)?.label;
|
||||||
style={{textDecoration: "underline dotted"}}
|
return (
|
||||||
>
|
<div className="flex items-center gap-2">
|
||||||
<Tooltip
|
<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>
|
</Tooltip>
|
||||||
</div>
|
|
||||||
|
|
||||||
<Segmented
|
<Dropdown
|
||||||
size={"small"}
|
menu={{ items: menuItems, onClick }}
|
||||||
className="text-sm"
|
trigger={['click']}
|
||||||
onChange={(v) => onChange(null, options.find((i) => i.value === v))}
|
placement="bottomRight"
|
||||||
value={filter.eventsOrder}
|
className="text-sm rounded-lg px-1 py-0.5 btn-events-order "
|
||||||
options={options}
|
data-event="btn-events-order"
|
||||||
/>
|
>
|
||||||
</div>;
|
<Button size={'small'} type='text'>{selected || 'Select'} </Button>
|
||||||
});
|
</Dropdown>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
export default EventsOrder;
|
export default EventsOrder;
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,14 @@
|
||||||
import {Space} from 'antd';
|
import { GripVertical, Plus, Filter } from 'lucide-react';
|
||||||
import {List} from 'immutable';
|
|
||||||
import {GripHorizontal} from 'lucide-react';
|
|
||||||
import { observer } from 'mobx-react-lite';
|
import { observer } from 'mobx-react-lite';
|
||||||
import React, { useEffect } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
|
import { Button } from 'antd';
|
||||||
|
import cn from 'classnames';
|
||||||
import FilterItem from '../FilterItem';
|
import FilterItem from '../FilterItem';
|
||||||
import EventsOrder from "Shared/Filters/FilterList/EventsOrder";
|
import EventsOrder from 'Shared/Filters/FilterList/EventsOrder';
|
||||||
|
import FilterSelection from '../FilterSelection/FilterSelection';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
filter?: any; // event/filter
|
filter?: any;
|
||||||
onUpdateFilter: (filterIndex: any, filter: any) => void;
|
onUpdateFilter: (filterIndex: any, filter: any) => void;
|
||||||
onFilterMove?: (filters: any) => void;
|
onFilterMove?: (filters: any) => void;
|
||||||
onRemoveFilter: (filterIndex: any) => void;
|
onRemoveFilter: (filterIndex: any) => void;
|
||||||
|
|
@ -19,26 +19,112 @@ interface Props {
|
||||||
supportsEmpty?: boolean;
|
supportsEmpty?: boolean;
|
||||||
readonly?: boolean;
|
readonly?: boolean;
|
||||||
excludeFilterKeys?: Array<string>;
|
excludeFilterKeys?: Array<string>;
|
||||||
|
excludeCategory?: string[];
|
||||||
isConditional?: boolean;
|
isConditional?: boolean;
|
||||||
actions?: React.ReactNode[];
|
actions?: React.ReactNode[];
|
||||||
|
onAddFilter: (filter: any) => void;
|
||||||
|
mergeDown?: boolean;
|
||||||
|
mergeUp?: boolean;
|
||||||
|
borderless?: boolean;
|
||||||
|
cannotAdd?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function FilterList(props: Props) {
|
export const FilterList = observer((props: Props) => {
|
||||||
const {
|
const {
|
||||||
observeChanges = () => {
|
observeChanges = () => {},
|
||||||
},
|
filter,
|
||||||
|
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,
|
filter,
|
||||||
hideEventsOrder = false,
|
hideEventsOrder = false,
|
||||||
saveRequestPayloads,
|
saveRequestPayloads,
|
||||||
supportsEmpty = true,
|
supportsEmpty = true,
|
||||||
excludeFilterKeys = [],
|
excludeFilterKeys = [],
|
||||||
isConditional,
|
isConditional,
|
||||||
actions = []
|
actions = [],
|
||||||
|
onAddFilter,
|
||||||
|
cannotAdd,
|
||||||
|
excludeCategory,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
const filters = filter.filters;
|
const filters = filter.filters;
|
||||||
const hasEvents = filters.filter((i: any) => i.isEvent).length > 0;
|
const hasEvents = filters.filter((i: any) => i.isEvent).length > 0;
|
||||||
const hasFilters = filters.filter((i: any) => !i.isEvent).length > 0;
|
|
||||||
|
|
||||||
let rowIndex = 0;
|
let rowIndex = 0;
|
||||||
const cannotDeleteFilter = hasEvents && !supportsEmpty;
|
const cannotDeleteFilter = hasEvents && !supportsEmpty;
|
||||||
|
|
@ -75,24 +161,23 @@ function FilterList(props: Props) {
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDragStart = React.useCallback((
|
const handleDragStart = React.useCallback(
|
||||||
ev: Record<string, any>,
|
(ev: Record<string, any>, index: number, elId: string) => {
|
||||||
index: number,
|
ev.dataTransfer.setData('text/plain', index.toString());
|
||||||
elId: string
|
|
||||||
) => {
|
|
||||||
ev.dataTransfer.setData("text/plain", index.toString());
|
|
||||||
setDraggedItem(index);
|
setDraggedItem(index);
|
||||||
const el = document.getElementById(elId);
|
const el = document.getElementById(elId);
|
||||||
if (el) {
|
if (el) {
|
||||||
ev.dataTransfer.setDragImage(el, 0, 0);
|
ev.dataTransfer.setDragImage(el, 0, 0);
|
||||||
}
|
}
|
||||||
}, [])
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
const handleDrop = React.useCallback(
|
const handleDrop = React.useCallback(
|
||||||
(event: Record<string, any>) => {
|
(event: Record<string, any>) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (draggedInd === null) return;
|
if (draggedInd === null) return;
|
||||||
const newItems = filters.toArray();
|
const newItems = filters;
|
||||||
const newPosition = calculateNewPosition(
|
const newPosition = calculateNewPosition(
|
||||||
draggedInd,
|
draggedInd,
|
||||||
hoveredItem.i,
|
hoveredItem.i,
|
||||||
|
|
@ -102,53 +187,88 @@ function FilterList(props: Props) {
|
||||||
const reorderedItem = newItems.splice(draggedInd, 1)[0];
|
const reorderedItem = newItems.splice(draggedInd, 1)[0];
|
||||||
newItems.splice(newPosition, 0, reorderedItem);
|
newItems.splice(newPosition, 0, reorderedItem);
|
||||||
|
|
||||||
props.onFilterMove?.(List(newItems));
|
props.onFilterMove?.(newItems);
|
||||||
setHoveredItem({ i: null, position: null });
|
setHoveredItem({ i: null, position: null });
|
||||||
setDraggedItem(null);
|
setDraggedItem(null);
|
||||||
},
|
},
|
||||||
[draggedInd, hoveredItem, filters, props.onFilterMove]
|
[draggedInd, hoveredItem, filters, props.onFilterMove]
|
||||||
);
|
);
|
||||||
|
|
||||||
const eventsNum = filters.filter((i: any) => i.isEvent).size
|
const eventsNum = filters.filter((i: any) => i.isEvent).length;
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col">
|
<div
|
||||||
{hasEvents && (
|
className={
|
||||||
<>
|
'border-b border-b-gray-lighter pt-2 px-4 rounded-xl bg-white border border-gray-lighter'
|
||||||
<div className="flex items-center mb-2">
|
}
|
||||||
<div className="text-sm color-gray-medium mr-auto">
|
style={{
|
||||||
{filter.eventsHeader || 'EVENTS'}
|
borderBottomLeftRadius: props.mergeDown ? 0 : undefined,
|
||||||
</div>
|
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>
|
<div className={'ml-auto'}>
|
||||||
{!hideEventsOrder && <EventsOrder filter={filter}
|
{!hideEventsOrder && (
|
||||||
onChange={props.onChangeEventsOrder}/>}
|
<EventsOrder filter={filter} onChange={props.onChangeEventsOrder} />
|
||||||
{actions && actions.map((action, index) => (
|
)}
|
||||||
<div key={index}>{action}</div>
|
{actions &&
|
||||||
))}
|
actions.map((action, index) => <div key={index}>{action}</div>)}
|
||||||
</Space>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={'flex flex-col '}>
|
<div className={'flex flex-col '}>
|
||||||
{filters.map((filter: any, filterIndex: number) =>
|
{filters.map((filter: any, filterIndex: number) =>
|
||||||
filter.isEvent ? (
|
filter.isEvent ? (
|
||||||
<div
|
<div
|
||||||
|
className={cn(
|
||||||
|
'hover:bg-active-blue px-5 pe-3 gap-2 items-center flex',
|
||||||
|
{
|
||||||
|
'bg-[#f6f6f6]': hoveredItem.i === filterIndex,
|
||||||
|
}
|
||||||
|
)}
|
||||||
style={{
|
style={{
|
||||||
pointerEvents: 'unset',
|
pointerEvents: 'unset',
|
||||||
paddingTop:
|
paddingTop:
|
||||||
hoveredItem.i === filterIndex &&
|
hoveredItem.i === filterIndex && hoveredItem.position === 'top'
|
||||||
hoveredItem.position === 'top'
|
? ''
|
||||||
? '1.5rem'
|
: '',
|
||||||
: '0.5rem',
|
|
||||||
paddingBottom:
|
paddingBottom:
|
||||||
hoveredItem.i === filterIndex &&
|
hoveredItem.i === filterIndex && hoveredItem.position === 'bottom'
|
||||||
hoveredItem.position === 'bottom'
|
? ''
|
||||||
? '1.5rem'
|
: '',
|
||||||
: '0.5rem',
|
marginLeft: '-1rem',
|
||||||
marginLeft: '-1.25rem',
|
width: 'calc(100% + 2rem)',
|
||||||
width: 'calc(100% + 2.5rem)',
|
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}`}
|
id={`${filter.key}-${filterIndex}`}
|
||||||
onDragOver={(e) => handleDragOverEv(e, filterIndex)}
|
onDragOver={(e) => handleDragOverEv(e, filterIndex)}
|
||||||
onDrop={(e) => handleDrop(e)}
|
onDrop={(e) => handleDrop(e)}
|
||||||
|
|
@ -156,68 +276,40 @@ function FilterList(props: Props) {
|
||||||
>
|
>
|
||||||
{!!props.onFilterMove && eventsNum > 1 ? (
|
{!!props.onFilterMove && eventsNum > 1 ? (
|
||||||
<div
|
<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}
|
draggable={!!props.onFilterMove}
|
||||||
onDragStart={(e) =>
|
onDragStart={(e) =>
|
||||||
handleDragStart(
|
handleDragStart(e, filterIndex, `${filter.key}-${filterIndex}`)
|
||||||
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>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<FilterItem
|
<FilterItem
|
||||||
filterIndex={rowIndex++}
|
filterIndex={rowIndex++}
|
||||||
filter={filter}
|
filter={filter}
|
||||||
onUpdate={(filter) =>
|
onUpdate={(filter) => props.onUpdateFilter(filterIndex, filter)}
|
||||||
props.onUpdateFilter(filterIndex, filter)
|
|
||||||
}
|
|
||||||
onRemoveFilter={() => onRemoveFilter(filterIndex)}
|
onRemoveFilter={() => onRemoveFilter(filterIndex)}
|
||||||
saveRequestPayloads={saveRequestPayloads}
|
saveRequestPayloads={saveRequestPayloads}
|
||||||
disableDelete={cannotDeleteFilter}
|
disableDelete={cannotDeleteFilter}
|
||||||
excludeFilterKeys={excludeFilterKeys}
|
excludeFilterKeys={excludeFilterKeys}
|
||||||
readonly={props.readonly}
|
readonly={props.readonly}
|
||||||
isConditional={isConditional}
|
isConditional={isConditional}
|
||||||
|
excludeCategory={excludeCategory}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : null
|
) : null
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
export default observer(FilterList);
|
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
export { default } from './FilterList';
|
export { FilterList, EventsList } from './FilterList';
|
||||||
|
|
@ -2,8 +2,6 @@
|
||||||
border-radius: .5rem;
|
border-radius: .5rem;
|
||||||
border: solid thin $gray-light;
|
border: solid thin $gray-light;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
overflow: hidden;
|
|
||||||
overflow-y: auto;
|
|
||||||
box-shadow: 0 2px 2px 0 $gray-light;
|
box-shadow: 0 2px 2px 0 $gray-light;
|
||||||
}
|
}
|
||||||
.optionItem {
|
.optionItem {
|
||||||
|
|
|
||||||
|
|
@ -7,11 +7,14 @@ import {
|
||||||
CircleAlert,
|
CircleAlert,
|
||||||
Clock2,
|
Clock2,
|
||||||
Code,
|
Code,
|
||||||
ContactRound, CornerDownRight,
|
ContactRound,
|
||||||
|
CornerDownRight,
|
||||||
Cpu,
|
Cpu,
|
||||||
Earth,
|
Earth,
|
||||||
FileStack, Layers,
|
FileStack,
|
||||||
MapPin, Megaphone,
|
Layers,
|
||||||
|
MapPin,
|
||||||
|
Megaphone,
|
||||||
MemoryStick,
|
MemoryStick,
|
||||||
MonitorSmartphone,
|
MonitorSmartphone,
|
||||||
Navigation,
|
Navigation,
|
||||||
|
|
@ -25,63 +28,70 @@ import {
|
||||||
Timer,
|
Timer,
|
||||||
VenetianMask,
|
VenetianMask,
|
||||||
Workflow,
|
Workflow,
|
||||||
Flag
|
Flag,
|
||||||
|
ChevronRight,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Icon, Loader } from 'UI';
|
import { Icon, Loader } from 'UI';
|
||||||
import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG';
|
import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG';
|
||||||
|
import { Input } from 'antd';
|
||||||
|
|
||||||
import { FilterKey } from 'Types/filter/filterType';
|
import { FilterCategory, FilterKey, FilterType } from "Types/filter/filterType";
|
||||||
import stl from './FilterModal.module.css';
|
import stl from './FilterModal.module.css';
|
||||||
import { observer } from 'mobx-react-lite';
|
import { observer } from 'mobx-react-lite';
|
||||||
import { useStore } from 'App/mstore';
|
import { useStore } from 'App/mstore';
|
||||||
|
|
||||||
const IconMap = {
|
export const IconMap = {
|
||||||
[FilterKey.CLICK]: <Pointer size={18} />,
|
[FilterKey.CLICK]: <Pointer size={14}/>,
|
||||||
[FilterKey.LOCATION]: <Navigation size={18} />,
|
[FilterKey.LOCATION]: <Navigation size={14} />,
|
||||||
[FilterKey.INPUT]: <RectangleEllipsis size={18} />,
|
[FilterKey.INPUT]: <RectangleEllipsis size={14} />,
|
||||||
[FilterKey.CUSTOM]: <Code size={18} />,
|
[FilterKey.CUSTOM]: <Code size={14} />,
|
||||||
[FilterKey.FETCH]: <ArrowUpDown size={18} />,
|
[FilterKey.FETCH]: <ArrowUpDown size={14} />,
|
||||||
[FilterKey.GRAPHQL]: <Network size={18} />,
|
[FilterKey.GRAPHQL]: <Network size={14} />,
|
||||||
[FilterKey.STATEACTION]: <RectangleEllipsis size={18} />,
|
[FilterKey.STATEACTION]: <RectangleEllipsis size={14} />,
|
||||||
[FilterKey.ERROR]: <OctagonAlert size={18} />,
|
[FilterKey.ERROR]: <OctagonAlert size={14} />,
|
||||||
[FilterKey.ISSUE]: <CircleAlert size={18} />,
|
[FilterKey.ISSUE]: <CircleAlert size={14} />,
|
||||||
[FilterKey.FETCH_FAILED]: <Code size={18} />,
|
[FilterKey.FETCH_FAILED]: <Code size={14} />,
|
||||||
[FilterKey.DOM_COMPLETE]: <ArrowUpDown size={18} />,
|
[FilterKey.DOM_COMPLETE]: <ArrowUpDown size={14} />,
|
||||||
[FilterKey.LARGEST_CONTENTFUL_PAINT_TIME]: <Network size={18} />,
|
[FilterKey.LARGEST_CONTENTFUL_PAINT_TIME]: <Network size={14} />,
|
||||||
[FilterKey.TTFB]: <Timer size={18} />,
|
[FilterKey.TTFB]: <Timer size={14} />,
|
||||||
[FilterKey.AVG_CPU_LOAD]: <Cpu size={18} />,
|
[FilterKey.AVG_CPU_LOAD]: <Cpu size={14} />,
|
||||||
[FilterKey.AVG_MEMORY_USAGE]: <MemoryStick size={18} />,
|
[FilterKey.AVG_MEMORY_USAGE]: <MemoryStick size={14} />,
|
||||||
[FilterKey.USERID]: <SquareUser size={18} />,
|
[FilterKey.USERID]: <SquareUser size={14} />,
|
||||||
[FilterKey.USERANONYMOUSID]: <VenetianMask size={18} />,
|
[FilterKey.USERANONYMOUSID]: <VenetianMask size={14} />,
|
||||||
[FilterKey.USER_CITY]: <Pin size={18} />,
|
[FilterKey.USER_CITY]: <Pin size={14} />,
|
||||||
[FilterKey.USER_STATE]: <MapPin size={18} />,
|
[FilterKey.USER_STATE]: <MapPin size={14} />,
|
||||||
[FilterKey.USER_COUNTRY]: <Earth size={18} />,
|
[FilterKey.USER_COUNTRY]: <Earth size={14} />,
|
||||||
[FilterKey.USER_DEVICE]: <Code size={18} />,
|
[FilterKey.USER_DEVICE]: <Code size={14} />,
|
||||||
[FilterKey.USER_OS]: <AppWindow size={18} />,
|
[FilterKey.USER_OS]: <AppWindow size={14} />,
|
||||||
[FilterKey.USER_BROWSER]: <Chrome size={18} />,
|
[FilterKey.USER_BROWSER]: <Chrome size={14} />,
|
||||||
[FilterKey.PLATFORM]: <MonitorSmartphone size={18} />,
|
[FilterKey.PLATFORM]: <MonitorSmartphone size={14} />,
|
||||||
[FilterKey.REVID]: <FileStack size={18} />,
|
[FilterKey.REVID]: <FileStack size={14} />,
|
||||||
[FilterKey.REFERRER]: <Workflow size={18} />,
|
[FilterKey.REFERRER]: <Workflow size={14} />,
|
||||||
[FilterKey.DURATION]: <Clock2 size={18} />,
|
[FilterKey.DURATION]: <Clock2 size={14} />,
|
||||||
[FilterKey.TAGGED_ELEMENT]: <SquareMousePointer size={18} />,
|
[FilterKey.TAGGED_ELEMENT]: <SquareMousePointer size={14} />,
|
||||||
[FilterKey.METADATA]: <ContactRound size={18} />,
|
[FilterKey.METADATA]: <ContactRound size={14} />,
|
||||||
[FilterKey.UTM_SOURCE]: <CornerDownRight size={18} />,
|
[FilterKey.UTM_SOURCE]: <CornerDownRight size={14} />,
|
||||||
[FilterKey.UTM_MEDIUM]: <Layers size={18} />,
|
[FilterKey.UTM_MEDIUM]: <Layers size={14} />,
|
||||||
[FilterKey.UTM_CAMPAIGN]: <Megaphone size={18} />,
|
[FilterKey.UTM_CAMPAIGN]: <Megaphone size={14} />,
|
||||||
[FilterKey.FEATURE_FLAG]: <Flag size={18} />
|
[FilterKey.FEATURE_FLAG]: <Flag size={14} />,
|
||||||
};
|
};
|
||||||
|
|
||||||
function filterJson(
|
function filterJson(
|
||||||
jsonObj: Record<string, any>,
|
jsonObj: Record<string, any>,
|
||||||
excludeKeys: string[] = [],
|
excludeKeys: string[] = [],
|
||||||
allowedFilterKeys: string[] = []
|
excludeCategory: string[] = [],
|
||||||
|
allowedFilterKeys: string[] = [],
|
||||||
|
mode: 'filters' | 'events'
|
||||||
): Record<string, any> {
|
): Record<string, any> {
|
||||||
return Object.fromEntries(
|
return Object.fromEntries(
|
||||||
Object.entries(jsonObj)
|
Object.entries(jsonObj)
|
||||||
.map(([key, value]) => {
|
.map(([key, value]) => {
|
||||||
const arr = value.filter((i: { key: string }) => {
|
const arr = value.filter((i: { key: string, isEvent: boolean, category: string }) => {
|
||||||
|
if (excludeCategory.includes(i.category)) return false;
|
||||||
if (excludeKeys.includes(i.key)) return false;
|
if (excludeKeys.includes(i.key)) return false;
|
||||||
|
if (mode === 'events' && !i.isEvent) return false;
|
||||||
|
if (mode === 'filters' && i.isEvent) return false;
|
||||||
return !(
|
return !(
|
||||||
allowedFilterKeys.length > 0 && !allowedFilterKeys.includes(i.key)
|
allowedFilterKeys.length > 0 && !allowedFilterKeys.includes(i.key)
|
||||||
);
|
);
|
||||||
|
|
@ -102,8 +112,8 @@ export const getMatchingEntries = (
|
||||||
|
|
||||||
if (lowerCaseQuery.length === 0)
|
if (lowerCaseQuery.length === 0)
|
||||||
return {
|
return {
|
||||||
matchingCategories: Object.keys(filters),
|
matchingCategories: ['All', ...Object.keys(filters)],
|
||||||
matchingFilters: filters
|
matchingFilters: filters,
|
||||||
};
|
};
|
||||||
|
|
||||||
Object.keys(filters).forEach((name) => {
|
Object.keys(filters).forEach((name) => {
|
||||||
|
|
@ -120,7 +130,7 @@ export const getMatchingEntries = (
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return { matchingCategories, matchingFilters };
|
return { matchingCategories: ['All', ...matchingCategories], matchingFilters };
|
||||||
};
|
};
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
@ -131,50 +141,14 @@ interface Props {
|
||||||
isMainSearch?: boolean;
|
isMainSearch?: boolean;
|
||||||
searchQuery?: string;
|
searchQuery?: string;
|
||||||
excludeFilterKeys?: Array<string>;
|
excludeFilterKeys?: Array<string>;
|
||||||
|
excludeCategory?: Array<string>;
|
||||||
allowedFilterKeys?: Array<string>;
|
allowedFilterKeys?: Array<string>;
|
||||||
isConditional?: boolean;
|
isConditional?: boolean;
|
||||||
isMobile?: boolean;
|
isMobile?: boolean;
|
||||||
|
mode: 'filters' | 'events';
|
||||||
}
|
}
|
||||||
|
|
||||||
function FilterModal(props: Props) {
|
export const getNewIcon = (filter: Record<string, any>) => {
|
||||||
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>) => {
|
|
||||||
if (filter.icon?.includes('metadata')) {
|
if (filter.icon?.includes('metadata')) {
|
||||||
return IconMap[FilterKey.METADATA];
|
return IconMap[FilterKey.METADATA];
|
||||||
}
|
}
|
||||||
|
|
@ -184,43 +158,126 @@ function FilterModal(props: Props) {
|
||||||
return IconMap[filter.key];
|
return IconMap[filter.key];
|
||||||
} else return <Icon name={filter.icon} size={16} />;
|
} 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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={stl.wrapper}
|
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
|
<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}
|
key={key}
|
||||||
|
onClick={() => setCategory(key)}
|
||||||
|
className={cn('rounded-xl px-4 py-2 hover:bg-active-blue capitalize cursor-pointer font-medium', key === category ? 'bg-active-blue text-teal' : '')}
|
||||||
>
|
>
|
||||||
<div className="uppercase font-medium mb-1 color-gray-medium tracking-widest text-sm">
|
|
||||||
{key}
|
{key}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
{matchingFilters[key] &&
|
|
||||||
matchingFilters[key].map((filter: Record<string, any>) => (
|
|
||||||
<div
|
|
||||||
key={filter.label}
|
|
||||||
className={cn(
|
|
||||||
stl.optionItem,
|
|
||||||
'flex items-center py-2 cursor-pointer -mx-2 px-2 gap-2 rounded-lg hover:shadow-sm'
|
|
||||||
)}
|
|
||||||
onClick={() => onFilterClick({ ...filter, value: [''] })}
|
|
||||||
>
|
|
||||||
{getNewIcon(filter)}
|
|
||||||
<span>{filter.label}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
className={'flex flex-col gap-1 overflow-y-auto w-full'}
|
||||||
|
style={{ maxHeight: 300, flex: 2 }}
|
||||||
|
>
|
||||||
|
{displayedFilters.length
|
||||||
|
? displayedFilters.map((filter: Record<string, any>) => (
|
||||||
|
<div
|
||||||
|
key={filter.label}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center p-2 cursor-pointer gap-1 rounded-lg hover:bg-active-blue'
|
||||||
|
)}
|
||||||
|
onClick={() => parseAndAdd({ ...filter })}
|
||||||
|
>
|
||||||
|
{filter.category ? <div style={{ width: 100 }} className={'text-neutral-500/90 w-full flex justify-between items-center'}>
|
||||||
|
<span>{filter.subCategory ? filter.subCategory : filter.category}</span>
|
||||||
|
<ChevronRight size={14} />
|
||||||
|
</div> : null}
|
||||||
|
<div className={'flex items-center gap-2'}>
|
||||||
|
<span className='text-neutral-500/90 text-xs'>{getNewIcon(filter)}</span>
|
||||||
|
<span>{filter.label}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
: null}
|
||||||
</div>
|
</div>
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
{showSearchList && (
|
{showSearchList && (
|
||||||
<Loader loading={fetchingFilterSearchList}>
|
<Loader loading={fetchingFilterSearchList}>
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue