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 Layout from 'App/layout/Layout';
|
||||
import PublicRoutes from 'App/PublicRoutes';
|
||||
import { useStore } from 'App/mstore';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
|
||||
const components: any = {
|
||||
SessionPure: lazy(() => import('Components/Session/Session')),
|
||||
|
|
@ -41,8 +43,11 @@ interface Props {
|
|||
}
|
||||
|
||||
function IFrameRoutes(props: Props) {
|
||||
const { isJwt = false, isLoggedIn = false, loading, onboarding, sites, siteId, jwt } = props;
|
||||
const siteIdList: any = sites.map(({ id }) => id).toJS();
|
||||
const { projectsStore } = useStore();
|
||||
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) {
|
||||
return (
|
||||
|
|
@ -75,11 +80,9 @@ function IFrameRoutes(props: Props) {
|
|||
export default connect((state: any) => ({
|
||||
changePassword: state.getIn(['user', 'account', 'changePassword']),
|
||||
onboarding: state.getIn(['user', 'onboarding']),
|
||||
sites: state.getIn(['site', 'list']),
|
||||
siteId: state.getIn(['site', 'siteId']),
|
||||
jwt: state.getIn(['user', 'jwt']),
|
||||
tenantId: state.getIn(['user', 'account', 'tenantId']),
|
||||
isEnterprise:
|
||||
state.getIn(['user', 'account', '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 { connect } from 'react-redux';
|
||||
import { Redirect, Route, Switch } from 'react-router-dom';
|
||||
|
||||
import AdditionalRoutes from 'App/AdditionalRoutes';
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { useStore } from "./mstore";
|
||||
import { GLOBAL_HAS_NO_RECORDINGS } from 'App/constants/storageKeys';
|
||||
import { OB_DEFAULT_TAB } from 'App/routes';
|
||||
import { Loader } from 'UI';
|
||||
|
|
@ -110,20 +110,20 @@ const SCOPE_SETUP = routes.scopeSetup();
|
|||
|
||||
interface Props {
|
||||
tenantId: string;
|
||||
siteId: string;
|
||||
sites: Map<string, any>;
|
||||
onboarding: boolean;
|
||||
scope: number;
|
||||
}
|
||||
|
||||
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 redirectToSetup = props.scope === 0;
|
||||
const redirectToOnboarding =
|
||||
!onboarding && (localStorage.getItem(GLOBAL_HAS_NO_RECORDINGS) === 'true' || !hasRecordings) && props.scope > 0;
|
||||
const siteIdList: any = sites.map(({ id }) => id).toJS();
|
||||
|
||||
!onboarding && (localStorage.getItem(GLOBAL_HAS_NO_RECORDINGS) === 'true' || (sites.length > 0 && !hasRecordings)) && props.scope > 0;
|
||||
const siteIdList: any = sites.map(({ id }) => id);
|
||||
return (
|
||||
<Suspense fallback={<Loader loading={true} className="flex-1" />}>
|
||||
<Switch key="content">
|
||||
|
|
@ -292,7 +292,5 @@ function PrivateRoutes(props: Props) {
|
|||
export default connect((state: any) => ({
|
||||
onboarding: state.getIn(['user', 'onboarding']),
|
||||
scope: getScope(state),
|
||||
sites: state.getIn(['site', 'list']),
|
||||
siteId: state.getIn(['site', 'siteId']),
|
||||
tenantId: state.getIn(['user', 'account', 'tenantId']),
|
||||
}))(PrivateRoutes);
|
||||
}))(observer(PrivateRoutes));
|
||||
|
|
|
|||
|
|
@ -10,60 +10,56 @@ import {
|
|||
GLOBAL_DESTINATION_PATH,
|
||||
IFRAME,
|
||||
JWT_PARAM,
|
||||
SPOT_ONBOARDING,
|
||||
SPOT_ONBOARDING
|
||||
} from 'App/constants/storageKeys';
|
||||
import Layout from 'App/layout/Layout';
|
||||
import { withStore } from 'App/mstore';
|
||||
import { useStore } from 'App/mstore';
|
||||
import { checkParam, handleSpotJWT, isTokenExpired } from 'App/utils';
|
||||
import { ModalProvider } from 'Components/Modal';
|
||||
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 { Loader } from 'UI';
|
||||
import * as routes from './routes';
|
||||
import { observer } from 'mobx-react-lite'
|
||||
|
||||
interface RouterProps
|
||||
extends RouteComponentProps,
|
||||
ConnectedProps<typeof connector> {
|
||||
isLoggedIn: boolean;
|
||||
sites: Map<string, any>;
|
||||
loading: boolean;
|
||||
changePassword: boolean;
|
||||
isEnterprise: boolean;
|
||||
fetchUserInfo: () => any;
|
||||
setSessionPath: (path: any) => any;
|
||||
fetchSiteList: (siteId?: number) => any;
|
||||
match: {
|
||||
params: {
|
||||
siteId: string;
|
||||
};
|
||||
};
|
||||
mstore: any;
|
||||
setJwt: (params: { jwt: string; spotJwt: string | null }) => any;
|
||||
fetchMetadata: (siteId: string) => void;
|
||||
initSite: (site: any) => void;
|
||||
scopeSetup: boolean;
|
||||
localSpotJwt: string | null;
|
||||
}
|
||||
|
||||
const Router: React.FC<RouterProps> = (props) => {
|
||||
const {
|
||||
isLoggedIn,
|
||||
siteId,
|
||||
sites,
|
||||
loading,
|
||||
userInfoLoading,
|
||||
location,
|
||||
fetchUserInfo,
|
||||
fetchSiteList,
|
||||
history,
|
||||
setSessionPath,
|
||||
scopeSetup,
|
||||
localSpotJwt,
|
||||
logout,
|
||||
scopeSetup,
|
||||
setJwt,
|
||||
} = 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 spotCb = params.get('spotCallback');
|
||||
|
|
@ -81,7 +77,7 @@ const Router: React.FC<RouterProps> = (props) => {
|
|||
handleSpotLogin(spotJwt);
|
||||
}
|
||||
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');
|
||||
}
|
||||
await fetchUserInfo();
|
||||
const siteIdFromPath = parseInt(location.pathname.split('/')[1]);
|
||||
const siteIdFromPath = location.pathname.split('/')[1];
|
||||
await fetchSiteList(siteIdFromPath);
|
||||
props.mstore.initClient();
|
||||
mstore.initClient();
|
||||
|
||||
if (localSpotJwt && !isTokenExpired(localSpotJwt)) {
|
||||
handleSpotLogin(localSpotJwt);
|
||||
|
|
@ -175,12 +171,16 @@ const Router: React.FC<RouterProps> = (props) => {
|
|||
}, [isSpotCb, isLoggedIn, localSpotJwt, isSignup]);
|
||||
|
||||
useEffect(() => {
|
||||
if (siteId && siteId !== lastFetchedSiteIdRef.current) {
|
||||
const activeSite = sites.find((s) => s.id == siteId);
|
||||
props.initSite(activeSite);
|
||||
props.fetchMetadata(siteId);
|
||||
lastFetchedSiteIdRef.current = siteId;
|
||||
}
|
||||
const fetchData = async () => {
|
||||
if (siteId && siteId !== lastFetchedSiteIdRef.current) {
|
||||
const activeSite = sites.find((s) => s.id == siteId);
|
||||
initSite(activeSite ?? {});
|
||||
lastFetchedSiteIdRef.current = activeSite?.id;
|
||||
await customFieldStore.fetchListActive(siteId + '');
|
||||
}
|
||||
};
|
||||
|
||||
void fetchData();
|
||||
}, [siteId]);
|
||||
|
||||
const lastFetchedSiteIdRef = useRef<any>(null);
|
||||
|
|
@ -226,29 +226,21 @@ const Router: React.FC<RouterProps> = (props) => {
|
|||
};
|
||||
|
||||
const mapStateToProps = (state: Map<string, any>) => {
|
||||
const siteId = state.getIn(['site', 'siteId']);
|
||||
const jwt = state.getIn(['user', 'jwt']);
|
||||
const changePassword = state.getIn(['user', 'account', 'changePassword']);
|
||||
const userInfoLoading = state.getIn([
|
||||
'user',
|
||||
'fetchUserInfoRequest',
|
||||
'loading',
|
||||
'loading'
|
||||
]);
|
||||
const sitesLoading = state.getIn(['site', 'fetchListRequest', 'loading']);
|
||||
const scopeSetup = getScope(state) === 0;
|
||||
const loading =
|
||||
Boolean(userInfoLoading) ||
|
||||
Boolean(sitesLoading) ||
|
||||
(!scopeSetup && !siteId);
|
||||
return {
|
||||
siteId,
|
||||
changePassword,
|
||||
sites: state.getIn(['site', 'list']),
|
||||
jwt,
|
||||
scopeSetup,
|
||||
localSpotJwt: state.getIn(['user', 'spotJwt']),
|
||||
isLoggedIn: jwt !== null && !changePassword,
|
||||
scopeSetup,
|
||||
loading,
|
||||
userInfoLoading,
|
||||
email: state.getIn(['user', 'account', 'email']),
|
||||
account: state.getIn(['user', 'account']),
|
||||
organisation: state.getIn(['user', 'account', 'name']),
|
||||
|
|
@ -256,20 +248,16 @@ const mapStateToProps = (state: Map<string, any>) => {
|
|||
tenants: state.getIn(['user', 'tenants']),
|
||||
isEnterprise:
|
||||
state.getIn(['user', 'account', 'edition']) === 'ee' ||
|
||||
state.getIn(['user', 'authDetails', 'edition']) === 'ee',
|
||||
state.getIn(['user', 'authDetails', 'edition']) === 'ee'
|
||||
};
|
||||
};
|
||||
|
||||
const mapDispatchToProps = {
|
||||
fetchUserInfo,
|
||||
setSessionPath,
|
||||
fetchSiteList,
|
||||
setJwt,
|
||||
fetchMetadata,
|
||||
initSite,
|
||||
logout,
|
||||
logout
|
||||
};
|
||||
|
||||
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 {
|
||||
private init: RequestInit;
|
||||
private readonly siteId: string | undefined;
|
||||
private siteId: string | undefined;
|
||||
private siteIdCheck: (() => { siteId: string | null }) | undefined;
|
||||
private refreshingTokenPromise: Promise<string> | null = null;
|
||||
|
||||
constructor() {
|
||||
const jwt = store.getState().getIn(['user', 'jwt']);
|
||||
const siteId = store.getState().getIn(['site', 'siteId']);
|
||||
this.init = {
|
||||
headers: new Headers({
|
||||
Accept: 'application/json',
|
||||
|
|
@ -69,7 +69,10 @@ export default class APIClient {
|
|||
if (jwt !== null) {
|
||||
(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 {
|
||||
|
|
@ -101,6 +104,7 @@ export default class APIClient {
|
|||
delete init.body; // GET requests shouldn't have a body
|
||||
}
|
||||
|
||||
this.siteId = this.siteIdCheck?.().siteId ?? undefined;
|
||||
return init;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { fetchLiveList } from 'Duck/sessions';
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { useStore } from 'App/mstore';
|
||||
import { Loader, NoContent, Label } from 'UI';
|
||||
import SessionItem from 'Shared/SessionItem';
|
||||
import { useModal } from 'App/components/Modal';
|
||||
|
|
@ -11,16 +11,20 @@ interface Props {
|
|||
list: any;
|
||||
session: any;
|
||||
userId: any;
|
||||
fetchLiveList: (params: any) => void;
|
||||
}
|
||||
function SessionList(props: Props) {
|
||||
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(() => {
|
||||
const params: any = {};
|
||||
if (props.session.userId) {
|
||||
params.userId = props.session.userId;
|
||||
}
|
||||
props.fetchLiveList(params);
|
||||
void fetchLiveList(params);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
|
|
@ -33,9 +37,9 @@ function SessionList(props: Props) {
|
|||
{props.userId}'s <span className="color-gray-medium">Live Sessions</span>{' '}
|
||||
</div>
|
||||
</div>
|
||||
<Loader loading={props.loading}>
|
||||
<Loader loading={loading}>
|
||||
<NoContent
|
||||
show={!props.loading && props.list.length === 0}
|
||||
show={!loading && list.length === 0}
|
||||
title={
|
||||
<div className="flex items-center justify-center flex-col">
|
||||
<AnimatedSVG name={ICONS.NO_LIVE_SESSIONS} size={60} />
|
||||
|
|
@ -45,7 +49,7 @@ function SessionList(props: Props) {
|
|||
}
|
||||
>
|
||||
<div className="p-4">
|
||||
{props.list.map((session: any) => (
|
||||
{list.map((session: any) => (
|
||||
<div className="mb-6" key={session.sessionId}>
|
||||
{session.pageTitle && session.pageTitle !== '' && (
|
||||
<div className="flex items-center mb-2">
|
||||
|
|
@ -65,14 +69,4 @@ function SessionList(props: Props) {
|
|||
);
|
||||
}
|
||||
|
||||
export default connect(
|
||||
(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);
|
||||
export default observer(SessionList);
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ const CustomFieldForm = ({ field, saving, errors, edit, save, onSave, onClose, o
|
|||
const mapStateToProps = (state) => ({
|
||||
field: state.getIn(['customFields', 'instance']),
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
function CustomFields(props) {
|
||||
const [currentSite, setCurrentSite] = React.useState(props.sites.get(0));
|
||||
const [deletingItem, setDeletingItem] = React.useState(null);
|
||||
const { showModal, hideModal } = useModal();
|
||||
const [currentSite, setCurrentSite] = React.useState(props.sites.get(0));
|
||||
const [deletingItem, setDeletingItem] = React.useState(null);
|
||||
const { showModal, hideModal } = useModal();
|
||||
|
||||
useEffect(() => {
|
||||
const activeSite = props.sites.get(0);
|
||||
if (!activeSite) return;
|
||||
useEffect(() => {
|
||||
const activeSite = props.sites.get(0);
|
||||
if (!activeSite) return;
|
||||
|
||||
props.fetchList(activeSite.id);
|
||||
}, []);
|
||||
props.fetchList(activeSite.id);
|
||||
}, []);
|
||||
|
||||
const save = (field) => {
|
||||
props.save(currentSite.id, field).then((response) => {
|
||||
if (!response || !response.errors || response.errors.size === 0) {
|
||||
hideModal();
|
||||
toast.success('Metadata added successfully!');
|
||||
} else {
|
||||
toast.error(response.errors[0]);
|
||||
}
|
||||
const save = (field) => {
|
||||
props.save(currentSite.id, field).then((response) => {
|
||||
if (!response || !response.errors || response.errors.size === 0) {
|
||||
hideModal();
|
||||
toast.success('Metadata added successfully!');
|
||||
} else {
|
||||
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) => {
|
||||
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 { 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>
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
export default connect(
|
||||
(state) => ({
|
||||
fields: state.getIn(['customFields', 'list']).sortBy((i) => i.index),
|
||||
field: state.getIn(['customFields', 'instance']),
|
||||
loading: state.getIn(['customFields', 'fetchRequest', 'loading']),
|
||||
sites: state.getIn(['site', 'list']),
|
||||
errors: state.getIn(['customFields', 'saveRequest', 'errors']),
|
||||
}),
|
||||
{
|
||||
init,
|
||||
fetchList,
|
||||
save,
|
||||
remove,
|
||||
}
|
||||
(state) => ({
|
||||
fields: state.getIn(['customFields', 'list']).sortBy((i) => i.index),
|
||||
field: state.getIn(['customFields', 'instance']),
|
||||
loading: state.getIn(['customFields', 'fetchRequest', 'loading']),
|
||||
sites: state.getIn(['site', 'list']),
|
||||
errors: state.getIn(['customFields', 'saveRequest', 'errors'])
|
||||
}),
|
||||
{
|
||||
init,
|
||||
fetchList,
|
||||
save,
|
||||
remove
|
||||
}
|
||||
)(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';
|
||||
|
||||
const ListItem = ({ field, onEdit, disabled }) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'group hover:bg-active-blue flex items-center justify-between py-3 px-5 cursor-pointer',
|
||||
field.index === 0 ? styles.preDefined : '',
|
||||
{
|
||||
[styles.disabled]: disabled,
|
||||
}
|
||||
)}
|
||||
onClick={() => field.index != 0 && onEdit(field)}
|
||||
>
|
||||
<span>{field.key}</span>
|
||||
<div className="invisible group-hover:visible" data-hidden={field.index === 0}>
|
||||
<Button variant="text-primary" icon="pencil" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'group hover:bg-active-blue flex items-center justify-between py-3 px-5 cursor-pointer',
|
||||
field.index === 0 ? styles.preDefined : '',
|
||||
{
|
||||
[styles.disabled]: disabled
|
||||
}
|
||||
)}
|
||||
onClick={() => field.index !== 0 && onEdit(field)}
|
||||
>
|
||||
<span>{field.key}</span>
|
||||
<div className="invisible group-hover:visible" data-hidden={field.index === 0}>
|
||||
<Button variant="text-primary" icon="pencil" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ListItem;
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import { useStore } from "App/mstore";
|
||||
import React from 'react';
|
||||
import DocLink from 'Shared/DocLink/DocLink';
|
||||
import AssistScript from './AssistScript';
|
||||
import AssistNpm from './AssistNpm';
|
||||
import { Tabs, CodeBlock } from 'UI';
|
||||
import { useState } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { observer } from 'mobx-react-lite'
|
||||
|
||||
const NPM = 'NPM';
|
||||
const SCRIPT = 'SCRIPT';
|
||||
|
|
@ -13,8 +14,11 @@ const TABS = [
|
|||
{ key: NPM, text: NPM },
|
||||
];
|
||||
|
||||
const AssistDoc = (props) => {
|
||||
const { projectKey } = props;
|
||||
const AssistDoc = () => {
|
||||
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 renderActiveTab = () => {
|
||||
|
|
@ -53,10 +57,4 @@ const AssistDoc = (props) => {
|
|||
|
||||
AssistDoc.displayName = 'AssistDoc';
|
||||
|
||||
export default connect((state) => {
|
||||
const siteId = state.getIn(['integrations', 'siteId']);
|
||||
const sites = state.getIn(['site', 'list']);
|
||||
return {
|
||||
projectKey: sites.find((site) => site.get('id') === siteId).get('projectKey'),
|
||||
};
|
||||
})(AssistDoc);
|
||||
export default observer(AssistDoc);
|
||||
|
|
|
|||
|
|
@ -1,17 +1,16 @@
|
|||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { tokenRE } from 'Types/integrations/bugsnagConfig';
|
||||
import { edit } from 'Duck/integrations/actions';
|
||||
import Select from 'Shared/Select';
|
||||
import { withRequest } from 'HOCs';
|
||||
|
||||
@connect(state => ({
|
||||
token: state.getIn([ 'bugsnag', 'instance', 'authorizationToken' ])
|
||||
}), { edit })
|
||||
}))
|
||||
@withRequest({
|
||||
dataName: "projects",
|
||||
initialData: [],
|
||||
dataWrapper: (data = [], prevData) => {
|
||||
dataWrapper: (data = []) => {
|
||||
if (!Array.isArray(data)) throw new Error('Wrong responce format.');
|
||||
const withOrgName = data.length > 1;
|
||||
return data.reduce((accum, { name: orgName, projects }) => {
|
||||
|
|
@ -35,15 +34,7 @@ export default class ProjectListDropdown extends React.PureComponent {
|
|||
if (!tokenRE.test(token)) return;
|
||||
this.props.fetchProjectList({
|
||||
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) {
|
||||
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 { 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 LogGroupDropdown from './LogGroupDropdown';
|
||||
import RegionDropdown from './RegionDropdown';
|
||||
import DocLink from 'Shared/DocLink/DocLink';
|
||||
import IntegrationModalCard from 'Components/Client/Integrations/IntegrationModalCard';
|
||||
|
||||
const CloudwatchForm = (props) => (
|
||||
<div className='bg-white h-screen overflow-y-auto' style={{ width: '350px' }}>
|
||||
<IntegrationModalCard title='Cloud Watch' icon='integrations/aws'
|
||||
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>
|
||||
<div className="bg-white h-screen overflow-y-auto" style={{ width: '350px' }}>
|
||||
<IntegrationModalCard
|
||||
title="Cloud Watch"
|
||||
icon="integrations/aws"
|
||||
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">
|
||||
<li>Create a Service Account</li>
|
||||
<li>Enter the details below</li>
|
||||
<li>Propagate openReplaySessionToken</li>
|
||||
</ol>
|
||||
<DocLink className='mt-4' label='Integrate CloudWatch'
|
||||
url='https://docs.openreplay.com/integrations/cloudwatch' />
|
||||
<DocLink
|
||||
className="mt-4"
|
||||
label="Integrate CloudWatch"
|
||||
url="https://docs.openreplay.com/integrations/cloudwatch"
|
||||
/>
|
||||
</div>
|
||||
<IntegrationForm
|
||||
{...props}
|
||||
name='cloudwatch'
|
||||
name="cloudwatch"
|
||||
formFields={[
|
||||
{
|
||||
key: 'awsAccessKeyId',
|
||||
label: 'AWS Access Key ID'
|
||||
label: 'AWS Access Key ID',
|
||||
},
|
||||
{
|
||||
key: 'awsSecretAccessKey',
|
||||
label: 'AWS Secret Access Key'
|
||||
label: 'AWS Secret Access Key',
|
||||
},
|
||||
{
|
||||
key: 'region',
|
||||
label: 'Region',
|
||||
component: RegionDropdown
|
||||
component: RegionDropdown,
|
||||
},
|
||||
{
|
||||
key: 'logGroupName',
|
||||
|
|
@ -44,8 +56,8 @@ const CloudwatchForm = (props) => (
|
|||
checkIfDisplayed: (config) =>
|
||||
config.awsSecretAccessKey.length === SECRET_ACCESS_KEY_LENGTH &&
|
||||
config.region !== '' &&
|
||||
config.awsAccessKeyId.length === ACCESS_KEY_ID_LENGTH
|
||||
}
|
||||
config.awsAccessKeyId.length === ACCESS_KEY_ID_LENGTH,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,77 +1,93 @@
|
|||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
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 { withRequest } from 'HOCs';
|
||||
import { integrationsService } from "App/services";
|
||||
|
||||
@connect(state => ({
|
||||
config: state.getIn([ 'cloudwatch', 'instance' ])
|
||||
}), { edit })
|
||||
@withRequest({
|
||||
dataName: "values",
|
||||
initialData: [],
|
||||
resetBeforeRequest: true,
|
||||
requestName: "fetchLogGroups",
|
||||
endpoint: '/integrations/cloudwatch/list_groups',
|
||||
method: 'POST',
|
||||
})
|
||||
export default class LogGroupDropdown extends React.PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.fetchLogGroups()
|
||||
}
|
||||
fetchLogGroups() {
|
||||
const { config } = this.props;
|
||||
if (config.region === "" ||
|
||||
config.awsSecretAccessKey.length !== SECRET_ACCESS_KEY_LENGTH ||
|
||||
config.awsAccessKeyId.length !== ACCESS_KEY_ID_LENGTH
|
||||
) return;
|
||||
this.props.fetchLogGroups({
|
||||
region: config.region,
|
||||
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();
|
||||
const LogGroupDropdown = (props) => {
|
||||
const { integrationsStore } = useStore();
|
||||
const config = integrationsStore.cloudwatch.instance;
|
||||
const edit = integrationsStore.cloudwatch.edit;
|
||||
const {
|
||||
value,
|
||||
name,
|
||||
placeholder,
|
||||
onChange,
|
||||
} = props;
|
||||
|
||||
const [values, setValues] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
const { region, awsSecretAccessKey, awsAccessKeyId } = config;
|
||||
|
||||
const fetchLogGroups = useCallback(() => {
|
||||
if (
|
||||
region === '' ||
|
||||
awsSecretAccessKey.length !== SECRET_ACCESS_KEY_LENGTH ||
|
||||
awsAccessKeyId.length !== ACCESS_KEY_ID_LENGTH
|
||||
) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
onChange = (target) => {
|
||||
if (typeof this.props.onChange === 'function') {
|
||||
this.props.onChange({ target });
|
||||
|
||||
setLoading(true);
|
||||
setError(false);
|
||||
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 {
|
||||
values,
|
||||
name,
|
||||
value,
|
||||
placeholder,
|
||||
loading,
|
||||
} = this.props;
|
||||
const options = values.map(g => ({ text: g, value: g }));
|
||||
return (
|
||||
<Select
|
||||
// selection
|
||||
options={ options }
|
||||
name={ name }
|
||||
value={ options.find(o => o.value === value) }
|
||||
placeholder={ placeholder }
|
||||
onChange={ this.onChange }
|
||||
loading={ loading }
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const options = values.map((g) => ({ text: g, value: g }));
|
||||
return (
|
||||
<Select
|
||||
options={options}
|
||||
name={name}
|
||||
value={options.find((o) => o.value === value)}
|
||||
placeholder={placeholder}
|
||||
onChange={handleChange}
|
||||
loading={loading}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default observer(LogGroupDropdown);
|
||||
|
|
|
|||
|
|
@ -1,97 +1,64 @@
|
|||
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';
|
||||
|
||||
@connect(
|
||||
(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);
|
||||
}
|
||||
}
|
||||
import DocLink from 'Shared/DocLink/DocLink';
|
||||
|
||||
validateConfig = (newProps) => {
|
||||
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 });
|
||||
});
|
||||
};
|
||||
import IntegrationForm from './IntegrationForm';
|
||||
|
||||
render() {
|
||||
const props = this.props;
|
||||
return (
|
||||
<div className='bg-white h-screen overflow-y-auto' style={{ width: '350px' }}>
|
||||
<IntegrationModalCard title='Elasticsearch' icon='integrations/elasticsearch'
|
||||
description='Integrate Elasticsearch with session replays to seamlessly observe backend errors.' />
|
||||
const ElasticsearchForm = (props) => {
|
||||
return (
|
||||
<div
|
||||
className="bg-white h-screen overflow-y-auto"
|
||||
style={{ width: '350px' }}
|
||||
>
|
||||
<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='font-medium mb-1'>How it works?</div>
|
||||
<ol className="list-decimal list-inside">
|
||||
<li>Create a new Elastic API key</li>
|
||||
<li>Enter the API key below</li>
|
||||
<li>Propagate openReplaySessionToken</li>
|
||||
</ol>
|
||||
<DocLink className='mt-4' label='Integrate Elasticsearch'
|
||||
url='https://docs.openreplay.com/integrations/elastic' />
|
||||
</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 className="p-5 border-b mb-4">
|
||||
<div className="font-medium mb-1">How it works?</div>
|
||||
<ol className="list-decimal list-inside">
|
||||
<li>Create a new Elastic API key</li>
|
||||
<li>Enter the API key below</li>
|
||||
<li>Propagate openReplaySessionToken</li>
|
||||
</ol>
|
||||
<DocLink
|
||||
className="mt-4"
|
||||
label="Integrate Elasticsearch"
|
||||
url="https://docs.openreplay.com/integrations/elastic"
|
||||
/>
|
||||
</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 { CodeBlock } from "UI";
|
||||
import DocLink from 'Shared/DocLink/DocLink';
|
||||
import ToggleContent from 'Shared/ToggleContent';
|
||||
import { connect } from 'react-redux';
|
||||
import { observer } from 'mobx-react-lite'
|
||||
|
||||
const GraphQLDoc = (props) => {
|
||||
const { projectKey } = props;
|
||||
const GraphQLDoc = () => {
|
||||
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';
|
||||
import trackerGraphQL from '@openreplay/tracker-graphql';
|
||||
//...
|
||||
|
|
@ -70,10 +74,4 @@ export const recordGraphQL = tracker.use(trackerGraphQL());`
|
|||
|
||||
GraphQLDoc.displayName = 'GraphQLDoc';
|
||||
|
||||
export default connect((state) => {
|
||||
const siteId = state.getIn(['integrations', 'siteId']);
|
||||
const sites = state.getIn(['site', 'list']);
|
||||
return {
|
||||
projectKey: sites.find((site) => site.get('id') === siteId).get('projectKey'),
|
||||
};
|
||||
})(GraphQLDoc);
|
||||
export default observer(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 { connect } from 'react-redux';
|
||||
|
||||
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 { PageTitle, Tooltip } from 'UI';
|
||||
import withPageTitle from 'HOCs/withPageTitle';
|
||||
|
||||
import AssistDoc from './AssistDoc';
|
||||
import BugsnagForm from './BugsnagForm';
|
||||
import CloudwatchForm from './CloudwatchForm';
|
||||
import DatadogForm from './DatadogForm';
|
||||
import ElasticsearchForm from './ElasticsearchForm';
|
||||
import GithubForm from './GithubForm';
|
||||
import GraphQLDoc from './GraphQLDoc';
|
||||
import IntegrationItem from './IntegrationItem';
|
||||
import JiraForm from './JiraForm';
|
||||
import MobxDoc from './MobxDoc';
|
||||
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 SentryForm from './SentryForm';
|
||||
import SlackForm from './SlackForm';
|
||||
import StackdriverForm from './StackdriverForm';
|
||||
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 {
|
||||
fetch: (name: string, siteId: string) => void;
|
||||
init: () => void;
|
||||
fetchIntegrationList: (siteId: any) => void;
|
||||
integratedList: any;
|
||||
initialSiteId: string;
|
||||
setSiteId: (siteId: string) => void;
|
||||
siteId: string;
|
||||
hideHeader?: boolean;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
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 [integratedList, setIntegratedList] = useState<string[]>([]);
|
||||
const [activeFilter, setActiveFilter] = useState<string>('all');
|
||||
|
||||
useEffect(() => {
|
||||
const list = props.integratedList
|
||||
const list = storeIntegratedList
|
||||
.filter((item: any) => item.integrated)
|
||||
.map((item: any) => item.name);
|
||||
setIntegratedList(list);
|
||||
}, [props.integratedList]);
|
||||
}, [storeIntegratedList]);
|
||||
|
||||
useEffect(() => {
|
||||
props.fetchIntegrationList(initialSiteId);
|
||||
props.setSiteId(initialSiteId);
|
||||
}, []);
|
||||
void fetchIntegrationList(siteId);
|
||||
}, [siteId]);
|
||||
|
||||
const onClick = (integration: any, width: number) => {
|
||||
if (integration.slug && integration.slug !== 'slack' && integration.slug !== 'msteams') {
|
||||
props.fetch(integration.slug, props.siteId);
|
||||
if (
|
||||
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(
|
||||
React.cloneElement(integration.component, {
|
||||
integrated: integratedList.includes(integration.slug)
|
||||
integrated: integratedList.includes(integration.slug),
|
||||
}),
|
||||
{ right: true, width }
|
||||
);
|
||||
};
|
||||
|
||||
const onChangeSelect = ({ value }: any) => {
|
||||
props.setSiteId(value.value);
|
||||
props.fetchIntegrationList(value.value);
|
||||
};
|
||||
|
||||
const onChange = (key: string) => {
|
||||
setActiveFilter(key);
|
||||
};
|
||||
|
|
@ -99,83 +106,92 @@ function Integrations(props: Props) {
|
|||
key: cat.key,
|
||||
title: cat.title,
|
||||
label: cat.title,
|
||||
icon: cat.icon
|
||||
}))
|
||||
|
||||
|
||||
const allIntegrations = filteredIntegrations.flatMap(cat => cat.integrations);
|
||||
icon: cat.icon,
|
||||
}));
|
||||
|
||||
const allIntegrations = filteredIntegrations.flatMap(
|
||||
(cat) => cat.integrations
|
||||
);
|
||||
|
||||
console.log(
|
||||
allIntegrations,
|
||||
integratedList
|
||||
)
|
||||
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>} />}
|
||||
|
||||
<IntegrationFilters onChange={onChange} activeItem={activeFilter} filters={filters} />
|
||||
<IntegrationFilters
|
||||
onChange={onChange}
|
||||
activeItem={activeFilter}
|
||||
filters={filters}
|
||||
/>
|
||||
</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
|
||||
`)}>
|
||||
`)}
|
||||
>
|
||||
{allIntegrations.map((integration: any) => (
|
||||
<IntegrationItem
|
||||
integrated={integratedList.includes(integration.slug)}
|
||||
integration={integration}
|
||||
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={
|
||||
(integration.slug === 'github' &&
|
||||
integratedList.includes('jira')) ||
|
||||
(integration.slug === 'jira' &&
|
||||
integratedList.includes('github'))
|
||||
(integration.slug === 'jira' && integratedList.includes('github'))
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default connect(
|
||||
(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));
|
||||
|
||||
export default withPageTitle('Integrations - OpenReplay Preferences')(observer(Integrations))
|
||||
|
||||
const integrations = [
|
||||
{
|
||||
title: '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,
|
||||
icon: 'exclamation-triangle',
|
||||
integrations: [
|
||||
{
|
||||
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',
|
||||
category: 'Errors',
|
||||
icon: 'integrations/jira',
|
||||
component: <JiraForm />
|
||||
component: <JiraForm />,
|
||||
},
|
||||
{
|
||||
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',
|
||||
category: 'Errors',
|
||||
icon: 'integrations/github',
|
||||
component: <GithubForm />
|
||||
}
|
||||
]
|
||||
component: <GithubForm />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Backend Logging',
|
||||
|
|
@ -186,106 +202,119 @@ const integrations = [
|
|||
'Sync your backend errors with sessions replays and see what happened front-to-back.',
|
||||
docs: () => (
|
||||
<DocCard
|
||||
title='Why use integrations?'
|
||||
icon='question-lg'
|
||||
iconBgColor='bg-red-lightest'
|
||||
iconColor='red'
|
||||
title="Why use integrations?"
|
||||
icon="question-lg"
|
||||
iconBgColor="bg-red-lightest"
|
||||
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>
|
||||
),
|
||||
integrations: [
|
||||
{
|
||||
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',
|
||||
icon: 'integrations/sentry',
|
||||
component: <SentryForm />
|
||||
component: <SentryForm />,
|
||||
},
|
||||
{
|
||||
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',
|
||||
icon: 'integrations/bugsnag',
|
||||
component: <BugsnagForm />
|
||||
component: <BugsnagForm />,
|
||||
},
|
||||
{
|
||||
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',
|
||||
icon: 'integrations/rollbar',
|
||||
component: <RollbarForm />
|
||||
component: <RollbarForm />,
|
||||
},
|
||||
{
|
||||
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',
|
||||
icon: 'integrations/elasticsearch',
|
||||
component: <ElasticsearchForm />
|
||||
component: <ElasticsearchForm />,
|
||||
},
|
||||
{
|
||||
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',
|
||||
icon: 'integrations/datadog',
|
||||
component: <DatadogForm />
|
||||
component: <DatadogForm />,
|
||||
},
|
||||
{
|
||||
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',
|
||||
icon: 'integrations/sumologic',
|
||||
component: <SumoLogicForm />
|
||||
component: <SumoLogicForm />,
|
||||
},
|
||||
{
|
||||
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',
|
||||
icon: 'integrations/google-cloud',
|
||||
component: <StackdriverForm />
|
||||
component: <StackdriverForm />,
|
||||
},
|
||||
{
|
||||
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',
|
||||
icon: 'integrations/aws',
|
||||
component: <CloudwatchForm />
|
||||
component: <CloudwatchForm />,
|
||||
},
|
||||
{
|
||||
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',
|
||||
icon: 'integrations/newrelic',
|
||||
component: <NewrelicForm />
|
||||
}
|
||||
]
|
||||
component: <NewrelicForm />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Collaboration',
|
||||
key: 'collaboration',
|
||||
isProject: false,
|
||||
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: [
|
||||
{
|
||||
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',
|
||||
category: 'Errors',
|
||||
icon: 'integrations/slack',
|
||||
component: <SlackForm />,
|
||||
shared: true
|
||||
shared: true,
|
||||
},
|
||||
{
|
||||
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',
|
||||
category: 'Errors',
|
||||
icon: 'integrations/teams',
|
||||
component: <MSTeams />,
|
||||
shared: true
|
||||
}
|
||||
]
|
||||
shared: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
// {
|
||||
// title: 'State Management',
|
||||
|
|
@ -302,72 +331,82 @@ const integrations = [
|
|||
icon: 'chat-left-text',
|
||||
docs: () => (
|
||||
<DocCard
|
||||
title='What are plugins?'
|
||||
icon='question-lg'
|
||||
iconBgColor='bg-red-lightest'
|
||||
iconColor='red'
|
||||
title="What are plugins?"
|
||||
icon="question-lg"
|
||||
iconBgColor="bg-red-lightest"
|
||||
iconColor="red"
|
||||
>
|
||||
Plugins capture your application’s store, monitor queries, track performance issues and even
|
||||
assist your end user through live sessions.
|
||||
Plugins capture your application’s store, monitor queries, track
|
||||
performance issues and even assist your end user through live sessions.
|
||||
</DocCard>
|
||||
),
|
||||
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: [
|
||||
{
|
||||
title: 'Redux',
|
||||
subtitle: 'Capture Redux actions/state and inspect them later on while replaying session recordings.',
|
||||
icon: 'integrations/redux', component: <ReduxDoc />
|
||||
subtitle:
|
||||
'Capture Redux actions/state and inspect them later on while replaying session recordings.',
|
||||
icon: 'integrations/redux',
|
||||
component: <ReduxDoc />,
|
||||
},
|
||||
{
|
||||
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',
|
||||
component: <VueDoc />
|
||||
component: <VueDoc />,
|
||||
},
|
||||
{
|
||||
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',
|
||||
component: <PiniaDoc />
|
||||
component: <PiniaDoc />,
|
||||
},
|
||||
{
|
||||
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',
|
||||
component: <GraphQLDoc />
|
||||
component: <GraphQLDoc />,
|
||||
},
|
||||
{
|
||||
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',
|
||||
component: <NgRxDoc />
|
||||
component: <NgRxDoc />,
|
||||
},
|
||||
{
|
||||
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',
|
||||
component: <MobxDoc />
|
||||
component: <MobxDoc />,
|
||||
},
|
||||
{
|
||||
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',
|
||||
component: <ProfilerDoc />
|
||||
component: <ProfilerDoc />,
|
||||
},
|
||||
{
|
||||
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',
|
||||
component: <AssistDoc />
|
||||
component: <AssistDoc />,
|
||||
},
|
||||
{
|
||||
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',
|
||||
// header: '🐻',
|
||||
component: <ZustandDoc />
|
||||
}
|
||||
]
|
||||
}
|
||||
component: <ZustandDoc />,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -1,11 +1,15 @@
|
|||
import React from 'react';
|
||||
import ToggleContent from 'Shared/ToggleContent';
|
||||
import DocLink from 'Shared/DocLink/DocLink';
|
||||
import { connect } from 'react-redux';
|
||||
import { CodeBlock } from "UI";
|
||||
import { useStore } from 'App/mstore';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
|
||||
const MobxDoc = (props) => {
|
||||
const { projectKey } = props;
|
||||
const MobxDoc = () => {
|
||||
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';
|
||||
import trackerMobX from '@openreplay/tracker-mobx';
|
||||
|
|
@ -67,10 +71,4 @@ function SomeFunctionalComponent() {
|
|||
|
||||
MobxDoc.displayName = 'MobxDoc';
|
||||
|
||||
export default connect((state) => {
|
||||
const siteId = state.getIn(['integrations', 'siteId']);
|
||||
const sites = state.getIn(['site', 'list']);
|
||||
return {
|
||||
projectKey: sites.find((site) => site.get('id') === siteId).get('projectKey'),
|
||||
};
|
||||
})(MobxDoc);
|
||||
export default observer(MobxDoc)
|
||||
|
|
|
|||
|
|
@ -1,11 +1,15 @@
|
|||
import { useStore } from "App/mstore";
|
||||
import React from 'react';
|
||||
import { CodeBlock } from "UI";
|
||||
import ToggleContent from 'Shared/ToggleContent';
|
||||
import DocLink from 'Shared/DocLink/DocLink';
|
||||
import { connect } from 'react-redux';
|
||||
import { observer } from 'mobx-react-lite'
|
||||
|
||||
const NgRxDoc = (props) => {
|
||||
const { projectKey } = props;
|
||||
const NgRxDoc = () => {
|
||||
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';
|
||||
import { reducers } from './reducers';
|
||||
import OpenReplay from '@openreplay/tracker';
|
||||
|
|
@ -80,10 +84,4 @@ const metaReducers = [tracker.use(trackerNgRx(<options>))]; // check list of ava
|
|||
|
||||
NgRxDoc.displayName = 'NgRxDoc';
|
||||
|
||||
export default connect((state) => {
|
||||
const siteId = state.getIn(['integrations', 'siteId']);
|
||||
const sites = state.getIn(['site', 'list']);
|
||||
return {
|
||||
projectKey: sites.find((site) => site.get('id') === siteId).get('projectKey'),
|
||||
};
|
||||
})(NgRxDoc);
|
||||
export default observer(NgRxDoc);
|
||||
|
|
|
|||
|
|
@ -1,11 +1,19 @@
|
|||
import { observer } from 'mobx-react-lite';
|
||||
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) => {
|
||||
const { projectKey } = props;
|
||||
import { useStore } from 'App/mstore';
|
||||
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'
|
||||
import OpenReplay from '@openreplay/tracker';
|
||||
import trackerVuex from '@openreplay/tracker-vuex';
|
||||
|
|
@ -28,7 +36,7 @@ piniaStorePlugin(examplePiniaStore)
|
|||
// now you can use examplePiniaStore as
|
||||
// usual pinia store
|
||||
// (destructure values or return it as a whole etc)
|
||||
`
|
||||
`;
|
||||
const usageCjs = `import Vuex from 'vuex'
|
||||
import OpenReplay from '@openreplay/tracker/cjs';
|
||||
import trackerVuex from '@openreplay/tracker-vuex/cjs';
|
||||
|
|
@ -55,34 +63,38 @@ piniaStorePlugin(examplePiniaStore)
|
|||
// now you can use examplePiniaStore as
|
||||
// usual pinia store
|
||||
// (destructure values or return it as a whole etc)
|
||||
}`
|
||||
}`;
|
||||
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>
|
||||
<div className="p-5">
|
||||
<div>
|
||||
This plugin allows you to capture Pinia mutations + state and inspect them later on while
|
||||
replaying session recordings. This is very useful for understanding and fixing issues.
|
||||
This plugin allows you to capture Pinia mutations + state and inspect
|
||||
them later on while replaying session recordings. This is very useful
|
||||
for understanding and fixing issues.
|
||||
</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>
|
||||
<p>
|
||||
Initialize the @openreplay/tracker package as usual and load the plugin into it. Then put
|
||||
the generated plugin into your plugins field of your store.
|
||||
Initialize the @openreplay/tracker package as usual and load the
|
||||
plugin into it. Then put the generated plugin into your plugins field
|
||||
of your store.
|
||||
</p>
|
||||
<div className="py-3" />
|
||||
|
||||
<ToggleContent
|
||||
label="Server-Side-Rendered (SSR)?"
|
||||
first={
|
||||
<CodeBlock code={usage} language="js" />
|
||||
}
|
||||
second={
|
||||
<CodeBlock code={usageCjs} language="js" />
|
||||
}
|
||||
first={<CodeBlock code={usage} language="js" />}
|
||||
second={<CodeBlock code={usageCjs} language="js" />}
|
||||
/>
|
||||
|
||||
<DocLink
|
||||
|
|
@ -97,10 +109,4 @@ piniaStorePlugin(examplePiniaStore)
|
|||
|
||||
PiniaDoc.displayName = 'PiniaDoc';
|
||||
|
||||
export default connect((state: any) => {
|
||||
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);
|
||||
export default observer(PiniaDoc);
|
||||
|
|
|
|||
|
|
@ -1,13 +1,16 @@
|
|||
import { useStore } from "App/mstore";
|
||||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { CodeBlock } from 'UI';
|
||||
|
||||
import DocLink from 'Shared/DocLink/DocLink';
|
||||
import ToggleContent from 'Shared/ToggleContent';
|
||||
|
||||
const ProfilerDoc = (props) => {
|
||||
const { projectKey } = props;
|
||||
const ProfilerDoc = () => {
|
||||
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';
|
||||
import trackerProfiler from '@openreplay/tracker-profiler';
|
||||
|
|
@ -87,12 +90,4 @@ const fn = profiler('call_name')(() => {
|
|||
|
||||
ProfilerDoc.displayName = 'ProfilerDoc';
|
||||
|
||||
export default connect((state) => {
|
||||
const siteId = state.getIn(['integrations', 'siteId']);
|
||||
const sites = state.getIn(['site', 'list']);
|
||||
return {
|
||||
projectKey: sites
|
||||
.find((site) => site.get('id') === siteId)
|
||||
.get('projectKey'),
|
||||
};
|
||||
})(ProfilerDoc);
|
||||
export default observer(ProfilerDoc);
|
||||
|
|
|
|||
|
|
@ -1,11 +1,15 @@
|
|||
import { useStore } from "App/mstore";
|
||||
import React from 'react';
|
||||
import { CodeBlock } from 'UI'
|
||||
import ToggleContent from '../../../shared/ToggleContent';
|
||||
import ToggleContent from 'Components/shared/ToggleContent';
|
||||
import DocLink from 'Shared/DocLink/DocLink';
|
||||
import { connect } from 'react-redux';
|
||||
import { observer } from 'mobx-react-lite'
|
||||
|
||||
const ReduxDoc = (props) => {
|
||||
const { projectKey } = props;
|
||||
const ReduxDoc = () => {
|
||||
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';
|
||||
import OpenReplay from '@openreplay/tracker';
|
||||
|
|
@ -74,10 +78,4 @@ const store = createStore(
|
|||
|
||||
ReduxDoc.displayName = 'ReduxDoc';
|
||||
|
||||
export default connect((state) => {
|
||||
const siteId = state.getIn(['integrations', 'siteId']);
|
||||
const sites = state.getIn(['site', 'list']);
|
||||
return {
|
||||
projectKey: sites.find((site) => site.get('id') === siteId).get('projectKey'),
|
||||
};
|
||||
})(ReduxDoc);
|
||||
export default observer(ReduxDoc);
|
||||
|
|
|
|||
|
|
@ -1,25 +1,36 @@
|
|||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { edit, save, init, update } from 'Duck/integrations/slack';
|
||||
import { Form, Input, Button, Message } 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 {
|
||||
componentWillUnmount() {
|
||||
this.props.init({});
|
||||
}
|
||||
function SlackAddForm(props) {
|
||||
const { onClose } = props;
|
||||
const { integrationsStore } = useStore();
|
||||
const instance = integrationsStore.slack.instance;
|
||||
const saving = integrationsStore.slack.loading;
|
||||
const errors = integrationsStore.slack.errors;
|
||||
const edit = integrationsStore.slack.edit;
|
||||
const onSave = integrationsStore.slack.saveIntegration;
|
||||
const update = integrationsStore.slack.update;
|
||||
const init = integrationsStore.slack.init;
|
||||
const onRemove = integrationsStore.slack.removeInt;
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => init({})
|
||||
}, [])
|
||||
|
||||
save = () => {
|
||||
const instance = this.props.instance;
|
||||
|
||||
const save = () => {
|
||||
if (instance.exists()) {
|
||||
this.props.update(this.props.instance);
|
||||
void update(instance);
|
||||
} else {
|
||||
this.props.save(this.props.instance);
|
||||
void onSave(instance);
|
||||
}
|
||||
};
|
||||
|
||||
remove = async (id) => {
|
||||
const remove = async (id) => {
|
||||
if (
|
||||
await confirm({
|
||||
header: 'Confirm',
|
||||
|
|
@ -27,79 +38,68 @@ class SlackAddForm extends React.PureComponent {
|
|||
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 });
|
||||
|
||||
render() {
|
||||
const { instance, saving, errors, onClose } = this.props;
|
||||
return (
|
||||
<div className="p-5" style={{ minWidth: '300px' }}>
|
||||
<Form>
|
||||
<Form.Field>
|
||||
<label>Name</label>
|
||||
<Input
|
||||
name="name"
|
||||
value={instance.name}
|
||||
onChange={this.write}
|
||||
placeholder="Enter any name"
|
||||
type="text"
|
||||
/>
|
||||
</Form.Field>
|
||||
<Form.Field>
|
||||
<label>URL</label>
|
||||
<Input
|
||||
name="endpoint"
|
||||
value={instance.endpoint}
|
||||
onChange={this.write}
|
||||
placeholder="Slack webhook URL"
|
||||
type="text"
|
||||
/>
|
||||
</Form.Field>
|
||||
<div className="flex justify-between">
|
||||
<div className="flex">
|
||||
<Button
|
||||
onClick={this.save}
|
||||
disabled={!instance.validate()}
|
||||
loading={saving}
|
||||
variant="primary"
|
||||
className="float-left mr-2"
|
||||
>
|
||||
{instance.exists() ? 'Update' : 'Add'}
|
||||
</Button>
|
||||
|
||||
<Button onClick={onClose}>{'Cancel'}</Button>
|
||||
</div>
|
||||
|
||||
<Button onClick={() => this.remove(instance.webhookId)} disabled={!instance.exists()}>
|
||||
{'Delete'}
|
||||
const write = ({ target: { name, value } }) => edit({ [name]: value });
|
||||
|
||||
return (
|
||||
<div className="p-5" style={{ minWidth: '300px' }}>
|
||||
<Form>
|
||||
<Form.Field>
|
||||
<label>Name</label>
|
||||
<Input
|
||||
name="name"
|
||||
value={instance.name}
|
||||
onChange={write}
|
||||
placeholder="Enter any name"
|
||||
type="text"
|
||||
/>
|
||||
</Form.Field>
|
||||
<Form.Field>
|
||||
<label>URL</label>
|
||||
<Input
|
||||
name="endpoint"
|
||||
value={instance.endpoint}
|
||||
onChange={write}
|
||||
placeholder="Slack webhook URL"
|
||||
type="text"
|
||||
/>
|
||||
</Form.Field>
|
||||
<div className="flex justify-between">
|
||||
<div className="flex">
|
||||
<Button
|
||||
onClick={save}
|
||||
disabled={!instance.validate()}
|
||||
loading={saving}
|
||||
variant="primary"
|
||||
className="float-left mr-2"
|
||||
>
|
||||
{instance.exists() ? 'Update' : 'Add'}
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
|
||||
{errors && (
|
||||
<div className="my-3">
|
||||
{errors.map((error) => (
|
||||
<Message visible={errors} size="mini" error key={error}>
|
||||
{error}
|
||||
</Message>
|
||||
))}
|
||||
<Button onClick={onClose}>{'Cancel'}</Button>
|
||||
</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(
|
||||
(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);
|
||||
export default observer(SlackAddForm);
|
||||
|
|
|
|||
|
|
@ -1,14 +1,16 @@
|
|||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { NoContent } from 'UI';
|
||||
import { remove, edit, init } from 'Duck/integrations/slack';
|
||||
import DocLink from 'Shared/DocLink/DocLink';
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { useStore } from 'App/mstore'
|
||||
|
||||
function SlackChannelList(props) {
|
||||
const { list } = props;
|
||||
const { integrationsStore } = useStore();
|
||||
const list = integrationsStore.slack.list;
|
||||
const edit = integrationsStore.slack.edit;
|
||||
|
||||
const onEdit = (instance) => {
|
||||
props.edit(instance);
|
||||
edit(instance.toData());
|
||||
props.onEdit();
|
||||
};
|
||||
|
||||
|
|
@ -24,7 +26,7 @@ function SlackChannelList(props) {
|
|||
</div>
|
||||
}
|
||||
size="small"
|
||||
show={list.size === 0}
|
||||
show={list.length === 0}
|
||||
>
|
||||
{list.map((c) => (
|
||||
<div
|
||||
|
|
@ -43,9 +45,4 @@ function SlackChannelList(props) {
|
|||
);
|
||||
}
|
||||
|
||||
export default connect(
|
||||
(state) => ({
|
||||
list: state.getIn(['slack', 'list']),
|
||||
}),
|
||||
{ remove, edit, init }
|
||||
)(SlackChannelList);
|
||||
export default observer(SlackChannelList);
|
||||
|
|
|
|||
|
|
@ -1,17 +1,14 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import SlackChannelList from './SlackChannelList/SlackChannelList';
|
||||
import { fetchList, init } from 'Duck/integrations/slack';
|
||||
import { connect } from 'react-redux';
|
||||
import SlackAddForm from './SlackAddForm';
|
||||
import { Button } from 'UI';
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { useStore } from 'App/mstore'
|
||||
|
||||
interface Props {
|
||||
onEdit?: (integration: any) => void;
|
||||
istance: any;
|
||||
fetchList: any;
|
||||
init: any;
|
||||
}
|
||||
const SlackForm = (props: Props) => {
|
||||
const SlackForm = () => {
|
||||
const { integrationsStore } = useStore();
|
||||
const init = integrationsStore.slack.init;
|
||||
const fetchList = integrationsStore.slack.fetchIntegrations;
|
||||
const [active, setActive] = React.useState(false);
|
||||
|
||||
const onEdit = () => {
|
||||
|
|
@ -20,11 +17,11 @@ const SlackForm = (props: Props) => {
|
|||
|
||||
const onNew = () => {
|
||||
setActive(true);
|
||||
props.init({});
|
||||
init({});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
props.fetchList();
|
||||
void fetchList();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
|
|
@ -47,9 +44,4 @@ const SlackForm = (props: Props) => {
|
|||
|
||||
SlackForm.displayName = 'SlackForm';
|
||||
|
||||
export default connect(
|
||||
(state: any) => ({
|
||||
istance: state.getIn(['slack', 'instance']),
|
||||
}),
|
||||
{ fetchList, init }
|
||||
)(SlackForm);
|
||||
export default observer(SlackForm);
|
||||
|
|
@ -1,36 +1,38 @@
|
|||
import { observer } from 'mobx-react-lite';
|
||||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { edit, save, init, update, remove } from 'Duck/integrations/teams';
|
||||
import { Form, Input, Button, Message } from 'UI';
|
||||
|
||||
import { useStore } from 'App/mstore';
|
||||
import { Button, Form, Input, Message } from 'UI';
|
||||
import { confirm } from 'UI';
|
||||
|
||||
interface Props {
|
||||
edit: (inst: any) => void;
|
||||
save: (inst: any) => void;
|
||||
init: (inst: any) => void;
|
||||
update: (inst: any) => void;
|
||||
remove: (id: string) => void;
|
||||
onClose: () => void;
|
||||
instance: any;
|
||||
saving: boolean;
|
||||
errors: any;
|
||||
}
|
||||
|
||||
class TeamsAddForm extends React.PureComponent<Props> {
|
||||
componentWillUnmount() {
|
||||
this.props.init({});
|
||||
}
|
||||
function TeamsAddForm({ onClose }: Props) {
|
||||
const { integrationsStore } = useStore();
|
||||
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 = () => {
|
||||
const instance = this.props.instance;
|
||||
if (instance.exists()) {
|
||||
this.props.update(this.props.instance);
|
||||
React.useEffect(() => {
|
||||
return () => init({});
|
||||
}, []);
|
||||
|
||||
const save = () => {
|
||||
if (instance?.exists()) {
|
||||
void update();
|
||||
} else {
|
||||
this.props.save(this.props.instance);
|
||||
void onSave();
|
||||
}
|
||||
};
|
||||
|
||||
remove = async (id: string) => {
|
||||
const remove = async (id: string) => {
|
||||
if (
|
||||
await confirm({
|
||||
header: 'Confirm',
|
||||
|
|
@ -38,80 +40,74 @@ class TeamsAddForm extends React.PureComponent<Props> {
|
|||
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 } }) =>
|
||||
this.props.edit({ [name]: value });
|
||||
const write = ({
|
||||
target: { name, value },
|
||||
}: {
|
||||
target: { name: string; value: string };
|
||||
}) => edit({ [name]: value });
|
||||
|
||||
render() {
|
||||
const { instance, saving, errors, onClose } = this.props;
|
||||
return (
|
||||
<div className="p-5" style={{ minWidth: '300px' }}>
|
||||
<Form>
|
||||
<Form.Field>
|
||||
<label>Name</label>
|
||||
<Input
|
||||
name="name"
|
||||
value={instance.name}
|
||||
onChange={this.write}
|
||||
placeholder="Enter any name"
|
||||
type="text"
|
||||
/>
|
||||
</Form.Field>
|
||||
<Form.Field>
|
||||
<label>URL</label>
|
||||
<Input
|
||||
name="endpoint"
|
||||
value={instance.endpoint}
|
||||
onChange={this.write}
|
||||
placeholder="Teams webhook URL"
|
||||
type="text"
|
||||
/>
|
||||
</Form.Field>
|
||||
<div className="flex justify-between">
|
||||
<div className="flex">
|
||||
<Button
|
||||
onClick={this.save}
|
||||
disabled={!instance.validate()}
|
||||
loading={saving}
|
||||
variant="primary"
|
||||
className="float-left mr-2"
|
||||
>
|
||||
{instance.exists() ? 'Update' : 'Add'}
|
||||
</Button>
|
||||
|
||||
<Button onClick={onClose}>{'Cancel'}</Button>
|
||||
</div>
|
||||
|
||||
<Button onClick={() => this.remove(instance.webhookId)} disabled={!instance.exists()}>
|
||||
{'Delete'}
|
||||
return (
|
||||
<div className="p-5" style={{ minWidth: '300px' }}>
|
||||
<Form>
|
||||
<Form.Field>
|
||||
<label>Name</label>
|
||||
<Input
|
||||
name="name"
|
||||
value={instance?.name}
|
||||
onChange={write}
|
||||
placeholder="Enter any name"
|
||||
type="text"
|
||||
/>
|
||||
</Form.Field>
|
||||
<Form.Field>
|
||||
<label>URL</label>
|
||||
<Input
|
||||
name="endpoint"
|
||||
value={instance?.endpoint}
|
||||
onChange={write}
|
||||
placeholder="Teams webhook URL"
|
||||
type="text"
|
||||
/>
|
||||
</Form.Field>
|
||||
<div className="flex justify-between">
|
||||
<div className="flex">
|
||||
<Button
|
||||
onClick={save}
|
||||
disabled={!instance?.validate()}
|
||||
loading={saving}
|
||||
variant="primary"
|
||||
className="float-left mr-2"
|
||||
>
|
||||
{instance?.exists() ? 'Update' : 'Add'}
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
|
||||
{errors && (
|
||||
<div className="my-3">
|
||||
{errors.map((error: any) => (
|
||||
<Message visible={errors} key={error}>
|
||||
{error}
|
||||
</Message>
|
||||
))}
|
||||
<Button onClick={onClose}>{'Cancel'}</Button>
|
||||
</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(
|
||||
(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);
|
||||
export default observer(TeamsAddForm);
|
||||
|
|
|
|||
|
|
@ -1,51 +1,57 @@
|
|||
import { observer } from 'mobx-react-lite';
|
||||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { useStore } from 'App/mstore';
|
||||
import { NoContent } from 'UI';
|
||||
import { remove, edit, init } from 'Duck/integrations/teams';
|
||||
|
||||
import DocLink from 'Shared/DocLink/DocLink';
|
||||
|
||||
function TeamsChannelList(props: { list: any, edit: (inst: any) => any, onEdit: () => void }) {
|
||||
const { list } = props;
|
||||
function TeamsChannelList(props: { onEdit: () => void }) {
|
||||
const { integrationsStore } = useStore();
|
||||
const list = integrationsStore.msteams.list;
|
||||
const edit = integrationsStore.msteams.edit;
|
||||
|
||||
const onEdit = (instance: Record<string, any>) => {
|
||||
props.edit(instance);
|
||||
props.onEdit();
|
||||
};
|
||||
const onEdit = (instance: Record<string, any>) => {
|
||||
edit(instance);
|
||||
props.onEdit();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-6">
|
||||
<NoContent
|
||||
title={
|
||||
<div className="p-5 mb-4">
|
||||
<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.
|
||||
</div>
|
||||
<DocLink className="mt-4 text-base" label="Integrate MS Teams" url="https://docs.openreplay.com/integrations/msteams" />
|
||||
</div>
|
||||
}
|
||||
size="small"
|
||||
show={list.size === 0}
|
||||
>
|
||||
{list.map((c: any) => (
|
||||
<div
|
||||
key={c.webhookId}
|
||||
className="border-t px-5 py-2 flex items-center justify-between cursor-pointer hover:bg-active-blue"
|
||||
onClick={() => onEdit(c)}
|
||||
>
|
||||
<div className="flex-grow-0" style={{ maxWidth: '90%' }}>
|
||||
<div>{c.name}</div>
|
||||
<div className="truncate test-xs color-gray-medium">{c.endpoint}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</NoContent>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="mt-6">
|
||||
<NoContent
|
||||
title={
|
||||
<div className="p-5 mb-4">
|
||||
<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.
|
||||
</div>
|
||||
<DocLink
|
||||
className="mt-4 text-base"
|
||||
label="Integrate MS Teams"
|
||||
url="https://docs.openreplay.com/integrations/msteams"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
size="small"
|
||||
show={list.length === 0}
|
||||
>
|
||||
{list.map((c: any) => (
|
||||
<div
|
||||
key={c.webhookId}
|
||||
className="border-t px-5 py-2 flex items-center justify-between cursor-pointer hover:bg-active-blue"
|
||||
onClick={() => onEdit(c)}
|
||||
>
|
||||
<div className="flex-grow-0" style={{ maxWidth: '90%' }}>
|
||||
<div>{c.name}</div>
|
||||
<div className="truncate test-xs color-gray-medium">
|
||||
{c.endpoint}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</NoContent>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default connect(
|
||||
(state: any) => ({
|
||||
list: state.getIn(['teams', 'list']),
|
||||
}),
|
||||
{ remove, edit, init }
|
||||
)(TeamsChannelList);
|
||||
export default observer(TeamsChannelList);
|
||||
|
|
|
|||
|
|
@ -1,17 +1,15 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import TeamsChannelList from './TeamsChannelList';
|
||||
import { fetchList, init } from 'Duck/integrations/teams';
|
||||
import { connect } from 'react-redux';
|
||||
import { useStore } from 'App/mstore';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
|
||||
import TeamsAddForm from './TeamsAddForm';
|
||||
import { Button } from 'UI';
|
||||
|
||||
interface Props {
|
||||
onEdit?: (integration: any) => void;
|
||||
istance: any;
|
||||
fetchList: any;
|
||||
init: any;
|
||||
}
|
||||
const MSTeams = (props: Props) => {
|
||||
const MSTeams = () => {
|
||||
const { integrationsStore } = useStore();
|
||||
const fetchList = integrationsStore.msteams.fetchIntegrations;
|
||||
const init = integrationsStore.msteams.init;
|
||||
const [active, setActive] = React.useState(false);
|
||||
|
||||
const onEdit = () => {
|
||||
|
|
@ -20,11 +18,11 @@ const MSTeams = (props: Props) => {
|
|||
|
||||
const onNew = () => {
|
||||
setActive(true);
|
||||
props.init({});
|
||||
init({});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
props.fetchList();
|
||||
void fetchList();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
|
|
@ -47,9 +45,4 @@ const MSTeams = (props: Props) => {
|
|||
|
||||
MSTeams.displayName = 'MSTeams';
|
||||
|
||||
export default connect(
|
||||
(state: any) => ({
|
||||
istance: state.getIn(['teams', 'instance']),
|
||||
}),
|
||||
{ fetchList, init }
|
||||
)(MSTeams);
|
||||
export default observer(MSTeams);
|
||||
|
|
|
|||
|
|
@ -1,11 +1,15 @@
|
|||
import { useStore } from "App/mstore";
|
||||
import React from 'react';
|
||||
import { CodeBlock } from "UI";
|
||||
import ToggleContent from '../../../shared/ToggleContent';
|
||||
import ToggleContent from 'Components/shared/ToggleContent';
|
||||
import DocLink from 'Shared/DocLink/DocLink';
|
||||
import { connect } from 'react-redux';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
|
||||
const VueDoc = (props) => {
|
||||
const { projectKey, siteId } = props;
|
||||
const VueDoc = () => {
|
||||
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'
|
||||
import OpenReplay from '@openreplay/tracker';
|
||||
|
|
@ -81,10 +85,4 @@ const store = new Vuex.Store({
|
|||
|
||||
VueDoc.displayName = 'VueDoc';
|
||||
|
||||
export default connect((state) => {
|
||||
const siteId = state.getIn(['integrations', 'siteId']);
|
||||
const sites = state.getIn(['site', 'list']);
|
||||
return {
|
||||
projectKey: sites.find((site) => site.get('id') === siteId).get('projectKey'),
|
||||
};
|
||||
})(VueDoc);
|
||||
export default observer(VueDoc);
|
||||
|
|
|
|||
|
|
@ -1,11 +1,15 @@
|
|||
import { useStore } from "App/mstore";
|
||||
import React from 'react';
|
||||
import { CodeBlock } from "UI";
|
||||
import ToggleContent from '../../../shared/ToggleContent';
|
||||
import ToggleContent from 'Components//shared/ToggleContent';
|
||||
import DocLink from 'Shared/DocLink/DocLink';
|
||||
import { connect } from 'react-redux';
|
||||
import { observer } from 'mobx-react-lite'
|
||||
|
||||
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";
|
||||
import Tracker from '@openreplay/tracker';
|
||||
|
|
@ -97,10 +101,4 @@ const useBearStore = create(
|
|||
|
||||
ZustandDoc.displayName = 'ZustandDoc';
|
||||
|
||||
export default connect((state) => {
|
||||
const siteId = state.getIn(['integrations', 'siteId']);
|
||||
const sites = state.getIn(['site', 'list']);
|
||||
return {
|
||||
projectKey: sites.find((site) => site.get('id') === siteId).get('projectKey'),
|
||||
};
|
||||
})(ZustandDoc);
|
||||
export default observer(ZustandDoc);
|
||||
|
|
|
|||
|
|
@ -4,12 +4,12 @@ import { Loader, NoContent, Button, Tooltip } from 'UI';
|
|||
import { connect } from 'react-redux';
|
||||
import stl from './roles.module.css';
|
||||
import RoleForm from './components/RoleForm';
|
||||
import { init, edit, fetchList, remove as deleteRole, resetErrors } from 'Duck/roles';
|
||||
import RoleItem from './components/RoleItem';
|
||||
import { confirm } from 'UI';
|
||||
import { toast } from 'react-toastify';
|
||||
import withPageTitle from 'HOCs/withPageTitle';
|
||||
import { useModal } from 'App/components/Modal';
|
||||
import { useStore } from "App/mstore";
|
||||
import { observer } from 'mobx-react-lite';
|
||||
|
||||
interface Props {
|
||||
loading: boolean;
|
||||
|
|
@ -23,29 +23,30 @@ interface Props {
|
|||
permissionsMap: any;
|
||||
removeErrors: any;
|
||||
resetErrors: () => void;
|
||||
projectsMap: any;
|
||||
}
|
||||
|
||||
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 isAdmin = account.admin || account.superAdmin;
|
||||
|
||||
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) => {
|
||||
init(role);
|
||||
showModal(<RoleForm closeModal={hideModal} permissionsMap={permissionsMap} deleteHandler={deleteHandler} />, { right: true });
|
||||
|
|
@ -110,24 +111,8 @@ function Roles(props: Props) {
|
|||
|
||||
export default connect(
|
||||
(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 {
|
||||
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']),
|
||||
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')(Roles));
|
||||
}
|
||||
)(withPageTitle('Roles & Access - OpenReplay Preferences')(observer(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 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 stl from './roleForm.module.css';
|
||||
|
||||
|
||||
interface Permission {
|
||||
name: string;
|
||||
value: string;
|
||||
name: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
role: any;
|
||||
edit: (role: any) => void;
|
||||
save: (role: any) => Promise<void>;
|
||||
closeModal: (toastMessage?: string) => void;
|
||||
saving: boolean;
|
||||
permissions: Array<Permission>[];
|
||||
projectOptions: Array<any>[];
|
||||
permissionsMap: any;
|
||||
projectsMap: any;
|
||||
deleteHandler: (id: any) => Promise<void>;
|
||||
closeModal: (toastMessage?: string) => void;
|
||||
permissionsMap: any;
|
||||
deleteHandler: (id: any) => Promise<void>;
|
||||
}
|
||||
|
||||
const RoleForm = (props: Props) => {
|
||||
const { role, edit, save, closeModal, saving, permissions, projectOptions, permissionsMap, projectsMap } = props;
|
||||
let focusElement = useRef<any>(null);
|
||||
const _save = () => {
|
||||
save(role).then(() => {
|
||||
closeModal(role.exists() ? 'Role updated' : 'Role created');
|
||||
});
|
||||
};
|
||||
const { roleStore, projectsStore } = useStore();
|
||||
const projects = projectsStore.list;
|
||||
const role = roleStore.instance;
|
||||
const saving = roleStore.loading;
|
||||
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 { permissions } = role;
|
||||
const index = permissions.indexOf(e);
|
||||
const _perms = permissions.contains(e) ? permissions.remove(index) : permissions.push(e);
|
||||
edit({ permissions: _perms });
|
||||
};
|
||||
const write = ({ target: { value, name } }: any) => roleStore.editRole({ [name]: value });
|
||||
|
||||
const onChangeProjects = (e: any) => {
|
||||
const { projects } = role;
|
||||
const index = projects.indexOf(e);
|
||||
const _projects = index === -1 ? projects.push(e) : projects.remove(index);
|
||||
edit({ projects: _projects });
|
||||
};
|
||||
const onChangePermissions = (e: any) => {
|
||||
const { permissions } = role;
|
||||
const index = permissions.indexOf(e);
|
||||
let _perms;
|
||||
if (permissions.includes(e)) {
|
||||
permissions.splice(index, 1);
|
||||
_perms = permissions;
|
||||
} else {
|
||||
_perms = permissions.concat(e);
|
||||
}
|
||||
roleStore.editRole({ permissions: _perms });
|
||||
};
|
||||
|
||||
const writeOption = ({ name, value }: any) => {
|
||||
if (name === 'permissions') {
|
||||
onChangePermissions(value);
|
||||
} else if (name === 'projects') {
|
||||
onChangeProjects(value);
|
||||
}
|
||||
};
|
||||
const onChangeProjects = (e: any) => {
|
||||
const { projects } = role;
|
||||
const index = projects.indexOf(e);
|
||||
let _projects;
|
||||
if (index === -1) {
|
||||
_projects = projects.concat(e)
|
||||
} else {
|
||||
projects.splice(index, 1)
|
||||
_projects = projects
|
||||
}
|
||||
roleStore.editRole({ projects: _projects });
|
||||
};
|
||||
|
||||
const toggleAllProjects = () => {
|
||||
const { allProjects } = role;
|
||||
edit({ allProjects: !allProjects });
|
||||
};
|
||||
const writeOption = ({ name, value }: any) => {
|
||||
if (name === 'permissions') {
|
||||
onChangePermissions(value);
|
||||
} else if (name === 'projects') {
|
||||
onChangeProjects(value);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
focusElement && focusElement.current && focusElement.current.focus();
|
||||
}, []);
|
||||
const toggleAllProjects = () => {
|
||||
const { allProjects } = role;
|
||||
roleStore.editRole({ allProjects: !allProjects });
|
||||
};
|
||||
|
||||
return (
|
||||
<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>
|
||||
useEffect(() => {
|
||||
focusElement && focusElement.current && focusElement.current.focus();
|
||||
}, []);
|
||||
|
||||
<Form.Field>
|
||||
<label>{'Project Access'}</label>
|
||||
return (
|
||||
<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">
|
||||
<Checkbox
|
||||
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>
|
||||
<label>{'Project Access'}</label>
|
||||
|
||||
<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.size > 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 className="flex my-3">
|
||||
<Checkbox
|
||||
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>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
export default connect(
|
||||
(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);
|
||||
export default observer(RoleForm);
|
||||
|
||||
function OptionLabel(nameMap: any, p: any, onChangeOption: (e: any) => void) {
|
||||
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>{nameMap[p]}</div>
|
||||
<div className="cursor-pointer ml-2" onClick={() => onChangeOption(p)}>
|
||||
<Icon name="close" size="12" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
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>{nameMap[p]}</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 { Tooltip, Button } from 'UI';
|
||||
import { useStore } from 'App/mstore';
|
||||
import { useObserver } from 'mobx-react-lite';
|
||||
import { init, remove, fetchGDPR } from 'Duck/site';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { connect } from 'react-redux';
|
||||
import { useModal } from 'App/components/Modal';
|
||||
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 LIMIT_WARNING = 'You have reached site limit.';
|
||||
|
||||
function AddProjectButton({ isAdmin = false, init = () => {} }: any) {
|
||||
const { userStore } = useStore();
|
||||
function AddProjectButton({ isAdmin = false }: any) {
|
||||
const { userStore, projectsStore } = useStore();
|
||||
const init = projectsStore.initProject;
|
||||
const { showModal, hideModal } = useModal();
|
||||
const limtis = useObserver(() => userStore.limits);
|
||||
const canAddProject = useObserver(
|
||||
() => isAdmin && (limtis.projects === -1 || limtis.projects > 0)
|
||||
);
|
||||
const limits = userStore.limits;
|
||||
const canAddProject = isAdmin && (limits.projects === -1 || limits.projects > 0)
|
||||
|
||||
const onClick = () => {
|
||||
init();
|
||||
init({});
|
||||
showModal(<NewSiteForm onClose={hideModal} />, { right: true });
|
||||
};
|
||||
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 { connect } from 'react-redux';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { useStore } from "App/mstore";
|
||||
import { Form, Button, Input, Icon } from 'UI';
|
||||
import { editGDPR, saveGDPR } from 'Duck/site';
|
||||
import { validateNumber } from 'App/validate';
|
||||
import styles from './siteForm.module.css';
|
||||
import Select from 'Shared/Select';
|
||||
|
|
@ -12,124 +12,118 @@ const inputModeOptions = [
|
|||
{ label: 'Obscure all inputs', value: 'hidden' },
|
||||
];
|
||||
|
||||
@connect(state => ({
|
||||
site: state.getIn([ 'site', 'instance' ]),
|
||||
gdpr: state.getIn([ 'site', 'instance', 'gdpr' ]),
|
||||
saving: state.getIn([ 'site', 'saveGDPR', 'loading' ]),
|
||||
}), {
|
||||
editGDPR,
|
||||
saveGDPR,
|
||||
})
|
||||
export default class GDPRForm extends React.PureComponent {
|
||||
onChange = ({ target: { name, value } }) => {
|
||||
function GDPRForm(props) {
|
||||
const { projectsStore } = useStore();
|
||||
const site = projectsStore.instance;
|
||||
const gdpr = site.gdpr;
|
||||
const saving = false //projectsStore.;
|
||||
const editGDPR = projectsStore.editGDPR;
|
||||
const saveGDPR = projectsStore.saveGDPR;
|
||||
|
||||
|
||||
const onChange = ({ target: { name, value } }) => {
|
||||
if (name === "sampleRate") {
|
||||
if (!validateNumber(value, { min: 0, max: 100 })) return;
|
||||
if (value.length > 1 && value[0] === "0") {
|
||||
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 === ''){
|
||||
this.props.editGDPR({ sampleRate: 100 });
|
||||
editGDPR({ sampleRate: 100 });
|
||||
}
|
||||
}
|
||||
|
||||
onChangeSelect = ({ name, value }) => {
|
||||
this.props.editGDPR({ [ name ]: value });
|
||||
const onChangeSelect = ({ name, value }) => {
|
||||
props.editGDPR({ [ name ]: value });
|
||||
};
|
||||
|
||||
onChangeOption = ({ target: { checked, name } }) => {
|
||||
this.props.editGDPR({ [ name ]: checked });
|
||||
const onChangeOption = ({ target: { checked, name } }) => {
|
||||
editGDPR({ [ name ]: checked });
|
||||
}
|
||||
|
||||
onSubmit = (e) => {
|
||||
const onSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
const { site, gdpr } = this.props;
|
||||
this.props.saveGDPR(site.id, gdpr);
|
||||
void saveGDPR(site.id);
|
||||
}
|
||||
|
||||
return (
|
||||
<Form className={ styles.formWrapper } onSubmit={ onSubmit }>
|
||||
<div className={ styles.content }>
|
||||
<Form.Field>
|
||||
<label>{ 'Name' }</label>
|
||||
<div>{ site.host }</div>
|
||||
</Form.Field>
|
||||
<Form.Field>
|
||||
<label>{ 'Session Capture Rate' }</label>
|
||||
<Input
|
||||
icon="percent"
|
||||
name="sampleRate"
|
||||
value={ gdpr.sampleRate }
|
||||
onChange={ onChange }
|
||||
onBlur={ onSampleRateBlur }
|
||||
className={ styles.sampleRate }
|
||||
/>
|
||||
</Form.Field>
|
||||
|
||||
render() {
|
||||
const {
|
||||
site, onClose, saving, gdpr,
|
||||
} = this.props;
|
||||
<Form.Field>
|
||||
<label htmlFor="defaultInputMode">{ 'Data Recording Options' }</label>
|
||||
<Select
|
||||
name="defaultInputMode"
|
||||
options={ inputModeOptions }
|
||||
onChange={ onChangeSelect }
|
||||
placeholder="Default Input Mode"
|
||||
value={ gdpr.defaultInputMode }
|
||||
/>
|
||||
</Form.Field>
|
||||
|
||||
return (
|
||||
<Form className={ styles.formWrapper } onSubmit={ this.onSubmit }>
|
||||
<div className={ styles.content }>
|
||||
<Form.Field>
|
||||
<label>{ 'Name' }</label>
|
||||
<div>{ site.host }</div>
|
||||
</Form.Field>
|
||||
<Form.Field>
|
||||
<label>{ 'Session Capture Rate' }</label>
|
||||
<Input
|
||||
icon="percent"
|
||||
name="sampleRate"
|
||||
value={ gdpr.sampleRate }
|
||||
onChange={ this.onChange }
|
||||
onBlur={ this.onSampleRateBlur }
|
||||
className={ styles.sampleRate }
|
||||
<Form.Field>
|
||||
<label>
|
||||
<input
|
||||
name="maskNumbers"
|
||||
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>
|
||||
<label htmlFor="defaultInputMode">{ 'Data Recording Options' }</label>
|
||||
<Select
|
||||
name="defaultInputMode"
|
||||
options={ inputModeOptions }
|
||||
onChange={ this.onChangeSelect }
|
||||
placeholder="Default Input Mode"
|
||||
value={ gdpr.defaultInputMode }
|
||||
// className={ styles.dropdown }
|
||||
<Form.Field>
|
||||
<label>
|
||||
<input
|
||||
name="maskEmails"
|
||||
type="checkbox"
|
||||
checked={ gdpr.maskEmails }
|
||||
onChange={ onChangeOption }
|
||||
/>
|
||||
</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>
|
||||
<label>
|
||||
<input
|
||||
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 className={ styles.blockIpWarapper }>
|
||||
<div className={ styles.button } onClick={ props.toggleBlockedIp }>
|
||||
{ 'Block IP' } <Icon name="next1" size="18" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={ styles.footer }>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="float-left mr-2"
|
||||
loading={ saving }
|
||||
content="Update"
|
||||
/>
|
||||
<Button onClick={ onClose } content="Cancel" />
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
<div className={ styles.footer }>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="float-left mr-2"
|
||||
loading={ saving }
|
||||
content="Update"
|
||||
/>
|
||||
<Button onClick={ onClose } content="Cancel" />
|
||||
</div>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
|
||||
export default observer(GDPRForm);
|
||||
|
|
@ -3,22 +3,17 @@ import React, { ChangeEvent, FormEvent, useEffect, useState } from 'react';
|
|||
import { ConnectedProps, connect } from 'react-redux';
|
||||
import { RouteComponentProps, withRouter } from 'react-router-dom';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
import { withStore } from 'App/mstore';
|
||||
import { useStore } from 'App/mstore';
|
||||
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 { Button, Form, Icon, Input, SegmentSelection } from 'UI';
|
||||
import { confirm } from 'UI';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
|
||||
import styles from './siteForm.module.css';
|
||||
|
||||
type OwnProps = {
|
||||
onClose: (arg: any) => void;
|
||||
mstore: any;
|
||||
canDelete: boolean;
|
||||
};
|
||||
|
||||
type PropsFromRedux = ConnectedProps<typeof connector>;
|
||||
|
|
@ -26,36 +21,34 @@ type PropsFromRedux = ConnectedProps<typeof connector>;
|
|||
type Props = PropsFromRedux & RouteComponentProps & OwnProps;
|
||||
|
||||
const NewSiteForm = ({
|
||||
site,
|
||||
loading,
|
||||
save,
|
||||
remove,
|
||||
edit,
|
||||
update,
|
||||
pushNewSite,
|
||||
fetchList,
|
||||
setSiteId,
|
||||
clearSearch,
|
||||
clearSearchLive,
|
||||
location: { pathname },
|
||||
onClose,
|
||||
mstore,
|
||||
activeSiteId,
|
||||
canDelete,
|
||||
}: 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 { searchStore } = useStore();
|
||||
|
||||
useEffect(() => {
|
||||
if (pathname.includes('onboarding')) {
|
||||
if (pathname.includes('onboarding') && site?.id) {
|
||||
setSiteId(site.id);
|
||||
}
|
||||
if (!site) projectsStore.initProject({});
|
||||
}, []);
|
||||
|
||||
const onSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (site.exists()) {
|
||||
update(site, site.id).then((response: any) => {
|
||||
if (site?.id && site.exists()) {
|
||||
projectsStore.updateProject( site.id, site.toData()).then((response: any) => {
|
||||
if (!response || !response.errors || response.errors.size === 0) {
|
||||
onClose(null);
|
||||
if (!pathname.includes('onboarding')) {
|
||||
|
|
@ -67,10 +60,10 @@ const NewSiteForm = ({
|
|||
}
|
||||
});
|
||||
} else {
|
||||
save(site).then((response: any) => {
|
||||
saveProject(site!).then((response: any) => {
|
||||
if (!response || !response.errors || response.errors.size === 0) {
|
||||
onClose(null);
|
||||
clearSearch();
|
||||
searchStore.clearSearch();
|
||||
clearSearchLive();
|
||||
mstore.initClient();
|
||||
toast.success('Project added successfully');
|
||||
|
|
@ -89,8 +82,9 @@ const NewSiteForm = ({
|
|||
confirmButton: 'Yes, delete',
|
||||
cancelButton: 'Cancel',
|
||||
})
|
||||
&& site?.id
|
||||
) {
|
||||
remove(site.id).then(() => {
|
||||
projectsStore.removeProject(site.id).then(() => {
|
||||
onClose(null);
|
||||
if (site.id === activeSiteId) {
|
||||
setSiteId(null);
|
||||
|
|
@ -103,9 +97,12 @@ const NewSiteForm = ({
|
|||
target: { name, value },
|
||||
}: ChangeEvent<HTMLInputElement>) => {
|
||||
setExistsError(false);
|
||||
edit({ [name]: value });
|
||||
projectsStore.editInstance({ [name]: value });
|
||||
};
|
||||
|
||||
if (!site) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className="bg-white h-screen overflow-y-auto"
|
||||
|
|
@ -116,7 +113,7 @@ const NewSiteForm = ({
|
|||
</h3>
|
||||
<Form
|
||||
className={styles.formWrapper}
|
||||
onSubmit={site.validate() && onSubmit}
|
||||
onSubmit={site.validate && onSubmit}
|
||||
>
|
||||
<div className={styles.content}>
|
||||
<Form.Field>
|
||||
|
|
@ -146,7 +143,7 @@ const NewSiteForm = ({
|
|||
]}
|
||||
value={site.platform}
|
||||
onChange={(value) => {
|
||||
edit({ platform: value });
|
||||
projectsStore.editInstance({ platform: value });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -157,9 +154,9 @@ const NewSiteForm = ({
|
|||
type="submit"
|
||||
className="float-left mr-2"
|
||||
loading={loading}
|
||||
disabled={!site.validate()}
|
||||
disabled={!site.validate}
|
||||
>
|
||||
{site.exists() ? 'Update' : 'Add'}
|
||||
{site?.exists() ? 'Update' : 'Add'}
|
||||
</Button>
|
||||
{site.exists() && (
|
||||
<Button
|
||||
|
|
@ -183,26 +180,9 @@ const NewSiteForm = ({
|
|||
);
|
||||
};
|
||||
|
||||
const mapStateToProps = (state: any) => ({
|
||||
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 mapStateToProps = null;
|
||||
const connector = connect(mapStateToProps, {
|
||||
save,
|
||||
remove,
|
||||
edit,
|
||||
update,
|
||||
pushNewSite,
|
||||
fetchList,
|
||||
setSiteId,
|
||||
clearSearch,
|
||||
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 cn from 'classnames';
|
||||
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 stl from './sites.module.css';
|
||||
import NewSiteForm from './NewSiteForm';
|
||||
|
|
@ -16,9 +15,11 @@ import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG';
|
|||
import { useModal } from 'App/components/Modal';
|
||||
import CaptureRate from 'Shared/SessionSettings/components/CaptureRate';
|
||||
import { BranchesOutlined } from '@ant-design/icons';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { useStore } from 'App/mstore'
|
||||
|
||||
type Project = {
|
||||
id: number;
|
||||
id: string;
|
||||
name: string;
|
||||
conditionsCount: number;
|
||||
platform: 'web' | 'mobile';
|
||||
|
|
@ -29,7 +30,11 @@ type Project = {
|
|||
|
||||
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 [showCaptureRate, setShowCaptureRate] = useState(true);
|
||||
const [activeProject, setActiveProject] = useState<Project | null>(null);
|
||||
|
|
@ -140,7 +145,7 @@ const Sites = ({ loading, sites, user, init }: PropsFromRedux) => {
|
|||
</div>
|
||||
}
|
||||
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="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">
|
||||
<Pagination
|
||||
page={page}
|
||||
total={filteredSites.size}
|
||||
total={filteredSites.length}
|
||||
onPageChange={(page) => updatePage(page)}
|
||||
limit={pageSize}
|
||||
/>
|
||||
|
|
@ -181,18 +186,10 @@ const Sites = ({ loading, sites, user, init }: PropsFromRedux) => {
|
|||
};
|
||||
|
||||
const mapStateToProps = (state: any) => ({
|
||||
site: state.getIn(['site', 'instance']),
|
||||
sites: state.getIn(['site', 'list']),
|
||||
loading: state.getIn(['site', 'loading']),
|
||||
user: state.getIn(['user', 'account']),
|
||||
account: state.getIn(['user', 'account']),
|
||||
});
|
||||
|
||||
const connector = connect(mapStateToProps, {
|
||||
init,
|
||||
remove,
|
||||
fetchGDPR,
|
||||
setSiteId,
|
||||
});
|
||||
const connector = connect(mapStateToProps, null);
|
||||
|
||||
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']),
|
||||
visitedEvents: state.getIn(['sessions', 'visitedEvents']),
|
||||
insights: state.getIn(['sessions', 'insights']),
|
||||
host: state.getIn(['sessions', 'host']),
|
||||
}),
|
||||
{ fetchInsights, }
|
||||
)
|
||||
|
|
|
|||
|
|
@ -24,11 +24,12 @@ import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG';
|
|||
|
||||
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 [showEditModal, setShowEditModal] = React.useState(false);
|
||||
|
||||
const { dashboardStore } = useStore();
|
||||
const list = dashboardStore.filteredList;
|
||||
const dashboardsSearch = dashboardStore.filter.query;
|
||||
const history = useHistory();
|
||||
|
|
@ -219,6 +220,4 @@ function DashboardList({ siteId }: { siteId: string }) {
|
|||
);
|
||||
}
|
||||
|
||||
export default connect((state: any) => ({
|
||||
siteId: state.getIn(['site', 'siteId']),
|
||||
}))(observer(DashboardList));
|
||||
export default observer(DashboardList);
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@ import { Modal } from 'antd';
|
|||
import React, { useEffect } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
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 SelectCard from './SelectCard';
|
||||
|
|
@ -12,7 +13,6 @@ interface NewDashboardModalProps {
|
|||
open: boolean;
|
||||
isAddingFromLibrary?: boolean;
|
||||
isEnterprise?: boolean;
|
||||
isMobile?: boolean;
|
||||
}
|
||||
|
||||
const NewDashboardModal: React.FC<NewDashboardModalProps> = ({
|
||||
|
|
@ -20,8 +20,9 @@ const NewDashboardModal: React.FC<NewDashboardModalProps> = ({
|
|||
open,
|
||||
isAddingFromLibrary = false,
|
||||
isEnterprise = false,
|
||||
isMobile = false,
|
||||
}) => {
|
||||
const { projectsStore } = useStore();
|
||||
const isMobile = projectsStore.isMobile;
|
||||
const [step, setStep] = React.useState<number>(0);
|
||||
const [selectedCategory, setSelectedCategory] =
|
||||
React.useState<string>('product-analytics');
|
||||
|
|
@ -75,10 +76,9 @@ const NewDashboardModal: React.FC<NewDashboardModalProps> = ({
|
|||
};
|
||||
|
||||
const mapStateToProps = (state: any) => ({
|
||||
isMobile: state.getIn(['site', 'instance', 'platform']) === 'ios',
|
||||
isEnterprise:
|
||||
state.getIn(['user', 'account', 'edition']) === 'ee' ||
|
||||
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 { withRouter, RouteComponentProps } from 'react-router-dom';
|
||||
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 {
|
||||
siteId: string;
|
||||
history: any;
|
||||
setShowAlerts: (show: boolean) => void;
|
||||
}
|
||||
function DashboardSideMenu(props: Props) {
|
||||
const { history, siteId, setShowAlerts } = props;
|
||||
const { history, siteId } = props;
|
||||
const isMetric = history.location.pathname.includes('metrics');
|
||||
const isDashboards = history.location.pathname.includes('dashboard');
|
||||
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 { showModal } = useModal();
|
||||
|
||||
// const onClick = () => {
|
||||
// alert('test')
|
||||
// showModal(<ErrorDetailsModal />, { right: true });
|
||||
// }
|
||||
|
||||
return (
|
||||
<div
|
||||
className={ cn("p-3 grid grid-cols-12 gap-4 cursor-pointer py-4 hover:bg-active-blue", className) }
|
||||
|
|
@ -49,7 +46,7 @@ function ErrorListItem(props: Props) {
|
|||
<Bar name="Sessions" minPointSize={1} dataKey="count" fill="#A8E0DA" />
|
||||
</BarChart>
|
||||
</div>
|
||||
<ErrorLabel
|
||||
<ErrorLabel
|
||||
// className={stl.sessions}
|
||||
topValue={ error.sessions }
|
||||
bottomValue="Sessions"
|
||||
|
|
@ -84,6 +81,6 @@ const CustomTooltip = ({ active, payload, label }: any) => {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
return null;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 { FilterKey } from 'App/types/filter/filterType';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
|
|
|
|||
|
|
@ -1,89 +1,48 @@
|
|||
import { observer } from 'mobx-react-lite';
|
||||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import withSiteIdRouter from 'HOCs/withSiteIdRouter';
|
||||
import { error as errorRoute } from 'App/routes';
|
||||
import { NoContent, Loader } from 'UI';
|
||||
import { fetch, fetchTrace } from 'Duck/errors';
|
||||
import MainSection from './MainSection';
|
||||
import SideSection from './SideSection';
|
||||
|
||||
import { useStore } from 'App/mstore';
|
||||
import { Loader, NoContent } from 'UI';
|
||||
|
||||
import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG';
|
||||
|
||||
@connect(
|
||||
(state) => ({
|
||||
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;
|
||||
import MainSection from './MainSection';
|
||||
import SideSection from './SideSection';
|
||||
|
||||
let nextDisabled = true,
|
||||
prevDisabled = true;
|
||||
if (list.size > 0) {
|
||||
nextDisabled = loading || list.last().errorId === errorId;
|
||||
prevDisabled = loading || list.first().errorId === errorId;
|
||||
}
|
||||
function ErrorInfo(props) {
|
||||
const { errorStore } = useStore();
|
||||
const instance = errorStore.instance;
|
||||
const ensureInstance = () => {
|
||||
if (errorStore.isLoading) return;
|
||||
errorStore.fetchError(props.errorId);
|
||||
errorStore.fetchErrorTrace(props.errorId);
|
||||
};
|
||||
|
||||
return (
|
||||
<NoContent
|
||||
title={
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<AnimatedSVG name={ICONS.EMPTY_STATE} size="170" />
|
||||
<div className="mt-4">No Error Found!</div>
|
||||
</div>
|
||||
}
|
||||
subtext="Please try to find existing one."
|
||||
show={!loading && errorIdInStore == null}
|
||||
>
|
||||
<div className="flex w-full">
|
||||
<Loader loading={loading} className="w-full">
|
||||
<MainSection className="w-9/12" />
|
||||
<SideSection className="w-3/12" />
|
||||
</Loader>
|
||||
React.useEffect(() => {
|
||||
ensureInstance();
|
||||
}, [props.errorId]);
|
||||
|
||||
const errorIdInStore = errorStore.instance?.errorId;
|
||||
const loading = errorStore.isLoading;
|
||||
return (
|
||||
<NoContent
|
||||
title={
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<AnimatedSVG name={ICONS.EMPTY_STATE} size="170" />
|
||||
<div className="mt-4">No Error Found!</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 { connect } from 'react-redux';
|
||||
import cn from 'classnames';
|
||||
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 { withRouter } from 'react-router-dom';
|
||||
|
||||
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 ErrorName from 'Components/Errors/ui/ErrorName';
|
||||
import Label from 'Components/Errors/ui/Label';
|
||||
import { FilterKey } from 'Types/filter/filterType';
|
||||
import { Button, ErrorDetails, Icon, Loader } from 'UI';
|
||||
|
||||
import SessionBar from './SessionBar';
|
||||
|
||||
@withSiteIdRouter
|
||||
@connect(
|
||||
(state) => ({
|
||||
error: state.getIn(['errors', 'instance']),
|
||||
trace: state.getIn(['errors', 'instanceTrace']),
|
||||
sourcemapUploaded: state.getIn(['errors', 'sourcemapUploaded']),
|
||||
resolveToggleLoading:
|
||||
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);
|
||||
};
|
||||
function MainSection(props) {
|
||||
const { errorStore, searchStore } = useStore();
|
||||
const error = errorStore.instance;
|
||||
const trace = errorStore.instanceTrace;
|
||||
const sourcemapUploaded = errorStore.sourcemapUploaded;
|
||||
const loading = errorStore.isLoading;
|
||||
const className = props.className;
|
||||
|
||||
unresolve = () => {
|
||||
const { error } = this.props;
|
||||
this.props.unresolve(error.errorId);
|
||||
const findSessions = () => {
|
||||
searchStore.addFilterByKeyAndValue(FilterKey.ERROR, error.message);
|
||||
props.history.push(sessionsRoute());
|
||||
};
|
||||
|
||||
ignore = () => {
|
||||
const { error } = this.props;
|
||||
this.props.ignore(error.errorId);
|
||||
};
|
||||
bookmark = () => {
|
||||
const { error } = this.props;
|
||||
this.props.toggleFavorite(error.errorId);
|
||||
};
|
||||
|
||||
findSessions = () => {
|
||||
this.props.addFilterByKeyAndValue(FilterKey.ERROR, this.props.error.message);
|
||||
this.props.history.push(sessionsRoute());
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
error,
|
||||
trace,
|
||||
sourcemapUploaded,
|
||||
ignoreLoading,
|
||||
resolveToggleLoading,
|
||||
toggleFavoriteLoading,
|
||||
className,
|
||||
traceLoading,
|
||||
} = this.props;
|
||||
const isPlayer = window.location.pathname.includes('/session/');
|
||||
|
||||
return (
|
||||
<div className={cn(className, 'bg-white border-radius-3 thin-gray-border mb-6')}>
|
||||
<div className="m-4">
|
||||
<ErrorName
|
||||
className="text-lg leading-relaxed"
|
||||
name={error.name}
|
||||
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}
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
className,
|
||||
'bg-white border-radius-3 thin-gray-border mb-6'
|
||||
)}
|
||||
>
|
||||
<div className="m-4">
|
||||
<ErrorName
|
||||
className="text-lg leading-relaxed"
|
||||
name={error.name}
|
||||
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 className="flex items-center mt-2">
|
||||
<div className="flex">
|
||||
<Label
|
||||
topValue={error.sessions}
|
||||
horizontal
|
||||
topValueSize="text-lg"
|
||||
bottomValue="Sessions"
|
||||
/>
|
||||
<Label
|
||||
topValue={error.users}
|
||||
horizontal
|
||||
topValueSize="text-lg"
|
||||
bottomValue="Users"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center mt-2">
|
||||
<div className="flex">
|
||||
<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 className="text-xs color-gray-medium">
|
||||
Over the past 30 days
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
<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 withRequest from 'HOCs/withRequest';
|
||||
import { Loader } from 'UI';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import React from 'react';
|
||||
|
||||
import { countries } from 'App/constants';
|
||||
import Trend from './Trend';
|
||||
import { useStore } from 'App/mstore';
|
||||
import { Loader } from 'UI';
|
||||
|
||||
import DateAgo from './DateAgo';
|
||||
import DistributionBar from './DistributionBar';
|
||||
import Trend from './Trend';
|
||||
import { errorService } from 'App/services';
|
||||
|
||||
const MAX_PERCENTAGE = 3;
|
||||
const MIN_COUNT = 4;
|
||||
const MAX_COUNT = 10;
|
||||
function hidePredicate(percentage, index) {
|
||||
if (index < MIN_COUNT) return false;
|
||||
if (index < MAX_COUNT && percentage < MAX_PERCENTAGE) return false;
|
||||
return true;
|
||||
if (index < MIN_COUNT) return false;
|
||||
if (index < MAX_COUNT && percentage < MAX_PERCENTAGE) return false;
|
||||
return true;
|
||||
}
|
||||
function partitionsWrapper(partitions = [], mapCountry = false) {
|
||||
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) {
|
||||
return [];
|
||||
return [];
|
||||
}
|
||||
const otherPrcs = counts
|
||||
.map(c => c/sum * 100)
|
||||
.filter(hidePredicate);
|
||||
const otherPrcsSum = otherPrcs.reduce((a,b)=>a+b,0);
|
||||
const showLength = partitions.length - otherPrcs.length;
|
||||
const show = partitions
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, showLength)
|
||||
.map(p => ({
|
||||
label: mapCountry
|
||||
? (countries[p.name] || "Unknown")
|
||||
: p.name,
|
||||
prc: p.count/sum * 100,
|
||||
}))
|
||||
const otherPrcs = counts.map((c) => (c / sum) * 100).filter(hidePredicate);
|
||||
const otherPrcsSum = otherPrcs.reduce((a, b) => a + b, 0);
|
||||
const showLength = partitions.length - otherPrcs.length;
|
||||
const show = partitions
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, showLength)
|
||||
.map((p) => ({
|
||||
label: mapCountry ? countries[p.name] || 'Unknown' : p.name,
|
||||
prc: (p.count / sum) * 100,
|
||||
}));
|
||||
|
||||
if (otherPrcsSum > 0) {
|
||||
show.push({
|
||||
label: "Other",
|
||||
label: 'Other',
|
||||
prc: otherPrcsSum,
|
||||
other: true,
|
||||
})
|
||||
});
|
||||
}
|
||||
return show;
|
||||
}
|
||||
function tagsWrapper(tags = []) {
|
||||
return tags.map(({ name, partitions }) => ({
|
||||
name,
|
||||
partitions: partitionsWrapper(partitions, name === "country")
|
||||
}))
|
||||
return tags.map(({ name, partitions }) => ({
|
||||
name,
|
||||
partitions: partitionsWrapper(partitions, name === 'country'),
|
||||
}));
|
||||
}
|
||||
|
||||
function dataWrapper(data = {}) {
|
||||
return {
|
||||
chart24: data.chart24 || [],
|
||||
chart30: data.chart30 || [],
|
||||
tags: tagsWrapper(data.tags),
|
||||
};
|
||||
return {
|
||||
chart24: data.chart24 || [],
|
||||
chart30: data.chart30 || [],
|
||||
tags: tagsWrapper(data.tags),
|
||||
};
|
||||
}
|
||||
|
||||
@connect(state => ({
|
||||
error: state.getIn([ "errors", "instance" ])
|
||||
}))
|
||||
@withRequest({
|
||||
initialData: props => dataWrapper(props.error),
|
||||
endpoint: props => `/errors/${ props.error.errorId }/stats`,
|
||||
dataWrapper,
|
||||
})
|
||||
export default class SideSection extends React.PureComponent {
|
||||
onDateChange = ({ startDate, endDate }) => {
|
||||
this.props.request({ startDate, endDate });
|
||||
}
|
||||
function SideSection(props) {
|
||||
const [data, setData] = React.useState({
|
||||
chart24: [],
|
||||
chart30: [],
|
||||
tags: [],
|
||||
});
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const { className } = props;
|
||||
const { errorStore } = useStore();
|
||||
const error = errorStore.instance;
|
||||
|
||||
render() {
|
||||
const {
|
||||
className,
|
||||
error,
|
||||
data,
|
||||
loading,
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<div className={ cn(className, "pl-5") }>
|
||||
<h3 className="text-xl mb-2">Overview</h3>
|
||||
<Trend
|
||||
chart={ data.chart24 }
|
||||
title="Past 24 hours"
|
||||
/>
|
||||
<div className="mb-6" />
|
||||
<Trend
|
||||
chart={ data.chart30 }
|
||||
title="Last 30 days"
|
||||
timeFormat={'l'}
|
||||
/>
|
||||
<div className="mb-6" />
|
||||
<DateAgo
|
||||
className="my-4"
|
||||
title="First Seen"
|
||||
timestamp={ error.firstOccurrence }
|
||||
/>
|
||||
<DateAgo
|
||||
className="my-4"
|
||||
title="Last Seen"
|
||||
timestamp={ error.lastOccurrence }
|
||||
/>
|
||||
{ data.tags.length > 0 && <h4 className="text-xl mt-6 mb-3">Summary</h4> }
|
||||
<Loader loading={loading}>
|
||||
{ data.tags.map(({ name, partitions }) =>
|
||||
<DistributionBar
|
||||
key={ name }
|
||||
title={name}
|
||||
partitions={partitions}
|
||||
className="mb-6"
|
||||
/>
|
||||
)}
|
||||
</Loader>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const grabData = async () => {
|
||||
setLoading(true);
|
||||
errorService.fetchErrorStats(error.errorId)
|
||||
.then(data => {
|
||||
setData(dataWrapper(data))
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
setData(dataWrapper(error))
|
||||
}, [error.errorId])
|
||||
|
||||
return (
|
||||
<div className={cn(className, 'pl-5')}>
|
||||
<h3 className="text-xl mb-2">Overview</h3>
|
||||
<Trend chart={data.chart24} title="Past 24 hours" />
|
||||
<div className="mb-6" />
|
||||
<Trend chart={data.chart30} title="Last 30 days" timeFormat={'l'} />
|
||||
<div className="mb-6" />
|
||||
<DateAgo
|
||||
className="my-4"
|
||||
title="First Seen"
|
||||
timestamp={error.firstOccurrence}
|
||||
/>
|
||||
<DateAgo
|
||||
className="my-4"
|
||||
title="Last Seen"
|
||||
timestamp={error.lastOccurrence}
|
||||
/>
|
||||
{data.tags.length > 0 && <h4 className="text-xl mt-6 mb-3">Summary</h4>}
|
||||
<Loader loading={loading}>
|
||||
{data.tags.map(({ name, partitions }) => (
|
||||
<DistributionBar
|
||||
key={name}
|
||||
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,15 +1,15 @@
|
|||
import React from 'react'
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
function FunnelIssuesHeader({ criticalIssuesCount, filters }) {
|
||||
function FunnelIssuesHeader({ criticalIssuesCount, filters }) {
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<div className="flex items-center mr-auto text-xl">
|
||||
<div className="font-medium mr-2">
|
||||
<div className="font-medium mr-2">
|
||||
Significant issues
|
||||
</div>
|
||||
<div className="mr-2">in this funnel</div>
|
||||
</div>
|
||||
<div className="mr-2">in this funnel</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue