Merge remote-tracking branch 'origin/dev' into api-v1.9.5

This commit is contained in:
Taha Yassine Kraiem 2023-01-06 16:26:43 +01:00
commit 912ff486e0
124 changed files with 3224 additions and 1061 deletions

View file

@ -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 } ],

View file

@ -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;
},
};

View file

@ -0,0 +1,13 @@
import { Provider } from 'react-redux';
import store from '../app/store';
import { StoreProvider, RootStore } from '../app/mstore';
const withProvider = (Story) => (
<Provider store={store}>
<StoreProvider store={new RootStore()}>
<Story />
</StoreProvider>
</Provider>
);
export default withProvider;

View file

@ -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]

View file

@ -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 {
}

View file

@ -25,7 +25,7 @@ const siteIdRequiredPaths = [
'/heatmaps',
'/custom_metrics',
'/dashboards',
'/metrics',
'/cards',
'/unprocessed',
'/notes',
// '/custom_metrics/sessions',

View file

@ -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();

View file

@ -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 (
<div className="py-2">No Data for selected period or URL.</div>
)
}
if (!visitedEvents || !visitedEvents.length) {
return <div className="py-2">Loading session</div>
}
const searchUrl = metricStore.instance.series[0].filter.filters[0].value[0]
const jumpToEvent = metricStore.instance.data.events.find((evt: Record<string, any>) => {
if (searchUrl) return evt.path.includes(searchUrl)
return evt
})
const jumpTimestamp = (jumpToEvent.timestamp - metricStore.instance.data.startTs) + jumpToEvent.domBuildingTime
return (
<div id="clickmap-render">
<WebPlayer
isClickmap
customSession={metricStore.instance.data}
customTimestamp={jumpTimestamp}
onMarkerClick={onMarkerClick}
/>
</div>
)
}
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))

View file

@ -0,0 +1 @@
export { default } from './ClickMapCard'

View file

