Compare commits

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

42 commits

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

1
frontend/.browserslistrc Normal file
View file

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

View file

@ -10,6 +10,8 @@ import NotFoundPage from 'Shared/NotFoundPage';
import { ModalProvider } from 'Components/Modal';
import 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));

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -14,124 +14,124 @@ import { useModal } from 'App/components/Modal';
import { toast } from 'react-toastify';
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));

View file

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

View file

@ -4,23 +4,23 @@ import { Button } from 'UI';
import styles from './listItem.module.css';
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;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,88 +1,95 @@
import withPageTitle from 'HOCs/withPageTitle';
import cn from 'classnames';
import { observer } from 'mobx-react-lite';
import React, { useEffect, useState } from 'react';
import { 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 applications store, monitor queries, track performance issues and even
assist your end user through live sessions.
Plugins capture your applications 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 />,
},
],
},
];

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,196 +1,234 @@
import React, { useRef, useEffect } from 'react';
import { observer } from 'mobx-react-lite';
import React, { useEffect, useRef } from 'react';
import { connect } from 'react-redux';
import 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>
);
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -17,10 +17,7 @@ function ErrorListItem(props: Props) {
const { error, className = '' } = props;
// const { 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;
};

View file

@ -1,21 +0,0 @@
import React, { useEffect } from 'react';
import ErrorListItem from '../ErrorListItem';
import { useStore } from 'App/mstore';
import { useObserver } from 'mobx-react-lite';
function ErrorsList(props) {
const { errorStore, metricStore } = useStore();
const metric = useObserver(() => metricStore.instance);
useEffect(() => {
errorStore.fetchErrors();
}, []);
return (
<div>
Errors List
<ErrorListItem error={{}} />
</div>
);
}
export default ErrorsList;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,154 +1,133 @@
import { RESOLVED } from 'Types/errorInfo';
import { FilterKey } from 'Types/filter/filterType';
import cn from 'classnames';
import { observer } from 'mobx-react-lite';
import React from 'react';
import { 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))
);

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,15 +1,15 @@
import React from 'react'
import { 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>
)
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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