Compare commits

...
Sign in to create a new pull request.

42 commits

Author SHA1 Message Date
Shekar Siri
7d4dd8e651
Merge branch 'redux-toolkit-move' into rtm-temp 2024-09-20 14:31:44 +05:30
Shekar Siri
b91868ebe1 change(ui): duck/search wip 2024-09-20 14:29:05 +05:30
nick-delirium
ca664e8860
sessions sotre 2024-09-20 09:26:09 +02:00
Shekar Siri
70293cd8de Merge remote-tracking branch 'origin/redux-toolkit-move' into rtm-temp 2024-09-20 11:25:33 +05:30
nick-delirium
b2fc450a1e
replace simple actions for session store 2024-09-19 17:17:48 +02:00
Shekar Siri
7a25a0e6ad Merge remote-tracking branch 'origin/redux-toolkit-move' into rtm-temp 2024-09-19 19:27:33 +05:30
nick-delirium
52c78f179d
fix userfilter 2024-09-19 15:45:38 +02:00
Shekar Siri
13f24ea6f4 Merge remote-tracking branch 'origin/redux-toolkit-move' into rtm-temp 2024-09-19 19:06:53 +05:30
nick-delirium
fb4caa3f13
init sessionstore outside of context 2024-09-19 15:03:06 +02:00
Shekar Siri
64a3eb7e89 change(ui): duck/search wip 2024-09-19 18:29:43 +05:30
nick-delirium
b840ff65ff
move session store 2024-09-19 14:58:50 +02:00
nick-delirium
f46170036b
fixup for SessionTags.tsx, remove duck/sources (?) 2024-09-19 12:00:50 +02:00
nick-delirium
daf254cc6f
remove all duck/site refs, remove old components 2024-09-19 11:45:25 +02:00
nick-delirium
997d69c389
move all critical components, drop site duck 2024-09-19 10:38:07 +02:00
nick-delirium
c2bc023c5e
fix setid context 2024-09-18 16:53:23 +02:00
nick-delirium
81ecbac892
new batch for site -> projects 2024-09-18 16:49:59 +02:00
nick-delirium
df20cd5333
move api and "few" files to new project store 2024-09-18 15:39:33 +02:00
nick-delirium
82586d23b2
start of projects refactoring 2024-09-18 15:14:22 +02:00
nick-delirium
a7a1eca9c3
some fixes for integrated check 2024-09-18 11:06:27 +02:00
nick-delirium
c94ca6fb88
finish removing integrations state 2024-09-18 10:57:15 +02:00
nick-delirium
5a011692f8
refactoring integrations reducers etc WIP 2024-09-17 16:52:56 +02:00
nick-delirium
b9590f702e
fix integrations service, fix babel config to >.25 + not ie 2024-09-17 11:41:50 +02:00
Shekar Siri
6c56567580 change(ui): duck/customMetrics cleanup and upgrades 2024-09-17 15:05:08 +05:30
Shekar Siri
2c12aa5239 change(ui): duck/filters cleanup 2024-09-17 15:05:08 +05:30
Shekar Siri
acf3fb4275 change(ui): duck/filters minor cleanup 2024-09-17 15:05:08 +05:30
Shekar Siri
577e47e11d change(ui): customMetrics cleanup 2024-09-17 15:05:08 +05:30
Shekar Siri
f2e103ad08 change(ui): customMetrics cleanup 2024-09-17 15:05:08 +05:30
Shekar Siri
d4d836ad24 change(ui): custom fields 2024-09-17 15:05:04 +05:30
Shekar Siri
4f2d61d1cf change(ui): funnel duck cleanup 2024-09-17 15:04:34 +05:30
nick-delirium
452fe62ebe
start moving integrations state to mobx 2024-09-16 17:51:25 +02:00
nick-delirium
de35ef8822
finish removing errors reducer 2024-09-16 15:39:07 +02:00
nick-delirium
29576a775c
remove errors reducer, drop old components 2024-09-16 14:38:00 +02:00
nick-delirium
3f9b485be6
some fixes after issues store 2024-09-16 14:38:00 +02:00
nick-delirium
ef318318d8
remove issues store 2024-09-16 14:38:00 +02:00
nick-delirium
5d3872c371
ui: move assignments to issueReportingStore.ts 2024-09-16 14:38:00 +02:00
nick-delirium
1a2e143888
ui: move player slice reducer to mobx family 2024-09-16 14:37:59 +02:00
nick-delirium
60ac0ed312
ui: drop unreferenced types 2024-09-16 14:37:59 +02:00
nick-delirium
6ea2be8dc4
ui: drop unreferenced types 2024-09-16 14:37:59 +02:00
nick-delirium
0f89770560
ui: migrating duck/roles to mobx 2024-09-16 14:37:59 +02:00
nick-delirium
98e50d0e96
changes for gdpr and site types 2024-09-16 14:37:59 +02:00
nick-delirium
c8a7991d77
remove unused reducer 2024-09-16 14:37:59 +02:00
nick-delirium
aecf3ecd96
start moving ui to redux tlk 2024-09-16 14:37:59 +02:00
318 changed files with 6618 additions and 10941 deletions

1
frontend/.browserslistrc Normal file
View file

@ -0,0 +1 @@
> 0.25% and not dead

View file