@ -20,7 +20,7 @@ function ErrorsByOrigin(props: Props) {
<NoContent
size="small"
title={NO_METRIC_DATA}
show={ metric.data.chart.length === 0 }
show={ metric.data.chart && metric.data.chart.length === 0 }
style={ { height: '240px' } }
>
<ResponsiveContainer height={ 240 } width="100%">

View file

@ -87,7 +87,7 @@ function ResponseTimeDistribution(props: Props) {
/>
<Bar minPointSize={1} name="Calls" dataKey="count" stackId="a" fill={colors[2]} label="Backend" />
<Tooltip {...Styles.tooltip} labelFormatter={val => 'Page Response Time: ' + val} />
{ metric.data.percentiles.map((item, i) => (
{ metric.data.percentiles && metric.data.percentiles.map((item: any, i: number) => (
<ReferenceLine
key={i}
label={

View file

@ -24,6 +24,7 @@ function SpeedIndexByLocation(props: Props) {
const max = metric.data.chart.reduce((acc: any, item: any) => 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;
});

View file

@ -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 (
<>
<Modal.Header title="Add Card" />
<Modal.Content className="px-3 pb-6">
<MetricTypeList siteId={props.siteId} dashboardId={parseInt(props.dashboardId)} />
</Modal.Content>
</>
);
}
export default AddCardModal;

View file

@ -0,0 +1 @@
export { default } from './AddCardModal';

View file

@ -35,7 +35,7 @@ function AlertsList({ fetchList, list: alertsList, alertsSearch, siteId, init, f
show={lenth === 0}
title={
<div className="flex flex-col items-center justify-center">
<AnimatedSVG name={ICONS.NO_ALERTS} size={80} />
<AnimatedSVG name={ICONS.NO_ALERTS} size={180} />
<div className="text-center text-gray-600 my-4">
{alertsSearch !== '' ? 'No matching results' : "You haven't created any alerts yet"}
</div>

View file

@ -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<HTMLInputElement>) => {
metricStore.setClickMaps(e.target.checked)
}
React.useEffect(() => {
return () => {
metricStore.setClickMaps(false)
}
}, [])
return (
<div className="mr-4 flex items-center cursor-pointer">
<Checkbox
onChange={onToggle}
label="Include rage clicks"
/>
</div>
);
}
export default observer(ClickMapRagePicker);

View file

@ -0,0 +1 @@
export { default } from './ClickMapRagePicker'

View file

@ -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 (
<div>
<DashboardEditModal
show={showEditModal}
closeHandler={() => setShowEditModal(false)}
focusTitle={focusTitle}
/>
<Breadcrumb
items={[
{
label: 'Dashboards',
to: withSiteId('/dashboard', siteId),
},
{ label: (dashboard && dashboard.name) || '' },
]}
/>
<div className="flex items-center mb-2 justify-between">
<div className="flex items-center" style={{ flex: 3 }}>
<PageTitle
title={
// @ts-ignore
<Tooltip delay={100} arrow title="Double click to rename">
{dashboard?.name}
</Tooltip>
}
onDoubleClick={() => onEdit(true)}
className="mr-3 select-none border-b border-b-borderColor-transparent hover:border-dotted hover:border-gray-medium cursor-pointer"
/>
</div>
<div className="flex items-center" style={{ flex: 1, justifyContent: 'end' }}>
<Button
variant="primary"
onClick={() =>
showModal(<AddCardModal dashboardId={dashboardId} siteId={siteId} />, { right: true })
}
icon="plus"
>
Add Card
</Button>
<div className="mx-4"></div>
<div
className="flex items-center flex-shrink-0 justify-end"
style={{ width: 'fit-content' }}
>
<SelectDateRange
style={{ width: '300px' }}
period={period}
onChange={(period: any) => dashboardStore.setPeriod(period)}
right={true}
/>
</div>
<div className="mx-4" />
<div className="flex items-center flex-shrink-0">
<DashboardOptions
editHandler={onEdit}
deleteHandler={onDelete}
renderReport={props.renderReport}
isTitlePresent={!!dashboard?.description}
/>
</div>
</div>
</div>
<div className="pb-4">
{/* @ts-ignore */}
<Tooltip delay={100} arrow title="Double click to rename" 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>
);
}
export default withRouter(withModal(observer(DashboardHeader)));

View file

@ -0,0 +1 @@
export { default } from './DashboardHeader';

View file

@ -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={
<div className="flex flex-col items-center justify-center">
<Icon name="no-dashboard" size={80} color="figmaColors-accent-secondary" />
<div className="text-center text-gray-600 my-4">
{dashboardsSearch !== ''
? 'No matching results'
: "You haven't created any dashboards yet"}
<div className="text-center my-4">
{dashboardsSearch !== '' ? (
'No matching results'
) : (
<div>
<div>Create your first Dashboard</div>
<div className="text-sm color-gray-medium font-normal">
A dashboard lets you visualize trends and insights of data captured by OpenReplay.
</div>
</div>
)}
</div>
<AnimatedSVG name={ICONS.NO_DASHBOARDS} size={180} />
{/* <div className="my-2 bg-active-blue rounded flex items-center justify-center px-80 py-20">
<Icon name="grid-1x2" size={40} color="figmaColors-accent-secondary" />
</div> */}
</div>
}
>

View file

@ -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 (
<div style={{ maxWidth: '1300px', margin: 'auto'}} className="bg-white rounded py-4 border">
<div className="flex items-center mb-4 justify-between px-6">
<div className="flex items-baseline mr-3">
<PageTitle title="Dashboards" />
</div>
<div className="ml-auto flex items-center">
<Button variant="primary" onClick={onAddDashboardClick}>Create Dashboard</Button>
<div className="ml-4 w-1/4" style={{ minWidth: 300 }}>
<DashboardSearch />
</div>
</div>
</div>
<div className="text-base text-disabled-text flex items-center px-6">
<Icon name="info-circle-fill" className="mr-2" size={16} />
A Dashboard is a collection of Metrics that can be shared across teams.
</div>
<DashboardList />
</div>
);
function DashboardsView({ history, siteId }: { history: any; siteId: string }) {
return (
<div style={{ maxWidth: '1300px', margin: 'auto' }} className="bg-white rounded py-4 border">
<Header history={history} siteId={siteId} />
<DashboardList />
</div>
);
}
export default withPageTitle('Dashboards - OpenReplay')(DashboardsView);

View file

@ -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 (
<div className="flex items-center mb-4 justify-between px-6">
<div className="flex items-baseline mr-3">
<PageTitle title="Dashboards" />
</div>
<div className="ml-auto flex items-center">
<Button variant="primary" onClick={onAddDashboardClick}>
New Dashboard
</Button>
<div className="mx-2">
<Select
options={[
{ label: 'Newest', value: 'desc' },
{ label: 'Oldest', value: 'asc' },
]}
defaultValue={sort.by}
plain
onChange={({ value }) => dashboardStore.updateKey('sort', { by: value.value })}
/>
</div>
<div className="w-1/4" style={{ minWidth: 300 }}>
<DashboardSearch />
</div>
</div>
</div>
);
}
export default Header;

View file

@ -38,7 +38,7 @@ function DashboardSideMenu(props: Props) {
<SideMenuitem
active={isMetric}
id="menu-manage-alerts"
title="Metrics"
title="Cards"
iconName="bar-chart-line"
onClick={() => redirect(withSiteId(metrics(), siteId))}
/>

View file

@ -1,23 +1,17 @@
import React, { useEffect } from 'react';
import { observer } from 'mobx-react-lite';
import { useStore } from 'App/mstore';
import { Button, PageTitle, Loader, Tooltip, Popover } from 'UI';
import { Loader } from 'UI';
import { withSiteId } from 'App/routes';
import withModal from 'App/components/Modal/withModal';
import DashboardWidgetGrid from '../DashboardWidgetGrid';
import { confirm } from 'UI';
import { withRouter, RouteComponentProps } from 'react-router-dom';
import { useModal } from 'App/components/Modal';
import DashboardModal from '../DashboardModal';
import DashboardEditModal from '../DashboardEditModal';
import AlertFormModal from 'App/components/Alerts/AlertFormModal';
import withPageTitle from 'HOCs/withPageTitle';
import withReport from 'App/components/hocs/withReport';
import DashboardOptions from '../DashboardOptions';
import SelectDateRange from 'Shared/SelectDateRange';
import Breadcrumb from 'Shared/Breadcrumb';
import AddMetricContainer from '../DashboardWidgetGrid/AddMetricContainer';
import OutsideClickDetectingDiv from 'Shared/OutsideClickDetectingDiv';
import DashboardHeader from '../DashboardHeader';
interface IProps {
siteId: string;
@ -32,14 +26,9 @@ function DashboardView(props: Props) {
const { dashboardStore } = useStore();
const { showModal } = useModal();
const [showTooltip, setShowTooltip] = React.useState(false);
const [focusTitle, setFocusedInput] = React.useState(true);
const [showEditModal, setShowEditModal] = React.useState(false);
const showAlertModal = dashboardStore.showAlertModal;
const loading = dashboardStore.fetchingDashboard;
const dashboard: any = dashboardStore.selectedDashboard;
const period = dashboardStore.period;
const queryParams = new URLSearchParams(props.location.search);
@ -50,6 +39,7 @@ function DashboardView(props: Props) {
search: queryParams.toString(),
});
};
const pushQuery = () => {
if (!queryParams.has('modal')) props.history.push('?modal=addMetric');
};
@ -81,117 +71,14 @@ function DashboardView(props: Props) {
);
};
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));
});
}
};
if (!dashboard) return null;
return (
<Loader loading={loading}>
<div style={{ maxWidth: '1300px', margin: 'auto' }}>
<DashboardEditModal
show={showEditModal}
closeHandler={() => setShowEditModal(false)}
focusTitle={focusTitle}
/>
<Breadcrumb
items={[
{
label: 'Dashboards',
to: withSiteId('/dashboard', siteId),
},
{ label: (dashboard && dashboard.name) || '' },
]}
/>
<div className="flex items-center mb-2 justify-between">
<div className="flex items-center" style={{ flex: 3 }}>
<PageTitle
title={
// @ts-ignore
<Tooltip delay={200} title="Double click to rename">
{dashboard?.name}
</Tooltip>
}
onDoubleClick={() => onEdit(true)}
className="mr-3 select-none border-b border-b-borderColor-transparent hover:border-dotted hover:border-gray-medium cursor-pointer"
actionButton={
// <OutsideClickDetectingDiv onClickOutside={() => setShowTooltip(false)}>
<Popover
// open={showTooltip}
// interactive
// useContext
// @ts-ignore
// theme="nopadding"
// hideDelay={0}
// duration={0}
// distance={20}
placement="left"
render={() => showTooltip && (
<div style={{ padding: 0 }}>
<AddMetricContainer
onAction={() => setShowTooltip(false)}
isPopup
siteId={siteId}
/>
</div>
)}
>
<Button variant="primary" onClick={() => setShowTooltip(true)}>
Add Metric
</Button>
</Popover>
// </OutsideClickDetectingDiv>
}
/>
</div>
<div className="flex items-center" style={{ flex: 1, justifyContent: 'end' }}>
<div className="flex items-center flex-shrink-0 justify-end" style={{ width: '300px' }}>
<SelectDateRange
style={{ width: '300px' }}
period={period}
onChange={(period: any) => dashboardStore.setPeriod(period)}
right={true}
/>
</div>
<div className="mx-4" />
<div className="flex items-center flex-shrink-0">
<DashboardOptions
editHandler={onEdit}
deleteHandler={onDelete}
renderReport={props.renderReport}
isTitlePresent={!!dashboard?.description}
/>
</div>
</div>
</div>
<div className="pb-4">
{/* @ts-ignore */}
<Tooltip delay={100} arrow title="Double click to rename" 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>
{/* @ts-ignore */}
<DashboardHeader renderReport={props.renderReport} siteId={siteId} dashboardId={dashboardId} />
<DashboardWidgetGrid
siteId={siteId}
dashboardId={dashboardId}

View file

@ -3,8 +3,8 @@ import { useStore } from 'App/mstore';
import WidgetWrapper from '../WidgetWrapper';
import { NoContent, Loader, Icon } from 'UI';
import { useObserver } from 'mobx-react-lite';
import AddMetricContainer from './AddMetricContainer';
import Widget from 'App/mstore/types/widget';
import MetricTypeList from '../MetricTypeList';
interface Props {
siteId: string;
@ -38,23 +38,28 @@ function DashboardWidgetGrid(props: Props) {
show={list.length === 0}
icon="no-metrics-chart"
title={
<span className="text-2xl capitalize-first text-figmaColors-text-primary">
Build your dashboard
</span>
}
subtext={
<div className="w-4/5 m-auto mt-4">
<AddMetricContainer siteId={siteId} />
<div className="bg-white rounded">
<div className="border-b p-5">
<div className="text-2xl font-normal color-gray-darkest">
There are no cards in this dashboard
</div>
<div className="text-base font-normal">
Try the most commonly used metrics or graphs to begin.
</div>
</div>
<div className="grid grid-cols-4 p-8 gap-2">
<MetricTypeList dashboardId={parseInt(dashboardId)} siteId={siteId} />
</div>
</div>
}
>
<div className="grid gap-4 grid-cols-4 items-start pb-10" id={props.id}>
{smallWidgets.length > 0 ? (
<>
<div className="font-semibold text-xl py-4 flex items-center gap-2 col-span-4">
<Icon name="grid-horizontal" size={26} />
Web Vitals
</div>
<div className="grid gap-4 grid-cols-4 items-start pb-10" id={props.id}>{smallWidgets.length > 0 ? (
<>
<div className="font-semibold text-xl py-4 flex items-center gap-2 col-span-4">
<Icon name="grid-horizontal" size={26} />
Web Vitals
</div>
{smallWidgets &&
smallWidgets.map((item: any, index: any) => (
<React.Fragment key={item.widgetId}>
@ -63,23 +68,24 @@ function DashboardWidgetGrid(props: Props) {
widget={item}
moveListItem={(dragIndex: any, hoverIndex: any) =>
dashboard.swapWidgetPosition(dragIndex, hoverIndex)
}
dashboardId={dashboardId}
}dashboardId={dashboardId}
siteId={siteId}
isWidget={true}
grid="vitals"
/>
</React.Fragment>
))}
</>
) : null}
{smallWidgets.length > 0 && regularWidgets.length > 0 ? (
<div className="font-semibold text-xl py-4 flex items-center gap-2 col-span-4">
<Icon name="grid-horizontal" size={26} />
All Metrics
</div>
) : null}
</>
) : null}
{smallWidgets.length > 0 && regularWidgets.length > 0 ? (
<div className="font-semibold text-xl py-4 flex items-center gap-2 col-span-4">
<Icon name="grid-horizontal" size={26} />
All Cards
</div>
) : null}
{regularWidgets &&
regularWidgets.map((item: any, index: any) => (
@ -97,10 +103,6 @@ function DashboardWidgetGrid(props: Props) {
/>
</React.Fragment>
))}
<div className="col-span-2" id="no-print">
<AddMetricContainer siteId={siteId} />
</div>
</div>
</NoContent>
</Loader>

View file

@ -11,6 +11,7 @@ interface Props {
series: any;
onRemoveSeries: (seriesIndex: any) => void;
canDelete?: boolean;
hideHeader?: boolean;
emptyMessage?: any;
observeChanges?: () => void;

View file

@ -1,66 +1,79 @@
import React from 'react';
import { Icon, Tooltip } from 'UI';
import React, { useEffect, useState } from 'react';
import { Icon, Checkbox, Tooltip } from 'UI';
import { checkForRecent } from 'App/date';
import { withRouter, RouteComponentProps } from 'react-router-dom';
import { withSiteId } from 'App/routes';
import { TYPES } from 'App/constants/card';
import cn from 'classnames';
interface Props extends RouteComponentProps {
metric: any;
siteId: string;
metric: any;
siteId: string;
selected?: boolean;
toggleSelection?: any;
disableSelection?: boolean
}
function MetricTypeIcon({ type }: any) {
const getIcon = () => {
switch (type) {
case 'funnel':
return 'filter';
case 'table':
return 'list-alt';
case 'timeseries':
return 'bar-chart-line';
}
}
const [card, setCard] = useState<any>('');
useEffect(() => {
const t = TYPES.find(i => i.slug === type);
setCard(t)
}, [type])
return (
<Tooltip
title={<div className="capitalize">{type}</div>}
>
<div className="w-9 h-9 rounded-full bg-tealx-lightest flex items-center justify-center mr-2">
<Icon name={getIcon()} size="16" color="tealx" />
</div>
</Tooltip>
)
return (
<Tooltip delay={0} title={<div className="capitalize">{card.title}</div>} >
<div className="w-9 h-9 rounded-full bg-tealx-lightest flex items-center justify-center mr-2">
{ card.icon && <Icon name={card.icon} size="16" color="tealx" /> }
</div>
</Tooltip>
);
}
function MetricListItem(props: Props) {
const { metric, history, siteId } = props;
const { metric, history, siteId, selected, toggleSelection = () => {}, disableSelection = false } = props;
const onItemClick = () => {
const path = withSiteId(`/metrics/${metric.metricId}`, siteId);
history.push(path);
};
return (
<div className="grid grid-cols-12 py-4 border-t select-none items-center hover:bg-active-blue cursor-pointer px-6" onClick={onItemClick}>
<div className="col-span-4 flex items-start">
<div className="flex items-center">
<MetricTypeIcon type={metric.metricType} />
<div className="link capitalize-first">
{metric.name}
</div>
</div>
</div>
<div className="col-span-4">{metric.owner}</div>
<div className="col-span-2">
<div className="flex items-center">
<Icon name={metric.isPublic ? "user-friends" : "person-fill"} className="mr-2" />
<span>{metric.isPublic ? 'Team' : 'Private'}</span>
</div>
</div>
<div className="col-span-2 text-right">{metric.lastModified && checkForRecent(metric.lastModified, 'LLL dd, yyyy, hh:mm a')}</div>
const onItemClick = (e: React.MouseEvent) => {
if (!disableSelection) {
return toggleSelection(e);
}
const path = withSiteId(`/metrics/${metric.metricId}`, siteId);
history.push(path);
};
return (
<div
className="grid grid-cols-12 py-4 border-t select-none items-center hover:bg-active-blue cursor-pointer px-6"
onClick={onItemClick}
>
<div className="col-span-4 flex items-center">
{!disableSelection && (
<Checkbox
name="slack"
className="mr-4"
type="checkbox"
checked={selected}
onClick={toggleSelection}
/>
)}
<div className="flex items-center">
<MetricTypeIcon type={metric.metricType} />
<div className={ cn("capitalize-first", { "link" : disableSelection })}>{metric.name}</div>
</div>
);
</div>
<div className="col-span-4">{metric.owner}</div>
<div className="col-span-2">
<div className="flex items-center">
<Icon name={metric.isPublic ? 'user-friends' : 'person-fill'} className="mr-2" />
<span>{metric.isPublic ? 'Team' : 'Private'}</span>
</div>
</div>
<div className="col-span-2 text-right">
{metric.lastModified && checkForRecent(metric.lastModified, 'LLL dd, yyyy, hh:mm a')}
</div>
</div>
);
}
export default withRouter(MetricListItem);

View file

@ -0,0 +1,39 @@
import { IconNames } from 'App/components/ui/SVG';
import React from 'react';
import { Icon } from 'UI';
export interface MetricType {
title: string;
icon?: IconNames;
description: string;
slug: string;
}
interface Props {
metric: MetricType;
onClick?: any;
}
function MetricTypeItem(props: Props) {
const {
metric: { title, icon, description, slug },
onClick = () => {},
} = props;
return (
<div
className="rounded color-gray-darkest flex items-start border border-transparent p-4 hover:bg-active-blue hover:!border-active-blue-border cursor-pointer group hover-color-teal"
onClick={onClick}
>
<div className="pr-4 pt-1">
{/* @ts-ignore */}
<Icon name={icon} size="20" color="gray-dark" />
</div>
<div className="flex flex-col items-start text-left">
<div className="text-base">{title}</div>
<div className="text-sm color-gray-medium font-normal">{description}</div>
</div>
</div>
);
}
export default MetricTypeItem;

View file

@ -0,0 +1 @@
export { default } from './MetricTypeItem';

View file

@ -0,0 +1,46 @@
import { useModal } from 'App/components/Modal';
import React from 'react';
import MetricsLibraryModal from '../MetricsLibraryModal';
import MetricTypeItem, { MetricType } from '../MetricTypeItem/MetricTypeItem';
import { TYPES, LIBRARY } from 'App/constants/card';
import { withRouter, RouteComponentProps } from 'react-router-dom';
import { dashboardMetricCreate, metricCreate, withSiteId } from 'App/routes';
import { useStore } from 'App/mstore';
interface Props extends RouteComponentProps {
dashboardId: number;
siteId: string;
}
function MetricTypeList(props: Props) {
const { dashboardId, siteId, history } = props;
const { metricStore } = useStore();
const { hideModal } = useModal();
const { showModal } = useModal();
const onClick = ({ slug }: MetricType) => {
hideModal();
if (slug === LIBRARY) {
return showModal(<MetricsLibraryModal siteId={siteId} dashboardId={dashboardId} />, { right: true, width: 800, onClose: () => {
metricStore.updateKey('metricsSearch', '')
} });
}
// TODO redirect to card builder with metricType query param
const path = withSiteId(dashboardMetricCreate(dashboardId + ''), siteId);
const queryString = new URLSearchParams({ type: slug }).toString();
history.push({
pathname: path,
search: `?${queryString}`
});
};
return (
<>
{TYPES.map((metric: MetricType) => (
<MetricTypeItem metric={metric} onClick={() => onClick(metric)} />
))}
</>
);
}
export default withRouter(MetricTypeList);

View file

@ -0,0 +1 @@
export { default } from './MetricTypeList';

View file

@ -0,0 +1,59 @@
import React from 'react';
import { Icon, PageTitle, Button, Link, SegmentSelection } from 'UI';
import MetricsSearch from '../MetricsSearch';
import Select from 'Shared/Select';
import { useStore } from 'App/mstore';
import { useObserver } from 'mobx-react-lite';
function MetricViewHeader() {
const { metricStore } = useStore();
const sort = useObserver(() => metricStore.sort);
const listView = useObserver(() => metricStore.listView);
return (
<div>
<div className="flex items-center mb-4 justify-between px-6">
<div className="flex items-baseline mr-3">
<PageTitle title="Cards" className="" />
</div>
<div className="ml-auto flex items-center">
<Link to={'/metrics/create'}>
<Button variant="primary">New Card</Button>
</Link>
<SegmentSelection
name="viewType"
className="mx-3"
primary
onSelect={ () => metricStore.updateKey('listView', !listView) }
value={{ value: listView ? 'list' : 'grid' }}
list={ [
{ value: 'list', name: '', icon: 'graph-up-arrow' },
{ value: 'grid', name: '', icon: 'hash' },
]}
/>
<div className="mx-2">
<Select
options={[
{ label: 'Newest', value: 'desc' },
{ label: 'Oldest', value: 'asc' },
]}
defaultValue={sort.by}
plain
onChange={({ value }) => metricStore.updateKey('sort', { by: value.value })}
/>
</div>
<div className="ml-4 w-1/4" style={{ minWidth: 300 }}>
<MetricsSearch />
</div>
</div>
</div>
<div className="text-base text-disabled-text flex items-center px-6">
<Icon name="info-circle-fill" className="mr-2" size={16} />
Create custom Cards to capture key interactions and track KPIs.
</div>
</div>
);
}
export default MetricViewHeader;

View file

@ -0,0 +1 @@
export { default } from './MetricViewHeader';

View file

@ -0,0 +1,14 @@
import React from 'react';
interface Props {
}
function MetricsGrid(props: Props) {
return (
<div className="grid grid-cols-3 gap-4">
</div>
);
}
export default MetricsGrid;

View file

@ -0,0 +1 @@
export { default } from './MetricsGrid'

View file

@ -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 (
<>
<Modal.Header title="Cards Library">
<div className="flex items-center justify-between px-4 pt-4">
<div className="text-lg flex items-center font-medium">
<div>Cards Library</div>
</div>
<div>
<MetricSearch onChange={onChange} />
</div>
</div>
</Modal.Header>
<Modal.Content className="p-4 pb-20">
<div className="border">
<MetricsList siteId={siteId} onSelectionChange={onSelectionChange} />
</div>
</Modal.Content>
<Modal.Footer>
<SelectedContent dashboardId={dashboardId} selected={selectedList} />
</Modal.Footer>
</>
);
}
export default observer(MetricsLibraryModal);
function MetricSearch({ onChange }: any) {
return (
<div className="relative">
<Icon name="search" className="absolute top-0 bottom-0 ml-2 m-auto" size="16" />
<input
name="dashboardsSearch"
className="bg-white p-2 border border-borderColor-gray-light-shade rounded w-full pl-10"
placeholder="Filter by title or owner"
onChange={onChange}
/>
</div>
);
}
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 (
<div className="flex items-center rounded border bg-gray-light-shade justify-between p-3">
<div>
Selected <span className="font-medium">{selected.length}</span> of{' '}
<span className="font-medium">{total}</span>
</div>
<div className="flex items-center">
<Button variant="text-primary" className="mr-2" onClick={hideModal}>
Cancel
</Button>
<Button disabled={selected.length === 0} variant="primary" onClick={addSelectedToDashboard}>
Add Selected to Dashboard
</Button>
</div>
</div>
);
}

View file

@ -0,0 +1 @@
export { default } from './MetricsLibraryModal';

View file

@ -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 (
<div className="grid grid-cols-4 gap-4 m-4 items-start">
{list.map((metric: any) => (
<React.Fragment key={metric.metricId}>
<WidgetWrapper
key={metric.metricId}
widget={metric}
active={selectedList.includes(metric.metricId)}
// isTemplate={true}
isWidget={metric.metricType === 'predefined'}
// onClick={() => toggleSelection(parseInt(metric.metricId))}
/>
</React.Fragment>
))}
</div>
);
}
export default GridView;

View file

@ -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 (
<div>
<div className="grid grid-cols-12 py-2 font-medium px-6">
<div className="col-span-4 flex items-center">
{!disableSelection && (
<Checkbox
name="slack"
className="mr-4"
type="checkbox"
checked={allSelected}
// onClick={() => selectedList(list.map((i: any) => i.metricId))}
onClick={props.toggleAll}
/>
)}
<span>Title</span>
</div>
<div className="col-span-4">Owner</div>
<div className="col-span-2">Visibility</div>
<div className="col-span-2 text-right">Last Modified</div>
</div>
{list.map((metric: any) => (
<MetricListItem
disableSelection={disableSelection}
metric={metric}
siteId={siteId}
selected={selectedList.includes(parseInt(metric.metricId))}
toggleSelection={(e: any) => {
e.stopPropagation();
toggleSelection && toggleSelection(parseInt(metric.metricId));
}}
/>
))}
</div>
);
}
export default ListView;

View file

@ -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<any>([]);
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={
<div className="flex flex-col items-center justify-center">
<Icon name="no-metrics" size={80} color="figmaColors-accent-secondary" />
<AnimatedSVG name={ICONS.NO_CARDS} size={180} />
<div className="text-center text-gray-600 my-4">
{metricsSearch !== '' ? 'No matching results' : "You haven't created any metrics yet"}
{metricsSearch !== '' ? 'No matching results' : "You haven't created any cards yet"}
</div>
</div>
}
>
<div className="mt-3 border-b rounded bg-white">
<div className="grid grid-cols-12 py-2 font-medium px-6">
<div className="col-span-4">Title</div>
<div className="col-span-4">Owner</div>
<div className="col-span-2">Visibility</div>
<div className="col-span-2 text-right">Last Modified</div>
</div>
{listView ? (
<ListView
disableSelection={!onSelectionChange}
siteId={siteId}
list={sliceListPerPage(list, metricStore.page - 1, metricStore.pageSize)}
selectedList={selectedMetrics}
toggleSelection={toggleMetricSelection}
allSelected={list.length === selectedMetrics.length}
toggleAll={({ target: { checked, name } }) =>
setSelectedMetrics(checked ? list.map((i: any) => i.metricId) : [])
}
/>
) : (
<GridView
siteId={siteId}
list={sliceListPerPage(list, metricStore.page - 1, metricStore.pageSize)}
selectedList={selectedMetrics}
toggleSelection={toggleMetricSelection}
/>
)}
{sliceListPerPage(list, metricStore.page - 1, metricStore.pageSize).map((metric: any) => (
<React.Fragment key={metric.metricId}>
<MetricListItem metric={metric} siteId={siteId} />
</React.Fragment>
))}
</div>
<div className="w-full flex items-center justify-between pt-4 px-6">
<div className="w-full flex items-center justify-between py-4 px-6 border-t">
<div className="text-disabled-text">
Showing{' '}
<span className="font-semibold">{Math.min(list.length, metricStore.pageSize)}</span> out
of <span className="font-semibold">{list.length}</span> metrics
of <span className="font-semibold">{list.length}</span> cards
</div>
<Pagination
page={metricStore.page}

View file

@ -1,40 +1,19 @@
import React from 'react';
import { Button, PageTitle, Icon, Link } from 'UI';
import withPageTitle from 'HOCs/withPageTitle';
import MetricsList from '../MetricsList';
import MetricsSearch from '../MetricsSearch';
import { useStore } from 'App/mstore';
import { useObserver } from 'mobx-react-lite';
import MetricViewHeader from '../MetricViewHeader';
interface Props {
siteId: string;
siteId: string;
}
function MetricsView({ siteId }: Props) {
const { metricStore } = useStore();
React.useEffect(() => {
metricStore.fetchList();
}, []);
return useObserver(() => (
<div style={{ maxWidth: '1300px', margin: 'auto'}} className="bg-white rounded py-4 border">
<div className="flex items-center mb-4 justify-between px-6">
<div className="flex items-baseline mr-3">
<PageTitle title="Metrics" className="" />
</div>
<div className="ml-auto flex items-center">
<Link to={'/metrics/create'}><Button variant="primary">Create Metric</Button></Link>
<div className="ml-4 w-1/4" style={{ minWidth: 300 }}>
<MetricsSearch />
</div>
</div>
</div>
<div className="text-base text-disabled-text flex items-center px-6">
<Icon name="info-circle-fill" className="mr-2" size={16} />
Create custom Metrics to capture user frustrations, monitor your app's performance and track other KPIs.
</div>
<MetricsList siteId={siteId} />
</div>
));
return useObserver(() => (
<div style={{ maxWidth: '1300px', margin: 'auto' }} className="bg-white rounded pt-4 border">
<MetricViewHeader />
<MetricsList siteId={siteId} />
</div>
));
}
export default withPageTitle('Metrics - OpenReplay')(MetricsView);
export default withPageTitle('Cards - OpenReplay')(MetricsView);

View file

@ -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<any>();
@ -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 <SessionWidget metric={metric} data={data} />
}
if (metricType === 'errors') {
return <ErrorsWidget metric={metric} data={data} />
}
// if (metricType === ERRORS) {
// return <ErrorsWidget metric={metric} data={data} />
// }
if (metricType === 'funnel') {
if (metricType === FUNNEL) {
return <FunnelWidget metric={metric} data={data} isWidget={isWidget || isTemplate} />
}
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 <CustomMetricOverviewChart data={data} />
}
return <WidgetPredefinedChart isTemplate={isTemplate} metric={defaultMetric} data={data} predefinedKey={metric.predefinedKey} />
return <WidgetPredefinedChart isTemplate={isTemplate} metric={defaultMetric} data={data} predefinedKey={metric.metricOf} />
}
if (metricType === 'timeseries') {
// TODO add USER_PATH, RETENTION, FEATUER_ADOPTION
if (metricType === TIMESERIES) {
if (viewType === 'lineChart') {
return (
<CustomMetriLineChart
@ -134,7 +140,7 @@ function WidgetChart(props: Props) {
}
}
if (metricType === 'table') {
if (metricType === TABLE) {
if (metricOf === FilterKey.SESSIONS) {
return (
<CustomMetricTableSessions
@ -155,7 +161,7 @@ function WidgetChart(props: Props) {
/>
)
}
if (viewType === 'table') {
if (viewType === TABLE) {
return (
<CustomMetricTable
metric={metric} data={data[0]}
@ -175,8 +181,20 @@ function WidgetChart(props: Props) {
)
}
}
if (metricType === CLICKMAP) {
if (!props.isPreview) {
return (
<div>
<img src={metric.thumbnail} alt="clickmap thumbnail" />
</div>
)
}
return (
<ClickMapCard />
)
}
return <div>Unknown</div>;
return <div>Unknown metric type</div>;
}
return (
<Loader loading={loading} style={{ height: `${isOverviewWidget ? 100 : 240}px` }}>

View file

@ -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<string, any>) => 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(() => (
<div className="p-6">
<div className="form-group">
<label className="font-medium">Metric Type</label>
<div className="flex items-center">
<SegmentSelection
icons
outline
name="metricType"
className="my-3"
onSelect={ onSelect }
value={metricTypes.find((i) => 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' && (
<>
<span className="mx-3">of</span>
<Select
name="metricOf"
options={timeseriesOptions}
defaultValue={metric.metricOf}
onChange={ writeOption }
/>
</>
)}
return (
<div className="p-6">
<div className="form-group">
<div className="flex items-center">
<span className="mr-2">Card showing</span>
<MetricTypeDropdown onSelect={writeOption} />
<MetricSubtypeDropdown onSelect={writeOption} />
{metric.metricType === 'table' && (
<>
<span className="mx-3">of</span>
<Select
name="metricOf"
options={tableOptions}
defaultValue={metric.metricOf}
onChange={ writeOption }
/>
</>
)}
{metric.metricOf === FilterKey.ISSUE && (
<>
<span className="mx-3">issue type</span>
<Select
name="metricValue"
options={issueOptions}
value={metric.metricValue}
onChange={writeOption}
isMulti={true}
placeholder="All Issues"
/>
</>
)}
{metric.metricOf === FilterKey.ISSUE && (
<>
<span className="mx-3">issue type</span>
<Select
name="metricValue"
options={issueOptions}
value={metric.metricValue}
onChange={ writeOption }
isMulti={true}
placeholder="All Issues"
/>
</>
)}
{metric.metricType === 'table' && !(metric.metricOf === FilterKey.ERRORS || metric.metricOf === FilterKey.SESSIONS) && (
<>
<span className="mx-3">showing</span>
<Select
name="metricFormat"
options={[
{ value: 'sessionCount', label: 'Session Count' },
]}
defaultValue={ metric.metricFormat }
onChange={ writeOption }
/>
</>
)}
</div>
</div>
<div className="form-group">
<div className="flex items-center font-medium py-2">
{`${(isTable || isFunnel) ? 'Filter by' : 'Chart Series'}`}
{!isTable && !isFunnel && (
<Button
className="ml-2"
variant="text-primary"
onClick={() => metric.addSeries()}
disabled={!canAddSeries}
>Add Series</Button>
)}
</div>
{metric.series.length > 0 && metric.series.slice(0, (isTable || isFunnel) ? 1 : metric.series.length).map((series: any, index: number) => (
<div className="mb-2" key={series.name}>
<FilterSeries
observeChanges={() => metric.updateKey('hasChanged', true)}
hideHeader={ isTable }
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.'
}
/>
</div>
))}
</div>
<div className="form-groups flex items-center justify-between">
<Tooltip
title="Cannot save funnel metric without at least 2 events"
disabled={!cannotSaveFunnel}
>
<Button
variant="primary"
onClick={onSave}
disabled={isSaving || cannotSaveFunnel}
>
{metric.exists() ? 'Update' : 'Create'}
</Button>
</Tooltip>
<div className="flex items-center">
{metric.exists() && (
<Button variant="text-primary" onClick={onDelete}>
<Icon name="trash" size="14" className="mr-2" color="teal"/>
Delete
</Button>
)}
</div>
</div>
{metric.metricType === 'table' &&
!(metric.metricOf === FilterKey.ERRORS || metric.metricOf === FilterKey.SESSIONS) && (
<>
<span className="mx-3">showing</span>
<Select
name="metricFormat"
options={[{ value: 'sessionCount', label: 'Session Count' }]}
defaultValue={metric.metricFormat}
onChange={writeOption}
/>
</>
)}
</div>
));
</div>
{isPredefined && (
<div className="flex items-center my-6 justify-center">
<Icon name="info-circle" size="18" color="gray-medium" />
<div className="ml-2">
Filtering or modification of OpenReplay provided metrics isn't supported at the moment.
</div>
</div>
)}
{!isPredefined && (
<div className="form-group">
<div className="flex items-center font-medium py-2">
{`${isTable || isFunnel || isClickmap ? 'Filter by' : 'Chart Series'}`}
{!isTable && !isFunnel && !isClickmap && (
<Button
className="ml-2"
variant="text-primary"
onClick={() => metric.addSeries()}
disabled={!canAddSeries}
>
ADD
</Button>
)}
</div>
{metric.series.length > 0 &&
metric.series
.slice(0, isTable || isFunnel || isClickmap ? 1 : metric.series.length)
.map((series: any, index: number) => (
<div className="mb-2" key={series.name}>
<FilterSeries
observeChanges={() => 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.'
}
/>
</div>
))}
</div>
)}
<div className="form-groups flex items-center justify-between">
<Tooltip
title="Cannot save funnel metric without at least 2 events"
disabled={!cannotSaveFunnel}
>
<Button variant="primary" onClick={onSave} disabled={isSaving || cannotSaveFunnel}>
{metric.exists()
? 'Update'
: parseInt(dashboardId) > 0
? 'Create & Add to Dashboard'
: 'Create'}
</Button>
</Tooltip>
<div className="flex items-center">
{metric.exists() && (
<Button variant="text-primary" onClick={onDelete}>
<Icon name="trash" size="14" className="mr-2" color="teal" />
Delete
</Button>
)}
</div>
</div>
</div>
);
}
export default WidgetForm;
export default observer(WidgetForm);

View file

@ -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 ? (
<>
<div className="mx-3">of</div>
<Select
name="metricOf"
placeholder="Select Card Type"
options={options}
value={options.find((i: any) => i.value === metric.metricOf)}
onChange={props.onSelect}
// className="mx-2"
components={{
MenuList: ({ children, ...props }: any) => {
return (
<components.MenuList {...props} className="!p-3">
{children}
</components.MenuList>
);
},
Option: ({ children, ...props }: any) => {
const { data } = props;
return <CustomDropdownOption children={children} {...props} {...data} />;
},
}}
/>
</>
) : null;
}
export default observer(MetricSubtypeDropdown);

View file

@ -0,0 +1 @@
export { default } from './MetricSubtypeDropdown';

View file

@ -0,0 +1,11 @@
import React from 'react';
import MetricTypeDropdown from './';
export default {
title: 'Dashboad/Cards/Form/MetricTypeDropdown',
component: MetricTypeDropdown,
};
const Template = (args: any) => <MetricTypeDropdown {...args} />;
export const Simple = Template.bind({});

View file

@ -0,0 +1,82 @@
import React, { useMemo } from 'react';
import { TYPES, LIBRARY } from 'App/constants/card';
import Select from 'Shared/Select';
import { MetricType } from 'App/components/Dashboard/components/MetricTypeItem/MetricTypeItem';
import { components } from 'react-select';
import CustomDropdownOption from 'Shared/CustomDropdownOption';
import { observer } from 'mobx-react-lite';
import { useStore } from 'App/mstore';
import withLocationHandlers from 'HOCs/withLocationHandlers';
import { Icon } from 'UI';
interface Options {
label: string;
icon: string;
value: string;
description: string;
}
interface Props {
query: Record<string, (key: string) => any>;
onSelect: (arg: any) => void;
}
function MetricTypeDropdown(props: Props) {
const { metricStore } = useStore();
const metric: any = metricStore.instance;
const options: Options[] = useMemo(() => {
// TYPES.shift(); // remove "Add from library" item
return TYPES.filter((i: MetricType) => i.slug !== LIBRARY).map((i: MetricType) => ({
label: i.title,
icon: i.icon,
value: i.slug,
description: i.description,
}));
}, []);
React.useEffect(() => {
const queryCardType = props.query.get('type');
if (queryCardType && options.length > 0 && metric.metricType) {
const type = options.find((i) => i.value === queryCardType);
setTimeout(() => onChange(type.value), 0);
}
}, []);
const onChange = (type: string) => {
metricStore.changeType(type);
};
return (
<Select
name="metricType"
placeholder="Select Card Type"
options={options}
value={options.find((i: any) => i.value === metric.metricType) || options[0]}
onChange={props.onSelect}
// onSelect={onSelect}
components={{
SingleValue: ({ children, ...props }: any) => {
const { data: { icon, label } } = props;
return (
<components.SingleValue {...props}>
<div className="flex items-center">
<Icon name={icon} size="18" color="gray-medium" />
<div className="ml-2">{label}</div>
</div>
</components.SingleValue>
);
},
MenuList: ({ children, ...props }: any) => {
return (
<components.MenuList {...props} className="!p-3">
{children}
</components.MenuList>
);
},
Option: ({ children, ...props }: any) => {
const { data } = props;
return <CustomDropdownOption children={children} {...props} {...data} />;
},
}}
/>
);
}
export default withLocationHandlers()(observer(MetricTypeDropdown));

View file

@ -0,0 +1 @@
export { default } from './MetricTypeDropdown';

View file

@ -0,0 +1,29 @@
export const renderClickmapThumbnail = () => {
// @ts-ignore
return import('html2canvas').then(({ default: html2canvas }) => {
// @ts-ignore
window.html2canvas = html2canvas;
const element = document.querySelector<HTMLIFrameElement>('#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")
}
})
}

View file

@ -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 <ErrorsByType data={data} metric={metric} />
case 'errors_per_domains':
case FilterKey.ERRORS_PER_DOMAINS:
return <ErrorsPerDomain data={data} metric={metric} />
case 'resources_by_party':
case FilterKey.RESOURCES_BY_PARTY:
return <ErrorsByOrigin data={data} metric={metric} />
case 'impacted_sessions_by_js_errors':
case FilterKey.IMPACTED_SESSIONS_BY_JS_ERRORS:
return <SessionsAffectedByJSErrors data={data} metric={metric} />
case 'domains_errors_4xx':
case FilterKey.DOMAINS_ERRORS_4XX:
return <CallsErrors4xx data={data} metric={metric} />
case 'domains_errors_5xx':
case FilterKey.DOMAINS_ERRORS_5XX:
return <CallsErrors5xx data={data} metric={metric} />
case 'calls_errors':
case FilterKey.CALLS_ERRORS:
return <CallWithErrors isTemplate={isTemplate} data={data} metric={metric} />
// PERFORMANCE
case 'impacted_sessions_by_slow_pages':
case FilterKey.IMPACTED_SESSIONS_BY_SLOW_PAGES:
return <SessionsImpactedBySlowRequests data={data} metric={metric} />
case 'pages_response_time_distribution':
case FilterKey.PAGES_RESPONSE_TIME_DISTRIBUTION:
return <ResponseTimeDistribution data={data} metric={metric} />
case 'speed_location':
case FilterKey.SPEED_LOCATION:
return <SpeedIndexByLocation metric={metric} />
case 'cpu':
case FilterKey.CPU:
return <CPULoad data={data} metric={metric} />
case 'crashes':
case FilterKey.CRASHES:
return <Crashes data={data} metric={metric} />
case 'pages_dom_buildtime':
case FilterKey.PAGES_DOM_BUILD_TIME:
return <DomBuildingTime data={data} metric={metric} />
case 'fps':
case FilterKey.FPS:
return <FPS data={data} metric={metric} />
case 'memory_consumption':
case FilterKey.MEMORY_CONSUMPTION:
return <MemoryConsumption data={data} metric={metric} />
case 'pages_response_time':
case FilterKey.PAGES_RESPONSE_TIME:
return <ResponseTime data={data} metric={metric} />
case 'resources_vs_visually_complete':
case FilterKey.RESOURCES_VS_VISUALLY_COMPLETE:
return <ResourceLoadedVsVisuallyComplete data={data} metric={metric} />
case 'sessions_per_browser':
case FilterKey.SESSIONS_PER_BROWSER:
return <SessionsPerBrowser data={data} metric={metric} />
case 'slowest_domains':
case FilterKey.SLOWEST_DOMAINS:
return <SlowestDomains data={data} metric={metric} />
case 'time_to_render':
case FilterKey.TIME_TO_RENDER:
return <TimeToRender data={data} metric={metric} />
// Resources
case 'resources_count_by_type':
case FilterKey.BREAKDOWN_OF_LOADED_RESOURCES:
return <BreakdownOfLoadedResources data={data} metric={metric} />
case 'missing_resources':
case FilterKey.MISSING_RESOURCES:
return <MissingResources isTemplate={isTemplate} data={data} metric={metric} />
case 'resource_type_vs_response_end':
case FilterKey.RESOURCE_TYPE_VS_RESPONSE_END:
return <ResourceLoadedVsResponseEnd data={data} metric={metric} />
case 'resources_loading_time':
case FilterKey.RESOURCES_LOADING_TIME:
return <ResourceLoadingTime data={data} metric={metric} />
case 'slowest_resources':
case FilterKey.SLOWEST_RESOURCES:
return <SlowestResources isTemplate={isTemplate} data={data} metric={metric} />
default:

View file

@ -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 (
<>
<div className={cn(className, 'bg-white rounded border')}>
<div className="flex items-center justify-between px-4 pt-2">
@ -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) {
</>
)}
<div className="mx-4" />
{metric.metricType === CLICKMAP ? (
<ClickMapRagePicker />
) : null}
<WidgetDateRange />
{/* add to dashboard */}
{metric.exists() && (
@ -93,7 +86,7 @@ function WidgetPreview(props: Props) {
className="ml-2 p-0"
onClick={() => setShowDashboardSelectionModal(true)}
disabled={!canAddToDashboard}
>
>
<Icon name="columns-gap-filled" size="14" className="mr-2" color="teal"/>
Add to Dashboard
</Button>
@ -112,7 +105,7 @@ function WidgetPreview(props: Props) {
/>
)}
</>
));
);
}
export default WidgetPreview;
export default observer(WidgetPreview);

