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"
+ />
+
+
+
+ showModal( , { right: true })
+ }
+ icon="plus"
+ >
+ Add Card
+
+
+
+ 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 (
-
-
-
-
-
Create Dashboard
-
-
-
-
-
-
-
- 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 (
+
+
+
+
+ New Dashboard
+
+
+ dashboardStore.updateKey('sort', { by: value.value })}
+ />
+
+
+
+
+
+
+ );
+}
+
+export default Header;
diff --git a/frontend/app/components/Dashboard/components/DashboardSideMenu/DashboardSideMenu.tsx b/frontend/app/components/Dashboard/components/DashboardSideMenu/DashboardSideMenu.tsx
index 42129f699..e689bce51 100644
--- a/frontend/app/components/Dashboard/components/DashboardSideMenu/DashboardSideMenu.tsx
+++ b/frontend/app/components/Dashboard/components/DashboardSideMenu/DashboardSideMenu.tsx
@@ -38,7 +38,7 @@ function DashboardSideMenu(props: Props) {