@ -10,6 +10,8 @@ import NotFoundPage from 'Shared/NotFoundPage';
import { ModalProvider } from 'Components/Modal'; import { ModalProvider } from 'Components/Modal';
import Layout from 'App/layout/Layout'; import Layout from 'App/layout/Layout';
import PublicRoutes from 'App/PublicRoutes'; import PublicRoutes from 'App/PublicRoutes';
import { useStore } from 'App/mstore';
import { observer } from 'mobx-react-lite';
const components: any = { const components: any = {
SessionPure: lazy(() => import('Components/Session/Session')), SessionPure: lazy(() => import('Components/Session/Session')),
@ -41,8 +43,11 @@ interface Props {
} }
function IFrameRoutes(props: Props) { function IFrameRoutes(props: Props) {
const { isJwt = false, isLoggedIn = false, loading, onboarding, sites, siteId, jwt } = props; const { projectsStore } = useStore();
const siteIdList: any = sites.map(({ id }) => id).toJS(); const sites = projectsStore.list;
const siteId = projectsStore.siteId;
const { isJwt = false, isLoggedIn = false, loading, onboarding, jwt } = props;
const siteIdList: any = sites.map(({ id }) => id);
if (isLoggedIn) { if (isLoggedIn) {
return ( return (
@ -75,11 +80,9 @@ function IFrameRoutes(props: Props) {
export default connect((state: any) => ({ export default connect((state: any) => ({
changePassword: state.getIn(['user', 'account', 'changePassword']), changePassword: state.getIn(['user', 'account', 'changePassword']),
onboarding: state.getIn(['user', 'onboarding']), onboarding: state.getIn(['user', 'onboarding']),
sites: state.getIn(['site', 'list']),
siteId: state.getIn(['site', 'siteId']),
jwt: state.getIn(['user', 'jwt']), jwt: state.getIn(['user', 'jwt']),
tenantId: state.getIn(['user', 'account', 'tenantId']), tenantId: state.getIn(['user', 'account', 'tenantId']),
isEnterprise: isEnterprise:
state.getIn(['user', 'account', 'edition']) === 'ee' || state.getIn(['user', 'account', 'edition']) === 'ee' ||
state.getIn(['user', 'authDetails', 'edition']) === 'ee' state.getIn(['user', 'authDetails', 'edition']) === 'ee'
}))(IFrameRoutes); }))(observer(IFrameRoutes));

View file

@ -3,8 +3,8 @@ import { Map } from 'immutable';
import React, { Suspense, lazy } from 'react'; import React, { Suspense, lazy } from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { Redirect, Route, Switch } from 'react-router-dom'; import { Redirect, Route, Switch } from 'react-router-dom';
import { observer } from 'mobx-react-lite'
import AdditionalRoutes from 'App/AdditionalRoutes'; import { useStore } from "./mstore";
import { GLOBAL_HAS_NO_RECORDINGS } from 'App/constants/storageKeys'; import { GLOBAL_HAS_NO_RECORDINGS } from 'App/constants/storageKeys';
import { OB_DEFAULT_TAB } from 'App/routes'; import { OB_DEFAULT_TAB } from 'App/routes';
import { Loader } from 'UI'; import { Loader } from 'UI';
@ -110,20 +110,20 @@ const SCOPE_SETUP = routes.scopeSetup();
interface Props { interface Props {
tenantId: string; tenantId: string;
siteId: string;
sites: Map<string, any>;
onboarding: boolean; onboarding: boolean;
scope: number; scope: number;
} }
function PrivateRoutes(props: Props) { function PrivateRoutes(props: Props) {
const { onboarding, sites, siteId } = props; const { projectsStore } = useStore();
const sites = projectsStore.list;
const siteId = projectsStore.siteId;
const { onboarding } = props;
const hasRecordings = sites.some(s => s.recorded); const hasRecordings = sites.some(s => s.recorded);
const redirectToSetup = props.scope === 0; const redirectToSetup = props.scope === 0;
const redirectToOnboarding = const redirectToOnboarding =
!onboarding && (localStorage.getItem(GLOBAL_HAS_NO_RECORDINGS) === 'true' || !hasRecordings) && props.scope > 0; !onboarding && (localStorage.getItem(GLOBAL_HAS_NO_RECORDINGS) === 'true' || (sites.length > 0 && !hasRecordings)) && props.scope > 0;
const siteIdList: any = sites.map(({ id }) => id).toJS(); const siteIdList: any = sites.map(({ id }) => id);
return ( return (
<Suspense fallback={<Loader loading={true} className="flex-1" />}> <Suspense fallback={<Loader loading={true} className="flex-1" />}>
<Switch key="content"> <Switch key="content">
@ -292,7 +292,5 @@ function PrivateRoutes(props: Props) {
export default connect((state: any) => ({ export default connect((state: any) => ({
onboarding: state.getIn(['user', 'onboarding']), onboarding: state.getIn(['user', 'onboarding']),
scope: getScope(state), scope: getScope(state),
sites: state.getIn(['site', 'list']),
siteId: state.getIn(['site', 'siteId']),
tenantId: state.getIn(['user', 'account', 'tenantId']), tenantId: state.getIn(['user', 'account', 'tenantId']),
}))(PrivateRoutes); }))(observer(PrivateRoutes));

View file

@ -10,60 +10,56 @@ import {
GLOBAL_DESTINATION_PATH, GLOBAL_DESTINATION_PATH,
IFRAME, IFRAME,
JWT_PARAM, JWT_PARAM,
SPOT_ONBOARDING, SPOT_ONBOARDING
} from 'App/constants/storageKeys'; } from 'App/constants/storageKeys';
import Layout from 'App/layout/Layout'; import Layout from 'App/layout/Layout';
import { withStore } from 'App/mstore'; import { useStore } from 'App/mstore';
import { checkParam, handleSpotJWT, isTokenExpired } from 'App/utils'; import { checkParam, handleSpotJWT, isTokenExpired } from 'App/utils';
import { ModalProvider } from 'Components/Modal'; import { ModalProvider } from 'Components/Modal';
import { ModalProvider as NewModalProvider } from 'Components/ModalContext'; import { ModalProvider as NewModalProvider } from 'Components/ModalContext';
import { fetchListActive as fetchMetadata } from 'Duck/customField';
import { setSessionPath } from 'Duck/sessions';
import { fetchList as fetchSiteList } from 'Duck/site';
import { init as initSite } from 'Duck/site';
import { fetchUserInfo, getScope, logout, setJwt } from 'Duck/user'; import { fetchUserInfo, getScope, logout, setJwt } from 'Duck/user';
import { Loader } from 'UI'; import { Loader } from 'UI';
import * as routes from './routes'; import * as routes from './routes';
import { observer } from 'mobx-react-lite'
interface RouterProps interface RouterProps
extends RouteComponentProps, extends RouteComponentProps,
ConnectedProps<typeof connector> { ConnectedProps<typeof connector> {
isLoggedIn: boolean; isLoggedIn: boolean;
sites: Map<string, any>;
loading: boolean;
changePassword: boolean; changePassword: boolean;
isEnterprise: boolean; isEnterprise: boolean;
fetchUserInfo: () => any; fetchUserInfo: () => any;
setSessionPath: (path: any) => any;
fetchSiteList: (siteId?: number) => any;
match: { match: {
params: { params: {
siteId: string; siteId: string;
}; };
}; };
mstore: any;
setJwt: (params: { jwt: string; spotJwt: string | null }) => any; setJwt: (params: { jwt: string; spotJwt: string | null }) => any;
fetchMetadata: (siteId: string) => void;
initSite: (site: any) => void;
scopeSetup: boolean;
localSpotJwt: string | null; localSpotJwt: string | null;
} }
const Router: React.FC<RouterProps> = (props) => { const Router: React.FC<RouterProps> = (props) => {
const { const {
isLoggedIn, isLoggedIn,
siteId, userInfoLoading,
sites,
loading,
location, location,
fetchUserInfo, fetchUserInfo,
fetchSiteList,
history, history,
setSessionPath,
scopeSetup,
localSpotJwt, localSpotJwt,
logout, logout,
scopeSetup,
setJwt,
} = props; } = props;
const mstore = useStore();
const { customFieldStore, projectsStore, sessionStore } = mstore;
const setSessionPath = sessionStore.setSessionPath;
const siteId = projectsStore.siteId;
const sitesLoading = projectsStore.sitesLoading;
const sites = projectsStore.list;
const loading = Boolean(userInfoLoading || (!scopeSetup && !siteId) || sitesLoading);
const initSite = projectsStore.initProject;
const fetchSiteList = projectsStore.fetchList;
const params = new URLSearchParams(location.search); const params = new URLSearchParams(location.search);
const spotCb = params.get('spotCallback'); const spotCb = params.get('spotCallback');
@ -81,7 +77,7 @@ const Router: React.FC<RouterProps> = (props) => {
handleSpotLogin(spotJwt); handleSpotLogin(spotJwt);
} }
if (urlJWT) { if (urlJWT) {
props.setJwt({ jwt: urlJWT, spotJwt: spotJwt ?? null }); setJwt({ jwt: urlJWT, spotJwt: spotJwt ?? null });
} }
}; };
@ -109,9 +105,9 @@ const Router: React.FC<RouterProps> = (props) => {
localStorage.setItem(SPOT_ONBOARDING, 'true'); localStorage.setItem(SPOT_ONBOARDING, 'true');
} }
await fetchUserInfo(); await fetchUserInfo();
const siteIdFromPath = parseInt(location.pathname.split('/')[1]); const siteIdFromPath = location.pathname.split('/')[1];
await fetchSiteList(siteIdFromPath); await fetchSiteList(siteIdFromPath);
props.mstore.initClient(); mstore.initClient();
if (localSpotJwt && !isTokenExpired(localSpotJwt)) { if (localSpotJwt && !isTokenExpired(localSpotJwt)) {
handleSpotLogin(localSpotJwt); handleSpotLogin(localSpotJwt);
@ -175,12 +171,16 @@ const Router: React.FC<RouterProps> = (props) => {
}, [isSpotCb, isLoggedIn, localSpotJwt, isSignup]); }, [isSpotCb, isLoggedIn, localSpotJwt, isSignup]);
useEffect(() => { useEffect(() => {
if (siteId && siteId !== lastFetchedSiteIdRef.current) { const fetchData = async () => {
const activeSite = sites.find((s) => s.id == siteId); if (siteId && siteId !== lastFetchedSiteIdRef.current) {
props.initSite(activeSite); const activeSite = sites.find((s) => s.id == siteId);
props.fetchMetadata(siteId); initSite(activeSite ?? {});
lastFetchedSiteIdRef.current = siteId; lastFetchedSiteIdRef.current = activeSite?.id;
} await customFieldStore.fetchListActive(siteId + '');
}
};
void fetchData();
}, [siteId]); }, [siteId]);
const lastFetchedSiteIdRef = useRef<any>(null); const lastFetchedSiteIdRef = useRef<any>(null);
@ -226,29 +226,21 @@ const Router: React.FC<RouterProps> = (props) => {
}; };
const mapStateToProps = (state: Map<string, any>) => { const mapStateToProps = (state: Map<string, any>) => {
const siteId = state.getIn(['site', 'siteId']);
const jwt = state.getIn(['user', 'jwt']); const jwt = state.getIn(['user', 'jwt']);
const changePassword = state.getIn(['user', 'account', 'changePassword']); const changePassword = state.getIn(['user', 'account', 'changePassword']);
const userInfoLoading = state.getIn([ const userInfoLoading = state.getIn([
'user', 'user',
'fetchUserInfoRequest', 'fetchUserInfoRequest',
'loading', 'loading'
]); ]);
const sitesLoading = state.getIn(['site', 'fetchListRequest', 'loading']);
const scopeSetup = getScope(state) === 0; const scopeSetup = getScope(state) === 0;
const loading =
Boolean(userInfoLoading) ||
Boolean(sitesLoading) ||
(!scopeSetup && !siteId);
return { return {
siteId,
changePassword, changePassword,
sites: state.getIn(['site', 'list']),
jwt, jwt,
scopeSetup,
localSpotJwt: state.getIn(['user', 'spotJwt']), localSpotJwt: state.getIn(['user', 'spotJwt']),
isLoggedIn: jwt !== null && !changePassword, isLoggedIn: jwt !== null && !changePassword,
scopeSetup, userInfoLoading,
loading,
email: state.getIn(['user', 'account', 'email']), email: state.getIn(['user', 'account', 'email']),
account: state.getIn(['user', 'account']), account: state.getIn(['user', 'account']),
organisation: state.getIn(['user', 'account', 'name']), organisation: state.getIn(['user', 'account', 'name']),
@ -256,20 +248,16 @@ const mapStateToProps = (state: Map<string, any>) => {
tenants: state.getIn(['user', 'tenants']), tenants: state.getIn(['user', 'tenants']),
isEnterprise: isEnterprise:
state.getIn(['user', 'account', 'edition']) === 'ee' || state.getIn(['user', 'account', 'edition']) === 'ee' ||
state.getIn(['user', 'authDetails', 'edition']) === 'ee', state.getIn(['user', 'authDetails', 'edition']) === 'ee'
}; };
}; };
const mapDispatchToProps = { const mapDispatchToProps = {
fetchUserInfo, fetchUserInfo,
setSessionPath,
fetchSiteList,
setJwt, setJwt,
fetchMetadata, logout
initSite,
logout,
}; };
const connector = connect(mapStateToProps, mapDispatchToProps); const connector = connect(mapStateToProps, mapDispatchToProps);
export default withStore(withRouter(connector(Router))); export default withRouter(connector(observer(Router)));

View file

@ -54,12 +54,12 @@ export const clean = (obj: any, forbiddenValues: any[] = [undefined, '']): any =
export default class APIClient { export default class APIClient {
private init: RequestInit; private init: RequestInit;
private readonly siteId: string | undefined; private siteId: string | undefined;
private siteIdCheck: (() => { siteId: string | null }) | undefined;
private refreshingTokenPromise: Promise<string> | null = null; private refreshingTokenPromise: Promise<string> | null = null;
constructor() { constructor() {
const jwt = store.getState().getIn(['user', 'jwt']); const jwt = store.getState().getIn(['user', 'jwt']);
const siteId = store.getState().getIn(['site', 'siteId']);
this.init = { this.init = {
headers: new Headers({ headers: new Headers({
Accept: 'application/json', Accept: 'application/json',
@ -69,7 +69,10 @@ export default class APIClient {
if (jwt !== null) { if (jwt !== null) {
(this.init.headers as Headers).set('Authorization', `Bearer ${jwt}`); (this.init.headers as Headers).set('Authorization', `Bearer ${jwt}`);
} }
this.siteId = siteId; }
setSiteIdCheck(checker: () => { siteId: string | null }): void {
this.siteIdCheck = checker
} }
private getInit(method: string = 'GET', params?: any, reqHeaders?: Record<string, any>): RequestInit { private getInit(method: string = 'GET', params?: any, reqHeaders?: Record<string, any>): RequestInit {
@ -101,6 +104,7 @@ export default class APIClient {
delete init.body; // GET requests shouldn't have a body delete init.body; // GET requests shouldn't have a body
} }
this.siteId = this.siteIdCheck?.().siteId ?? undefined;
return init; return init;
} }

View file

@ -1,6 +1,6 @@
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import { connect } from 'react-redux'; import { observer } from 'mobx-react-lite'
import { fetchLiveList } from 'Duck/sessions'; import { useStore } from 'App/mstore';
import { Loader, NoContent, Label } from 'UI'; import { Loader, NoContent, Label } from 'UI';
import SessionItem from 'Shared/SessionItem'; import SessionItem from 'Shared/SessionItem';
import { useModal } from 'App/components/Modal'; import { useModal } from 'App/components/Modal';
@ -11,16 +11,20 @@ interface Props {
list: any; list: any;
session: any; session: any;
userId: any; userId: any;
fetchLiveList: (params: any) => void;
} }
function SessionList(props: Props) { function SessionList(props: Props) {
const { hideModal } = useModal(); const { hideModal } = useModal();
const { sessionStore } = useStore();
const fetchLiveList = sessionStore.fetchLiveSessions;
const session = sessionStore.current;
const list = sessionStore.liveSessions.filter((i: any) => i.userId === session.userId && i.sessionId !== session.sessionId);
const loading = sessionStore.loadingLiveSessions;
useEffect(() => { useEffect(() => {
const params: any = {}; const params: any = {};
if (props.session.userId) { if (props.session.userId) {
params.userId = props.session.userId; params.userId = props.session.userId;
} }
props.fetchLiveList(params); void fetchLiveList(params);
}, []); }, []);
return ( return (
@ -33,9 +37,9 @@ function SessionList(props: Props) {
{props.userId}'s <span className="color-gray-medium">Live Sessions</span>{' '} {props.userId}'s <span className="color-gray-medium">Live Sessions</span>{' '}
</div> </div>
</div> </div>
<Loader loading={props.loading}> <Loader loading={loading}>
<NoContent <NoContent
show={!props.loading && props.list.length === 0} show={!loading && list.length === 0}
title={ title={
<div className="flex items-center justify-center flex-col"> <div className="flex items-center justify-center flex-col">
<AnimatedSVG name={ICONS.NO_LIVE_SESSIONS} size={60} /> <AnimatedSVG name={ICONS.NO_LIVE_SESSIONS} size={60} />
@ -45,7 +49,7 @@ function SessionList(props: Props) {
} }
> >
<div className="p-4"> <div className="p-4">
{props.list.map((session: any) => ( {list.map((session: any) => (
<div className="mb-6" key={session.sessionId}> <div className="mb-6" key={session.sessionId}>
{session.pageTitle && session.pageTitle !== '' && ( {session.pageTitle && session.pageTitle !== '' && (
<div className="flex items-center mb-2"> <div className="flex items-center mb-2">
@ -65,14 +69,4 @@ function SessionList(props: Props) {
); );
} }
export default connect( export default observer(SessionList);
(state: any) => {
const session = state.getIn(['sessions', 'current']);
return {
session,
list: state.getIn(['sessions', 'liveSessions']).filter((i: any) => i.userId === session.userId && i.sessionId !== session.sessionId),
loading: state.getIn(['sessions', 'fetchLiveListRequest', 'loading']),
};
},
{ fetchLiveList }
)(SessionList);

View file

@ -55,7 +55,7 @@ const CustomFieldForm = ({ field, saving, errors, edit, save, onSave, onClose, o
const mapStateToProps = (state) => ({ const mapStateToProps = (state) => ({
field: state.getIn(['customFields', 'instance']), field: state.getIn(['customFields', 'instance']),
saving: state.getIn(['customFields', 'saveRequest', 'loading']), saving: state.getIn(['customFields', 'saveRequest', 'loading']),
errors: state.getIn(['customFields', 'saveRequest', 'errors']), errors: state.getIn(['customFields', 'saveRequest', 'errors'])
}); });
export default connect(mapStateToProps, { edit, save })(CustomFieldForm); export default connect(mapStateToProps, { edit, save })(CustomFieldForm);

View file

@ -0,0 +1,92 @@
import React, { useRef, useState } from 'react';
import { Form, Input, confirm } from 'UI';
import styles from './customFieldForm.module.css';
import { useStore } from 'App/mstore';
import { useModal } from 'Components/Modal';
import { toast } from 'react-toastify';
import { Button } from 'antd';
import { Trash } from 'UI/Icons';
import { observer } from 'mobx-react-lite';
interface CustomFieldFormProps {
siteId: string;
}
const CustomFieldForm: React.FC<CustomFieldFormProps> = ({ siteId }) => {
console.log('siteId', siteId);
const focusElementRef = useRef<HTMLInputElement>(null);
const { customFieldStore: store } = useStore();
const field = store.instance;
const { hideModal } = useModal();
const [loading, setLoading] = useState(false);
const write = ({ target: { value, name } }: any) => store.edit({ [name]: value });
const exists = field?.exists();
const onDelete = async () => {
if (
await confirm({
header: 'Metadata',
confirmation: `Are you sure you want to remove?`
})
) {
store.remove(siteId, field?.index!).then(() => {
hideModal();
});
}
};
const onSave = (field: any) => {
setLoading(true);
store.save(siteId, field).then((response) => {
if (!response || !response.errors || response.errors.size === 0) {
hideModal();
toast.success('Metadata added successfully!');
} else {
toast.error(response.errors[0]);
}
}).finally(() => {
setLoading(false);
});
};
return (
<div className="bg-white h-screen overflow-y-auto">
<h3 className="p-5 text-2xl">{exists ? 'Update' : 'Add'} Metadata Field</h3>
<Form className={styles.wrapper}>
<Form.Field>
<label>{'Field Name'}</label>
<Input
ref={focusElementRef}
name="key"
value={field?.key}
onChange={write}
placeholder="Field Name"
maxLength={50}
/>
</Form.Field>
<div className="flex justify-between">
<div className="flex items-center">
<Button
onClick={() => onSave(field)}
disabled={!field?.validate()}
loading={loading}
type="primary"
className="float-left mr-2"
>
{exists ? 'Update' : 'Add'}
</Button>
<Button data-hidden={!exists} onClick={hideModal}>
{'Cancel'}
</Button>
</div>
<Button type="text" icon={<Trash />} data-hidden={!exists} onClick={onDelete}></Button>
</div>
</Form>
</div>
);
};
export default observer(CustomFieldForm);

View file

@ -14,124 +14,124 @@ import { useModal } from 'App/components/Modal';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
function CustomFields(props) { function CustomFields(props) {
const [currentSite, setCurrentSite] = React.useState(props.sites.get(0)); const [currentSite, setCurrentSite] = React.useState(props.sites.get(0));
const [deletingItem, setDeletingItem] = React.useState(null); const [deletingItem, setDeletingItem] = React.useState(null);
const { showModal, hideModal } = useModal(); const { showModal, hideModal } = useModal();
useEffect(() => { useEffect(() => {
const activeSite = props.sites.get(0); const activeSite = props.sites.get(0);
if (!activeSite) return; if (!activeSite) return;
props.fetchList(activeSite.id); props.fetchList(activeSite.id);
}, []); }, []);
const save = (field) => { const save = (field) => {
props.save(currentSite.id, field).then((response) => { props.save(currentSite.id, field).then((response) => {
if (!response || !response.errors || response.errors.size === 0) { if (!response || !response.errors || response.errors.size === 0) {
hideModal(); hideModal();
toast.success('Metadata added successfully!'); toast.success('Metadata added successfully!');
} else { } else {
toast.error(response.errors[0]); toast.error(response.errors[0]);
} }
});
};
const init = (field) => {
props.init(field);
showModal(<CustomFieldForm onClose={hideModal} onSave={save} onDelete={() => removeMetadata(field)} />);
};
const onChangeSelect = ({ value }) => {
const site = props.sites.find((s) => s.id === value.value);
setCurrentSite(site);
props.fetchList(site.id);
};
const removeMetadata = async (field) => {
if (
await confirm({
header: 'Metadata',
confirmation: `Are you sure you want to remove?`
})
) {
setDeletingItem(field.index);
props
.remove(currentSite.id, field.index)
.then(() => {
hideModal();
})
.finally(() => {
setDeletingItem(null);
}); });
}; }
};
const init = (field) => { const { fields, loading } = props;
props.init(field); return (
showModal(<CustomFieldForm onClose={hideModal} onSave={save} onDelete={() => removeMetadata(field)} />); <div className="bg-white rounded-lg shadow-sm border p-5 ">
}; <div className={cn(styles.tabHeader)}>
<h3 className={cn(styles.tabTitle, 'text-2xl')}>{'Metadata'}</h3>
const onChangeSelect = ({ value }) => { <div style={{ marginRight: '15px' }}>
const site = props.sites.find((s) => s.id === value.value); <SiteDropdown value={currentSite && currentSite.id} onChange={onChangeSelect} />
setCurrentSite(site);
props.fetchList(site.id);
};
const removeMetadata = async (field) => {
if (
await confirm({
header: 'Metadata',
confirmation: `Are you sure you want to remove?`,
})
) {
setDeletingItem(field.index);
props
.remove(currentSite.id, field.index)
.then(() => {
hideModal();
})
.finally(() => {
setDeletingItem(null);
});
}
};
const { fields, loading } = props;
return (
<div className="bg-white rounded-lg shadow-sm border p-5 ">
<div className={cn(styles.tabHeader)}>
<h3 className={cn(styles.tabTitle, 'text-2xl')}>{'Metadata'}</h3>
<div style={{ marginRight: '15px' }}>
<SiteDropdown value={currentSite && currentSite.id} onChange={onChangeSelect} />
</div>
<div className="ml-auto">
<Tooltip title="You've reached the limit of 10 metadata." disabled={fields.size < 10}>
<Button disabled={fields.size >= 10} variant="primary" onClick={() => init()}>Add Metadata</Button>
</Tooltip>
</div>
</div>
<div className="text-base text-disabled-text flex px-5 items-center my-3">
<Icon name="info-circle-fill" className="mr-2" size={16} />
See additonal user information in sessions.
<a href="https://docs.openreplay.com/installation/metadata" className="link ml-1" target="_blank">Learn more</a>
</div>
<Loader loading={loading}>
<NoContent
title={
<div className="flex flex-col items-center justify-center">
<AnimatedSVG name={ICONS.NO_METADATA} size={60} />
{/* <div className="mt-4" /> */}
<div className="text-center my-4">None added yet</div>
</div>
}
size="small"
show={fields.size === 0}
>
<div className={styles.list}>
{fields
.filter((i) => i.index)
.map((field) => (
<>
<ListItem
disabled={deletingItem && deletingItem === field.index}
key={field._key}
field={field}
onEdit={init}
// onDelete={ () => removeMetadata(field) }
/>
<Divider className="m-0" />
</>
))}
</div>
</NoContent>
</Loader>
</div> </div>
); <div className="ml-auto">
<Tooltip title="You've reached the limit of 10 metadata." disabled={fields.size < 10}>
<Button disabled={fields.size >= 10} variant="primary" onClick={() => init()}>Add Metadata</Button>
</Tooltip>
</div>
</div>
<div className="text-base text-disabled-text flex px-5 items-center my-3">
<Icon name="info-circle-fill" className="mr-2" size={16} />
See additonal user information in sessions.
<a href="https://docs.openreplay.com/installation/metadata" className="link ml-1" target="_blank">Learn more</a>
</div>
<Loader loading={loading}>
<NoContent
title={
<div className="flex flex-col items-center justify-center">
<AnimatedSVG name={ICONS.NO_METADATA} size={60} />
{/* <div className="mt-4" /> */}
<div className="text-center my-4">None added yet</div>
</div>
}
size="small"
show={fields.size === 0}
>
<div className={styles.list}>
{fields
.filter((i) => i.index)
.map((field) => (
<>
<ListItem
disabled={deletingItem && deletingItem === field.index}
key={field._key}
field={field}
onEdit={init}
// onDelete={ () => removeMetadata(field) }
/>
<Divider className="m-0" />
</>
))}
</div>
</NoContent>
</Loader>
</div>
);
} }
export default connect( export default connect(
(state) => ({ (state) => ({
fields: state.getIn(['customFields', 'list']).sortBy((i) => i.index), fields: state.getIn(['customFields', 'list']).sortBy((i) => i.index),
field: state.getIn(['customFields', 'instance']), field: state.getIn(['customFields', 'instance']),
loading: state.getIn(['customFields', 'fetchRequest', 'loading']), loading: state.getIn(['customFields', 'fetchRequest', 'loading']),
sites: state.getIn(['site', 'list']), sites: state.getIn(['site', 'list']),
errors: state.getIn(['customFields', 'saveRequest', 'errors']), errors: state.getIn(['customFields', 'saveRequest', 'errors'])
}), }),
{ {
init, init,
fetchList, fetchList,
save, save,
remove, remove
} }
)(withPageTitle('Metadata - OpenReplay Preferences')(CustomFields)); )(withPageTitle('Metadata - OpenReplay Preferences')(CustomFields));

View file

@ -0,0 +1,108 @@
import React, { useEffect, useState } from 'react';
import cn from 'classnames';
import withPageTitle from 'HOCs/withPageTitle';
import { Button, Loader, NoContent, Icon, Tooltip, Divider } from 'UI';
import SiteDropdown from 'Shared/SiteDropdown';
import styles from './customFields.module.css';
import CustomFieldForm from './CustomFieldForm';
import ListItem from './ListItem';
import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG';
import { useModal } from 'App/components/Modal';
import { useStore } from 'App/mstore';
import { observer } from 'mobx-react-lite';
const CustomFields = () => {
const { customFieldStore: store, projectsStore } = useStore();
const sites = projectsStore.list;
const [currentSite, setCurrentSite] = useState(sites[0]);
const [deletingItem, setDeletingItem] = useState<number | null>(null);
const { showModal, hideModal } = useModal();
const fields = store.list;
const [loading, setLoading] = useState(false);
useEffect(() => {
const activeSite = sites[0];
if (!activeSite) return;
setCurrentSite(activeSite);
setLoading(true);
store.fetchList(activeSite.id).finally(() => {
setLoading(false);
});
}, [sites]);
const handleInit = (field?: any) => {
console.log('field', field);
store.init(field);
showModal(<CustomFieldForm siteId={currentSite.id} />, {
title: field ? 'Edit Metadata' : 'Add Metadata', right: true
});
};
const onChangeSelect = ({ value }: { value: { value: number } }) => {
const site = sites.find((s: any) => s.id === value.value);
setCurrentSite(site);
setLoading(true);
store.fetchList(site.id).finally(() => {
setLoading(false);
});
};
return (
<div className="bg-white rounded-lg shadow-sm border p-5">
<div className={cn(styles.tabHeader)}>
<h3 className={cn(styles.tabTitle, 'text-2xl')}>{'Metadata'}</h3>
<div style={{ marginRight: '15px' }}>
<SiteDropdown value={currentSite && currentSite.id} onChange={onChangeSelect} />
</div>
<div className="ml-auto">
<Tooltip title="You've reached the limit of 10 metadata." disabled={fields.length < 10}>
<Button disabled={fields.length >= 10} variant="primary" onClick={() => handleInit()}>
Add Metadata
</Button>
</Tooltip>
</div>
</div>
<div className="text-base text-disabled-text flex px-5 items-center my-3">
<Icon name="info-circle-fill" className="mr-2" size={16} />
See additional user information in sessions.
<a href="https://docs.openreplay.com/installation/metadata" className="link ml-1" target="_blank">
Learn more
</a>
</div>
<Loader loading={loading}>
<NoContent
title={
<div className="flex flex-col items-center justify-center">
<AnimatedSVG name={ICONS.NO_METADATA} size={60} />
<div className="text-center my-4">None added yet</div>
</div>
}
size="small"
show={fields.length === 0}
>
<div className={styles.list}>
{fields
.filter((i: any) => i.index)
.map((field: any) => (
<>
<ListItem
disabled={deletingItem !== null && deletingItem === field.index}
key={field._key}
field={field}
onEdit={handleInit}
/>
<Divider className="m-0" />
</>
))}
</div>
</NoContent>
</Loader>
</div>
);
};
export default withPageTitle('Metadata - OpenReplay Preferences')(observer(CustomFields));

View file

@ -4,23 +4,23 @@ import { Button } from 'UI';
import styles from './listItem.module.css'; import styles from './listItem.module.css';
const ListItem = ({ field, onEdit, disabled }) => { const ListItem = ({ field, onEdit, disabled }) => {
return ( return (
<div <div
className={cn( className={cn(
'group hover:bg-active-blue flex items-center justify-between py-3 px-5 cursor-pointer', 'group hover:bg-active-blue flex items-center justify-between py-3 px-5 cursor-pointer',
field.index === 0 ? styles.preDefined : '', field.index === 0 ? styles.preDefined : '',
{ {
[styles.disabled]: disabled, [styles.disabled]: disabled
} }
)} )}
onClick={() => field.index != 0 && onEdit(field)} onClick={() => field.index !== 0 && onEdit(field)}
> >
<span>{field.key}</span> <span>{field.key}</span>
<div className="invisible group-hover:visible" data-hidden={field.index === 0}> <div className="invisible group-hover:visible" data-hidden={field.index === 0}>
<Button variant="text-primary" icon="pencil" /> <Button variant="text-primary" icon="pencil" />
</div> </div>
</div> </div>
); );
}; };
export default ListItem; export default ListItem;

View file

@ -1,10 +1,11 @@
import { useStore } from "App/mstore";
import React from 'react'; import React from 'react';
import DocLink from 'Shared/DocLink/DocLink'; import DocLink from 'Shared/DocLink/DocLink';
import AssistScript from './AssistScript'; import AssistScript from './AssistScript';
import AssistNpm from './AssistNpm'; import AssistNpm from './AssistNpm';
import { Tabs, CodeBlock } from 'UI'; import { Tabs, CodeBlock } from 'UI';
import { useState } from 'react'; import { useState } from 'react';
import { connect } from 'react-redux'; import { observer } from 'mobx-react-lite'
const NPM = 'NPM'; const NPM = 'NPM';
const SCRIPT = 'SCRIPT'; const SCRIPT = 'SCRIPT';
@ -13,8 +14,11 @@ const TABS = [
{ key: NPM, text: NPM }, { key: NPM, text: NPM },
]; ];
const AssistDoc = (props) => { const AssistDoc = () => {
const { projectKey } = props; const { integrationsStore, projectsStore } = useStore();
const sites = projectsStore.list;
const siteId = integrationsStore.integrations.siteId
const projectKey = siteId ? sites.find((site) => site.id === siteId)?.projectKey : sites[0]?.projectKey
const [activeTab, setActiveTab] = useState(SCRIPT); const [activeTab, setActiveTab] = useState(SCRIPT);
const renderActiveTab = () => { const renderActiveTab = () => {
@ -53,10 +57,4 @@ const AssistDoc = (props) => {
AssistDoc.displayName = 'AssistDoc'; AssistDoc.displayName = 'AssistDoc';
export default connect((state) => { export default observer(AssistDoc);
const siteId = state.getIn(['integrations', 'siteId']);
const sites = state.getIn(['site', 'list']);
return {
projectKey: sites.find((site) => site.get('id') === siteId).get('projectKey'),
};
})(AssistDoc);

View file

@ -1,17 +1,16 @@
import React from 'react'; import React from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { tokenRE } from 'Types/integrations/bugsnagConfig'; import { tokenRE } from 'Types/integrations/bugsnagConfig';
import { edit } from 'Duck/integrations/actions';
import Select from 'Shared/Select'; import Select from 'Shared/Select';
import { withRequest } from 'HOCs'; import { withRequest } from 'HOCs';
@connect(state => ({ @connect(state => ({
token: state.getIn([ 'bugsnag', 'instance', 'authorizationToken' ]) token: state.getIn([ 'bugsnag', 'instance', 'authorizationToken' ])
}), { edit }) }))
@withRequest({ @withRequest({
dataName: "projects", dataName: "projects",
initialData: [], initialData: [],
dataWrapper: (data = [], prevData) => { dataWrapper: (data = []) => {
if (!Array.isArray(data)) throw new Error('Wrong responce format.'); if (!Array.isArray(data)) throw new Error('Wrong responce format.');
const withOrgName = data.length > 1; const withOrgName = data.length > 1;
return data.reduce((accum, { name: orgName, projects }) => { return data.reduce((accum, { name: orgName, projects }) => {
@ -35,15 +34,7 @@ export default class ProjectListDropdown extends React.PureComponent {
if (!tokenRE.test(token)) return; if (!tokenRE.test(token)) return;
this.props.fetchProjectList({ this.props.fetchProjectList({
authorizationToken: token, authorizationToken: token,
}).then(() => { })
const { value, projects } = this.props;
const values = projects.map(p => p.id);
if (!values.includes(value) && values.length > 0) {
this.props.edit("bugsnag", {
projectId: values[0],
});
}
});
} }
componentDidUpdate(prevProps) { componentDidUpdate(prevProps) {
if (prevProps.token !== this.props.token) { if (prevProps.token !== this.props.token) {

View file

@ -1,41 +1,53 @@
import {
ACCESS_KEY_ID_LENGTH,
SECRET_ACCESS_KEY_LENGTH,
} from 'Types/integrations/cloudwatchConfig';
import React from 'react'; import React from 'react';
import { ACCESS_KEY_ID_LENGTH, SECRET_ACCESS_KEY_LENGTH } from 'Types/integrations/cloudwatchConfig';
import IntegrationModalCard from 'Components/Client/Integrations/IntegrationModalCard';
import DocLink from 'Shared/DocLink/DocLink';
import IntegrationForm from '../IntegrationForm'; import IntegrationForm from '../IntegrationForm';
import LogGroupDropdown from './LogGroupDropdown'; import LogGroupDropdown from './LogGroupDropdown';
import RegionDropdown from './RegionDropdown'; import RegionDropdown from './RegionDropdown';
import DocLink from 'Shared/DocLink/DocLink';
import IntegrationModalCard from 'Components/Client/Integrations/IntegrationModalCard';
const CloudwatchForm = (props) => ( const CloudwatchForm = (props) => (
<div className='bg-white h-screen overflow-y-auto' style={{ width: '350px' }}> <div className="bg-white h-screen overflow-y-auto" style={{ width: '350px' }}>
<IntegrationModalCard title='Cloud Watch' icon='integrations/aws' <IntegrationModalCard
description='Integrate CloudWatch to see backend logs and errors alongside session replay.' /> title="Cloud Watch"
<div className='p-5 border-b mb-4'> icon="integrations/aws"
<div className='font-medium mb-1'>How it works?</div> description="Integrate CloudWatch to see backend logs and errors alongside session replay."
/>
<div className="p-5 border-b mb-4">
<div className="font-medium mb-1">How it works?</div>
<ol className="list-decimal list-inside"> <ol className="list-decimal list-inside">
<li>Create a Service Account</li> <li>Create a Service Account</li>
<li>Enter the details below</li> <li>Enter the details below</li>
<li>Propagate openReplaySessionToken</li> <li>Propagate openReplaySessionToken</li>
</ol> </ol>
<DocLink className='mt-4' label='Integrate CloudWatch' <DocLink
url='https://docs.openreplay.com/integrations/cloudwatch' /> className="mt-4"
label="Integrate CloudWatch"
url="https://docs.openreplay.com/integrations/cloudwatch"
/>
</div> </div>
<IntegrationForm <IntegrationForm
{...props} {...props}
name='cloudwatch' name="cloudwatch"
formFields={[ formFields={[
{ {
key: 'awsAccessKeyId', key: 'awsAccessKeyId',
label: 'AWS Access Key ID' label: 'AWS Access Key ID',
}, },
{ {
key: 'awsSecretAccessKey', key: 'awsSecretAccessKey',
label: 'AWS Secret Access Key' label: 'AWS Secret Access Key',
}, },
{ {
key: 'region', key: 'region',
label: 'Region', label: 'Region',
component: RegionDropdown component: RegionDropdown,
}, },
{ {
key: 'logGroupName', key: 'logGroupName',
@ -44,8 +56,8 @@ const CloudwatchForm = (props) => (
checkIfDisplayed: (config) => checkIfDisplayed: (config) =>
config.awsSecretAccessKey.length === SECRET_ACCESS_KEY_LENGTH && config.awsSecretAccessKey.length === SECRET_ACCESS_KEY_LENGTH &&
config.region !== '' && config.region !== '' &&
config.awsAccessKeyId.length === ACCESS_KEY_ID_LENGTH config.awsAccessKeyId.length === ACCESS_KEY_ID_LENGTH,
} },
]} ]}
/> />
</div> </div>

View file

@ -1,77 +1,93 @@
import React from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import { connect } from 'react-redux';
import { ACCESS_KEY_ID_LENGTH, SECRET_ACCESS_KEY_LENGTH } from 'Types/integrations/cloudwatchConfig'; import { ACCESS_KEY_ID_LENGTH, SECRET_ACCESS_KEY_LENGTH } from 'Types/integrations/cloudwatchConfig';
import { edit } from 'Duck/integrations/actions'; import { observer } from 'mobx-react-lite';
import { useStore } from 'App/mstore';
import Select from 'Shared/Select'; import Select from 'Shared/Select';
import { withRequest } from 'HOCs'; import { integrationsService } from "App/services";
@connect(state => ({ const LogGroupDropdown = (props) => {
config: state.getIn([ 'cloudwatch', 'instance' ]) const { integrationsStore } = useStore();
}), { edit }) const config = integrationsStore.cloudwatch.instance;
@withRequest({ const edit = integrationsStore.cloudwatch.edit;
dataName: "values", const {
initialData: [], value,
resetBeforeRequest: true, name,
requestName: "fetchLogGroups", placeholder,
endpoint: '/integrations/cloudwatch/list_groups', onChange,
method: 'POST', } = props;
})
export default class LogGroupDropdown extends React.PureComponent { const [values, setValues] = useState([]);
constructor(props) { const [loading, setLoading] = useState(false);
super(props); const [error, setError] = useState(false);
this.fetchLogGroups()
} const { region, awsSecretAccessKey, awsAccessKeyId } = config;
fetchLogGroups() {
const { config } = this.props; const fetchLogGroups = useCallback(() => {
if (config.region === "" || if (
config.awsSecretAccessKey.length !== SECRET_ACCESS_KEY_LENGTH || region === '' ||
config.awsAccessKeyId.length !== ACCESS_KEY_ID_LENGTH awsSecretAccessKey.length !== SECRET_ACCESS_KEY_LENGTH ||
) return; awsAccessKeyId.length !== ACCESS_KEY_ID_LENGTH
this.props.fetchLogGroups({ ) {
region: config.region, return;
awsSecretAccessKey: config.awsSecretAccessKey,
awsAccessKeyId: config.awsAccessKeyId,
}).then(() => {
const { value, values, name } = this.props;
if (!values.includes(value) && values.length > 0) {
this.props.edit("cloudwatch", {
[ name ]: values[0],
});
}
});
}
componentDidUpdate(prevProps) {
const { config } = this.props;
if (prevProps.config.region !== config.region ||
prevProps.config.awsSecretAccessKey !== config.awsSecretAccessKey ||
prevProps.config.awsAccessKeyId !== config.awsAccessKeyId) {
this.fetchLogGroups();
} }
}
onChange = (target) => { setLoading(true);
if (typeof this.props.onChange === 'function') { setError(false);
this.props.onChange({ target }); setValues([]); // Reset values before request
const params = {
region: region,
awsSecretAccessKey: awsSecretAccessKey,
awsAccessKeyId: awsAccessKeyId,
};
integrationsService.client
.post('/integrations/cloudwatch/list_groups', params)
.then((response) => response.json())
.then(({ errors, data }) => {
if (errors) {
setError(true);
setLoading(false);
return;
}
setValues(data);
setLoading(false);
// If current value is not in the new values list, update it
if (!data.includes(value) && data.length > 0) {
edit({
[name]: data[0],
});
}
})
.catch(() => {
setError(true);
setLoading(false);
});
}, [region, awsSecretAccessKey, awsAccessKeyId, value, name, edit]);
// Fetch log groups on mount and when config changes
useEffect(() => {
fetchLogGroups();
}, [fetchLogGroups]);
const handleChange = (target) => {
if (typeof onChange === 'function') {
onChange({ target });
} }
} };
render() {
const { const options = values.map((g) => ({ text: g, value: g }));
values, return (
name, <Select
value, options={options}
placeholder, name={name}
loading, value={options.find((o) => o.value === value)}
} = this.props; placeholder={placeholder}
const options = values.map(g => ({ text: g, value: g })); onChange={handleChange}
return ( loading={loading}
<Select />
// selection );
options={ options } };
name={ name }
value={ options.find(o => o.value === value) } export default observer(LogGroupDropdown);
placeholder={ placeholder }
onChange={ this.onChange }
loading={ loading }
/>
);
}
}

View file

@ -1,97 +1,64 @@
import React from 'react'; import React from 'react';
import { connect } from 'react-redux';
import IntegrationForm from './IntegrationForm';
import { withRequest } from 'HOCs';
import { edit } from 'Duck/integrations/actions';
import DocLink from 'Shared/DocLink/DocLink';
import IntegrationModalCard from 'Components/Client/Integrations/IntegrationModalCard'; import IntegrationModalCard from 'Components/Client/Integrations/IntegrationModalCard';
@connect( import DocLink from 'Shared/DocLink/DocLink';
(state) => ({
config: state.getIn(['elasticsearch', 'instance'])
}),
{ edit }
)
@withRequest({
dataName: 'isValid',
initialData: false,
dataWrapper: (data) => data.state,
requestName: 'validateConfig',
endpoint: '/integrations/elasticsearch/test',
method: 'POST'
})
export default class ElasticsearchForm extends React.PureComponent {
componentWillReceiveProps(newProps) {
const {
config: { host, port, apiKeyId, apiKey }
} = this.props;
const { loading, config } = newProps;
const valuesChanged = host !== config.host || port !== config.port || apiKeyId !== config.apiKeyId || apiKey !== config.apiKey;
if (!loading && valuesChanged && newProps.config.validateKeys() && newProps) {
this.validateConfig(newProps);
}
}
validateConfig = (newProps) => { import IntegrationForm from './IntegrationForm';
const { config } = newProps;
this.props
.validateConfig({
host: config.host,
port: config.port,
apiKeyId: config.apiKeyId,
apiKey: config.apiKey
})
.then((res) => {
const { isValid } = this.props;
this.props.edit('elasticsearch', { isValid: isValid });
});
};
render() { const ElasticsearchForm = (props) => {
const props = this.props; return (
return ( <div
<div className='bg-white h-screen overflow-y-auto' style={{ width: '350px' }}> className="bg-white h-screen overflow-y-auto"
<IntegrationModalCard title='Elasticsearch' icon='integrations/elasticsearch' style={{ width: '350px' }}
description='Integrate Elasticsearch with session replays to seamlessly observe backend errors.' /> >
<IntegrationModalCard
title="Elasticsearch"
icon="integrations/elasticsearch"
description="Integrate Elasticsearch with session replays to seamlessly observe backend errors."
/>
<div className='p-5 border-b mb-4'> <div className="p-5 border-b mb-4">
<div className='font-medium mb-1'>How it works?</div> <div className="font-medium mb-1">How it works?</div>
<ol className="list-decimal list-inside"> <ol className="list-decimal list-inside">
<li>Create a new Elastic API key</li> <li>Create a new Elastic API key</li>
<li>Enter the API key below</li> <li>Enter the API key below</li>
<li>Propagate openReplaySessionToken</li> <li>Propagate openReplaySessionToken</li>
</ol> </ol>
<DocLink className='mt-4' label='Integrate Elasticsearch' <DocLink
url='https://docs.openreplay.com/integrations/elastic' /> className="mt-4"
</div> label="Integrate Elasticsearch"
<IntegrationForm url="https://docs.openreplay.com/integrations/elastic"
{...props}
name='elasticsearch'
formFields={[
{
key: 'host',
label: 'Host'
},
{
key: 'apiKeyId',
label: 'API Key ID'
},
{
key: 'apiKey',
label: 'API Key'
},
{
key: 'indexes',
label: 'Indexes'
},
{
key: 'port',
label: 'Port',
type: 'number'
}
]}
/> />
</div> </div>
); <IntegrationForm
} {...props}
} name="elasticsearch"
formFields={[
{
key: 'host',
label: 'Host',
},
{
key: 'apiKeyId',
label: 'API Key ID',
},
{
key: 'apiKey',
label: 'API Key',
},
{
key: 'indexes',
label: 'Indexes',
},
{
key: 'port',
label: 'Port',
type: 'number',
},
]}
/>
</div>
);
};
export default ElasticsearchForm;

View file

@ -1,11 +1,15 @@
import { useStore } from "App/mstore";
import React from 'react'; import React from 'react';
import { CodeBlock } from "UI"; import { CodeBlock } from "UI";
import DocLink from 'Shared/DocLink/DocLink'; import DocLink from 'Shared/DocLink/DocLink';
import ToggleContent from 'Shared/ToggleContent'; import ToggleContent from 'Shared/ToggleContent';
import { connect } from 'react-redux'; import { observer } from 'mobx-react-lite'
const GraphQLDoc = (props) => { const GraphQLDoc = () => {
const { projectKey } = props; const { integrationsStore, projectsStore } = useStore();
const sites = projectsStore.list;
const siteId = integrationsStore.integrations.siteId
const projectKey = siteId ? sites.find((site) => site.id === siteId)?.projectKey : sites[0]?.projectKey
const usage = `import OpenReplay from '@openreplay/tracker'; const usage = `import OpenReplay from '@openreplay/tracker';
import trackerGraphQL from '@openreplay/tracker-graphql'; import trackerGraphQL from '@openreplay/tracker-graphql';
//... //...
@ -70,10 +74,4 @@ export const recordGraphQL = tracker.use(trackerGraphQL());`
GraphQLDoc.displayName = 'GraphQLDoc'; GraphQLDoc.displayName = 'GraphQLDoc';
export default connect((state) => { export default observer(GraphQLDoc);
const siteId = state.getIn(['integrations', 'siteId']);
const sites = state.getIn(['site', 'list']);
return {
projectKey: sites.find((site) => site.get('id') === siteId).get('projectKey'),
};
})(GraphQLDoc);

View file

@ -1,142 +0,0 @@
import React from 'react';
import { connect } from 'react-redux';
import { Input, Form, Button, Checkbox, Loader } from 'UI';
import { save, init, edit, remove } from 'Duck/integrations/actions';
import { fetchIntegrationList } from 'Duck/integrations/integrations';
@connect(
(state, { name, customPath }) => ({
sites: state.getIn(['site', 'list']),
initialSiteId: state.getIn(['site', 'siteId']),
list: state.getIn([name, 'list']),
config: state.getIn([name, 'instance']),
loading: state.getIn([name, 'fetchRequest', 'loading']),
saving: state.getIn([customPath || name, 'saveRequest', 'loading']),
removing: state.getIn([name, 'removeRequest', 'loading']),
siteId: state.getIn(['integrations', 'siteId']),
}),
{
save,
init,
edit,
remove,
// fetchList,
fetchIntegrationList,
}
)
export default class IntegrationForm extends React.PureComponent {
constructor(props) {
super(props);
}
fetchList = () => {
const { siteId, initialSiteId } = this.props;
if (!siteId) {
this.props.fetchIntegrationList(initialSiteId);
} else {
this.props.fetchIntegrationList(siteId);
}
}
write = ({ target: { value, name: key, type, checked } }) => {
if (type === 'checkbox') this.props.edit(this.props.name, { [key]: checked });
else this.props.edit(this.props.name, { [key]: value });
};
// onChangeSelect = ({ value }) => {
// const { sites, list, name } = this.props;
// const site = sites.find((s) => s.id === value.value);
// this.setState({ currentSiteId: site.id });
// this.init(value.value);
// };
// init = (siteId) => {
// const { list, name } = this.props;
// const config = parseInt(siteId) > 0 ? list.find((s) => s.projectId === siteId) : undefined;
// this.props.init(name, config ? config : list.first());
// };
save = () => {
const { config, name, customPath, ignoreProject } = this.props;
const isExists = config.exists();
// const { currentSiteId } = this.state;
this.props.save(customPath || name, !ignoreProject ? this.props.siteId : null, config).then(() => {
// this.props.fetchList(name);
this.fetchList();
this.props.onClose();
if (isExists) return;
});
};
remove = () => {
const { name, config, ignoreProject } = this.props;
this.props.remove(name, !ignoreProject ? config.projectId : null).then(() => {
this.props.onClose();
this.fetchList();
});
};
render() {
const { config, saving, removing, formFields, name, loading, integrated } = this.props;
return (
<Loader loading={loading}>
<div className="ph-20">
<Form>
{formFields.map(
({
key,
label,
placeholder = label,
component: Component = 'input',
type = 'text',
checkIfDisplayed,
autoFocus = false,
}) =>
(typeof checkIfDisplayed !== 'function' || checkIfDisplayed(config)) &&
(type === 'checkbox' ? (
<Form.Field key={key}>
<Checkbox
label={label}
name={key}
value={config[key]}
onChange={this.write}
placeholder={placeholder}
type={Component === 'input' ? type : null}
/>
</Form.Field>
) : (
<Form.Field key={key}>
<label>{label}</label>
<Input
name={key}
value={config[key]}
onChange={this.write}
placeholder={placeholder}
type={Component === 'input' ? type : null}
autoFocus={autoFocus}
/>
</Form.Field>
))
)}
<Button
onClick={this.save}
disabled={!config.validate()}
loading={saving || loading}
variant="primary"
className="float-left mr-2"
>
{config.exists() ? 'Update' : 'Add'}
</Button>
{integrated && (
<Button loading={removing} onClick={this.remove}>
{'Delete'}
</Button>
)}
</Form>
</div>
</Loader>
);
}
}

View file

@ -0,0 +1,109 @@
import { observer } from 'mobx-react-lite';
import React from 'react';
import { connect } from 'react-redux';
import { useStore } from 'App/mstore';
import { namedStore } from 'App/mstore/integrationsStore';
import { Button, Checkbox, Form, Input, Loader } from 'UI';
function IntegrationForm(props: any) {
const { formFields, name, integrated } = props;
const { integrationsStore, projectsStore } = useStore();
const sites = projectsStore.list;
const initialSiteId = projectsStore.siteId;
const integrationStore = integrationsStore[name as unknown as namedStore];
const config = integrationStore.instance;
const loading = integrationStore.loading;
const onSave = integrationStore.saveIntegration;
const onRemove = integrationStore.deleteIntegration;
const edit = integrationStore.edit;
const fetchIntegrationList = integrationsStore.integrations.fetchIntegrations;
const fetchList = () => {
void fetchIntegrationList(initialSiteId);
};
const write = ({ target: { value, name: key, type, checked } }) => {
if (type === 'checkbox') edit({ [key]: checked });
else edit({ [key]: value });
};
const save = () => {
const { name, customPath } = props;
onSave(customPath || name).then(() => {
fetchList();
props.onClose();
});
};
const remove = () => {
onRemove().then(() => {
props.onClose();
fetchList();
});
};
return (
<Loader loading={loading}>
<div className="ph-20">
<Form>
{formFields.map(
({
key,
label,
placeholder = label,
component: Component = 'input',
type = 'text',
checkIfDisplayed,
autoFocus = false,
}) =>
(typeof checkIfDisplayed !== 'function' ||
checkIfDisplayed(config)) &&
(type === 'checkbox' ? (
<Form.Field key={key}>
<Checkbox
label={label}
name={key}
value={config[key]}
onChange={write}
placeholder={placeholder}
type={Component === 'input' ? type : null}
/>
</Form.Field>
) : (
<Form.Field key={key}>
<label>{label}</label>
<Input
name={key}
value={config[key]}
onChange={write}
placeholder={placeholder}
type={Component === 'input' ? type : null}
autoFocus={autoFocus}
/>
</Form.Field>
))
)}
<Button
onClick={save}
disabled={!config?.validate()}
loading={loading}
variant="primary"
className="float-left mr-2"
>
{config?.exists() ? 'Update' : 'Add'}
</Button>
{integrated && (
<Button loading={loading} onClick={remove}>
{'Delete'}
</Button>
)}
</Form>
</div>
</Loader>
);
}
export default observer(IntegrationForm);

View file

@ -1,88 +1,95 @@
import withPageTitle from 'HOCs/withPageTitle';
import cn from 'classnames';
import { observer } from 'mobx-react-lite';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { useModal } from 'App/components/Modal'; import { useModal } from 'App/components/Modal';
import cn from 'classnames'; import { useStore } from 'App/mstore';
import IntegrationFilters from 'Components/Client/Integrations/IntegrationFilters';
import { PageTitle } from 'UI';
import { fetch, init } from 'Duck/integrations/actions';
import { fetchIntegrationList, setSiteId } from 'Duck/integrations/integrations';
import SiteDropdown from 'Shared/SiteDropdown';
import ReduxDoc from './ReduxDoc';
import VueDoc from './VueDoc';
import GraphQLDoc from './GraphQLDoc';
import NgRxDoc from './NgRxDoc';
import MobxDoc from './MobxDoc';
import ProfilerDoc from './ProfilerDoc';
import AssistDoc from './AssistDoc';
import PiniaDoc from './PiniaDoc';
import ZustandDoc from './ZustandDoc';
import MSTeams from './Teams';
import DocCard from 'Shared/DocCard/DocCard'; import DocCard from 'Shared/DocCard/DocCard';
import { PageTitle, Tooltip } from 'UI';
import withPageTitle from 'HOCs/withPageTitle';
import AssistDoc from './AssistDoc';
import BugsnagForm from './BugsnagForm'; import BugsnagForm from './BugsnagForm';
import CloudwatchForm from './CloudwatchForm'; import CloudwatchForm from './CloudwatchForm';
import DatadogForm from './DatadogForm'; import DatadogForm from './DatadogForm';
import ElasticsearchForm from './ElasticsearchForm'; import ElasticsearchForm from './ElasticsearchForm';
import GithubForm from './GithubForm'; import GithubForm from './GithubForm';
import GraphQLDoc from './GraphQLDoc';
import IntegrationItem from './IntegrationItem'; import IntegrationItem from './IntegrationItem';
import JiraForm from './JiraForm'; import JiraForm from './JiraForm';
import MobxDoc from './MobxDoc';
import NewrelicForm from './NewrelicForm'; import NewrelicForm from './NewrelicForm';
import NgRxDoc from './NgRxDoc';
import PiniaDoc from './PiniaDoc';
import ProfilerDoc from './ProfilerDoc';
import ReduxDoc from './ReduxDoc';
import RollbarForm from './RollbarForm'; import RollbarForm from './RollbarForm';
import SentryForm from './SentryForm'; import SentryForm from './SentryForm';
import SlackForm from './SlackForm'; import SlackForm from './SlackForm';
import StackdriverForm from './StackdriverForm'; import StackdriverForm from './StackdriverForm';
import SumoLogicForm from './SumoLogicForm'; import SumoLogicForm from './SumoLogicForm';
import IntegrationFilters from 'Components/Client/Integrations/IntegrationFilters'; import MSTeams from './Teams';
import VueDoc from './VueDoc';
import ZustandDoc from './ZustandDoc';
interface Props { interface Props {
fetch: (name: string, siteId: string) => void;
init: () => void;
fetchIntegrationList: (siteId: any) => void;
integratedList: any;
initialSiteId: string;
setSiteId: (siteId: string) => void;
siteId: string; siteId: string;
hideHeader?: boolean; hideHeader?: boolean;
loading?: boolean;
} }
function Integrations(props: Props) { function Integrations(props: Props) {
const { initialSiteId, hideHeader = false, loading = false } = props; const { integrationsStore, projectsStore } = useStore();
const siteId = projectsStore.siteId;
const fetchIntegrationList = integrationsStore.integrations.fetchIntegrations;
const storeIntegratedList = integrationsStore.integrations.list;
const { hideHeader = false } = props;
const { showModal } = useModal(); const { showModal } = useModal();
const [integratedList, setIntegratedList] = useState<string[]>([]); const [integratedList, setIntegratedList] = useState<string[]>([]);
const [activeFilter, setActiveFilter] = useState<string>('all'); const [activeFilter, setActiveFilter] = useState<string>('all');
useEffect(() => { useEffect(() => {
const list = props.integratedList const list = storeIntegratedList
.filter((item: any) => item.integrated) .filter((item: any) => item.integrated)
.map((item: any) => item.name); .map((item: any) => item.name);
setIntegratedList(list); setIntegratedList(list);
}, [props.integratedList]); }, [storeIntegratedList]);
useEffect(() => { useEffect(() => {
props.fetchIntegrationList(initialSiteId); void fetchIntegrationList(siteId);
props.setSiteId(initialSiteId); }, [siteId]);
}, []);
const onClick = (integration: any, width: number) => { const onClick = (integration: any, width: number) => {
if (integration.slug && integration.slug !== 'slack' && integration.slug !== 'msteams') { if (
props.fetch(integration.slug, props.siteId); integration.slug &&
integration.slug !== 'slack' &&
integration.slug !== 'msteams'
) {
const intName = integration.slug as
| 'sentry'
| 'bugsnag'
| 'rollbar'
| 'elasticsearch'
| 'datadog'
| 'sumologic'
| 'stackdriver'
| 'cloudwatch'
| 'newrelic';
if (integrationsStore[intName]) {
void integrationsStore[intName].fetchIntegration(siteId);
}
} }
showModal( showModal(
React.cloneElement(integration.component, { React.cloneElement(integration.component, {
integrated: integratedList.includes(integration.slug) integrated: integratedList.includes(integration.slug),
}), }),
{ right: true, width } { right: true, width }
); );
}; };
const onChangeSelect = ({ value }: any) => {
props.setSiteId(value.value);
props.fetchIntegrationList(value.value);
};
const onChange = (key: string) => { const onChange = (key: string) => {
setActiveFilter(key); setActiveFilter(key);
}; };
@ -99,83 +106,92 @@ function Integrations(props: Props) {
key: cat.key, key: cat.key,
title: cat.title, title: cat.title,
label: cat.title, label: cat.title,
icon: cat.icon icon: cat.icon,
})) }));
const allIntegrations = filteredIntegrations.flatMap(cat => cat.integrations);
const allIntegrations = filteredIntegrations.flatMap(
(cat) => cat.integrations
);
console.log(
allIntegrations,
integratedList
)
return ( return (
<> <>
<div className='bg-white rounded-lg border shadow-sm p-5 mb-4'> <div className="bg-white rounded-lg border shadow-sm p-5 mb-4">
{!hideHeader && <PageTitle title={<div>Integrations</div>} />} {!hideHeader && <PageTitle title={<div>Integrations</div>} />}
<IntegrationFilters onChange={onChange} activeItem={activeFilter} filters={filters} /> <IntegrationFilters
onChange={onChange}
activeItem={activeFilter}
filters={filters}
/>
</div> </div>
<div className='mb-4' /> <div className="mb-4" />
<div className={cn(` <div
className={cn(`
mt-4 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3 mt-4 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3
`)}> `)}
>
{allIntegrations.map((integration: any) => ( {allIntegrations.map((integration: any) => (
<IntegrationItem <IntegrationItem
integrated={integratedList.includes(integration.slug)} integrated={integratedList.includes(integration.slug)}
integration={integration} integration={integration}
onClick={() => onClick={() =>
onClick(integration, filteredIntegrations.find(cat => cat.integrations.includes(integration)).title === 'Plugins' ? 500 : 350) onClick(
integration,
filteredIntegrations.find((cat) =>
cat.integrations.includes(integration)
).title === 'Plugins'
? 500
: 350
)
} }
hide={ hide={
(integration.slug === 'github' && (integration.slug === 'github' &&
integratedList.includes('jira')) || integratedList.includes('jira')) ||
(integration.slug === 'jira' && (integration.slug === 'jira' && integratedList.includes('github'))
integratedList.includes('github'))
} }
/> />
))} ))}
</div> </div>
</> </>
); );
} }
export default connect( export default withPageTitle('Integrations - OpenReplay Preferences')(observer(Integrations))
(state: any) => ({
initialSiteId: state.getIn(['site', 'siteId']),
integratedList: state.getIn(['integrations', 'list']) || [],
loading: state.getIn(['integrations', 'fetchRequest', 'loading']),
siteId: state.getIn(['integrations', 'siteId'])
}),
{ fetch, init, fetchIntegrationList, setSiteId }
)(withPageTitle('Integrations - OpenReplay Preferences')(Integrations));
const integrations = [ const integrations = [
{ {
title: 'Issue Reporting', title: 'Issue Reporting',
key: 'issue-reporting', key: 'issue-reporting',
description: 'Seamlessly report issues or share issues with your team right from OpenReplay.', description:
'Seamlessly report issues or share issues with your team right from OpenReplay.',
isProject: false, isProject: false,
icon: 'exclamation-triangle', icon: 'exclamation-triangle',
integrations: [ integrations: [
{ {
title: 'Jira', title: 'Jira',
subtitle: 'Integrate Jira with OpenReplay to enable the creation of a new ticket directly from a session.', subtitle:
'Integrate Jira with OpenReplay to enable the creation of a new ticket directly from a session.',
slug: 'jira', slug: 'jira',
category: 'Errors', category: 'Errors',
icon: 'integrations/jira', icon: 'integrations/jira',
component: <JiraForm /> component: <JiraForm />,
}, },
{ {
title: 'Github', title: 'Github',
subtitle: 'Integrate GitHub with OpenReplay to enable the direct creation of a new issue from a session.', subtitle:
'Integrate GitHub with OpenReplay to enable the direct creation of a new issue from a session.',
slug: 'github', slug: 'github',
category: 'Errors', category: 'Errors',
icon: 'integrations/github', icon: 'integrations/github',
component: <GithubForm /> component: <GithubForm />,
} },
] ],
}, },
{ {
title: 'Backend Logging', title: 'Backend Logging',
@ -186,106 +202,119 @@ const integrations = [
'Sync your backend errors with sessions replays and see what happened front-to-back.', 'Sync your backend errors with sessions replays and see what happened front-to-back.',
docs: () => ( docs: () => (
<DocCard <DocCard
title='Why use integrations?' title="Why use integrations?"
icon='question-lg' icon="question-lg"
iconBgColor='bg-red-lightest' iconBgColor="bg-red-lightest"
iconColor='red' iconColor="red"
> >
Sync your backend errors with sessions replays and see what happened front-to-back. Sync your backend errors with sessions replays and see what happened
front-to-back.
</DocCard> </DocCard>
), ),
integrations: [ integrations: [
{ {
title: 'Sentry', title: 'Sentry',
subtitle: 'Integrate Sentry with session replays to seamlessly observe backend errors.', subtitle:
'Integrate Sentry with session replays to seamlessly observe backend errors.',
slug: 'sentry', slug: 'sentry',
icon: 'integrations/sentry', icon: 'integrations/sentry',
component: <SentryForm /> component: <SentryForm />,
}, },
{ {
title: 'Bugsnag', title: 'Bugsnag',
subtitle: 'Integrate Bugsnag to access the OpenReplay session linked to the JS exception within its interface.', subtitle:
'Integrate Bugsnag to access the OpenReplay session linked to the JS exception within its interface.',
slug: 'bugsnag', slug: 'bugsnag',
icon: 'integrations/bugsnag', icon: 'integrations/bugsnag',
component: <BugsnagForm /> component: <BugsnagForm />,
}, },
{ {
title: 'Rollbar', title: 'Rollbar',
subtitle: 'Integrate Rollbar with session replays to seamlessly observe backend errors.', subtitle:
'Integrate Rollbar with session replays to seamlessly observe backend errors.',
slug: 'rollbar', slug: 'rollbar',
icon: 'integrations/rollbar', icon: 'integrations/rollbar',
component: <RollbarForm /> component: <RollbarForm />,
}, },
{ {
title: 'Elasticsearch', title: 'Elasticsearch',
subtitle: 'Integrate Elasticsearch with session replays to seamlessly observe backend errors.', subtitle:
'Integrate Elasticsearch with session replays to seamlessly observe backend errors.',
slug: 'elasticsearch', slug: 'elasticsearch',
icon: 'integrations/elasticsearch', icon: 'integrations/elasticsearch',
component: <ElasticsearchForm /> component: <ElasticsearchForm />,
}, },
{ {
title: 'Datadog', title: 'Datadog',
subtitle: 'Incorporate DataDog to visualize backend errors alongside session replay, for easy troubleshooting.', subtitle:
'Incorporate DataDog to visualize backend errors alongside session replay, for easy troubleshooting.',
slug: 'datadog', slug: 'datadog',
icon: 'integrations/datadog', icon: 'integrations/datadog',
component: <DatadogForm /> component: <DatadogForm />,
}, },
{ {
title: 'Sumo Logic', title: 'Sumo Logic',
subtitle: 'Integrate Sumo Logic with session replays to seamlessly observe backend errors.', subtitle:
'Integrate Sumo Logic with session replays to seamlessly observe backend errors.',
slug: 'sumologic', slug: 'sumologic',
icon: 'integrations/sumologic', icon: 'integrations/sumologic',
component: <SumoLogicForm /> component: <SumoLogicForm />,
}, },
{ {
title: 'Google Cloud', title: 'Google Cloud',
subtitle: 'Integrate Google Cloud to view backend logs and errors in conjunction with session replay', subtitle:
'Integrate Google Cloud to view backend logs and errors in conjunction with session replay',
slug: 'stackdriver', slug: 'stackdriver',
icon: 'integrations/google-cloud', icon: 'integrations/google-cloud',
component: <StackdriverForm /> component: <StackdriverForm />,
}, },
{ {
title: 'CloudWatch', title: 'CloudWatch',
subtitle: 'Integrate CloudWatch to see backend logs and errors alongside session replay.', subtitle:
'Integrate CloudWatch to see backend logs and errors alongside session replay.',
slug: 'cloudwatch', slug: 'cloudwatch',
icon: 'integrations/aws', icon: 'integrations/aws',
component: <CloudwatchForm /> component: <CloudwatchForm />,
}, },
{ {
title: 'Newrelic', title: 'Newrelic',
subtitle: 'Integrate NewRelic with session replays to seamlessly observe backend errors.', subtitle:
'Integrate NewRelic with session replays to seamlessly observe backend errors.',
slug: 'newrelic', slug: 'newrelic',
icon: 'integrations/newrelic', icon: 'integrations/newrelic',
component: <NewrelicForm /> component: <NewrelicForm />,
} },
] ],
}, },
{ {
title: 'Collaboration', title: 'Collaboration',
key: 'collaboration', key: 'collaboration',
isProject: false, isProject: false,
icon: 'file-code', icon: 'file-code',
description: 'Share your sessions with your team and collaborate on issues.', description:
'Share your sessions with your team and collaborate on issues.',
integrations: [ integrations: [
{ {
title: 'Slack', title: 'Slack',
subtitle: 'Integrate Slack to empower every user in your org with the ability to send sessions to any Slack channel.', subtitle:
'Integrate Slack to empower every user in your org with the ability to send sessions to any Slack channel.',
slug: 'slack', slug: 'slack',
category: 'Errors', category: 'Errors',
icon: 'integrations/slack', icon: 'integrations/slack',
component: <SlackForm />, component: <SlackForm />,
shared: true shared: true,
}, },
{ {
title: 'MS Teams', title: 'MS Teams',
subtitle: 'Integrate MS Teams to empower every user in your org with the ability to send sessions to any MS Teams channel.', subtitle:
'Integrate MS Teams to empower every user in your org with the ability to send sessions to any MS Teams channel.',
slug: 'msteams', slug: 'msteams',
category: 'Errors', category: 'Errors',
icon: 'integrations/teams', icon: 'integrations/teams',
component: <MSTeams />, component: <MSTeams />,
shared: true shared: true,
} },
] ],
}, },
// { // {
// title: 'State Management', // title: 'State Management',
@ -302,72 +331,82 @@ const integrations = [
icon: 'chat-left-text', icon: 'chat-left-text',
docs: () => ( docs: () => (
<DocCard <DocCard
title='What are plugins?' title="What are plugins?"
icon='question-lg' icon="question-lg"
iconBgColor='bg-red-lightest' iconBgColor="bg-red-lightest"
iconColor='red' iconColor="red"
> >
Plugins capture your applications store, monitor queries, track performance issues and even Plugins capture your applications store, monitor queries, track
assist your end user through live sessions. performance issues and even assist your end user through live sessions.
</DocCard> </DocCard>
), ),
description: description:
'Reproduce issues as if they happened in your own browser. Plugins help capture your application\'s store, HTTP requeets, GraphQL queries, and more.', "Reproduce issues as if they happened in your own browser. Plugins help capture your application's store, HTTP requeets, GraphQL queries, and more.",
integrations: [ integrations: [
{ {
title: 'Redux', title: 'Redux',
subtitle: 'Capture Redux actions/state and inspect them later on while replaying session recordings.', subtitle:
icon: 'integrations/redux', component: <ReduxDoc /> 'Capture Redux actions/state and inspect them later on while replaying session recordings.',
icon: 'integrations/redux',
component: <ReduxDoc />,
}, },
{ {
title: 'VueX', title: 'VueX',
subtitle: 'Capture VueX mutations/state and inspect them later on while replaying session recordings.', subtitle:
'Capture VueX mutations/state and inspect them later on while replaying session recordings.',
icon: 'integrations/vuejs', icon: 'integrations/vuejs',
component: <VueDoc /> component: <VueDoc />,
}, },
{ {
title: 'Pinia', title: 'Pinia',
subtitle: 'Capture Pinia mutations/state and inspect them later on while replaying session recordings.', subtitle:
'Capture Pinia mutations/state and inspect them later on while replaying session recordings.',
icon: 'integrations/pinia', icon: 'integrations/pinia',
component: <PiniaDoc /> component: <PiniaDoc />,
}, },
{ {
title: 'GraphQL', title: 'GraphQL',
subtitle: 'Capture GraphQL requests and inspect them later on while replaying session recordings. This plugin is compatible with Apollo and Relay implementations.', subtitle:
'Capture GraphQL requests and inspect them later on while replaying session recordings. This plugin is compatible with Apollo and Relay implementations.',
icon: 'integrations/graphql', icon: 'integrations/graphql',
component: <GraphQLDoc /> component: <GraphQLDoc />,
}, },
{ {
title: 'NgRx', title: 'NgRx',
subtitle: 'Capture NgRx actions/state and inspect them later on while replaying session recordings.\n', subtitle:
'Capture NgRx actions/state and inspect them later on while replaying session recordings.\n',
icon: 'integrations/ngrx', icon: 'integrations/ngrx',
component: <NgRxDoc /> component: <NgRxDoc />,
}, },
{ {
title: 'MobX', title: 'MobX',
subtitle: 'Capture MobX mutations and inspect them later on while replaying session recordings.', subtitle:
'Capture MobX mutations and inspect them later on while replaying session recordings.',
icon: 'integrations/mobx', icon: 'integrations/mobx',
component: <MobxDoc /> component: <MobxDoc />,
}, },
{ {
title: 'Profiler', title: 'Profiler',
subtitle: 'Plugin allows you to measure your JS functions performance and capture both arguments and result for each call.', subtitle:
'Plugin allows you to measure your JS functions performance and capture both arguments and result for each call.',
icon: 'integrations/openreplay', icon: 'integrations/openreplay',
component: <ProfilerDoc /> component: <ProfilerDoc />,
}, },
{ {
title: 'Assist', title: 'Assist',
subtitle: 'OpenReplay Assist allows you to support your users by seeing their live screen and instantly hopping on call (WebRTC) with them without requiring any 3rd-party screen sharing software.\n', subtitle:
'OpenReplay Assist allows you to support your users by seeing their live screen and instantly hopping on call (WebRTC) with them without requiring any 3rd-party screen sharing software.\n',
icon: 'integrations/openreplay', icon: 'integrations/openreplay',
component: <AssistDoc /> component: <AssistDoc />,
}, },
{ {
title: 'Zustand', title: 'Zustand',
subtitle: 'Capture Zustand mutations/state and inspect them later on while replaying session recordings.', subtitle:
'Capture Zustand mutations/state and inspect them later on while replaying session recordings.',
icon: 'integrations/zustand', icon: 'integrations/zustand',
// header: '🐻', // header: '🐻',
component: <ZustandDoc /> component: <ZustandDoc />,
} },
] ],
} },
]; ];

View file

@ -1,11 +1,15 @@
import React from 'react'; import React from 'react';
import ToggleContent from 'Shared/ToggleContent'; import ToggleContent from 'Shared/ToggleContent';
import DocLink from 'Shared/DocLink/DocLink'; import DocLink from 'Shared/DocLink/DocLink';
import { connect } from 'react-redux';
import { CodeBlock } from "UI"; import { CodeBlock } from "UI";
import { useStore } from 'App/mstore';
import { observer } from 'mobx-react-lite';
const MobxDoc = (props) => { const MobxDoc = () => {
const { projectKey } = props; const { integrationsStore, projectsStore } = useStore();
const sites = projectsStore.list;
const siteId = integrationsStore.integrations.siteId
const projectKey = siteId ? sites.find((site) => site.id === siteId)?.projectKey : sites[0]?.projectKey
const mobxUsage = `import OpenReplay from '@openreplay/tracker'; const mobxUsage = `import OpenReplay from '@openreplay/tracker';
import trackerMobX from '@openreplay/tracker-mobx'; import trackerMobX from '@openreplay/tracker-mobx';
@ -67,10 +71,4 @@ function SomeFunctionalComponent() {
MobxDoc.displayName = 'MobxDoc'; MobxDoc.displayName = 'MobxDoc';
export default connect((state) => { export default observer(MobxDoc)
const siteId = state.getIn(['integrations', 'siteId']);
const sites = state.getIn(['site', 'list']);
return {
projectKey: sites.find((site) => site.get('id') === siteId).get('projectKey'),
};
})(MobxDoc);

View file

@ -1,11 +1,15 @@
import { useStore } from "App/mstore";
import React from 'react'; import React from 'react';
import { CodeBlock } from "UI"; import { CodeBlock } from "UI";
import ToggleContent from 'Shared/ToggleContent'; import ToggleContent from 'Shared/ToggleContent';
import DocLink from 'Shared/DocLink/DocLink'; import DocLink from 'Shared/DocLink/DocLink';
import { connect } from 'react-redux'; import { observer } from 'mobx-react-lite'
const NgRxDoc = (props) => { const NgRxDoc = () => {
const { projectKey } = props; const { integrationsStore, projectsStore } = useStore();
const sites = projectsStore.list;
const siteId = integrationsStore.integrations.siteId
const projectKey = siteId ? sites.find((site) => site.id === siteId)?.projectKey : sites[0]?.projectKey
const usage = `import { StoreModule } from '@ngrx/store'; const usage = `import { StoreModule } from '@ngrx/store';
import { reducers } from './reducers'; import { reducers } from './reducers';
import OpenReplay from '@openreplay/tracker'; import OpenReplay from '@openreplay/tracker';
@ -80,10 +84,4 @@ const metaReducers = [tracker.use(trackerNgRx(<options>))]; // check list of ava
NgRxDoc.displayName = 'NgRxDoc'; NgRxDoc.displayName = 'NgRxDoc';
export default connect((state) => { export default observer(NgRxDoc);
const siteId = state.getIn(['integrations', 'siteId']);
const sites = state.getIn(['site', 'list']);
return {
projectKey: sites.find((site) => site.get('id') === siteId).get('projectKey'),
};
})(NgRxDoc);

View file

@ -1,11 +1,19 @@
import { observer } from 'mobx-react-lite';
import React from 'react'; import React from 'react';
import { CodeBlock } from "UI";
import ToggleContent from '../../../shared/ToggleContent';
import DocLink from 'Shared/DocLink/DocLink';
import { connect } from 'react-redux';
const PiniaDoc = (props) => { import { useStore } from 'App/mstore';
const { projectKey } = props; import ToggleContent from 'Components/shared/ToggleContent';
import { CodeBlock } from 'UI';
import DocLink from 'Shared/DocLink/DocLink';
const PiniaDoc = () => {
const { integrationsStore, projectsStore } = useStore();
const sites = projectsStore.list;
const siteId = integrationsStore.integrations.siteId;
const projectKey = siteId
? sites.find((site) => site.id === siteId)?.projectKey
: sites[0]?.projectKey;
const usage = `import Vuex from 'vuex' const usage = `import Vuex from 'vuex'
import OpenReplay from '@openreplay/tracker'; import OpenReplay from '@openreplay/tracker';
import trackerVuex from '@openreplay/tracker-vuex'; import trackerVuex from '@openreplay/tracker-vuex';
@ -28,7 +36,7 @@ piniaStorePlugin(examplePiniaStore)
// now you can use examplePiniaStore as // now you can use examplePiniaStore as
// usual pinia store // usual pinia store
// (destructure values or return it as a whole etc) // (destructure values or return it as a whole etc)
` `;
const usageCjs = `import Vuex from 'vuex' const usageCjs = `import Vuex from 'vuex'
import OpenReplay from '@openreplay/tracker/cjs'; import OpenReplay from '@openreplay/tracker/cjs';
import trackerVuex from '@openreplay/tracker-vuex/cjs'; import trackerVuex from '@openreplay/tracker-vuex/cjs';
@ -55,34 +63,38 @@ piniaStorePlugin(examplePiniaStore)
// now you can use examplePiniaStore as // now you can use examplePiniaStore as
// usual pinia store // usual pinia store
// (destructure values or return it as a whole etc) // (destructure values or return it as a whole etc)
}` }`;
return ( return (
<div className="bg-white h-screen overflow-y-auto" style={{ width: '500px' }}> <div
className="bg-white h-screen overflow-y-auto"
style={{ width: '500px' }}
>
<h3 className="p-5 text-2xl">VueX</h3> <h3 className="p-5 text-2xl">VueX</h3>
<div className="p-5"> <div className="p-5">
<div> <div>
This plugin allows you to capture Pinia mutations + state and inspect them later on while This plugin allows you to capture Pinia mutations + state and inspect
replaying session recordings. This is very useful for understanding and fixing issues. them later on while replaying session recordings. This is very useful
for understanding and fixing issues.
</div> </div>
<div className="font-bold my-2 text-lg">Installation</div> <div className="font-bold my-2 text-lg">Installation</div>
<CodeBlock code={`npm i @openreplay/tracker-vuex --save`} language="bash" /> <CodeBlock
code={`npm i @openreplay/tracker-vuex --save`}
language="bash"
/>
<div className="font-bold my-2 text-lg">Usage</div> <div className="font-bold my-2 text-lg">Usage</div>
<p> <p>
Initialize the @openreplay/tracker package as usual and load the plugin into it. Then put Initialize the @openreplay/tracker package as usual and load the
the generated plugin into your plugins field of your store. plugin into it. Then put the generated plugin into your plugins field
of your store.
</p> </p>
<div className="py-3" /> <div className="py-3" />
<ToggleContent <ToggleContent
label="Server-Side-Rendered (SSR)?" label="Server-Side-Rendered (SSR)?"
first={ first={<CodeBlock code={usage} language="js" />}
<CodeBlock code={usage} language="js" /> second={<CodeBlock code={usageCjs} language="js" />}
}
second={
<CodeBlock code={usageCjs} language="js" />
}
/> />
<DocLink <DocLink
@ -97,10 +109,4 @@ piniaStorePlugin(examplePiniaStore)
PiniaDoc.displayName = 'PiniaDoc'; PiniaDoc.displayName = 'PiniaDoc';
export default connect((state: any) => { export default observer(PiniaDoc);
const siteId = state.getIn(['integrations', 'siteId']);
const sites = state.getIn(['site', 'list']);
return {
projectKey: sites.find((site: any) => site.get('id') === siteId).get('projectKey'),
};
})(PiniaDoc);

View file

@ -1,13 +1,16 @@
import { useStore } from "App/mstore";
import React from 'react'; import React from 'react';
import { connect } from 'react-redux'; import { observer } from 'mobx-react-lite';
import { CodeBlock } from 'UI'; import { CodeBlock } from 'UI';
import DocLink from 'Shared/DocLink/DocLink'; import DocLink from 'Shared/DocLink/DocLink';
import ToggleContent from 'Shared/ToggleContent'; import ToggleContent from 'Shared/ToggleContent';
const ProfilerDoc = (props) => { const ProfilerDoc = () => {
const { projectKey } = props; const { integrationsStore, projectsStore } = useStore();
const sites = projectsStore.list;
const siteId = integrationsStore.integrations.siteId
const projectKey = siteId ? sites.find((site) => site.id === siteId)?.projectKey : sites[0]?.projectKey
const usage = `import OpenReplay from '@openreplay/tracker'; const usage = `import OpenReplay from '@openreplay/tracker';
import trackerProfiler from '@openreplay/tracker-profiler'; import trackerProfiler from '@openreplay/tracker-profiler';
@ -87,12 +90,4 @@ const fn = profiler('call_name')(() => {
ProfilerDoc.displayName = 'ProfilerDoc'; ProfilerDoc.displayName = 'ProfilerDoc';
export default connect((state) => { export default observer(ProfilerDoc);
const siteId = state.getIn(['integrations', 'siteId']);
const sites = state.getIn(['site', 'list']);
return {
projectKey: sites
.find((site) => site.get('id') === siteId)
.get('projectKey'),
};
})(ProfilerDoc);

View file

@ -1,11 +1,15 @@
import { useStore } from "App/mstore";
import React from 'react'; import React from 'react';
import { CodeBlock } from 'UI' import { CodeBlock } from 'UI'
import ToggleContent from '../../../shared/ToggleContent'; import ToggleContent from 'Components/shared/ToggleContent';
import DocLink from 'Shared/DocLink/DocLink'; import DocLink from 'Shared/DocLink/DocLink';
import { connect } from 'react-redux'; import { observer } from 'mobx-react-lite'
const ReduxDoc = (props) => { const ReduxDoc = () => {
const { projectKey } = props; const { integrationsStore, projectsStore } = useStore();
const sites = projectsStore.list;
const siteId = integrationsStore.integrations.siteId
const projectKey = siteId ? sites.find((site) => site.id === siteId)?.projectKey : sites[0]?.projectKey
const usage = `import { applyMiddleware, createStore } from 'redux'; const usage = `import { applyMiddleware, createStore } from 'redux';
import OpenReplay from '@openreplay/tracker'; import OpenReplay from '@openreplay/tracker';
@ -74,10 +78,4 @@ const store = createStore(
ReduxDoc.displayName = 'ReduxDoc'; ReduxDoc.displayName = 'ReduxDoc';
export default connect((state) => { export default observer(ReduxDoc);
const siteId = state.getIn(['integrations', 'siteId']);
const sites = state.getIn(['site', 'list']);
return {
projectKey: sites.find((site) => site.get('id') === siteId).get('projectKey'),
};
})(ReduxDoc);

View file

@ -1,25 +1,36 @@
import React from 'react'; import React from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { edit, save, init, update } from 'Duck/integrations/slack';
import { Form, Input, Button, Message } from 'UI'; import { Form, Input, Button, Message } from 'UI';
import { confirm } from 'UI'; import { confirm } from 'UI';
import { remove } from 'Duck/integrations/slack'; import { observer } from 'mobx-react-lite'
import { useStore } from 'App/mstore'
class SlackAddForm extends React.PureComponent { function SlackAddForm(props) {
componentWillUnmount() { const { onClose } = props;
this.props.init({}); const { integrationsStore } = useStore();
} const instance = integrationsStore.slack.instance;
const saving = integrationsStore.slack.loading;
const errors = integrationsStore.slack.errors;
const edit = integrationsStore.slack.edit;
const onSave = integrationsStore.slack.saveIntegration;
const update = integrationsStore.slack.update;
const init = integrationsStore.slack.init;
const onRemove = integrationsStore.slack.removeInt;
React.useEffect(() => {
return () => init({})
}, [])
save = () => {
const instance = this.props.instance; const save = () => {
if (instance.exists()) { if (instance.exists()) {
this.props.update(this.props.instance); void update(instance);
} else { } else {
this.props.save(this.props.instance); void onSave(instance);
} }
}; };
remove = async (id) => { const remove = async (id) => {
if ( if (
await confirm({ await confirm({
header: 'Confirm', header: 'Confirm',
@ -27,79 +38,68 @@ class SlackAddForm extends React.PureComponent {
confirmation: `Are you sure you want to permanently delete this channel?`, confirmation: `Are you sure you want to permanently delete this channel?`,
}) })
) { ) {
this.props.remove(id); await onRemove(id);
onClose();
} }
}; };
write = ({ target: { name, value } }) => this.props.edit({ [name]: value }); const write = ({ target: { name, value } }) => edit({ [name]: value });
render() { return (
const { instance, saving, errors, onClose } = this.props; <div className="p-5" style={{ minWidth: '300px' }}>
return ( <Form>
<div className="p-5" style={{ minWidth: '300px' }}> <Form.Field>
<Form> <label>Name</label>
<Form.Field> <Input
<label>Name</label> name="name"
<Input value={instance.name}
name="name" onChange={write}
value={instance.name} placeholder="Enter any name"
onChange={this.write} type="text"
placeholder="Enter any name" />
type="text" </Form.Field>
/> <Form.Field>
</Form.Field> <label>URL</label>
<Form.Field> <Input
<label>URL</label> name="endpoint"
<Input value={instance.endpoint}
name="endpoint" onChange={write}
value={instance.endpoint} placeholder="Slack webhook URL"
onChange={this.write} type="text"
placeholder="Slack webhook URL" />
type="text" </Form.Field>
/> <div className="flex justify-between">
</Form.Field> <div className="flex">
<div className="flex justify-between"> <Button
<div className="flex"> onClick={save}
<Button disabled={!instance.validate()}
onClick={this.save} loading={saving}
disabled={!instance.validate()} variant="primary"
loading={saving} className="float-left mr-2"
variant="primary" >
className="float-left mr-2" {instance.exists() ? 'Update' : 'Add'}
>
{instance.exists() ? 'Update' : 'Add'}
</Button>
<Button onClick={onClose}>{'Cancel'}</Button>
</div>
<Button onClick={() => this.remove(instance.webhookId)} disabled={!instance.exists()}>
{'Delete'}
</Button> </Button>
</div>
</Form>
{errors && ( <Button onClick={onClose}>{'Cancel'}</Button>
<div className="my-3">
{errors.map((error) => (
<Message visible={errors} size="mini" error key={error}>
{error}
</Message>
))}
</div> </div>
)}
</div> <Button onClick={() => remove(instance.webhookId)} disabled={!instance.exists()}>
); {'Delete'}
} </Button>
</div>
</Form>
{errors && (
<div className="my-3">
{errors.map((error) => (
<Message visible={errors} size="mini" error key={error}>
{error}
</Message>
))}
</div>
)}
</div>
);
} }
export default connect( export default observer(SlackAddForm);
(state) => ({
instance: state.getIn(['slack', 'instance']),
saving:
state.getIn(['slack', 'saveRequest', 'loading']) ||
state.getIn(['slack', 'updateRequest', 'loading']),
errors: state.getIn(['slack', 'saveRequest', 'errors']),
}),
{ edit, save, init, remove, update }
)(SlackAddForm);

View file

@ -1,14 +1,16 @@
import React from 'react'; import React from 'react';
import { connect } from 'react-redux';
import { NoContent } from 'UI'; import { NoContent } from 'UI';
import { remove, edit, init } from 'Duck/integrations/slack';
import DocLink from 'Shared/DocLink/DocLink'; import DocLink from 'Shared/DocLink/DocLink';
import { observer } from 'mobx-react-lite'
import { useStore } from 'App/mstore'
function SlackChannelList(props) { function SlackChannelList(props) {
const { list } = props; const { integrationsStore } = useStore();
const list = integrationsStore.slack.list;
const edit = integrationsStore.slack.edit;
const onEdit = (instance) => { const onEdit = (instance) => {
props.edit(instance); edit(instance.toData());
props.onEdit(); props.onEdit();
}; };
@ -24,7 +26,7 @@ function SlackChannelList(props) {
</div> </div>
} }
size="small" size="small"
show={list.size === 0} show={list.length === 0}
> >
{list.map((c) => ( {list.map((c) => (
<div <div
@ -43,9 +45,4 @@ function SlackChannelList(props) {
); );
} }
export default connect( export default observer(SlackChannelList);
(state) => ({
list: state.getIn(['slack', 'list']),
}),
{ remove, edit, init }
)(SlackChannelList);

View file

@ -1,17 +1,14 @@
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import SlackChannelList from './SlackChannelList/SlackChannelList'; import SlackChannelList from './SlackChannelList/SlackChannelList';
import { fetchList, init } from 'Duck/integrations/slack';
import { connect } from 'react-redux';
import SlackAddForm from './SlackAddForm'; import SlackAddForm from './SlackAddForm';
import { Button } from 'UI'; import { Button } from 'UI';
import { observer } from 'mobx-react-lite'
import { useStore } from 'App/mstore'
interface Props { const SlackForm = () => {
onEdit?: (integration: any) => void; const { integrationsStore } = useStore();
istance: any; const init = integrationsStore.slack.init;
fetchList: any; const fetchList = integrationsStore.slack.fetchIntegrations;
init: any;
}
const SlackForm = (props: Props) => {
const [active, setActive] = React.useState(false); const [active, setActive] = React.useState(false);
const onEdit = () => { const onEdit = () => {
@ -20,11 +17,11 @@ const SlackForm = (props: Props) => {
const onNew = () => { const onNew = () => {
setActive(true); setActive(true);
props.init({}); init({});
} }
useEffect(() => { useEffect(() => {
props.fetchList(); void fetchList();
}, []); }, []);
return ( return (
@ -47,9 +44,4 @@ const SlackForm = (props: Props) => {
SlackForm.displayName = 'SlackForm'; SlackForm.displayName = 'SlackForm';
export default connect( export default observer(SlackForm);
(state: any) => ({
istance: state.getIn(['slack', 'instance']),
}),
{ fetchList, init }
)(SlackForm);

View file

@ -1,36 +1,38 @@
import { observer } from 'mobx-react-lite';
import React from 'react'; import React from 'react';
import { connect } from 'react-redux';
import { edit, save, init, update, remove } from 'Duck/integrations/teams'; import { useStore } from 'App/mstore';
import { Form, Input, Button, Message } from 'UI'; import { Button, Form, Input, Message } from 'UI';
import { confirm } from 'UI'; import { confirm } from 'UI';
interface Props { interface Props {
edit: (inst: any) => void;
save: (inst: any) => void;
init: (inst: any) => void;
update: (inst: any) => void;
remove: (id: string) => void;
onClose: () => void; onClose: () => void;
instance: any;
saving: boolean;
errors: any;
} }
class TeamsAddForm extends React.PureComponent<Props> { function TeamsAddForm({ onClose }: Props) {
componentWillUnmount() { const { integrationsStore } = useStore();
this.props.init({}); const instance = integrationsStore.msteams.instance;
} const saving = integrationsStore.msteams.loading;
const errors = integrationsStore.msteams.errors;
const edit = integrationsStore.msteams.edit;
const onSave = integrationsStore.msteams.saveIntegration;
const init = integrationsStore.msteams.init;
const onRemove = integrationsStore.msteams.removeInt;
const update = integrationsStore.msteams.update;
save = () => { React.useEffect(() => {
const instance = this.props.instance; return () => init({});
if (instance.exists()) { }, []);
this.props.update(this.props.instance);
const save = () => {
if (instance?.exists()) {
void update();
} else { } else {
this.props.save(this.props.instance); void onSave();
} }
}; };
remove = async (id: string) => { const remove = async (id: string) => {
if ( if (
await confirm({ await confirm({
header: 'Confirm', header: 'Confirm',
@ -38,80 +40,74 @@ class TeamsAddForm extends React.PureComponent<Props> {
confirmation: `Are you sure you want to permanently delete this channel?`, confirmation: `Are you sure you want to permanently delete this channel?`,
}) })
) { ) {
this.props.remove(id); void onRemove(id);
} }
}; };
write = ({ target: { name, value } }: { target: { name: string; value: string } }) => const write = ({
this.props.edit({ [name]: value }); target: { name, value },
}: {
target: { name: string; value: string };
}) => edit({ [name]: value });
render() { return (
const { instance, saving, errors, onClose } = this.props; <div className="p-5" style={{ minWidth: '300px' }}>
return ( <Form>
<div className="p-5" style={{ minWidth: '300px' }}> <Form.Field>
<Form> <label>Name</label>
<Form.Field> <Input
<label>Name</label> name="name"
<Input value={instance?.name}
name="name" onChange={write}
value={instance.name} placeholder="Enter any name"
onChange={this.write} type="text"
placeholder="Enter any name" />
type="text" </Form.Field>
/> <Form.Field>
</Form.Field> <label>URL</label>
<Form.Field> <Input
<label>URL</label> name="endpoint"
<Input value={instance?.endpoint}
name="endpoint" onChange={write}
value={instance.endpoint} placeholder="Teams webhook URL"
onChange={this.write} type="text"
placeholder="Teams webhook URL" />
type="text" </Form.Field>
/> <div className="flex justify-between">
</Form.Field> <div className="flex">
<div className="flex justify-between"> <Button
<div className="flex"> onClick={save}
<Button disabled={!instance?.validate()}
onClick={this.save} loading={saving}
disabled={!instance.validate()} variant="primary"
loading={saving} className="float-left mr-2"
variant="primary" >
className="float-left mr-2" {instance?.exists() ? 'Update' : 'Add'}
>
{instance.exists() ? 'Update' : 'Add'}
</Button>
<Button onClick={onClose}>{'Cancel'}</Button>
</div>
<Button onClick={() => this.remove(instance.webhookId)} disabled={!instance.exists()}>
{'Delete'}
</Button> </Button>
</div>
</Form>
{errors && ( <Button onClick={onClose}>{'Cancel'}</Button>
<div className="my-3">
{errors.map((error: any) => (
<Message visible={errors} key={error}>
{error}
</Message>
))}
</div> </div>
)}
</div> <Button
); onClick={() => remove(instance?.webhookId)}
} disabled={!instance.exists()}
>
{'Delete'}
</Button>
</div>
</Form>
{errors && (
<div className="my-3">
{errors.map((error: any) => (
<Message visible={errors} key={error}>
{error}
</Message>
))}
</div>
)}
</div>
);
} }
export default connect( export default observer(TeamsAddForm);
(state: any) => ({
instance: state.getIn(['teams', 'instance']),
saving:
state.getIn(['teams', 'saveRequest', 'loading']) ||
state.getIn(['teams', 'updateRequest', 'loading']),
errors: state.getIn(['teams', 'saveRequest', 'errors']),
}),
{ edit, save, init, remove, update }
)(TeamsAddForm);

View file

@ -1,51 +1,57 @@
import { observer } from 'mobx-react-lite';
import React from 'react'; import React from 'react';
import { connect } from 'react-redux';
import { useStore } from 'App/mstore';
import { NoContent } from 'UI'; import { NoContent } from 'UI';
import { remove, edit, init } from 'Duck/integrations/teams';
import DocLink from 'Shared/DocLink/DocLink'; import DocLink from 'Shared/DocLink/DocLink';
function TeamsChannelList(props: { list: any, edit: (inst: any) => any, onEdit: () => void }) { function TeamsChannelList(props: { onEdit: () => void }) {
const { list } = props; const { integrationsStore } = useStore();
const list = integrationsStore.msteams.list;
const edit = integrationsStore.msteams.edit;
const onEdit = (instance: Record<string, any>) => { const onEdit = (instance: Record<string, any>) => {
props.edit(instance); edit(instance);
props.onEdit(); props.onEdit();
}; };
return ( return (
<div className="mt-6"> <div className="mt-6">
<NoContent <NoContent
title={ title={
<div className="p-5 mb-4"> <div className="p-5 mb-4">
<div className="text-base text-left"> <div className="text-base text-left">
Integrate MS Teams with OpenReplay and share insights with the rest of the team, directly from the recording page. Integrate MS Teams with OpenReplay and share insights with the
</div> rest of the team, directly from the recording page.
<DocLink className="mt-4 text-base" label="Integrate MS Teams" url="https://docs.openreplay.com/integrations/msteams" /> </div>
</div> <DocLink
} className="mt-4 text-base"
size="small" label="Integrate MS Teams"
show={list.size === 0} url="https://docs.openreplay.com/integrations/msteams"
> />
{list.map((c: any) => ( </div>
<div }
key={c.webhookId} size="small"
className="border-t px-5 py-2 flex items-center justify-between cursor-pointer hover:bg-active-blue" show={list.length === 0}
onClick={() => onEdit(c)} >
> {list.map((c: any) => (
<div className="flex-grow-0" style={{ maxWidth: '90%' }}> <div
<div>{c.name}</div> key={c.webhookId}
<div className="truncate test-xs color-gray-medium">{c.endpoint}</div> className="border-t px-5 py-2 flex items-center justify-between cursor-pointer hover:bg-active-blue"
</div> onClick={() => onEdit(c)}
</div> >
))} <div className="flex-grow-0" style={{ maxWidth: '90%' }}>
</NoContent> <div>{c.name}</div>
</div> <div className="truncate test-xs color-gray-medium">
); {c.endpoint}
</div>
</div>
</div>
))}
</NoContent>
</div>
);
} }
export default connect( export default observer(TeamsChannelList);
(state: any) => ({
list: state.getIn(['teams', 'list']),
}),
{ remove, edit, init }
)(TeamsChannelList);

View file

@ -1,17 +1,15 @@
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import TeamsChannelList from './TeamsChannelList'; import TeamsChannelList from './TeamsChannelList';
import { fetchList, init } from 'Duck/integrations/teams'; import { useStore } from 'App/mstore';
import { connect } from 'react-redux'; import { observer } from 'mobx-react-lite';
import TeamsAddForm from './TeamsAddForm'; import TeamsAddForm from './TeamsAddForm';
import { Button } from 'UI'; import { Button } from 'UI';
interface Props { const MSTeams = () => {
onEdit?: (integration: any) => void; const { integrationsStore } = useStore();
istance: any; const fetchList = integrationsStore.msteams.fetchIntegrations;
fetchList: any; const init = integrationsStore.msteams.init;
init: any;
}
const MSTeams = (props: Props) => {
const [active, setActive] = React.useState(false); const [active, setActive] = React.useState(false);
const onEdit = () => { const onEdit = () => {
@ -20,11 +18,11 @@ const MSTeams = (props: Props) => {
const onNew = () => { const onNew = () => {
setActive(true); setActive(true);
props.init({}); init({});
} }
useEffect(() => { useEffect(() => {
props.fetchList(); void fetchList();
}, []); }, []);
return ( return (
@ -47,9 +45,4 @@ const MSTeams = (props: Props) => {
MSTeams.displayName = 'MSTeams'; MSTeams.displayName = 'MSTeams';
export default connect( export default observer(MSTeams);
(state: any) => ({
istance: state.getIn(['teams', 'instance']),
}),
{ fetchList, init }
)(MSTeams);

View file

@ -1,11 +1,15 @@
import { useStore } from "App/mstore";
import React from 'react'; import React from 'react';
import { CodeBlock } from "UI"; import { CodeBlock } from "UI";
import ToggleContent from '../../../shared/ToggleContent'; import ToggleContent from 'Components/shared/ToggleContent';
import DocLink from 'Shared/DocLink/DocLink'; import DocLink from 'Shared/DocLink/DocLink';
import { connect } from 'react-redux'; import { observer } from 'mobx-react-lite';
const VueDoc = (props) => { const VueDoc = () => {
const { projectKey, siteId } = props; const { integrationsStore, projectsStore } = useStore();
const sites = projectsStore.list;
const siteId = integrationsStore.integrations.siteId
const projectKey = siteId ? sites.find((site) => site.id === siteId)?.projectKey : sites[0]?.projectKey
const usage = `import Vuex from 'vuex' const usage = `import Vuex from 'vuex'
import OpenReplay from '@openreplay/tracker'; import OpenReplay from '@openreplay/tracker';
@ -81,10 +85,4 @@ const store = new Vuex.Store({
VueDoc.displayName = 'VueDoc'; VueDoc.displayName = 'VueDoc';
export default connect((state) => { export default observer(VueDoc);
const siteId = state.getIn(['integrations', 'siteId']);
const sites = state.getIn(['site', 'list']);
return {
projectKey: sites.find((site) => site.get('id') === siteId).get('projectKey'),
};
})(VueDoc);

View file

@ -1,11 +1,15 @@
import { useStore } from "App/mstore";
import React from 'react'; import React from 'react';
import { CodeBlock } from "UI"; import { CodeBlock } from "UI";
import ToggleContent from '../../../shared/ToggleContent'; import ToggleContent from 'Components//shared/ToggleContent';
import DocLink from 'Shared/DocLink/DocLink'; import DocLink from 'Shared/DocLink/DocLink';
import { connect } from 'react-redux'; import { observer } from 'mobx-react-lite'
const ZustandDoc = (props) => { const ZustandDoc = (props) => {
const { projectKey } = props; const { integrationsStore, projectsStore } = useStore();
const sites = projectsStore.list;
const siteId = integrationsStore.integrations.siteId
const projectKey = siteId ? sites.find((site) => site.id === siteId)?.projectKey : sites[0]?.projectKey
const usage = `import create from "zustand"; const usage = `import create from "zustand";
import Tracker from '@openreplay/tracker'; import Tracker from '@openreplay/tracker';
@ -97,10 +101,4 @@ const useBearStore = create(
ZustandDoc.displayName = 'ZustandDoc'; ZustandDoc.displayName = 'ZustandDoc';
export default connect((state) => { export default observer(ZustandDoc);
const siteId = state.getIn(['integrations', 'siteId']);
const sites = state.getIn(['site', 'list']);
return {
projectKey: sites.find((site) => site.get('id') === siteId).get('projectKey'),
};
})(ZustandDoc);

View file

@ -4,12 +4,12 @@ import { Loader, NoContent, Button, Tooltip } from 'UI';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import stl from './roles.module.css'; import stl from './roles.module.css';
import RoleForm from './components/RoleForm'; import RoleForm from './components/RoleForm';
import { init, edit, fetchList, remove as deleteRole, resetErrors } from 'Duck/roles';
import RoleItem from './components/RoleItem'; import RoleItem from './components/RoleItem';
import { confirm } from 'UI'; import { confirm } from 'UI';
import { toast } from 'react-toastify';
import withPageTitle from 'HOCs/withPageTitle'; import withPageTitle from 'HOCs/withPageTitle';
import { useModal } from 'App/components/Modal'; import { useModal } from 'App/components/Modal';
import { useStore } from "App/mstore";
import { observer } from 'mobx-react-lite';
interface Props { interface Props {
loading: boolean; loading: boolean;
@ -23,29 +23,30 @@ interface Props {
permissionsMap: any; permissionsMap: any;
removeErrors: any; removeErrors: any;
resetErrors: () => void; resetErrors: () => void;
projectsMap: any;
} }
function Roles(props: Props) { function Roles(props: Props) {
const { loading, roles, init, edit, deleteRole, account, permissionsMap, projectsMap, removeErrors } = props; const { roleStore, projectsStore } = useStore();
const projectsMap = projectsStore.list.reduce((acc: any, p: any) => {
acc[p.id] = p.name;
return acc;
}, {})
const roles = roleStore.list;
const loading = roleStore.loading;
const init = roleStore.init;
const deleteRole = roleStore.deleteRole;
const permissionsMap: any = {};
roleStore.permissions.forEach((p: any) => {
permissionsMap[p.value] = p.text;
});
const { account } = props;
const { showModal, hideModal } = useModal(); const { showModal, hideModal } = useModal();
const isAdmin = account.admin || account.superAdmin; const isAdmin = account.admin || account.superAdmin;
useEffect(() => { useEffect(() => {
props.fetchList(); void roleStore.fetchRoles();
}, []); }, []);
useEffect(() => {
if (removeErrors && removeErrors.size > 0) {
removeErrors.forEach((e: any) => {
toast.error(e);
});
}
return () => {
props.resetErrors();
};
}, [removeErrors]);
const editHandler = (role: any) => { const editHandler = (role: any) => {
init(role); init(role);
showModal(<RoleForm closeModal={hideModal} permissionsMap={permissionsMap} deleteHandler={deleteHandler} />, { right: true }); showModal(<RoleForm closeModal={hideModal} permissionsMap={permissionsMap} deleteHandler={deleteHandler} />, { right: true });
@ -110,24 +111,8 @@ function Roles(props: Props) {
export default connect( export default connect(
(state: any) => { (state: any) => {
const permissions = state.getIn(['roles', 'permissions']);
const permissionsMap: any = {};
permissions.forEach((p: any) => {
permissionsMap[p.value] = p.text;
});
const projects = state.getIn(['site', 'list']);
return { return {
instance: state.getIn(['roles', 'instance']) || null,
permissionsMap: permissionsMap,
roles: state.getIn(['roles', 'list']),
removeErrors: state.getIn(['roles', 'removeRequest', 'errors']),
loading: state.getIn(['roles', 'fetchRequest', 'loading']),
account: state.getIn(['user', 'account']), account: state.getIn(['user', 'account']),
projectsMap: projects.reduce((acc: any, p: any) => {
acc[p.get('id')] = p.get('name');
return acc;
}, {}),
}; };
}, }
{ init, edit, fetchList, deleteRole, resetErrors } )(withPageTitle('Roles & Access - OpenReplay Preferences')(observer(Roles)));
)(withPageTitle('Roles & Access - OpenReplay Preferences')(Roles));

View file

@ -1,15 +0,0 @@
import React from 'react';
import Role from 'Types/role'
interface Props {
role: Role
}
function Permissions(props: Props) {
return (
<div>
</div>
);
}
export default Permissions;

View file

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

View file

@ -1,196 +1,234 @@
import React, { useRef, useEffect } from 'react'; import { observer } from 'mobx-react-lite';
import React, { useEffect, useRef } from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import stl from './roleForm.module.css';
import { save, edit } from 'Duck/roles';
import { Form, Input, Button, Checkbox, Icon } from 'UI';
import { useStore } from 'App/mstore';
import { Button, Checkbox, Form, Icon, Input } from 'UI';
import Select from 'Shared/Select'; import Select from 'Shared/Select';
import stl from './roleForm.module.css';
interface Permission { interface Permission {
name: string; name: string;
value: string; value: string;
} }
interface Props { interface Props {
role: any; closeModal: (toastMessage?: string) => void;
edit: (role: any) => void; permissionsMap: any;
save: (role: any) => Promise<void>; deleteHandler: (id: any) => Promise<void>;
closeModal: (toastMessage?: string) => void;
saving: boolean;
permissions: Array<Permission>[];
projectOptions: Array<any>[];
permissionsMap: any;
projectsMap: any;
deleteHandler: (id: any) => Promise<void>;
} }
const RoleForm = (props: Props) => { const RoleForm = (props: Props) => {
const { role, edit, save, closeModal, saving, permissions, projectOptions, permissionsMap, projectsMap } = props; const { roleStore, projectsStore } = useStore();
let focusElement = useRef<any>(null); const projects = projectsStore.list;
const _save = () => { const role = roleStore.instance;
save(role).then(() => { const saving = roleStore.loading;
closeModal(role.exists() ? 'Role updated' : 'Role created'); const { closeModal, permissionsMap } = props;
}); const projectOptions = projects
}; .filter(({ value }) => !role.projects.includes(value))
.map((p: any) => ({
key: p.id,
value: p.id,
label: p.name,
}))
.filter(({ value }: any) => !role.projects.includes(value));
const projectsMap = projects.reduce((acc: any, p: any) => {
acc[p.id] = p.name;
return acc;
}, {});
const write = ({ target: { value, name } }: any) => edit({ [name]: value }); let focusElement = useRef<any>(null);
const permissions: {}[] = roleStore.permissions
.filter(({ value }) => !role.permissions.includes(value))
.map((p) => ({
label: p.text,
value: p.value,
}));
const _save = () => {
roleStore.saveRole(role).then(() => {
closeModal(role.exists() ? 'Role updated' : 'Role created');
});
};
const onChangePermissions = (e: any) => { const write = ({ target: { value, name } }: any) => roleStore.editRole({ [name]: value });
const { permissions } = role;
const index = permissions.indexOf(e);
const _perms = permissions.contains(e) ? permissions.remove(index) : permissions.push(e);
edit({ permissions: _perms });
};
const onChangeProjects = (e: any) => { const onChangePermissions = (e: any) => {
const { projects } = role; const { permissions } = role;
const index = projects.indexOf(e); const index = permissions.indexOf(e);
const _projects = index === -1 ? projects.push(e) : projects.remove(index); let _perms;
edit({ projects: _projects }); if (permissions.includes(e)) {
}; permissions.splice(index, 1);
_perms = permissions;
} else {
_perms = permissions.concat(e);
}
roleStore.editRole({ permissions: _perms });
};
const writeOption = ({ name, value }: any) => { const onChangeProjects = (e: any) => {
if (name === 'permissions') { const { projects } = role;
onChangePermissions(value); const index = projects.indexOf(e);
} else if (name === 'projects') { let _projects;
onChangeProjects(value); if (index === -1) {
} _projects = projects.concat(e)
}; } else {
projects.splice(index, 1)
_projects = projects
}
roleStore.editRole({ projects: _projects });
};
const toggleAllProjects = () => { const writeOption = ({ name, value }: any) => {
const { allProjects } = role; if (name === 'permissions') {
edit({ allProjects: !allProjects }); onChangePermissions(value);
}; } else if (name === 'projects') {
onChangeProjects(value);
}
};
useEffect(() => { const toggleAllProjects = () => {
focusElement && focusElement.current && focusElement.current.focus(); const { allProjects } = role;
}, []); roleStore.editRole({ allProjects: !allProjects });
};
return ( useEffect(() => {
<div className="bg-white h-screen overflow-y-auto" style={{ width: '350px' }}> focusElement && focusElement.current && focusElement.current.focus();
<h3 className="p-5 text-2xl">{role.exists() ? 'Edit Role' : 'Create Role'}</h3> }, []);
<div className="px-5">
<Form onSubmit={_save}>
<Form.Field>
<label>{'Title'}</label>
<Input
ref={focusElement}
name="name"
value={role.name}
onChange={write}
maxLength={40}
className={stl.input}
id="name-field"
placeholder="Ex. Admin"
/>
</Form.Field>
<Form.Field> return (
<label>{'Project Access'}</label> <div
className="bg-white h-screen overflow-y-auto"
style={{ width: '350px' }}
>
<h3 className="p-5 text-2xl">
{role.exists() ? 'Edit Role' : 'Create Role'}
</h3>
<div className="px-5">
<Form onSubmit={_save}>
<Form.Field>
<label>{'Title'}</label>
<Input
ref={focusElement}
name="name"
value={role.name}
onChange={write}
maxLength={40}
className={stl.input}
id="name-field"
placeholder="Ex. Admin"
/>
</Form.Field>
<div className="flex my-3"> <Form.Field>
<Checkbox <label>{'Project Access'}</label>
name="allProjects"
className="font-medium mr-3"
type="checkbox"
checked={role.allProjects}
onClick={toggleAllProjects}
label={''}
/>
<div className="cursor-pointer leading-none select-none" onClick={toggleAllProjects}>
<div>All Projects</div>
<span className="text-xs text-gray-600">(Uncheck to select specific projects)</span>
</div>
</div>
{!role.allProjects && (
<>
<Select
isSearchable
name="projects"
options={projectOptions}
onChange={({ value }: any) => writeOption({ name: 'projects', value: value.value })}
value={null}
/>
{role.projects.size > 0 && (
<div className="flex flex-row items-start flex-wrap mt-4">
{role.projects.map((p: any) => OptionLabel(projectsMap, p, onChangeProjects))}
</div>
)}
</>
)}
</Form.Field>
<Form.Field> <div className="flex my-3">
<label>{'Capability Access'}</label> <Checkbox
<Select name="allProjects"
isSearchable className="font-medium mr-3"
name="permissions" type="checkbox"
options={permissions} checked={role.allProjects}
onChange={({ value }: any) => writeOption({ name: 'permissions', value: value.value })} onClick={toggleAllProjects}
value={null} label={''}
/> />
{role.permissions.size > 0 && ( <div
<div className="flex flex-row items-start flex-wrap mt-4"> className="cursor-pointer leading-none select-none"
{role.permissions.map((p: any) => OptionLabel(permissionsMap, p, onChangePermissions))} onClick={toggleAllProjects}
</div> >
)} <div>All Projects</div>
</Form.Field> <span className="text-xs text-gray-600">
</Form> (Uncheck to select specific projects)
</span>
<div className="flex items-center"> </div>
<div className="flex items-center mr-auto">
<Button onClick={_save} disabled={!role.validate()} loading={saving} variant="primary" className="float-left mr-2">
{role.exists() ? 'Update' : 'Add'}
</Button>
{role.exists() && <Button onClick={closeModal}>{'Cancel'}</Button>}
</div>
{role.exists() && (
<Button variant="text" onClick={() => props.deleteHandler(role)}>
<Icon name="trash" size="18" />
</Button>
)}
</div>
</div> </div>
{!role.allProjects && (
<>
<Select
isSearchable
name="projects"
options={projectOptions}
onChange={({ value }: any) =>
writeOption({ name: 'projects', value: value.value })
}
value={null}
/>
{role.projects.size > 0 && (
<div className="flex flex-row items-start flex-wrap mt-4">
{role.projects.map((p: any) =>
OptionLabel(projectsMap, p, onChangeProjects)
)}
</div>
)}
</>
)}
</Form.Field>
<Form.Field>
<label>{'Capability Access'}</label>
<Select
isSearchable
name="permissions"
options={permissions}
onChange={({ value }: any) =>
writeOption({ name: 'permissions', value: value.value })
}
value={null}
/>
{role.permissions.length > 0 && (
<div className="flex flex-row items-start flex-wrap mt-4">
{role.permissions.map((p: any) =>
OptionLabel(permissionsMap, p, onChangePermissions)
)}
</div>
)}
</Form.Field>
</Form>
<div className="flex items-center">
<div className="flex items-center mr-auto">
<Button
onClick={_save}
disabled={!role.validate}
loading={saving}
variant="primary"
className="float-left mr-2"
>
{role.exists() ? 'Update' : 'Add'}
</Button>
{role.exists() && <Button onClick={closeModal}>{'Cancel'}</Button>}
</div>
{role.exists() && (
<Button variant="text" onClick={() => props.deleteHandler(role)}>
<Icon name="trash" size="18" />
</Button>
)}
</div> </div>
); </div>
</div>
);
}; };
export default connect( export default observer(RoleForm);
(state: any) => {
const role = state.getIn(['roles', 'instance']);
const projects = state.getIn(['site', 'list']);
return {
role,
projectOptions: projects
.map((p: any) => ({
key: p.get('id'),
value: p.get('id'),
label: p.get('name'),
// isDisabled: role.projects.includes(p.get('id')),
}))
.filter(({ value }: any) => !role.projects.includes(value))
.toJS(),
permissions: state
.getIn(['roles', 'permissions'])
.filter(({ value }: any) => !role.permissions.includes(value))
.map(({ text, value }: any) => ({ label: text, value }))
.toJS(),
saving: state.getIn(['roles', 'saveRequest', 'loading']),
projectsMap: projects.reduce((acc: any, p: any) => {
acc[p.get('id')] = p.get('name');
return acc;
}, {}),
};
},
{ edit, save }
)(RoleForm);
function OptionLabel(nameMap: any, p: any, onChangeOption: (e: any) => void) { function OptionLabel(nameMap: any, p: any, onChangeOption: (e: any) => void) {
return ( return (
<div className="px-2 py-1 rounded bg-gray-lightest mr-2 mb-2 border flex items-center justify-between" key={p.roleId}> <div
<div>{nameMap[p]}</div> className="px-2 py-1 rounded bg-gray-lightest mr-2 mb-2 border flex items-center justify-between"
<div className="cursor-pointer ml-2" onClick={() => onChangeOption(p)}> key={p.roleId}
<Icon name="close" size="12" /> >
</div> <div>{nameMap[p]}</div>
</div> <div className="cursor-pointer ml-2" onClick={() => onChangeOption(p)}>
); <Icon name="close" size="12" />
</div>
</div>
);
} }

View file

@ -1,8 +1,7 @@
import React from 'react'; import React from 'react';
import { Tooltip, Button } from 'UI'; import { Tooltip, Button } from 'UI';
import { useStore } from 'App/mstore'; import { useStore } from 'App/mstore';
import { useObserver } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite';
import { init, remove, fetchGDPR } from 'Duck/site';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { useModal } from 'App/components/Modal'; import { useModal } from 'App/components/Modal';
import NewSiteForm from '../NewSiteForm'; import NewSiteForm from '../NewSiteForm';
@ -10,16 +9,15 @@ import NewSiteForm from '../NewSiteForm';
const PERMISSION_WARNING = 'You dont have the permissions to perform this action.'; const PERMISSION_WARNING = 'You dont have the permissions to perform this action.';
const LIMIT_WARNING = 'You have reached site limit.'; const LIMIT_WARNING = 'You have reached site limit.';
function AddProjectButton({ isAdmin = false, init = () => {} }: any) { function AddProjectButton({ isAdmin = false }: any) {
const { userStore } = useStore(); const { userStore, projectsStore } = useStore();
const init = projectsStore.initProject;
const { showModal, hideModal } = useModal(); const { showModal, hideModal } = useModal();
const limtis = useObserver(() => userStore.limits); const limits = userStore.limits;
const canAddProject = useObserver( const canAddProject = isAdmin && (limits.projects === -1 || limits.projects > 0)
() => isAdmin && (limtis.projects === -1 || limtis.projects > 0)
);
const onClick = () => { const onClick = () => {
init(); init({});
showModal(<NewSiteForm onClose={hideModal} />, { right: true }); showModal(<NewSiteForm onClose={hideModal} />, { right: true });
}; };
return ( return (
@ -34,4 +32,4 @@ function AddProjectButton({ isAdmin = false, init = () => {} }: any) {
); );
} }
export default connect(null, { init, remove, fetchGDPR })(AddProjectButton); export default observer(AddProjectButton);

View file

@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import { connect } from 'react-redux'; import { observer } from 'mobx-react-lite';
import { useStore } from "App/mstore";
import { Form, Button, Input, Icon } from 'UI'; import { Form, Button, Input, Icon } from 'UI';
import { editGDPR, saveGDPR } from 'Duck/site';
import { validateNumber } from 'App/validate'; import { validateNumber } from 'App/validate';
import styles from './siteForm.module.css'; import styles from './siteForm.module.css';
import Select from 'Shared/Select'; import Select from 'Shared/Select';
@ -12,124 +12,118 @@ const inputModeOptions = [
{ label: 'Obscure all inputs', value: 'hidden' }, { label: 'Obscure all inputs', value: 'hidden' },
]; ];
@connect(state => ({ function GDPRForm(props) {
site: state.getIn([ 'site', 'instance' ]), const { projectsStore } = useStore();
gdpr: state.getIn([ 'site', 'instance', 'gdpr' ]), const site = projectsStore.instance;
saving: state.getIn([ 'site', 'saveGDPR', 'loading' ]), const gdpr = site.gdpr;
}), { const saving = false //projectsStore.;
editGDPR, const editGDPR = projectsStore.editGDPR;
saveGDPR, const saveGDPR = projectsStore.saveGDPR;
})
export default class GDPRForm extends React.PureComponent {
onChange = ({ target: { name, value } }) => { const onChange = ({ target: { name, value } }) => {
if (name === "sampleRate") { if (name === "sampleRate") {
if (!validateNumber(value, { min: 0, max: 100 })) return; if (!validateNumber(value, { min: 0, max: 100 })) return;
if (value.length > 1 && value[0] === "0") { if (value.length > 1 && value[0] === "0") {
value = value.slice(1); value = value.slice(1);
} }
} }
this.props.editGDPR({ [ name ]: value }); editGDPR({ [ name ]: value });
} }
onSampleRateBlur = ({ target: { name, value } }) => { //TODO: editState hoc const onSampleRateBlur = ({ target: { name, value } }) => { //TODO: editState hoc
if (value === ''){ if (value === ''){
this.props.editGDPR({ sampleRate: 100 }); editGDPR({ sampleRate: 100 });
} }
} }
onChangeSelect = ({ name, value }) => { const onChangeSelect = ({ name, value }) => {
this.props.editGDPR({ [ name ]: value }); props.editGDPR({ [ name ]: value });
}; };
onChangeOption = ({ target: { checked, name } }) => { const onChangeOption = ({ target: { checked, name } }) => {
this.props.editGDPR({ [ name ]: checked }); editGDPR({ [ name ]: checked });
} }
onSubmit = (e) => { const onSubmit = (e) => {
e.preventDefault(); e.preventDefault();
const { site, gdpr } = this.props; void saveGDPR(site.id);
this.props.saveGDPR(site.id, gdpr);
} }
return (
<Form className={ styles.formWrapper } onSubmit={ onSubmit }>
<div className={ styles.content }>
<Form.Field>
<label>{ 'Name' }</label>
<div>{ site.host }</div>
</Form.Field>
<Form.Field>
<label>{ 'Session Capture Rate' }</label>
<Input
icon="percent"
name="sampleRate"
value={ gdpr.sampleRate }
onChange={ onChange }
onBlur={ onSampleRateBlur }
className={ styles.sampleRate }
/>
</Form.Field>
render() { <Form.Field>
const { <label htmlFor="defaultInputMode">{ 'Data Recording Options' }</label>
site, onClose, saving, gdpr, <Select
} = this.props; name="defaultInputMode"
options={ inputModeOptions }
onChange={ onChangeSelect }
placeholder="Default Input Mode"
value={ gdpr.defaultInputMode }
/>
</Form.Field>
return ( <Form.Field>
<Form className={ styles.formWrapper } onSubmit={ this.onSubmit }> <label>
<div className={ styles.content }> <input
<Form.Field> name="maskNumbers"
<label>{ 'Name' }</label> type="checkbox"
<div>{ site.host }</div> checked={ gdpr.maskNumbers }
</Form.Field> onChange={ onChangeOption }
<Form.Field>
<label>{ 'Session Capture Rate' }</label>
<Input
icon="percent"
name="sampleRate"
value={ gdpr.sampleRate }
onChange={ this.onChange }
onBlur={ this.onSampleRateBlur }
className={ styles.sampleRate }
/> />
</Form.Field> { 'Do not record any numeric text' }
<div className={ styles.controlSubtext }>{ 'If enabled, OpenReplay will not record or store any numeric text for all sessions.' }</div>
</label>
</Form.Field>
<Form.Field> <Form.Field>
<label htmlFor="defaultInputMode">{ 'Data Recording Options' }</label> <label>
<Select <input
name="defaultInputMode" name="maskEmails"
options={ inputModeOptions } type="checkbox"
onChange={ this.onChangeSelect } checked={ gdpr.maskEmails }
placeholder="Default Input Mode" onChange={ onChangeOption }
value={ gdpr.defaultInputMode }
// className={ styles.dropdown }
/> />
</Form.Field> { 'Do not record email addresses ' }
<div className={ styles.controlSubtext }>{ 'If enabled, OpenReplay will not record or store any email address for all sessions.' }</div>
</label>
</Form.Field>
<Form.Field> <div className={ styles.blockIpWarapper }>
<label> <div className={ styles.button } onClick={ props.toggleBlockedIp }>
<input { 'Block IP' } <Icon name="next1" size="18" />
name="maskNumbers"
type="checkbox"
checked={ gdpr.maskNumbers }
onChange={ this.onChangeOption }
/>
{ 'Do not record any numeric text' }
<div className={ styles.controlSubtext }>{ 'If enabled, OpenReplay will not record or store any numeric text for all sessions.' }</div>
</label>
</Form.Field>
<Form.Field>
<label>
<input
name="maskEmails"
type="checkbox"
checked={ gdpr.maskEmails }
onChange={ this.onChangeOption }
/>
{ 'Do not record email addresses ' }
<div className={ styles.controlSubtext }>{ 'If enabled, OpenReplay will not record or store any email address for all sessions.' }</div>
</label>
</Form.Field>
<div className={ styles.blockIpWarapper }>
<div className={ styles.button } onClick={ this.props.toggleBlockedIp }>
{ 'Block IP' } <Icon name="next1" size="18" />
</div>
</div> </div>
</div> </div>
</div>
<div className={ styles.footer }> <div className={ styles.footer }>
<Button <Button
variant="outline" variant="outline"
className="float-left mr-2" className="float-left mr-2"
loading={ saving } loading={ saving }
content="Update" content="Update"
/> />
<Button onClick={ onClose } content="Cancel" /> <Button onClick={ onClose } content="Cancel" />
</div> </div>
</Form> </Form>
); )
}
} }
export default observer(GDPRForm);

View file

@ -3,22 +3,17 @@ import React, { ChangeEvent, FormEvent, useEffect, useState } from 'react';
import { ConnectedProps, connect } from 'react-redux'; import { ConnectedProps, connect } from 'react-redux';
import { RouteComponentProps, withRouter } from 'react-router-dom'; import { RouteComponentProps, withRouter } from 'react-router-dom';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { useStore } from 'App/mstore';
import { withStore } from 'App/mstore';
import { clearSearch as clearSearchLive } from 'Duck/liveSearch'; import { clearSearch as clearSearchLive } from 'Duck/liveSearch';
import { clearSearch } from 'Duck/search';
import { edit, fetchList, remove, save, update } from 'Duck/site';
import { setSiteId } from 'Duck/site';
import { pushNewSite } from 'Duck/user'; import { pushNewSite } from 'Duck/user';
import { Button, Form, Icon, Input, SegmentSelection } from 'UI'; import { Button, Form, Icon, Input, SegmentSelection } from 'UI';
import { confirm } from 'UI'; import { confirm } from 'UI';
import { observer } from 'mobx-react-lite';
import styles from './siteForm.module.css'; import styles from './siteForm.module.css';
type OwnProps = { type OwnProps = {
onClose: (arg: any) => void; onClose: (arg: any) => void;
mstore: any;
canDelete: boolean;
}; };
type PropsFromRedux = ConnectedProps<typeof connector>; type PropsFromRedux = ConnectedProps<typeof connector>;
@ -26,36 +21,34 @@ type PropsFromRedux = ConnectedProps<typeof connector>;
type Props = PropsFromRedux & RouteComponentProps & OwnProps; type Props = PropsFromRedux & RouteComponentProps & OwnProps;
const NewSiteForm = ({ const NewSiteForm = ({
site,
loading,
save,
remove,
edit,
update,
pushNewSite,
fetchList,
setSiteId,
clearSearch,
clearSearchLive, clearSearchLive,
location: { pathname }, location: { pathname },
onClose, onClose,
mstore,
activeSiteId,
canDelete,
}: Props) => { }: Props) => {
const mstore = useStore();
const { projectsStore } = mstore;
const activeSiteId = projectsStore.active?.id
const site = projectsStore.instance;
const siteList = projectsStore.list;
const loading = projectsStore.loading;
const canDelete = siteList.length > 1;
const setSiteId = projectsStore.setSiteId;
const saveProject = projectsStore.save;
const fetchList = projectsStore.fetchList;
const [existsError, setExistsError] = useState(false); const [existsError, setExistsError] = useState(false);
const { searchStore } = useStore();
useEffect(() => { useEffect(() => {
if (pathname.includes('onboarding')) { if (pathname.includes('onboarding') && site?.id) {
setSiteId(site.id); setSiteId(site.id);
} }
if (!site) projectsStore.initProject({});
}, []); }, []);
const onSubmit = (e: FormEvent) => { const onSubmit = (e: FormEvent) => {
e.preventDefault(); e.preventDefault();
if (site?.id && site.exists()) {
if (site.exists()) { projectsStore.updateProject( site.id, site.toData()).then((response: any) => {
update(site, site.id).then((response: any) => {
if (!response || !response.errors || response.errors.size === 0) { if (!response || !response.errors || response.errors.size === 0) {
onClose(null); onClose(null);
if (!pathname.includes('onboarding')) { if (!pathname.includes('onboarding')) {
@ -67,10 +60,10 @@ const NewSiteForm = ({
} }
}); });
} else { } else {
save(site).then((response: any) => { saveProject(site!).then((response: any) => {
if (!response || !response.errors || response.errors.size === 0) { if (!response || !response.errors || response.errors.size === 0) {
onClose(null); onClose(null);
clearSearch(); searchStore.clearSearch();
clearSearchLive(); clearSearchLive();
mstore.initClient(); mstore.initClient();
toast.success('Project added successfully'); toast.success('Project added successfully');
@ -89,8 +82,9 @@ const NewSiteForm = ({
confirmButton: 'Yes, delete', confirmButton: 'Yes, delete',
cancelButton: 'Cancel', cancelButton: 'Cancel',
}) })
&& site?.id
) { ) {
remove(site.id).then(() => { projectsStore.removeProject(site.id).then(() => {
onClose(null); onClose(null);
if (site.id === activeSiteId) { if (site.id === activeSiteId) {
setSiteId(null); setSiteId(null);
@ -103,9 +97,12 @@ const NewSiteForm = ({
target: { name, value }, target: { name, value },
}: ChangeEvent<HTMLInputElement>) => { }: ChangeEvent<HTMLInputElement>) => {
setExistsError(false); setExistsError(false);
edit({ [name]: value }); projectsStore.editInstance({ [name]: value });
}; };
if (!site) {
return null
}
return ( return (
<div <div
className="bg-white h-screen overflow-y-auto" className="bg-white h-screen overflow-y-auto"
@ -116,7 +113,7 @@ const NewSiteForm = ({
</h3> </h3>
<Form <Form
className={styles.formWrapper} className={styles.formWrapper}
onSubmit={site.validate() && onSubmit} onSubmit={site.validate && onSubmit}
> >
<div className={styles.content}> <div className={styles.content}>
<Form.Field> <Form.Field>
@ -146,7 +143,7 @@ const NewSiteForm = ({
]} ]}
value={site.platform} value={site.platform}
onChange={(value) => { onChange={(value) => {
edit({ platform: value }); projectsStore.editInstance({ platform: value });
}} }}
/> />
</div> </div>
@ -157,9 +154,9 @@ const NewSiteForm = ({
type="submit" type="submit"
className="float-left mr-2" className="float-left mr-2"
loading={loading} loading={loading}
disabled={!site.validate()} disabled={!site.validate}
> >
{site.exists() ? 'Update' : 'Add'} {site?.exists() ? 'Update' : 'Add'}
</Button> </Button>
{site.exists() && ( {site.exists() && (
<Button <Button
@ -183,26 +180,9 @@ const NewSiteForm = ({
); );
}; };
const mapStateToProps = (state: any) => ({ const mapStateToProps = null;
activeSiteId: state.getIn(['site', 'active', 'id']),
site: state.getIn(['site', 'instance']),
siteList: state.getIn(['site', 'list']),
loading:
state.getIn(['site', 'save', 'loading']) ||
state.getIn(['site', 'remove', 'loading']),
canDelete: state.getIn(['site', 'list']).size > 1,
});
const connector = connect(mapStateToProps, { const connector = connect(mapStateToProps, {
save,
remove,
edit,
update,
pushNewSite,
fetchList,
setSiteId,
clearSearch,
clearSearchLive, clearSearchLive,
}); });
export default connector(withRouter(withStore(NewSiteForm))); export default connector(withRouter(observer(NewSiteForm)));

View file

@ -3,7 +3,6 @@ import { connect, ConnectedProps } from 'react-redux';
import { Tag } from 'antd'; import { Tag } from 'antd';
import cn from 'classnames'; import cn from 'classnames';
import { Loader, Button, TextLink, NoContent, Pagination, PageTitle, Divider, Icon } from 'UI'; import { Loader, Button, TextLink, NoContent, Pagination, PageTitle, Divider, Icon } from 'UI';
import { init, remove, fetchGDPR, setSiteId } from 'Duck/site';
import withPageTitle from 'HOCs/withPageTitle'; import withPageTitle from 'HOCs/withPageTitle';
import stl from './sites.module.css'; import stl from './sites.module.css';
import NewSiteForm from './NewSiteForm'; import NewSiteForm from './NewSiteForm';
@ -16,9 +15,11 @@ import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG';
import { useModal } from 'App/components/Modal'; import { useModal } from 'App/components/Modal';
import CaptureRate from 'Shared/SessionSettings/components/CaptureRate'; import CaptureRate from 'Shared/SessionSettings/components/CaptureRate';
import { BranchesOutlined } from '@ant-design/icons'; import { BranchesOutlined } from '@ant-design/icons';
import { observer } from 'mobx-react-lite';
import { useStore } from 'App/mstore'
type Project = { type Project = {
id: number; id: string;
name: string; name: string;
conditionsCount: number; conditionsCount: number;
platform: 'web' | 'mobile'; platform: 'web' | 'mobile';
@ -29,7 +30,11 @@ type Project = {
type PropsFromRedux = ConnectedProps<typeof connector>; type PropsFromRedux = ConnectedProps<typeof connector>;
const Sites = ({ loading, sites, user, init }: PropsFromRedux) => { const Sites = ({ user }: PropsFromRedux) => {
const { projectsStore } = useStore();
const sites = projectsStore.list;
const loading = projectsStore.sitesLoading;
const init = projectsStore.initProject
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [showCaptureRate, setShowCaptureRate] = useState(true); const [showCaptureRate, setShowCaptureRate] = useState(true);
const [activeProject, setActiveProject] = useState<Project | null>(null); const [activeProject, setActiveProject] = useState<Project | null>(null);
@ -140,7 +145,7 @@ const Sites = ({ loading, sites, user, init }: PropsFromRedux) => {
</div> </div>
} }
size="small" size="small"
show={!loading && filteredSites.size === 0} show={!loading && filteredSites.length === 0}
> >
<div className="grid grid-cols-12 gap-2 w-full items-center px-5 py-3 font-medium"> <div className="grid grid-cols-12 gap-2 w-full items-center px-5 py-3 font-medium">
<div className="col-span-4">Project Name</div> <div className="col-span-4">Project Name</div>
@ -160,7 +165,7 @@ const Sites = ({ loading, sites, user, init }: PropsFromRedux) => {
<div className="w-full flex items-center justify-center py-10"> <div className="w-full flex items-center justify-center py-10">
<Pagination <Pagination
page={page} page={page}
total={filteredSites.size} total={filteredSites.length}
onPageChange={(page) => updatePage(page)} onPageChange={(page) => updatePage(page)}
limit={pageSize} limit={pageSize}
/> />
@ -181,18 +186,10 @@ const Sites = ({ loading, sites, user, init }: PropsFromRedux) => {
}; };
const mapStateToProps = (state: any) => ({ const mapStateToProps = (state: any) => ({
site: state.getIn(['site', 'instance']),
sites: state.getIn(['site', 'list']),
loading: state.getIn(['site', 'loading']),
user: state.getIn(['user', 'account']), user: state.getIn(['user', 'account']),
account: state.getIn(['user', 'account']), account: state.getIn(['user', 'account']),
}); });
const connector = connect(mapStateToProps, { const connector = connect(mapStateToProps, null);
init,
remove,
fetchGDPR,
setSiteId,
});
export default connector(withPageTitle('Projects - OpenReplay Preferences')(Sites)); export default connector(withPageTitle('Projects - OpenReplay Preferences')(observer(Sites)));

View file

@ -81,7 +81,6 @@ export default connect(
insightsFilters: state.getIn(['sessions', 'insightFilters']), insightsFilters: state.getIn(['sessions', 'insightFilters']),
visitedEvents: state.getIn(['sessions', 'visitedEvents']), visitedEvents: state.getIn(['sessions', 'visitedEvents']),
insights: state.getIn(['sessions', 'insights']), insights: state.getIn(['sessions', 'insights']),
host: state.getIn(['sessions', 'host']),
}), }),
{ fetchInsights, } { fetchInsights, }
) )

View file

@ -24,11 +24,12 @@ import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG';
import DashboardEditModal from '../DashboardEditModal'; import DashboardEditModal from '../DashboardEditModal';
function DashboardList({ siteId }: { siteId: string }) { function DashboardList() {
const { dashboardStore, projectsStore } = useStore();
const siteId = projectsStore.siteId;
const [focusTitle, setFocusedInput] = React.useState(true); const [focusTitle, setFocusedInput] = React.useState(true);
const [showEditModal, setShowEditModal] = React.useState(false); const [showEditModal, setShowEditModal] = React.useState(false);
const { dashboardStore } = useStore();
const list = dashboardStore.filteredList; const list = dashboardStore.filteredList;
const dashboardsSearch = dashboardStore.filter.query; const dashboardsSearch = dashboardStore.filter.query;
const history = useHistory(); const history = useHistory();
@ -219,6 +220,4 @@ function DashboardList({ siteId }: { siteId: string }) {
); );
} }
export default connect((state: any) => ({ export default observer(DashboardList);
siteId: state.getIn(['site', 'siteId']),
}))(observer(DashboardList));

View file

@ -2,7 +2,8 @@ import { Modal } from 'antd';
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import colors from 'tailwindcss/colors'; import colors from 'tailwindcss/colors';
import { observer } from 'mobx-react-lite';
import { useStore } from 'App/mstore';
import CreateCard from 'Components/Dashboard/components/DashboardList/NewDashModal/CreateCard'; import CreateCard from 'Components/Dashboard/components/DashboardList/NewDashModal/CreateCard';
import SelectCard from './SelectCard'; import SelectCard from './SelectCard';
@ -12,7 +13,6 @@ interface NewDashboardModalProps {
open: boolean; open: boolean;
isAddingFromLibrary?: boolean; isAddingFromLibrary?: boolean;
isEnterprise?: boolean; isEnterprise?: boolean;
isMobile?: boolean;
} }
const NewDashboardModal: React.FC<NewDashboardModalProps> = ({ const NewDashboardModal: React.FC<NewDashboardModalProps> = ({
@ -20,8 +20,9 @@ const NewDashboardModal: React.FC<NewDashboardModalProps> = ({
open, open,
isAddingFromLibrary = false, isAddingFromLibrary = false,
isEnterprise = false, isEnterprise = false,
isMobile = false,
}) => { }) => {
const { projectsStore } = useStore();
const isMobile = projectsStore.isMobile;
const [step, setStep] = React.useState<number>(0); const [step, setStep] = React.useState<number>(0);
const [selectedCategory, setSelectedCategory] = const [selectedCategory, setSelectedCategory] =
React.useState<string>('product-analytics'); React.useState<string>('product-analytics');
@ -75,10 +76,9 @@ const NewDashboardModal: React.FC<NewDashboardModalProps> = ({
}; };
const mapStateToProps = (state: any) => ({ const mapStateToProps = (state: any) => ({
isMobile: state.getIn(['site', 'instance', 'platform']) === 'ios',
isEnterprise: isEnterprise:
state.getIn(['user', 'account', 'edition']) === 'ee' || state.getIn(['user', 'account', 'edition']) === 'ee' ||
state.getIn(['user', 'account', 'edition']) === 'msaas', state.getIn(['user', 'account', 'edition']) === 'msaas',
}); });
export default connect(mapStateToProps)(NewDashboardModal); export default connect(mapStateToProps)(observer(NewDashboardModal));

View file

@ -2,17 +2,13 @@ import React from 'react';
import { SideMenuitem } from 'UI'; import { SideMenuitem } from 'UI';
import { withRouter, RouteComponentProps } from 'react-router-dom'; import { withRouter, RouteComponentProps } from 'react-router-dom';
import { withSiteId, metrics, dashboard, alerts } from 'App/routes'; import { withSiteId, metrics, dashboard, alerts } from 'App/routes';
import { connect } from 'react-redux';
import { compose } from 'redux';
import { setShowAlerts } from 'Duck/dashboard';
interface Props extends RouteComponentProps { interface Props extends RouteComponentProps {
siteId: string; siteId: string;
history: any; history: any;
setShowAlerts: (show: boolean) => void;
} }
function DashboardSideMenu(props: Props) { function DashboardSideMenu(props: Props) {
const { history, siteId, setShowAlerts } = props; const { history, siteId } = props;
const isMetric = history.location.pathname.includes('metrics'); const isMetric = history.location.pathname.includes('metrics');
const isDashboards = history.location.pathname.includes('dashboard'); const isDashboards = history.location.pathname.includes('dashboard');
const isAlerts = history.location.pathname.includes('alerts'); const isAlerts = history.location.pathname.includes('alerts');
@ -57,4 +53,4 @@ function DashboardSideMenu(props: Props) {
); );
} }
export default compose(withRouter, connect(null, { setShowAlerts }))(DashboardSideMenu); export default withRouter(DashboardSideMenu);

View file

@ -1,67 +0,0 @@
import React, { useState } from 'react'
import ErrorFrame from './ErrorFrame'
import { IconButton, Icon } from 'UI';
const docLink = 'https://docs.openreplay.com/installation/upload-sourcemaps';
interface Props {
error: any,
errorStack: any,
}
function ErrorDetails({ className, name = "Error", message, errorStack, sourcemapUploaded }: any) {
const [showRaw, setShowRaw] = useState(false)
const firstFunc = errorStack.first() && errorStack.first().function
const openDocs = () => {
window.open(docLink, '_blank');
}
return (
<div className={className} >
{ !sourcemapUploaded && (
<div
style={{ backgroundColor: 'rgba(204, 0, 0, 0.1)' }}
className="font-normal flex items-center text-sm font-regular color-red border p-2 rounded"
>
<Icon name="info" size="16" color="red" />
<div className="ml-2">Source maps must be uploaded to OpenReplay to be able to see stack traces. <a href="#" className="color-red font-medium underline" style={{ textDecoration: 'underline' }} onClick={openDocs}>Learn more.</a></div>
</div>
) }
<div className="flex items-center my-3">
<h3 className="text-xl mr-auto">
Stacktrace
</h3>
<div className="flex justify-end mr-2">
<IconButton
onClick={() => setShowRaw(false) }
label="FULL"
plain={!showRaw}
primaryText={!showRaw}
/>
<IconButton
primaryText={showRaw}
onClick={() => setShowRaw(true) }
plain={showRaw}
label="RAW"
/>
</div>
</div>
<div className="mb-6 code-font" data-hidden={showRaw}>
<div className="leading-relaxed font-weight-bold">{ name }</div>
<div style={{ wordBreak: 'break-all'}}>{message}</div>
</div>
{ showRaw &&
<div className="mb-3 code-font">{name} : {firstFunc ? firstFunc : '?' }</div>
}
{ errorStack.map((frame: any, i: any) => (
<div className="mb-3" key={frame.key}>
<ErrorFrame frame={frame} showRaw={showRaw} isFirst={i == 0} />
</div>
))
}
</div>
)
}
ErrorDetails.displayName = "ErrorDetails";
export default ErrorDetails;

View file

@ -1,53 +0,0 @@
import React, { useState } from 'react'
import { Icon } from 'UI';
import cn from 'classnames';
import stl from './errorFrame.module.css';
function ErrorFrame({ frame = {}, showRaw, isFirst }) {
const [open, setOpen] = useState(isFirst)
const hasContext = frame.context && frame.context.length > 0;
return (
<div>
{ showRaw ?
<div className={stl.rawLine}>at { frame.function ? frame.function : '?' } <span className="color-gray-medium">({`${frame.filename}:${frame.lineNo}:${frame.colNo}`})</span></div>
:
<div className={stl.formatted}>
<div className={cn(stl.header, 'flex items-center cursor-pointer')} onClick={() => setOpen(!open)}>
<div className="truncate">
<span className="font-medium">{ frame.absPath }</span>
{ frame.function &&
<>
<span>{' in '}</span>
<span className="font-medium"> {frame.function} </span>
</>
}
<span>{' at line '}</span>
<span className="font-medium">
{frame.lineNo}:{frame.colNo}
</span>
</div>
{ hasContext &&
<div className="ml-auto mr-3">
<Icon name={ open ? 'minus' : 'plus'} size="14" color="gray-medium" />
</div>
}
</div>
{ open && hasContext &&
<ol start={ frame.context[0][0]} className={stl.content}>
{ frame.context.map(i => (
<li
key={i[0]}
className={ cn("leading-7 text-sm break-all h-auto pl-2", { [stl.errorLine] :i[0] == frame.lineNo }) }
>
<span>{ i[1].replace(/ /g, "\u00a0") }</span>
</li>
))}
</ol>
}
</div>
}
</div>
)
}
export default ErrorFrame;

View file

@ -1,25 +0,0 @@
.rawLine {
margin-left: 30px;
font-family: 'Menlo', 'monaco', 'consolas', monospace;
font-size: 13px;
}
.formatted {
border: solid thin #EEE;
border-radius: 3px;
}
.header {
background-color: $gray-lightest;
padding: 8px;
border-bottom: solid thin #EEE;
}
.content {
font-family: 'Menlo', 'monaco', 'consolas', monospace;
list-style-position: inside;
list-style-type: decimal-leading-zero;
}
.errorLine {
background-color: $teal;
color: white !important;
font-weight: bold;
}

View file

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

View file

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

View file

@ -17,10 +17,7 @@ function ErrorListItem(props: Props) {
const { error, className = '' } = props; const { error, className = '' } = props;
// const { showModal } = useModal(); // const { showModal } = useModal();
// const onClick = () => {
// alert('test')
// showModal(<ErrorDetailsModal />, { right: true });
// }
return ( return (
<div <div
className={ cn("p-3 grid grid-cols-12 gap-4 cursor-pointer py-4 hover:bg-active-blue", className) } className={ cn("p-3 grid grid-cols-12 gap-4 cursor-pointer py-4 hover:bg-active-blue", className) }
@ -49,7 +46,7 @@ function ErrorListItem(props: Props) {
<Bar name="Sessions" minPointSize={1} dataKey="count" fill="#A8E0DA" /> <Bar name="Sessions" minPointSize={1} dataKey="count" fill="#A8E0DA" />
</BarChart> </BarChart>
</div> </div>
<ErrorLabel <ErrorLabel
// className={stl.sessions} // className={stl.sessions}
topValue={ error.sessions } topValue={ error.sessions }
bottomValue="Sessions" bottomValue="Sessions"
@ -84,6 +81,6 @@ const CustomTooltip = ({ active, payload, label }: any) => {
</div> </div>
); );
} }
return null; return null;
}; };

View file

@ -1,21 +0,0 @@
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

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

View file

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

View file

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

View file

@ -1,4 +1,3 @@
import filters from 'App/duck/filters';
import Filter from 'App/mstore/types/filter'; import Filter from 'App/mstore/types/filter';
import { FilterKey } from 'App/types/filter/filterType'; import { FilterKey } from 'App/types/filter/filterType';
import { observer } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite';

View file

@ -1,89 +1,48 @@
import { observer } from 'mobx-react-lite';
import React from 'react'; import React from 'react';
import { connect } from 'react-redux';
import withSiteIdRouter from 'HOCs/withSiteIdRouter'; import { useStore } from 'App/mstore';
import { error as errorRoute } from 'App/routes'; import { Loader, NoContent } from 'UI';
import { NoContent, Loader } from 'UI';
import { fetch, fetchTrace } from 'Duck/errors';
import MainSection from './MainSection';
import SideSection from './SideSection';
import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG'; import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG';
@connect( import MainSection from './MainSection';
(state) => ({ import SideSection from './SideSection';
errorIdInStore: state.getIn(['errors', 'instance']).errorId,
list: state.getIn(['errors', 'instanceTrace']),
loading:
state.getIn(['errors', 'fetch', 'loading']) ||
state.getIn(['errors', 'fetchTrace', 'loading']),
errorOnFetch:
state.getIn(['errors', 'fetch', 'errors']) || state.getIn(['errors', 'fetchTrace', 'errors']),
}),
{
fetch,
fetchTrace,
}
)
@withSiteIdRouter
export default class ErrorInfo extends React.PureComponent {
ensureInstance() {
const { errorId, loading, errorOnFetch } = this.props;
if (!loading && this.props.errorIdInStore !== errorId && errorId != null) {
this.props.fetch(errorId);
this.props.fetchTrace(errorId);
}
}
componentDidMount() {
this.ensureInstance();
}
componentDidUpdate(prevProps) {
if (prevProps.errorId !== this.props.errorId || prevProps.errorIdInStore !== this.props.errorIdInStore) {
this.ensureInstance();
}
}
next = () => {
const { list, errorId } = this.props;
const curIndex = list.findIndex((e) => e.errorId === errorId);
const next = list.get(curIndex + 1);
if (next != null) {
this.props.history.push(errorRoute(next.errorId));
}
};
prev = () => {
const { list, errorId } = this.props;
const curIndex = list.findIndex((e) => e.errorId === errorId);
const prev = list.get(curIndex - 1);
if (prev != null) {
this.props.history.push(errorRoute(prev.errorId));
}
};
render() {
const { loading, errorIdInStore, list, errorId } = this.props;
let nextDisabled = true, function ErrorInfo(props) {
prevDisabled = true; const { errorStore } = useStore();
if (list.size > 0) { const instance = errorStore.instance;
nextDisabled = loading || list.last().errorId === errorId; const ensureInstance = () => {
prevDisabled = loading || list.first().errorId === errorId; if (errorStore.isLoading) return;
} errorStore.fetchError(props.errorId);
errorStore.fetchErrorTrace(props.errorId);
};
return ( React.useEffect(() => {
<NoContent ensureInstance();
title={ }, [props.errorId]);
<div className="flex flex-col items-center justify-center">
<AnimatedSVG name={ICONS.EMPTY_STATE} size="170" /> const errorIdInStore = errorStore.instance?.errorId;
<div className="mt-4">No Error Found!</div> const loading = errorStore.isLoading;
</div> return (
} <NoContent
subtext="Please try to find existing one." title={
show={!loading && errorIdInStore == null} <div className="flex flex-col items-center justify-center">
> <AnimatedSVG name={ICONS.EMPTY_STATE} size="170" />
<div className="flex w-full"> <div className="mt-4">No Error Found!</div>
<Loader loading={loading} className="w-full">
<MainSection className="w-9/12" />
<SideSection className="w-3/12" />
</Loader>
</div> </div>
</NoContent> }
); subtext="Please try to find existing one."
} show={!loading && errorIdInStore == null}
>
<div className="flex w-full">
<Loader loading={loading || !instance} className="w-full">
<MainSection className="w-9/12" />
<SideSection className="w-3/12" />
</Loader>
</div>
</NoContent>
);
} }
export default observer(ErrorInfo);

View file

@ -1,154 +1,133 @@
import { RESOLVED } from 'Types/errorInfo';
import { FilterKey } from 'Types/filter/filterType';
import cn from 'classnames';
import { observer } from 'mobx-react-lite';
import React from 'react'; import React from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import cn from 'classnames'; import { withRouter } from 'react-router-dom';
import withSiteIdRouter from 'HOCs/withSiteIdRouter';
import { ErrorDetails, Icon, Loader, Button } from 'UI';
import { sessions as sessionsRoute } from 'App/routes';
import { RESOLVED } from 'Types/errorInfo';
import { addFilterByKeyAndValue } from 'Duck/search';
import { resolve, unresolve, ignore, toggleFavorite } from 'Duck/errors';
import { resentOrDate } from 'App/date'; import { resentOrDate } from 'App/date';
import { useStore } from 'App/mstore';
import { sessions as sessionsRoute } from 'App/routes';
import Divider from 'Components/Errors/ui/Divider'; import Divider from 'Components/Errors/ui/Divider';
import ErrorName from 'Components/Errors/ui/ErrorName'; import ErrorName from 'Components/Errors/ui/ErrorName';
import Label from 'Components/Errors/ui/Label'; import Label from 'Components/Errors/ui/Label';
import { FilterKey } from 'Types/filter/filterType'; import { Button, ErrorDetails, Icon, Loader } from 'UI';
import SessionBar from './SessionBar'; import SessionBar from './SessionBar';
@withSiteIdRouter function MainSection(props) {
@connect( const { errorStore, searchStore } = useStore();
(state) => ({ const error = errorStore.instance;
error: state.getIn(['errors', 'instance']), const trace = errorStore.instanceTrace;
trace: state.getIn(['errors', 'instanceTrace']), const sourcemapUploaded = errorStore.sourcemapUploaded;
sourcemapUploaded: state.getIn(['errors', 'sourcemapUploaded']), const loading = errorStore.isLoading;
resolveToggleLoading: const className = props.className;
state.getIn(['errors', 'resolve', 'loading']) ||
state.getIn(['errors', 'unresolve', 'loading']),
ignoreLoading: state.getIn(['errors', 'ignore', 'loading']),
toggleFavoriteLoading: state.getIn(['errors', 'toggleFavorite', 'loading']),
traceLoading: state.getIn(['errors', 'fetchTrace', 'loading']),
}),
{
resolve,
unresolve,
ignore,
toggleFavorite,
addFilterByKeyAndValue,
}
)
export default class MainSection extends React.PureComponent {
resolve = () => {
const { error } = this.props;
this.props.resolve(error.errorId);
};
unresolve = () => { const findSessions = () => {
const { error } = this.props; searchStore.addFilterByKeyAndValue(FilterKey.ERROR, error.message);
this.props.unresolve(error.errorId); props.history.push(sessionsRoute());
}; };
return (
ignore = () => { <div
const { error } = this.props; className={cn(
this.props.ignore(error.errorId); className,
}; 'bg-white border-radius-3 thin-gray-border mb-6'
bookmark = () => { )}
const { error } = this.props; >
this.props.toggleFavorite(error.errorId); <div className="m-4">
}; <ErrorName
className="text-lg leading-relaxed"
findSessions = () => { name={error.name}
this.props.addFilterByKeyAndValue(FilterKey.ERROR, this.props.error.message); message={error.stack0InfoString}
this.props.history.push(sessionsRoute()); lineThrough={error.status === RESOLVED}
}; />
<div className="flex flex-col">
render() { <div
const { className="flex items-center color-gray-dark font-semibold"
error, style={{ wordBreak: 'break-all' }}
trace, >
sourcemapUploaded, {error.message}
ignoreLoading, </div>
resolveToggleLoading, <div className="flex items-center mt-2">
toggleFavoriteLoading, <div className="flex">
className, <Label
traceLoading, topValue={error.sessions}
} = this.props; horizontal
const isPlayer = window.location.pathname.includes('/session/'); topValueSize="text-lg"
bottomValue="Sessions"
return ( />
<div className={cn(className, 'bg-white border-radius-3 thin-gray-border mb-6')}> <Label
<div className="m-4"> topValue={error.users}
<ErrorName horizontal
className="text-lg leading-relaxed" topValueSize="text-lg"
name={error.name} bottomValue="Users"
message={error.stack0InfoString} />
lineThrough={error.status === RESOLVED}
/>
<div className="flex flex-col">
<div
className="flex items-center color-gray-dark font-semibold"
style={{ wordBreak: 'break-all' }}
>
{error.message}
</div> </div>
<div className="flex items-center mt-2"> <div className="text-xs color-gray-medium">
<div className="flex"> Over the past 30 days
<Label
topValue={error.sessions}
horizontal
topValueSize="text-lg"
bottomValue="Sessions"
/>
<Label
topValue={error.users}
horizontal
topValueSize="text-lg"
bottomValue="Users"
/>
</div>
<div className="text-xs color-gray-medium">Over the past 30 days</div>
</div> </div>
</div> </div>
</div> </div>
<Divider />
<div className="m-4">
<div className="flex items-center">
<h3 className="text-xl inline-block mr-2">Last session with this error</h3>
<span className="font-thin text-sm">{resentOrDate(error.lastOccurrence)}</span>
<Button className="ml-auto" variant="text-primary" onClick={this.findSessions}>
Find all sessions with this error
<Icon className="ml-1" name="next1" color="teal" />
</Button>
</div>
<SessionBar className="my-4" session={error.lastHydratedSession} />
{error.customTags.length > 0 ? (
<div className="flex items-start flex-col">
<div>
<span className="font-semibold">More Info</span> <span className="text-disabled-text">(most recent call)</span>
</div>
<div className="mt-4 flex items-center gap-3 w-full flex-wrap">
{error.customTags.map((tag) => (
<div className="flex items-center rounded overflow-hidden bg-gray-lightest">
<div className="bg-gray-light-shade py-1 px-2 text-disabled-text">{Object.entries(tag)[0][0]}</div> <div className="py-1 px-2 text-gray-dark">{Object.entries(tag)[0][1]}</div>
</div>
))}
</div>
</div>
) : null}
</div>
<Divider />
<div className="m-4">
<Loader loading={traceLoading}>
<ErrorDetails
name={error.name}
message={error.message}
errorStack={trace}
error={error}
sourcemapUploaded={sourcemapUploaded}
/>
</Loader>
</div>
</div> </div>
);
} <Divider />
<div className="m-4">
<div className="flex items-center">
<h3 className="text-xl inline-block mr-2">
Last session with this error
</h3>
<span className="font-thin text-sm">
{resentOrDate(error.lastOccurrence)}
</span>
<Button
className="ml-auto"
variant="text-primary"
onClick={findSessions}
>
Find all sessions with this error
<Icon className="ml-1" name="next1" color="teal" />
</Button>
</div>
<SessionBar className="my-4" session={error.lastHydratedSession} />
{error.customTags.length > 0 ? (
<div className="flex items-start flex-col">
<div>
<span className="font-semibold">More Info</span>{' '}
<span className="text-disabled-text">(most recent call)</span>
</div>
<div className="mt-4 flex items-center gap-3 w-full flex-wrap">
{error.customTags.map((tag) => (
<div className="flex items-center rounded overflow-hidden bg-gray-lightest">
<div className="bg-gray-light-shade py-1 px-2 text-disabled-text">
{Object.entries(tag)[0][0]}
</div>
{' '}
<div className="py-1 px-2 text-gray-dark">
{Object.entries(tag)[0][1]}
</div>
</div>
))}
</div>
</div>
) : null}
</div>
<Divider />
<div className="m-4">
<Loader loading={loading}>
<ErrorDetails
name={error.name}
message={error.message}
errorStack={trace}
error={error}
sourcemapUploaded={sourcemapUploaded}
/>
</Loader>
</div>
</div>
);
} }
export default withRouter(
connect(null)(observer(MainSection))
);

View file

@ -1,123 +1,120 @@
import React from 'react';
import { connect } from 'react-redux';
import cn from 'classnames'; import cn from 'classnames';
import withRequest from 'HOCs/withRequest'; import { observer } from 'mobx-react-lite';
import { Loader } from 'UI'; import React from 'react';
import { countries } from 'App/constants'; import { countries } from 'App/constants';
import Trend from './Trend'; import { useStore } from 'App/mstore';
import { Loader } from 'UI';
import DateAgo from './DateAgo'; import DateAgo from './DateAgo';
import DistributionBar from './DistributionBar'; import DistributionBar from './DistributionBar';
import Trend from './Trend';
import { errorService } from 'App/services';
const MAX_PERCENTAGE = 3; const MAX_PERCENTAGE = 3;
const MIN_COUNT = 4; const MIN_COUNT = 4;
const MAX_COUNT = 10; const MAX_COUNT = 10;
function hidePredicate(percentage, index) { function hidePredicate(percentage, index) {
if (index < MIN_COUNT) return false; if (index < MIN_COUNT) return false;
if (index < MAX_COUNT && percentage < MAX_PERCENTAGE) return false; if (index < MAX_COUNT && percentage < MAX_PERCENTAGE) return false;
return true; return true;
} }
function partitionsWrapper(partitions = [], mapCountry = false) { function partitionsWrapper(partitions = [], mapCountry = false) {
const counts = partitions.map(({ count }) => count); const counts = partitions.map(({ count }) => count);
const sum = counts.reduce((a,b)=>parseInt(a)+parseInt(b),0); const sum = counts.reduce((a, b) => parseInt(a) + parseInt(b), 0);
if (sum === 0) { if (sum === 0) {
return []; return [];
} }
const otherPrcs = counts const otherPrcs = counts.map((c) => (c / sum) * 100).filter(hidePredicate);
.map(c => c/sum * 100) const otherPrcsSum = otherPrcs.reduce((a, b) => a + b, 0);
.filter(hidePredicate); const showLength = partitions.length - otherPrcs.length;
const otherPrcsSum = otherPrcs.reduce((a,b)=>a+b,0); const show = partitions
const showLength = partitions.length - otherPrcs.length; .sort((a, b) => b.count - a.count)
const show = partitions .slice(0, showLength)
.sort((a, b) => b.count - a.count) .map((p) => ({
.slice(0, showLength) label: mapCountry ? countries[p.name] || 'Unknown' : p.name,
.map(p => ({ prc: (p.count / sum) * 100,
label: mapCountry }));
? (countries[p.name] || "Unknown")
: p.name,
prc: p.count/sum * 100,
}))
if (otherPrcsSum > 0) { if (otherPrcsSum > 0) {
show.push({ show.push({
label: "Other", label: 'Other',
prc: otherPrcsSum, prc: otherPrcsSum,
other: true, other: true,
}) });
} }
return show; return show;
} }
function tagsWrapper(tags = []) { function tagsWrapper(tags = []) {
return tags.map(({ name, partitions }) => ({ return tags.map(({ name, partitions }) => ({
name, name,
partitions: partitionsWrapper(partitions, name === "country") partitions: partitionsWrapper(partitions, name === 'country'),
})) }));
} }
function dataWrapper(data = {}) { function dataWrapper(data = {}) {
return { return {
chart24: data.chart24 || [], chart24: data.chart24 || [],
chart30: data.chart30 || [], chart30: data.chart30 || [],
tags: tagsWrapper(data.tags), tags: tagsWrapper(data.tags),
}; };
} }
@connect(state => ({ function SideSection(props) {
error: state.getIn([ "errors", "instance" ]) const [data, setData] = React.useState({
})) chart24: [],
@withRequest({ chart30: [],
initialData: props => dataWrapper(props.error), tags: [],
endpoint: props => `/errors/${ props.error.errorId }/stats`, });
dataWrapper, const [loading, setLoading] = React.useState(false);
}) const { className } = props;
export default class SideSection extends React.PureComponent { const { errorStore } = useStore();
onDateChange = ({ startDate, endDate }) => { const error = errorStore.instance;
this.props.request({ startDate, endDate });
}
render() {
const {
className,
error,
data,
loading,
} = this.props;
return ( const grabData = async () => {
<div className={ cn(className, "pl-5") }> setLoading(true);
<h3 className="text-xl mb-2">Overview</h3> errorService.fetchErrorStats(error.errorId)
<Trend .then(data => {
chart={ data.chart24 } setData(dataWrapper(data))
title="Past 24 hours" })
/> .finally(() => setLoading(false));
<div className="mb-6" /> }
<Trend
chart={ data.chart30 } React.useEffect(() => {
title="Last 30 days" setData(dataWrapper(error))
timeFormat={'l'} }, [error.errorId])
/>
<div className="mb-6" /> return (
<DateAgo <div className={cn(className, 'pl-5')}>
className="my-4" <h3 className="text-xl mb-2">Overview</h3>
title="First Seen" <Trend chart={data.chart24} title="Past 24 hours" />
timestamp={ error.firstOccurrence } <div className="mb-6" />
/> <Trend chart={data.chart30} title="Last 30 days" timeFormat={'l'} />
<DateAgo <div className="mb-6" />
className="my-4" <DateAgo
title="Last Seen" className="my-4"
timestamp={ error.lastOccurrence } title="First Seen"
/> timestamp={error.firstOccurrence}
{ data.tags.length > 0 && <h4 className="text-xl mt-6 mb-3">Summary</h4> } />
<Loader loading={loading}> <DateAgo
{ data.tags.map(({ name, partitions }) => className="my-4"
<DistributionBar title="Last Seen"
key={ name } timestamp={error.lastOccurrence}
title={name} />
partitions={partitions} {data.tags.length > 0 && <h4 className="text-xl mt-6 mb-3">Summary</h4>}
className="mb-6" <Loader loading={loading}>
/> {data.tags.map(({ name, partitions }) => (
)} <DistributionBar
</Loader> key={name}
</div> title={name}
); partitions={partitions}
} className="mb-6"
/>
))}
</Loader>
</div>
);
} }
export default observer(SideSection);

View file

@ -1,154 +0,0 @@
import React from 'react';
import { connect } from 'react-redux';
import withSiteIdRouter from 'HOCs/withSiteIdRouter';
import withPermissions from 'HOCs/withPermissions'
import { UNRESOLVED, RESOLVED, IGNORED, BOOKMARK } from "Types/errorInfo";
import { fetchBookmarks, editOptions } from "Duck/errors";
import { applyFilter } from 'Duck/search';
import { errors as errorsRoute, isRoute } from "App/routes";
import withPageTitle from 'HOCs/withPageTitle';
import cn from 'classnames';
import SelectDateRange from 'Shared/SelectDateRange';
import Period from 'Types/app/period';
import List from './List/List';
import ErrorInfo from './Error/ErrorInfo';
import Header from './Header';
import SideMenuSection from './SideMenu/SideMenuSection';
import SideMenuDividedItem from './SideMenu/SideMenuDividedItem';
const ERRORS_ROUTE = errorsRoute();
function getStatusLabel(status) {
switch(status) {
case UNRESOLVED:
return "Unresolved";
case RESOLVED:
return "Resolved";
case IGNORED:
return "Ignored";
default:
return "";
}
}
@withPermissions(['ERRORS'], 'page-margin container-90')
@withSiteIdRouter
@connect(state => ({
list: state.getIn([ "errors", "list" ]),
status: state.getIn([ "errors", "options", "status" ]),
filter: state.getIn([ 'search', 'instance' ]),
}), {
fetchBookmarks,
applyFilter,
editOptions,
})
@withPageTitle("Errors - OpenReplay")
export default class Errors extends React.PureComponent {
constructor(props) {
super(props)
this.state = {
filter: '',
}
}
ensureErrorsPage() {
const { history } = this.props;
if (!isRoute(ERRORS_ROUTE, history.location.pathname)) {
history.push(ERRORS_ROUTE);
}
}
onStatusItemClick = ({ key }) => {
this.props.editOptions({ status: key });
}
onBookmarksClick = () => {
this.props.editOptions({ status: BOOKMARK });
}
onDateChange = (e) => {
const dateValues = e.toJSON();
this.props.applyFilter(dateValues);
};
render() {
const {
count,
match: {
params: { errorId }
},
status,
list,
history,
filter,
} = this.props;
const { startDate, endDate, rangeValue } = filter;
const period = new Period({ start: startDate, end: endDate, rangeName: rangeValue });
return (
<div className="page-margin container-90" >
<div className={cn("side-menu", {'disabled' : !isRoute(ERRORS_ROUTE, history.location.pathname)})}>
<SideMenuSection
title="Errors"
onItemClick={this.onStatusItemClick}
items={[
{
key: UNRESOLVED,
icon: "exclamation-circle",
label: getStatusLabel(UNRESOLVED),
active: status === UNRESOLVED,
},
{
key: RESOLVED,
icon: "check",
label: getStatusLabel(RESOLVED),
active: status === RESOLVED,
},
{
key: IGNORED,
icon: "ban",
label: getStatusLabel(IGNORED),
active: status === IGNORED,
}
]}
/>
<SideMenuDividedItem
className="mt-3 mb-4"
iconName="star"
title="Bookmarks"
active={ status === BOOKMARK }
onClick={ this.onBookmarksClick }
/>
</div>
<div className="side-menu-margined">
{ errorId == null ?
<>
<div className="mb-5 flex items-baseline">
<Header
text={ status === BOOKMARK ? "Bookmarks" : getStatusLabel(status) }
count={ list.size }
/>
<div className="ml-3 flex items-center">
<span className="mr-2 color-gray-medium">Seen in</span>
<SelectDateRange
period={period}
onChange={this.onDateChange}
/>
</div>
</div>
<List
status={ status }
list={ list }
/>
</>
:
<ErrorInfo errorId={ errorId } list={ list } />
}
</div>
</div>
);
}
}

View file

@ -1,14 +0,0 @@
import React from 'react';
function Header({ text, count }) {
return (
<h3 className="text-2xl capitalize">
<span>{ text }</span>
{ count != null && <span className="ml-2 font-normal color-gray-medium">{ count }</span> }
</h3>
);
}
Header.displayName = "Header";
export default Header;

View file

@ -1,259 +0,0 @@
import React from 'react';
import { connect } from 'react-redux';
import { Set } from "immutable";
import { NoContent, Loader, Checkbox, IconButton, Input, Pagination } from 'UI';
import { merge, resolve, unresolve, ignore, updateCurrentPage, editOptions } from "Duck/errors";
import { applyFilter } from 'Duck/filters';
import { IGNORED, UNRESOLVED } from 'Types/errorInfo';
import Divider from 'Components/Errors/ui/Divider';
import ListItem from './ListItem/ListItem';
import { debounce } from 'App/utils';
import Select from 'Shared/Select';
import EmptyStateSvg from '../../../svg/no-results.svg';
const sortOptionsMap = {
'occurrence-desc': 'Last Occurrence',
'occurrence-desc': 'First Occurrence',
'sessions-asc': 'Sessions Ascending',
'sessions-desc': 'Sessions Descending',
'users-asc': 'Users Ascending',
'users-desc': 'Users Descending',
};
const sortOptions = Object.entries(sortOptionsMap)
.map(([ value, label ]) => ({ value, label }));
@connect(state => ({
loading: state.getIn([ "errors", "loading" ]),
resolveToggleLoading: state.getIn(["errors", "resolve", "loading"]) ||
state.getIn(["errors", "unresolve", "loading"]),
ignoreLoading: state.getIn([ "errors", "ignore", "loading" ]),
mergeLoading: state.getIn([ "errors", "merge", "loading" ]),
currentPage: state.getIn(["errors", "currentPage"]),
limit: state.getIn(["errors", "limit"]),
total: state.getIn([ 'errors', 'totalCount' ]),
sort: state.getIn([ 'errors', 'options', 'sort' ]),
order: state.getIn([ 'errors', 'options', 'order' ]),
query: state.getIn([ "errors", "options", "query" ]),
}), {
merge,
resolve,
unresolve,
ignore,
applyFilter,
updateCurrentPage,
editOptions,
})
export default class List extends React.PureComponent {
constructor(props) {
super(props)
this.state = {
checkedAll: false,
checkedIds: Set(),
query: props.query,
}
this.debounceFetch = debounce(this.props.editOptions, 1000);
}
componentDidMount() {
this.props.applyFilter({ });
}
check = ({ errorId }) => {
const { checkedIds } = this.state;
const newCheckedIds = checkedIds.contains(errorId)
? checkedIds.remove(errorId)
: checkedIds.add(errorId);
this.setState({
checkedAll: newCheckedIds.size === this.props.list.size,
checkedIds: newCheckedIds
});
}
checkAll = () => {
if (this.state.checkedAll) {
this.setState({
checkedAll: false,
checkedIds: Set(),
});
} else {
this.setState({
checkedAll: true,
checkedIds: this.props.list.map(({ errorId }) => errorId).toSet(),
});
}
}
resetChecked = () => {
this.setState({
checkedAll: false,
checkedIds: Set(),
});
}
currentCheckedIds() {
return this.state.checkedIds
.intersect(this.props.list.map(({ errorId }) => errorId).toSet());
}
merge = () => {
this.props.merge(currentCheckedIds().toJS()).then(this.resetChecked);
}
applyToAllChecked(f) {
return Promise.all(this.currentCheckedIds().map(f).toJS()).then(this.resetChecked);
}
resolve = () => {
this.applyToAllChecked(this.props.resolve);
}
unresolve = () => {
this.applyToAllChecked(this.props.unresolve);
}
ignore = () => {
this.applyToAllChecked(this.props.ignore);
}
addPage = () => this.props.updateCurrentPage(this.props.currentPage + 1)
writeOption = ({ name, value }) => {
const [ sort, order ] = value.split('-');
if (name === 'sort') {
this.props.editOptions({ sort, order });
}
}
// onQueryChange = ({ target: { value, name } }) => props.edit({ [ name ]: value })
onQueryChange = ({ target: { value, name } }) => {
this.setState({ query: value });
this.debounceFetch({ query: value });
}
render() {
const {
list,
status,
loading,
ignoreLoading,
resolveToggleLoading,
mergeLoading,
currentPage,
total,
sort,
order,
limit,
} = this.props;
const {
checkedAll,
checkedIds,
query,
} = this.state;
const someLoading = loading || ignoreLoading || resolveToggleLoading || mergeLoading;
const currentCheckedIds = this.currentCheckedIds();
return (
<div className="bg-white p-5 border-radius-3 thin-gray-border">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center" style={{ height: "36px" }}>
<Checkbox
className="mr-3"
checked={ checkedAll }
onChange={ this.checkAll }
/>
{ status === UNRESOLVED
? <IconButton
outline
className="mr-3"
label="Resolve"
icon="check"
size="small"
loading={ resolveToggleLoading }
onClick={ this.resolve }
disabled={ someLoading || currentCheckedIds.size === 0}
/>
: <IconButton
outline
className="mr-3"
label="Unresolve"
icon="exclamation-circle"
size="small"
loading={ resolveToggleLoading }
onClick={ this.unresolve }
disabled={ someLoading || currentCheckedIds.size === 0}
/>
}
{ status !== IGNORED &&
<IconButton
outline
className="mr-3"
label="Ignore"
icon="ban"
size="small"
loading={ ignoreLoading }
onClick={ this.ignore }
disabled={ someLoading || currentCheckedIds.size === 0}
/>
}
</div>
<div className="flex items-center ml-6">
<span className="mr-2 color-gray-medium">Sort By</span>
<Select
defaultValue={ `${sort}-${order}` }
name="sort"
plain
options={ sortOptions }
onChange={ this.writeOption }
/>
<Input
style={{ width: '350px'}}
wrapperClassName="ml-3"
placeholder="Filter by name or message"
icon="search"
name="filter"
onChange={ this.onQueryChange }
value={query}
/>
</div>
</div>
<Divider />
<NoContent
title={
<div className="flex flex-col items-center justify-center">
<object style={{ width: "180px"}} type="image/svg+xml" data={EmptyStateSvg} />
<span className="mr-2">No Errors Found!</span>
</div>
}
subtext="Please try to change your search parameters."
// animatedIcon="empty-state"
show={ !loading && list.size === 0}
>
<Loader loading={ loading }>
{ list.map(e =>
<div key={e.errorId} style={{ opacity: e.disabled ? 0.5 : 1}}>
<ListItem
disabled={someLoading || e.disabled}
key={e.errorId}
error={e}
checked={ checkedIds.contains(e.errorId) }
onCheck={ this.check }
/>
<Divider/>
</div>
)}
<div className="w-full flex items-center justify-center mt-4">
<Pagination
page={currentPage}
total={total}
onPageChange={(page) => this.props.updateCurrentPage(page)}
limit={limit}
debounceRequest={500}
/>
</div>
</Loader>
</NoContent>
</div>
);
}
}

View file

@ -1,87 +0,0 @@
import React from 'react';
import { BarChart, Bar, YAxis, Tooltip, XAxis } from 'recharts';
import cn from 'classnames';
import { DateTime } from 'luxon'
import { diffFromNowString } from 'App/date';
import { error as errorRoute } from 'App/routes';
import { IGNORED, RESOLVED } from 'Types/errorInfo';
import { Checkbox, Link } from 'UI';
import ErrorName from 'Components/Errors/ui/ErrorName';
import Label from 'Components/Errors/ui/Label';
import stl from './listItem.module.css';
import { Styles } from '../../../Dashboard/Widgets/common';
const CustomTooltip = ({ active, payload, label }) => {
if (active) {
const p = payload[0].payload;
const dateStr = p.timestamp ? DateTime.fromMillis(p.timestamp).toFormat('l') : ''
return (
<div className="rounded border bg-white p-2">
<p className="label text-sm color-gray-medium">{dateStr}</p>
<p className="text-sm">Sessions: {p.count}</p>
</div>
);
}
return null;
};
function ListItem({ className, onCheck, checked, error, disabled }) {
const getDateFormat = val => {
const d = new Date(val);
return (d.getMonth()+ 1) + '/' + d.getDate()
}
return (
<div className={ cn("flex justify-between cursor-pointer py-4", className) } id="error-item">
<Checkbox
disabled={disabled}
checked={ checked }
onChange={ () => onCheck(error) }
/>
<div className={ cn("ml-3 flex-1 leading-tight", stl.name) } >
<Link to={errorRoute(error.errorId)} >
<ErrorName
icon={error.status === IGNORED ? 'ban' : null }
lineThrough={error.status === RESOLVED}
name={ error.name }
message={ error.stack0InfoString }
bold={ !error.viewed }
/>
<div
className={ cn("truncate color-gray-medium", { "line-through" : error.status === RESOLVED}) }
>
{ error.message }
</div>
</Link>
</div>
<BarChart width={ 150 } height={ 40 } data={ error.chart }>
<XAxis hide dataKey="timestamp" />
<YAxis hide domain={[0, 'dataMax + 8']} />
<Tooltip {...Styles.tooltip} label="Sessions" content={<CustomTooltip />} />
<Bar name="Sessions" minPointSize={1} dataKey="count" fill="#A8E0DA" />
</BarChart>
<Label
className={stl.sessions}
topValue={ error.sessions }
bottomValue="Sessions"
/>
<Label
className={stl.users}
topValue={ error.users }
bottomValue="Users"
/>
<Label
className={stl.occurrence}
topValue={ `${diffFromNowString(error.lastOccurrence)} ago` }
bottomValue="Last Seen"
/>
</div>
);
}
ListItem.displayName = "ListItem";
export default ListItem;

View file

@ -1,16 +0,0 @@
.name {
min-width: 55%;
}
.sessions {
width: 6%;
}
.users {
width: 5%;
}
.occurrence {
width: 15%;
min-width: 152px;
}

View file

@ -1,20 +0,0 @@
import React from 'react';
import { SideMenuitem } from "UI";
import Divider from 'Components/Errors/ui/Divider';
function SideMenuDividedItem({ className, noTopDivider = false, noBottomDivider = false, ...props }) {
return (
<div className={className}>
{ !noTopDivider && <Divider /> }
<SideMenuitem
className="my-3"
{ ...props }
/>
{ !noBottomDivider && <Divider /> }
</div>
);
}
SideMenuDividedItem.displayName = "SideMenuDividedItem";
export default SideMenuDividedItem;

View file

@ -1,14 +0,0 @@
import React from 'react';
import cn from 'classnames';
import stl from './sideMenuHeader.module.css';
function SideMenuHeader({ text, className }) {
return (
<div className={ cn(className, stl.label, "uppercase color-gray") }>
{ text }
</div>
)
}
SideMenuHeader.displayName = "SideMenuHeader";
export default SideMenuHeader;

View file

@ -1,24 +0,0 @@
import React from 'react';
import { SideMenuitem } from 'UI';
import SideMenuHeader from './SideMenuHeader';
function SideMenuSection({ title, items, onItemClick }) {
return (
<>
<SideMenuHeader className="mb-4" text={ title }/>
{ items.map(item =>
<SideMenuitem
key={ item.key }
active={ item.active }
title={ item.label }
iconName={ item.icon }
onClick={() => onItemClick(item)}
/>
)}
</>
);
}
SideMenuSection.displayName = "SideMenuSection";
export default SideMenuSection;

View file

@ -1,4 +0,0 @@
.label {
letter-spacing: 0.2em;
color: gray;
}

View file

@ -1,159 +0,0 @@
import React, { useState, useEffect } from 'react'
import { Tabs, Loader } from 'UI'
import FunnelHeader from 'Components/Funnels/FunnelHeader'
import FunnelGraph from 'Components/Funnels/FunnelGraph'
import FunnelSessionList from 'Components/Funnels/FunnelSessionList'
import FunnelOverview from 'Components/Funnels/FunnelOverview'
import FunnelIssues from 'Components/Funnels/FunnelIssues'
import { connect } from 'react-redux';
import {
fetch, fetchInsights, fetchList, fetchFiltered, fetchIssuesFiltered, fetchSessionsFiltered, fetchIssueTypes, resetFunnel, refresh
} from 'Duck/funnels';
import { applyFilter, setFilterOptions, resetFunnelFilters, setInitialFilters } from 'Duck/funnelFilters';
import { withRouter } from 'react-router';
import { sessions as sessionsRoute, funnel as funnelRoute, withSiteId } from 'App/routes';
import FunnelSearch from 'Shared/FunnelSearch';
import cn from 'classnames';
import IssuesEmptyMessage from 'Components/Funnels/IssuesEmptyMessage'
const TAB_ISSUES = 'ANALYSIS';
const TAB_SESSIONS = 'SESSIONS';
const TABS = [ TAB_ISSUES, TAB_SESSIONS ].map(tab => ({
text: tab,
disabled: false,
key: tab,
}));
const FunnelDetails = (props) => {
const { insights, funnels, funnel, funnelId, loading, liveFilters, issuesLoading, sessionsLoading, refresh } = props;
const [activeTab, setActiveTab] = useState(TAB_ISSUES)
const [showFilters, setShowFilters] = useState(false)
const [mounted, setMounted] = useState(false);
const onTabClick = activeTab => setActiveTab(activeTab)
useEffect(() => {
if (funnels.size === 0) {
props.fetchList();
}
props.fetchIssueTypes()
props.fetch(funnelId).then(() => {
setMounted(true);
}).then(() => {
props.refresh(funnelId);
})
}, []);
// useEffect(() => {
// if (funnel && funnel.filter && liveFilters.events.size === 0) {
// props.setInitialFilters();
// }
// }, [funnel])
const onBack = () => {
props.history.push(sessionsRoute());
}
const redirect = funnelId => {
const { siteId, history } = props;
props.resetFunnel();
props.resetFunnelFilters();
history.push(withSiteId(funnelRoute(parseInt(funnelId)), siteId));
}
const renderActiveTab = (tab, hasNoStages) => {
switch(tab) {
case TAB_ISSUES:
return !hasNoStages && <FunnelIssues funnelId={funnelId} />
case TAB_SESSIONS:
return <FunnelSessionList funnelId={funnelId} />
}
}
const hasNoStages = !loading && insights.stages.length <= 1;
const showEmptyMessage = hasNoStages && activeTab === TAB_ISSUES && !loading;
return (
<div className="page-margin container-70">
<FunnelHeader
funnel={funnel}
insights={insights}
redirect={redirect}
funnels={funnels}
onBack={onBack}
funnelId={parseInt(funnelId)}
toggleFilters={() => setShowFilters(!showFilters)}
showFilters={showFilters}
/>
<div className="my-3" />
{showFilters && (
<FunnelSearch />
)
}
<div className="my-3" />
<Tabs
tabs={ TABS }
active={ activeTab }
onClick={ onTabClick }
/>
<div className="my-8" />
<Loader loading={loading}>
<IssuesEmptyMessage onAddEvent={() => setShowFilters(true)} show={showEmptyMessage}>
<div>
<div className={cn("flex items-start", { 'hidden' : activeTab === TAB_SESSIONS || hasNoStages })}>
<div className="flex-1">
<FunnelGraph data={insights.stages} funnelId={funnelId} />
</div>
<div style={{ width: '35%'}} className="px-14">
<FunnelOverview funnel={insights} />
</div>
</div>
<div className="my-8" />
<Loader loading={issuesLoading || sessionsLoading}>
{ renderActiveTab(activeTab, hasNoStages) }
</Loader>
</div>
</IssuesEmptyMessage>
</Loader>
</div>
)
}
export default connect((state, props) => {
const insightsLoading = state.getIn(['funnels', 'fetchInsights', 'loading']);
const issuesLoading = state.getIn(['funnels', 'fetchIssuesRequest', 'loading']);
const funnelLoading = state.getIn(['funnels', 'fetchRequest', 'loading']);
const sessionsLoading = state.getIn(['funnels', 'fetchSessionsRequest', 'loading']);
return {
funnels: state.getIn(['funnels', 'list']),
funnel: state.getIn(['funnels', 'instance']),
insights: state.getIn(['funnels', 'insights']),
loading: funnelLoading || (insightsLoading && (issuesLoading || sessionsLoading)),
issuesLoading,
sessionsLoading,
funnelId: props.match.params.funnelId,
activeStages: state.getIn(['funnels', 'activeStages']),
funnelFilters: state.getIn(['funnels', 'funnelFilters']),
siteId: state.getIn([ 'site', 'siteId' ]),
liveFilters: state.getIn(['funnelFilters', 'appliedFilter']),
}
}, {
fetch,
fetchInsights,
fetchFiltered,
fetchIssuesFiltered,
fetchList,
applyFilter,
setFilterOptions,
fetchIssuesFiltered,
fetchSessionsFiltered,
fetchIssueTypes,
resetFunnel,
resetFunnelFilters,
setInitialFilters,
refresh,
})(withRouter((FunnelDetails)))

View file

@ -1,303 +0,0 @@
import React, { useState } from 'react';
import { Icon, Tooltip as AppTooltip } from 'UI';
import { numberCompact } from 'App/utils';
import {
BarChart,
Bar,
Cell,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
LabelList,
} from 'recharts';
import { connect } from 'react-redux';
import { setActiveStages } from 'Duck/funnels';
import { Styles } from '../../Dashboard/Widgets/common';
import { numberWithCommas } from 'App/utils';
import { truncate } from 'App/utils';
const MIN_BAR_HEIGHT = 20;
function CustomTick(props) {
const { x, y, payload } = props;
return (
<g transform={`translate(${x},${y})`}>
<text x={0} y={0} dy={16} fontSize={12} textAnchor="middle" fill="#666">
{payload.value}
</text>
</g>
);
}
function FunnelGraph(props) {
const { data, activeStages, funnelId, liveFilters } = props;
const [activeIndex, setActiveIndex] = useState(activeStages);
const renderPercentage = (props) => {
const { x, y, width, height, value } = props;
const radius = 10;
const _x = x + width / 2 + 45;
return (
<g>
<svg width="46px" height="21px" version="1.1">
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<path
d="M37.2387001,0.5 L45.3588127,10.5034561 L37.4215407,20.5 L0.5,20.5 L0.5,0.5 L37.2387001,0.5 Z"
id="Rectangle"
stroke="#AFACAC"
fill="#FFFFFF"
></path>
</g>
</svg>
<text x={x} y={70} fill="#000" textAnchor="middle" dominantBaseline="middle">
{numberCompact(value)}
</text>
</g>
);
};
const renderCustomizedLabel = (props) => {
const { x, y, width, height, value, textColor = '#fff' } = props;
const radius = 10;
if (value === 0) return;
return (
<g>
<text
x={x + width / 2}
y={y - radius + 20}
fill={textColor}
font-size="12"
textAnchor="middle"
dominantBaseline="middle"
>
{numberCompact(value)}
</text>
</g>
);
};
const handleClick = (data, index) => {
if (activeStages.length === 1 && activeStages.includes(index)) {
// selecting the same bar
props.setActiveStages([], null);
return;
}
if (activeStages.length === 2) {
// already having two bars
return;
}
// new selection
const arr = activeStages.concat([index]);
props.setActiveStages(arr.sort(), arr.length === 2 && liveFilters, funnelId);
};
const resetActiveSatges = () => {
props.setActiveStages([], liveFilters, funnelId, true);
};
const renderDropLabel = ({ x, y, width, value }) => {
if (value === 0) return;
return (
<text fill="#cc0000" x={x + width / 2} y={y - 5} textAnchor="middle" fontSize="12">
{value}
</text>
);
};
const renderMainLabel = ({ x, y, width, value }) => {
return (
<text fill="#FFFFFF" x={x + width / 2} y={y + 14} textAnchor="middle" fontSize="12">
{numberWithCommas(value)}
</text>
);
};
const CustomBar = (props) => {
const { fill, x, y, width, height, sessionsCount, index, dropDueToIssues } = props;
const yp = sessionsCount < MIN_BAR_HEIGHT ? MIN_BAR_HEIGHT - 1 : dropDueToIssues;
const tmp = (height <= 20 ? 20 : height) - (TEMP[index].height > 20 ? 0 : TEMP[index].height);
return (
<svg>
<rect x={x} y={y} width={width} height={tmp} fill={fill} />
</svg>
);
};
const MainBar = (props) => {
const {
fill,
x,
y,
width,
height,
sessionsCount,
index,
dropDueToIssues,
hasSelection = false,
} = props;
const yp = sessionsCount < MIN_BAR_HEIGHT ? MIN_BAR_HEIGHT - 1 : dropDueToIssues;
TEMP[index] = { height, y };
return (
<svg style={{ cursor: hasSelection ? '' : 'pointer' }}>
<rect x={x} y={y} width={width} height={height} fill={fill} />
</svg>
);
};
const renderDropPct = (props) => {
// TODO
const { fill, x, y, width, height, value, totalBars } = props;
const barW = x + 730 / totalBars / 2;
return (
<svg>
<rect x={barW} y={80} width={width} height={20} fill="red" />
</svg>
);
};
const CustomTooltip = (props) => {
const { payload } = props;
if (payload.length === 0) return null;
const { value, headerText } = payload[0].payload;
// const value = payload[0].payload.value;
if (!value) return null;
return (
<div className="rounded border bg-white p-2">
<div>{headerText}</div>
{value.map((i) => (
<div className="text-sm ml-2">{truncate(i, 30)}</div>
))}
</div>
);
};
// const CustomTooltip = ({ active, payload, msg = '' }) => {
// return (
// <div className="rounded border bg-white p-2">
// <p className="text-sm">{msg}</p>
// </div>
// );
// };
const TEMP = {};
return (
<div className="relative">
{activeStages.length === 2 && (
<div
className="absolute right-0 top-0 cursor-pointer z-10"
style={{ marginRight: '60px', marginTop: '0' }}
onClick={resetActiveSatges}
>
<AppTooltip title={`Reset Selection`}>
<Icon name="sync-alt" size="15" color="teal" />
</AppTooltip>
</div>
)}
<BarChart
width={800}
height={190}
data={data}
margin={{ top: 20, right: 20, left: 0, bottom: 0 }}
background={'transparent'}
>
<CartesianGrid strokeDasharray="1 3" stroke="#BBB" vertical={false} />
{/* {activeStages.length < 2 && <Tooltip cursor={{ fill: 'transparent' }} content={<CustomTooltip msg={activeStages.length > 0 ? 'Select one more event.' : 'Select any two events to analyze in depth.'} />} />} */}
<Tooltip cursor={{ fill: 'transparent' }} content={CustomTooltip} />
<Bar
dataKey="sessionsCount"
onClick={handleClick}
maxBarSize={80}
stackId="a"
shape={<MainBar hasSelection={activeStages.length === 2} />}
cursor="pointer"
minPointSize={MIN_BAR_HEIGHT}
background={false}
>
<LabelList dataKey="sessionsCount" content={renderMainLabel} />
{data.map((entry, index) => {
const selected =
activeStages.includes(index) || (index > activeStages[0] && index < activeStages[1]);
const opacity = activeStages.length > 0 && !selected ? 0.4 : 1;
return (
<Cell
cursor="pointer"
fill={selected ? '#394EFF' : opacity === 1 ? '#3EAAAF' : '#CCC'}
key={`cell-${index}`}
/>
);
})}
</Bar>
<Bar
hide={activeStages.length !== 2}
dataKey="dropDueToIssues"
onClick={handleClick}
maxBarSize={80}
stackId="a"
shape={<CustomBar />}
minPointSize={MIN_BAR_HEIGHT}
>
<LabelList dataKey="dropDueToIssues" content={renderDropLabel} />
{data.map((entry, index) => {
const selected =
activeStages.includes(index) || (index > activeStages[0] && index < activeStages[1]);
const opacity = activeStages.length > 0 && !selected ? 0.4 : 1;
return (
<Cell
opacity={opacity}
cursor="pointer"
fill={activeStages[1] === index ? '#cc000040' : 'transparent'}
key={`cell-${index}`}
/>
);
})}
</Bar>
<XAxis
stroke={0}
dataKey="label"
strokeWidth={0}
interval={0}
// tick ={{ fill: '#666', fontSize: 12 }}
tick={<CustomTick />}
xAxisId={0}
/>
{/* <XAxis
stroke={0}
xAxisId={1}
dataKey="value"
strokeWidth={0}
interval={0}
dy={-15} dx={0}
tick ={{ fill: '#666', fontSize: 12 }}
tickFormatter={val => '"' + val + '"'}
/> */}
<YAxis
interval={0}
strokeWidth={0}
tick={{ fill: '#999999', fontSize: 11 }}
tickFormatter={(val) => Styles.tickFormatter(val)}
/>
</BarChart>
</div>
);
}
export default connect(
(state) => ({
activeStages: state.getIn(['funnels', 'activeStages']).toJS(),
liveFilters: state.getIn(['funnelFilters', 'appliedFilter']),
}),
{ setActiveStages }
)(FunnelGraph);

View file

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

View file

@ -1,37 +0,0 @@
import React from 'react'
import { connect } from 'react-redux'
import { withRouter } from 'react-router'
import { Dropdown } from 'UI'
import { funnel as funnelRoute, withSiteId } from 'App/routes';
function FunnelDropdown(props) {
const { options, funnel } = props;
const writeOption = (e, { name, value }) => {
const { siteId, history } = props;
history.push(withSiteId(funnelRoute(parseInt(value)), siteId));
}
return (
<div>
<Dropdown
selection
basic
options={ options.toJS() }
name="funnel"
value={ funnel.funnelId || ''}
defaultValue={ funnel.funnelId }
icon={null}
style={{ border: 'none' }}
onChange={ writeOption }
selectOnBlur={false}
/>
</div>
)
}
export default connect((state, props) => ({
funnels: state.getIn(['funnels', 'list']),
funnel: state.getIn(['funnels', 'instance']),
siteId: state.getIn([ 'site', 'siteId' ]),
}), { })(withRouter(FunnelDropdown))

View file

@ -1,149 +0,0 @@
import React, { useState } from 'react';
import { Icon, BackLink, IconButton, Dropdown, Tooltip, TextEllipsis, Button } from 'UI';
import {
remove as deleteFunnel,
fetch,
fetchInsights,
fetchIssuesFiltered,
fetchSessionsFiltered,
} from 'Duck/funnels';
import { editFilter, editFunnelFilter, refresh } from 'Duck/funnels';
import DateRange from 'Shared/DateRange';
import { connect } from 'react-redux';
import { confirm } from 'UI';
import FunnelSaveModal from 'Components/Funnels/FunnelSaveModal';
import stl from './funnelHeader.module.css';
const Info = ({ label = '', value = '', className = 'mx-4' }) => {
return (
<div className={className}>
<span className="color-gray-medium">{label}</span>
<span className="font-medium ml-2">{value}</span>
</div>
);
};
const FunnelHeader = (props) => {
const {
funnel,
insights,
funnels,
onBack,
funnelId,
showFilters = false,
funnelFilters,
renameHandler,
} = props;
const [showSaveModal, setShowSaveModal] = useState(false);
const writeOption = (e, { name, value }) => {
props.redirect(value);
props.fetch(value).then(() => props.refresh(value));
};
const deleteFunnel = async (e, funnel) => {
e.preventDefault();
e.stopPropagation();
if (
await confirm({
header: 'Delete Funnel',
confirmButton: 'Delete',
confirmation: `Are you sure you want to permanently delete "${funnel.name}"?`,
})
) {
props.deleteFunnel(funnel.funnelId).then(props.onBack);
} else {
}
};
const onDateChange = (e) => {
props.editFunnelFilter(e, funnelId);
};
const options = funnels.map(({ funnelId, name }) => ({ text: name, value: funnelId })).toJS();
const selectedFunnel = funnels.filter((i) => i.funnelId === parseInt(funnelId)).first() || {};
const eventsCount = funnel.filter.filters.filter((i) => i.isEvent).size;
return (
<div>
<div className="bg-white border rounded flex items-center w-full relative group pr-2">
<BackLink
onClick={onBack}
vertical
className="absolute"
style={{ left: '-50px', top: '8px' }}
/>
<FunnelSaveModal show={showSaveModal} closeHandler={() => setShowSaveModal(false)} />
<div className="flex items-center mr-auto relative">
<Dropdown
scrolling
trigger={
<div
className="text-xl capitalize font-medium"
style={{ maxWidth: '300px', overflow: 'hidden' }}
>
<TextEllipsis text={selectedFunnel.name} />
</div>
}
options={options}
className={stl.dropdown}
name="funnel"
value={parseInt(funnelId)}
// icon={null}
onChange={writeOption}
selectOnBlur={false}
icon={
<Icon name="chevron-down" color="gray-dark" size="14" className={stl.dropdownIcon} />
}
/>
<Info label="Events" value={eventsCount} />
<span>-</span>
<Button variant="text-primary" onClick={props.toggleFilters}>
{showFilters ? 'HIDE' : 'EDIT FUNNEL'}
</Button>
<Info label="Sessions" value={insights.sessionsCount} />
<Info label="Conversion" value={`${insights.conversions}%`} />
</div>
<div className="flex items-center">
<div className="flex items-center invisible group-hover:visible">
<Tooltip title={`Edit Funnel`}>
<IconButton icon="edit" onClick={() => setShowSaveModal(true)} />
</Tooltip>
<Tooltip title={`Remove Funnel`}>
<IconButton
icon="trash"
onClick={(e) => deleteFunnel(e, funnel)}
className="ml-2 mr-2"
/>
</Tooltip>
</div>
<DateRange
rangeValue={funnelFilters.rangeValue}
startDate={funnelFilters.startDate}
endDate={funnelFilters.endDate}
onDateChange={onDateChange}
customRangeRight
/>
</div>
</div>
</div>
);
};
export default connect(
(state) => ({
funnelFilters: state.getIn(['funnels', 'funnelFilters']).toJS(),
funnel: state.getIn(['funnels', 'instance']),
}),
{
editFilter,
editFunnelFilter,
deleteFunnel,
fetch,
fetchInsights,
fetchIssuesFiltered,
fetchSessionsFiltered,
refresh,
}
)(FunnelHeader);

View file

@ -1,30 +0,0 @@
.dropdown {
display: flex !important;
align-items: center;
padding: 0 20px;
border-radius: 0;
border-radius: 0;
color: $gray-darkest;
font-weight: 500;
height: 54px;
padding-right: 20px;
border-right: solid thin #eee;
border-bottom-left-radius: 3px;
border-top-left-radius: 3px;
&:hover {
background-color: $gray-lightest;
}
}
.dropdownTrigger {
padding: 4px 8px;
border-radius: 3px;
&:hover {
background-color: $gray-light;
}
}
.dropdownIcon {
margin-top: 4px;
margin-left: 6px;
}

View file

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

View file

@ -1,43 +0,0 @@
import React, { useEffect } from 'react'
import IssueItem from 'Components/Funnels/IssueItem'
import FunnelSessionList from 'Components/Funnels/FunnelSessionList'
import { connect } from 'react-redux'
import { withRouter } from 'react-router'
import { fetchIssue, setNavRef, resetIssue } from 'Duck/funnels'
import { funnel as funnelRoute, withSiteId } from 'App/routes'
import { Loader } from 'UI'
function FunnelIssueDetails(props) {
const { issue, issueId, funnelId, loading = false } = props;
useEffect(() => {
props.fetchIssue(funnelId, issueId)
return () => {
props.resetIssue();
}
}, [issueId])
const onBack = () => {
const { siteId, history } = props;
history.push(withSiteId(funnelRoute(parseInt(funnelId)), siteId));
}
return (
<div className="page-margin container-70" >
<Loader loading={loading}>
<IssueItem issue={issue} inDetails onBack={onBack} />
<div className="my-6" />
<FunnelSessionList funnelId={funnelId} issueId={issueId} inDetails />
</Loader>
</div>
)
}
export default connect((state, props) => ({
loading: state.getIn(['funnels', 'fetchIssueRequest', 'loading']),
issue: state.getIn(['funnels', 'issue']),
issueId: props.match.params.issueId,
funnelId: props.match.params.funnelId,
siteId: state.getIn([ 'site', 'siteId' ]),
}), { fetchIssue, setNavRef, resetIssue })(withRouter(FunnelIssueDetails))

View file

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

View file

@ -1,89 +0,0 @@
import React, { useState } from 'react'
import { connect } from 'react-redux'
import { fetchIssues, fetchIssuesFiltered } from 'Duck/funnels'
import { LoadMoreButton, NoContent } from 'UI'
import FunnelIssuesHeader from '../FunnelIssuesHeader'
import IssueItem from '../IssueItem';
import { funnelIssue as funnelIssueRoute, withSiteId } from 'App/routes'
import { withRouter } from 'react-router'
import IssueFilter from '../IssueFilter';
import SortDropdown from './SortDropdown';
import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG';
const PER_PAGE = 10;
function FunnelIssues(props) {
const {
funnel, list, loading = false,
criticalIssuesCount, issueFilters, sort
} = props;
const [showPages, setShowPages] = useState(1)
const addPage = () => setShowPages(showPages + 1);
const onClick = ({ issueId }) => {
const { siteId, history } = props;
history.push(withSiteId(funnelIssueRoute(funnel.funnelId, issueId), siteId));
}
let filteredList = issueFilters.size > 0 ? list.filter(item => issueFilters.includes(item.type)) : list;
filteredList = sort.sort ? filteredList.sortBy(i => i[sort.sort]) : filteredList;
filteredList = sort.order === 'desc' ? filteredList.reverse() : filteredList;
const displayedCount = Math.min(showPages * PER_PAGE, filteredList.size);
return (
<div>
<FunnelIssuesHeader criticalIssuesCount={criticalIssuesCount} />
<div className="my-5 flex items-start justify-between">
<IssueFilter />
<div className="flex items-center ml-6 flex-shrink-0">
<span className="mr-2 color-gray-medium">Sort By</span>
<SortDropdown />
</div>
</div>
<NoContent
title={
<div className="flex flex-col items-center justify-center">
<AnimatedSVG name={ICONS.NO_RESULTS} size="60" />
<div className="mt-4">No Issues Found!</div>
</div>
}
subtext="Please try changing your search parameters."
// animatedIcon="no-results"
show={ !loading && filteredList.size === 0}
>
{ filteredList.take(displayedCount).map(issue => (
<div className="mb-4">
<IssueItem
key={ issue.issueId }
issue={ issue }
onClick={() => onClick(issue)}
/>
</div>
))}
<LoadMoreButton
className="mt-12 mb-12"
displayedCount={displayedCount}
totalCount={filteredList.size}
loading={loading}
onClick={addPage}
/>
</NoContent>
</div>
)
}
export default connect(state => ({
list: state.getIn(['funnels', 'issues']),
criticalIssuesCount: state.getIn(['funnels', 'criticalIssuesCount']),
loading: state.getIn(['funnels', 'fetchIssuesRequest', 'loading']),
siteId: state.getIn([ 'site', 'siteId' ]),
funnel: state.getIn(['funnels', 'instance']),
activeStages: state.getIn(['funnels', 'activeStages']),
funnelFilters: state.getIn(['funnels', 'funnelFilters']),
liveFilters: state.getIn(['funnelFilters', 'appliedFilter']),
issueFilters: state.getIn(['funnels', 'issueFilters', 'filters']),
sort: state.getIn(['funnels', 'issueFilters', 'sort']),
}), { fetchIssues, fetchIssuesFiltered })(withRouter(FunnelIssues))

View file

@ -1,48 +0,0 @@
import React from 'react';
import { connect } from 'react-redux';
import Select from 'Shared/Select'
import { sort } from 'Duck/sessions';
import { applyIssueFilter } from 'Duck/funnels';
const sortOptionsMap = {
'afectedUsers-desc': 'Affected Users (High)',
'afectedUsers-asc': 'Affected Users (Low)',
'conversionImpact-desc': 'Conversion Impact (High)',
'conversionImpact-asc': 'Conversion Impact (Low)',
'lostConversions-desc': 'Lost Conversions (High)',
'lostConversions-asc': 'Lost Conversions (Low)',
};
const sortOptions = Object.entries(sortOptionsMap)
.map(([ value, label ]) => ({ value, label }));
@connect(state => ({
sorts: state.getIn(['funnels', 'issueFilters', 'sort'])
}), { sort, applyIssueFilter })
export default class SortDropdown extends React.PureComponent {
state = { value: null }
sort = ({ value }) => {
this.setState({ value: value })
const [ sort, order ] = value.split('-');
const sign = order === 'desc' ? -1 : 1;
this.props.applyIssueFilter({ sort: { order, sort } });
this.props.sort(sort, sign)
setTimeout(() => this.props.sort(sort, sign), 3000); //AAA
}
render() {
const { sorts } = this.props;
return (
<Select
plain
right
name="sortSessions"
defaultValue={sorts.sort + '-' + sorts.order}
options={sortOptions}
onChange={ this.sort }
/>
);
}
}

View file

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

View file

@ -1,23 +0,0 @@
.dropdown {
display: flex !important;
padding: 4px 6px;
border-radius: 3px;
color: $gray-darkest;
font-weight: 500;
&:hover {
background-color: $gray-light;
}
}
.dropdownTrigger {
padding: 4px 8px;
border-radius: 3px;
&:hover {
background-color: $gray-light;
}
}
.dropdownIcon {
margin-top: 2px;
margin-left: 3px;
}

View file

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

View file

@ -1,27 +0,0 @@
import React from 'react';
import { connect } from 'react-redux';
import { applyFilter, fetchList } from 'Duck/filters';
import { fetchList as fetchFunnelsList } from 'Duck/funnels';
import DateRangeDropdown from 'Shared/DateRangeDropdown';
@connect(state => ({
rangeValue: state.getIn([ 'filters', 'appliedFilter', 'rangeValue' ]),
startDate: state.getIn([ 'filters', 'appliedFilter', 'startDate' ]),
endDate: state.getIn([ 'filters', 'appliedFilter', 'endDate' ]),
}), {
applyFilter, fetchList, fetchFunnelsList
})
export default class DateRange extends React.PureComponent {
render() {
const { startDate, endDate, rangeValue, className } = this.props;
return (
<DateRangeDropdown
button
rangeValue={ rangeValue }
startDate={ startDate }
endDate={ endDate }
className={ className }
/>
);
}
}

View file

@ -1,15 +1,15 @@
import React from 'react' import React from 'react'
import { connect } from 'react-redux'; import { connect } from 'react-redux';
function FunnelIssuesHeader({ criticalIssuesCount, filters }) { function FunnelIssuesHeader({ criticalIssuesCount, filters }) {
return ( return (
<div className="flex items-center"> <div className="flex items-center">
<div className="flex items-center mr-auto text-xl"> <div className="flex items-center mr-auto text-xl">
<div className="font-medium mr-2"> <div className="font-medium mr-2">
Significant issues Significant issues
</div> </div>
<div className="mr-2">in this funnel</div> <div className="mr-2">in this funnel</div>
</div> </div>
</div> </div>
) )
} }

View file

@ -1,32 +0,0 @@
import React from 'react'
import FunnelGraphSmall from '../FunnelGraphSmall'
function FunnelItem({ funnel, onClick = () => null }) {
return (
<div className="w-full flex items-center p-4 bg-white rounded border cursor-pointer" onClick={onClick}>
<div className="mr-4">
<FunnelGraphSmall data={funnel.stages} />
</div>
<div className="mr-auto">
<div className="text-xl mb-2">{funnel.name}</div>
<div className="flex items-center text-sm">
<div className="mr-3"><span className="font-medium">{funnel.stepsCount}</span> Steps</div>
<div><span className="font-medium">{funnel.sessionsCount}</span> Sessions</div>
</div>
</div>
<div className="text-center text-sm px-6">
<div className="text-xl mb-2 color-red">{funnel.criticalIssuesCount}</div>
<div>Critical Issues</div>
</div>
<div className="text-center text-sm px-6">
<div className="text-xl mb-2">{funnel.missedConversions}%</div>
<div>Missed Conversions</div>
</div>
</div>
)
}
export default FunnelItem

View file

@ -1,112 +0,0 @@
import React from 'react';
import { connect } from 'react-redux';
import { Button, Modal, Form, Icon, Checkbox, Input } from 'UI';
import styles from './funnelSaveModal.module.css';
import { edit, save, fetchList as fetchFunnelsList } from 'Duck/funnels';
@connect(
(state) => ({
filter: state.getIn(['search', 'instance']),
funnel: state.getIn(['funnels', 'instance']),
loading:
state.getIn(['funnels', 'saveRequest', 'loading']) ||
state.getIn(['funnels', 'updateRequest', 'loading']),
}),
{ edit, save, fetchFunnelsList }
)
export default class FunnelSaveModal extends React.PureComponent {
state = { name: 'Untitled', isPublic: false };
static getDerivedStateFromProps(props) {
if (!props.show) {
return {
name: props.funnel.name || 'Untitled',
isPublic: props.funnel.isPublic,
};
}
return null;
}
onNameChange = ({ target: { value } }) => {
this.props.edit({ name: value });
};
onChangeOption = (e, { checked, name }) => this.props.edit({ [name]: checked });
onSave = () => {
const { funnel, filter } = this.props;
if (funnel.name && funnel.name.trim() === '') return;
this.props.save(funnel).then(
function () {
this.props.fetchFunnelsList();
this.props.closeHandler();
}.bind(this)
);
};
render() {
const { show, closeHandler, loading, funnel } = this.props;
return (
<Modal size="small" open={show} onClose={this.props.closeHandler}>
<Modal.Header className={styles.modalHeader}>
<div>{'Save Funnel'}</div>
<Icon
role="button"
tabIndex="-1"
color="gray-dark"
size="14"
name="close"
onClick={closeHandler}
/>
</Modal.Header>
<Modal.Content>
<Form onSubmit={this.onSave}>
<Form.Field>
<label>{'Title:'}</label>
<Input
autoFocus={true}
className={styles.name}
name="name"
value={funnel.name}
onChange={this.onNameChange}
placeholder="Title"
/>
</Form.Field>
<Form.Field>
<div className="flex items-center">
<Checkbox
name="isPublic"
className="font-medium"
type="checkbox"
checked={funnel.isPublic}
onClick={this.onChangeOption}
className="mr-3"
/>
<div
className="flex items-center cursor-pointer"
onClick={() => this.props.edit({ isPublic: !funnel.isPublic })}
>
<Icon name="user-friends" size="16" />
<span className="ml-2"> Team Visible</span>
</div>
</div>
</Form.Field>
</Form>
</Modal.Content>
<Modal.Footer className="">
<Button
variant="primary"
onClick={this.onSave}
loading={loading}
className="float-left mr-2"
>
{funnel.exists() ? 'Modify' : 'Save'}
</Button>
<Button onClick={closeHandler}>{'Cancel'}</Button>
</Modal.Footer>
</Modal>
);
}
}

View file

@ -1,15 +0,0 @@
@import 'mixins.css';
.modalHeader {
display: flex !important;
align-items: center;
justify-content: space-between;
}
.cancelButton {
@mixin plainButton;
}
.applyButton {
@mixin basicButton;
}

View file

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

View file

@ -1,68 +0,0 @@
import React, { useState, useEffect } from 'react'
import { connect } from 'react-redux'
import SessionItem from 'Shared/SessionItem'
import { fetchSessions, fetchSessionsFiltered } from 'Duck/funnels'
import { setFunnelPage } from 'Duck/sessions'
import { LoadMoreButton, NoContent } from 'UI'
import FunnelSessionsHeader from '../FunnelSessionsHeader'
import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG';
const PER_PAGE = 10;
function FunnelSessionList(props) {
const { funnelId, issueId, list, sessionsTotal, sessionsSort, inDetails = false } = props;
const [showPages, setShowPages] = useState(1)
const displayedCount = Math.min(showPages * PER_PAGE, list.size);
const addPage = () => setShowPages(showPages + 1);
useEffect(() => {
props.setFunnelPage({
funnelId,
issueId
})
}, [])
return (
<div>
<FunnelSessionsHeader sessionsCount={inDetails ? sessionsTotal : list.size} inDetails={inDetails} />
<div className="mb-4" />
<NoContent
title={
<div className="flex flex-col items-center justify-center">
<AnimatedSVG name={ICONS.NO_RESULTS} size="60" />
<div className="mt-4">No recordings found!</div>
</div>
}
subtext="Please try changing your search parameters."
// animatedIcon="no-results"
show={ list.size === 0}
>
{ list.take(displayedCount).map(session => (
<SessionItem
key={ session.sessionId }
session={ session }
/>
))}
<LoadMoreButton
className="mt-12 mb-12"
displayedCount={displayedCount}
totalCount={list.size}
onClick={addPage}
/>
</NoContent>
</div>
)
}
export default connect(state => ({
list: state.getIn(['funnels', 'sessions']),
sessionsTotal: state.getIn(['funnels', 'sessionsTotal']),
funnel: state.getIn(['funnels', 'instance']),
activeStages: state.getIn(['funnels', 'activeStages']).toJS(),
liveFilters: state.getIn(['funnelFilters', 'appliedFilter']),
funnelFilters: state.getIn(['funnels', 'funnelFilters']),
sessionsSort: state.getIn(['funnels', 'sessionsSort']),
}), { fetchSessions, fetchSessionsFiltered, setFunnelPage })(FunnelSessionList)

View file

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

View file

@ -1,28 +0,0 @@
import React from 'react';
import { connect } from 'react-redux';
import { applyFilter, fetchList } from 'Duck/filters';
import { fetchList as fetchFunnelsList } from 'Duck/funnels';
import DateRangeDropdown from 'Shared/DateRangeDropdown';
@connect(state => ({
rangeValue: state.getIn([ 'filters', 'appliedFilter', 'rangeValue' ]),
startDate: state.getIn([ 'filters', 'appliedFilter', 'startDate' ]),
endDate: state.getIn([ 'filters', 'appliedFilter', 'endDate' ]),
}), {
applyFilter, fetchList, fetchFunnelsList
})
export default class DateRange extends React.PureComponent {
render() {
const { startDate, endDate, rangeValue, className } = this.props;
return (
<DateRangeDropdown
button
// onChange={ this.onDateChange }
rangeValue={ rangeValue }
startDate={ startDate }
endDate={ endDate }
className={ className }
/>
);
}
}

View file

@ -1,32 +0,0 @@
import React from 'react';
import { connect } from 'react-redux';
import Select from 'Shared/Select';
import { setSessionsSort as sort } from 'Duck/funnels';
import { setSessionsSort } from 'Duck/funnels';
@connect(state => ({
sessionsSort: state.getIn(['funnels','sessionsSort'])
}), { sort, setSessionsSort })
export default class SortDropdown extends React.PureComponent {
state = { value: null }
sort = ({ value }) => {
this.setState({ value: value })
const [ sort, order ] = value.split('-');
const sign = order === 'desc' ? -1 : 1;
setTimeout(() => this.props.sort(sort, sign), 100);
}
render() {
const { options, issuesSort } = this.props;
return (
<Select
right
plain
name="sortSessions"
options={options}
defaultValue={ options[ 0 ].value }
onChange={ this.sort }
/>
);
}
}

View file

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

View file

@ -1,23 +0,0 @@
.dropdown {
display: flex !important;
padding: 4px 6px;
border-radius: 3px;
color: $gray-darkest;
font-weight: 500;
&:hover {
background-color: $gray-light;
}
}
.dropdownTrigger {
padding: 4px 8px;
border-radius: 3px;
&:hover {
background-color: $gray-light;
}
}
.dropdownIcon {
margin-top: 2px;
margin-left: 3px;
}

View file

@ -1,55 +0,0 @@
import React from 'react'
import { connect } from 'react-redux';
import { Icon, Dropdown, TagBadge } from 'UI'
import { applyIssueFilter, removeIssueFilter } from 'Duck/funnels';
import cn from 'classnames';
import stl from './issueFilter.module.css';
import { List } from 'immutable';
function IssueFilter(props) {
const { filters, issueTypes, issueTypesMap } = props;
const onChangeFilter = (e, { name, value }) => {
const errors = filters.toJS();
errors.push(value);
props.applyIssueFilter({ filters: List(errors) });
}
return (
<div className="flex items-start">
<Dropdown
trigger={
<div className={cn("py-2 px-3 bg-white rounded-full flex items-center text-sm mb-2", stl.filterBtn)}>
<Icon name="filter" size="12" color="teal" />
<span className="ml-2 font-medium leading-none">Filter</span>
</div>
}
options={ issueTypes.filter(i => !filters.includes(i.value)) }
name="change"
icon={null}
onChange={onChangeFilter}
basic
scrolling
selectOnBlur={false}
/>
<div className="flex items-center ml-3 flex-wrap">
{filters.map(err => (
<TagBadge
className="mb-2"
key={ err }
hashed={false}
text={ issueTypesMap[err] }
onRemove={ () => props.removeIssueFilter(err) }
outline
/>
))}
</div>
</div>
)
}
export default connect(state => ({
filters: state.getIn(['funnels', 'issueFilters', 'filters']),
issueTypes: state.getIn(['funnels', 'issueTypes']).toJS(),
issueTypesMap: state.getIn(['funnels', 'issueTypesMap']),
}), { applyIssueFilter, removeIssueFilter })(IssueFilter)

View file

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

View file

@ -1,7 +0,0 @@
.filterBtn {
border: dashed 1px $teal !important;
color: $teal;
&:hover {
background-color: $active-blue;
}
}

View file

@ -1,26 +0,0 @@
import React from 'react'
import { Button } from 'UI'
import { addEvent } from 'Duck/funnelFilters'
import Event, { TYPES } from 'Types/filter/event';
import { connect } from 'react-redux';
function IssuesEmptyMessage(props) {
const { children, show } = props;
const createHandler = () => {
props.addEvent(Event({ type: TYPES.LOCATION, key: TYPES.LOCATION } ))
props.onAddEvent();
}
return (show ? (
<div className="flex flex-col items-center justify-center">
<div className="flex flex-col items-center justify-center text-center my-6">
<div className="text-3xl font-medium mb-4">See what's impacting conversions</div>
<div className="mb-4 text-xl">Add events to your funnel to identify potential issues that are causing conversion loss.</div>
<Button variant="primary" onClick={ createHandler }>+ ADD EVENTS</Button>
</div>
<img src="/assets/img/funnel_intro.png" />
</div>
) : children
)
}
export default connect(null, { addEvent })(IssuesEmptyMessage)

View file

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

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