View file

@ -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<string, any>) => {
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 (
<div className={cn(className, "bg-white p-3 pb-0 rounded border")}>
<div className="flex items-center justify-between">
<div className="flex items-baseline">
<h2 className="text-2xl">Sessions</h2>
<h2 className="text-xl">{metricStore.clickMapSearch ? 'Clicks' : 'Sessions'}</h2>
<div className="ml-2 color-gray-medium">
{metricStore.clickMapLabel ? `on "${metricStore.clickMapLabel}" ` : null}
between <span className="font-medium color-gray-darkest">{startTime}</span> and{' '}
<span className="font-medium color-gray-darkest">{endTime}</span>{' '}
</div>
</div>
{widget.metricType !== 'table' && (
{widget.metricType !== 'table' && widget.metricType !== CLICKMAP && (
<div className="flex items-center ml-6">
<span className="mr-2 color-gray-medium">Filter by Series</span>
<Select options={seriesOptions} defaultValue={'all'} onChange={writeOption} plain />
@ -118,7 +147,7 @@ function WidgetSessions(props: Props) {
</Loader>
</div>
</div>
));
);
}
const getListSessionsBySeries = (data: any, seriesId: any) => {

View file

@ -30,7 +30,7 @@ function WidgetSubDetailsView(props: Props) {
<div>
<Breadcrumb
items={[
{ label: dashboardId ? 'Dashboard' : 'Metrics', to: dashboardId ? withSiteId('/dashboard/' + dashboardId, siteId) : withSiteId('/metrics', siteId) },
{ label: dashboardId ? 'Dashboard' : 'Cards', to: dashboardId ? withSiteId('/dashboard/' + dashboardId, siteId) : withSiteId('/metrics', siteId) },
{ label: widget.name, to: withSiteId(`/metrics/${widget.metricId}`, siteId) },
{ label: issueInstance ? issueInstance.title : 'Sub Details' }
]}

View file

@ -73,7 +73,7 @@ function WidgetView(props: Props) {
<Breadcrumb
items={[
{
label: dashboardName ? dashboardName : 'Metrics',
label: dashboardName ? dashboardName : 'Cards',
to: dashboardId ? withSiteId('/dashboard/' + dashboardId, siteId) : withSiteId('/metrics', siteId),
},
{ label: widget.name },
@ -100,7 +100,7 @@ function WidgetView(props: Props) {
</h1>
<div className="text-gray-600 w-full cursor-pointer" onClick={() => setExpanded(!expanded)}>
<div className="flex items-center select-none w-fit ml-auto">
<span className="mr-2 color-teal">{expanded ? 'Close' : 'Edit'}</span>
<span className="mr-2 color-teal">{expanded ? 'Collapse' : 'Edit'}</span>
<Icon name={expanded ? 'chevron-up' : 'chevron-down'} size="16" color="teal" />
</div>
</div>
@ -112,7 +112,7 @@ function WidgetView(props: Props) {
<WidgetPreview className="mt-8" name={widget.name} />
{widget.metricOf !== FilterKey.SESSIONS && widget.metricOf !== FilterKey.ERRORS && (
<>
{(widget.metricType === 'table' || widget.metricType === 'timeseries') && <WidgetSessions className="mt-8" />}
{(widget.metricType === 'table' || widget.metricType === 'timeseries' || widget.metricType === 'clickMap') && <WidgetSessions className="mt-8" />}
{widget.metricType === 'funnel' && <FunnelIssues />}
</>
)}

View file

@ -1,6 +1,6 @@
import React, { useRef } from 'react';
import cn from 'classnames';
import { ItemMenu, Tooltip } from 'UI';
import { ItemMenu, Tooltip, TextEllipsis } from 'UI';
import { useDrag, useDrop } from 'react-dnd';
import WidgetChart from '../WidgetChart';
import { observer } from 'mobx-react-lite';
@ -127,7 +127,7 @@ function WidgetWrapper(props: Props & RouteComponentProps) {
})}
>
{!props.hideName ? (
<div className="capitalize-first w-full font-medium">{widget.name}</div>
<div className="capitalize-first w-full font-medium"><TextEllipsis text={widget.name} /></div>
) : null}
{isWidget && (
<div className="flex items-center" id="no-print">
@ -162,7 +162,7 @@ function WidgetWrapper(props: Props & RouteComponentProps) {
<LazyLoad offset={!isTemplate ? 100 : 600}>
<div className="px-4" onClick={onChartClick}>
<WidgetChart metric={widget} isTemplate={isTemplate} isWidget={isWidget} />
<WidgetChart isPreview={isPreview} metric={widget} isTemplate={isTemplate} isWidget={isWidget} />
</div>
</LazyLoad>
</Tooltip>

View file

@ -1,9 +1,18 @@
import React, { useEffect } from 'react';
import ReactDOM from 'react-dom';
import ModalOverlay from './ModalOverlay';
import cn from 'classnames';
import { useHistory } from 'react-router';
export default function Modal({ component, props, hideModal }: any) {
const DEFAULT_WIDTH = 350;
interface Props {
component: any;
className?: string;
props: any;
hideModal?: boolean;
width?: number;
}
function Modal({ component, className = 'bg-white', props, hideModal }: Props) {
const history = useHistory();
useEffect(() => {
@ -13,10 +22,16 @@ export default function Modal({ component, props, hideModal }: any) {
}
});
});
return component ? (
ReactDOM.createPortal(
<ModalOverlay hideModal={hideModal} left={!props.right} right={props.right}>
{component}
<div
className={className}
style={{ width: `${props.width ? props.width : DEFAULT_WIDTH}px` }}
>
{component}
</div>
</ModalOverlay>,
document.querySelector('#modal-root')
)
@ -24,3 +39,36 @@ export default function Modal({ component, props, hideModal }: any) {
<></>
);
}
Modal.Header = ({ title, children }: { title?: string, children?: any }) => {
return !!children ? (
<div>
{children}
</div>
): (
<div className="text-lg flex items-center p-4 font-medium">
<div>{title}</div>
</div>
);
};
Modal.Content = ({ children, className = 'p-4' }: { children: any; className?: string }) => {
return (
<div
className={cn('overflow-y-auto relative', className)}
style={{ height: 'calc(100vh - 52px)' }}
>
{children}
</div>
);
};
Modal.Footer = ({ children, className = '' }: any) => {
return (
<div className={cn('absolute bottom-0 w-full left-0 right-0', className)} style={{}}>
{children}
</div>
);
};
export default Modal;

View file

@ -3,60 +3,59 @@ import React, { Component, createContext } from 'react';
import Modal from './Modal';
const ModalContext = createContext({
component: null,
props: {
right: true,
onClose: () => {},
},
showModal: (component: any, props: any) => {},
hideModal: () => {},
component: null,
props: {
right: true,
onClose: () => {},
},
showModal: (component: any, props: any) => {},
hideModal: () => {},
});
export class ModalProvider extends Component {
handleKeyDown = (e: any) => {
if (e.keyCode === 27) {
this.hideModal();
}
};
showModal = (component, props = { right: true }) => {
this.setState({
component,
props,
});
document.addEventListener('keydown', this.handleKeyDown);
document.querySelector('body').style.overflow = 'hidden';
};
hideModal = () => {
document.removeEventListener('keydown', this.handleKeyDown);
document.querySelector('body').style.overflow = 'visible';
const { props } = this.state;
if (props.onClose) {
props.onClose();
}
this.setState({
component: null,
props: {},
});
};
state = {
component: null,
get isModalActive() { return this.component !== null },
props: {},
showModal: this.showModal,
hideModal: this.hideModal,
};
render() {
return (
<ModalContext.Provider value={this.state}>
<Modal {...this.state} />
{this.props.children}
</ModalContext.Provider>
);
handleKeyDown = (e: any) => {
if (e.keyCode === 27) {
this.hideModal();
}
};
showModal = (component, props = { right: true }) => {
this.setState({
component,
props,
});
document.addEventListener('keydown', this.handleKeyDown);
document.querySelector('body').style.overflow = 'hidden';
};
hideModal = () => {
document.removeEventListener('keydown', this.handleKeyDown);
document.querySelector('body').style.overflow = 'visible';
const { props } = this.state;
if (props.onClose) {
props.onClose();
}
this.setState({
component: null,
props: {},
});
};
state = {
component: null,
get isModalActive() { return this.component !== null },props: {},
showModal: this.showModal,
hideModal: this.hideModal,
};
render() {
return (
<ModalContext.Provider value={this.state}>
<Modal {...this.state} />
{this.props.children}
</ModalContext.Provider>
);
}
}
export const ModalConsumer = ModalContext.Consumer;

View file

@ -1,9 +1,7 @@
import React from 'react';
import { ModalConsumer } from './';
export default BaseComponent => React.memo(props => (
<ModalConsumer>
{ value => <BaseComponent { ...value } { ...props } /> }
</ModalConsumer>
));
export default (BaseComponent) =>
React.memo((props) => (
<ModalConsumer>{(value) => <BaseComponent {...value} {...props} />}</ModalConsumer>
));

View file

@ -88,7 +88,7 @@ function LivePlayer({
const TABS = {
EVENTS: 'User Steps',
HEATMAPS: 'Click Map',
CLICKMAP: 'Click Map',
};
const [activeTab, setActiveTab] = useState('');

View file

@ -12,7 +12,7 @@ const TABS = {
HEATMAPS: 'Click Map',
};
function PlayerContent({ session, live, fullscreen, activeTab, setActiveTab }) {
function PlayerContent({ session, live, fullscreen, activeTab, setActiveTab, isClickmap }) {
const { store } = React.useContext(PlayerContext)
const {
@ -51,7 +51,7 @@ function PlayerContent({ session, live, fullscreen, activeTab, setActiveTab }) {
style={activeTab && !fullscreen ? { maxWidth: 'calc(100% - 270px)' } : undefined}
>
<div className={cn(styles.session, 'relative')} data-fullscreen={fullscreen}>
<PlayerBlock activeTab={activeTab} />
<PlayerBlock activeTab={activeTab} isClickmap={isClickmap} />
</div>
</div>
{activeTab !== '' && (

View file

@ -12,10 +12,11 @@ import ReadNote from '../Session_/Player/Controls/components/ReadNote';
import { fetchList as fetchMembers } from 'Duck/member';
import PlayerContent from './PlayerContent';
import { IPlayerContext, PlayerContext, defaultContextValue } from './playerContext';
import { observer } from 'mobx-react-lite';
const TABS = {
EVENTS: 'User Steps',
HEATMAPS: 'Click Map',
CLICKMAP: 'Click Map',
};
function WebPlayer(props: any) {
@ -23,10 +24,14 @@ function WebPlayer(props: any) {
session,
toggleFullscreen,
closeBottomBlock,
live,
fullscreen,
jwt,
fetchList
live,
fullscreen,
fetchList,
customSession,
isClickmap,
insights,
jumpTimestamp,
onMarkerClick,
} = props;
const { notesStore } = useStore();
const [activeTab, setActiveTab] = useState('');
@ -35,31 +40,56 @@ function WebPlayer(props: any) {
const [contextValue, setContextValue] = useState<IPlayerContext>(defaultContextValue);
useEffect(() => {
fetchList('issues');
const [WebPlayerInst, PlayerStore] = createWebPlayer(session, (state) =>
if (!isClickmap) {
fetchList('issues');
}
const usedSession = isClickmap && customSession ? customSession : session;
const [WebPlayerInst, PlayerStore] = createWebPlayer(usedSession, (state) =>
makeAutoObservable(state)
);
setContextValue({ player: WebPlayerInst, store: PlayerStore });
props.fetchMembers();
notesStore.fetchSessionNotes(session.sessionId).then((r) => {
const note = props.query.get('note');
if (note) {
WebPlayerInst.pause();
setNoteItem(notesStore.getNoteById(parseInt(note, 10), r));
setShowNote(true);
}
});
if (!isClickmap) {
notesStore.fetchSessionNotes(session.sessionId).then((r) => {
const note = props.query.get('note');
if (note) {
WebPlayerInst.pause();
setNoteItem(notesStore.getNoteById(parseInt(note, 10), r));
setShowNote(true);
}
});
} else {
WebPlayerInst.setMarkerClick(onMarkerClick)
}
const jumptTime = props.query.get('jumpto');
if (jumptTime) {
WebPlayerInst.jump(parseInt(jumptTime));
const jumpToTime = props.query.get('jumpto');
if (jumpToTime) {
WebPlayerInst.jump(parseInt(jumpToTime));
}
return () => WebPlayerInst.clean();
}, [session.sessionId]);
const isPlayerReady = contextValue.store?.get().ready
React.useEffect(() => {
contextValue.player && contextValue.player.play()
if (isClickmap && isPlayerReady && insights.size > 0) {
setTimeout(() => {
contextValue.player.jump(jumpTimestamp)
contextValue.player.pause()
contextValue.player.scaleFullPage()
setTimeout(() => { contextValue.player.showClickmap(insights) }, 250)
}, 500)
}
return () => {
isPlayerReady && contextValue.player.showClickmap(null)
}
}, [insights, isPlayerReady, jumpTimestamp])
// LAYOUT (TODO: local layout state - useContext or something..)
useEffect(
() => () => {
@ -78,36 +108,39 @@ function WebPlayer(props: any) {
return (
<PlayerContext.Provider value={contextValue}>
<>
<>
{!isClickmap ? (
<PlayerBlockHeader
// @ts-ignore TODO?
// @ts-ignore TODO?
activeTab={activeTab}
setActiveTab={setActiveTab}
tabs={TABS}
fullscreen={fullscreen}
/>
{/* @ts-ignore */}
<PlayerContent
activeTab={activeTab}
fullscreen={fullscreen}
live={live}
setActiveTab={setActiveTab}
session={session}
/>
<Modal open={showNoteModal} onClose={onNoteClose}>
{showNoteModal ? (
<ReadNote
userEmail={
props.members.find((m: Record<string, any>) => m.id === noteItem?.userId)
?.email || ''
}
note={noteItem}
onClose={onNoteClose}
notFound={!noteItem}
/>
) : null}
</Modal>
</>
) : null}
{/* @ts-ignore */}
<PlayerContent
activeTab={activeTab}
fullscreen={fullscreen}
live={live}
setActiveTab={setActiveTab}
session={session}
isClickmap={isClickmap}
/>
<Modal open={showNoteModal} onClose={onNoteClose}>
{showNoteModal ? (
<ReadNote
userEmail={
props.members.find((m: Record<string, any>) => m.id === noteItem?.userId)?.email
|| ''
}
note={noteItem}
onClose={onNoteClose}
notFound={!noteItem}
/>
) : null}
</Modal>
</>
</PlayerContext.Provider>
);
}
@ -115,6 +148,8 @@ function WebPlayer(props: any) {
export default connect(
(state: any) => ({
session: state.getIn(['sessions', 'current']),
insights: state.getIn(['sessions', 'insights']),
visitedEvents: state.getIn(['sessions', 'visitedEvents']),
jwt: state.getIn(['user', 'jwt']),
fullscreen: state.getIn(['components', 'player', 'fullscreen']),
showEvents: state.get('showEvents'),
@ -126,4 +161,4 @@ export default connect(
fetchList,
fetchMembers,
}
)(withLocationHandlers()(WebPlayer));
)(withLocationHandlers()(observer(WebPlayer)));

View file

@ -100,7 +100,7 @@ function PageInsightsPanel({ filters, fetchInsights, events = [], insights, urlO
}
export default connect(
(state) => {
(state: any) => {
const events = state.getIn(['sessions', 'visitedEvents']);
return {
filters: state.getIn(['sessions', 'insightFilters']),

View file

@ -19,11 +19,13 @@ import { observer } from 'mobx-react-lite';
interface Props {
nextId: string,
closedLive?: boolean,
isClickmap?: boolean,
}
function Overlay({
nextId,
closedLive,
isClickmap,
}: Props) {
const { player, store } = React.useContext(PlayerContext)
@ -49,7 +51,7 @@ function Overlay({
const concetionStatus = peerConnectionStatus
const showAutoplayTimer = !live && completed && autoplay && nextId
const showPlayIconLayer = !live && !markedTargets && !inspectorMode && !loading && !showAutoplayTimer;
const showPlayIconLayer = !isClickmap && !live && !markedTargets && !inspectorMode && !loading && !showAutoplayTimer;
const showLiveStatusText = live && livePlay && liveStatusText && !loading;
const showRequestWindow =

View file

@ -44,65 +44,66 @@ function Player(props) {
activeTab,
fullView,
isMultiview,
isClickmap,
} = props;
const playerContext = React.useContext(PlayerContext)
const playerContext = React.useContext(PlayerContext);
const screenWrapper = React.useRef();
const bottomBlockIsActive = !fullscreen && bottomBlock !== NONE
const bottomBlockIsActive = !fullscreen && bottomBlock !== NONE;
React.useEffect(() => {
props.updateLastPlayedSession(props.sessionId);
if (!props.closedLive || isMultiview) {
const parentElement = findDOMNode(screenWrapper.current); //TODO: good architecture
playerContext.player.attach(parentElement)
playerContext.player.attach(parentElement);
playerContext.player.play();
}
}, [])
}, []);
React.useEffect(() => {
playerContext.player.scale();
}, [props.bottomBlock, props.fullscreen, playerContext.player])
}, [props.bottomBlock, props.fullscreen, playerContext.player]);
if (!playerContext.player) return null;
const maxWidth = activeTab ? 'calc(100vw - 270px)' : '100vw';
return (
<div
className={cn(className, stl.playerBody, 'flex flex-col relative', fullscreen && 'pb-2')}
data-bottom-block={bottomBlockIsActive}
>
{fullscreen && <EscapeButton onClose={fullscreenOff} />}
<div className="relative flex-1 overflow-hidden">
<Overlay nextId={nextId} closedLive={closedLive} />
<div className={stl.screenWrapper} ref={screenWrapper} />
className={cn(className, stl.playerBody, 'flex flex-col relative', fullscreen && 'pb-2')}
data-bottom-block={bottomBlockIsActive}
>
{fullscreen && <EscapeButton onClose={fullscreenOff} />}
<div className={cn("relative flex-1", isClickmap ? 'overflow-visible' : 'overflow-hidden')}>
<Overlay nextId={nextId} closedLive={closedLive} isClickmap={isClickmap} />
<div className={cn(stl.screenWrapper, isClickmap && '!overflow-y-scroll')} ref={screenWrapper} />
</div>
{!fullscreen && !!bottomBlock && (
<div style={{ maxWidth, width: '100%' }}>
{bottomBlock === OVERVIEW && <OverviewPanel />}
{bottomBlock === CONSOLE && <ConsolePanel />}
{bottomBlock === NETWORK && <NetworkPanel />}
{/* {bottomBlock === STACKEVENTS && <StackEvents />} */}
{bottomBlock === STACKEVENTS && <StackEventPanel />}
{bottomBlock === STORAGE && <Storage />}
{bottomBlock === PROFILER && <ProfilerPanel />}
{bottomBlock === PERFORMANCE && <ConnectedPerformance />}
{bottomBlock === GRAPHQL && <GraphQL />}
{bottomBlock === EXCEPTIONS && <Exceptions />}
{bottomBlock === INSPECTOR && <Inspector />}
</div>
{!fullscreen && !!bottomBlock && (
<div style={{ maxWidth, width: '100%' }}>
{bottomBlock === OVERVIEW && <OverviewPanel />}
{bottomBlock === CONSOLE && <ConsolePanel />}
{bottomBlock === NETWORK && (
<NetworkPanel />
)}
{/* {bottomBlock === STACKEVENTS && <StackEvents />} */}
{bottomBlock === STACKEVENTS && <StackEventPanel />}
{bottomBlock === STORAGE && <Storage />}
{bottomBlock === PROFILER && <ProfilerPanel />}
{bottomBlock === PERFORMANCE && <ConnectedPerformance />}
{bottomBlock === GRAPHQL && <GraphQL />}
{bottomBlock === EXCEPTIONS && <Exceptions />}
{bottomBlock === INSPECTOR && <Inspector />}
</div>
)}
{!fullView && !isMultiview && <Controls
)}
{!fullView && !isMultiview && !isClickmap ? (
<Controls
speedDown={playerContext.player.speedDown}
speedUp={playerContext.player.speedUp}
jump={playerContext.player.jump}
/>}
</div>
)
/>
) : null}
</div>
);
}
export default connect((state) => {
export default connect(
(state) => {
const isAssist = window.location.pathname.includes('/assist/');
return {
fullscreen: state.getIn(['components', 'player', 'fullscreen']),
@ -118,4 +119,4 @@ export default connect((state) => {
fullscreenOff,
updateLastPlayedSession,
}
)(Player)
)(Player);

View file

@ -14,19 +14,21 @@ import styles from './playerBlock.module.css';
}))
export default class PlayerBlock extends React.PureComponent {
render() {
const { fullscreen, sessionId, disabled, activeTab, jiraConfig, fullView = false, isMultiview } = this.props;
const { fullscreen, sessionId, disabled, activeTab, jiraConfig, fullView = false, isMultiview, isClickmap } = this.props;
const shouldShowSubHeader = !fullscreen && !fullView && !isMultiview && !isClickmap
return (
<div className={cn(styles.playerBlock, 'flex flex-col overflow-x-hidden')} style={{ minWidth: isMultiview ? '100%' : undefined }}>
{!fullscreen && !fullView && !isMultiview && (
<div className={cn(styles.playerBlock, 'flex flex-col', !isClickmap ? 'overflow-x-hidden' : 'overflow-visible')} style={{ zIndex: isClickmap ? 1 : undefined, minWidth: isMultiview || isClickmap ? '100%' : undefined }}>
{shouldShowSubHeader ? (
<SubHeader sessionId={sessionId} disabled={disabled} jiraConfig={jiraConfig} />
)}
) : null}
<Player
className="flex-1"
fullscreen={fullscreen}
activeTab={activeTab}
fullView={fullView}
isMultiview={isMultiview}
isClickmap={isClickmap}
/>
</div>
);

View file

@ -74,7 +74,7 @@ function PlayerBlockHeader(props: any) {
return { label: key, value };
});
const TABS = [props.tabs.EVENTS, props.tabs.HEATMAPS].map((tab) => ({
const TABS = [props.tabs.EVENTS, props.tabs.CLICKMAP].map((tab) => ({
text: tab,
key: tab,
}));

View file

@ -16,6 +16,10 @@ import NoIssues from '../../../svg/ca-no-issues.svg';
import NoAuditTrail from '../../../svg/ca-no-audit-trail.svg';
import NoAnnouncements from '../../../svg/ca-no-announcements.svg';
import NoAlerts from '../../../svg/ca-no-alerts.svg';
import NoNotes from '../../../svg/ca-no-notes.svg';
import NoCards from '../../../svg/ca-no-cards.svg';
import NoSearchResults from '../../../svg/ca-no-search-results.svg';
import NoDashboards from '../../../svg/ca-no-dashboards.svg';
export enum ICONS {
DASHBOARD_ICON = 'dashboard-icn',
@ -35,6 +39,10 @@ export enum ICONS {
NO_AUDIT_TRAIL = 'ca-no-audit-trail',
NO_ANNOUNCEMENTS = 'ca-no-announcements',
NO_ALERTS = 'ca-no-alerts',
NO_NOTES = 'ca-no-notes',
NO_CARDS = 'ca-no-cards',
NO_SEARCH_RESULTS = 'ca-no-search-results',
NO_DASHBOARDS = 'ca-no-dashboards',
}
interface Props {
@ -79,6 +87,14 @@ function AnimatedSVG(props: Props) {
return <img style={{ width: size + 'px' }} src={NoAnnouncements} />;
case ICONS.NO_ALERTS:
return <img style={{ width: size + 'px' }} src={NoAlerts} />;
case ICONS.NO_NOTES:
return <img style={{ width: size + 'px' }} src={NoNotes} />;
case ICONS.NO_CARDS:
return <img style={{ width: size + 'px' }} src={NoCards} />;
case ICONS.NO_SEARCH_RESULTS:
return <img style={{ width: size + 'px' }} src={NoSearchResults} />;
case ICONS.NO_DASHBOARDS:
return <img style={{ width: size + 'px' }} src={NoDashboards} />;
default:
return null;
}

View file

@ -0,0 +1,38 @@
import React from 'react';
import { components, OptionProps } from 'react-select';
import { Icon } from 'UI';
import cn from 'classnames';
export interface Props extends OptionProps {
icon?: string;
label: string;
description: string;
}
function CustomDropdownOption(props: Props) {
const { icon = '', label, description, isSelected, isFocused } = props;
return (
<components.Option {...props} className="!p-0 mb-2">
<div
className={cn(
'group p-2 flex item-start border border-transparent rounded hover:border-teal hover:!bg-active-blue !leading-0'
)}
>
{icon && (
<Icon
// @ts-ignore
name={icon}
className="pt-2 mr-3"
size={18}
color={isSelected || isFocused ? 'teal' : 'gray-dark'}
/>
)}
<div className={cn('flex flex-col', { '!color-teal': isFocused || isSelected })}>
<div className="font-medium leading-0">{label}</div>
<div className="text-sm color-gray-dark">{description}</div>
</div>
</div>
</components.Option>
);
}
export default CustomDropdownOption;

View file

@ -0,0 +1 @@
export { default } from './CustomDropdownOption';

View file

@ -1 +1 @@
export { default } from './DateRangeDropdown';
// export { default } from './DateRangeDropdown';

View file

@ -115,6 +115,7 @@ interface Props {
onSelect: (e: any, item: any) => void;
value: any;
icon?: string;
hideOrText?: boolean
}
function FilterAutoComplete(props: Props) {
@ -128,6 +129,7 @@ function FilterAutoComplete(props: Props) {
endpoint = '',
params = {},
value = '',
hideOrText = false,
} = props;
const [loading, setLoading] = useState(false);
const [options, setOptions] = useState<any>([]);
@ -240,7 +242,7 @@ function FilterAutoComplete(props: Props) {
</div>
</div>
{!showOrButton && <div className="ml-3">or</div>}
{!showOrButton && !hideOrText && <div className="ml-3">or</div>}
</div>
);
}

View file

@ -28,11 +28,11 @@ function FilterItem(props: Props) {
});
};
const onOperatorChange = (e: any, { name, value }: any) => {
const onOperatorChange = (e: any, { value }: any) => {
props.onUpdate({ ...filter, operator: value });
};
const onSourceOperatorChange = (e: any, { name, value }: any) => {
const onSourceOperatorChange = (e: any, { value }: any) => {
props.onUpdate({ ...filter, sourceOperator: value });
};

View file

@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import React, { useEffect } from 'react';
import FilterItem from '../FilterItem';
import { SegmentSelection, Tooltip } from 'UI';
import { List } from 'immutable';
@ -48,7 +48,7 @@ function FilterList(props: Props) {
<SegmentSelection
primary
name="eventsOrder"
extraSmall={true}
size="small"
onSelect={props.onChangeEventsOrder}
value={{ value: filter.eventsOrder }}
list={[

View file

@ -4,6 +4,7 @@ import { connect } from 'react-redux';
import cn from 'classnames';
import stl from './FilterModal.module.css';
import { filtersMap } from 'Types/filter/newFilter';
import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG';
export const getMatchingEntries = (searchQuery: string, filters: Record<string, any>) => {
const matchingCategories: string[] = [];
@ -61,7 +62,6 @@ function FilterModal(props: Props) {
const isResultEmpty = (!filterSearchList || Object.keys(filterSearchList).length === 0)
&& matchingCategories.length === 0 && Object.keys(matchingFilters).length === 0
// console.log(matchingFilters)
return (
<div className={stl.wrapper} style={{ width: '480px', maxHeight: '380px', overflowY: 'auto'}}>
<div className={searchQuery && !isResultEmpty ? 'mb-6' : ''} style={{ columns: matchingCategories.length > 1 ? 'auto 200px' : 1 }}>
@ -86,8 +86,8 @@ function FilterModal(props: Props) {
<Loader size="small" loading={fetchingFilterSearchList}>
<div className="-mx-6 px-6">
{isResultEmpty && !fetchingFilterSearchList ? (
<div className="flex items-center">
<Icon className="color-gray-medium" name="binoculars" size="24" />
<div className="flex items-center flex-col">
<AnimatedSVG name={ICONS.NO_SEARCH_RESULTS} size={180} />
<div className="color-gray-medium font-medium px-3"> No Suggestions Found </div>
</div>
) : Object.keys(filterSearchList).map((key, index) => {

View file

@ -49,9 +49,9 @@ function FilterValue(props: Props) {
setDurationValues({ ...durationValues, ...newValues });
};
const handleBlur = (e: any) => {
const handleBlur = () => {
if (filter.type === FilterType.DURATION) {
const { maxDuration, minDuration, key } = filter;
const { maxDuration, minDuration } = filter;
if (maxDuration || minDuration) return;
if (maxDuration !== durationValues.maxDuration || minDuration !== durationValues.minDuration) {
props.onUpdate({ ...filter, value: [durationValues.minDuration, durationValues.maxDuration] });

View file

@ -0,0 +1,28 @@
import SankeyChart, { SankeyChartData } from './SankeyChart';
import React from 'react';
import { ComponentMeta, ComponentStory } from '@storybook/react';
const data: SankeyChartData = {
nodes: [
{ name: 'Home Page' },
{ name: 'Dashboard' },
{ name: 'Preferences' },
{ name: 'Billing' },
],
links: [
{ source: 0, target: 1, value: 100 },
{ source: 1, target: 2, value: 50 },
{ source: 1, target: 3, value: 50 },
{ source: 2, target: 3, value: 10 },
],
};
export default {
title: 'Dashboad/Cards/SankeyChart',
component: SankeyChart,
} as ComponentMeta<typeof SankeyChart>;
const Template: ComponentStory<typeof SankeyChart> = (args: any) => <SankeyChart {...args} />;
export const Simple = Template.bind({});
Simple.args = { data };

View file

@ -0,0 +1,134 @@
import React from 'react';
import { Sankey, Tooltip, Rectangle, Layer, ResponsiveContainer } from 'recharts';
type Node = {
name: string;
}
type Link = {
source: number;
target: number;
value: number;
}
export interface SankeyChartData {
links: Link[];
nodes: Node[];
}
interface Props {
data: SankeyChartData;
nodePadding?: number;
nodeWidth?: number;
}
function SankeyChart(props: Props) {
const { data, nodePadding = 50, nodeWidth = 10 } = props;
return (
<div className="rounded border shadow">
<div className="text-lg p-3 border-b bg-gray-lightest">Sankey Chart</div>
<div className="">
<ResponsiveContainer height={500} width="100%">
<Sankey
width={960}
height={500}
data={data}
// node={{ stroke: '#77c878', strokeWidth: 0 }}
node={<CustomNodeComponent />}
nodePadding={nodePadding}
nodeWidth={nodeWidth}
margin={{
left: 10,
right: 100,
top: 10,
bottom: 10,
}}
link={<CustomLinkComponent />}
>
<defs>
<linearGradient id={'linkGradient'}>
<stop offset="0%" stopColor="rgba(0, 136, 254, 0.5)" />
<stop offset="100%" stopColor="rgba(0, 197, 159, 0.3)" />
</linearGradient>
</defs>
<Tooltip content={<CustomTooltip />} />
</Sankey>
</ResponsiveContainer>
</div>
</div>
);
}
export default SankeyChart;
const CustomTooltip = (props: any) => {
return <div className="rounded bg-white border p-0 px-1 text-sm">test</div>;
// if (active && payload && payload.length) {
// return (
// <div className="custom-tooltip">
// <p className="label">{`${label} : ${payload[0].value}`}</p>
// <p className="intro">{getIntroOfPage(label)}</p>
// <p className="desc">Anything you want can be displayed here.</p>
// </div>
// );
// }
return null;
};
function CustomNodeComponent({ x, y, width, height, index, payload, containerWidth }: any) {
const isOut = x + width + 6 > containerWidth;
return (
<Layer key={`CustomNode${index}`}>
<Rectangle x={x} y={y} width={width} height={height} fill="#5192ca" fillOpacity="1" />
<text
textAnchor={isOut ? 'end' : 'start'}
x={isOut ? x - 6 : x + width + 6}
y={y + height / 2}
fontSize="8"
// stroke="#333"
>
{payload.name}
</text>
<text
textAnchor={isOut ? 'end' : 'start'}
x={isOut ? x - 6 : x + width + 6}
y={y + height / 2 + 13}
fontSize="12"
// stroke="#333"
// strokeOpacity="0.5"
>
{payload.value + 'k'}
</text>
</Layer>
);
}
const CustomLinkComponent = (props: any) => {
const [fill, setFill] = React.useState('url(#linkGradient)');
const { sourceX, targetX, sourceY, targetY, sourceControlX, targetControlX, linkWidth, index } =
props;
return (
<Layer key={`CustomLink${index}`}>
<path
d={`
M${sourceX},${sourceY + linkWidth / 2}
C${sourceControlX},${sourceY + linkWidth / 2}
${targetControlX},${targetY + linkWidth / 2}
${targetX},${targetY + linkWidth / 2}
L${targetX},${targetY - linkWidth / 2}
C${targetControlX},${targetY - linkWidth / 2}
${sourceControlX},${sourceY - linkWidth / 2}
${sourceX},${sourceY - linkWidth / 2}
Z
`}
fill={fill}
strokeWidth="0"
onMouseEnter={() => {
setFill('rgba(0, 136, 254, 0.5)');
}}
onMouseLeave={() => {
setFill('url(#linkGradient)');
}}
/>
</Layer>
);
};

View file

@ -0,0 +1 @@
export { default } from './SankeyChart';

View file

@ -0,0 +1,23 @@
import { storiesOf } from '@storybook/react';
import ScatterChart from './ScatterChart';
const data01 = [
{ x: 100, y: 200, z: 200 },
{ x: 120, y: 100, z: 260 },
{ x: 170, y: 300, z: 400 },
{ x: 140, y: 250, z: 280 },
{ x: 150, y: 400, z: 500 },
{ x: 110, y: 280, z: 200 },
];
const data02 = [
{ x: 200, y: 260, z: 240 },
{ x: 240, y: 290, z: 220 },
{ x: 190, y: 290, z: 250 },
{ x: 198, y: 250, z: 210 },
{ x: 180, y: 280, z: 260 },
{ x: 210, y: 220, z: 230 },
];
storiesOf('ScatterChart', module).add('Pure', () => (
<ScatterChart dataFirst={data01} dataSecond={data02} />
));

View file

@ -0,0 +1,45 @@
import React from 'react';
import {
ScatterChart,
Scatter,
Tooltip,
CartesianGrid,
XAxis,
YAxis,
ZAxis,
Legend,
ResponsiveContainer,
} from 'recharts';
interface Props {
dataFirst: any;
dataSecond: any;
}
function ScatterChartComponent(props: Props) {
const { dataFirst, dataSecond } = props;
return (
<div className="rounded border shadow">
<div className="text-lg p-3 border-b bg-gray-lightest">Scatter Chart</div>
<div className="">
<ResponsiveContainer height={500} width="100%">
<ScatterChart
width={730}
height={250}
margin={{ top: 20, right: 20, bottom: 10, left: 10 }}
>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="x" name="stature" unit="cm" />
<YAxis dataKey="y" name="weight" unit="kg" />
<ZAxis dataKey="z" range={[64, 144]} name="score" unit="km" />
<Tooltip cursor={{ strokeDasharray: '3 3' }} />
<Legend />
<Scatter name="A school" data={dataFirst} fill="#8884d8" />
<Scatter name="B school" data={dataSecond} fill="#82ca9d" />
</ScatterChart>
</ResponsiveContainer>
</div>
</div>
);
}
export default ScatterChartComponent;

View file

@ -0,0 +1 @@
export { default } from './ScatterChart';

View file

@ -118,7 +118,7 @@ function LiveSessionList(props: Props) {
<NoContent
title={
<div className="flex items-center justify-center flex-col">
<AnimatedSVG name={ICONS.NO_LIVE_SESSIONS} size={170} />
<AnimatedSVG name={ICONS.NO_LIVE_SESSIONS} size={180} />
<div className="mt-2" />
<div className="text-center text-gray-600">No live sessions found.</div>
</div>

View file

@ -4,6 +4,7 @@ import { sliceListPerPage } from 'App/utils';
import NoteItem from './NoteItem';
import { observer } from 'mobx-react-lite';
import { useStore } from 'App/mstore';
import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG';
function NotesList({ members }: { members: Array<Record<string, any>> }) {
const { notesStore } = useStore();
@ -20,7 +21,8 @@ function NotesList({ members }: { members: Array<Record<string, any>> }) {
show={list.length === 0}
title={
<div className="flex flex-col items-center justify-center">
<Icon name="no-dashboard" size={80} color="figmaColors-accent-secondary" />
{/* <Icon name="no-dashboard" size={80} color="figmaColors-accent-secondary" /> */}
<AnimatedSVG name={ICONS.NO_NOTES} size={180} />
<div className="text-center text-gray-600 my-4">No notes yet</div>
</div>
}

View file

@ -137,13 +137,13 @@ function SessionList(props: Props) {
<NoContent
title={
<div className="flex items-center justify-center flex-col">
<AnimatedSVG name={NO_CONTENT.icon} size={170} />
<AnimatedSVG name={NO_CONTENT.icon} size={180} />
<div className="mt-2" />
<div className="text-center text-gray-600 relative">
{NO_CONTENT.message}
{noContentType === NoContentType.ToDate ? (
<div style={{ position: 'absolute', right: -170, top: -110 }}>
<Icon name="list-arrow" size={130} width={150} />
<div style={{ position: 'absolute', right: -200, top: -170 }}>
<Icon name="pointer-sessions-search" size={250} width={240} />
</div>
) : null}
</div>

File diff suppressed because one or more lines are too long

View file

@ -38,9 +38,9 @@ class SegmentSelection extends React.Component {
className
)}
>
{list.map((item) => (
{list.map((item, i) => (
<div
key={item.name}
key={`${item.name}-${i}`}
className={cn(styles.item, 'w-full', { 'opacity-25 cursor-default': item.disabled })}
data-active={this.props.value && this.props.value.value === item.value}
onClick={() => !item.disabled && this.setActiveItem(item)}
@ -48,7 +48,7 @@ class SegmentSelection extends React.Component {
{item.icon && (
<Icon
name={item.icon}
size={size === 'extraSmall' || icons ? 14 : 20}
size={size === 'extraSmall' || size === 'small' || icons ? 14 : 20}
marginRight={item.name ? '6' : ''}
/>
)}

View file

@ -10,7 +10,8 @@
& .item {
color: $gray-medium;
font-weight: medium;
padding: 10px;
padding: 0 6px;
height: 33px;
flex: 1;
text-align: center;
cursor: pointer;
@ -70,6 +71,7 @@
.small .item {
padding: 4px 8px;
height: 26px;
}
.extraSmall .item {

View file

@ -0,0 +1,225 @@
import { IconNames } from 'App/components/ui/SVG';
import { FilterKey, IssueType } from 'Types/filter/filterType';
export interface CardType {
title: string;
icon?: IconNames;
description: string;
slug: string;
subTypes?: CardType[];
}
export const LIBRARY = 'library';
export const TIMESERIES = 'timeseries';
export const TABLE = 'table';
export const CLICKMAP = 'clickMap';
export const FUNNEL = 'funnel';
export const ERRORS = 'errors';
export const PERFORMANCE = 'performance';
export const RESOURCE_MONITORING = 'resources';
export const WEB_VITALS = 'webVitals';
export const USER_PATH = 'userPath';
export const RETENTION = 'retention';
export const FEATURE_ADOPTION = 'featureAdoption';
export const TYPES: CardType[] = [
{
title: 'Add from Library',
icon: 'grid',
description: 'Select a pre existing card from card library',
slug: LIBRARY,
},
{
title: 'Clickmap',
icon: 'puzzle-piece',
description: 'Track the features that are being used the most.',
slug: CLICKMAP,
subTypes: [
{ title: 'Visited URL', slug: FilterKey.CLICKMAP_URL, description: "" },
]
},
{
title: 'Timeseries',
icon: 'graph-up',
description: 'Trend of sessions count in over the time.',
slug: TIMESERIES,
subTypes: [{ title: 'Session Count', slug: 'sessionCount', description: '' }],
},
{
title: 'Table',
icon: 'list-alt',
description: 'See list of Users, Sessions, Errors, Issues, etc.,',
slug: TABLE,
subTypes: [
{ title: 'Users', slug: FilterKey.USERID, description: '' },
{ title: 'Sessions', slug: FilterKey.SESSIONS, description: '' },
{ title: 'JS Errors', slug: FilterKey.ERRORS, description: '' },
{ title: 'Issues', slug: FilterKey.ISSUE, description: '' },
{ title: 'Browser', slug: FilterKey.USER_BROWSER, description: '' },
{ title: 'Devices', slug: FilterKey.USER_DEVICE, description: '' },
{ title: 'Countries', slug: FilterKey.USER_COUNTRY, description: '' },
{ title: 'URLs', slug: FilterKey.LOCATION, description: '' },
],
},
{
title: 'Funnel',
icon: 'funnel',
description: 'Uncover the issues impacting user journeys.',
slug: FUNNEL,
},
{
title: 'Errors Tracking',
icon: 'exclamation-circle',
description: 'Discover user journeys between 2 points.',
slug: ERRORS,
subTypes: [
{ title: 'Resources by Party', slug: FilterKey.RESOURCES_BY_PARTY, description: '' },
{ title: 'Errors per Domains', slug: FilterKey.ERRORS_PER_DOMAINS, description: '' },
{ title: 'Errors per type', slug: FilterKey.ERRORS_PER_TYPE, description: '' },
{ title: 'Calls Errors', slug: FilterKey.CALLS_ERRORS, description: '' },
{ title: 'Domains Errors 4xx', slug: FilterKey.DOMAINS_ERRORS_4XX, description: '' },
{ title: 'Domains Errors 5xx', slug: FilterKey.DOMAINS_ERRORS_5XX, description: '' },
{
title: 'Impacted Sessions by JS Errors',
slug: FilterKey.IMPACTED_SESSIONS_BY_JS_ERRORS,
description: '',
},
],
},
{
title: 'Performance Monitoring',
icon: 'speedometer2',
description: 'Retention graph of users / features over a period of time.',
slug: PERFORMANCE,
subTypes: [
{ title: 'CPU', slug: FilterKey.CPU, description: '' },
{ title: 'Crashes', slug: FilterKey.CRASHES, description: '' },
{ title: 'FPS', slug: FilterKey.FPS, description: '' },
{ title: 'Pages Dom Build Time', slug: FilterKey.PAGES_DOM_BUILD_TIME, description: '' },
{ title: 'Memory Consumption', slug: FilterKey.MEMORY_CONSUMPTION, description: '' },
{ title: 'Pages Response Time', slug: FilterKey.PAGES_RESPONSE_TIME, description: '' },
{
title: 'Pages Response Time Distribution',
slug: FilterKey.PAGES_RESPONSE_TIME_DISTRIBUTION,
description: '',
},
{
title: 'Resources vs Visually Complete',
slug: FilterKey.RESOURCES_VS_VISUALLY_COMPLETE,
description: '',
},
{ title: 'Sessions per Browser', slug: FilterKey.SESSIONS_PER_BROWSER, description: '' },
{ title: 'Slowest Domains', slug: FilterKey.SLOWEST_DOMAINS, description: '' },
{ title: 'Speed Location', slug: FilterKey.SPEED_LOCATION, description: '' },
{ title: 'Time to Render', slug: FilterKey.TIME_TO_RENDER, description: '' },
{
title: 'Impacted Sessions by Slow Pages',
slug: FilterKey.IMPACTED_SESSIONS_BY_SLOW_PAGES,
description: '',
},
],
},
{
title: 'Resource Monitoring',
icon: 'files',
description: 'Find the adoption of your all features in your app.',
slug: RESOURCE_MONITORING,
subTypes: [
{
title: 'Breakdown of Loaded Resources',
slug: FilterKey.BREAKDOWN_OF_LOADED_RESOURCES,
description: '',
},
{ title: 'Missing Resources', slug: FilterKey.MISSING_RESOURCES, description: '' },
{
title: 'Resource Type vs Response End',
slug: FilterKey.RESOURCE_TYPE_VS_RESPONSE_END,
description: '',
},
{ title: 'Resource Fetch Time', slug: FilterKey.RESOURCE_FETCH_TIME, description: '' },
{ title: 'Slowest Resources', slug: FilterKey.SLOWEST_RESOURCES, description: '' },
],
},
{
title: 'Web Vitals',
icon: 'activity',
description: 'Find the adoption of your all features in your app.',
slug: WEB_VITALS,
subTypes: [
{
title: 'CPU Load',
slug: FilterKey.AVG_CPU,
description: 'Uncover the issues impacting user journeys',
},
{
title: 'DOM Content Loaded',
slug: FilterKey.AVG_DOM_CONTENT_LOADED,
description: 'Keep a close eye on errors and track their type, origin and domain.',
},
{
title: 'DOM Content Loaded Start',
slug: FilterKey.AVG_DOM_CONTENT_LOAD_START,
description:
'FInd out which resources are missing and those that may be slowign your web app.',
},
{
title: 'First Meaningful Paint',
slug: FilterKey.AVG_FIRST_CONTENTFUL_PIXEL,
description:
"Optimize your app's performance by tracking slow domains, page resposne times, memory consumption, CPU usage and more.",
},
{
title: 'First Paint',
slug: FilterKey.AVG_FIRST_PAINT,
description:
'Find out which resources are missing and those that may be slowing your web app.',
},
{ title: 'Frame Rate', slug: FilterKey.AVG_FPS, description: '' },
{
title: 'Image Load Time',
slug: FilterKey.AVG_IMAGE_LOAD_TIME,
description:
'Find out which resources are missing and those that may be slowing your web app.',
},
{ title: 'Page Load Time', slug: FilterKey.AVG_PAGE_LOAD_TIME, description: '' },
{ title: 'DOM Build Time', slug: FilterKey.AVG_PAGES_DOM_BUILD_TIME, description: '' },
{ title: 'Pages Response Time', slug: FilterKey.AVG_PAGES_RESPONSE_TIME, description: '' },
{ title: 'Request Load Time', slug: FilterKey.AVG_REQUEST_LOADT_IME, description: '' },
{ title: 'Response Time ', slug: FilterKey.AVG_RESPONSE_TIME, description: '' },
{ title: 'Session Dueration', slug: FilterKey.AVG_SESSION_DURATION, description: '' },
{ title: 'Time Till First Byte', slug: FilterKey.AVG_TILL_FIRST_BYTE, description: '' },
{ title: 'Time to be Interactive', slug: FilterKey.AVG_TIME_TO_INTERACTIVE, description: '' },
{ title: 'Time to Render', slug: FilterKey.AVG_TIME_TO_RENDER, description: '' },
{ title: 'JS Heap Size', slug: FilterKey.AVG_USED_JS_HEAP_SIZE, description: '' },
{ title: 'Visited Pages', slug: FilterKey.AVG_VISITED_PAGES, description: '' },
{
title: 'Captured Requests',
slug: FilterKey.COUNT_REQUESTS,
description: 'Trend of sessions count in over the time.',
},
{
title: 'Captured Sessions',
slug: FilterKey.COUNT_SESSIONS,
description: 'See list of users, sessions, errors, issues, etc.,',
},
],
},
{
title: 'User Path',
icon: 'signpost-split',
description: 'Discover user journeys between 2 points.',
slug: USER_PATH,
},
{
title: 'Retention',
icon: 'arrow-repeat',
description: 'Retension graph of users / features over a period of time.',
slug: RETENTION,
},
{
title: 'Feature Adoption',
icon: 'card-checklist',
description: 'Find the adoption of your all features in your app.',
slug: FEATURE_ADOPTION,
},
];

View file

@ -1,7 +1,7 @@
import { FilterKey, IssueType } from 'Types/filter/filterType';
// TODO remove text property from options
export const options = [
{ key: 'on', label: 'on', value: 'on' },
{ key: 'on', label: 'on', value: 'on' },
{ key: 'notOn', label: 'not on', value: 'notOn' },
{ key: 'onAny', label: 'on any', value: 'onAny' },
{ key: 'is', label: 'is', value: 'is' },
@ -13,9 +13,9 @@ export const options = [
{ key: 'contains', label: 'contains', value: 'contains' },
{ key: 'notContains', label: 'not contains', value: 'notContains' },
{ key: 'hasAnyValue', label: 'has any value', value: 'hasAnyValue' },
{ key: 'hasNoValue', label: 'has no value', value: 'hasNoValue' },
{ key: 'hasNoValue', label: 'has no value', value: 'hasNoValue' },
{ key: 'isSignedUp', label: 'is signed up', value: 'isSignedUp' },
{ key: 'notSignedUp', label: 'not signed up', value: 'notSignedUp' },
{ key: 'notSignedUp', label: 'not signed up', value: 'notSignedUp' },
{ key: 'before', label: 'before', value: 'before' },
{ key: 'after', label: 'after', value: 'after' },
{ key: 'inRage', label: 'in rage', value: 'inRage' },
@ -37,6 +37,7 @@ const stringFilterKeysPerformance = ['is', 'inAnyPage', 'isNot', 'contains', 'st
const targetFilterKeys = ['on', 'notOn', 'onAny', 'contains', 'startsWith', 'endsWith', 'notContains'];
const signUpStatusFilterKeys = ['isSignedUp', 'notSignedUp'];
const rangeFilterKeys = ['before', 'after', 'on', 'inRange', 'notInRange', 'withInLast', 'notWithInLast'];
const pageUrlFilter = ['contains', 'startsWith', 'endsWith']
const getOperatorsByKeys = (keys) => {
return options.filter(option => keys.includes(option.key));
@ -50,6 +51,7 @@ export const booleanOperators = [
{ key: 'true', label: 'true', value: 'true' },
{ key: 'false', label: 'false', value: 'false' },
]
export const pageUrlOperators = options.filter(({key}) => pageUrlFilter.includes(key))
export const customOperators = [
{ key: '=', label: '=', value: '=' },
@ -86,6 +88,7 @@ export const metricOf = [
{ label: 'Devices', value: FilterKey.USER_DEVICE, type: 'table' },
{ label: 'Countries', value: FilterKey.USER_COUNTRY, type: 'table' },
{ label: 'URLs', value: FilterKey.LOCATION, type: 'table' },
]
export const methodOptions = [
@ -97,7 +100,7 @@ export const methodOptions = [
{ label: 'HEAD', value: 'HEAD' },
{ label: 'OPTIONS', value: 'OPTIONS' },
{ label: 'TRACE', value: 'TRACE' },
{ label: 'CONNECT', value: 'CONNECT' },
{ label: 'CONNECT', value: 'CONNECT' },
]
export const issueOptions = [
@ -128,4 +131,5 @@ export default {
metricOf,
issueOptions,
methodOptions,
pageUrlOperators,
}

View file

@ -318,6 +318,12 @@ export const fetch =
filter: getState().getIn(['filters', 'appliedFilter']),
});
};
export const setCustomSession = (session, filter) =>
(dispatch, getState) => { dispatch({
type: FETCH.SUCCESS,
filter: getState().getIn(['filters', 'appliedFilter']),
data: session,
})}
export function toggleFavorite(sessionId) {
return {

View file

@ -37,7 +37,7 @@ const setClient = (state, data) => {
}
export const UPDATE_JWT = 'jwt/UPDATE';
export const DELETE = 'jwt/DELETE'
export const DELETE = new RequestTypes('jwt/DELETE')
export function setJwt(data) {
return {
type: UPDATE_JWT,
@ -63,14 +63,13 @@ const reducer = (state = initialState, action = {}) => {
return state.set('account', Account(action.data)).set('passwordErrors', List());
case FETCH_TENANTS.SUCCESS:
return state.set('authDetails', action.data);
// return state.set('tenants', action.data.map(i => ({ text: i.name, value: i.tenantId})));
case UPDATE_PASSWORD.FAILURE:
return state.set('passwordErrors', List(action.errors))
case FETCH_ACCOUNT.FAILURE:
case LOGIN.FAILURE:
case DELETE:
console.log('hi')
deleteCookie('jwt', '/', '.openreplay.com')
case DELETE.SUCCESS:
case DELETE.FAILURE:
deleteCookie('jwt', '/', 'openreplay.com')
return initialState;
case PUT_CLIENT.REQUEST:
return state.mergeIn([ 'account' ], action.params);
@ -136,7 +135,8 @@ export const fetchUserInfo = () => ({
export function logout() {
return {
type: DELETE,
types: DELETE.toArray(),
call: client => client.post('/logout')
};
}

View file

@ -1,7 +1,6 @@
import {
makeAutoObservable,
runInAction,
computed,
} from "mobx";
import Dashboard from "./types/dashboard";
import Widget from "./types/widget";
@ -52,7 +51,7 @@ export default class DashboardStore {
page: number = 1
pageSize: number = 10
dashboardsSearch: string = ''
sort: any = {}
sort: any = { by: 'desc'}
constructor() {
makeAutoObservable(this);
@ -66,9 +65,9 @@ export default class DashboardStore {
this.drillDownFilter.updateKey("endTimestamp", timeStamps.endTimestamp);
}
@computed
get sortedDashboards() {
return [...this.dashboards].sort((a, b) => b.createdAt - a.createdAt)
const sortOrder = this.sort.by
return [...this.dashboards].sort((a, b) => sortOrder === 'desc' ? b.createdAt - a.createdAt : a.createdAt - b.createdAt)
}
toggleAllSelectedWidgets(isSelected: boolean) {
@ -119,7 +118,7 @@ export default class DashboardStore {
return this.dashboards.filter((d) => ids.includes(d.dashboardId));
}
initDashboard(dashboard: Dashboard) {
initDashboard(dashboard?: Dashboard) {
this.dashboardInstance = dashboard
? new Dashboard().fromJson(dashboard)
: new Dashboard();
@ -277,9 +276,9 @@ export default class DashboardStore {
);
}
getDashboard(dashboardId: string): Dashboard | null {
getDashboard(dashboardId: string|number): Dashboard | null {
return (
this.dashboards.find((d) => d.dashboardId === dashboardId) || null
this.dashboards.find((d) => d.dashboardId == dashboardId) || null
);
}
@ -380,10 +379,10 @@ export default class DashboardStore {
return dashboardService
.addWidget(dashboard, metricIds)
.then((response) => {
toast.success("Metric added to dashboard.");
toast.success("Card added to dashboard.");
})
.catch(() => {
toast.error("Metric could not be added.");
toast.error("Card could not be added.");
})
.finally(() => {
this.isSaving = false;

View file

@ -1,153 +1,190 @@
import { makeAutoObservable, computed } from "mobx"
import Widget from "./types/widget";
import { metricService, errorService } from "App/services";
import { makeAutoObservable } from 'mobx';
import Widget from './types/widget';
import { metricService, errorService } from 'App/services';
import { toast } from 'react-toastify';
import Error from "./types/error";
import Error from './types/error';
import { TIMESERIES, TABLE, CLICKMAP, FUNNEL, ERRORS, RESOURCE_MONITORING, PERFORMANCE, WEB_VITALS } from 'App/constants/card';
export default class MetricStore {
isLoading: boolean = false
isSaving: boolean = false
isLoading: boolean = false;
isSaving: boolean = false;
metrics: Widget[] = []
instance = new Widget()
metrics: Widget[] = [];
instance = new Widget();
page: number = 1
pageSize: number = 10
metricsSearch: string = ""
sort: any = {}
page: number = 1;
pageSize: number = 10;
metricsSearch: string = '';
sort: any = { by: 'desc' };
sessionsPage: number = 1
sessionsPageSize: number = 10
sessionsPage: number = 1;
sessionsPageSize: number = 10;
listView?: boolean = true
clickMapFilter: boolean = false
constructor() {
makeAutoObservable(this)
clickMapSearch = ''
clickMapLabel = ''
constructor() {
makeAutoObservable(this);
}
get sortedWidgets() {
return [...this.metrics].sort((a, b) => this.sort.by === 'desc' ? b.lastModified - a.lastModified : a.lastModified - b.lastModified)
}
// State Actions
init(metric?: Widget | null) {
this.instance.update(metric || new Widget());
}
updateKey(key: string, value: any) {
// @ts-ignore
this[key] = value;
}
setClickMaps(val: boolean) {
this.clickMapFilter = val
}
changeClickMapSearch(val: string, label: string) {
this.clickMapSearch = val
this.clickMapLabel = label
}
merge(object: any) {
Object.assign(this.instance, object);
this.instance.updateKey('hasChanged', true);
}
changeType(value: string) {
const obj: any = { metricType: value};
if (value === TABLE || value === TIMESERIES) {
obj['viewType'] = 'table';
}
@computed
get sortedWidgets() {
return [...this.metrics].sort((a, b) => b.lastModified - a.lastModified)
if (value === TIMESERIES) {
obj['viewType'] = 'lineChart';
}
if (value === ERRORS || value === RESOURCE_MONITORING || value === PERFORMANCE || value === WEB_VITALS) {
obj['viewType'] = 'chart';
}
// State Actions
init(metric?: Widget | null) {
this.instance.update(metric || new Widget())
if (value === FUNNEL) {
obj['metricOf'] = 'sessionCount';
}
this.instance.update(obj)
}
updateKey(key: string, value: any) {
reset(id: string) {
const metric = this.findById(id);
if (metric) {
this.instance = metric;
}
}
addToList(metric: Widget) {
this.metrics.push(metric);
}
updateInList(metric: Widget) {
// @ts-ignore
const index = this.metrics.findIndex((m: Widget) => m[Widget.ID_KEY] === metric[Widget.ID_KEY]);
if (index >= 0) {
this.metrics[index] = metric;
}
}
findById(id: string) {
// @ts-ignore
return this.metrics.find((m) => m[Widget.ID_KEY] === id);
}
removeById(id: string): void {
// @ts-ignore
this.metrics = this.metrics.filter((m) => m[Widget.ID_KEY] !== id);
}
get paginatedList(): Widget[] {
const start = (this.page - 1) * this.pageSize;
const end = start + this.pageSize;
return this.metrics.slice(start, end);
}
// API Communication
async save(metric: Widget): Promise<Widget> {
this.isSaving = true;
try {
const metricData = await metricService.saveMetric(metric);
const _metric = new Widget().fromJson(metricData);
if (!metric.exists()) {
toast.success('Card created successfully');
this.addToList(_metric);
} else {
toast.success('Card updated successfully');
this.updateInList(_metric);
}
this.instance = _metric;
this.instance.updateKey('hasChanged', false);
return _metric;
} catch (error) {
toast.error('Error saving metric');
throw error;
} finally {
this.isSaving = false;
}
}
fetchList() {
this.isLoading = true;
return metricService
.getMetrics()
.then((metrics: any[]) => {
this.metrics = metrics.map((m) => new Widget().fromJson(m));
})
.finally(() => {
this.isLoading = false;
});
}
fetch(id: string, period?: any) {
this.isLoading = true;
return metricService
.getMetric(id)
.then((metric: any) => {
return (this.instance = new Widget().fromJson(metric, period));
})
.finally(() => {
this.isLoading = false;
});
}
delete(metric: Widget) {
this.isSaving = true;
// @ts-ignore
return metricService
.deleteMetric(metric[Widget.ID_KEY])
.then(() => {
// @ts-ignore
this[key] = value
}
this.removeById(metric[Widget.ID_KEY]);
toast.success('Card deleted successfully');
})
.finally(() => {
this.instance.updateKey('hasChanged', false);
this.isSaving = false;
});
}
merge(object: any) {
Object.assign(this.instance, object)
this.instance.updateKey('hasChanged', true)
}
reset(id: string) {
const metric = this.findById(id)
if (metric) {
this.instance = metric
}
}
addToList(metric: Widget) {
this.metrics.push(metric)
}
updateInList(metric: Widget) {
// @ts-ignore
const index = this.metrics.findIndex((m: Widget) => m[Widget.ID_KEY] === metric[Widget.ID_KEY])
if (index >= 0) {
this.metrics[index] = metric
}
}
findById(id: string) {
// @ts-ignore
return this.metrics.find(m => m[Widget.ID_KEY] === id)
}
removeById(id: string): void {
// @ts-ignore
this.metrics = this.metrics.filter(m => m[Widget.ID_KEY] !== id)
}
get paginatedList(): Widget[] {
const start = (this.page - 1) * this.pageSize
const end = start + this.pageSize
return this.metrics.slice(start, end)
}
// API Communication
save(metric: Widget, dashboardId?: string): Promise<any> {
const wasCreating = !metric.exists()
this.isSaving = true
return new Promise((resolve, reject) => {
metricService.saveMetric(metric, dashboardId)
.then((metric: any) => {
const _metric = new Widget().fromJson(metric)
if (wasCreating) {
toast.success('Metric created successfully')
this.addToList(_metric)
this.instance = _metric
} else {
toast.success('Metric updated successfully')
this.updateInList(_metric)
}
resolve(_metric)
}).catch(() => {
toast.error('Error saving metric')
reject()
}).finally(() => {
this.instance.updateKey('hasChanged', false)
this.isSaving = false
})
fetchError(errorId: any): Promise<any> {
return new Promise((resolve, reject) => {
errorService
.one(errorId)
.then((error: any) => {
resolve(new Error().fromJSON(error));
})
}
fetchList() {
this.isLoading = true
return metricService.getMetrics()
.then((metrics: any[]) => {
this.metrics = metrics.map(m => new Widget().fromJson(m))
}).finally(() => {
this.isLoading = false
})
}
fetch(id: string, period?: any) {
this.isLoading = true
return metricService.getMetric(id)
.then((metric: any) => {
return this.instance = new Widget().fromJson(metric, period)
})
.finally(() => {
this.isLoading = false
})
}
delete(metric: Widget) {
this.isSaving = true
// @ts-ignore
return metricService.deleteMetric(metric[Widget.ID_KEY])
.then(() => {
// @ts-ignore
this.removeById(metric[Widget.ID_KEY])
toast.success('Metric deleted successfully')
}).finally(() => {
this.instance.updateKey('hasChanged', false)
this.isSaving = false
})
}
fetchError(errorId: any): Promise<any> {
return new Promise((resolve, reject) => {
errorService.one(errorId).then((error: any) => {
resolve(new Error().fromJSON(error))
}).catch((error: any) => {
toast.error('Failed to fetch error details.')
reject(error)
})
})
}
.catch((error: any) => {
toast.error('Failed to fetch error details.');
reject(error);
});
});
}
}

View file

@ -94,7 +94,7 @@ export default class SessionStore {
getSessions(filter: any): Promise<any> {
return new Promise((resolve, reject) => {
sessionService
.getSessions(filter.toJson())
.getSessions(filter.toJson?.() || filter)
.then((response: any) => {
resolve({
sessions: response.sessions.map((session: any) => new Session().fromJson(session)),

View file

@ -1,19 +1,20 @@
import { makeAutoObservable, runInAction, observable, action } from "mobx"
import { makeAutoObservable, runInAction } from "mobx"
import FilterSeries from "./filterSeries";
import { DateTime } from 'luxon';
import { metricService, errorService } from "App/services";
import Session from "App/mstore/types/session";
import Funnelissue from 'App/mstore/types/funnelIssue';
import { issueOptions } from 'App/constants/filterOptions';
import { FilterKey } from 'Types/filter/filterType';
import Period, { LAST_24_HOURS } from 'Types/app/period';
import { metricService } from "App/services";
import { WEB_VITALS } from "App/constants/card";
export default class Widget {
public static get ID_KEY():string { return "metricId" }
metricId: any = undefined
widgetId: any = undefined
category?: string = undefined
name: string = "Untitled Metric"
name: string = "Untitled Card"
// metricType: string = "timeseries"
metricType: string = "timeseries"
metricOf: string = "sessionCount"
@ -30,6 +31,7 @@ export default class Widget {
config: any = {}
page: number = 1
limit: number = 5
thumbnail?: string
params: any = { density: 70 }
period: Record<string, any> = Period({ rangeName: LAST_24_HOURS }) // temp value in detail view
@ -84,7 +86,7 @@ export default class Widget {
this.metricFormat = json.metricFormat
this.viewType = json.viewType
this.name = json.name
this.series = json.series ? json.series.map((series: any) => new FilterSeries().fromJson(series)) : [],
this.series = json.series ? json.series.map((series: any) => new FilterSeries().fromJson(series)) : []
this.dashboards = json.dashboards || []
this.owner = json.ownerEmail
this.lastModified = json.editedAt || json.createdAt ? DateTime.fromMillis(json.editedAt || json.createdAt) : null
@ -92,6 +94,7 @@ export default class Widget {
this.position = json.config.position
this.predefinedKey = json.predefinedKey
this.category = json.category
this.thumbnail = json.thumbnail
if (period) {
this.period = period
@ -129,9 +132,10 @@ export default class Widget {
viewType: this.viewType,
name: this.name,
series: this.series.map((series: any) => series.toJson()),
thumbnail: this.thumbnail,
config: {
...this.config,
col: (this.metricType === 'funnel' || this.metricOf === FilterKey.ERRORS || this.metricOf === FilterKey.SESSIONS) ? 4 : 2
col: (this.metricType === 'funnel' || this.metricOf === FilterKey.ERRORS || this.metricOf === FilterKey.SESSIONS || this.metricOf === FilterKey.SLOWEST_RESOURCES || this.metricOf === FilterKey.MISSING_RESOURCES || this.metricOf === FilterKey.PAGES_RESPONSE_TIME_DISTRIBUTION) ? 4 : (this.metricType === WEB_VITALS ? 1 : 2)
},
}
}
@ -155,7 +159,7 @@ export default class Widget {
}
fetchSessions(metricId: any, filter: any): Promise<any> {
return new Promise((resolve, reject) => {
return new Promise((resolve) => {
metricService.fetchSessions(metricId, filter).then((response: any[]) => {
resolve(response.map((cat: { sessions: any[]; }) => {
return {
@ -168,7 +172,7 @@ export default class Widget {
}
fetchIssues(filter: any): Promise<any> {
return new Promise((resolve, reject) => {
return new Promise((resolve) => {
metricService.fetchIssues(filter).then((response: any) => {
const significantIssues = response.issues.significant ? response.issues.significant.map((issue: any) => new Funnelissue().fromJSON(issue)) : []
const insignificantIssues = response.issues.insignificant ? response.issues.insignificant.map((issue: any) => new Funnelissue().fromJSON(issue)) : []

View file

@ -192,6 +192,7 @@ export default class MessageManager {
private onFileReadFinally = () => {
this.waitingForFiles = false
this.setMessagesLoading(false)
// this.state.update({ filesLoaded: true })
}
private async loadMessages() {
@ -235,7 +236,7 @@ export default class MessageManager {
.finally(this.onFileReadFinally)
// load devtools
if (this.session.devtoolsURL.length) {
if (this.session.devtoolsURL?.length) {
this.state.update({ devtoolsLoading: true })
loadFiles(this.session.devtoolsURL, createNewParser())
.catch(() =>

View file

@ -117,6 +117,10 @@ export default class Screen {
return this.iframe.contentDocument;
}
get iframeStylesRef(): CSSStyleDeclaration {
return this.iframe.style
}
private boundingRect: DOMRect | null = null;
private getBoundingClientRect(): DOMRect {
if (this.boundingRect === null) {
@ -172,11 +176,11 @@ export default class Screen {
return this.getElementsFromInternalPoint(this.getInternalViewportCoordinates(point));
}
getElementBySelector(selector: string): Element | null {
getElementBySelector(selector: string) {
if (!selector) return null;
try {
const safeSelector = selector.replace(/:/g, '\\\\3A ').replace(/\//g, '\\/');
return this.document?.querySelector(safeSelector) || null;
return this.document?.querySelector<HTMLElement>(safeSelector) || null;
} catch (e) {
console.error("Can not select element. ", e)
return null
@ -191,22 +195,22 @@ export default class Screen {
this.iframe.style.display = flag ? '' : 'none';
}
private s: number = 1;
private scaleRatio: number = 1;
getScale() {
return this.s;
return this.scaleRatio;
}
scale({ height, width }: Dimensions) {
if (!this.parentElement) return;
const { offsetWidth, offsetHeight } = this.parentElement;
this.s = Math.min(offsetWidth / width, offsetHeight / height);
if (this.s > 1) {
this.s = 1;
this.scaleRatio = Math.min(offsetWidth / width, offsetHeight / height);
if (this.scaleRatio > 1) {
this.scaleRatio = 1;
} else {
this.s = Math.round(this.s * 1e3) / 1e3;
this.scaleRatio = Math.round(this.scaleRatio * 1e3) / 1e3;
}
this.screen.style.transform = `scale(${ this.s }) translate(-50%, -50%)`;
this.screen.style.transform = `scale(${ this.scaleRatio }) translate(-50%, -50%)`;
this.screen.style.width = width + 'px';
this.screen.style.height = height + 'px';
this.iframe.style.width = width + 'px';
@ -214,4 +218,31 @@ export default class Screen {
this.boundingRect = this.overlay.getBoundingClientRect();
}
scaleFullPage() {
if (!this.parentElement) return;
const { width: boxWidth } = this.parentElement.getBoundingClientRect();
const { height, width } = this.document.body.getBoundingClientRect();
this.overlay.remove()
this.scaleRatio = boxWidth/width;
if (this.scaleRatio > 1) {
this.scaleRatio = 1;
} else {
this.scaleRatio = Math.round(this.scaleRatio * 1e3) / 1e3;
}
Object.assign(this.screen.style, {
top: '0',
left: '0',
height: height + 'px',
width: width + 'px',
transform: `scale(${this.scaleRatio})`,
})
Object.assign(this.iframe.style, {
width: width + 'px',
height: height + 'px',
})
}
}

View file

@ -6,4 +6,4 @@ export interface Point {
export interface Dimensions {
width: number
height: number
}
}

View file

@ -1,6 +1,6 @@
import { Log, LogLevel } from './types'
import type { Store } from '../common/types'
import type { Store } from 'App/player'
import Player, { State as PlayerState } from '../player/Player'
import MessageManager from './MessageManager'
@ -29,8 +29,8 @@ export default class WebPlayer extends Player {
console.log(session.events, session.stackEvents, session.resources, session.errors)
let initialLists = live ? {} : {
event: session.events,
stack: session.stackEvents,
resource: session.resources, // MBTODO: put ResourceTiming in file
stack: session.stackEvents || [],
resource: session.resources || [], // MBTODO: put ResourceTiming in file
exceptions: session.errors.map(({ time, errorId, name }: any) =>
Log({
level: LogLevel.ERROR,
@ -76,6 +76,12 @@ export default class WebPlayer extends Player {
// this.updateMarketTargets() ??
}
scaleFullPage = () => {
window.removeEventListener('resize', this.scale)
window.addEventListener('resize', this.screen.scaleFullPage)
return this.screen.scaleFullPage()
}
// Inspector & marker
mark(e: Element) {
this.inspectorController.marker?.mark(e)
@ -107,6 +113,27 @@ export default class WebPlayer extends Player {
this.targetMarker.markTargets(...args)
}
showClickmap = (...args: Parameters<TargetMarker['injectTargets']>) => {
this.pause()
this.targetMarker.injectTargets(...args)
}
setMarkerClick = (...args: Parameters<TargetMarker['setOnMarkerClick']>) => {
this.targetMarker.setOnMarkerClick(...args)
}
// TODO separate message receivers
toggleTimetravel = async () => {
if (!this.wpState.get().liveTimeTravel) {
await this.messageManager.reloadWithUnprocessedFile(() =>
this.wpState.update({
liveTimeTravel: true,
})
)
}
}
toggleUserName = (name?: string) => {
this.screen.cursor.showTag(name)
}

View file

@ -1,7 +1,7 @@
import type Screen from '../Screen/Screen'
import type { Point } from '../Screen/types'
import type { Store } from '../../common/types'
import { clickmapStyles } from './clickmapStyles'
function getOffset(el: Element, innerWindow: Window) {
const rect = el.getBoundingClientRect();
@ -36,6 +36,10 @@ export interface State {
export default class TargetMarker {
private clickMapOverlay: HTMLDivElement | null = null
private clickContainers: HTMLDivElement[] = []
private smallClicks: HTMLDivElement[] = []
private onMarkerClick: (selector: string, innerText: string) => void
static INITIAL_STATE: State = {
markedTargets: null,
activeTargetIndex: 0
@ -50,8 +54,8 @@ export default class TargetMarker {
const { markedTargets } = this.store.get()
if (markedTargets) {
this.store.update({
markedTargets: markedTargets.map((mt: any) => ({
...mt,
markedTargets: markedTargets.map((mt: any) => ({
...mt,
boundingRect: this.calculateRelativeBoundingRect(mt.el),
})),
});
@ -63,12 +67,12 @@ export default class TargetMarker {
if (!parentEl) return {top:0, left:0, width:0,height:0} //TODO: can be initialized(?) on mounted screen only
const { top, left, width, height } = el.getBoundingClientRect()
const s = this.screen.getScale()
const scrinRect = this.screen.overlay.getBoundingClientRect() //this.screen.getBoundingClientRect() (now private)
const screenRect = this.screen.overlay.getBoundingClientRect() //this.screen.getBoundingClientRect() (now private)
const parentRect = parentEl.getBoundingClientRect()
return {
top: top*s + scrinRect.top - parentRect.top,
left: left*s + scrinRect.left - parentRect.left,
top: top*s + screenRect.top - parentRect.top,
left: left*s + screenRect.left - parentRect.left,
width: width*s,
height: height*s,
}
@ -95,7 +99,7 @@ export default class TargetMarker {
})
}, 0)
}
}
this.store.update({ activeTargetIndex: index });
}
@ -111,6 +115,7 @@ export default class TargetMarker {
selections.forEach((s) => {
const el = this.screen.getElementBySelector(s.selector);
if (!el) return;
markedTargets.push({
...s,
el,
@ -120,7 +125,7 @@ export default class TargetMarker {
count: s.count,
})
});
this.actualScroll = this.screen.getCurrentScroll()
this.actualScroll = this.screen.getCurrentScroll()
this.store.update({ markedTargets });
} else {
if (this.actualScroll) {
@ -131,4 +136,100 @@ export default class TargetMarker {
}
}
}
injectTargets(selections: { selector: string, count: number, clickRage?: boolean }[] | null) {
if (selections) {
const totalCount = selections.reduce((a, b) => {
return a + b.count
}, 0);
this.clickMapOverlay?.remove()
const overlay = document.createElement("div")
const iframeSize = this.screen.iframeStylesRef
const scaleRatio = this.screen.getScale()
Object.assign(overlay.style, clickmapStyles.overlayStyle({ height: iframeSize.height, width: iframeSize.width, scale: scaleRatio }))
this.clickMapOverlay = overlay
selections.forEach((s, i) => {
const el = this.screen.getElementBySelector(s.selector);
if (!el) return;
const bubbleContainer = document.createElement("div")
const {top, left, width, height} = el.getBoundingClientRect()
const totalClicks = document.createElement("div")
totalClicks.innerHTML = `${s.count} ${s.count !== 1 ? 'Clicks' : 'Click'}`
Object.assign(totalClicks.style, clickmapStyles.totalClicks)
const percent = document.createElement("div")
percent.style.fontSize = "14px"
percent.innerHTML = `${Math.round((s.count * 100) / totalCount)}% of the clicks recorded in this page`
bubbleContainer.appendChild(totalClicks)
bubbleContainer.appendChild(percent)
const containerId = `clickmap-bubble-${i}`
bubbleContainer.id = containerId
this.clickContainers.push(bubbleContainer)
Object.assign(bubbleContainer.style, clickmapStyles.bubbleContainer({ top, left, height }))
const border = document.createElement("div")
Object.assign(border.style, clickmapStyles.highlight({ width, height, top, left }))
const smallClicksBubble = document.createElement("div")
smallClicksBubble.innerHTML = `${s.count}`
const smallClicksId = containerId + '-small'
smallClicksBubble.id = smallClicksId
this.smallClicks.push(smallClicksBubble)
border.onclick = (e) => {
e.stopPropagation()
const innerText = el.innerText.length > 25 ? `${el.innerText.slice(0, 20)}...` : el.innerText
this.onMarkerClick?.(s.selector, innerText)
this.clickContainers.forEach(container => {
if (container.id === containerId) {
container.style.visibility = "visible"
} else {
container.style.visibility = "hidden"
}
})
this.smallClicks.forEach(container => {
if (container.id !== smallClicksId) {
container.style.visibility = "visible"
} else {
container.style.visibility = "hidden"
}
})
}
overlay.onclick = (e) => {
e.stopPropagation()
this.onMarkerClick('', '')
this.clickContainers.forEach(container => {
container.style.visibility = "hidden"
})
this.smallClicks.forEach(container => {
container.style.visibility = "visible"
})
}
Object.assign(smallClicksBubble.style, clickmapStyles.clicks({ top, height, isRage: s.clickRage }))
border.appendChild(smallClicksBubble)
overlay.appendChild(bubbleContainer)
overlay.appendChild(border)
});
this.screen.getParentElement()?.appendChild(overlay)
} else {
this.store.update({ markedTargets: null });
this.clickMapOverlay?.remove()
this.clickMapOverlay = null
this.smallClicks = []
this.clickContainers = []
}
}
setOnMarkerClick(cb: (selector: string) => void) {
this.onMarkerClick = cb
}
}

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