Merge remote-tracking branch 'origin/dev' into api-v1.9.5
This commit is contained in:
commit
912ff486e0
124 changed files with 3224 additions and 1061 deletions
|
|
@ -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 } ],
|
||||
|
|
|
|||
36
frontend/.storybook/main.ts
Normal file
36
frontend/.storybook/main.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
13
frontend/.storybook/openReplayDecorator.js
Normal file
13
frontend/.storybook/openReplayDecorator.js
Normal 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;
|
||||
13
frontend/.storybook/preview.ts
Normal file
13
frontend/.storybook/preview.ts
Normal 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]
|
||||
|
|
@ -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 {
|
||||
|
||||
}
|
||||
|
|
@ -25,7 +25,7 @@ const siteIdRequiredPaths = [
|
|||
'/heatmaps',
|
||||
'/custom_metrics',
|
||||
'/dashboards',
|
||||
'/metrics',
|
||||
'/cards',
|
||||
'/unprocessed',
|
||||
'/notes',
|
||||
// '/custom_metrics/sessions',
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from './ClickMapCard'
|
||||
|
|
@ -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%">
|
||||
|
|
|
|||
|
|
@ -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={
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from './AddCardModal';
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from './ClickMapRagePicker'
|
||||
|
|
@ -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)));
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from './DashboardHeader';
|
||||
|
|
@ -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>
|
||||
}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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))}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ interface Props {
|
|||
series: any;
|
||||
onRemoveSeries: (seriesIndex: any) => void;
|
||||
canDelete?: boolean;
|
||||
|
||||
hideHeader?: boolean;
|
||||
emptyMessage?: any;
|
||||
observeChanges?: () => void;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from './MetricTypeItem';
|
||||
|
|
@ -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);
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from './MetricTypeList';
|
||||
|
|
@ -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;
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from './MetricViewHeader';
|
||||
|
|
@ -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;
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from './MetricsGrid'
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from './MetricsLibraryModal';
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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` }}>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from './MetricSubtypeDropdown';
|
||||
|
|
@ -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({});
|
||||
|
|
@ -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));
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from './MetricTypeDropdown';
|
||||
|
|
@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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' }
|
||||
]}
|
||||
|
|
|
|||
|
|
@ -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 />}
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
));
|
||||
|
|
|
|||
|
|
@ -88,7 +88,7 @@ function LivePlayer({
|
|||
|
||||
const TABS = {
|
||||
EVENTS: 'User Steps',
|
||||
HEATMAPS: 'Click Map',
|
||||
CLICKMAP: 'Click Map',
|
||||
};
|
||||
const [activeTab, setActiveTab] = useState('');
|
||||
|
||||
|
|
|
|||
|
|
@ -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 !== '' && (
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
|
|
|
|||
|
|
@ -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']),
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from './CustomDropdownOption';
|
||||
|
|
@ -1 +1 @@
|
|||
export { default } from './DateRangeDropdown';
|
||||
// export { default } from './DateRangeDropdown';
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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={[
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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] });
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from './SankeyChart';
|
||||
|
|
@ -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} />
|
||||
));
|
||||
|
|
@ -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;
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from './ScatterChart';
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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' : ''}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
225
frontend/app/constants/card.ts
Normal file
225
frontend/app/constants/card.ts
Normal 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,
|
||||
},
|
||||
];
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
|
|
|
|||
|
|
@ -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)) : []
|
||||
|
|
|
|||
|
|
@ -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(() =>
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,4 +6,4 @@ export interface Point {
|
|||
export interface Dimensions {
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Reference in a new issue