diff --git a/frontend/.babelrc b/frontend/.babelrc index 8c99ee5a4..631979df1 100644 --- a/frontend/.babelrc +++ b/frontend/.babelrc @@ -5,6 +5,7 @@ "@babel/preset-typescript" ], "plugins": [ + "babel-plugin-react-require", [ "@babel/plugin-proposal-private-property-in-object", { "loose": true } ], [ "@babel/plugin-transform-runtime", { "regenerator": true } ], [ "@babel/plugin-proposal-decorators", { "legacy":true } ], diff --git a/frontend/.storybook/main.ts b/frontend/.storybook/main.ts new file mode 100644 index 000000000..b11d20308 --- /dev/null +++ b/frontend/.storybook/main.ts @@ -0,0 +1,36 @@ +import custom from '../webpack.config'; + +export default { + stories: ['../app/components/**/*.stories.@(js|jsx|ts|tsx)'], + addons: [ + '@storybook/addon-links', + '@storybook/addon-essentials', + '@storybook/addon-interactions', + ], + framework: '@storybook/react', + core: { + builder: '@storybook/builder-webpack5', + }, + reactOptions: { + fastRefresh: true, + }, + webpackFinal: async (config: any) => { + config.module = custom.module; + config.resolve = custom.resolve; + if (custom.plugins) { + config.plugins.unshift(custom.plugins[0]); + config.plugins.unshift(custom.plugins[1]); + config.plugins.unshift(custom.plugins[4]); + } + config.module.rules.unshift({ + test: /\.(svg)$/i, + exclude: /node_modules/, + use: [ + { + loader: 'file-loader', + }, + ], + }); + return config; + }, +}; diff --git a/frontend/.storybook/openReplayDecorator.js b/frontend/.storybook/openReplayDecorator.js new file mode 100644 index 000000000..730eaf636 --- /dev/null +++ b/frontend/.storybook/openReplayDecorator.js @@ -0,0 +1,13 @@ +import { Provider } from 'react-redux'; +import store from '../app/store'; +import { StoreProvider, RootStore } from '../app/mstore'; + +const withProvider = (Story) => ( + + + + + +); + +export default withProvider; diff --git a/frontend/.storybook/preview.ts b/frontend/.storybook/preview.ts new file mode 100644 index 000000000..0334feb28 --- /dev/null +++ b/frontend/.storybook/preview.ts @@ -0,0 +1,13 @@ +import openReplayProvider from './openReplayDecorator'; +import '../app/styles/index.scss'; + +export default { + actions: { argTypesRegex: '^on[A-Z].*' }, + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/, + }, + }, +}; +export const decorators = [openReplayProvider] \ No newline at end of file diff --git a/frontend/.storybook/webpack.config.js b/frontend/.storybook/webpack.config.js index 1a123fa52..ff2dbcee8 100644 --- a/frontend/.storybook/webpack.config.js +++ b/frontend/.storybook/webpack.config.js @@ -1,15 +1,3 @@ -const pathAlias = require('../path-alias'); -const mainConfig = require('../webpack.config.js'); - -module.exports = async ({ config }) => { - var conf = mainConfig(); - config.resolve.alias = Object.assign(conf.resolve.alias, config.resolve.alias); // Path Alias - config.resolve.extensions = conf.resolve.extensions - config.module.rules = conf.module.rules; - config.module.rules[0].use[0] = 'style-loader'; // instead of separated css - config.module.rules[1].use[0] = 'style-loader'; - config.plugins.push(conf.plugins[0]); // global React - config.plugins.push(conf.plugins[5]); - config.entry = config.entry.concat(conf.entry.slice(2)) // CSS entries - return config; -}; +export default { + +} \ No newline at end of file diff --git a/frontend/app/api_client.js b/frontend/app/api_client.js index 5673a0aab..a9e092486 100644 --- a/frontend/app/api_client.js +++ b/frontend/app/api_client.js @@ -25,7 +25,7 @@ const siteIdRequiredPaths = [ '/heatmaps', '/custom_metrics', '/dashboards', - '/metrics', + '/cards', '/unprocessed', '/notes', // '/custom_metrics/sessions', diff --git a/frontend/app/components/Alerts/AlertForm.js b/frontend/app/components/Alerts/AlertForm.js index 2d9f26027..fd6fe3d77 100644 --- a/frontend/app/components/Alerts/AlertForm.js +++ b/frontend/app/components/Alerts/AlertForm.js @@ -61,8 +61,6 @@ const AlertForm = (props) => { const write = ({ target: { value, name } }) => props.edit({ [name]: value }); const writeOption = (e, { name, value }) => props.edit({ [name]: value.value }); const onChangeCheck = ({ target: { checked, name } }) => props.edit({ [name]: checked }); - // const onChangeOption = ({ checked, name }) => props.edit({ [ name ]: checked }) - // const onChangeCheck = (e) => { console.log(e) } useEffect(() => { props.fetchTriggerOptions(); diff --git a/frontend/app/components/Dashboard/Widgets/CustomMetricsWidgets/ClickMapCard/ClickMapCard.tsx b/frontend/app/components/Dashboard/Widgets/CustomMetricsWidgets/ClickMapCard/ClickMapCard.tsx new file mode 100644 index 000000000..775782804 --- /dev/null +++ b/frontend/app/components/Dashboard/Widgets/CustomMetricsWidgets/ClickMapCard/ClickMapCard.tsx @@ -0,0 +1,76 @@ +import React from 'react' +import { useStore } from 'App/mstore' +import { observer } from 'mobx-react-lite' +import WebPlayer from 'App/components/Session/WebPlayer' +import { connect } from 'react-redux' +import { setCustomSession } from 'App/duck/sessions' +import { fetchInsights } from 'Duck/sessions'; + +function ClickMapCard({ + setCustomSession, + visitedEvents, + insights, + fetchInsights, + insightsFilters, + host, +}: any) { + const { metricStore, dashboardStore } = useStore(); + const onMarkerClick = (s: string, innerText: string) => { + metricStore.changeClickMapSearch(s, innerText) + } + + React.useEffect(() => { + if (metricStore.instance.data.mobsUrl) { + setCustomSession(metricStore.instance.data) + } + }, [metricStore.instance]) + + React.useEffect(() => { + if (visitedEvents.length) { + const urlOptions = visitedEvents.map(({ url, host }: any) => ({ label: url, value: url, host })) + const url = insightsFilters.url ? insightsFilters.url : host + urlOptions[0].value; + const rangeValue = dashboardStore.drillDownPeriod.rangeValue + const startDate = dashboardStore.drillDownPeriod.start + const endDate = dashboardStore.drillDownPeriod.end + fetchInsights({ ...insightsFilters, url, startDate, endDate, rangeValue, clickRage: metricStore.clickMapFilter }) + } + }, [visitedEvents, metricStore.clickMapFilter]) + + if (!metricStore.instance.data.mobsUrl || insights.size === 0) { + return ( +
No Data for selected period or URL.
+ ) + } + if (!visitedEvents || !visitedEvents.length) { + return
Loading session
+ } + + const searchUrl = metricStore.instance.series[0].filter.filters[0].value[0] + const jumpToEvent = metricStore.instance.data.events.find((evt: Record) => { + if (searchUrl) return evt.path.includes(searchUrl) + return evt + }) + const jumpTimestamp = (jumpToEvent.timestamp - metricStore.instance.data.startTs) + jumpToEvent.domBuildingTime + + return ( +
+ +
+ ) +} + +export default connect( + (state: any) => ({ + insightsFilters: state.getIn(['sessions', 'insightFilters']), + visitedEvents: state.getIn(['sessions', 'visitedEvents']), + insights: state.getIn(['sessions', 'insights']), + host: state.getIn(['sessions', 'host']), + }), + { setCustomSession, fetchInsights } +) +(observer(ClickMapCard)) diff --git a/frontend/app/components/Dashboard/Widgets/CustomMetricsWidgets/ClickMapCard/index.ts b/frontend/app/components/Dashboard/Widgets/CustomMetricsWidgets/ClickMapCard/index.ts new file mode 100644 index 000000000..c72a4090b --- /dev/null +++ b/frontend/app/components/Dashboard/Widgets/CustomMetricsWidgets/ClickMapCard/index.ts @@ -0,0 +1 @@ +export { default } from './ClickMapCard' diff --git a/frontend/app/components/Dashboard/Widgets/PredefinedWidgets/ErrorsByOrigin/ErrorsByOrigin.tsx b/frontend/app/components/Dashboard/Widgets/PredefinedWidgets/ErrorsByOrigin/ErrorsByOrigin.tsx index e405ba422..cd95853fe 100644 --- a/frontend/app/components/Dashboard/Widgets/PredefinedWidgets/ErrorsByOrigin/ErrorsByOrigin.tsx +++ b/frontend/app/components/Dashboard/Widgets/PredefinedWidgets/ErrorsByOrigin/ErrorsByOrigin.tsx @@ -20,7 +20,7 @@ function ErrorsByOrigin(props: Props) { diff --git a/frontend/app/components/Dashboard/Widgets/PredefinedWidgets/ResponseTimeDistribution/ResponseTimeDistribution.tsx b/frontend/app/components/Dashboard/Widgets/PredefinedWidgets/ResponseTimeDistribution/ResponseTimeDistribution.tsx index 548a229ab..4326b3b3a 100644 --- a/frontend/app/components/Dashboard/Widgets/PredefinedWidgets/ResponseTimeDistribution/ResponseTimeDistribution.tsx +++ b/frontend/app/components/Dashboard/Widgets/PredefinedWidgets/ResponseTimeDistribution/ResponseTimeDistribution.tsx @@ -87,7 +87,7 @@ function ResponseTimeDistribution(props: Props) { /> 'Page Response Time: ' + val} /> - { metric.data.percentiles.map((item, i) => ( + { metric.data.percentiles && metric.data.percentiles.map((item: any, i: number) => ( Math.max(acc, item.value), 0); const min = metric.data.chart.reduce((acc: any, item: any) => Math.min(acc, item.value), 0); metric.data.chart.forEach((item: any) => { + if (!item || !item.userCountry) { return } item.perNumber = positionOfTheNumber(min, max, item.value, 5); data[item.userCountry.toLowerCase()] = item; }); diff --git a/frontend/app/components/Dashboard/components/AddCardModal/AddCardModal.tsx b/frontend/app/components/Dashboard/components/AddCardModal/AddCardModal.tsx new file mode 100644 index 000000000..227b485f6 --- /dev/null +++ b/frontend/app/components/Dashboard/components/AddCardModal/AddCardModal.tsx @@ -0,0 +1,20 @@ +import Modal from 'App/components/Modal/Modal'; +import React from 'react'; +import MetricTypeList from '../MetricTypeList'; + +interface Props { + siteId: string; + dashboardId: string; +} +function AddCardModal(props: Props) { + return ( + <> + + + + + + ); +} + +export default AddCardModal; diff --git a/frontend/app/components/Dashboard/components/AddCardModal/index.ts b/frontend/app/components/Dashboard/components/AddCardModal/index.ts new file mode 100644 index 000000000..c7864e849 --- /dev/null +++ b/frontend/app/components/Dashboard/components/AddCardModal/index.ts @@ -0,0 +1 @@ +export { default } from './AddCardModal'; diff --git a/frontend/app/components/Dashboard/components/Alerts/AlertsList.tsx b/frontend/app/components/Dashboard/components/Alerts/AlertsList.tsx index 54cdf0a4f..57af6efc2 100644 --- a/frontend/app/components/Dashboard/components/Alerts/AlertsList.tsx +++ b/frontend/app/components/Dashboard/components/Alerts/AlertsList.tsx @@ -35,7 +35,7 @@ function AlertsList({ fetchList, list: alertsList, alertsSearch, siteId, init, f show={lenth === 0} title={
- +
{alertsSearch !== '' ? 'No matching results' : "You haven't created any alerts yet"}
diff --git a/frontend/app/components/Dashboard/components/ClickMapRagePicker/ClickMapRagePicker.tsx b/frontend/app/components/Dashboard/components/ClickMapRagePicker/ClickMapRagePicker.tsx new file mode 100644 index 000000000..f13a21682 --- /dev/null +++ b/frontend/app/components/Dashboard/components/ClickMapRagePicker/ClickMapRagePicker.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import { Checkbox} from "UI"; +import { useStore } from 'App/mstore'; +import { observer } from 'mobx-react-lite'; + +function ClickMapRagePicker() { + const { metricStore } = useStore(); + + const onToggle = (e: React.ChangeEvent) => { + metricStore.setClickMaps(e.target.checked) + } + + React.useEffect(() => { + return () => { + metricStore.setClickMaps(false) + } + }, []) + + return ( +
+ +
+ ); +} + +export default observer(ClickMapRagePicker); \ No newline at end of file diff --git a/frontend/app/components/Dashboard/components/ClickMapRagePicker/index.ts b/frontend/app/components/Dashboard/components/ClickMapRagePicker/index.ts new file mode 100644 index 000000000..208ab0919 --- /dev/null +++ b/frontend/app/components/Dashboard/components/ClickMapRagePicker/index.ts @@ -0,0 +1 @@ +export { default } from './ClickMapRagePicker' \ No newline at end of file diff --git a/frontend/app/components/Dashboard/components/DashboardHeader/DashboardHeader.tsx b/frontend/app/components/Dashboard/components/DashboardHeader/DashboardHeader.tsx new file mode 100644 index 000000000..25ff80e81 --- /dev/null +++ b/frontend/app/components/Dashboard/components/DashboardHeader/DashboardHeader.tsx @@ -0,0 +1,130 @@ +import React from 'react'; +import { Tooltip } from 'react-tippy'; +import Breadcrumb from 'Shared/Breadcrumb'; +import { withSiteId } from 'App/routes'; +import { withRouter, RouteComponentProps } from 'react-router-dom'; +import { Button, PageTitle, confirm } from 'UI'; +import SelectDateRange from 'Shared/SelectDateRange'; +import { useStore } from 'App/mstore'; +import { useModal } from 'App/components/Modal'; +import DashboardOptions from '../DashboardOptions'; +import withModal from 'App/components/Modal/withModal'; +import { observer } from 'mobx-react-lite'; +import DashboardEditModal from '../DashboardEditModal'; +import AddCardModal from '../AddCardModal'; + +interface IProps { + dashboardId: string; + siteId: string; + renderReport?: any; +} + +type Props = IProps & RouteComponentProps; + +function DashboardHeader(props: Props) { + const { siteId, dashboardId } = props; + const { dashboardStore } = useStore(); + const { showModal } = useModal(); + const [focusTitle, setFocusedInput] = React.useState(true); + const [showEditModal, setShowEditModal] = React.useState(false); + const period = dashboardStore.period; + + const dashboard: any = dashboardStore.selectedDashboard; + + const onEdit = (isTitle: boolean) => { + dashboardStore.initDashboard(dashboard); + setFocusedInput(isTitle); + setShowEditModal(true); + }; + + const onDelete = async () => { + if ( + await confirm({ + header: 'Confirm', + confirmButton: 'Yes, delete', + confirmation: `Are you sure you want to permanently delete this Dashboard?`, + }) + ) { + dashboardStore.deleteDashboard(dashboard).then(() => { + props.history.push(withSiteId(`/dashboard`, siteId)); + }); + } + }; + return ( +
+ setShowEditModal(false)} + focusTitle={focusTitle} + /> + +
+
+ + {dashboard?.name} + + } + onDoubleClick={() => onEdit(true)} + className="mr-3 select-none border-b border-b-borderColor-transparent hover:border-dotted hover:border-gray-medium cursor-pointer" + /> +
+
+ +
+
+ dashboardStore.setPeriod(period)} + right={true} + /> +
+
+
+ +
+
+
+
+ {/* @ts-ignore */} + +

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

+
+
+
+ ); +} + +export default withRouter(withModal(observer(DashboardHeader))); diff --git a/frontend/app/components/Dashboard/components/DashboardHeader/index.ts b/frontend/app/components/Dashboard/components/DashboardHeader/index.ts new file mode 100644 index 000000000..4bd864695 --- /dev/null +++ b/frontend/app/components/Dashboard/components/DashboardHeader/index.ts @@ -0,0 +1 @@ +export { default } from './DashboardHeader'; diff --git a/frontend/app/components/Dashboard/components/DashboardList/DashboardList.tsx b/frontend/app/components/Dashboard/components/DashboardList/DashboardList.tsx index 7bb716733..64ff7e143 100644 --- a/frontend/app/components/Dashboard/components/DashboardList/DashboardList.tsx +++ b/frontend/app/components/Dashboard/components/DashboardList/DashboardList.tsx @@ -5,6 +5,7 @@ import { useStore } from 'App/mstore'; import { filterList } from 'App/utils'; import { sliceListPerPage } from 'App/utils'; import DashboardListItem from './DashboardListItem'; +import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG'; function DashboardList() { const { dashboardStore } = useStore(); @@ -24,12 +25,22 @@ function DashboardList() { show={lenth === 0} title={
- -
- {dashboardsSearch !== '' - ? 'No matching results' - : "You haven't created any dashboards yet"} +
+ {dashboardsSearch !== '' ? ( + 'No matching results' + ) : ( +
+
Create your first Dashboard
+
+ A dashboard lets you visualize trends and insights of data captured by OpenReplay. +
+
+ )}
+ + {/*
+ +
*/}
} > diff --git a/frontend/app/components/Dashboard/components/DashboardList/DashboardsView.tsx b/frontend/app/components/Dashboard/components/DashboardList/DashboardsView.tsx index 7378e88f8..f0c8b46ff 100644 --- a/frontend/app/components/Dashboard/components/DashboardList/DashboardsView.tsx +++ b/frontend/app/components/Dashboard/components/DashboardList/DashboardsView.tsx @@ -1,45 +1,15 @@ import React from 'react'; -import { Button, PageTitle, Icon } from 'UI'; import withPageTitle from 'HOCs/withPageTitle'; -import { useStore } from 'App/mstore'; -import { withSiteId } from 'App/routes'; - import DashboardList from './DashboardList'; -import DashboardSearch from './DashboardSearch'; +import Header from './Header'; -function DashboardsView({ history, siteId }: { history: any, siteId: string }) { - const { dashboardStore } = useStore(); - - const onAddDashboardClick = () => { - dashboardStore.initDashboard(); - dashboardStore - .save(dashboardStore.dashboardInstance) - .then(async (syncedDashboard) => { - dashboardStore.selectDashboardById(syncedDashboard.dashboardId); - history.push(withSiteId(`/dashboard/${syncedDashboard.dashboardId}`, siteId)) - }) - } - - return ( -
-
-
- -
-
- -
- -
-
-
-
- - A Dashboard is a collection of Metrics that can be shared across teams. -
- -
- ); +function DashboardsView({ history, siteId }: { history: any; siteId: string }) { + return ( +
+
+ +
+ ); } export default withPageTitle('Dashboards - OpenReplay')(DashboardsView); diff --git a/frontend/app/components/Dashboard/components/DashboardList/Header.tsx b/frontend/app/components/Dashboard/components/DashboardList/Header.tsx new file mode 100644 index 000000000..f77d3ae69 --- /dev/null +++ b/frontend/app/components/Dashboard/components/DashboardList/Header.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +import { Button, PageTitle } from 'UI'; +import Select from 'Shared/Select'; +import DashboardSearch from './DashboardSearch'; +import { useStore } from 'App/mstore'; +import { useObserver } from 'mobx-react-lite'; +import { withSiteId } from 'App/routes'; + +function Header({ history, siteId }: { history: any; siteId: string }) { + const { dashboardStore } = useStore(); + const sort = useObserver(() => dashboardStore.sort); + + const onAddDashboardClick = () => { + dashboardStore.initDashboard(); + dashboardStore.save(dashboardStore.dashboardInstance).then(async (syncedDashboard) => { + dashboardStore.selectDashboardById(syncedDashboard.dashboardId); + history.push(withSiteId(`/dashboard/${syncedDashboard.dashboardId}`, siteId)); + }); + }; + + return ( +
+
+ +
+
+ +
+ metricStore.updateKey('sort', { by: value.value })} + /> +
+
+ +
+
+
+
+ + Create custom Cards to capture key interactions and track KPIs. +
+
+ ); +} + +export default MetricViewHeader; diff --git a/frontend/app/components/Dashboard/components/MetricViewHeader/index.ts b/frontend/app/components/Dashboard/components/MetricViewHeader/index.ts new file mode 100644 index 000000000..fd6048fc9 --- /dev/null +++ b/frontend/app/components/Dashboard/components/MetricViewHeader/index.ts @@ -0,0 +1 @@ +export { default } from './MetricViewHeader'; diff --git a/frontend/app/components/Dashboard/components/MetricsGrid/MetricsGrid.tsx b/frontend/app/components/Dashboard/components/MetricsGrid/MetricsGrid.tsx new file mode 100644 index 000000000..a98a972f5 --- /dev/null +++ b/frontend/app/components/Dashboard/components/MetricsGrid/MetricsGrid.tsx @@ -0,0 +1,14 @@ +import React from 'react'; + +interface Props { + +} +function MetricsGrid(props: Props) { + return ( +
+ +
+ ); +} + +export default MetricsGrid; \ No newline at end of file diff --git a/frontend/app/components/Dashboard/components/MetricsGrid/index.ts b/frontend/app/components/Dashboard/components/MetricsGrid/index.ts new file mode 100644 index 000000000..6e16e72d9 --- /dev/null +++ b/frontend/app/components/Dashboard/components/MetricsGrid/index.ts @@ -0,0 +1 @@ +export { default } from './MetricsGrid' \ No newline at end of file diff --git a/frontend/app/components/Dashboard/components/MetricsLibraryModal/MetricsLibraryModal.tsx b/frontend/app/components/Dashboard/components/MetricsLibraryModal/MetricsLibraryModal.tsx new file mode 100644 index 000000000..5227b7171 --- /dev/null +++ b/frontend/app/components/Dashboard/components/MetricsLibraryModal/MetricsLibraryModal.tsx @@ -0,0 +1,100 @@ +import Modal from 'App/components/Modal/Modal'; +import React, { useEffect, useMemo, useState } from 'react'; +import MetricsList from '../MetricsList'; +import { Button, Icon } from 'UI'; +import { useModal } from 'App/components/Modal'; +import { useStore } from 'App/mstore'; +import { observer, useObserver } from 'mobx-react-lite'; + +interface Props { + dashboardId: number; + siteId: string; +} +function MetricsLibraryModal(props: Props) { + const { metricStore } = useStore(); + const { siteId, dashboardId } = props; + const [selectedList, setSelectedList] = useState([]); + + useEffect(() => { + metricStore.updateKey('listView', true); + }, []); + + const onSelectionChange = (list: any) => { + setSelectedList(list); + }; + + const onChange = ({ target: { value } }: any) => { + metricStore.updateKey('metricsSearch', value) + }; + + return ( + <> + +
+
+
Cards Library
+
+
+ +
+
+
+ +
+ +
+
+ + + + + ); +} + +export default observer(MetricsLibraryModal); + +function MetricSearch({ onChange }: any) { + return ( +
+ + +
+ ); +} + +function SelectedContent({ dashboardId, selected }: any) { + const { hideModal } = useModal(); + const { metricStore, dashboardStore } = useStore(); + const total = useObserver(() => metricStore.sortedWidgets.length); + const dashboard = useMemo(() => dashboardStore.getDashboard(dashboardId), [dashboardId]); + + const addSelectedToDashboard = () => { + if (!dashboard || !dashboard.dashboardId) return; + dashboardStore.addWidgetToDashboard(dashboard, selected).then(() => { + hideModal(); + dashboardStore.fetch(dashboard.dashboardId); + }); + }; + + return ( +
+
+ Selected {selected.length} of{' '} + {total} +
+
+ + +
+
+ ); +} diff --git a/frontend/app/components/Dashboard/components/MetricsLibraryModal/index.ts b/frontend/app/components/Dashboard/components/MetricsLibraryModal/index.ts new file mode 100644 index 000000000..f217fc7c3 --- /dev/null +++ b/frontend/app/components/Dashboard/components/MetricsLibraryModal/index.ts @@ -0,0 +1 @@ +export { default } from './MetricsLibraryModal'; diff --git a/frontend/app/components/Dashboard/components/MetricsList/GridView.tsx b/frontend/app/components/Dashboard/components/MetricsList/GridView.tsx new file mode 100644 index 000000000..9b131bb2a --- /dev/null +++ b/frontend/app/components/Dashboard/components/MetricsList/GridView.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import MetricListItem from '../MetricListItem'; +import WidgetWrapper from 'App/components/Dashboard/components/WidgetWrapper'; + +interface Props { + list: any; + siteId: any; + selectedList: any; + toggleSelection?: (metricId: any) => void; +} +function GridView(props: Props) { + const { siteId, list, selectedList, toggleSelection } = props; + return ( +
+ {list.map((metric: any) => ( + + toggleSelection(parseInt(metric.metricId))} + /> + + ))} +
+ ); +} + +export default GridView; diff --git a/frontend/app/components/Dashboard/components/MetricsList/ListView.tsx b/frontend/app/components/Dashboard/components/MetricsList/ListView.tsx new file mode 100644 index 000000000..1236487cc --- /dev/null +++ b/frontend/app/components/Dashboard/components/MetricsList/ListView.tsx @@ -0,0 +1,53 @@ +import React from 'react'; +import MetricListItem from '../MetricListItem'; +import { Checkbox } from 'UI'; + +interface Props { + list: any; + siteId: any; + selectedList: any; + toggleSelection?: (metricId: any) => void; + toggleAll?: (e: any) => void; + disableSelection?: boolean; + allSelected?: boolean +} +function ListView(props: Props) { + const { siteId, list, selectedList, toggleSelection, disableSelection = false, allSelected = false } = props; + return ( +
+
+
+ {!disableSelection && ( + selectedList(list.map((i: any) => i.metricId))} + onClick={props.toggleAll} + /> + )} + Title +
+ +
Owner
+
Visibility
+
Last Modified
+
+ {list.map((metric: any) => ( + { + e.stopPropagation(); + toggleSelection && toggleSelection(parseInt(metric.metricId)); + }} + /> + ))} +
+ ); +} + +export default ListView; diff --git a/frontend/app/components/Dashboard/components/MetricsList/MetricsList.tsx b/frontend/app/components/Dashboard/components/MetricsList/MetricsList.tsx index 2869f5240..cb30afad1 100644 --- a/frontend/app/components/Dashboard/components/MetricsList/MetricsList.tsx +++ b/frontend/app/components/Dashboard/components/MetricsList/MetricsList.tsx @@ -1,25 +1,57 @@ -import { observer } from 'mobx-react-lite'; -import React, { useEffect } from 'react'; +import { observer, useObserver } from 'mobx-react-lite'; +import React, { useEffect, useState } from 'react'; import { NoContent, Pagination, Icon } from 'UI'; import { useStore } from 'App/mstore'; import { filterList } from 'App/utils'; -import MetricListItem from '../MetricListItem'; import { sliceListPerPage } from 'App/utils'; import Widget from 'App/mstore/types/widget'; +import GridView from './GridView'; +import ListView from './ListView'; +import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG'; -function MetricsList({ siteId }: { siteId: string }) { +function MetricsList({ + siteId, + onSelectionChange, +}: { + siteId: string; + onSelectionChange?: (selected: any[]) => void; +}) { const { metricStore } = useStore(); const metrics = metricStore.sortedWidgets; const metricsSearch = metricStore.metricsSearch; + const listView = useObserver(() => metricStore.listView); + const [selectedMetrics, setSelectedMetrics] = useState([]); + const sortBy = useObserver(() => metricStore.sort.by); + + useEffect(() => { + metricStore.fetchList(); + }, []); + + useEffect(() => { + if (!onSelectionChange) { + return; + } + onSelectionChange(selectedMetrics); + }, [selectedMetrics]); + + const toggleMetricSelection = (id: any) => { + if (selectedMetrics.includes(id)) { + setSelectedMetrics(selectedMetrics.filter((i: number) => i !== id)); + } else { + setSelectedMetrics([...selectedMetrics, id]); + } + }; const filterByDashboard = (item: Widget, searchRE: RegExp) => { const dashboardsStr = item.dashboards.map((d: any) => d.name).join(' '); return searchRE.test(dashboardsStr); }; + const list = metricsSearch !== '' ? filterList(metrics, metricsSearch, ['name', 'metricType', 'owner'], filterByDashboard) : metrics; + const lenth = list.length; useEffect(() => { @@ -31,33 +63,39 @@ function MetricsList({ siteId }: { siteId: string }) { show={lenth === 0} title={
- +
- {metricsSearch !== '' ? 'No matching results' : "You haven't created any metrics yet"} + {metricsSearch !== '' ? 'No matching results' : "You haven't created any cards yet"}
} > -
-
-
Title
-
Owner
-
Visibility
-
Last Modified
-
+ {listView ? ( + + setSelectedMetrics(checked ? list.map((i: any) => i.metricId) : []) + } + /> + ) : ( + + )} - {sliceListPerPage(list, metricStore.page - 1, metricStore.pageSize).map((metric: any) => ( - - - - ))} -
- -
+
Showing{' '} {Math.min(list.length, metricStore.pageSize)} out - of {list.length} metrics + of {list.length} cards
{ - metricStore.fetchList(); - }, []); - return useObserver(() => ( -
-
-
- -
-
- -
- -
-
-
-
- - Create custom Metrics to capture user frustrations, monitor your app's performance and track other KPIs. -
- -
- )); + return useObserver(() => ( +
+ + +
+ )); } -export default withPageTitle('Metrics - OpenReplay')(MetricsView); +export default withPageTitle('Cards - OpenReplay')(MetricsView); diff --git a/frontend/app/components/Dashboard/components/WidgetChart/WidgetChart.tsx b/frontend/app/components/Dashboard/components/WidgetChart/WidgetChart.tsx index 67247a2d2..c32d0d498 100644 --- a/frontend/app/components/Dashboard/components/WidgetChart/WidgetChart.tsx +++ b/frontend/app/components/Dashboard/components/WidgetChart/WidgetChart.tsx @@ -1,10 +1,10 @@ -import React, { useState, useRef, useEffect, useCallback } from 'react'; +import React, { useState, useRef, useEffect } from 'react'; import CustomMetriLineChart from 'App/components/Dashboard/Widgets/CustomMetricsWidgets/CustomMetriLineChart'; import CustomMetricPercentage from 'App/components/Dashboard/Widgets/CustomMetricsWidgets/CustomMetricPercentage'; import CustomMetricTable from 'App/components/Dashboard/Widgets/CustomMetricsWidgets/CustomMetricTable'; import CustomMetricPieChart from 'App/components/Dashboard/Widgets/CustomMetricsWidgets/CustomMetricPieChart'; import { Styles } from 'App/components/Dashboard/Widgets/common'; -import { observer, useObserver } from 'mobx-react-lite'; +import { observer } from 'mobx-react-lite'; import { Loader } from 'UI'; import { useStore } from 'App/mstore'; import WidgetPredefinedChart from '../WidgetPredefinedChart'; @@ -13,27 +13,31 @@ import { getStartAndEndTimestampsByDensity } from 'Types/dashboard/helper'; import { debounce } from 'App/utils'; import useIsMounted from 'App/hooks/useIsMounted' import { FilterKey } from 'Types/filter/filterType'; - +import { TIMESERIES, TABLE, CLICKMAP, FUNNEL, ERRORS, PERFORMANCE, RESOURCE_MONITORING, WEB_VITALS } from 'App/constants/card'; import FunnelWidget from 'App/components/Funnels/FunnelWidget'; import ErrorsWidget from '../Errors/ErrorsWidget'; import SessionWidget from '../Sessions/SessionWidget'; import CustomMetricTableSessions from 'App/components/Dashboard/Widgets/CustomMetricsWidgets/CustomMetricTableSessions'; import CustomMetricTableErrors from 'App/components/Dashboard/Widgets/CustomMetricsWidgets/CustomMetricTableErrors'; +import ClickMapCard from 'App/components/Dashboard/Widgets/CustomMetricsWidgets/ClickMapCard' + interface Props { metric: any; isWidget?: boolean; isTemplate?: boolean; + isPreview?: boolean; } + function WidgetChart(props: Props) { const { isWidget = false, metric, isTemplate } = props; - const { dashboardStore, metricStore } = useStore(); + const { dashboardStore, metricStore, sessionStore } = useStore(); const _metric: any = metricStore.instance; - const period = useObserver(() => dashboardStore.period); - const drillDownPeriod = useObserver(() => dashboardStore.drillDownPeriod); + const period = dashboardStore.period; + const drillDownPeriod = dashboardStore.drillDownPeriod; const drillDownFilter = dashboardStore.drillDownFilter; const colors = Styles.customMetricColors; const [loading, setLoading] = useState(true) - const isOverviewWidget = metric.metricType === 'predefined' && metric.viewType === 'overview'; + const isOverviewWidget = metric.metricType === WEB_VITALS; const params = { density: isOverviewWidget ? 7 : 70 } const metricParams = { ...params } const prevMetricRef = useRef(); @@ -81,12 +85,12 @@ function WidgetChart(props: Props) { if (prevMetricRef.current && prevMetricRef.current.name !== metric.name) { prevMetricRef.current = metric; return - }; + } prevMetricRef.current = metric; const timestmaps = drillDownPeriod.toTimestamps(); const payload = isWidget ? { ...params } : { ...metricParams, ...timestmaps, ...metric.toJson() }; debounceRequest(metric, payload, isWidget, !isWidget ? drillDownPeriod : period); - }, [drillDownPeriod, period, depsString, _metric.page, metric.metricType, metric.metricOf, metric.viewType]); + }, [drillDownPeriod, period, depsString, _metric.page, metric.metricType, metric.metricOf, metric.viewType, metric.metricValue]); const renderChart = () => { @@ -97,23 +101,25 @@ function WidgetChart(props: Props) { return } - if (metricType === 'errors') { - return - } + // if (metricType === ERRORS) { + // return + // } - if (metricType === 'funnel') { + if (metricType === FUNNEL) { return } - if (metricType === 'predefined') { - const defaultMetric = metric.data.chart.length === 0 ? metricWithData : metric + if (metricType === 'predefined' || metricType === ERRORS || metricType === PERFORMANCE || metricType === RESOURCE_MONITORING || metricType === WEB_VITALS) { + const defaultMetric = metric.data.chart && metric.data.chart.length === 0 ? metricWithData : metric if (isOverviewWidget) { return } - return + return } - if (metricType === 'timeseries') { + // TODO add USER_PATH, RETENTION, FEATUER_ADOPTION + + if (metricType === TIMESERIES) { if (viewType === 'lineChart') { return ( ) } - if (viewType === 'table') { + if (viewType === TABLE) { return ( + clickmap thumbnail +
+ ) + } + return ( + + ) + } - return
Unknown
; + return
Unknown metric type
; } return ( diff --git a/frontend/app/components/Dashboard/components/WidgetForm/WidgetForm.tsx b/frontend/app/components/Dashboard/components/WidgetForm/WidgetForm.tsx index bf0ccda20..bc8429d87 100644 --- a/frontend/app/components/Dashboard/components/WidgetForm/WidgetForm.tsx +++ b/frontend/app/components/Dashboard/components/WidgetForm/WidgetForm.tsx @@ -1,228 +1,247 @@ import React from 'react'; -import { metricTypes, metricOf, issueOptions } from 'App/constants/filterOptions'; +import { metricOf, issueOptions } from 'App/constants/filterOptions'; import { FilterKey } from 'Types/filter/filterType'; import { useStore } from 'App/mstore'; -import { useObserver } from 'mobx-react-lite'; -import { Button, Icon, SegmentSelection } from 'UI' +import { observer } from 'mobx-react-lite'; +import { Button, Icon, confirm, Tooltip } from 'UI'; import FilterSeries from '../FilterSeries'; -import { confirm, Tooltip } from 'UI'; -import Select from 'Shared/Select' -import { withSiteId, dashboardMetricDetails, metricDetails } from 'App/routes' +import Select from 'Shared/Select'; +import { withSiteId, dashboardMetricDetails, metricDetails } from 'App/routes'; +import MetricTypeDropdown from './components/MetricTypeDropdown'; +import MetricSubtypeDropdown from './components/MetricSubtypeDropdown'; +import { + TIMESERIES, + TABLE, + CLICKMAP, + FUNNEL, + ERRORS, + RESOURCE_MONITORING, + PERFORMANCE, + WEB_VITALS, +} from 'App/constants/card'; +import { clickmapFilter } from 'App/types/filter/newFilter'; +import { renderClickmapThumbnail } from './renderMap'; interface Props { - history: any; - match: any; - onDelete: () => void; -} - -const metricIcons = { - timeseries: 'graph-up', - table: 'table', - funnel: 'funnel', + history: any; + match: any; + onDelete: () => void; } function WidgetForm(props: Props) { + const { + history, + match: { + params: { siteId, dashboardId }, + }, + } = props; + const { metricStore, dashboardStore } = useStore(); + const isSaving = metricStore.isSaving; + const metric: any = metricStore.instance; - const { history, match: { params: { siteId, dashboardId } } } = props; - const { metricStore, dashboardStore } = useStore(); - const dashboards = dashboardStore.dashboards; - const isSaving = useObserver(() => metricStore.isSaving); - const metric: any = useObserver(() => metricStore.instance) + const timeseriesOptions = metricOf.filter((i) => i.type === 'timeseries'); + const tableOptions = metricOf.filter((i) => i.type === 'table'); + const isTable = metric.metricType === 'table'; + const isClickmap = metric.metricType === CLICKMAP; + const isFunnel = metric.metricType === 'funnel'; + const canAddSeries = metric.series.length < 3; + const eventsLength = metric.series[0].filter.filters.filter((i: any) => i.isEvent).length; + const cannotSaveFunnel = isFunnel && (!metric.series[0] || eventsLength <= 1); + const isPredefined = [ERRORS, PERFORMANCE, RESOURCE_MONITORING, WEB_VITALS].includes( + metric.metricType + ); - const timeseriesOptions = metricOf.filter(i => i.type === 'timeseries'); - const tableOptions = metricOf.filter(i => i.type === 'table'); - const isTable = metric.metricType === 'table'; - const isFunnel = metric.metricType === 'funnel'; - const canAddToDashboard = metric.exists() && dashboards.length > 0; - const canAddSeries = metric.series.length < 3; - const eventsLength = useObserver(() => metric.series[0].filter.filters.filter((i: any) => i.isEvent).length) - const cannotSaveFunnel = isFunnel && (!metric.series[0] || eventsLength <= 1); + const writeOption = ({ value, name }: { value: any; name: any }) => { + value = Array.isArray(value) ? value : value.value; + const obj: any = { [name]: value }; - const writeOption = ({ value, name }: any) => { - value = Array.isArray(value) ? value : value.value - const obj: any = { [ name ]: value }; + if (name === 'metricValue') { + obj.metricValue = value; - if (name === 'metricValue') { - obj['metricValue'] = value; + if (Array.isArray(obj.metricValue) && obj.metricValue.length > 1) { + obj.metricValue = obj.metricValue.filter((i: any) => i.value !== 'all'); + } + } - // handle issues (remove all when other option is selected) - if (Array.isArray(obj['metricValue']) && obj['metricValue'].length > 1) { - obj['metricValue'] = obj['metricValue'].filter(i => i.value !== 'all'); - } - } + if (name === 'metricType') { + switch (value) { + case TIMESERIES: + obj.metricOf = timeseriesOptions[0].value; + obj.viewType = 'lineChart'; + break; + case TABLE: + obj.metricOf = tableOptions[0].value; + obj.viewType = 'table'; + break; + case FUNNEL: + obj.metricOf = 'sessionCount'; + break; + case ERRORS: + case RESOURCE_MONITORING: + case PERFORMANCE: + case WEB_VITALS: + obj.viewType = 'chart'; + break; + case CLICKMAP: + obj.viewType = 'chart'; - if (name === 'metricOf') { - // if (value === FilterKey.ISSUE) { - // obj['metricValue'] = [{ value: 'all', label: 'All' }]; - // } - } + if (value !== CLICKMAP) { + metric.series[0].filter.removeFilter(0); + } - if (name === 'metricType') { - if (value === 'timeseries') { - obj['metricOf'] = timeseriesOptions[0].value; - obj['viewType'] = 'lineChart'; - } else if (value === 'table') { - obj['metricOf'] = tableOptions[0].value; - obj['viewType'] = 'table'; - } - } - - metricStore.merge(obj); - }; - - const onSelect = (_: any, option: Record) => writeOption({ value: { value: option.value }, name: option.name}) - - const onSave = () => { - const wasCreating = !metric.exists() - metricStore.save(metric, dashboardId) - .then((metric: any) => { - if (wasCreating) { - if (parseInt(dashboardId) > 0) { - history.replace(withSiteId(dashboardMetricDetails(dashboardId, metric.metricId), siteId)); - const dashboard = dashboardStore.getDashboard(parseInt(dashboardId)) - dashboardStore.addWidgetToDashboard(dashboard, [metric.metricId]) - } else { - history.replace(withSiteId(metricDetails(metric.metricId), siteId)); - } - } + if (metric.series[0].filter.filters.length < 1) { + metric.series[0].filter.addFilter({ + ...clickmapFilter, + value: [''], }); + } + break; + } } + metricStore.merge(obj); + }; - const onDelete = async () => { - if (await confirm({ - header: 'Confirm', - confirmButton: 'Yes, delete', - confirmation: `Are you sure you want to permanently delete this metric?` - })) { - metricStore.delete(metric).then(props.onDelete); - } + const onSave = async () => { + const wasCreating = !metric.exists(); + if (isClickmap) { + try { + metric.thumbnail = await renderClickmapThumbnail(); + } catch (e) { + console.error(e); + } } + const savedMetric = await metricStore.save(metric); + if (wasCreating) { + if (parseInt(dashboardId, 10) > 0) { + history.replace(withSiteId(dashboardMetricDetails(dashboardId, savedMetric.metricId), siteId)); + dashboardStore.addWidgetToDashboard(dashboardStore.getDashboard(parseInt(dashboardId, 10))!, [savedMetric.metricId]); + } else { + history.replace(withSiteId(metricDetails(savedMetric.metricId), siteId)); + } + } + }; - return useObserver(() => ( -
-
- -
- i.value === metric.metricType) || metricTypes[0]} - // @ts-ignore - list={metricTypes.map((i) => ({ value: i.value, name: i.label, icon: metricIcons[i.value] }))} - /> + const onDelete = async () => { + if ( + await confirm({ + header: 'Confirm', + confirmButton: 'Yes, delete', + confirmation: `Are you sure you want to permanently delete this metric?`, + }) + ) { + metricStore.delete(metric).then(props.onDelete); + } + }; - {metric.metricType === 'timeseries' && ( - <> - of - - - )} + {metric.metricOf === FilterKey.ISSUE && ( + <> + issue type + - - )} - - {metric.metricType === 'table' && !(metric.metricOf === FilterKey.ERRORS || metric.metricOf === FilterKey.SESSIONS) && ( - <> - showing - + + )}
- )); +
+ + {isPredefined && ( +
+ +
+ Filtering or modification of OpenReplay provided metrics isn't supported at the moment. +
+
+ )} + + {!isPredefined && ( +
+
+ {`${isTable || isFunnel || isClickmap ? 'Filter by' : 'Chart Series'}`} + {!isTable && !isFunnel && !isClickmap && ( + + )} +
+ + {metric.series.length > 0 && + metric.series + .slice(0, isTable || isFunnel || isClickmap ? 1 : metric.series.length) + .map((series: any, index: number) => ( +
+ metric.updateKey('hasChanged', true)} + hideHeader={isTable || isClickmap} + seriesIndex={index} + series={series} + onRemoveSeries={() => metric.removeSeries(index)} + canDelete={metric.series.length > 1} + emptyMessage={ + isTable + ? 'Filter data using any event or attribute. Use Add Step button below to do so.' + : 'Add user event or filter to define the series by clicking Add Step.' + } + /> +
+ ))} +
+ )} + +
+ + + +
+ {metric.exists() && ( + + )} +
+
+
+ ); } -export default WidgetForm; +export default observer(WidgetForm); diff --git a/frontend/app/components/Dashboard/components/WidgetForm/components/MetricSubtypeDropdown/MetricSubtypeDropdown.tsx b/frontend/app/components/Dashboard/components/WidgetForm/components/MetricSubtypeDropdown/MetricSubtypeDropdown.tsx new file mode 100644 index 000000000..37e2b46ad --- /dev/null +++ b/frontend/app/components/Dashboard/components/WidgetForm/components/MetricSubtypeDropdown/MetricSubtypeDropdown.tsx @@ -0,0 +1,66 @@ +import { useStore } from 'App/mstore'; +import { observer } from 'mobx-react-lite'; +import { TYPES } from 'App/constants/card'; +import { MetricType } from 'App/components/Dashboard/components/MetricTypeItem/MetricTypeItem'; +import React from 'react'; +import Select from 'Shared/Select'; +import { components } from 'react-select'; +import CustomDropdownOption from 'Shared/CustomDropdownOption'; + +interface Props { + onSelect: any; +} +function MetricSubtypeDropdown(props: Props) { + const { metricStore } = useStore(); + const metric: any = metricStore.instance; + + const options: any = React.useMemo(() => { + const type = TYPES.find((i: MetricType) => i.slug === metric.metricType); + if (type && type.subTypes) { + const options = type.subTypes.map((i: MetricType) => ({ + label: i.title, + icon: i.icon, + value: i.slug, + description: i.description, + })); + return options; + } + return false; + }, [metric.metricType]); + + React.useEffect(() => { + // @ts-ignore + if (options && !options.map(i => i.value).includes(metric.metricOf)) { + setTimeout(() => props.onSelect({ name: 'metricOf', value: { value: options[0].value }}), 0) + } + }, [metric.metricType]) + + return options ? ( + <> +
of
+ i.value === metric.metricType) || options[0]} + onChange={props.onSelect} + // onSelect={onSelect} + components={{ + SingleValue: ({ children, ...props }: any) => { + const { data: { icon, label } } = props; + return ( + +
+ +
{label}
+
+
+ ); + }, + MenuList: ({ children, ...props }: any) => { + return ( + + {children} + + ); + }, + Option: ({ children, ...props }: any) => { + const { data } = props; + return ; + }, + }} + /> + ); +} + +export default withLocationHandlers()(observer(MetricTypeDropdown)); diff --git a/frontend/app/components/Dashboard/components/WidgetForm/components/MetricTypeDropdown/index.ts b/frontend/app/components/Dashboard/components/WidgetForm/components/MetricTypeDropdown/index.ts new file mode 100644 index 000000000..ae7bd4cb1 --- /dev/null +++ b/frontend/app/components/Dashboard/components/WidgetForm/components/MetricTypeDropdown/index.ts @@ -0,0 +1 @@ +export { default } from './MetricTypeDropdown'; diff --git a/frontend/app/components/Dashboard/components/WidgetForm/renderMap.ts b/frontend/app/components/Dashboard/components/WidgetForm/renderMap.ts new file mode 100644 index 000000000..05aaff537 --- /dev/null +++ b/frontend/app/components/Dashboard/components/WidgetForm/renderMap.ts @@ -0,0 +1,29 @@ +export const renderClickmapThumbnail = () => { + // @ts-ignore + return import('html2canvas').then(({ default: html2canvas }) => { + // @ts-ignore + window.html2canvas = html2canvas; + const element = document.querySelector('#clickmap-render * iframe').contentDocument.body + if (element) { + const dimensions = element.getBoundingClientRect() + return html2canvas( + element, + { + scale: 1, + // allowTaint: true, + useCORS: true, + foreignObjectRendering: true, + height: dimensions.height > 900 ? 900 : dimensions.height, + width: dimensions.width > 1200 ? 1200 : dimensions.width, + x: 0, + y: 0, + ignoreElements: (e) => e.id.includes('render-ignore'), + } + ).then((canvas) => { + return canvas.toDataURL('img/png'); + }).catch(console.log); + } else { + Promise.reject("can't find clickmap container") + } + }) +} \ No newline at end of file diff --git a/frontend/app/components/Dashboard/components/WidgetPredefinedChart/WidgetPredefinedChart.tsx b/frontend/app/components/Dashboard/components/WidgetPredefinedChart/WidgetPredefinedChart.tsx index 9f246794e..c411ee25c 100644 --- a/frontend/app/components/Dashboard/components/WidgetPredefinedChart/WidgetPredefinedChart.tsx +++ b/frontend/app/components/Dashboard/components/WidgetPredefinedChart/WidgetPredefinedChart.tsx @@ -27,6 +27,7 @@ import CallWithErrors from '../../Widgets/PredefinedWidgets/CallWithErrors'; import SpeedIndexByLocation from '../../Widgets/PredefinedWidgets/SpeedIndexByLocation'; import SlowestResources from '../../Widgets/PredefinedWidgets/SlowestResources'; import ResponseTimeDistribution from '../../Widgets/PredefinedWidgets/ResponseTimeDistribution'; +import { FilterKey } from 'Types/filter/filterType'; interface Props { data: any; @@ -40,59 +41,59 @@ function WidgetPredefinedChart(props: Props) { const renderWidget = () => { switch (predefinedKey) { // ERRORS - case 'errors_per_type': + case FilterKey.ERRORS_PER_TYPE: return - case 'errors_per_domains': + case FilterKey.ERRORS_PER_DOMAINS: return - case 'resources_by_party': + case FilterKey.RESOURCES_BY_PARTY: return - case 'impacted_sessions_by_js_errors': + case FilterKey.IMPACTED_SESSIONS_BY_JS_ERRORS: return - case 'domains_errors_4xx': + case FilterKey.DOMAINS_ERRORS_4XX: return - case 'domains_errors_5xx': + case FilterKey.DOMAINS_ERRORS_5XX: return - case 'calls_errors': + case FilterKey.CALLS_ERRORS: return // PERFORMANCE - case 'impacted_sessions_by_slow_pages': + case FilterKey.IMPACTED_SESSIONS_BY_SLOW_PAGES: return - case 'pages_response_time_distribution': + case FilterKey.PAGES_RESPONSE_TIME_DISTRIBUTION: return - case 'speed_location': + case FilterKey.SPEED_LOCATION: return - case 'cpu': + case FilterKey.CPU: return - case 'crashes': + case FilterKey.CRASHES: return - case 'pages_dom_buildtime': + case FilterKey.PAGES_DOM_BUILD_TIME: return - case 'fps': + case FilterKey.FPS: return - case 'memory_consumption': + case FilterKey.MEMORY_CONSUMPTION: return - case 'pages_response_time': + case FilterKey.PAGES_RESPONSE_TIME: return - case 'resources_vs_visually_complete': + case FilterKey.RESOURCES_VS_VISUALLY_COMPLETE: return - case 'sessions_per_browser': + case FilterKey.SESSIONS_PER_BROWSER: return - case 'slowest_domains': + case FilterKey.SLOWEST_DOMAINS: return - case 'time_to_render': + case FilterKey.TIME_TO_RENDER: return // Resources - case 'resources_count_by_type': + case FilterKey.BREAKDOWN_OF_LOADED_RESOURCES: return - case 'missing_resources': + case FilterKey.MISSING_RESOURCES: return - case 'resource_type_vs_response_end': + case FilterKey.RESOURCE_TYPE_VS_RESPONSE_END: return - case 'resources_loading_time': + case FilterKey.RESOURCES_LOADING_TIME: return - case 'slowest_resources': + case FilterKey.SLOWEST_RESOURCES: return default: diff --git a/frontend/app/components/Dashboard/components/WidgetPreview/WidgetPreview.tsx b/frontend/app/components/Dashboard/components/WidgetPreview/WidgetPreview.tsx index bea850d11..523dee9c8 100644 --- a/frontend/app/components/Dashboard/components/WidgetPreview/WidgetPreview.tsx +++ b/frontend/app/components/Dashboard/components/WidgetPreview/WidgetPreview.tsx @@ -3,11 +3,12 @@ import cn from 'classnames'; import WidgetWrapper from '../WidgetWrapper'; import { useStore } from 'App/mstore'; import { SegmentSelection, Button, Icon } from 'UI'; -import { useObserver } from 'mobx-react-lite'; +import { observer } from 'mobx-react-lite'; import { FilterKey } from 'Types/filter/filterType'; import WidgetDateRange from '../WidgetDateRange/WidgetDateRange'; -// import Period, { LAST_24_HOURS, LAST_30_DAYS } from 'Types/app/period'; +import ClickMapRagePicker from "Components/Dashboard/components/ClickMapRagePicker"; import DashboardSelectionModal from '../DashboardSelectionModal/DashboardSelectionModal'; +import { CLICKMAP, TABLE, TIMESERIES } from "App/constants/card"; interface Props { className?: string; @@ -18,29 +19,18 @@ function WidgetPreview(props: Props) { const { className = '' } = props; const { metricStore, dashboardStore } = useStore(); const dashboards = dashboardStore.dashboards; - const metric: any = useObserver(() => metricStore.instance); - const isTimeSeries = metric.metricType === 'timeseries'; - const isTable = metric.metricType === 'table'; - const drillDownFilter = useObserver(() => dashboardStore.drillDownFilter); - const disableVisualization = useObserver(() => metric.metricOf === FilterKey.SESSIONS || metric.metricOf === FilterKey.ERRORS); - // const period = useObserver(() => dashboardStore.drillDownPeriod); + const metric: any = metricStore.instance; + const isTimeSeries = metric.metricType === TIMESERIES; + const isTable = metric.metricType === TABLE; + const disableVisualization = metric.metricOf === FilterKey.SESSIONS || metric.metricOf === FilterKey.ERRORS; - const chagneViewType = (e, { name, value }: any) => { + const changeViewType = (_, { name, value }: any) => { metric.update({ [ name ]: value }); } - // const onChangePeriod = (period: any) => { - // dashboardStore.setDrillDownPeriod(period); - // const periodTimestamps = period.toTimestamps(); - // drillDownFilter.merge({ - // startTimestamp: periodTimestamps.startTimestamp, - // endTimestamp: periodTimestamps.endTimestamp, - // }) - // } - const canAddToDashboard = metric.exists() && dashboards.length > 0; - return useObserver(() => ( + return ( <>
@@ -55,8 +45,8 @@ function WidgetPreview(props: Props) { name="viewType" className="my-3" primary - icons={true} - onSelect={ chagneViewType } + size="small" + onSelect={ changeViewType } value={{ value: metric.viewType }} list={ [ { value: 'lineChart', name: 'Chart', icon: 'graph-up-arrow' }, @@ -73,8 +63,8 @@ function WidgetPreview(props: Props) { name="viewType" className="my-3" primary={true} - icons={true} - onSelect={ chagneViewType } + size="small" + onSelect={ changeViewType } value={{ value: metric.viewType }} list={[ { value: 'table', name: 'Table', icon: 'table' }, @@ -85,6 +75,9 @@ function WidgetPreview(props: Props) { )}
+ {metric.metricType === CLICKMAP ? ( + + ) : null} {/* add to dashboard */} {metric.exists() && ( @@ -93,7 +86,7 @@ function WidgetPreview(props: Props) { className="ml-2 p-0" onClick={() => setShowDashboardSelectionModal(true)} disabled={!canAddToDashboard} - > + > Add to Dashboard @@ -112,7 +105,7 @@ function WidgetPreview(props: Props) { /> )} - )); + ); } -export default WidgetPreview; +export default observer(WidgetPreview); diff --git a/frontend/app/components/Dashboard/components/WidgetSessions/WidgetSessions.tsx b/frontend/app/components/Dashboard/components/WidgetSessions/WidgetSessions.tsx index d3a092b49..ebb07b49c 100644 --- a/frontend/app/components/Dashboard/components/WidgetSessions/WidgetSessions.tsx +++ b/frontend/app/components/Dashboard/components/WidgetSessions/WidgetSessions.tsx @@ -10,6 +10,7 @@ import { debounce } from 'App/utils'; import useIsMounted from 'App/hooks/useIsMounted'; import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG'; import { numberWithCommas } from 'App/utils'; +import { CLICKMAP } from "App/constants/card"; interface Props { className?: string; @@ -21,9 +22,9 @@ function WidgetSessions(props: Props) { const isMounted = useIsMounted(); const [loading, setLoading] = useState(false); const filteredSessions = getListSessionsBySeries(data, activeSeries); - const { dashboardStore, metricStore } = useStore(); - const filter = useObserver(() => dashboardStore.drillDownFilter); - const widget: any = useObserver(() => metricStore.instance); + const { dashboardStore, metricStore, sessionStore } = useStore(); + const filter = dashboardStore.drillDownFilter; + const widget = metricStore.instance; const startTime = DateTime.fromMillis(filter.startTimestamp).toFormat('LLL dd, yyyy HH:mm'); const endTime = DateTime.fromMillis(filter.endTimestamp).toFormat('LLL dd, yyyy HH:mm'); const [seriesOptions, setSeriesOptions] = useState([{ label: 'All', value: 'all' }]); @@ -50,30 +51,58 @@ function WidgetSessions(props: Props) { setLoading(false); }); }; + const fetchClickmapSessions = (customFilters: Record) => { + sessionStore.getSessions(customFilters) + .then(data => { + setData([{ ...data, seriesId: 1 , seriesName: "Clicks" }]) + }) + } const debounceRequest: any = React.useCallback(debounce(fetchSessions, 1000), []); + const debounceClickMapSearch = React.useCallback(debounce(fetchClickmapSessions, 1000), []) const depsString = JSON.stringify(widget.series); useEffect(() => { - debounceRequest(widget.metricId, { - ...filter, - series: widget.toJsonDrilldown(), - page: metricStore.sessionsPage, - limit: metricStore.sessionsPageSize, - }); - }, [filter.startTimestamp, filter.endTimestamp, filter.filters, depsString, metricStore.sessionsPage]); + if (widget.metricType === CLICKMAP && metricStore.clickMapSearch) { + const clickFilter = { + value: [ + metricStore.clickMapSearch + ], + type: "CLICK", + operator: "onSelector", + isEvent: true, + // @ts-ignore + "filters": [] + } + const timeRange = { + rangeValue: dashboardStore.drillDownPeriod.rangeValue, + startDate: dashboardStore.drillDownPeriod.start, + endDate: dashboardStore.drillDownPeriod.end, + } + const customFilter = { ...filter, ...timeRange, filters: [ ...sessionStore.userFilter.filters, clickFilter]} + debounceClickMapSearch(customFilter) + } else { + debounceRequest(widget.metricId, { + ...filter, + series: widget.toJsonDrilldown(), + page: metricStore.sessionsPage, + limit: metricStore.sessionsPageSize, + }); + } + }, [filter.startTimestamp, filter.endTimestamp, filter.filters, depsString, metricStore.sessionsPage, metricStore.clickMapSearch]); - return useObserver(() => ( + return (
-

Sessions

+

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

+ {metricStore.clickMapLabel ? `on "${metricStore.clickMapLabel}" ` : null} between {startTime} and{' '} {endTime}{' '}
- {widget.metricType !== 'table' && ( + {widget.metricType !== 'table' && widget.metricType !== CLICKMAP && (
Filter by Series