Merge branch 'funnels' into deb-funnels

This commit is contained in:
Shekar Siri 2022-06-13 17:21:46 +02:00
commit 44d735d0a5
103 changed files with 1942 additions and 191 deletions

View file

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

View file

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

View file

@ -9,8 +9,6 @@ import { numberWithCommas } from 'App/utils';
interface Props {
metric: any,
data: any;
params: any;
// seriesMap: any;
colors: any;
onClick?: (filters) => void;
}

View file

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

View file

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

View file

@ -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}
/>
)}
</>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,12 @@
import React from 'react';
import ErrorsList from '../ErrorsList';
function ErrorsWidget(props) {
return (
<div>
<ErrorsList />
</div>
);
}
export default ErrorsWidget;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,12 @@
import React from 'react';
import SessionList from '../SessionList';
function SessionWidget(props) {
return (
<div>
<SessionList />
</div>
);
}
export default SessionWidget;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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;

View 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;

View file

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

View 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;

View file

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

View file

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

View file

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

View 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);
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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;

View file

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

View file

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

View file

@ -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) => {

View file

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

View file

@ -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%' }}
/>
{

View file

@ -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) }
/>

View file

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

View file

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

View file

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

View file

@ -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'),

View file

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

View 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
}
)
})
}
}

View 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"}]

View file

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

View file

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

View 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;
}

View file

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

View file

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

View 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
}
}

View 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'
}
}

View 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
}
}

View 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;
}
}

View file

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

View file

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

View file

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

View 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();
}
}

View 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())
}
}

View 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 || {});
}
}

View file

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

View file

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

View file

@ -255,6 +255,7 @@ p {
.link {
color: $blue !important;
cursor: pointer;
&:hover {
text-decoration: underline !important;
}

View 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

View 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

View 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

View file

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