Compare commits
42 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d4dd8e651 | ||
|
|
b91868ebe1 | ||
|
|
ca664e8860 | ||
|
|
70293cd8de | ||
|
|
b2fc450a1e | ||
|
|
7a25a0e6ad | ||
|
|
52c78f179d | ||
|
|
13f24ea6f4 | ||
|
|
fb4caa3f13 | ||
|
|
64a3eb7e89 | ||
|
|
b840ff65ff | ||
|
|
f46170036b | ||
|
|
daf254cc6f | ||
|
|
997d69c389 | ||
|
|
c2bc023c5e | ||
|
|
81ecbac892 | ||
|
|
df20cd5333 | ||
|
|
82586d23b2 | ||
|
|
a7a1eca9c3 | ||
|
|
c94ca6fb88 | ||
|
|
5a011692f8 | ||
|
|
b9590f702e | ||
|
|
6c56567580 | ||
|
|
2c12aa5239 | ||
|
|
acf3fb4275 | ||
|
|
577e47e11d | ||
|
|
f2e103ad08 | ||
|
|
d4d836ad24 | ||
|
|
4f2d61d1cf | ||
|
|
452fe62ebe | ||
|
|
de35ef8822 | ||
|
|
29576a775c | ||
|
|
3f9b485be6 | ||
|
|
ef318318d8 | ||
|
|
5d3872c371 | ||
|
|
1a2e143888 | ||
|
|
60ac0ed312 | ||
|
|
6ea2be8dc4 | ||
|
|
0f89770560 | ||
|
|
98e50d0e96 | ||
|
|
c8a7991d77 | ||
|
|
aecf3ecd96 |
318 changed files with 6618 additions and 10941 deletions
1
frontend/.browserslistrc
Normal file
1
frontend/.browserslistrc
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
> 0.25% and not dead
|
||||||
|
|
@ -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));
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
|
|
||||||
|
|
@ -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)));
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -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));
|
||||||
|
|
|
||||||
108
frontend/app/components/Client/CustomFields/CustomFields.tsx
Normal file
108
frontend/app/components/Client/CustomFields/CustomFields.tsx
Normal 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));
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
109
frontend/app/components/Client/Integrations/IntegrationForm.tsx
Normal file
109
frontend/app/components/Client/Integrations/IntegrationForm.tsx
Normal 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);
|
||||||
|
|
@ -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 application’s store, monitor queries, track performance issues and even
|
Plugins capture your application’s 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 />,
|
||||||
}
|
},
|
||||||
]
|
],
|
||||||
}
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
save = () => {
|
React.useEffect(() => {
|
||||||
const instance = this.props.instance;
|
return () => init({})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
|
||||||
|
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);
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
|
|
@ -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);
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
|
|
|
||||||
|
|
@ -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));
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
export { default } from './Permissions';
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 don’t have the permissions to perform this action.';
|
const PERMISSION_WARNING = 'You don’t 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);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
return (
|
||||||
const {
|
<Form className={ styles.formWrapper } onSubmit={ onSubmit }>
|
||||||
site, onClose, saving, gdpr,
|
<div className={ styles.content }>
|
||||||
} = this.props;
|
<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>
|
||||||
|
|
||||||
return (
|
<Form.Field>
|
||||||
<Form className={ styles.formWrapper } onSubmit={ this.onSubmit }>
|
<label htmlFor="defaultInputMode">{ 'Data Recording Options' }</label>
|
||||||
<div className={ styles.content }>
|
<Select
|
||||||
<Form.Field>
|
name="defaultInputMode"
|
||||||
<label>{ 'Name' }</label>
|
options={ inputModeOptions }
|
||||||
<div>{ site.host }</div>
|
onChange={ onChangeSelect }
|
||||||
</Form.Field>
|
placeholder="Default Input Mode"
|
||||||
<Form.Field>
|
value={ gdpr.defaultInputMode }
|
||||||
<label>{ 'Session Capture Rate' }</label>
|
/>
|
||||||
<Input
|
</Form.Field>
|
||||||
icon="percent"
|
|
||||||
name="sampleRate"
|
<Form.Field>
|
||||||
value={ gdpr.sampleRate }
|
<label>
|
||||||
onChange={ this.onChange }
|
<input
|
||||||
onBlur={ this.onSampleRateBlur }
|
name="maskNumbers"
|
||||||
className={ styles.sampleRate }
|
type="checkbox"
|
||||||
|
checked={ gdpr.maskNumbers }
|
||||||
|
onChange={ onChangeOption }
|
||||||
/>
|
/>
|
||||||
</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);
|
||||||
|
|
@ -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)));
|
||||||
|
|
|
||||||
|
|
@ -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)));
|
||||||
|
|
|
||||||
|
|
@ -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, }
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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));
|
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
export { default } from './ErrorFrame';
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
export { default } from './ErrorDetails';
|
|
||||||
|
|
@ -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) }
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
export { default } from './ErrorsList';
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import ErrorsList from '../ErrorsList';
|
|
||||||
|
|
||||||
function ErrorsWidget(props) {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<ErrorsList />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ErrorsWidget;
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
export { default } from './ErrorsWidget';
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
.name {
|
|
||||||
min-width: 55%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sessions {
|
|
||||||
width: 6%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.users {
|
|
||||||
width: 5%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.occurrence {
|
|
||||||
width: 15%;
|
|
||||||
min-width: 152px;
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
.label {
|
|
||||||
letter-spacing: 0.2em;
|
|
||||||
color: gray;
|
|
||||||
}
|
|
||||||
|
|
@ -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)))
|
|
||||||
|
|
@ -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);
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
export { default } from './FunnelGraph'
|
|
||||||
|
|
@ -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))
|
|
||||||
|
|
@ -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);
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
export { default } from './FunnelHeader';
|
|
||||||
|
|
@ -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))
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
export { default } from './FunnelIssueDetails'
|
//export { default } from './FunnelIssueDetails'
|
||||||
|
|
|
||||||
|
|
@ -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))
|
|
||||||
|
|
@ -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 }
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
export { default } from './SortDropdown';
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
export { default } from './FunnelIssues'
|
|
||||||
|
|
@ -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 }
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
@import 'mixins.css';
|
|
||||||
|
|
||||||
.modalHeader {
|
|
||||||
display: flex !important;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cancelButton {
|
|
||||||
@mixin plainButton;
|
|
||||||
}
|
|
||||||
|
|
||||||
.applyButton {
|
|
||||||
@mixin basicButton;
|
|
||||||
}
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
export { default } from './FunnelSaveModal'
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
export { default } from './FunnelSessionList'
|
|
||||||
|
|
@ -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 }
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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 }
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
export { default } from './SortDropdown';
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
export { default } from './IssueFilter'
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
.filterBtn {
|
|
||||||
border: dashed 1px $teal !important;
|
|
||||||
color: $teal;
|
|
||||||
&:hover {
|
|
||||||
background-color: $active-blue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
export { default } from './IssuesEmptyMessage'
|
|
||||||
|
|
@ -1,40 +1,26 @@
|
||||||
|
import { observer } from 'mobx-react-lite';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Icon } from 'UI';
|
|
||||||
import { useStore } from 'App/mstore';
|
|
||||||
import { useObserver } from 'mobx-react-lite';
|
|
||||||
import { useModal } from 'App/components/Modal';
|
|
||||||
import NewSiteForm from 'App/components/Client/Sites/NewSiteForm';
|
import NewSiteForm from 'App/components/Client/Sites/NewSiteForm';
|
||||||
import { init } from 'Duck/site';
|
import { useModal } from 'App/components/Modal';
|
||||||
import { connect } from 'react-redux';
|
import { useStore } from 'App/mstore';
|
||||||
interface Props {
|
import { Icon } from 'UI';
|
||||||
isAdmin?: boolean;
|
|
||||||
init?: (data: any) => void;
|
|
||||||
}
|
|
||||||
function NewProjectButton(props: Props) {
|
|
||||||
const { isAdmin = false } = props;
|
|
||||||
const { userStore } = useStore();
|
|
||||||
const limtis = useObserver(() => userStore.limits);
|
|
||||||
const canAddProject = useObserver(() => isAdmin && (limtis.projects === -1 || limtis.projects > 0));
|
|
||||||
const { showModal, hideModal } = useModal();
|
|
||||||
|
|
||||||
const onClick = () => {
|
function NewProjectButton() {
|
||||||
props.init({});
|
const { projectsStore } = useStore();
|
||||||
showModal(<NewSiteForm onClose={hideModal} />, { right: true });
|
const { showModal, hideModal } = useModal();
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
const onClick = () => {
|
||||||
<li onClick={onClick}>
|
projectsStore.initProject({});
|
||||||
<Icon name="folder-plus" size="16" color="teal" />
|
showModal(<NewSiteForm onClose={hideModal} />, { right: true });
|
||||||
<span className="ml-3 color-teal">Add Project</span>
|
};
|
||||||
</li>
|
|
||||||
// <div
|
return (
|
||||||
// className={cn('flex items-center justify-center py-3 cursor-pointer hover:bg-active-blue ', { disabled: !canAddProject })}
|
<li onClick={onClick}>
|
||||||
// onClick={onClick}
|
<Icon name="folder-plus" size="16" color="teal" />
|
||||||
// >
|
<span className="ml-3 color-teal">Add Project</span>
|
||||||
// <Icon name="plus" size={12} className="mr-2" color="teal" />
|
</li>
|
||||||
// <span className="color-teal">Add New Project</span>
|
);
|
||||||
// </div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default connect(null, { init })(NewProjectButton);
|
export default observer(NewProjectButton);
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue