Merge branch 'funnels' into deb-funnels
This commit is contained in:
commit
44d735d0a5
103 changed files with 1942 additions and 191 deletions
|
|
@ -17,8 +17,9 @@ const AssistPure = lazy(() => import('Components/Assist'));
|
|||
const BugFinderPure = lazy(() => import('Components/BugFinder/BugFinder'));
|
||||
const DashboardPure = lazy(() => import('Components/Dashboard/NewDashboard'));
|
||||
const ErrorsPure = lazy(() => import('Components/Errors/Errors'));
|
||||
const FunnelDetails = lazy(() => import('Components/Funnels/FunnelDetails'));
|
||||
const FunnelDetailsPure = lazy(() => import('Components/Funnels/FunnelDetails'));
|
||||
const FunnelIssueDetails = lazy(() => import('Components/Funnels/FunnelIssueDetails'));
|
||||
const FunnelPagePure = lazy(() => import('Components/Funnels/FunnelPage'));
|
||||
import WidgetViewPure from 'Components/Dashboard/components/WidgetView';
|
||||
import Header from 'Components/Header/Header';
|
||||
import { fetchList as fetchMetadata } from 'Duck/customField';
|
||||
|
|
@ -37,20 +38,21 @@ import { ModalProvider } from './components/Modal';
|
|||
|
||||
const BugFinder = withSiteIdUpdater(BugFinderPure);
|
||||
const Dashboard = withSiteIdUpdater(DashboardPure);
|
||||
const WidgetView = withSiteIdUpdater(WidgetViewPure);
|
||||
const Session = withSiteIdUpdater(SessionPure);
|
||||
const LiveSession = withSiteIdUpdater(LiveSessionPure);
|
||||
const Assist = withSiteIdUpdater(AssistPure);
|
||||
const Client = withSiteIdUpdater(ClientPure);
|
||||
const Onboarding = withSiteIdUpdater(OnboardingPure);
|
||||
const Errors = withSiteIdUpdater(ErrorsPure);
|
||||
const Funnels = withSiteIdUpdater(FunnelDetails);
|
||||
const FunnelPage = withSiteIdUpdater(FunnelPagePure);
|
||||
const FunnelsDetails = withSiteIdUpdater(FunnelDetailsPure);
|
||||
const FunnelIssue = withSiteIdUpdater(FunnelIssueDetails);
|
||||
const withSiteId = routes.withSiteId;
|
||||
// const withObTab = routes.withObTab;
|
||||
|
||||
const METRICS_PATH = routes.metrics();
|
||||
const METRICS_DETAILS = routes.metricDetails();
|
||||
const METRICS_DETAILS_SUB = routes.metricDetailsSub();
|
||||
|
||||
const DASHBOARD_PATH = routes.dashboard();
|
||||
const DASHBOARD_SELECT_PATH = routes.dashboardSelected();
|
||||
|
|
@ -62,7 +64,8 @@ const SESSIONS_PATH = routes.sessions();
|
|||
const ASSIST_PATH = routes.assist();
|
||||
const ERRORS_PATH = routes.errors();
|
||||
const ERROR_PATH = routes.error();
|
||||
const FUNNEL_PATH = routes.funnel();
|
||||
const FUNNEL_PATH = routes.funnels();
|
||||
const FUNNEL_CREATE_PATH = routes.funnelsCreate();
|
||||
const FUNNEL_ISSUE_PATH = routes.funnelIssue();
|
||||
const SESSION_PATH = routes.session();
|
||||
const LIVE_SESSION_PATH = routes.liveSession();
|
||||
|
|
@ -199,6 +202,7 @@ class Router extends React.Component {
|
|||
{/* DASHBOARD and Metrics */}
|
||||
<Route exact strict path={ withSiteId(METRICS_PATH, siteIdList) } component={ Dashboard } />
|
||||
<Route exact strict path={ withSiteId(METRICS_DETAILS, siteIdList) } component={ Dashboard } />
|
||||
<Route exact strict path={ withSiteId(METRICS_DETAILS_SUB, siteIdList) } component={ Dashboard } />
|
||||
<Route exact strict path={ withSiteId(DASHBOARD_PATH, siteIdList) } component={ Dashboard } />
|
||||
<Route exact strict path={ withSiteId(DASHBOARD_SELECT_PATH, siteIdList) } component={ Dashboard } />
|
||||
<Route exact strict path={ withSiteId(DASHBOARD_METRIC_CREATE_PATH, siteIdList) } component={ Dashboard } />
|
||||
|
|
@ -207,7 +211,9 @@ class Router extends React.Component {
|
|||
<Route exact strict path={ withSiteId(ASSIST_PATH, siteIdList) } component={ Assist } />
|
||||
<Route exact strict path={ withSiteId(ERRORS_PATH, siteIdList) } component={ Errors } />
|
||||
<Route exact strict path={ withSiteId(ERROR_PATH, siteIdList) } component={ Errors } />
|
||||
<Route exact strict path={ withSiteId(FUNNEL_PATH, siteIdList) } component={ Funnels } />
|
||||
<Route exact strict path={ withSiteId(FUNNEL_PATH, siteIdList) } component={ FunnelPage } />
|
||||
<Route exact strict path={ withSiteId(FUNNEL_CREATE_PATH, siteIdList) } component={ FunnelsDetails } />
|
||||
{/* <Route exact strict path={ withSiteId(FUNNEL_PATH, siteIdList) } component={ Funnels } /> */}
|
||||
<Route exact strict path={ withSiteId(FUNNEL_ISSUE_PATH, siteIdList) } component={ FunnelIssue } />
|
||||
<Route exact strict path={ withSiteId(SESSIONS_PATH, siteIdList) } component={ BugFinder } />
|
||||
<Route exact strict path={ withSiteId(SESSION_PATH, siteIdList) } component={ Session } />
|
||||
|
|
|
|||
|
|
@ -60,4 +60,4 @@ export default connect(state => ({
|
|||
activeTab: state.getIn([ 'search', 'activeTab' ]),
|
||||
period: state.getIn([ 'search', 'period' ]),
|
||||
filter: state.getIn([ 'search', 'instance' ]),
|
||||
}), { applyFilter })(SessionListHeader);
|
||||
}), { applyFilter })(SessionListHeader);
|
||||
|
|
@ -9,8 +9,6 @@ import { numberWithCommas } from 'App/utils';
|
|||
interface Props {
|
||||
metric: any,
|
||||
data: any;
|
||||
params: any;
|
||||
// seriesMap: any;
|
||||
colors: any;
|
||||
onClick?: (filters) => void;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,16 @@
|
|||
import React from 'react';
|
||||
|
||||
interface Props {
|
||||
data: any
|
||||
metric?: any
|
||||
isTemplate?: boolean;
|
||||
}
|
||||
function CustomMetricTableSessions(props: Props) {
|
||||
return (
|
||||
<div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CustomMetricTableSessions;
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from './CustomMetricTableSessions';
|
||||
|
|
@ -1,28 +1,24 @@
|
|||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { Loader, NoContent, SegmentSelection, Icon } from 'UI';
|
||||
import { Loader, NoContent, SegmentSelection } from 'UI';
|
||||
import { Styles } from '../../common';
|
||||
// import { ResponsiveContainer, XAxis, YAxis, CartesianGrid, Tooltip, LineChart, Line, Legend } from 'recharts';
|
||||
import Period, { LAST_24_HOURS, LAST_30_MINUTES, YESTERDAY, LAST_7_DAYS } from 'Types/app/period';
|
||||
import Period from 'Types/app/period';
|
||||
import stl from './CustomMetricWidgetPreview.module.css';
|
||||
import { getChartFormatter } from 'Types/dashboard/helper';
|
||||
import { remove } from 'Duck/customMetrics';
|
||||
import DateRange from 'Shared/DateRange';
|
||||
import { edit } from 'Duck/customMetrics';
|
||||
import CustomMetriLineChart from '../CustomMetriLineChart';
|
||||
import CustomMetricPercentage from '../CustomMetricPercentage';
|
||||
import CustomMetricTable from '../CustomMetricTable';
|
||||
|
||||
import APIClient from 'App/api_client';
|
||||
import CustomMetricPieChart from '../CustomMetricPieChart';
|
||||
|
||||
const customParams = rangeName => {
|
||||
const customParams = (rangeName: string) => {
|
||||
const params = { density: 70 }
|
||||
|
||||
if (rangeName === LAST_24_HOURS) params.density = 70
|
||||
if (rangeName === LAST_30_MINUTES) params.density = 70
|
||||
if (rangeName === YESTERDAY) params.density = 70
|
||||
if (rangeName === LAST_7_DAYS) params.density = 70
|
||||
// if (rangeName === LAST_24_HOURS) params.density = 70
|
||||
// if (rangeName === LAST_30_MINUTES) params.density = 70
|
||||
// if (rangeName === YESTERDAY) params.density = 70
|
||||
// if (rangeName === LAST_7_DAYS) params.density = 70
|
||||
|
||||
return params
|
||||
}
|
||||
|
|
@ -30,23 +26,18 @@ const customParams = rangeName => {
|
|||
interface Props {
|
||||
metric: any;
|
||||
data?: any;
|
||||
showSync?: boolean;
|
||||
// compare?: boolean;
|
||||
onClickEdit?: (e) => void;
|
||||
remove: (id) => void;
|
||||
edit: (metric) => void;
|
||||
}
|
||||
function CustomMetricWidget(props: Props) {
|
||||
const { metric, showSync } = props;
|
||||
const { metric } = props;
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [data, setData] = useState<any>({ chart: [{}] })
|
||||
const [seriesMap, setSeriesMap] = useState<any>([]);
|
||||
const [period, setPeriod] = useState(Period({ rangeName: metric.rangeName, startDate: metric.startDate, endDate: metric.endDate }));
|
||||
|
||||
const colors = Styles.customMetricColors;
|
||||
const params = customParams(period.rangeName)
|
||||
const gradientDef = Styles.gradientDef();
|
||||
const metricParams = { ...params, metricId: metric.metricId, viewType: 'lineChart' }
|
||||
const prevMetricRef = useRef<any>();
|
||||
const isTimeSeries = metric.metricType === 'timeseries';
|
||||
const isTable = metric.metricType === 'table';
|
||||
|
|
@ -59,29 +50,6 @@ function CustomMetricWidget(props: Props) {
|
|||
};
|
||||
prevMetricRef.current = metric;
|
||||
setLoading(true);
|
||||
|
||||
// fetch new data for the widget preview
|
||||
// new APIClient()['post']('/custom_metrics/try', { ...metricParams, ...metric.toSaveData() })
|
||||
// .then(response => response.json())
|
||||
// .then(({ errors, data }) => {
|
||||
// if (errors) {
|
||||
// console.log('err', errors)
|
||||
// } else {
|
||||
// const namesMap = data
|
||||
// .map(i => Object.keys(i))
|
||||
// .flat()
|
||||
// .filter(i => i !== 'time' && i !== 'timestamp')
|
||||
// .reduce((unique: any, item: any) => {
|
||||
// if (!unique.includes(item)) {
|
||||
// unique.push(item);
|
||||
// }
|
||||
// return unique;
|
||||
// }, []);
|
||||
|
||||
// setSeriesMap(namesMap);
|
||||
// setData(getChartFormatter(period)(data));
|
||||
// }
|
||||
// }).finally(() => setLoading(false));
|
||||
}, [metric])
|
||||
|
||||
const onDateChange = (changedDates) => {
|
||||
|
|
@ -185,7 +153,6 @@ function CustomMetricWidget(props: Props) {
|
|||
metric={metric}
|
||||
data={data[0]}
|
||||
colors={colors}
|
||||
params={params}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ function SessionsPerBrowser(props: Props) {
|
|||
|
||||
const getVersions = item => {
|
||||
return Object.keys(item)
|
||||
.filter(i => i !== 'browser' && i !== 'count')
|
||||
.filter(i => i !== 'browser' && i !== 'count' && i !== 'time' && i !== 'timestamp')
|
||||
.map(i => ({ key: 'v' +i, value: item[i]}))
|
||||
}
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import React from 'react';
|
||||
import { numberWithCommas } from 'App/utils';
|
||||
|
||||
const colors = ['#3EAAAF', '#5FBABF', '#7BCBCF', '#96DCDF', '#ADDCDF'];
|
||||
const colors = ['#1E889A', '#239DB2', '#28B2C9', '#36C0D7', '#65CFE1'];
|
||||
const colorsx = ['#256669', '#38999e', '#3eaaaf', '#51b3b7', '#78c4c7', '#9fd5d7', '#c5e6e7'].reverse();
|
||||
const compareColors = ['#394EFF', '#4D5FFF', '#808DFF', '#B3BBFF', '#E5E8FF'];
|
||||
const compareColorsx = ["#222F99", "#2E3ECC", "#394EFF", "#6171FF", "#8895FF", "#B0B8FF", "#D7DCFF"].reverse();
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { withRouter } from 'react-router-dom';
|
|||
import {
|
||||
metrics,
|
||||
metricDetails,
|
||||
metricDetailsSub,
|
||||
dashboardSelected,
|
||||
dashboardMetricCreate,
|
||||
dashboardMetricDetails,
|
||||
|
|
@ -14,6 +15,7 @@ import {
|
|||
import DashboardView from '../DashboardView';
|
||||
import MetricsView from '../MetricsView';
|
||||
import WidgetView from '../WidgetView';
|
||||
import WidgetSubDetailsView from '../WidgetSubDetailsView';
|
||||
|
||||
function DashboardViewSelected({ siteId, dashboardId }) {
|
||||
return (
|
||||
|
|
@ -37,6 +39,10 @@ function DashboardRouter(props: Props) {
|
|||
<Route exact strict path={withSiteId(metricDetails(), siteId)}>
|
||||
<WidgetView siteId={siteId} {...props} />
|
||||
</Route>
|
||||
|
||||
<Route exact strict path={withSiteId(metricDetailsSub(), siteId)}>
|
||||
<WidgetSubDetailsView siteId={siteId} {...props} />
|
||||
</Route>
|
||||
|
||||
<Route exact strict path={withSiteId(dashboard(''), siteId)}>
|
||||
<DashboardView siteId={siteId} dashboardId={dashboardId} />
|
||||
|
|
|
|||
|
|
@ -123,15 +123,6 @@ function DashboardView(props: RouteComponentProps<Props>) {
|
|||
</div>
|
||||
<div className="flex items-center" style={{ flex: 1, justifyContent: 'end' }}>
|
||||
<div className="flex items-center flex-shrink-0 justify-end" style={{ width: '300px'}}>
|
||||
{/* <span className="mr-2 color-gray-medium">Time Range</span> */}
|
||||
{/* <DateRange
|
||||
rangeValue={period.rangeName}
|
||||
startDate={period.start}
|
||||
endDate={period.end}
|
||||
onDateChange={(period) => dashboardStore.setPeriod(period)}
|
||||
customRangeRight
|
||||
direction="left"
|
||||
/> */}
|
||||
<SelectDateRange
|
||||
style={{ width: '300px'}}
|
||||
fluid
|
||||
|
|
|
|||
|
|
@ -0,0 +1,14 @@
|
|||
import React from 'react';
|
||||
|
||||
interface Props {
|
||||
error: any;
|
||||
}
|
||||
function ErrorListItem(props: Props) {
|
||||
return (
|
||||
<div>
|
||||
Errors List Item
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ErrorListItem;
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from './ErrorListItem'
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import ErrorListItem from '../ErrorListItem';
|
||||
import { useStore } from 'App/mstore';
|
||||
import { useObserver } from 'mobx-react-lite';
|
||||
|
||||
function ErrorsList(props) {
|
||||
const { errorStore, metricStore } = useStore();
|
||||
const metric = useObserver(() => metricStore.instance);
|
||||
|
||||
useEffect(() => {
|
||||
errorStore.fetchErrors();
|
||||
}, []);
|
||||
return (
|
||||
<div>
|
||||
Errors List
|
||||
<ErrorListItem error={{}} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ErrorsList;
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from './ErrorsList';
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
import React from 'react';
|
||||
import ErrorsList from '../ErrorsList';
|
||||
|
||||
function ErrorsWidget(props) {
|
||||
return (
|
||||
<div>
|
||||
<ErrorsList />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ErrorsWidget;
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from './ErrorsWidget';
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import { useStore } from 'App/mstore';
|
||||
import { useObserver } from 'mobx-react-lite';
|
||||
import { Loader } from 'UI';
|
||||
import FunnelIssuesListItem from '../FunnelIssuesListItem';
|
||||
import SessionItem from 'App/components/shared/SessionItem/SessionItem';
|
||||
|
||||
interface Props {
|
||||
issueId: string;
|
||||
}
|
||||
function FunnelIssueDetails(props: Props) {
|
||||
const { dashboardStore, metricStore } = useStore();
|
||||
const { issueId } = props;
|
||||
const filter = useObserver(() => dashboardStore.drillDownFilter);
|
||||
const widget = useObserver(() => metricStore.instance);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [funnelIssue, setFunnelIssue] = useState<any>(null);
|
||||
const [sessions, setSessions] = useState<any>([]);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
widget.fetchIssue(widget.metricId, issueId, filter).then((resp: any) => {
|
||||
setFunnelIssue(resp.issue);
|
||||
setSessions(resp.sessions);
|
||||
}).finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Loader loading={loading}>
|
||||
{ funnelIssue && <FunnelIssuesListItem
|
||||
issue={funnelIssue}
|
||||
inDetails={true}
|
||||
/>}
|
||||
|
||||
<div className="mt-6">
|
||||
{sessions.map((session: any) => (
|
||||
<SessionItem key={session.id} session={session} />
|
||||
))}
|
||||
</div>
|
||||
</Loader>
|
||||
);
|
||||
}
|
||||
|
||||
export default FunnelIssueDetails;
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from './FunnelIssueDetails';
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
import React from 'react';
|
||||
import { Popup } from 'UI';
|
||||
|
||||
const MIN_WIDTH = '20px';
|
||||
interface Props {
|
||||
issue: any
|
||||
}
|
||||
function FunnelIssueGraph(props: Props) {
|
||||
const { issue } = props;
|
||||
return (
|
||||
<div className="flex rounded-sm" style={{ width: '600px' }}>
|
||||
<Popup
|
||||
trigger={
|
||||
<div style={{ width: issue.unaffectedSessionsPer + '%', minWidth: MIN_WIDTH }} className="relative">
|
||||
<div className="w-full relative rounded-tl-sm rounded-bl-sm" style={{ height: '18px', backgroundColor: 'rgba(217, 219, 238, 0.7)' }} />
|
||||
<div className="absolute ml-2 font-bold top-0 bottom-0 text-sm">{issue.unaffectedSessions}</div>
|
||||
</div>
|
||||
}
|
||||
content={ `Unaffected sessions` }
|
||||
size="tiny"
|
||||
inverted
|
||||
position="top center"
|
||||
/>
|
||||
<Popup
|
||||
trigger={
|
||||
<div style={{ width: issue.affectedSessionsPer + '%', minWidth: MIN_WIDTH}} className="border-l relative">
|
||||
<div className="w-full relative" style={{ height: '18px', backgroundColor: 'rgba(238, 238, 238, 0.7)' }} />
|
||||
<div className="absolute ml-2 font-bold top-0 bottom-0 text-sm">{issue.affectedSessions}</div>
|
||||
{/* <div className="absolute left-0 ml-1 text-xs">{issue.affectedSessionsPer}</div> */}
|
||||
</div>
|
||||
}
|
||||
content={ `Affected sessions` }
|
||||
size="tiny"
|
||||
inverted
|
||||
position="top center"
|
||||
/>
|
||||
<Popup
|
||||
trigger={
|
||||
<div style={{ width: issue.lostConversionsPer + '%', minWidth: MIN_WIDTH}} className="border-l relative">
|
||||
<div className="w-full relative rounded-tr-sm rounded-br-sm" style={{ height: '18px', backgroundColor: 'rgba(204, 0, 0, 0.26)' }} />
|
||||
<div className="absolute ml-2 font-bold top-0 bottom-0 text-sm color-red">{issue.lostConversions}</div>
|
||||
</div>
|
||||
}
|
||||
content={ `Conversion lost` }
|
||||
size="tiny"
|
||||
inverted
|
||||
position="top center"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FunnelIssueGraph;
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from './FunnelIssueGraph';
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
import React from 'react';
|
||||
import { withRouter } from 'react-router-dom';
|
||||
import { useStore } from 'App/mstore';
|
||||
import { useModal } from 'App/components/Modal';
|
||||
import FunnelIssueDetails from '../FunnelIssueDetails';
|
||||
|
||||
interface Props {
|
||||
issueId: string;
|
||||
}
|
||||
function FunnelIssueModal(props: Props) {
|
||||
const { issueId } = props;
|
||||
const { hideModal } = useModal();
|
||||
return (
|
||||
<div style={{ width: '85vw', maxWidth: '1200px' }}>
|
||||
<div
|
||||
className="border-r shadow p-4 h-screen"
|
||||
style={{ backgroundColor: '#FAFAFA', zIndex: 999, width: '100%' }}
|
||||
>
|
||||
<FunnelIssueDetails issueId={issueId} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FunnelIssueModal;
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from './FunnelIssueModal';
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import { useStore } from 'App/mstore';
|
||||
import { useObserver } from 'mobx-react-lite';
|
||||
import { NoContent, Loader } from 'UI';
|
||||
import FunnelIssuesDropdown from '../FunnelIssuesDropdown';
|
||||
import FunnelIssuesSort from '../FunnelIssuesSort';
|
||||
import FunnelIssuesList from '../FunnelIssuesList';
|
||||
import { DateTime } from 'luxon';
|
||||
import { debounce } from 'App/utils';
|
||||
import useIsMounted from 'App/hooks/useIsMounted'
|
||||
import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG';
|
||||
|
||||
function FunnelIssues() {
|
||||
const { metricStore, dashboardStore } = useStore();
|
||||
const [data, setData] = useState<any>({ issues: [] });
|
||||
const [loading, setLoading] = useState(false);
|
||||
const isMounted = useIsMounted()
|
||||
// const funnel = useObserver(() => funnelStore.instance);
|
||||
// const funnel = useObserver(() => metricStore.instance);
|
||||
// const issues = useObserver(() => funnelStore.issues);
|
||||
// const loading = useObserver(() => funnelStore.isLoadingIssues);
|
||||
|
||||
const fetchIssues = (filter: any) => {
|
||||
if (!isMounted()) return;
|
||||
setLoading(true)
|
||||
widget.fetchIssues(filter).then((res: any) => {
|
||||
setData(res)
|
||||
}).finally(() => {
|
||||
setLoading(false)
|
||||
});
|
||||
}
|
||||
|
||||
const filter = useObserver(() => dashboardStore.drillDownFilter);
|
||||
const widget: any = useObserver(() => metricStore.instance);
|
||||
const startTime = DateTime.fromMillis(filter.startTimestamp).toFormat('LLL dd, yyyy HH:mm a');
|
||||
const endTime = DateTime.fromMillis(filter.endTimestamp).toFormat('LLL dd, yyyy HH:mm a');
|
||||
const debounceRequest: any = React.useCallback(debounce(fetchIssues, 1000), []);
|
||||
|
||||
|
||||
const depsString = JSON.stringify(widget.series);
|
||||
useEffect(() => {
|
||||
debounceRequest({ ...filter, series: widget.toJsonDrilldown(), page: metricStore.sessionsPage, limit: metricStore.sessionsPageSize });
|
||||
}, [filter.startTimestamp, filter.endTimestamp, filter.filters, depsString, metricStore.sessionsPage]);
|
||||
|
||||
return useObserver(() => (
|
||||
<div className="my-8">
|
||||
<div className="flex justify-between">
|
||||
<h1 className="font-medium text-2xl">Most significant issues <span className="font-normal">identified in this funnel</span></h1>
|
||||
</div>
|
||||
<div className="my-6 flex justify-between items-start">
|
||||
<FunnelIssuesDropdown />
|
||||
<div className="flex-shrink-0">
|
||||
<FunnelIssuesSort />
|
||||
</div>
|
||||
</div>
|
||||
<Loader loading={loading}>
|
||||
<NoContent
|
||||
show={!loading && data.issues.length === 0}
|
||||
title={
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<AnimatedSVG name={ICONS.NO_RESULTS} size="170" />
|
||||
<div className="mt-6 text-2xl">No issues found</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<FunnelIssuesList issues={data.issues} />
|
||||
</NoContent>
|
||||
</Loader>
|
||||
</div>
|
||||
));
|
||||
}
|
||||
|
||||
export default FunnelIssues;
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
import React, { Component, ReactNode, FunctionComponent, useEffect } from 'react';
|
||||
import Select from 'Shared/Select'
|
||||
import { components } from 'react-select';
|
||||
import { Icon } from 'UI';
|
||||
import FunnelIssuesSelectedFilters from '../FunnelIssuesSelectedFilters';
|
||||
import { useStore } from 'App/mstore';
|
||||
|
||||
const options = [
|
||||
{ value: "click_rage", label: "Click Rage" },
|
||||
{ value: "dead_click", label: "Dead Click" },
|
||||
{ value: "excessive_scrolling", label: "Excessive Scrolling" },
|
||||
{ value: "bad_request", label: "Bad Request" },
|
||||
{ value: "missing_resource", label: "Missing Image" },
|
||||
{ value: "memory", label: "High Memory Usage" },
|
||||
{ value: "cpu", label: "High CPU" },
|
||||
{ value: "slow_resource", label: "Slow Resource" },
|
||||
{ value: "slow_page_load", label: "Slow Page" },
|
||||
{ value: "crash", label: "Crash" },
|
||||
{ value: "custom_event_error", label: "Custom Error" },
|
||||
{ value: "js_error", label: "Error" }
|
||||
]
|
||||
|
||||
function FunnelIssuesDropdown(props) {
|
||||
const { funnelStore } = useStore();
|
||||
const [isOpen, setIsOpen] = React.useState(false);
|
||||
const [selectedValues, setSelectedValues] = React.useState<any>([]);
|
||||
const filteredOptions = options.filter((option: any) => {
|
||||
return !selectedValues.includes(option.value);
|
||||
});
|
||||
|
||||
const selectedOptions = options.filter((option: any) => {
|
||||
return selectedValues.includes(option.value);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
funnelStore.updateKey('issuesFilter', selectedOptions);
|
||||
}, [selectedOptions]);
|
||||
|
||||
const handleChange = ({ value }: any) => {
|
||||
toggleSelectedValue(value.value);
|
||||
}
|
||||
|
||||
const toggleSelectedValue = (value: string) => {
|
||||
if (selectedValues.includes(value)) {
|
||||
setSelectedValues(selectedValues.filter(v => v !== value));
|
||||
} else {
|
||||
setSelectedValues([...selectedValues, value]);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-start">
|
||||
<Select
|
||||
menuIsOpen={isOpen}
|
||||
onMenuOpen={() => setIsOpen(true)}
|
||||
onMenuClose={() => setIsOpen(false)}
|
||||
options={filteredOptions}
|
||||
onChange={handleChange}
|
||||
styles={{
|
||||
control: (provided: any) => ({
|
||||
...provided,
|
||||
border: 'none',
|
||||
boxShadow: 'none',
|
||||
backgroundColor: 'transparent',
|
||||
minHeight: 'unset',
|
||||
}),
|
||||
menuList: (provided: any) => ({
|
||||
...provided,
|
||||
padding: 0,
|
||||
minWidth: '190px',
|
||||
}),
|
||||
}}
|
||||
components={{
|
||||
ValueContainer: (): any => null,
|
||||
DropdownIndicator: (): any => null,
|
||||
IndicatorSeparator: (): any => null,
|
||||
IndicatorsContainer: (): any => null,
|
||||
Control: ({ children, ...props }: any) => (
|
||||
<components.Control {...props}>
|
||||
{ children }
|
||||
<button className="px-2 py-1 bg-white rounded-2xl border border-teal border-dashed color-teal flex items-center hover:bg-active-blue" onClick={() => setIsOpen(!isOpen)}>
|
||||
<Icon name="funnel" size={16} color="teal" />
|
||||
<span className="ml-2">Issues</span>
|
||||
</button>
|
||||
</components.Control>
|
||||
),
|
||||
Placeholder: (): any => null,
|
||||
SingleValue: (): any => null,
|
||||
}}
|
||||
/>
|
||||
<FunnelIssuesSelectedFilters removeSelectedValue={toggleSelectedValue} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FunnelIssuesDropdown;
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from './FunnelIssuesDropdown';
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
import { useStore } from 'App/mstore';
|
||||
import { useObserver } from 'mobx-react-lite';
|
||||
import React from 'react';
|
||||
import FunnelIssuesListItem from '../FunnelIssuesListItem';
|
||||
|
||||
interface Props {
|
||||
issues: any;
|
||||
}
|
||||
function FunnelIssuesList(props: Props) {
|
||||
const { issues } = props;
|
||||
const { funnelStore } = useStore();
|
||||
const issuesSort = useObserver(() => funnelStore.issuesSort);
|
||||
const issuesFilter = useObserver(() => funnelStore.issuesFilter.map((issue: any) => issue.value));
|
||||
|
||||
let filteredIssues = useObserver(() => issuesFilter.length > 0 ? issues.filter((issue: any) => issuesFilter.includes(issue.type)) : issues);
|
||||
filteredIssues = useObserver(() => issuesSort.sort ? filteredIssues.slice().sort((a: { [x: string]: number; }, b: { [x: string]: number; }) => a[issuesSort.sort] - b[issuesSort.sort]): filteredIssues);
|
||||
filteredIssues = useObserver(() => issuesSort.order === 'desc' ? filteredIssues.reverse() : filteredIssues);
|
||||
|
||||
return useObserver(() => (
|
||||
<div>
|
||||
{filteredIssues.map((issue: any, index: React.Key) => (
|
||||
<div key={index} className="mb-4">
|
||||
<FunnelIssuesListItem issue={issue} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
));
|
||||
}
|
||||
|
||||
export default FunnelIssuesList;
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from './FunnelIssuesList';
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
import React from 'react';
|
||||
import cn from 'classnames';
|
||||
import { Icon, TextEllipsis } from 'UI';
|
||||
import FunnelIssueGraph from '../FunnelIssueGraph';
|
||||
import { useModal } from 'App/components/Modal';
|
||||
import FunnelIssueModal from '../FunnelIssueModal';
|
||||
|
||||
interface Props {
|
||||
issue: any;
|
||||
inDetails?: boolean;
|
||||
}
|
||||
function FunnelIssuesListItem(props) {
|
||||
const { issue, inDetails = false } = props;
|
||||
const { showModal } = useModal();
|
||||
const onClick = () => {
|
||||
showModal(<FunnelIssueModal issueId={issue.issueId} />, { right: true });
|
||||
}
|
||||
return (
|
||||
<div className={cn('flex flex-col bg-white w-full rounded border relative hover:bg-active-blue', { 'cursor-pointer bg-hover' : !inDetails })} onClick={!inDetails ? onClick : () => null}>
|
||||
{/* {inDetails && (
|
||||
<BackLink onClick={onBack} className="absolute" style={{ left: '-50px', top: '8px' }} />
|
||||
)} */}
|
||||
<div className="flex items-center px-6 py-4 relative">
|
||||
<div className="mr-3">
|
||||
<div
|
||||
className="flex items-center justify-center flex-shrink-0 mr-3 relative"
|
||||
>
|
||||
<Icon name={issue.icon.icon} style={{ fill: issue.icon.color }} size="24" className="z-10 inset-0" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{inDetails && (
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<div className="text-lg font-medium mb-2 capitalize">{issue.title}</div>
|
||||
<div className="text-xl whitespace-nowrap">
|
||||
<TextEllipsis text={issue.contextString} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!inDetails && (
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<div className="text-xl mb-2 capitalize">{issue.title}</div>
|
||||
<div className="text-sm color-gray-medium whitespace-nowrap leading-none">
|
||||
<TextEllipsis text={issue.contextString} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-center text-sm ml-10 flex-shrink-0">
|
||||
<div className="text-xl mb-2">{issue.affectedUsers}</div>
|
||||
<div className="color-gray-medium leading-none">Affected Users</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center text-sm ml-10 flex-shrink-0">
|
||||
<div className="text-xl mb-2 color-red">{issue.conversionImpact}<span className="text-sm ml-1">%</span></div>
|
||||
<div className="color-gray-medium leading-none">Conversion Impact</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center text-sm ml-10 flex-shrink-0">
|
||||
<div className="text-xl mb-2">{issue.lostConversions}</div>
|
||||
<div className="color-gray-medium leading-none">Lost Conversions</div>
|
||||
</div>
|
||||
</div>
|
||||
{inDetails && (
|
||||
<div className="flex items-center px-6 py-4 justify-between border-t">
|
||||
<FunnelIssueGraph issue={issue} />
|
||||
<div className="flex items-center">
|
||||
<Info label="Unaffected sessions" color="rgba(217, 219, 238, 0.7)" />
|
||||
<Info label="Affected sessions" color="rgba(238, 238, 238, 0.7)" />
|
||||
<Info label="Conversion Lost" color="rgba(204, 0, 0, 0.26)" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FunnelIssuesListItem;
|
||||
|
||||
const Info = ({ label = '', color = 'red'}) => {
|
||||
return (
|
||||
<div className="flex items-center ml-4">
|
||||
<div className="flex text-sm items-center color-gray-medium">
|
||||
<div className={ cn("w-2 h-2 rounded-full mr-2") } style={{ backgroundColor: color }} />
|
||||
<div>{ label }</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from './FunnelIssuesListItem';
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
import React from 'react';
|
||||
import { Icon } from 'UI';
|
||||
import { useStore } from 'App/mstore';
|
||||
import { useObserver } from 'mobx-react-lite';
|
||||
|
||||
interface Props {
|
||||
removeSelectedValue: (value: string) => void;
|
||||
}
|
||||
function FunnelIssuesSelectedFilters(props: Props) {
|
||||
const { funnelStore } = useStore();
|
||||
const issuesFilter = useObserver(() => funnelStore.issuesFilter);
|
||||
const { removeSelectedValue } = props;
|
||||
|
||||
return (
|
||||
<div className="flex items-center flex-wrap">
|
||||
{issuesFilter.map((option, index) => (
|
||||
<div key={index} className="transition-all ml-2 mb-2 flex items-center border rounded-2xl bg-white select-none overflow-hidden">
|
||||
<span className="pl-3 color-gray-dark">{option.label}</span>
|
||||
<button className="ml-1 hover:bg-active-blue px-2 py-2" onClick={() => removeSelectedValue(option.value)}>
|
||||
<Icon name="close"/>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FunnelIssuesSelectedFilters;
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from './FunnelIssuesSelectedFilters';
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
import { useStore } from 'App/mstore';
|
||||
import React from 'react';
|
||||
import Select from 'Shared/Select';
|
||||
|
||||
const sortOptions = [
|
||||
{ value: 'afectedUsers-desc', label: 'Affected Users (High)' },
|
||||
{ value: 'afectedUsers-asc', label: 'Affected Users (Low)' },
|
||||
{ value: 'conversionImpact-desc', label: 'Conversion Impact (High)' },
|
||||
{ value: 'conversionImpact-asc', label: 'Conversion Impact (Low)' },
|
||||
{ value: 'lostConversions-desc', label: 'Lost Conversions (High)' },
|
||||
{ value: 'lostConversions-asc', label: 'Lost Conversions (Low)' },
|
||||
]
|
||||
|
||||
interface Props {
|
||||
// onChange?: (value: string) => void;
|
||||
}
|
||||
function FunnelIssuesSort(props: Props) {
|
||||
const { funnelStore } = useStore();
|
||||
|
||||
const onSortChange = (opt: any) => {
|
||||
const [ sort, order ] = opt.value.value.split('-');
|
||||
funnelStore.updateKey('issuesSort', { sort, order });
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Select
|
||||
plain
|
||||
defaultValue={sortOptions[0].value}
|
||||
options={sortOptions}
|
||||
alignRight={true}
|
||||
onChange={onSortChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FunnelIssuesSort;
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from './FunnelIssuesSort';
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
import React from 'react';
|
||||
import SessionListItem from '../SessionListItem';
|
||||
|
||||
function SessionList(props) {
|
||||
return (
|
||||
<div>
|
||||
Session list
|
||||
<SessionListItem session={{}} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SessionList;
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from './SessionList';
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
import React from 'react';
|
||||
|
||||
interface Props {
|
||||
session: any;
|
||||
}
|
||||
function SessionListItem(props: Props) {
|
||||
return (
|
||||
<div>
|
||||
Session list item
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SessionListItem;
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from './SessionListItem';
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
import React from 'react';
|
||||
import SessionList from '../SessionList';
|
||||
|
||||
function SessionWidget(props) {
|
||||
return (
|
||||
<div>
|
||||
<SessionList />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SessionWidget;
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from './SessionWidget';
|
||||
|
|
@ -13,6 +13,9 @@ import { getStartAndEndTimestampsByDensity } from 'Types/dashboard/helper';
|
|||
import { debounce } from 'App/utils';
|
||||
import useIsMounted from 'App/hooks/useIsMounted'
|
||||
|
||||
import FunnelWidget from 'App/components/Funnels/FunnelWidget';
|
||||
import ErrorsWidget from '../Errors/ErrorsWidget';
|
||||
import SessionWidget from '../Sessions/SessionWidget';
|
||||
interface Props {
|
||||
metric: any;
|
||||
isWidget?: boolean;
|
||||
|
|
@ -35,6 +38,7 @@ function WidgetChart(props: Props) {
|
|||
|
||||
const isTableWidget = metric.metricType === 'table' && metric.viewType === 'table';
|
||||
const isPieChart = metric.metricType === 'table' && metric.viewType === 'pieChart';
|
||||
const isFunnel = metric.metricType === 'funnel';
|
||||
|
||||
const onChartClick = (event: any) => {
|
||||
if (event) {
|
||||
|
|
@ -60,7 +64,7 @@ function WidgetChart(props: Props) {
|
|||
|
||||
const depsString = JSON.stringify(_metric.series);
|
||||
|
||||
const fetchMetricChartData = (metric, payload, isWidget) => {
|
||||
const fetchMetricChartData = (metric: any, payload: any, isWidget: any) => {
|
||||
if (!isMounted()) return;
|
||||
setLoading(true)
|
||||
dashboardStore.fetchMetricChartData(metric, payload, isWidget).then((res: any) => {
|
||||
|
|
@ -84,6 +88,18 @@ function WidgetChart(props: Props) {
|
|||
const renderChart = () => {
|
||||
const { metricType, viewType } = metric;
|
||||
|
||||
if (metricType === 'sessions') {
|
||||
return <SessionWidget metric={metric} />
|
||||
}
|
||||
|
||||
if (metricType === 'errors') {
|
||||
return <ErrorsWidget metric={metric} />
|
||||
}
|
||||
|
||||
if (metricType === 'funnel') {
|
||||
return <FunnelWidget metric={metric} />
|
||||
}
|
||||
|
||||
if (metricType === 'predefined') {
|
||||
if (isOverviewWidget) {
|
||||
return <CustomMetricOverviewChart data={data} />
|
||||
|
|
@ -135,7 +151,7 @@ function WidgetChart(props: Props) {
|
|||
return <div>Unknown</div>;
|
||||
}
|
||||
return useObserver(() => (
|
||||
<Loader loading={loading} style={{ height: `${isOverviewWidget ? 100 : 240}px` }}>
|
||||
<Loader loading={!isFunnel && loading} style={{ height: `${isOverviewWidget ? 100 : 240}px` }}>
|
||||
{renderChart()}
|
||||
</Loader>
|
||||
));
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import React, { useState } from 'react';
|
||||
import DropdownPlain from 'Shared/DropdownPlain';
|
||||
import { metricTypes, metricOf, issueOptions } from 'App/constants/filterOptions';
|
||||
import { FilterKey } from 'Types/filter/filterType';
|
||||
import { useStore } from 'App/mstore';
|
||||
|
|
@ -7,6 +6,7 @@ import { useObserver } from 'mobx-react-lite';
|
|||
import { Button, Icon } from 'UI'
|
||||
import FilterSeries from '../FilterSeries';
|
||||
import { confirm } from 'UI';
|
||||
import Select from 'Shared/Select'
|
||||
import { withSiteId, dashboardMetricDetails, metricDetails } from 'App/routes'
|
||||
import DashboardSelectionModal from '../DashboardSelectionModal/DashboardSelectionModal';
|
||||
|
||||
|
|
@ -22,27 +22,36 @@ function WidgetForm(props: Props) {
|
|||
const { metricStore, dashboardStore } = useStore();
|
||||
const dashboards = dashboardStore.dashboards;
|
||||
const isSaving = useObserver(() => metricStore.isSaving);
|
||||
const metric: any = useObserver(() => metricStore.instance);
|
||||
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 _issueOptions = [{ text: 'All', value: 'all' }].concat(issueOptions);
|
||||
const isFunnel = metric.metricType === 'funnel';
|
||||
const isErrors = metric.metricType === 'errors';
|
||||
const isSessions = metric.metricType === 'sessions';
|
||||
const _issueOptions = [{ label: 'All', value: 'all' }].concat(issueOptions);
|
||||
const canAddToDashboard = metric.exists() && dashboards.length > 0;
|
||||
const canAddSeries = metric.series.length < 3;
|
||||
|
||||
const write = ({ target: { value, name } }) => metricStore.merge({ [ name ]: value });
|
||||
const writeOption = (e, { value, name }) => {
|
||||
const obj = { [ name ]: value };
|
||||
// const write = ({ target: { value, name } }) => metricStore.merge({ [ 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];
|
||||
obj['metricValue'] = value;
|
||||
|
||||
// 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 === 'metricOf') {
|
||||
if (value === FilterKey.ISSUE) {
|
||||
obj['metricValue'] = ['all'];
|
||||
}
|
||||
// if (value === FilterKey.ISSUE) {
|
||||
// obj['metricValue'] = [{ value: 'all', label: 'All' }];
|
||||
// }
|
||||
}
|
||||
|
||||
if (name === 'metricType') {
|
||||
|
|
@ -60,14 +69,13 @@ function WidgetForm(props: Props) {
|
|||
|
||||
const onSave = () => {
|
||||
const wasCreating = !metric.exists()
|
||||
metricStore.save(metric, dashboardId).then((metric) => {
|
||||
metricStore.save(metric, dashboardId).then((metric: any) => {
|
||||
if (wasCreating) {
|
||||
if (parseInt(dashboardId) > 0) {
|
||||
history.replace(withSiteId(dashboardMetricDetails(parseInt(dashboardId), metric.metricId), siteId));
|
||||
} else {
|
||||
history.replace(withSiteId(metricDetails(metric.metricId), siteId));
|
||||
}
|
||||
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -87,24 +95,24 @@ function WidgetForm(props: Props) {
|
|||
}
|
||||
|
||||
return useObserver(() => (
|
||||
<div className="p-4">
|
||||
<div className="p-6">
|
||||
<div className="form-group">
|
||||
<label className="font-medium">Metric Type</label>
|
||||
<div className="flex items-center">
|
||||
<DropdownPlain
|
||||
<Select
|
||||
name="metricType"
|
||||
options={metricTypes}
|
||||
value={ metric.metricType }
|
||||
value={metricTypes.find((i: any) => i.value === metric.metricType) || metricTypes[0]}
|
||||
onChange={ writeOption }
|
||||
/>
|
||||
|
||||
{metric.metricType === 'timeseries' && (
|
||||
<>
|
||||
<span className="mx-3">of</span>
|
||||
<DropdownPlain
|
||||
<Select
|
||||
name="metricOf"
|
||||
options={timeseriesOptions}
|
||||
value={ metric.metricOf }
|
||||
defaultValue={metric.metricOf}
|
||||
onChange={ writeOption }
|
||||
/>
|
||||
</>
|
||||
|
|
@ -113,10 +121,10 @@ function WidgetForm(props: Props) {
|
|||
{metric.metricType === 'table' && (
|
||||
<>
|
||||
<span className="mx-3">of</span>
|
||||
<DropdownPlain
|
||||
<Select
|
||||
name="metricOf"
|
||||
options={tableOptions}
|
||||
value={ metric.metricOf }
|
||||
defaultValue={metric.metricOf}
|
||||
onChange={ writeOption }
|
||||
/>
|
||||
</>
|
||||
|
|
@ -125,11 +133,13 @@ function WidgetForm(props: Props) {
|
|||
{metric.metricOf === FilterKey.ISSUE && (
|
||||
<>
|
||||
<span className="mx-3">issue type</span>
|
||||
<DropdownPlain
|
||||
<Select
|
||||
name="metricValue"
|
||||
options={_issueOptions}
|
||||
value={ metric.metricValue[0] }
|
||||
options={issueOptions}
|
||||
value={metric.metricValue}
|
||||
onChange={ writeOption }
|
||||
isMulti={true}
|
||||
placeholder="All Issues"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
|
@ -137,12 +147,12 @@ function WidgetForm(props: Props) {
|
|||
{metric.metricType === 'table' && (
|
||||
<>
|
||||
<span className="mx-3">showing</span>
|
||||
<DropdownPlain
|
||||
<Select
|
||||
name="metricFormat"
|
||||
options={[
|
||||
{ value: 'sessionCount', text: 'Session Count' },
|
||||
{ value: 'sessionCount', label: 'Session Count' },
|
||||
]}
|
||||
value={ metric.metricFormat }
|
||||
defaultValue={ metric.metricFormat }
|
||||
onChange={ writeOption }
|
||||
/>
|
||||
</>
|
||||
|
|
@ -151,9 +161,9 @@ function WidgetForm(props: Props) {
|
|||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<div className="flex items-center font-medium items-center py-2">
|
||||
{`${isTable ? 'Filter by' : 'Chart Series'}`}
|
||||
{!isTable && (
|
||||
<div className="flex items-center font-medium py-2">
|
||||
{`${(isTable || isFunnel) ? 'Filter by' : 'Chart Series'}`}
|
||||
{!isTable && !isFunnel && (
|
||||
<Button
|
||||
className="ml-2"
|
||||
variant="text-primary"
|
||||
|
|
@ -163,7 +173,7 @@ function WidgetForm(props: Props) {
|
|||
)}
|
||||
</div>
|
||||
|
||||
{metric.series.length > 0 && metric.series.slice(0, isTable ? 1 : metric.series.length).map((series: any, index: number) => (
|
||||
{metric.series.length > 0 && metric.series.slice(0, (isTable || isFunnel) ? 1 : metric.series.length).map((series: any, index: number) => (
|
||||
<div className="mb-2">
|
||||
<FilterSeries
|
||||
hideHeader={ isTable }
|
||||
|
|
|
|||
|
|
@ -2,9 +2,9 @@ import React from 'react';
|
|||
import cn from 'classnames';
|
||||
import WidgetWrapper from '../WidgetWrapper';
|
||||
import { useStore } from 'App/mstore';
|
||||
import { Loader, NoContent, SegmentSelection, Icon } from 'UI';
|
||||
import DateRange from 'Shared/DateRange';
|
||||
import { SegmentSelection } from 'UI';
|
||||
import { useObserver } from 'mobx-react-lite';
|
||||
import SelectDateRange from 'Shared/SelectDateRange';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
|
|
@ -17,7 +17,7 @@ function WidgetPreview(props: Props) {
|
|||
const isTimeSeries = metric.metricType === 'timeseries';
|
||||
const isTable = metric.metricType === 'table';
|
||||
|
||||
const chagneViewType = (e, { name, value }) => {
|
||||
const chagneViewType = (e, { name, value }: any) => {
|
||||
metric.update({ [ name ]: value });
|
||||
}
|
||||
|
||||
|
|
@ -63,13 +63,9 @@ function WidgetPreview(props: Props) {
|
|||
)}
|
||||
<div className="mx-4" />
|
||||
<span className="mr-1 color-gray-medium">Time Range</span>
|
||||
<DateRange
|
||||
rangeValue={period.rangeName}
|
||||
startDate={period.startDate}
|
||||
endDate={period.endDate}
|
||||
onDateChange={(period) => dashboardStore.setPeriod(period)}
|
||||
customRangeRight
|
||||
direction="left"
|
||||
<SelectDateRange
|
||||
period={period}
|
||||
onChange={(period: any) => dashboardStore.setPeriod(period)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import { NoContent, Dropdown, Icon, Loader, Pagination } from 'UI';
|
||||
import Select from 'Shared/Select';
|
||||
import cn from 'classnames';
|
||||
import { useStore } from 'App/mstore';
|
||||
import SessionItem from 'Shared/SessionItem';
|
||||
|
|
@ -18,20 +19,20 @@ function WidgetSessions(props: Props) {
|
|||
const isMounted = useIsMounted()
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [seriesOptions, setSeriesOptions] = useState([
|
||||
{ text: 'All', value: 'all' },
|
||||
{ label: 'All', value: 'all' },
|
||||
]);
|
||||
|
||||
const [activeSeries, setActiveSeries] = useState('all');
|
||||
|
||||
const writeOption = (e, { name, value }) => setActiveSeries(value);
|
||||
const writeOption = (e, { name, value }) => setActiveSeries(value.value);
|
||||
useEffect(() => {
|
||||
if (!data) return;
|
||||
const seriesOptions = data.map(item => ({
|
||||
text: item.seriesName,
|
||||
label: item.seriesName,
|
||||
value: item.seriesId,
|
||||
}));
|
||||
setSeriesOptions([
|
||||
{ text: 'All', value: 'all' },
|
||||
{ label: 'All', value: 'all' },
|
||||
...seriesOptions,
|
||||
]);
|
||||
}, [data]);
|
||||
|
|
@ -70,7 +71,7 @@ function WidgetSessions(props: Props) {
|
|||
{ widget.metricType !== 'table' && (
|
||||
<div className="flex items-center ml-6">
|
||||
<span className="mr-2 color-gray-medium">Series</span>
|
||||
<Dropdown
|
||||
{/* <Dropdown
|
||||
// className={stl.dropdown}
|
||||
className="font-medium flex items-center hover:bg-gray-light rounded px-2 py-1"
|
||||
direction="left"
|
||||
|
|
@ -81,6 +82,11 @@ function WidgetSessions(props: Props) {
|
|||
id="change-dropdown"
|
||||
// icon={null}
|
||||
icon={ <Icon name="chevron-down" color="gray-dark" size="14" className="ml-2" /> }
|
||||
/> */}
|
||||
<Select
|
||||
options={ seriesOptions }
|
||||
onChange={ writeOption }
|
||||
plain
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,46 @@
|
|||
import Breadcrumb from 'App/components/shared/Breadcrumb';
|
||||
import { useStore } from 'App/mstore';
|
||||
import { useObserver } from 'mobx-react-lite';
|
||||
import React, { useEffect } from 'react';
|
||||
import { withSiteId } from 'App/routes';
|
||||
import { Loader } from 'UI';
|
||||
import FunnelIssueDetails from '../Funnels/FunnelIssueDetails';
|
||||
|
||||
interface Props {
|
||||
history: any;
|
||||
match: any
|
||||
siteId: any
|
||||
}
|
||||
function WidgetSubDetailsView(props: Props) {
|
||||
const { match: { params: { siteId, dashboardId, metricId, subId } } } = props;
|
||||
const { metricStore, funnelStore } = useStore();
|
||||
const widget = useObserver(() => metricStore.instance);
|
||||
const issueInstance = useObserver(() => funnelStore.issueInstance);
|
||||
const loadingWidget = useObserver(() => metricStore.isLoading);
|
||||
// const isFunnel = widget.metricType === 'funnel'; // TODO uncomment this line
|
||||
const isFunnel = widget.metricType === 'table'; // TODO remove this line
|
||||
|
||||
useEffect(() => {
|
||||
if (!widget || !widget.exists()) {
|
||||
metricStore.fetch(metricId);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Breadcrumb
|
||||
items={[
|
||||
{ label: dashboardId ? 'Dashboard' : 'Metrics', to: dashboardId ? withSiteId('/dashboard/' + dashboardId, siteId) : withSiteId('/metrics', siteId) },
|
||||
{ label: widget.name, to: withSiteId(`/metrics/${widget.metricId}`, siteId) },
|
||||
{ label: issueInstance ? issueInstance.title : 'Sub Details' }
|
||||
]}
|
||||
/>
|
||||
|
||||
<Loader loading={loadingWidget}>
|
||||
{isFunnel && <FunnelIssueDetails funnelId={metricId} issueId={subId} />}
|
||||
</Loader>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default WidgetSubDetailsView;
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from './WidgetSubDetailsView';
|
||||
|
|
@ -7,7 +7,10 @@ import WidgetPreview from '../WidgetPreview';
|
|||
import WidgetSessions from '../WidgetSessions';
|
||||
import { useObserver } from 'mobx-react-lite';
|
||||
import WidgetName from '../WidgetName';
|
||||
import { withSiteId } from 'App/routes';
|
||||
|
||||
import FunnelIssues from '../Funnels/FunnelIssues/FunnelIssues';
|
||||
import Breadcrumb from 'Shared/Breadcrumb';
|
||||
interface Props {
|
||||
history: any;
|
||||
match: any
|
||||
|
|
@ -23,7 +26,7 @@ function WidgetView(props: Props) {
|
|||
React.useEffect(() => {
|
||||
if (metricId && metricId !== 'create') {
|
||||
metricStore.fetch(metricId);
|
||||
} else {
|
||||
} else if (metricId === 'create') {
|
||||
metricStore.init();
|
||||
}
|
||||
}, [])
|
||||
|
|
@ -40,11 +43,16 @@ function WidgetView(props: Props) {
|
|||
return useObserver(() => (
|
||||
<Loader loading={loading}>
|
||||
<div className="relative pb-10">
|
||||
<BackLink onClick={onBackHandler} vertical className="absolute" style={{ left: '-50px', top: '0px' }} />
|
||||
<Breadcrumb
|
||||
items={[
|
||||
{ label: dashboardId ? 'Dashboard' : 'Metrics', to: dashboardId ? withSiteId('/dashboard/' + dashboardId, siteId) : withSiteId('/metrics', siteId) },
|
||||
{ label: widget.name, }
|
||||
]}
|
||||
/>
|
||||
<div className="bg-white rounded border">
|
||||
<div
|
||||
className={cn(
|
||||
"p-4 flex justify-between items-center",
|
||||
"px-6 py-4 flex justify-between items-center",
|
||||
{
|
||||
'cursor-pointer hover:bg-active-blue hover:shadow-border-blue': !expanded,
|
||||
}
|
||||
|
|
@ -73,7 +81,8 @@ function WidgetView(props: Props) {
|
|||
</div>
|
||||
|
||||
<WidgetPreview className="mt-8" />
|
||||
<WidgetSessions className="mt-8" />
|
||||
{ (widget.metricType === 'table' || widget.metricType === 'timeseries') && <WidgetSessions className="mt-8" /> }
|
||||
{ widget.metricType === 'funnel' && <FunnelIssues /> }
|
||||
</div>
|
||||
</Loader>
|
||||
));
|
||||
|
|
|
|||
|
|
@ -0,0 +1,15 @@
|
|||
import React from 'react';
|
||||
import { withRouter } from 'react-router-dom';
|
||||
|
||||
interface Props {
|
||||
|
||||
}
|
||||
function FunnelDetails(props: Props) {
|
||||
return (
|
||||
<div>
|
||||
Create View/Detail View
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default withRouter(FunnelDetails);
|
||||
24
frontend/app/components/Funnels/FunnelItem/FunnelItem.tsx
Normal file
24
frontend/app/components/Funnels/FunnelItem/FunnelItem.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { IFunnel } from 'App/mstore/types/funnel';
|
||||
import React from 'react';
|
||||
import { Icon } from 'UI';
|
||||
|
||||
interface Props {
|
||||
funnel: IFunnel
|
||||
}
|
||||
function FunnelItem(props: Props) {
|
||||
const { funnel } = props;
|
||||
return (
|
||||
<div className="grid grid-cols-12 p-3 border-t select-none items-center">
|
||||
<div className="col-span-4 flex items-center">
|
||||
<div className="bg-tealx-lightest w-8 h-8 rounded-full flex items-center justify-center" style={{ backgroundColor: 'rgba()'}}>
|
||||
<Icon name={funnel.isPublic ? 'user-friends' : 'person-fill'} color="tealx" />
|
||||
</div>
|
||||
<span className="ml-2 link">{funnel.name}</span>
|
||||
</div>
|
||||
<div className="col-span-3">{funnel.name}</div>
|
||||
<div className="col-span-3">{funnel.name}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FunnelItem;
|
||||
61
frontend/app/components/Funnels/FunnelList/FunnelList.tsx
Normal file
61
frontend/app/components/Funnels/FunnelList/FunnelList.tsx
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import { PageTitle, Button, Pagination, Icon, Loader } from 'UI';
|
||||
import { useStore } from 'App/mstore';
|
||||
import { useObserver } from 'mobx-react-lite';
|
||||
import React, { useEffect } from 'react';
|
||||
import { sliceListPerPage } from 'App/utils';
|
||||
import FunnelItem from '../FunnelItem/FunnelItem';
|
||||
import FunnelSearch from '../FunnelSearch';
|
||||
|
||||
function FunnelList(props) {
|
||||
const { funnelStore } = useStore()
|
||||
const list = useObserver(() => funnelStore.list)
|
||||
const loading = useObserver(() => funnelStore.isLoading)
|
||||
|
||||
useEffect(() => {
|
||||
if (list.length === 0) {
|
||||
funnelStore.fetchFunnels()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Loader loading={loading}>
|
||||
<div className="flex items-center">
|
||||
<div className="flex items-center">
|
||||
<PageTitle title='Funnels' className="mr-3" />
|
||||
<Button primary size="small" onClick={() => {}}>New Funnel</Button>
|
||||
</div>
|
||||
<div className="ml-auto w-1/4">
|
||||
<FunnelSearch />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="color-gray-medium mt-2 mb-4">Funnels make it easy to uncover the most significant issues that impacted conversions.</div>
|
||||
|
||||
<div className="mt-3 border rounded bg-white">
|
||||
<div className="grid grid-cols-12 p-3 font-medium">
|
||||
<div className="col-span-4 flex items-center">
|
||||
<Icon name="funnel-fill"/> <span className="ml-2">Title</span>
|
||||
</div>
|
||||
<div className="col-span-3">Owner</div>
|
||||
<div className="col-span-3">Last Modified</div>
|
||||
</div>
|
||||
|
||||
{sliceListPerPage(list, funnelStore.page - 1, funnelStore.pageSize).map((funnel: any) => (
|
||||
<FunnelItem funnel={funnel} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="w-full flex items-center justify-center py-8">
|
||||
<Pagination
|
||||
page={funnelStore.page}
|
||||
totalPages={Math.ceil(list.length / funnelStore.pageSize)}
|
||||
onPageChange={(page) => funnelStore.updateKey('page', page)}
|
||||
limit={funnelStore.pageSize}
|
||||
debounceRequest={100}
|
||||
/>
|
||||
</div>
|
||||
</Loader>
|
||||
);
|
||||
}
|
||||
|
||||
export default FunnelList;
|
||||
1
frontend/app/components/Funnels/FunnelList/index.ts
Normal file
1
frontend/app/components/Funnels/FunnelList/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default } from './FunnelList';
|
||||
26
frontend/app/components/Funnels/FunnelPage/FunnelPage.tsx
Normal file
26
frontend/app/components/Funnels/FunnelPage/FunnelPage.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import React from 'react';
|
||||
import { Switch, Route } from 'react-router';
|
||||
import FunnelDetails from '../FunnelDetails/FunnelDetails';
|
||||
import FunnelList from '../FunnelList';
|
||||
|
||||
function FunnelPage(props) {
|
||||
return (
|
||||
<div className="page-margin container-70">
|
||||
<Switch>
|
||||
<Route path="/">
|
||||
<FunnelList />
|
||||
</Route>
|
||||
|
||||
<Route path="/funnel/create">
|
||||
<FunnelDetails />
|
||||
</Route>
|
||||
|
||||
{/* <Route path="/funnel/:id">
|
||||
<FunnelDetail />
|
||||
</Route> */}
|
||||
</Switch>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FunnelPage;
|
||||
1
frontend/app/components/Funnels/FunnelPage/index.ts
Normal file
1
frontend/app/components/Funnels/FunnelPage/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default } from './FunnelPage';
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
import { useObserver } from 'mobx-react-lite';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useStore } from 'App/mstore';
|
||||
import { Icon } from 'UI';
|
||||
import { debounce } from 'App/utils';
|
||||
|
||||
let debounceUpdate: any = () => {}
|
||||
function FunnelSearch(props) {
|
||||
const { funnelStore } = useStore();
|
||||
const [query, setQuery] = useState(funnelStore.search);
|
||||
useEffect(() => {
|
||||
debounceUpdate = debounce((key, value) => funnelStore.updateKey(key, value), 500);
|
||||
}, [])
|
||||
|
||||
const write = ({ target: { name, value } }) => {
|
||||
setQuery(value);
|
||||
debounceUpdate('metricsSearch', value);
|
||||
}
|
||||
|
||||
return useObserver(() => (
|
||||
<div className="relative">
|
||||
<Icon name="search" className="absolute top-0 bottom-0 ml-2 m-auto" size="16" />
|
||||
<input
|
||||
value={query}
|
||||
name="metricsSearch"
|
||||
className="bg-white p-2 border rounded w-full pl-10"
|
||||
placeholder="Filter by title, owner"
|
||||
onChange={write}
|
||||
/>
|
||||
</div>
|
||||
));
|
||||
}
|
||||
|
||||
export default FunnelSearch;
|
||||
1
frontend/app/components/Funnels/FunnelSearch/index.ts
Normal file
1
frontend/app/components/Funnels/FunnelSearch/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default } from './FunnelSearch';
|
||||
60
frontend/app/components/Funnels/FunnelWidget/FunnelBar.tsx
Normal file
60
frontend/app/components/Funnels/FunnelWidget/FunnelBar.tsx
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import React from 'react';
|
||||
import FunnelStepText from './FunnelStepText';
|
||||
import { Icon } from 'UI';
|
||||
|
||||
interface Props {
|
||||
filter: any;
|
||||
}
|
||||
function FunnelBar(props: Props) {
|
||||
const { filter } = props;
|
||||
const completedPercentage = calculatePercentage(filter.sessionsCount, filter.dropDueToIssues);
|
||||
|
||||
return (
|
||||
<div className="w-full mb-4">
|
||||
<FunnelStepText filter={filter} />
|
||||
<div style={{
|
||||
height: '25px',
|
||||
// marginBottom: '10px',
|
||||
width: '100%',
|
||||
backgroundColor: '#f5f5f5',
|
||||
position: 'relative',
|
||||
borderRadius: '3px',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<div className="flex items-center" style={{
|
||||
width: `${completedPercentage}%`,
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
// height: '10px',
|
||||
backgroundColor: '#00b5ad',
|
||||
}}>
|
||||
<div className="color-white absolute right-0 flex items-center font-medium mr-2 leading-3">{completedPercentage}%</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between py-2">
|
||||
<div className="flex items-center">
|
||||
<Icon name="arrow-right-short" size="20" color="green" />
|
||||
<span className="mx-1 font-medium">{filter.sessionsCount}</span>
|
||||
<span>Completed</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Icon name="caret-down-fill" color="red" size={16} />
|
||||
<span className="font-medium mx-1 color-red">{filter.dropDueToIssues}</span>
|
||||
<span>Dropped off</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FunnelBar;
|
||||
|
||||
const calculatePercentage = (completed: number, dropped: number) => {
|
||||
const total = completed + dropped;
|
||||
if (total === 0) {
|
||||
return 0;
|
||||
}
|
||||
return Math.round((completed / total) * 100);
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
import React from 'react';
|
||||
|
||||
interface Props {
|
||||
filter: any;
|
||||
}
|
||||
function FunnelStepText(props: Props) {
|
||||
const { filter } = props;
|
||||
const total = filter.value.length;
|
||||
return (
|
||||
<div className="mb-2 color-gray-medium">
|
||||
<span className="color-gray-darkest">{filter.label}</span>
|
||||
<span className="mx-1">{filter.operator}</span>
|
||||
{filter.value.map((value: any, index: number) => (
|
||||
<span key={index}>
|
||||
<span key={index} className="font-medium color-gray-darkest">{value}</span>
|
||||
{index < total - 1 && <span className="mx-1 color-gray-medium">or</span>}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FunnelStepText;
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
.step {
|
||||
/* display: flex; */
|
||||
position: relative;
|
||||
transition: all 0.5s ease;
|
||||
&:before {
|
||||
content: '';
|
||||
border-left: 2px solid $gray-lightest;
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
bottom: 9px;
|
||||
left: 10px;
|
||||
/* width: 1px; */
|
||||
height: 100%;
|
||||
z-index: 0;
|
||||
}
|
||||
&:last-child:before {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.step-disabled {
|
||||
filter: grayscale(1);
|
||||
opacity: 0.8;
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
import React from 'react';
|
||||
import Widget from 'App/mstore/types/widget';
|
||||
import Funnelbar from './FunnelBar';
|
||||
import cn from 'classnames';
|
||||
import stl from './FunnelWidget.module.css';
|
||||
import { Icon } from 'UI';
|
||||
import { useObserver } from 'mobx-react-lite';
|
||||
|
||||
interface Props {
|
||||
metric: Widget;
|
||||
}
|
||||
function FunnelWidget(props: Props) {
|
||||
const { metric } = props;
|
||||
const funnel = metric.data.funnel || { stages: [] };
|
||||
|
||||
return useObserver(() => (
|
||||
<>
|
||||
<div className="w-full">
|
||||
{funnel.stages.map((filter: any, index: any) => (
|
||||
<div key={index} className={cn("flex items-start mb-4", stl.step, { [stl['step-disabled']] : !filter.isActive })}>
|
||||
<div className="z-10 w-6 h-6 border mr-4 text-sm rounded-full bg-gray-lightest flex items-center justify-center leading-3">
|
||||
{index + 1}
|
||||
</div>
|
||||
<Funnelbar key={index} filter={filter} />
|
||||
<div className="self-end flex items-center justify-center ml-4" style={{ marginBottom: '49px'}}>
|
||||
<button onClick={() => filter.updateKey('isActive', !filter.isActive)}>
|
||||
<Icon name="eye-slash-fill" color={filter.isActive ? "gray-light" : "gray-darkest"} size="22" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center pb-4">
|
||||
<div className="flex items-center">
|
||||
<span className="text-xl mr-2">Lost conversions</span>
|
||||
<div className="rounded px-2 py-1 bg-red-lightest color-red">
|
||||
<span className="text-xl mr-2 font-medium">{funnel.lostConversions}</span>
|
||||
<span className="text-sm">(12%)</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mx-3" />
|
||||
<div className="flex items-center">
|
||||
<span className="text-xl mr-2">Total conversions</span>
|
||||
<div className="rounded px-2 py-1 bg-tealx-lightest color-tealx">
|
||||
<span className="text-xl mr-2 font-medium">20</span>
|
||||
<span className="text-sm">(12%)</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mx-3" />
|
||||
<div className="flex items-center">
|
||||
<span className="text-xl mr-2">Affected users</span>
|
||||
<div className="rounded px-2 py-1 bg-gray-lightest">
|
||||
<span className="text-xl font-medium">{funnel.affectedUsers}</span>
|
||||
{/* <span className="text-sm">(12%)</span> */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
));
|
||||
}
|
||||
|
||||
export default FunnelWidget;
|
||||
1
frontend/app/components/Funnels/FunnelWidget/index.ts
Normal file
1
frontend/app/components/Funnels/FunnelWidget/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default } from './FunnelWidget';
|
||||
|
|
@ -7,6 +7,7 @@ import {
|
|||
assist,
|
||||
client,
|
||||
errors,
|
||||
funnels,
|
||||
dashboard,
|
||||
withSiteId,
|
||||
CLIENT_DEFAULT_TAB,
|
||||
|
|
@ -31,6 +32,7 @@ const DASHBOARD_PATH = dashboard();
|
|||
const SESSIONS_PATH = sessions();
|
||||
const ASSIST_PATH = assist();
|
||||
const ERRORS_PATH = errors();
|
||||
const FUNNELS_PATH = funnels();
|
||||
const CLIENT_PATH = client(CLIENT_DEFAULT_TAB);
|
||||
const AUTOREFRESH_INTERVAL = 30 * 1000;
|
||||
|
||||
|
|
@ -105,6 +107,13 @@ const Header = (props) => {
|
|||
>
|
||||
{ 'Errors' }
|
||||
</NavLink>
|
||||
<NavLink
|
||||
to={ withSiteId(FUNNELS_PATH, siteId) }
|
||||
className={ styles.nav }
|
||||
activeClassName={ styles.active }
|
||||
>
|
||||
{ 'Funnels' }
|
||||
</NavLink>
|
||||
<NavLink
|
||||
to={ withSiteId(DASHBOARD_PATH, siteId) }
|
||||
className={ styles.nav }
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import stl from './projectCodeSnippet.module.css'
|
|||
import CircleNumber from '../../CircleNumber';
|
||||
import Highlight from 'react-highlight'
|
||||
import Select from 'Shared/Select'
|
||||
import CodeSnippet from 'Shared/CodeSnippet';
|
||||
|
||||
const inputModeOptions = [
|
||||
{ label: 'Record all inputs', value: 'plain' },
|
||||
|
|
@ -38,7 +39,7 @@ const ProjectCodeSnippet = props => {
|
|||
var initOpts = {
|
||||
projectKey: "PROJECT_KEY",
|
||||
ingestPoint: "https://${window.location.hostname}/ingest",
|
||||
defaultInputMode: ${inputModeOptionsMap[gdpr.defaultInputMode]},
|
||||
defaultInputMode: ${gdpr.defaultInputMode},
|
||||
obscureTextNumbers: ${gdpr.maskNumbers},
|
||||
obscureTextEmails: ${gdpr.maskEmails},
|
||||
};
|
||||
|
|
@ -65,18 +66,13 @@ const ProjectCodeSnippet = props => {
|
|||
}
|
||||
|
||||
const onChangeSelect = ({ name, value }) => {
|
||||
// console.log(name, value)
|
||||
// const { gdpr } = site;
|
||||
const _gdpr = { ...gdpr.toData() };
|
||||
props.editGDPR({ [ name ]: value });
|
||||
_gdpr[name] = value;
|
||||
props.editGDPR({ [ name ]: value });
|
||||
saveGDPR(_gdpr)
|
||||
};
|
||||
|
||||
const onChangeOption = ({ target: { name, checked } }) => {
|
||||
// const { gdpr } = site;
|
||||
console.log(name, checked)
|
||||
const _gdpr = { ...gdpr.toData() };
|
||||
_gdpr[name] = checked;
|
||||
props.editGDPR({ [ name ]: checked });
|
||||
|
|
@ -121,7 +117,7 @@ const ProjectCodeSnippet = props => {
|
|||
<Select
|
||||
name="defaultInputMode"
|
||||
options={ inputModeOptions }
|
||||
onChange={ ({ value }) => onChangeSelect({ name: 'defaultInputMode', value }) }
|
||||
onChange={ ({ value }) => onChangeSelect({ name: 'defaultInputMode', value: value.value }) }
|
||||
placeholder="Default Input Mode"
|
||||
defaultValue={ gdpr.defaultInputMode }
|
||||
/>
|
||||
|
|
@ -170,7 +166,7 @@ const ProjectCodeSnippet = props => {
|
|||
host={ site && site.host }
|
||||
projectKey={ site && site.projectKey }
|
||||
ingestPoint={`"https://${window.location.hostname}/ingest"`}
|
||||
defaultInputMode={ inputModeOptionsMap[gdpr.defaultInputMode] }
|
||||
defaultInputMode={ gdpr.defaultInputMode }
|
||||
obscureTextNumbers={ gdpr.maskNumbers }
|
||||
obscureTextEmails={ gdpr.maskEmails }
|
||||
/>
|
||||
|
|
|
|||
30
frontend/app/components/shared/Breadcrumb/Breadcrumb.tsx
Normal file
30
frontend/app/components/shared/Breadcrumb/Breadcrumb.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import React from 'react';
|
||||
import { Icon } from 'UI';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
interface Props {
|
||||
items: any
|
||||
}
|
||||
function Breadcrumb(props) {
|
||||
const { items } = props;
|
||||
return (
|
||||
<div className="mb-3 flex items-center text-lg">
|
||||
{items.map((item, index) => {
|
||||
if (index === items.length - 1) {
|
||||
return (
|
||||
<span key={index} className="color-gray-medium">{item.label}</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div key={index} className="color-gray-darkest hover:color-teal flex items-center">
|
||||
<Link to={item.to} className="">{item.label}</Link>
|
||||
<span className="mx-2">/</span>
|
||||
{/* <Icon name="chevron-right" /> */}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Breadcrumb;
|
||||
1
frontend/app/components/shared/Breadcrumb/index.ts
Normal file
1
frontend/app/components/shared/Breadcrumb/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default } from './Breadcrumb';
|
||||
|
|
@ -10,6 +10,7 @@ const inputModeOptions = [
|
|||
|
||||
const inputModeOptionsMap: any = {}
|
||||
inputModeOptions.forEach((o: any, i: any) => inputModeOptionsMap[o.value] = i)
|
||||
console.log('inputModeOptionsMap', inputModeOptionsMap)
|
||||
|
||||
|
||||
interface Props {
|
||||
|
|
@ -22,12 +23,13 @@ interface Props {
|
|||
}
|
||||
function CodeSnippet(props: Props) {
|
||||
const { host, projectKey, ingestPoint, defaultInputMode, obscureTextNumbers, obscureTextEmails } = props;
|
||||
console.log('defaultInputMode', defaultInputMode)
|
||||
const codeSnippet = `<!-- OpenReplay Tracking Code for ${host} -->
|
||||
<script>
|
||||
var initOpts = {
|
||||
projectKey: "${projectKey}",
|
||||
ingestPoint: ${ingestPoint},
|
||||
defaultInputMode: ${inputModeOptions[defaultInputMode]},
|
||||
defaultInputMode: ${inputModeOptionsMap[defaultInputMode]},
|
||||
obscureTextNumbers: ${obscureTextNumbers},
|
||||
obscureTextEmails: ${obscureTextEmails},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -29,11 +29,11 @@ function FilterItem(props: Props) {
|
|||
};
|
||||
|
||||
const onOperatorChange = (e, { name, value }) => {
|
||||
props.onUpdate({ ...filter, operator: value })
|
||||
props.onUpdate({ ...filter, operator: value.value })
|
||||
}
|
||||
|
||||
const onSourceOperatorChange = (e, { name, value }) => {
|
||||
props.onUpdate({ ...filter, sourceOperator: value })
|
||||
props.onUpdate({ ...filter, sourceOperator: value.value })
|
||||
}
|
||||
|
||||
const onUpdateSubFilter = (subFilter, subFilterIndex) => {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import React from 'react';
|
|||
import Select, { components, DropdownIndicatorProps } from 'react-select';
|
||||
import { Icon } from 'UI';
|
||||
import colors from 'App/theme/colors';
|
||||
const { ValueContainer } = components;
|
||||
|
||||
interface Props {
|
||||
options: any[];
|
||||
|
|
@ -9,21 +10,48 @@ interface Props {
|
|||
defaultValue?: string;
|
||||
plain?: boolean;
|
||||
components?: any;
|
||||
styles?: any;
|
||||
onChange: (value: any) => void;
|
||||
name?: string;
|
||||
[x:string]: any;
|
||||
}
|
||||
export default function({ right = false, plain = false, options, isSearchable = false, components = {}, defaultValue = '', ...rest }: Props) {
|
||||
export default function({ name = '', onChange, right = false, plain = false, options, isSearchable = false, components = {}, styles = {}, defaultValue = '', ...rest }: Props) {
|
||||
const defaultSelected = options.find(o => o.value === defaultValue) || options[0];
|
||||
const customStyles = {
|
||||
option: (provided: any, state: any) => ({
|
||||
...provided,
|
||||
whiteSpace: 'nowrap',
|
||||
option: (provided, state) => ({
|
||||
...provided,
|
||||
whiteSpace: 'nowrap',
|
||||
transition: 'all 0.3s',
|
||||
backgroundColor: state.isFocused ? colors['active-blue'] : 'transparent',
|
||||
color: state.isFocused ? colors.teal : 'black',
|
||||
'&:hover': {
|
||||
transition: 'all 0.2s',
|
||||
backgroundColor: colors['active-blue'],
|
||||
},
|
||||
'&:focus': {
|
||||
transition: 'all 0.2s',
|
||||
backgroundColor: colors['active-blue'],
|
||||
}
|
||||
}),
|
||||
menu: (provided: any, state: any) => ({
|
||||
...provided,
|
||||
top: 31,
|
||||
borderRadius: '3px',
|
||||
right: right ? 0 : undefined,
|
||||
border: `1px solid ${colors['gray-light']}`,
|
||||
// borderRadius: '3px',
|
||||
backgroundColor: '#fff',
|
||||
boxShadow: '1px 1px 1px rgba(0, 0, 0, 0.1)',
|
||||
position: 'absolute',
|
||||
minWidth: 'fit-content',
|
||||
zIndex: 99,
|
||||
// zIndex: 99,
|
||||
overflow: 'hidden',
|
||||
zIndex: 100,
|
||||
...(right && { right: 0 })
|
||||
}),
|
||||
menuList: (provided: any, state: any) => ({
|
||||
...provided,
|
||||
padding: 0,
|
||||
}),
|
||||
control: (provided: any) => {
|
||||
const obj = {
|
||||
|
|
@ -31,13 +59,20 @@ export default function({ right = false, plain = false, options, isSearchable =
|
|||
border: 'solid thin #ddd',
|
||||
cursor: 'pointer',
|
||||
minHeight: '36px',
|
||||
transition: 'all 0.5s',
|
||||
['&:hover']: {
|
||||
backgroundColor: colors['gray-lightest'],
|
||||
transition: 'all 0.2s ease-in-out'
|
||||
}
|
||||
}
|
||||
if (plain) {
|
||||
obj['backgroundColor'] = 'transparent';
|
||||
obj['border'] = '1px solid transparent'
|
||||
obj['backgroundColor'] = 'transparent'
|
||||
obj['&:hover'] = {
|
||||
borderColor: 'transparent',
|
||||
backgroundColor: colors['gray-light']
|
||||
backgroundColor: colors['gray-light'],
|
||||
transition: 'all 0.2s ease-in-out'
|
||||
}
|
||||
obj['&:focus'] = {
|
||||
borderColor: 'transparent'
|
||||
|
|
@ -50,6 +85,7 @@ export default function({ right = false, plain = false, options, isSearchable =
|
|||
},
|
||||
indicatorsContainer: (provided: any) => ({
|
||||
...provided,
|
||||
maxHeight: '34px',
|
||||
padding: 0,
|
||||
}),
|
||||
valueContainer: (provided: any) => ({
|
||||
|
|
@ -61,9 +97,15 @@ export default function({ right = false, plain = false, options, isSearchable =
|
|||
const transition = 'opacity 300ms';
|
||||
|
||||
return { ...provided, opacity, transition };
|
||||
}
|
||||
},
|
||||
noOptionsMessage: (provided: any) => ({
|
||||
...provided,
|
||||
whiteSpace: 'nowrap !important',
|
||||
// minWidth: 'fit-content',
|
||||
}),
|
||||
}
|
||||
const defaultSelected = defaultValue ? options.find(x => x.value === defaultValue) : null;
|
||||
|
||||
|
||||
return (
|
||||
<Select
|
||||
options={options}
|
||||
|
|
@ -72,9 +114,11 @@ export default function({ right = false, plain = false, options, isSearchable =
|
|||
components={{
|
||||
IndicatorSeparator: () => null,
|
||||
DropdownIndicator,
|
||||
ValueContainer: CustomValueContainer,
|
||||
...components,
|
||||
}}
|
||||
styles={customStyles}
|
||||
onChange={(value) => onChange({ name, value: value })}
|
||||
styles={{ ...customStyles, ...styles }}
|
||||
theme={(theme) => ({
|
||||
...theme,
|
||||
colors: {
|
||||
|
|
@ -83,6 +127,7 @@ export default function({ right = false, plain = false, options, isSearchable =
|
|||
}
|
||||
})}
|
||||
blurInputOnSelect={true}
|
||||
// menuPosition="fixed"
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
|
|
@ -96,4 +141,22 @@ const DropdownIndicator = (
|
|||
<Icon name="chevron-down" size="16" />
|
||||
</components.DropdownIndicator>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
const CustomValueContainer = ({ children, ...rest }: any) => {
|
||||
const selectedCount = rest.getValue().length
|
||||
const conditional = (selectedCount < 3)
|
||||
|
||||
let firstChild: any = []
|
||||
|
||||
if (!conditional) {
|
||||
firstChild = [children[0].shift(), children[1]]
|
||||
}
|
||||
|
||||
return (
|
||||
<ValueContainer {...rest}>
|
||||
{conditional ? children : firstChild}
|
||||
{!conditional && ` and ${selectedCount - 1} others`}
|
||||
</ValueContainer>
|
||||
)
|
||||
}
|
||||
|
|
@ -37,7 +37,7 @@ function SelectDateRange(props: Props) {
|
|||
plain
|
||||
value={selectedValue}
|
||||
options={options}
|
||||
onChange={({ value }: any) => onChange(value)}
|
||||
onChange={({ value }: any) => onChange(value.value)}
|
||||
components={{ SingleValue: ({ children, ...props} : any) => {
|
||||
return (
|
||||
<components.SingleValue {...props}>
|
||||
|
|
@ -46,6 +46,7 @@ function SelectDateRange(props: Props) {
|
|||
)
|
||||
} }}
|
||||
period={period}
|
||||
right={true}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
{
|
||||
|
|
|
|||
|
|
@ -58,9 +58,6 @@ const ProjectCodeSnippet = props => {
|
|||
|
||||
const onChangeSelect = ({ name, value }) => {
|
||||
const { gdpr } = site;
|
||||
// const _gdpr = { ...gdpr.toData() };
|
||||
// props.editGDPR({ [ name ]: value });
|
||||
// _gdpr[name] = value;
|
||||
props.editGDPR({ [ name ]: value });
|
||||
saveGDPR({ ...gdpr, [ name ]: value });
|
||||
};
|
||||
|
|
@ -107,7 +104,7 @@ const ProjectCodeSnippet = props => {
|
|||
<Select
|
||||
name="defaultInputMode"
|
||||
options={ inputModeOptions }
|
||||
onChange={ ({ value }) => onChangeSelect({ name: 'defaultInputMode', value }) }
|
||||
onChange={ ({ value }) => onChangeSelect({ name: 'defaultInputMode', value: value.value }) }
|
||||
placeholder="Default Input Mode"
|
||||
value={ inputModeOptions.find(o => o.value === gdpr.defaultInputMode) }
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
import React from 'react';
|
||||
import cn from 'classnames';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
className?: string;
|
||||
}
|
||||
function PageTitle({ title, actionButton = null, subTitle = '', className = '', subTitleClass }) {
|
||||
return (
|
||||
<div>
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ const SVG = (props: Props) => {
|
|||
case 'arrow-alt-square-right': return <svg viewBox="0 0 448 512" width={ `${ width }px` } height={ `${ height }px` } ><path d="M0 80v352c0 26.5 21.5 48 48 48h352c26.5 0 48-21.5 48-48V80c0-26.5-21.5-48-48-48H48C21.5 32 0 53.5 0 80zm400-16c8.8 0 16 7.2 16 16v352c0 8.8-7.2 16-16 16H48c-8.8 0-16-7.2-16-16V80c0-8.8 7.2-16 16-16h352zm-208 64v64H88c-13.2 0-24 10.8-24 24v80c0 13.2 10.8 24 24 24h104v64c0 28.4 34.5 42.8 54.6 22.6l128-128c12.5-12.5 12.5-32.8 0-45.3l-128-128c-20.1-20-54.6-5.8-54.6 22.7zm160 128L224 384v-96H96v-64h128v-96l128 128z"/></svg>;
|
||||
case 'arrow-clockwise': return <svg class="bi bi-arrow-clockwise" viewBox="0 0 16 16" width={ `${ width }px` } height={ `${ height }px` } ><path d="M8 3a5 5 0 1 0 4.546 2.914.5.5 0 0 1 .908-.417A6 6 0 1 1 8 2v1z"/><path d="M8 4.466V.534a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384L8.41 4.658A.25.25 0 0 1 8 4.466z"/></svg>;
|
||||
case 'arrow-down': return <svg viewBox="0 0 448 512" width={ `${ width }px` } height={ `${ height }px` } ><path d="m443.5 248.5-7.1-7.1c-4.7-4.7-12.3-4.7-17 0L241 419.9V44c0-6.6-5.4-12-12-12h-10c-6.6 0-12 5.4-12 12v375.9L28.5 241.4c-4.7-4.7-12.3-4.7-17 0l-7.1 7.1c-4.7 4.7-4.7 12.3 0 17l211 211.1c4.7 4.7 12.3 4.7 17 0l211-211.1c4.8-4.8 4.8-12.3.1-17z"/></svg>;
|
||||
case 'arrow-right-short': return <svg class="bi bi-arrow-right-short" viewBox="0 0 16 16" width={ `${ width }px` } height={ `${ height }px` } ><path d="M4 8a.5.5 0 0 1 .5-.5h5.793L8.146 5.354a.5.5 0 1 1 .708-.708l3 3a.5.5 0 0 1 0 .708l-3 3a.5.5 0 0 1-.708-.708L10.293 8.5H4.5A.5.5 0 0 1 4 8z"/></svg>;
|
||||
case 'arrow-square-left': return <svg class="bi bi-arrow-left-square" viewBox="0 0 16 16" width={ `${ width }px` } height={ `${ height }px` } ><path d="M15 2a1 1 0 0 0-1-1H2a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V2zM0 2a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V2zm11.5 5.5a.5.5 0 0 1 0 1H5.707l2.147 2.146a.5.5 0 0 1-.708.708l-3-3a.5.5 0 0 1 0-.708l3-3a.5.5 0 1 1 .708.708L5.707 7.5H11.5z"/></svg>;
|
||||
case 'arrow-square-right': return <svg class="bi bi-arrow-right-square" viewBox="0 0 16 16" width={ `${ width }px` } height={ `${ height }px` } ><path d="M15 2a1 1 0 0 0-1-1H2a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V2zM0 2a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V2zm4.5 5.5a.5.5 0 0 0 0 1h5.793l-2.147 2.146a.5.5 0 0 0 .708.708l3-3a.5.5 0 0 0 0-.708l-3-3a.5.5 0 1 0-.708.708L10.293 7.5H4.5z"/></svg>;
|
||||
case 'arrow-up': return <svg viewBox="0 0 448 512" width={ `${ width }px` } height={ `${ height }px` } ><path d="m4.465 263.536 7.07 7.071c4.686 4.686 12.284 4.686 16.971 0L207 92.113V468c0 6.627 5.373 12 12 12h10c6.627 0 12-5.373 12-12V92.113l178.494 178.493c4.686 4.686 12.284 4.686 16.971 0l7.07-7.071c4.686-4.686 4.686-12.284 0-16.97l-211.05-211.05c-4.686-4.686-12.284-4.686-16.971 0L4.465 246.566c-4.687 4.686-4.687 12.284 0 16.97z"/></svg>;
|
||||
|
|
@ -145,6 +146,7 @@ const SVG = (props: Props) => {
|
|||
case 'expand-wide': return <svg class="bi bi-aspect-ratio" viewBox="0 0 16 16" width={ `${ width }px` } height={ `${ height }px` } ><path d="M0 3.5A1.5 1.5 0 0 1 1.5 2h13A1.5 1.5 0 0 1 16 3.5v9a1.5 1.5 0 0 1-1.5 1.5h-13A1.5 1.5 0 0 1 0 12.5v-9zM1.5 3a.5.5 0 0 0-.5.5v9a.5.5 0 0 0 .5.5h13a.5.5 0 0 0 .5-.5v-9a.5.5 0 0 0-.5-.5h-13z"/><path d="M2 4.5a.5.5 0 0 1 .5-.5h3a.5.5 0 0 1 0 1H3v2.5a.5.5 0 0 1-1 0v-3zm12 7a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1 0-1H13V8.5a.5.5 0 0 1 1 0v3z"/></svg>;
|
||||
case 'explosion': return <svg class="bi bi-file-break" viewBox="0 0 16 16" width={ `${ width }px` } height={ `${ height }px` } ><path d="M0 10.5a.5.5 0 0 1 .5-.5h15a.5.5 0 0 1 0 1H.5a.5.5 0 0 1-.5-.5zM12 0H4a2 2 0 0 0-2 2v7h1V2a1 1 0 0 1 1-1h8a1 1 0 0 1 1 1v7h1V2a2 2 0 0 0-2-2zm2 12h-1v2a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1v-2H2v2a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2v-2z"/></svg>;
|
||||
case 'external-link-alt': return <svg class="bi bi-box-arrow-up-right" viewBox="0 0 16 16" width={ `${ width }px` } height={ `${ height }px` } ><path d="M8.636 3.5a.5.5 0 0 0-.5-.5H1.5A1.5 1.5 0 0 0 0 4.5v10A1.5 1.5 0 0 0 1.5 16h10a1.5 1.5 0 0 0 1.5-1.5V7.864a.5.5 0 0 0-1 0V14.5a.5.5 0 0 1-.5.5h-10a.5.5 0 0 1-.5-.5v-10a.5.5 0 0 1 .5-.5h6.636a.5.5 0 0 0 .5-.5z"/><path d="M16 .5a.5.5 0 0 0-.5-.5h-5a.5.5 0 0 0 0 1h3.793L6.146 9.146a.5.5 0 1 0 .708.708L15 1.707V5.5a.5.5 0 0 0 1 0v-5z"/></svg>;
|
||||
case 'eye-slash-fill': return <svg class="bi bi-eye-slash-fill" viewBox="0 0 16 16" width={ `${ width }px` } height={ `${ height }px` } ><path d="m10.79 12.912-1.614-1.615a3.5 3.5 0 0 1-4.474-4.474l-2.06-2.06C.938 6.278 0 8 0 8s3 5.5 8 5.5a7.029 7.029 0 0 0 2.79-.588zM5.21 3.088A7.028 7.028 0 0 1 8 2.5c5 0 8 5.5 8 5.5s-.939 1.721-2.641 3.238l-2.062-2.062a3.5 3.5 0 0 0-4.474-4.474L5.21 3.089z"/><path d="M5.525 7.646a2.5 2.5 0 0 0 2.829 2.829l-2.83-2.829zm4.95.708-2.829-2.83a2.5 2.5 0 0 1 2.829 2.829zm3.171 6-12-12 .708-.708 12 12-.708.708z"/></svg>;
|
||||
case 'eye-slash': return <svg viewBox="0 0 576 512" width={ `${ width }px` } height={ `${ height }px` } ><path d="m321.496 406.121 21.718 29.272A331.764 331.764 0 0 1 288 440C168.14 440 63.003 376.006 6.646 280.369a47.999 47.999 0 0 1 0-48.739c25.834-43.84 61.913-81.01 104.943-108.427l19.125 25.777c-39.83 24.942-73.004 59.027-96.499 98.896a16.008 16.008 0 0 0 0 16.246C86.163 352.277 182.135 408 288 408c11.298 0 22.476-.64 33.496-1.879zM141.972 164.155C133.037 183.57 128 205.19 128 228c0 85.822 71.23 156 160 156 5.566 0 11.063-.277 16.479-.815l-23.266-31.359C213.676 348.408 160 294.277 160 228a120.48 120.48 0 0 1 4.664-33.26l-22.692-30.585zM569.354 280.37c-33.709 57.202-84.861 103.039-146.143 130.673l56.931 76.732c4 5.391 2.872 13.004-2.519 17.004l-6.507 4.828c-5.391 4-13.004 2.872-17.003-2.519L95.859 24.225c-4-5.391-2.872-13.004 2.519-17.004l6.507-4.828c5.391-4 13.004-2.872 17.003 2.519l62.189 83.82C216.741 77.883 251.696 72 288 72c119.86 0 224.996 63.994 281.354 159.63a48.005 48.005 0 0 1 0 48.74zM416 228c0-68.483-57.308-124-128-124-28.059 0-54.002 8.754-75.095 23.588l34.709 46.782c20.339-16.584 48.244-18.755 70.523-6.84h-.02c-14.554 0-26.353 11.799-26.353 26.353s11.799 26.353 26.353 26.353c14.554 0 26.353-11.799 26.353-26.353v-.02c15.223 28.465 6.889 64.554-20.679 83.18l38.514 51.911C394.803 306.465 416 269.638 416 228zm125.785 19.877c-29.072-49.333-73.341-90.435-127.66-115.887 55.405 69.029 41.701 170.413-32.734 222.688l22.238 29.973c57.564-24.305 106.246-66.38 138.155-120.527a16.008 16.008 0 0 0 .001-16.247z"/></svg>;
|
||||
case 'eye': return <svg viewBox="0 0 576 512" width={ `${ width }px` } height={ `${ height }px` } ><path d="M569.354 231.631C512.969 135.948 407.808 72 288 72 168.14 72 63.004 135.994 6.646 231.63a47.999 47.999 0 0 0 0 48.739C63.032 376.053 168.192 440 288 440c119.86 0 224.996-63.994 281.354-159.631a48.002 48.002 0 0 0 0-48.738zM416 228c0 68.483-57.308 124-128 124s-128-55.517-128-124 57.308-124 128-124 128 55.517 128 124zm125.784 36.123C489.837 352.277 393.865 408 288 408c-106.291 0-202.061-56.105-253.784-143.876a16.006 16.006 0 0 1 0-16.247c29.072-49.333 73.341-90.435 127.66-115.887C140.845 158.191 128 191.568 128 228c0 85.818 71.221 156 160 156 88.77 0 160-70.178 160-156 0-36.411-12.833-69.794-33.875-96.01 53.76 25.189 98.274 66.021 127.66 115.887a16.006 16.006 0 0 1-.001 16.246zM224 224c0-10.897 2.727-21.156 7.53-30.137v.02c0 14.554 11.799 26.353 26.353 26.353 14.554 0 26.353-11.799 26.353-26.353s-11.799-26.353-26.353-26.353h-.02c8.981-4.803 19.24-7.53 30.137-7.53 35.346 0 64 28.654 64 64s-28.654 64-64 64-64-28.654-64-64z"/></svg>;
|
||||
case 'fetch': return <svg class="bi bi-arrow-left-right" viewBox="0 0 16 16" width={ `${ width }px` } height={ `${ height }px` } ><path d="M1 11.5a.5.5 0 0 0 .5.5h11.793l-3.147 3.146a.5.5 0 0 0 .708.708l4-4a.5.5 0 0 0 0-.708l-4-4a.5.5 0 0 0-.708.708L13.293 11H1.5a.5.5 0 0 0-.5.5zm14-7a.5.5 0 0 1-.5.5H2.707l3.147 3.146a.5.5 0 1 1-.708.708l-4-4a.5.5 0 0 1 0-.708l4-4a.5.5 0 1 1 .708.708L2.707 4H14.5a.5.5 0 0 1 .5.5z"/></svg>;
|
||||
|
|
@ -211,6 +213,7 @@ const SVG = (props: Props) => {
|
|||
case 'funnel/mouse': return <svg class="bi bi-mouse" viewBox="0 0 16 16" width={ `${ width }px` } height={ `${ height }px` } ><path d="M8 3a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-1 0v-2A.5.5 0 0 1 8 3zm4 8a4 4 0 0 1-8 0V5a4 4 0 1 1 8 0v6zM8 0a5 5 0 0 0-5 5v6a5 5 0 0 0 10 0V5a5 5 0 0 0-5-5z"/></svg>;
|
||||
case 'funnel/patch-exclamation-fill': return <svg class="bi bi-patch-exclamation-fill" viewBox="0 0 16 16" width={ `${ width }px` } height={ `${ height }px` } ><path d="M10.067.87a2.89 2.89 0 0 0-4.134 0l-.622.638-.89-.011a2.89 2.89 0 0 0-2.924 2.924l.01.89-.636.622a2.89 2.89 0 0 0 0 4.134l.637.622-.011.89a2.89 2.89 0 0 0 2.924 2.924l.89-.01.622.636a2.89 2.89 0 0 0 4.134 0l.622-.637.89.011a2.89 2.89 0 0 0 2.924-2.924l-.01-.89.636-.622a2.89 2.89 0 0 0 0-4.134l-.637-.622.011-.89a2.89 2.89 0 0 0-2.924-2.924l-.89.01-.622-.636zM8 4c.535 0 .954.462.9.995l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 4.995A.905.905 0 0 1 8 4zm.002 6a1 1 0 1 1 0 2 1 1 0 0 1 0-2z"/></svg>;
|
||||
case 'funnel/sd-card': return <svg class="bi bi-sd-card" viewBox="0 0 16 16" width={ `${ width }px` } height={ `${ height }px` } ><path d="M6.25 3.5a.75.75 0 0 0-1.5 0v2a.75.75 0 0 0 1.5 0v-2zm2 0a.75.75 0 0 0-1.5 0v2a.75.75 0 0 0 1.5 0v-2zm2 0a.75.75 0 0 0-1.5 0v2a.75.75 0 0 0 1.5 0v-2zm2 0a.75.75 0 0 0-1.5 0v2a.75.75 0 0 0 1.5 0v-2z"/><path d="M5.914 0H12.5A1.5 1.5 0 0 1 14 1.5v13a1.5 1.5 0 0 1-1.5 1.5h-9A1.5 1.5 0 0 1 2 14.5V3.914c0-.398.158-.78.44-1.06L4.853.439A1.5 1.5 0 0 1 5.914 0zM13 1.5a.5.5 0 0 0-.5-.5H5.914a.5.5 0 0 0-.353.146L3.146 3.561A.5.5 0 0 0 3 3.914V14.5a.5.5 0 0 0 .5.5h9a.5.5 0 0 0 .5-.5v-13z"/></svg>;
|
||||
case 'funnel-fill': return <svg class="bi bi-funnel-fill" width={ `${ width }px` } height={ `${ height }px` } ><path d="M1.5 1.5A.5.5 0 0 1 2 1h12a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-.128.334L10 8.692V13.5a.5.5 0 0 1-.342.474l-3 1A.5.5 0 0 1 6 14.5V8.692L1.628 3.834A.5.5 0 0 1 1.5 3.5v-2z"/></svg>;
|
||||
case 'funnel': return <svg class="bi bi-funnel" viewBox="0 0 16 16" width={ `${ width }px` } height={ `${ height }px` } ><path d="M1.5 1.5A.5.5 0 0 1 2 1h12a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-.128.334L10 8.692V13.5a.5.5 0 0 1-.342.474l-3 1A.5.5 0 0 1 6 14.5V8.692L1.628 3.834A.5.5 0 0 1 1.5 3.5v-2zm1 .5v1.308l4.372 4.858A.5.5 0 0 1 7 8.5v5.306l2-.666V8.5a.5.5 0 0 1 .128-.334L13.5 3.308V2h-11z"/></svg>;
|
||||
case 'geo-alt-fill-custom': return <svg viewBox="0 0 12 16" width={ `${ width }px` } height={ `${ height }px` } ><path d="M6 16s6-5.686 6-10A6 6 0 1 0 0 6c0 4.314 6 10 6 10Z"/></svg>;
|
||||
case 'github': return <svg class="bi bi-github" viewBox="0 0 16 16" width={ `${ width }px` } height={ `${ height }px` } ><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.012 8.012 0 0 0 16 8c0-4.42-3.58-8-8-8z"/></svg>;
|
||||
|
|
|
|||
|
|
@ -62,6 +62,9 @@ export const customOperators = [
|
|||
export const metricTypes = [
|
||||
{ text: 'Timeseries', label: 'Timeseries', value: 'timeseries' },
|
||||
{ text: 'Table', label: 'Table', value: 'table' },
|
||||
{ label: 'Funnel', value: 'funnel' },
|
||||
// { label: 'Errors', value: 'errors' },
|
||||
// { label: 'Sessions', value: 'sessions' },
|
||||
];
|
||||
|
||||
export const tableColumnName = {
|
||||
|
|
@ -76,6 +79,7 @@ export const tableColumnName = {
|
|||
export const metricOf = [
|
||||
{ text: 'Session Count', label: 'Session Count', value: 'sessionCount', type: 'timeseries' },
|
||||
{ text: 'Users', label: 'Users', value: FilterKey.USERID, type: 'table' },
|
||||
{ text: 'Sessions', label: 'Sessions', value: FilterKey.SESSIONS, type: 'table' },
|
||||
{ text: 'Issues', label: 'Issues', value: FilterKey.ISSUE, type: 'table' },
|
||||
{ text: 'Browsers', label: 'Browsers', value: FilterKey.USER_BROWSER, type: 'table' },
|
||||
{ text: 'Devices', label: 'Devices', value: FilterKey.USER_DEVICE, type: 'table' },
|
||||
|
|
|
|||
|
|
@ -7,8 +7,9 @@ export const CUSTOM_RANGE = 'CUSTOM_RANGE';
|
|||
|
||||
const DATE_RANGE_LABELS = {
|
||||
// LAST_30_MINUTES: '30 Minutes',
|
||||
TODAY: 'Today',
|
||||
YESTERDAY: 'Yesterday',
|
||||
// TODAY: 'Today',
|
||||
LAST_24_HOURS: 'Last 24 Hours',
|
||||
// YESTERDAY: 'Yesterday',
|
||||
LAST_7_DAYS: 'Past 7 Days',
|
||||
LAST_30_DAYS: 'Past 30 Days',
|
||||
//THIS_MONTH: 'This Month',
|
||||
|
|
@ -58,6 +59,11 @@ export function getDateRangeFromValue(value) {
|
|||
moment().subtract(1, 'days').startOf('day'),
|
||||
moment().subtract(1, 'days').endOf('day'),
|
||||
);
|
||||
case DATE_RANGE_VALUES.LAST_24_HOURS:
|
||||
return moment.range(
|
||||
moment().subtract(24, 'hours'),
|
||||
moment(),
|
||||
);
|
||||
case DATE_RANGE_VALUES.LAST_7_DAYS:
|
||||
return moment.range(
|
||||
moment().subtract(7, 'days').startOf('day'),
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { toast } from 'react-toastify';
|
|||
import Period, { LAST_24_HOURS, LAST_7_DAYS } from 'Types/app/period';
|
||||
import { getChartFormatter } from 'Types/dashboard/helper';
|
||||
import Filter, { IFilter } from "./types/filter";
|
||||
import Funnel from "./types/funnel";
|
||||
|
||||
export interface IDashboardSotre {
|
||||
dashboards: IDashboard[]
|
||||
|
|
@ -78,7 +79,7 @@ export default class DashboardStore implements IDashboardSotre {
|
|||
currentWidget: Widget = new Widget()
|
||||
widgetCategories: any[] = []
|
||||
widgets: Widget[] = []
|
||||
period: Period = Period({ rangeName: LAST_7_DAYS })
|
||||
period: Period = Period({ rangeName: LAST_24_HOURS })
|
||||
drillDownFilter: Filter = new Filter()
|
||||
startTimestamp: number = 0
|
||||
endTimestamp: number = 0
|
||||
|
|
@ -130,7 +131,7 @@ export default class DashboardStore implements IDashboardSotre {
|
|||
fetchMetricChartData: action
|
||||
})
|
||||
|
||||
const drillDownPeriod = Period({ rangeName: LAST_7_DAYS }).toTimestamps();
|
||||
const drillDownPeriod = Period({ rangeName: LAST_24_HOURS }).toTimestamps();
|
||||
this.drillDownFilter.updateKey('startTimestamp', drillDownPeriod.startTimestamp)
|
||||
this.drillDownFilter.updateKey('endTimestamp', drillDownPeriod.endTimestamp)
|
||||
}
|
||||
|
|
@ -427,7 +428,7 @@ export default class DashboardStore implements IDashboardSotre {
|
|||
}
|
||||
|
||||
setPeriod(period: any) {
|
||||
this.period = Period({ start: period.startDate, end: period.endDate, rangeName: period.rangeValue })
|
||||
this.period = new Period({ start: period.startDate, end: period.endDate, rangeName: period.rangeName })
|
||||
}
|
||||
|
||||
fetchMetricChartData(metric: IWidget, data: any, isWidget: boolean = false): Promise<any> {
|
||||
|
|
@ -440,6 +441,11 @@ export default class DashboardStore implements IDashboardSotre {
|
|||
const _data = { ...data, chart: getChartFormatter(this.period)(data.chart) }
|
||||
metric.setData(_data)
|
||||
resolve(_data);
|
||||
} else if (metric.metricType === 'funnel') {
|
||||
const _data = { ...data }
|
||||
_data.funnel = new Funnel().fromJSON(data)
|
||||
metric.setData(_data)
|
||||
resolve(_data);
|
||||
} else {
|
||||
const _data = {
|
||||
...data,
|
||||
|
|
|
|||
55
frontend/app/mstore/errorStore.ts
Normal file
55
frontend/app/mstore/errorStore.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { makeAutoObservable, runInAction, observable, action, reaction } from "mobx"
|
||||
import { errorService } from "App/services"
|
||||
import Error from "./types/error"
|
||||
|
||||
export default class ErrorStore {
|
||||
isLoading: boolean = false
|
||||
isSaving: boolean = false
|
||||
|
||||
errors: any[] = []
|
||||
instance: Error | null = null
|
||||
|
||||
constructor() {
|
||||
makeAutoObservable(this, {
|
||||
|
||||
})
|
||||
}
|
||||
|
||||
updateKey(key: string, value: any) {
|
||||
this[key] = value
|
||||
}
|
||||
|
||||
fetchErrors(): Promise<any> {
|
||||
this.isLoading = true
|
||||
return new Promise((resolve, reject) => {
|
||||
errorService.all()
|
||||
.then(response => {
|
||||
const errors = response.map(e => new Error().fromJSON(e));
|
||||
this.errors = errors
|
||||
resolve(errors)
|
||||
}).catch(error => {
|
||||
reject(error)
|
||||
}).finally(() => {
|
||||
this.isLoading = false
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fetchError(errorId: string): Promise<any> {
|
||||
this.isLoading = true
|
||||
return new Promise((resolve, reject) => {
|
||||
errorService.one(errorId)
|
||||
.then(response => {
|
||||
const error = new Error().fromJSON(response);
|
||||
this.instance = error
|
||||
resolve(error)
|
||||
}).catch(error => {
|
||||
reject(error)
|
||||
}).finally(() => {
|
||||
this.isLoading = false
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
150
frontend/app/mstore/funnelStore.ts
Normal file
150
frontend/app/mstore/funnelStore.ts
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
import { makeAutoObservable, runInAction, observable, action, reaction } from "mobx"
|
||||
import { funnelService } from "App/services"
|
||||
import Funnel, { IFunnel } from "./types/funnel";
|
||||
import Session from './types/session';
|
||||
import FunnelIssue from './types/funnelIssue';
|
||||
import Period, { LAST_7_DAYS } from 'Types/app/period';
|
||||
|
||||
export default class FunnelStore {
|
||||
isLoading: boolean = false
|
||||
isSaving: boolean = false
|
||||
list: IFunnel[] = []
|
||||
instance: IFunnel | null = null
|
||||
period: Period = Period({ rangeName: LAST_7_DAYS })
|
||||
search: string = ''
|
||||
|
||||
page: number = 1
|
||||
pageSize: number = 10
|
||||
|
||||
issues: any[] = []
|
||||
isLoadingIssues: boolean = false
|
||||
issuesFilter: any = []
|
||||
|
||||
issueInstance: FunnelIssue | null = null
|
||||
issuesSort = {
|
||||
sort: "afectedUsers",
|
||||
order: 'desc',
|
||||
}
|
||||
|
||||
constructor() {
|
||||
makeAutoObservable(this, {
|
||||
updateKey: action,
|
||||
fetchFunnels: action,
|
||||
fetchFunnel: action,
|
||||
saveFunnel: action,
|
||||
deleteFunnel: action
|
||||
})
|
||||
this.issues = sampleIssues.map(i => new FunnelIssue().fromJSON(i));
|
||||
}
|
||||
|
||||
updateKey(key: string, value: any) {
|
||||
this[key] = value
|
||||
}
|
||||
|
||||
fetchFunnels(): Promise<any> {
|
||||
this.isLoading = true
|
||||
return new Promise((resolve, reject) => {
|
||||
funnelService.all()
|
||||
.then(response => {
|
||||
this.list = response
|
||||
resolve(response)
|
||||
}).catch(error => {
|
||||
reject(error)
|
||||
}).finally(() => {
|
||||
this.isLoading = false
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fetchFunnel(funnelId: string): Promise<any> {
|
||||
this.isLoading = true
|
||||
return new Promise((resolve, reject) => {
|
||||
funnelService.one(funnelId)
|
||||
.then(response => {
|
||||
const _funnel = new Funnel().fromJSON(response)
|
||||
this.instance = _funnel
|
||||
resolve(_funnel)
|
||||
}).catch(error => {
|
||||
reject(error)
|
||||
}).finally(() => {
|
||||
this.isLoading = false
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
saveFunnel(funnel: IFunnel): Promise<any> {
|
||||
this.isSaving = true
|
||||
const wasCreating = !funnel.funnelId
|
||||
return new Promise((resolve, reject) => {
|
||||
funnelService.save(funnel)
|
||||
.then(response => {
|
||||
const _funnel = new Funnel().fromJSON(response)
|
||||
if (wasCreating) {
|
||||
this.list.push(_funnel)
|
||||
}
|
||||
resolve(_funnel)
|
||||
}).catch(error => {
|
||||
reject(error)
|
||||
}).finally(() => {
|
||||
this.isSaving = false
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
deleteFunnel(funnelId: string): Promise<any> {
|
||||
this.isSaving = true
|
||||
return new Promise((resolve, reject) => {
|
||||
funnelService.delete(funnelId)
|
||||
.then(response => {
|
||||
this.list = this.list.filter(funnel => funnel.funnelId !== funnelId)
|
||||
resolve(funnelId)
|
||||
}).catch(error => {
|
||||
reject(error)
|
||||
}).finally(() => {
|
||||
this.isSaving = false
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fetchIssues(funnelId?: string): Promise<any> {
|
||||
this.isLoadingIssues = true
|
||||
return new Promise((resolve, reject) => {
|
||||
funnelService.fetchIssues(funnelId, this.period.toTimestamps())
|
||||
.then(response => {
|
||||
this.issues = response.map(i => new FunnelIssue().fromJSON(i))
|
||||
resolve(this.issues)
|
||||
}).catch(error => {
|
||||
reject(error)
|
||||
}
|
||||
).finally(() => {
|
||||
this.isLoadingIssues = false
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fetchIssue(funnelId: string, issueId: string): Promise<any> {
|
||||
this.isLoadingIssues = true
|
||||
return new Promise((resolve, reject) => {
|
||||
// funnelService.fetchIssue(funnelId, issueId)
|
||||
funnelService.fetchIssue('143', '91515f9118ed803291f87133e2cb49a16ea')
|
||||
.then(response => {
|
||||
this.issueInstance = new FunnelIssue().fromJSON(response.issue)
|
||||
this.issueInstance.sessions = response.sessions.sessions.map(i => new Session().fromJson(i))
|
||||
console.log('response.sessions', response.sessions);
|
||||
resolve(this.issueInstance)
|
||||
}).catch(error => {
|
||||
reject(error)
|
||||
}
|
||||
).finally(() => {
|
||||
this.isLoadingIssues = false
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const sampleIssues = [{"type":"missing_resource","title":"Missing Image","affectedSessions":1,"unaffectedSessions":59,"lostConversions":0,"affectedUsers":1,"conversionImpact":11,"contextString":"https://openreplay.com/images/assist-story-4.svg","issueId":"9157d556854f17cc25df3510bf7a980fd4d"},{"type":"click_rage","title":"Click Rage","affectedSessions":1,"unaffectedSessions":59,"lostConversions":0,"affectedUsers":1,"conversionImpact":11,"contextString":"Back More Info 0:40 78:02 Pause Back Skip to Issue 1x Skip Inactivity Network Fetch 6 Redux 2 Consol","issueId":"91502c2c13f69c09a68503c61b0fd4461ca"},{"type":"missing_resource","title":"Missing Image","affectedSessions":1,"unaffectedSessions":59,"lostConversions":0,"affectedUsers":1,"conversionImpact":11,"contextString":"https://openreplay.com/images/icn-slack.svg","issueId":"915b1b3a25c5f1127ec762235be7f896c3a"},{"type":"dead_click","title":"Dead Click","affectedSessions":1,"unaffectedSessions":59,"lostConversions":0,"affectedUsers":1,"conversionImpact":11,"contextString":"py-1 px-2 bg-white border border-gray-light rounded w-16","issueId":"9159e30220bb6a6a31afcaa1979a0c7d69c"},{"type":"dead_click","title":"Dead Click","affectedSessions":4,"unaffectedSessions":56,"lostConversions":2,"affectedUsers":2,"conversionImpact":61,"contextString":"OpenReplay App New Project SESSIONS ASSIST ERRORS DASHBOARDS Billing Details Announcements There are","issueId":"91515f9118ed803291f87133e2cb49a16ea"},{"type":"dead_click","title":"Dead Click","affectedSessions":2,"unaffectedSessions":58,"lostConversions":0,"affectedUsers":1,"conversionImpact":20,"contextString":"Type to search","issueId":"915832d68d21f03f83af1bfc758a1dda50b"},{"type":"missing_resource","title":"Missing Image","affectedSessions":1,"unaffectedSessions":59,"lostConversions":0,"affectedUsers":1,"conversionImpact":11,"contextString":"https://openreplay.com/images/icn-linkedin.svg","issueId":"91506bb929c2cb3679f8b01c228d8a0b5c8"},{"type":"dead_click","title":"Dead Click","affectedSessions":3,"unaffectedSessions":57,"lostConversions":0,"affectedUsers":2,"conversionImpact":31,"contextString":"Search sessions using any captured event (click, input, page, error...)","issueId":"9157be39a537e81243a2ff44ad74867941f"},{"type":"cpu","title":"High CPU","affectedSessions":1,"unaffectedSessions":59,"lostConversions":0,"affectedUsers":1,"conversionImpact":11,"contextString":"","issueId":"915a68d6bb4448b5822836dbc797bafadf9"},{"type":"dead_click","title":"Dead Click","affectedSessions":1,"unaffectedSessions":59,"lostConversions":0,"affectedUsers":1,"conversionImpact":11,"contextString":"Back More Info Report Issue This session's issues 10:35 83:19 Play Back Skip to Issue 4x Skip Inacti","issueId":"915b43e81f8da042f70ea47bd9ad14a3bb8"},{"type":"missing_resource","title":"Missing Image","affectedSessions":1,"unaffectedSessions":59,"lostConversions":0,"affectedUsers":1,"conversionImpact":11,"contextString":"https://openreplay.com/images/icn-git.svg","issueId":"915f7f277daa7a695d5bf9e233c43af7f02"},{"type":"click_rage","title":"Click Rage","affectedSessions":1,"unaffectedSessions":59,"lostConversions":0,"affectedUsers":1,"conversionImpact":11,"contextString":"Back More Info 0:35 78:02 Pause Back Skip to Issue 1x Skip Inactivity Network Fetch 6 Redux 2 Consol","issueId":"915788d9976c9f80c6f599e3e5816f2c7be"},{"type":"missing_resource","title":"Missing Image","affectedSessions":1,"unaffectedSessions":59,"lostConversions":0,"affectedUsers":1,"conversionImpact":11,"contextString":"https://openreplay.com/images/icn-twitter.svg","issueId":"915ab993784a0432c39b4a4e9248dfe6acd"},{"type":"missing_resource","title":"Missing Image","affectedSessions":1,"unaffectedSessions":59,"lostConversions":0,"affectedUsers":1,"conversionImpact":11,"contextString":"https://openreplay.com/images/logo-open-replay.svg","issueId":"915ac8719c95392adb8b79d2d5eae1063b9"}]
|
||||
|
|
@ -4,19 +4,23 @@ import MetricStore, { IMetricStore } from './metricStore';
|
|||
import UserStore from './userStore';
|
||||
import RoleStore from './roleStore';
|
||||
import APIClient from 'App/api_client';
|
||||
import FunnelStore from './funnelStore';
|
||||
import { makeAutoObservable, observable, action } from "mobx"
|
||||
import { dashboardService, metricService, sessionService, userService, auditService } from 'App/services';
|
||||
import { dashboardService, metricService, sessionService, userService, auditService, funnelService, errorService } from 'App/services';
|
||||
import SettingsStore from './settingsStore';
|
||||
import AuditStore from './auditStore';
|
||||
import NotificationStore from './notificationStore';
|
||||
import ErrorStore from './errorStore';
|
||||
|
||||
export class RootStore {
|
||||
dashboardStore: IDashboardSotre;
|
||||
metricStore: IMetricStore;
|
||||
funnelStore: FunnelStore;
|
||||
settingsStore: SettingsStore;
|
||||
userStore: UserStore;
|
||||
roleStore: RoleStore;
|
||||
auditStore: AuditStore;
|
||||
errorStore: ErrorStore;
|
||||
notificationStore: NotificationStore
|
||||
|
||||
limits: any;
|
||||
|
|
@ -24,10 +28,12 @@ export class RootStore {
|
|||
constructor() {
|
||||
this.dashboardStore = new DashboardStore();
|
||||
this.metricStore = new MetricStore();
|
||||
this.funnelStore = new FunnelStore();
|
||||
this.settingsStore = new SettingsStore();
|
||||
this.userStore = new UserStore();
|
||||
this.roleStore = new RoleStore();
|
||||
this.auditStore = new AuditStore();
|
||||
this.errorStore = new ErrorStore();
|
||||
this.notificationStore = new NotificationStore();
|
||||
makeAutoObservable(this, {
|
||||
limits: observable,
|
||||
|
|
@ -39,10 +45,13 @@ export class RootStore {
|
|||
const client = new APIClient();
|
||||
dashboardService.initClient(client)
|
||||
metricService.initClient(client)
|
||||
funnelService.initClient(client)
|
||||
sessionService.initClient(client)
|
||||
userService.initClient(client)
|
||||
auditService.initClient(client)
|
||||
errorService.initClient(client)
|
||||
}
|
||||
|
||||
|
||||
fetchLimits(): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
userService.getLimits()
|
||||
|
|
|
|||
|
|
@ -90,6 +90,9 @@ export default class MetricStore implements IMetricStore {
|
|||
|
||||
// State Actions
|
||||
init(metric?: IWidget|null) {
|
||||
// const _metric = new Widget().fromJson(sampleJsonErrors)
|
||||
// this.instance.update(metric || _metric)
|
||||
|
||||
this.instance.update(metric || new Widget())
|
||||
}
|
||||
|
||||
|
|
@ -137,29 +140,32 @@ export default class MetricStore implements IMetricStore {
|
|||
save(metric: IWidget, dashboardId?: string): Promise<any> {
|
||||
const wasCreating = !metric.exists()
|
||||
this.isSaving = true
|
||||
return metricService.saveMetric(metric, dashboardId)
|
||||
.then((metric) => {
|
||||
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)
|
||||
}
|
||||
return _metric
|
||||
}).catch(() => {
|
||||
toast.error('Error saving metric')
|
||||
}).finally(() => {
|
||||
this.isSaving = false
|
||||
})
|
||||
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.isSaving = false
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fetchList() {
|
||||
this.isLoading = true
|
||||
return metricService.getMetrics()
|
||||
.then(metrics => {
|
||||
.then((metrics: any[]) => {
|
||||
this.metrics = metrics.map(m => new Widget().fromJson(m))
|
||||
}).finally(() => {
|
||||
this.isLoading = false
|
||||
|
|
@ -169,7 +175,7 @@ export default class MetricStore implements IMetricStore {
|
|||
fetch(id: string) {
|
||||
this.isLoading = true
|
||||
return metricService.getMetric(id)
|
||||
.then(metric => {
|
||||
.then((metric: any) => {
|
||||
return this.instance = new Widget().fromJson(metric)
|
||||
}).finally(() => {
|
||||
this.isLoading = false
|
||||
|
|
@ -186,4 +192,43 @@ export default class MetricStore implements IMetricStore {
|
|||
this.isSaving = false
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const sampleJsonFunnel = {
|
||||
// metricId: 1,
|
||||
name: "Funnel Sample",
|
||||
metricType: 'funnel',
|
||||
series: [
|
||||
{
|
||||
name: 'Series 1',
|
||||
filter: {
|
||||
eventsOrder: 'then',
|
||||
filters: [
|
||||
{ type: 'LOCATION', operator: 'is', value: ['/sessions', '/errors', '/users'], percent: 100, completed: 60, dropped: 40, },
|
||||
{ type: 'LOCATION', operator: 'is', value: ['/sessions'], percent: 80, completed: 40, dropped: 60, },
|
||||
{ type: 'CLICK', operator: 'on', value: ['DASHBOARDS'], percent: 80, completed: 10, dropped: 90, }
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
const sampleJsonErrors = {
|
||||
// metricId: 1,
|
||||
name: "Errors Sample",
|
||||
metricType: 'errors',
|
||||
metricFormat: 'sessionCount',
|
||||
series: [
|
||||
{
|
||||
name: 'Series 1',
|
||||
filter: {
|
||||
eventsOrder: 'then',
|
||||
filters: [
|
||||
{ type: 'LOCATION', operator: 'is', value: ['/sessions', '/errors', '/users'], percent: 100, completed: 60, dropped: 40, },
|
||||
{ type: 'LOCATION', operator: 'is', value: ['/sessions'], percent: 80, completed: 40, dropped: 60, },
|
||||
{ type: 'CLICK', operator: 'on', value: ['DASHBOARDS'], percent: 80, completed: 10, dropped: 90, }
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
}
|
||||
42
frontend/app/mstore/types/error.ts
Normal file
42
frontend/app/mstore/types/error.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
|
||||
export default class Error {
|
||||
sessionId: string = ''
|
||||
messageId: string = ''
|
||||
timestamp: string = ''
|
||||
errorId: string = ''
|
||||
projectId: string = ''
|
||||
source: string = ''
|
||||
name: string = ''
|
||||
message: string = ''
|
||||
time: string = ''
|
||||
function: string = '?'
|
||||
stack0InfoString: string = ''
|
||||
|
||||
constructor() {
|
||||
}
|
||||
|
||||
fromJSON(json: any) {
|
||||
this.sessionId = json.sessionId
|
||||
this.messageId = json.messageId
|
||||
this.timestamp = json.timestamp
|
||||
this.errorId = json.errorId
|
||||
this.projectId = json.projectId
|
||||
this.source = json.source
|
||||
this.name = json.name
|
||||
this.message = json.message
|
||||
this.time = json.time
|
||||
this.function = json.function
|
||||
this.stack0InfoString = getStck0InfoString(json.stack || [])
|
||||
return this
|
||||
}
|
||||
}
|
||||
|
||||
function getStck0InfoString(stack) {
|
||||
const stack0 = stack[0];
|
||||
if (!stack0) return "";
|
||||
let s = stack0.function || "";
|
||||
if (stack0.url) {
|
||||
s += ` (${stack0.url})`;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
|
@ -62,7 +62,7 @@ export default class Filter implements IFilter {
|
|||
this.filters[index] = new FilterItem(filter)
|
||||
}
|
||||
|
||||
updateKey(key: string, value) {
|
||||
updateKey(key: string, value: any) {
|
||||
this[key] = value
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,9 @@ export default class FilterItem {
|
|||
filters: FilterItem[] = []
|
||||
operatorOptions: any[] = []
|
||||
options: any[] = []
|
||||
isActive: boolean = true
|
||||
completed: number = 0
|
||||
dropped: number = 0
|
||||
|
||||
constructor(data: any = {}) {
|
||||
makeAutoObservable(this, {
|
||||
|
|
@ -22,13 +25,20 @@ export default class FilterItem {
|
|||
operator: observable,
|
||||
source: observable,
|
||||
filters: observable,
|
||||
isActive: observable,
|
||||
|
||||
merge: action
|
||||
})
|
||||
|
||||
|
||||
|
||||
this.merge(data)
|
||||
}
|
||||
|
||||
updateKey(key: string, value: any) {
|
||||
this[key] = value
|
||||
}
|
||||
|
||||
merge(data) {
|
||||
Object.keys(data).forEach(key => {
|
||||
this[key] = data[key]
|
||||
|
|
@ -56,6 +66,9 @@ export default class FilterItem {
|
|||
this.operator = json.operator
|
||||
|
||||
this.filters = _filter.type === FilterType.SUB_FILTERS && json.filters ? json.filters.map(i => new FilterItem().fromJson(i, json.type)) : []
|
||||
|
||||
this.completed = json.completed
|
||||
this.dropped = json.dropped
|
||||
return this
|
||||
}
|
||||
|
||||
|
|
|
|||
35
frontend/app/mstore/types/funnel.ts
Normal file
35
frontend/app/mstore/types/funnel.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import FunnelStage from './funnelStage'
|
||||
|
||||
export interface IFunnel {
|
||||
affectedUsers: number;
|
||||
conversionImpact: number
|
||||
lostConversions: number
|
||||
isPublic: boolean
|
||||
fromJSON: (json: any) => void
|
||||
toJSON: () => any
|
||||
exists: () => boolean
|
||||
}
|
||||
|
||||
export default class Funnel implements IFunnel {
|
||||
affectedUsers: number = 0
|
||||
conversionImpact: number = 0
|
||||
lostConversions: number = 0
|
||||
isPublic: boolean = false
|
||||
stages: FunnelStage[] = []
|
||||
|
||||
constructor() {
|
||||
}
|
||||
|
||||
fromJSON(json: any) {
|
||||
if (json.stages.length >= 1) {
|
||||
const firstStage = json.stages[0]
|
||||
const lastStage = json.stages[json.stages.length - 1]
|
||||
this.lostConversions = json.totalDropDueToIssues
|
||||
this.conversionImpact = this.lostConversions ? Math.round((this.lostConversions / firstStage.sessionsCount) * 100) : 0;
|
||||
this.stages = json.stages ? json.stages.map((stage: any) => new FunnelStage().fromJSON(stage)) : []
|
||||
this.affectedUsers = firstStage.usersCount ? firstStage.usersCount - lastStage.usersCount : 0;
|
||||
}
|
||||
|
||||
return this
|
||||
}
|
||||
}
|
||||
74
frontend/app/mstore/types/funnelIssue.ts
Normal file
74
frontend/app/mstore/types/funnelIssue.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
export default class FunnelIssue {
|
||||
issueId: string = ''
|
||||
title: string = ''
|
||||
type: string = ''
|
||||
affectedSessions: number = 0
|
||||
affectedUsers: number = 0
|
||||
unaffectedSessions: number = 0
|
||||
contextString: string = ''
|
||||
conversionImpact: number = 0
|
||||
lostConversions: number = 0
|
||||
lostConversionsPer: number = 0
|
||||
affectedSessionsPer: number = 0
|
||||
unaffectedSessionsPer: number = 0
|
||||
icon: any = {}
|
||||
sessions: any[] = []
|
||||
|
||||
constructor() {
|
||||
}
|
||||
|
||||
fromJSON(json: any) {
|
||||
this.issueId = json.issueId
|
||||
this.title = json.title
|
||||
this.type = json.type
|
||||
this.icon = getIconDetails(json.type)
|
||||
this.affectedSessions = json.affectedSessions
|
||||
this.affectedUsers = json.affectedUsers
|
||||
this.unaffectedSessions = json.unaffectedSessions
|
||||
this.contextString = json.contextString
|
||||
this.conversionImpact = json.conversionImpact
|
||||
this.lostConversions = json.lostConversions
|
||||
|
||||
const total = json.lostConversions + json.affectedSessions + json.unaffectedSessions;
|
||||
this.lostConversionsPer = json.lostConversions * 100 / total;
|
||||
this.affectedSessionsPer = json.affectedSessions * 100 / total;
|
||||
this.unaffectedSessionsPer = json.unaffectedSessions * 100 / total;
|
||||
return this
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const getIconDetails = (type) => {
|
||||
switch(type) {
|
||||
case 'click_rage':
|
||||
return { icon: 'funnel/emoji-angry-fill', color: '#CC0000' };
|
||||
case 'dead_click':
|
||||
return { icon: 'funnel/emoji-dizzy-fill', color: '#9C001F' };
|
||||
case 'excessive_scrolling':
|
||||
return { icon: 'funnel/mouse', color: '#D3545F' };
|
||||
case 'bad_request':
|
||||
return { icon: 'funnel/patch-exclamation-fill', color: '#D70072' };
|
||||
case 'missing_resource':
|
||||
return { icon: 'funnel/image-fill', color: '#B89C50' };
|
||||
case 'memory':
|
||||
return { icon: 'funnel/cpu-fill', color: '#8A5A83' };
|
||||
case 'cpu':
|
||||
return { icon: 'funnel/hdd-fill', color: '#8A5A83' };
|
||||
case 'slow_resource':
|
||||
return { icon: 'funnel/hourglass-top', color: '#8B006D' };
|
||||
case 'slow_page_load':
|
||||
return { icon: 'funnel/hourglass-top', color: '#8B006D' };
|
||||
case 'custom_event_error':
|
||||
case 'custom':
|
||||
return { icon: 'funnel/exclamation-circle-fill', color: '#BF6C00' };
|
||||
case 'crash':
|
||||
return { icon: 'funnel/file-x', color: '#BF2D00' };
|
||||
case 'js_exception':
|
||||
return { icon: 'funnel/exclamation-circle', color: '#BF2D00' };
|
||||
}
|
||||
|
||||
return {
|
||||
icon: 'info',
|
||||
color: 'red'
|
||||
}
|
||||
}
|
||||
36
frontend/app/mstore/types/funnelStage.ts
Normal file
36
frontend/app/mstore/types/funnelStage.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { makeAutoObservable, observable, action } from "mobx"
|
||||
import { filterLabelMap } from 'Types/filter/newFilter';
|
||||
export default class FunnelStage {
|
||||
dropDueToIssues: number = 0;
|
||||
dropPct: number = 0;
|
||||
operator: string = "";
|
||||
sessionsCount: number = 0;
|
||||
usersCount: number = 0;
|
||||
type: string = '';
|
||||
value: string[] = [];
|
||||
label: string = '';
|
||||
isActive: boolean = false;
|
||||
|
||||
constructor() {
|
||||
makeAutoObservable(this, {
|
||||
isActive: observable,
|
||||
updateKey: action,
|
||||
})
|
||||
}
|
||||
|
||||
fromJSON(json: any) {
|
||||
this.dropDueToIssues = json.dropDueToIssues;
|
||||
this.dropPct = json.dropPct;
|
||||
this.operator = json.operator;
|
||||
this.sessionsCount = json.sessionsCount;
|
||||
this.usersCount = json.usersCount;
|
||||
this.value = json.value;
|
||||
this.type = json.type;
|
||||
this.label = filterLabelMap[json.type] || json.type;
|
||||
return this;
|
||||
}
|
||||
|
||||
updateKey(key: any, value: any) {
|
||||
this[key] = value
|
||||
}
|
||||
}
|
||||
12
frontend/app/mstore/types/funnelnsights.ts
Normal file
12
frontend/app/mstore/types/funnelnsights.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import FunnelStage from "./funnelStage";
|
||||
|
||||
export default class FunnelInsights {
|
||||
stages: FunnelStage[] = [];
|
||||
totalDropDueToIssues: number = 0;
|
||||
|
||||
fromJSON(json: any) {
|
||||
this.stages = json.stages.map(stage => new FunnelStage().fromJSON(stage));
|
||||
this.totalDropDueToIssues = json.totalDropDueToIssues;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,9 +1,10 @@
|
|||
import { makeAutoObservable, runInAction, observable, action, reaction, computed } from "mobx"
|
||||
import { makeAutoObservable, runInAction, observable, action } from "mobx"
|
||||
import FilterSeries from "./filterSeries";
|
||||
import { DateTime } from 'luxon';
|
||||
import { IFilter } from "./filter";
|
||||
import { metricService } from "App/services";
|
||||
import Session, { ISession } from "App/mstore/types/session";
|
||||
import Session from "App/mstore/types/session";
|
||||
import Funnelissue from 'App/mstore/types/funnelIssue';
|
||||
import { issueOptions } from 'App/constants/filterOptions';
|
||||
|
||||
export interface IWidget {
|
||||
metricId: any
|
||||
|
|
@ -53,7 +54,8 @@ export default class Widget implements IWidget {
|
|||
metricId: any = undefined
|
||||
widgetId: any = undefined
|
||||
name: string = "New Metric"
|
||||
metricType: string = "timeseries"
|
||||
// metricType: string = "timeseries"
|
||||
metricType: string = "table"
|
||||
metricOf: string = "sessionCount"
|
||||
metricValue: string = ""
|
||||
viewType: string = "lineChart"
|
||||
|
|
@ -131,14 +133,14 @@ export default class Widget implements IWidget {
|
|||
runInAction(() => {
|
||||
this.metricId = json.metricId
|
||||
this.widgetId = json.widgetId
|
||||
this.metricValue = json.metricValue
|
||||
this.metricValue = this.metricValueFromArray(json.metricValue)
|
||||
this.metricOf = json.metricOf
|
||||
this.metricType = json.metricType
|
||||
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.dashboards = json.dashboards
|
||||
this.dashboards = json.dashboards || []
|
||||
this.owner = json.ownerEmail
|
||||
this.lastModified = json.editedAt || json.createdAt ? DateTime.fromMillis(json.editedAt || json.createdAt) : null
|
||||
this.config = json.config
|
||||
|
|
@ -167,12 +169,16 @@ export default class Widget implements IWidget {
|
|||
metricId: this.metricId,
|
||||
widgetId: this.widgetId,
|
||||
metricOf: this.metricOf,
|
||||
metricValue: this.metricValue,
|
||||
metricValue: this.metricValueToArray(this.metricValue),
|
||||
metricType: this.metricType,
|
||||
metricFormat: this.metricFormat,
|
||||
viewType: this.viewType,
|
||||
name: this.name,
|
||||
series: this.series.map((series: any) => series.toJson()),
|
||||
config: {
|
||||
...this.config,
|
||||
col: this.metricType === 'funnel' ? 4 : this.config.col
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -198,14 +204,49 @@ export default class Widget implements IWidget {
|
|||
|
||||
fetchSessions(metricId: any, filter: any): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
metricService.fetchSessions(metricId, filter).then(response => {
|
||||
resolve(response.map(cat => {
|
||||
metricService.fetchSessions(metricId, filter).then((response: any[]) => {
|
||||
resolve(response.map((cat: { sessions: any[]; }) => {
|
||||
return {
|
||||
...cat,
|
||||
sessions: cat.sessions.map(s => new Session().fromJson(s))
|
||||
sessions: cat.sessions.map((s: any) => new Session().fromJson(s))
|
||||
}
|
||||
}))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fetchIssues(filter: any): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
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)) : []
|
||||
resolve({
|
||||
issues: significantIssues.length > 0 ? significantIssues : insignificantIssues,
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fetchIssue(funnelId: any, issueId: any, params: any): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
metricService.fetchIssue(funnelId, issueId, params).then((response: any) => {
|
||||
resolve({
|
||||
issue: new Funnelissue().fromJSON(response.issue),
|
||||
sessions: response.sessions.map((s: any) => new Session().fromJson(s)),
|
||||
})
|
||||
}).catch((error: any) => {
|
||||
reject(error)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
private metricValueFromArray(metricValue: any) {
|
||||
if (!Array.isArray(metricValue)) return metricValue;
|
||||
return issueOptions.filter((i: any) => metricValue.includes(i.value))
|
||||
}
|
||||
|
||||
private metricValueToArray(metricValue: any) {
|
||||
if (!Array.isArray(metricValue)) return metricValue;
|
||||
return metricValue.map((i: any) => i.value)
|
||||
}
|
||||
}
|
||||
|
|
@ -133,7 +133,7 @@ export default class MessageDistributor extends StatedScreen {
|
|||
|
||||
const r = new MFileReader(new Uint8Array(), this.sessionStart)
|
||||
const msgs: Array<Message> = []
|
||||
loadFiles(this.session.mobsUrl,
|
||||
loadFiles([this.session.mobsUrl],
|
||||
b => {
|
||||
r.append(b)
|
||||
let next: ReturnType<MFileReader['next']>
|
||||
|
|
|
|||
|
|
@ -92,6 +92,8 @@ export const liveSession = (sessionId = ':sessionId', hash) => hashed(`/assist/$
|
|||
export const errors = params => queried('/errors', params);
|
||||
export const error = (id = ':errorId', hash) => hashed(`/errors/${ id }`, hash);
|
||||
|
||||
export const funnels = params => queried('/funnels', params)
|
||||
export const funnelsCreate = () => `/funnels/create`;
|
||||
export const funnel = (id = ':funnelId', hash) => hashed(`/funnels/${ id }`, hash);
|
||||
export const funnelIssue = (id = ':funnelId', issueId = ':issueId', hash) => hashed(`/funnels/${ id }/${ issueId}`, hash);
|
||||
|
||||
|
|
@ -110,6 +112,7 @@ export const dashboardMetricCreate = (dashboardId = ':dashboardId', hash) => ha
|
|||
export const metrics = () => `/metrics`;
|
||||
export const metricCreate = () => `/metrics/create`;
|
||||
export const metricDetails = (id = ':metricId', hash) => hashed(`/metrics/${ id }`, hash);
|
||||
export const metricDetailsSub = (id = ':metricId', subId = ':subId', hash) => hashed(`/metrics/${ id }/details/${subId}`, hash);
|
||||
|
||||
const REQUIRED_SITE_ID_ROUTES = [
|
||||
liveSession(''),
|
||||
|
|
@ -119,6 +122,7 @@ const REQUIRED_SITE_ID_ROUTES = [
|
|||
|
||||
metrics(),
|
||||
metricDetails(''),
|
||||
metricDetailsSub(''),
|
||||
|
||||
dashboard(''),
|
||||
dashboardSelected(''),
|
||||
|
|
@ -129,6 +133,8 @@ const REQUIRED_SITE_ID_ROUTES = [
|
|||
error(''),
|
||||
errors(),
|
||||
onboarding(''),
|
||||
funnels(''),
|
||||
funnelsCreate(''),
|
||||
funnel(''),
|
||||
funnelIssue(''),
|
||||
];
|
||||
|
|
@ -156,6 +162,7 @@ export function isRoute(route, path){
|
|||
|
||||
const SITE_CHANGE_AVALIABLE_ROUTES = [
|
||||
sessions(),
|
||||
funnels(),
|
||||
assist(),
|
||||
dashboard(),
|
||||
metrics(),
|
||||
|
|
|
|||
12
frontend/app/services/BaseService.ts
Normal file
12
frontend/app/services/BaseService.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import APIClient from 'App/api_client';
|
||||
export default class BaseService {
|
||||
client: APIClient;
|
||||
|
||||
constructor(client?: APIClient) {
|
||||
this.client = client ? client : new APIClient();
|
||||
}
|
||||
|
||||
initClient(client?: APIClient) {
|
||||
this.client = client || new APIClient();
|
||||
}
|
||||
}
|
||||
14
frontend/app/services/ErrorService.ts
Normal file
14
frontend/app/services/ErrorService.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import BaseService from './BaseService';
|
||||
|
||||
export default class ErrorService extends BaseService {
|
||||
all(params: any = {}): Promise<any[]> {
|
||||
return this.client.post('/errors/search', params)
|
||||
.then(response => response.json())
|
||||
.then(response => response.data || []);
|
||||
}
|
||||
|
||||
one(id: string): Promise<any> {
|
||||
return this.client.get(`/errors/${id}`)
|
||||
.then(response => response.json())
|
||||
}
|
||||
}
|
||||
65
frontend/app/services/FunnelService.ts
Normal file
65
frontend/app/services/FunnelService.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { IFunnel } from "App/mstore/types/funnel"
|
||||
import APIClient from 'App/api_client';
|
||||
|
||||
export interface IFunnelService {
|
||||
initClient(client?: APIClient)
|
||||
all(): Promise<any[]>
|
||||
one(funnelId: string): Promise<any>
|
||||
save(funnel: IFunnel): Promise<any>
|
||||
delete(funnelId: string): Promise<any>
|
||||
|
||||
fetchInsights(funnelId: string, payload: any): Promise<any>
|
||||
fetchIssues(funnelId?: string, payload?: any): Promise<any>
|
||||
fetchIssue(funnelId: string, issueId: string): Promise<any>
|
||||
}
|
||||
|
||||
export default class FunnelService implements IFunnelService {
|
||||
private client: APIClient;
|
||||
|
||||
constructor(client?: APIClient) {
|
||||
this.client = client ? client : new APIClient();
|
||||
}
|
||||
|
||||
initClient(client?: APIClient) {
|
||||
this.client = client || new APIClient();
|
||||
}
|
||||
|
||||
all(): Promise<any[]> {
|
||||
return this.client.get('/funnels')
|
||||
.then(response => response.json())
|
||||
.then(response => response.data || []);
|
||||
}
|
||||
|
||||
one(funnelId: string): Promise<any> {
|
||||
return this.client.get(`/funnels/${funnelId}`)
|
||||
.then(response => response.json())
|
||||
}
|
||||
|
||||
save(funnel: IFunnel): Promise<any> {
|
||||
return this.client.post('/funnels', funnel)
|
||||
.then(response => response.json())
|
||||
}
|
||||
|
||||
delete(funnelId: string): Promise<any> {
|
||||
return this.client.delete(`/funnels/${funnelId}`)
|
||||
.then(response => response.json())
|
||||
}
|
||||
|
||||
fetchInsights(funnelId: string, payload: any): Promise<any> {
|
||||
return this.client.post(`/funnels/${funnelId}/insights`, payload)
|
||||
.then(response => response.json())
|
||||
}
|
||||
|
||||
fetchIssues(funnelId?: string, payload?: any): Promise<any> {
|
||||
const path = funnelId ? `/funnels/${funnelId}/issues` : '/funnels/issues';
|
||||
return this.client.post(path, payload)
|
||||
.then(response => response.json())
|
||||
.then(response => response.data || []);
|
||||
}
|
||||
|
||||
fetchIssue(funnelId: string, issueId: string): Promise<any> {
|
||||
return this.client.post(`/funnels/${funnelId}/issues/${issueId}/sessions`, {})
|
||||
.then(response => response.json())
|
||||
.then(response => response.data || {});
|
||||
}
|
||||
}
|
||||
|
|
@ -13,6 +13,7 @@ export interface IMetricService {
|
|||
getTemplates(): Promise<any>;
|
||||
getMetricChartData(metric: IWidget, data: any, isWidget: boolean): Promise<any>;
|
||||
fetchSessions(metricId: string, filter: any): Promise<any>
|
||||
fetchIssues(filter: string): Promise<any>;
|
||||
}
|
||||
|
||||
export default class MetricService implements IMetricService {
|
||||
|
|
@ -32,8 +33,8 @@ export default class MetricService implements IMetricService {
|
|||
*/
|
||||
getMetrics(): Promise<any> {
|
||||
return this.client.get('/metrics')
|
||||
.then(response => response.json())
|
||||
.then(response => response.data || []);
|
||||
.then((response: { json: () => any; }) => response.json())
|
||||
.then((response: { data: any; }) => response.data || []);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -43,8 +44,8 @@ export default class MetricService implements IMetricService {
|
|||
*/
|
||||
getMetric(metricId: string): Promise<any> {
|
||||
return this.client.get('/metrics/' + metricId)
|
||||
.then(response => response.json())
|
||||
.then(response => response.data || {});
|
||||
.then((response: { json: () => any; }) => response.json())
|
||||
.then((response: { data: any; }) => response.data || {});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -58,8 +59,8 @@ export default class MetricService implements IMetricService {
|
|||
const method = isCreating ? 'post' : 'put';
|
||||
const url = isCreating ? '/metrics' : '/metrics/' + data[Widget.ID_KEY];
|
||||
return this.client[method](url, data)
|
||||
.then(response => response.json())
|
||||
.then(response => response.data || {});
|
||||
.then((response: { json: () => any; }) => response.json())
|
||||
.then((response: { data: any; }) => response.data || {});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -69,8 +70,8 @@ export default class MetricService implements IMetricService {
|
|||
*/
|
||||
deleteMetric(metricId: string): Promise<any> {
|
||||
return this.client.delete('/metrics/' + metricId)
|
||||
.then(response => response.json())
|
||||
.then(response => response.data);
|
||||
.then((response: { json: () => any; }) => response.json())
|
||||
.then((response: { data: any; }) => response.data);
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -80,15 +81,15 @@ export default class MetricService implements IMetricService {
|
|||
*/
|
||||
getTemplates(): Promise<any> {
|
||||
return this.client.get('/metrics/templates')
|
||||
.then(response => response.json())
|
||||
.then(response => response.data || []);
|
||||
.then((response: { json: () => any; }) => response.json())
|
||||
.then((response: { data: any; }) => response.data || []);
|
||||
}
|
||||
|
||||
getMetricChartData(metric: IWidget, data: any, isWidget: boolean = false): Promise<any> {
|
||||
const path = isWidget ? `/metrics/${metric.metricId}/chart` : `/metrics/try`;
|
||||
return this.client.post(path, data)
|
||||
.then(response => response.json())
|
||||
.then(response => response.data || {});
|
||||
.then((response: { json: () => any; }) => response.json())
|
||||
.then((response: { data: any; }) => response.data || {});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -98,7 +99,19 @@ export default class MetricService implements IMetricService {
|
|||
*/
|
||||
fetchSessions(metricId: string, filter: any): Promise<any> {
|
||||
return this.client.post(metricId ? `/metrics/${metricId}/sessions` : '/metrics/try/sessions', filter)
|
||||
.then(response => response.json())
|
||||
.then(response => response.data || []);
|
||||
.then((response: { json: () => any; }) => response.json())
|
||||
.then((response: { data: any; }) => response.data || []);
|
||||
}
|
||||
|
||||
fetchIssues(filter: string): Promise<any> {
|
||||
return this.client.post(`/metrics/try/issues`, filter)
|
||||
.then((response: { json: () => any; }) => response.json())
|
||||
.then((response: { data: any; }) => response.data || {});
|
||||
}
|
||||
|
||||
fetchIssue(funnelId: string, issueId: string, params: any): Promise<any> {
|
||||
return this.client.post(`/funnels/${funnelId}/issues/${issueId}/sessions`, params)
|
||||
.then((response: { json: () => any; }) => response.json())
|
||||
.then((response: { data: any; }) => response.data || {});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,11 +1,15 @@
|
|||
import DashboardService, { IDashboardService } from "./DashboardService";
|
||||
import MetricService, { IMetricService } from "./MetricService";
|
||||
import FunnelService, { IFunnelService } from "./FunnelService";
|
||||
import SessionSerivce from "./SessionService";
|
||||
import UserService from "./UserService";
|
||||
import AuditService from './AuditService';
|
||||
import ErrorService from "./ErrorService";
|
||||
|
||||
export const dashboardService: IDashboardService = new DashboardService();
|
||||
export const metricService: IMetricService = new MetricService();
|
||||
export const sessionService: SessionSerivce = new SessionSerivce();
|
||||
export const userService: UserService = new UserService();
|
||||
export const auditService: AuditService = new AuditService();
|
||||
export const funnelService: IFunnelService = new FunnelService();
|
||||
export const auditService: AuditService = new AuditService();
|
||||
export const errorService: ErrorService = new ErrorService();
|
||||
|
|
|
|||
|
|
@ -255,6 +255,7 @@ p {
|
|||
|
||||
.link {
|
||||
color: $blue !important;
|
||||
cursor: pointer;
|
||||
&:hover {
|
||||
text-decoration: underline !important;
|
||||
}
|
||||
|
|
|
|||
3
frontend/app/svg/icons/arrow-right-short.svg
Normal file
3
frontend/app/svg/icons/arrow-right-short.svg
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" class="bi bi-arrow-right-short" viewBox="0 0 16 16">
|
||||
<path fill-rule="evenodd" d="M4 8a.5.5 0 0 1 .5-.5h5.793L8.146 5.354a.5.5 0 1 1 .708-.708l3 3a.5.5 0 0 1 0 .708l-3 3a.5.5 0 0 1-.708-.708L10.293 8.5H4.5A.5.5 0 0 1 4 8z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 273 B |
4
frontend/app/svg/icons/eye-slash-fill.svg
Normal file
4
frontend/app/svg/icons/eye-slash-fill.svg
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" class="bi bi-eye-slash-fill" viewBox="0 0 16 16">
|
||||
<path d="m10.79 12.912-1.614-1.615a3.5 3.5 0 0 1-4.474-4.474l-2.06-2.06C.938 6.278 0 8 0 8s3 5.5 8 5.5a7.029 7.029 0 0 0 2.79-.588zM5.21 3.088A7.028 7.028 0 0 1 8 2.5c5 0 8 5.5 8 5.5s-.939 1.721-2.641 3.238l-2.062-2.062a3.5 3.5 0 0 0-4.474-4.474L5.21 3.089z"/>
|
||||
<path d="M5.525 7.646a2.5 2.5 0 0 0 2.829 2.829l-2.83-2.829zm4.95.708-2.829-2.83a2.5 2.5 0 0 1 2.829 2.829zm3.171 6-12-12 .708-.708 12 12-.708.708z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 512 B |
3
frontend/app/svg/icons/funnel-fill.svg
Normal file
3
frontend/app/svg/icons/funnel-fill.svg
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-funnel-fill" viewBox="0 0 16 16">
|
||||
<path d="M1.5 1.5A.5.5 0 0 1 2 1h12a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-.128.334L10 8.692V13.5a.5.5 0 0 1-.342.474l-3 1A.5.5 0 0 1 6 14.5V8.692L1.628 3.834A.5.5 0 0 1 1.5 3.5v-2z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 313 B |
|
|
@ -17,6 +17,8 @@ module.exports = {
|
|||
"tealx-light": "#E2F0EE",
|
||||
"tealx-light-border": "#C6DCDA",
|
||||
|
||||
"tealx-lightest": 'rgba(62, 170, 175, 0.1)',
|
||||
|
||||
orange: "#E28940",
|
||||
yellow: "#FFFBE5",
|
||||
yellow2: "#F5A623",
|
||||
|
|
@ -26,6 +28,7 @@ module.exports = {
|
|||
"green-dark": "#2C9848",
|
||||
red: "#cc0000",
|
||||
red2: "#F5A623",
|
||||
"red-lightest": 'rgba(204, 0, 0, 0.1)',
|
||||
blue: "#366CD9",
|
||||
blue2: "#0076FF",
|
||||
"active-blue": "#F6F7FF",
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue