getting rid of redux for good (#2556)
* start moving ui to redux tlk * remove unused reducer * changes for gdpr and site types * ui: migrating duck/roles to mobx * ui: drop unreferenced types * ui: drop unreferenced types * ui: move player slice reducer to mobx family * ui: move assignments to issueReportingStore.ts * remove issues store * some fixes after issues store * remove errors reducer, drop old components * finish removing errors reducer * start moving integrations state to mobx * change(ui): funnel duck cleanup * change(ui): custom fields * change(ui): customMetrics cleanup * change(ui): customMetrics cleanup * change(ui): duck/filters minor cleanup * change(ui): duck/filters cleanup * change(ui): duck/customMetrics cleanup and upgrades * fix integrations service, fix babel config to >.25 + not ie * refactoring integrations reducers etc WIP * finish removing integrations state * some fixes for integrated check * start of projects refactoring * move api and "few" files to new project store * new batch for site -> projects * fix setid context * move all critical components, drop site duck * remove all duck/site refs, remove old components * fixup for SessionTags.tsx, remove duck/sources (?) * move session store * init sessionstore outside of context * fix userfilter * replace simple actions for session store * sessions sotre * Rtm temp (#2597) * change(ui): duck/search wip * change(ui): duck/search wip * change(ui): duck/search wip * change(ui): duck/searchLive wip * change(ui): duck/searchLive wip * change(ui): duck/searchLive wip * change(ui): duck/searchLive wip * change(ui): search states * change(ui): search states * change(ui): search states * change(ui): fix savedSearch store * change(ui): fix savedSearch store * some fixes for session connector * change(ui): fix savedSearch store * change(ui): fix searchLive * change(ui): fix searchLive * fixes for session replay * change(ui): bookmark fetch * last components for sessions * add fetchautoplaylist * finish session reducer, remove deleted reducers * change(ui): fix the search fetch * change(ui): fix the search fetch * fix integrations call ctx * ensure ctx for sessionstore * fix(ui): checking for latest sessions path * start removing user reducer * removing user reducer pt2... * finish user store * remove rand log * fix crashes * tinkering workflow file for tracker test * making sure prefetched sessions work properly * fix conflict * fix router redirects during loading --------- Co-authored-by: Shekar Siri <sshekarsiri@gmail.com>
This commit is contained in:
parent
70a337f766
commit
a71381da40
431 changed files with 9681 additions and 17014 deletions
10
.github/workflows/tracker-tests.yaml
vendored
10
.github/workflows/tracker-tests.yaml
vendored
|
|
@ -9,24 +9,16 @@ on:
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [ "dev", "main" ]
|
branches: [ "dev", "main" ]
|
||||||
paths:
|
paths:
|
||||||
- frontend/**
|
|
||||||
- tracker/**
|
- tracker/**
|
||||||
jobs:
|
jobs:
|
||||||
build-and-test:
|
build-and-test:
|
||||||
runs-on: macos-latest
|
runs-on: macos-latest
|
||||||
name: Build and test Tracker
|
name: Build and test Tracker
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
node-version: [ 18.x ]
|
|
||||||
steps:
|
steps:
|
||||||
- uses: oven-sh/setup-bun@v1
|
- uses: oven-sh/setup-bun@v2
|
||||||
with:
|
with:
|
||||||
bun-version: latest
|
bun-version: latest
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
- name: Use Node.js ${{ matrix.node-version }}
|
|
||||||
uses: actions/setup-node@v3
|
|
||||||
with:
|
|
||||||
node-version: ${{ matrix.node-version }}
|
|
||||||
- name: Cache tracker modules
|
- name: Cache tracker modules
|
||||||
uses: actions/cache@v3
|
uses: actions/cache@v3
|
||||||
with:
|
with:
|
||||||
|
|
|
||||||
1
frontend/.browserslistrc
Normal file
1
frontend/.browserslistrc
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
> 0.25% and not dead
|
||||||
|
|
@ -1,15 +1,15 @@
|
||||||
import React, { lazy, Suspense } from 'react';
|
import React, { lazy, Suspense } from 'react';
|
||||||
import { Switch, Route } from 'react-router-dom';
|
import { Switch, Route } from 'react-router-dom';
|
||||||
import { connect } from 'react-redux';
|
|
||||||
import { Loader } from 'UI';
|
import { Loader } from 'UI';
|
||||||
import withSiteIdUpdater from 'HOCs/withSiteIdUpdater';
|
import withSiteIdUpdater from 'HOCs/withSiteIdUpdater';
|
||||||
|
|
||||||
import * as routes from './routes';
|
import * as routes from './routes';
|
||||||
import { Map } from 'immutable';
|
|
||||||
import NotFoundPage from 'Shared/NotFoundPage';
|
import NotFoundPage from 'Shared/NotFoundPage';
|
||||||
import { ModalProvider } from 'Components/Modal';
|
import { ModalProvider } from 'Components/Modal';
|
||||||
import Layout from 'App/layout/Layout';
|
import Layout from 'App/layout/Layout';
|
||||||
import PublicRoutes from 'App/PublicRoutes';
|
import PublicRoutes from 'App/PublicRoutes';
|
||||||
|
import { useStore } from 'App/mstore';
|
||||||
|
import { observer } from 'mobx-react-lite';
|
||||||
|
|
||||||
const components: any = {
|
const components: any = {
|
||||||
SessionPure: lazy(() => import('Components/Session/Session')),
|
SessionPure: lazy(() => import('Components/Session/Session')),
|
||||||
|
|
@ -29,20 +29,16 @@ const LIVE_SESSION_PATH = routes.liveSession();
|
||||||
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
isEnterprise: boolean;
|
|
||||||
tenantId: string;
|
|
||||||
siteId: string;
|
|
||||||
jwt: string;
|
|
||||||
sites: Map<string, any>;
|
|
||||||
onboarding: boolean;
|
|
||||||
isJwt?: boolean;
|
isJwt?: boolean;
|
||||||
isLoggedIn?: boolean;
|
isLoggedIn?: boolean;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function IFrameRoutes(props: Props) {
|
function IFrameRoutes(props: Props) {
|
||||||
const { isJwt = false, isLoggedIn = false, loading, onboarding, sites, siteId, jwt } = props;
|
const { projectsStore } = useStore();
|
||||||
const siteIdList: any = sites.map(({ id }) => id).toJS();
|
const sites = projectsStore.list;
|
||||||
|
const { isJwt = false, isLoggedIn = false, loading } = props;
|
||||||
|
const siteIdList: any = sites.map(({ id }) => id);
|
||||||
|
|
||||||
if (isLoggedIn) {
|
if (isLoggedIn) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -72,14 +68,4 @@ function IFrameRoutes(props: Props) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export default connect((state: any) => ({
|
export default observer(IFrameRoutes);
|
||||||
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);
|
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,13 @@
|
||||||
import withSiteIdUpdater from 'HOCs/withSiteIdUpdater';
|
import withSiteIdUpdater from 'HOCs/withSiteIdUpdater';
|
||||||
import { Map } from 'immutable';
|
|
||||||
import React, { Suspense, lazy } from 'react';
|
import React, { Suspense, lazy } from 'react';
|
||||||
import { connect } from 'react-redux';
|
|
||||||
import { Redirect, Route, Switch } from 'react-router-dom';
|
import { Redirect, Route, Switch } from 'react-router-dom';
|
||||||
|
import { observer } from 'mobx-react-lite'
|
||||||
import AdditionalRoutes from 'App/AdditionalRoutes';
|
import { useStore } from "./mstore";
|
||||||
import { GLOBAL_HAS_NO_RECORDINGS } from 'App/constants/storageKeys';
|
import { GLOBAL_HAS_NO_RECORDINGS } from 'App/constants/storageKeys';
|
||||||
import { OB_DEFAULT_TAB } from 'App/routes';
|
import { OB_DEFAULT_TAB } from 'App/routes';
|
||||||
import { Loader } from 'UI';
|
import { Loader } from 'UI';
|
||||||
|
|
||||||
import APIClient from './api_client';
|
import APIClient from './api_client';
|
||||||
import { getScope } from './duck/user';
|
|
||||||
import * as routes from './routes';
|
import * as routes from './routes';
|
||||||
|
|
||||||
const components: any = {
|
const components: any = {
|
||||||
|
|
@ -108,21 +105,18 @@ const SPOTS_LIST_PATH = routes.spotsList();
|
||||||
const SPOT_PATH = routes.spot();
|
const SPOT_PATH = routes.spot();
|
||||||
const SCOPE_SETUP = routes.scopeSetup();
|
const SCOPE_SETUP = routes.scopeSetup();
|
||||||
|
|
||||||
interface Props {
|
function PrivateRoutes() {
|
||||||
tenantId: string;
|
const { projectsStore, userStore } = useStore();
|
||||||
siteId: string;
|
const onboarding = userStore.onboarding;
|
||||||
sites: Map<string, any>;
|
const scope = userStore.scopeState;
|
||||||
onboarding: boolean;
|
const tenantId = userStore.account.tenantId;
|
||||||
scope: number;
|
const sites = projectsStore.list;
|
||||||
}
|
const siteId = projectsStore.siteId;
|
||||||
|
|
||||||
function PrivateRoutes(props: Props) {
|
|
||||||
const { onboarding, sites, siteId } = props;
|
|
||||||
const hasRecordings = sites.some(s => s.recorded);
|
const hasRecordings = sites.some(s => s.recorded);
|
||||||
const redirectToSetup = props.scope === 0;
|
const redirectToSetup = scope === 0;
|
||||||
const redirectToOnboarding =
|
const redirectToOnboarding =
|
||||||
!onboarding && (localStorage.getItem(GLOBAL_HAS_NO_RECORDINGS) === 'true' || !hasRecordings) && props.scope > 0;
|
!onboarding && (localStorage.getItem(GLOBAL_HAS_NO_RECORDINGS) === 'true' || (sites.length > 0 && !hasRecordings)) && scope > 0;
|
||||||
const siteIdList: any = sites.map(({ id }) => id).toJS();
|
const siteIdList: any = sites.map(({ id }) => id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={<Loader loading={true} className="flex-1" />}>
|
<Suspense fallback={<Loader loading={true} className="flex-1" />}>
|
||||||
|
|
@ -151,7 +145,7 @@ function PrivateRoutes(props: Props) {
|
||||||
path={SPOT_PATH}
|
path={SPOT_PATH}
|
||||||
component={enhancedComponents.Spot}
|
component={enhancedComponents.Spot}
|
||||||
/>
|
/>
|
||||||
{props.scope === 1 ? <Redirect to={SPOTS_LIST_PATH} /> : null}
|
{scope === 1 ? <Redirect to={SPOTS_LIST_PATH} /> : null}
|
||||||
<Route
|
<Route
|
||||||
path="/integrations/"
|
path="/integrations/"
|
||||||
render={({ location }) => {
|
render={({ location }) => {
|
||||||
|
|
@ -160,13 +154,13 @@ function PrivateRoutes(props: Props) {
|
||||||
case '/integrations/slack':
|
case '/integrations/slack':
|
||||||
client.post('integrations/slack/add', {
|
client.post('integrations/slack/add', {
|
||||||
code: location.search.split('=')[1],
|
code: location.search.split('=')[1],
|
||||||
state: props.tenantId,
|
state: tenantId,
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
case '/integrations/msteams':
|
case '/integrations/msteams':
|
||||||
client.post('integrations/msteams/add', {
|
client.post('integrations/msteams/add', {
|
||||||
code: location.search.split('=')[1],
|
code: location.search.split('=')[1],
|
||||||
state: props.tenantId,
|
state: tenantId,
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
@ -283,16 +277,12 @@ function PrivateRoutes(props: Props) {
|
||||||
{Object.entries(routes.redirects).map(([fr, to]) => (
|
{Object.entries(routes.redirects).map(([fr, to]) => (
|
||||||
<Redirect key={fr} exact strict from={fr} to={to} />
|
<Redirect key={fr} exact strict from={fr} to={to} />
|
||||||
))}
|
))}
|
||||||
<Redirect to={withSiteId(routes.sessions(), siteId)} />
|
<Route path={"*"}>
|
||||||
|
<Redirect to={withSiteId(routes.sessions(), siteId)} />
|
||||||
|
</Route>
|
||||||
</Switch>
|
</Switch>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default connect((state: any) => ({
|
export default observer(PrivateRoutes);
|
||||||
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);
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,8 @@ import { Loader } from 'UI';
|
||||||
import { Redirect, Route, Switch } from 'react-router-dom';
|
import { Redirect, Route, Switch } from 'react-router-dom';
|
||||||
import Signup from 'Components/Signup/Signup';
|
import Signup from 'Components/Signup/Signup';
|
||||||
import SupportCallout from 'Shared/SupportCallout';
|
import SupportCallout from 'Shared/SupportCallout';
|
||||||
import { connect } from 'react-redux';
|
import { useStore } from 'App/mstore';
|
||||||
|
import { observer } from 'mobx-react-lite';
|
||||||
import * as routes from 'App/routes';
|
import * as routes from 'App/routes';
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -14,16 +15,12 @@ const SPOT_PATH = routes.spot();
|
||||||
|
|
||||||
const Login = lazy(() => import('Components/Login/Login'));
|
const Login = lazy(() => import('Components/Login/Login'));
|
||||||
const ForgotPassword = lazy(() => import('Components/ForgotPassword/ForgotPassword'));
|
const ForgotPassword = lazy(() => import('Components/ForgotPassword/ForgotPassword'));
|
||||||
const UpdatePassword = lazy(() => import('Components/UpdatePassword/UpdatePassword'));
|
|
||||||
const Spot = lazy(() => import('Components/Spots/SpotPlayer/SpotPlayer'));
|
const Spot = lazy(() => import('Components/Spots/SpotPlayer/SpotPlayer'));
|
||||||
|
|
||||||
interface Props {
|
function PublicRoutes() {
|
||||||
isEnterprise: boolean;
|
const { userStore } = useStore();
|
||||||
changePassword: boolean;
|
const isEnterprise = userStore.isEnterprise;
|
||||||
}
|
const hideSupport = isEnterprise || location.pathname.includes('spots') || location.pathname.includes('view-spot')
|
||||||
|
|
||||||
function PublicRoutes(props: Props) {
|
|
||||||
const hideSupport = props.isEnterprise || location.pathname.includes('spots') || location.pathname.includes('view-spot')
|
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={<Loader loading={true} className='flex-1' />}>
|
<Suspense fallback={<Loader loading={true} className='flex-1' />}>
|
||||||
<Switch>
|
<Switch>
|
||||||
|
|
@ -39,9 +36,4 @@ function PublicRoutes(props: Props) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export default connect((state: any) => ({
|
export default observer(PublicRoutes)
|
||||||
changePassword: state.getIn(['user', 'account', 'changePassword']),
|
|
||||||
isEnterprise:
|
|
||||||
state.getIn(['user', 'account', 'edition']) === 'ee' ||
|
|
||||||
state.getIn(['user', 'authDetails', 'edition']) === 'ee'
|
|
||||||
}))(PublicRoutes);
|
|
||||||
|
|
@ -1,6 +1,4 @@
|
||||||
import { Map } from 'immutable';
|
|
||||||
import React, { useEffect, useRef } from 'react';
|
import React, { useEffect, useRef } from 'react';
|
||||||
import { ConnectedProps, connect } from 'react-redux';
|
|
||||||
import { RouteComponentProps, withRouter } from 'react-router-dom';
|
import { RouteComponentProps, withRouter } from 'react-router-dom';
|
||||||
|
|
||||||
import IFrameRoutes from 'App/IFrameRoutes';
|
import IFrameRoutes from 'App/IFrameRoutes';
|
||||||
|
|
@ -10,60 +8,49 @@ import {
|
||||||
GLOBAL_DESTINATION_PATH,
|
GLOBAL_DESTINATION_PATH,
|
||||||
IFRAME,
|
IFRAME,
|
||||||
JWT_PARAM,
|
JWT_PARAM,
|
||||||
SPOT_ONBOARDING,
|
SPOT_ONBOARDING
|
||||||
} from 'App/constants/storageKeys';
|
} from 'App/constants/storageKeys';
|
||||||
import Layout from 'App/layout/Layout';
|
import Layout from 'App/layout/Layout';
|
||||||
import { withStore } from 'App/mstore';
|
import { useStore } from 'App/mstore';
|
||||||
import { checkParam, handleSpotJWT, isTokenExpired } from 'App/utils';
|
import { checkParam, handleSpotJWT, isTokenExpired } from 'App/utils';
|
||||||
import { ModalProvider } from 'Components/Modal';
|
import { ModalProvider } from 'Components/Modal';
|
||||||
import { ModalProvider as NewModalProvider } from 'Components/ModalContext';
|
import { ModalProvider as NewModalProvider } from 'Components/ModalContext';
|
||||||
import { fetchListActive as fetchMetadata } from 'Duck/customField';
|
|
||||||
import { setSessionPath } from 'Duck/sessions';
|
|
||||||
import { fetchList as fetchSiteList } from 'Duck/site';
|
|
||||||
import { init as initSite } from 'Duck/site';
|
|
||||||
import { fetchUserInfo, getScope, logout, setJwt } from 'Duck/user';
|
|
||||||
import { Loader } from 'UI';
|
import { Loader } from 'UI';
|
||||||
import * as routes from './routes';
|
import * as routes from './routes';
|
||||||
|
import { observer } from 'mobx-react-lite'
|
||||||
|
|
||||||
interface RouterProps
|
interface RouterProps extends RouteComponentProps {
|
||||||
extends RouteComponentProps,
|
|
||||||
ConnectedProps<typeof connector> {
|
|
||||||
isLoggedIn: boolean;
|
|
||||||
sites: Map<string, any>;
|
|
||||||
loading: boolean;
|
|
||||||
changePassword: boolean;
|
|
||||||
isEnterprise: boolean;
|
|
||||||
fetchUserInfo: () => any;
|
|
||||||
setSessionPath: (path: any) => any;
|
|
||||||
fetchSiteList: (siteId?: number) => any;
|
|
||||||
match: {
|
match: {
|
||||||
params: {
|
params: {
|
||||||
siteId: string;
|
siteId: string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
mstore: any;
|
|
||||||
setJwt: (params: { jwt: string; spotJwt: string | null }) => any;
|
|
||||||
fetchMetadata: (siteId: string) => void;
|
|
||||||
initSite: (site: any) => void;
|
|
||||||
scopeSetup: boolean;
|
|
||||||
localSpotJwt: string | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const Router: React.FC<RouterProps> = (props) => {
|
const Router: React.FC<RouterProps> = (props) => {
|
||||||
const {
|
const {
|
||||||
isLoggedIn,
|
|
||||||
siteId,
|
|
||||||
sites,
|
|
||||||
loading,
|
|
||||||
location,
|
location,
|
||||||
fetchUserInfo,
|
|
||||||
fetchSiteList,
|
|
||||||
history,
|
history,
|
||||||
setSessionPath,
|
|
||||||
scopeSetup,
|
|
||||||
localSpotJwt,
|
|
||||||
logout,
|
|
||||||
} = props;
|
} = props;
|
||||||
|
const mstore = useStore();
|
||||||
|
const { customFieldStore, projectsStore, sessionStore, searchStore, userStore } = mstore;
|
||||||
|
const jwt = userStore.jwt;
|
||||||
|
const changePassword = userStore.account.changePassword;
|
||||||
|
const userInfoLoading = userStore.fetchInfoRequest.loading;
|
||||||
|
const scopeSetup = userStore.scopeState === 0;
|
||||||
|
const localSpotJwt = userStore.spotJwt;
|
||||||
|
const isLoggedIn = Boolean(jwt && !changePassword);
|
||||||
|
const fetchUserInfo = userStore.fetchUserInfo;
|
||||||
|
const setJwt = userStore.updateJwt;
|
||||||
|
const logout = userStore.logout;
|
||||||
|
|
||||||
|
const setSessionPath = sessionStore.setSessionPath;
|
||||||
|
const siteId = projectsStore.siteId;
|
||||||
|
const sitesLoading = projectsStore.sitesLoading;
|
||||||
|
const sites = projectsStore.list;
|
||||||
|
const loading = Boolean(userInfoLoading || (!scopeSetup && !siteId) || sitesLoading);
|
||||||
|
const initSite = projectsStore.initProject;
|
||||||
|
const fetchSiteList = projectsStore.fetchList;
|
||||||
|
|
||||||
const params = new URLSearchParams(location.search);
|
const params = new URLSearchParams(location.search);
|
||||||
const spotCb = params.get('spotCallback');
|
const spotCb = params.get('spotCallback');
|
||||||
|
|
@ -81,7 +68,7 @@ const Router: React.FC<RouterProps> = (props) => {
|
||||||
handleSpotLogin(spotJwt);
|
handleSpotLogin(spotJwt);
|
||||||
}
|
}
|
||||||
if (urlJWT) {
|
if (urlJWT) {
|
||||||
props.setJwt({ jwt: urlJWT, spotJwt: spotJwt ?? null });
|
setJwt({ jwt: urlJWT, spotJwt: spotJwt ?? null });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -109,9 +96,9 @@ const Router: React.FC<RouterProps> = (props) => {
|
||||||
localStorage.setItem(SPOT_ONBOARDING, 'true');
|
localStorage.setItem(SPOT_ONBOARDING, 'true');
|
||||||
}
|
}
|
||||||
await fetchUserInfo();
|
await fetchUserInfo();
|
||||||
const siteIdFromPath = parseInt(location.pathname.split('/')[1]);
|
const siteIdFromPath = location.pathname.split('/')[1];
|
||||||
await fetchSiteList(siteIdFromPath);
|
await fetchSiteList(siteIdFromPath);
|
||||||
props.mstore.initClient();
|
mstore.initClient();
|
||||||
|
|
||||||
if (localSpotJwt && !isTokenExpired(localSpotJwt)) {
|
if (localSpotJwt && !isTokenExpired(localSpotJwt)) {
|
||||||
handleSpotLogin(localSpotJwt);
|
handleSpotLogin(localSpotJwt);
|
||||||
|
|
@ -141,6 +128,7 @@ const Router: React.FC<RouterProps> = (props) => {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
checkParams();
|
checkParams();
|
||||||
handleJwtFromUrl();
|
handleJwtFromUrl();
|
||||||
|
mstore.initClient();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -169,18 +157,23 @@ const Router: React.FC<RouterProps> = (props) => {
|
||||||
if (localSpotJwt && !isTokenExpired(localSpotJwt)) {
|
if (localSpotJwt && !isTokenExpired(localSpotJwt)) {
|
||||||
handleSpotLogin(localSpotJwt);
|
handleSpotLogin(localSpotJwt);
|
||||||
} else {
|
} else {
|
||||||
logout();
|
void logout();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [isSpotCb, isLoggedIn, localSpotJwt, isSignup]);
|
}, [isSpotCb, isLoggedIn, localSpotJwt, isSignup]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (siteId && siteId !== lastFetchedSiteIdRef.current) {
|
const fetchData = async () => {
|
||||||
const activeSite = sites.find((s) => s.id == siteId);
|
if (siteId && siteId !== lastFetchedSiteIdRef.current) {
|
||||||
props.initSite(activeSite);
|
const activeSite = sites.find((s) => s.id == siteId);
|
||||||
props.fetchMetadata(siteId);
|
initSite(activeSite ?? {});
|
||||||
lastFetchedSiteIdRef.current = siteId;
|
lastFetchedSiteIdRef.current = activeSite?.id;
|
||||||
}
|
await customFieldStore.fetchListActive(siteId + '');
|
||||||
|
await searchStore.fetchSavedSearchList()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void fetchData();
|
||||||
}, [siteId]);
|
}, [siteId]);
|
||||||
|
|
||||||
const lastFetchedSiteIdRef = useRef<any>(null);
|
const lastFetchedSiteIdRef = useRef<any>(null);
|
||||||
|
|
@ -225,51 +218,4 @@ const Router: React.FC<RouterProps> = (props) => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const mapStateToProps = (state: Map<string, any>) => {
|
export default withRouter(observer(Router));
|
||||||
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',
|
|
||||||
]);
|
|
||||||
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,
|
|
||||||
localSpotJwt: state.getIn(['user', 'spotJwt']),
|
|
||||||
isLoggedIn: jwt !== null && !changePassword,
|
|
||||||
scopeSetup,
|
|
||||||
loading,
|
|
||||||
email: state.getIn(['user', 'account', 'email']),
|
|
||||||
account: state.getIn(['user', 'account']),
|
|
||||||
organisation: state.getIn(['user', 'account', 'name']),
|
|
||||||
tenantId: state.getIn(['user', 'account', 'tenantId']),
|
|
||||||
tenants: state.getIn(['user', 'tenants']),
|
|
||||||
isEnterprise:
|
|
||||||
state.getIn(['user', 'account', 'edition']) === 'ee' ||
|
|
||||||
state.getIn(['user', 'authDetails', 'edition']) === 'ee',
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const mapDispatchToProps = {
|
|
||||||
fetchUserInfo,
|
|
||||||
setSessionPath,
|
|
||||||
fetchSiteList,
|
|
||||||
setJwt,
|
|
||||||
fetchMetadata,
|
|
||||||
initSite,
|
|
||||||
logout,
|
|
||||||
};
|
|
||||||
|
|
||||||
const connector = connect(mapStateToProps, mapDispatchToProps);
|
|
||||||
|
|
||||||
export default withStore(withRouter(connector(Router)));
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,4 @@
|
||||||
import store from 'App/store';
|
|
||||||
import { queried } from './routes';
|
import { queried } from './routes';
|
||||||
import { setJwt } from 'Duck/user';
|
|
||||||
|
|
||||||
const siteIdRequiredPaths: string[] = [
|
const siteIdRequiredPaths: string[] = [
|
||||||
'/dashboard',
|
'/dashboard',
|
||||||
|
|
@ -54,27 +52,42 @@ export const clean = (obj: any, forbiddenValues: any[] = [undefined, '']): any =
|
||||||
|
|
||||||
export default class APIClient {
|
export default class APIClient {
|
||||||
private init: RequestInit;
|
private init: RequestInit;
|
||||||
private readonly siteId: string | undefined;
|
private siteId: string | undefined;
|
||||||
|
private siteIdCheck: (() => { siteId: string | null }) | undefined;
|
||||||
|
private getJwt: () => string | null = () => null;
|
||||||
|
private onUpdateJwt: (data: { jwt?: string, spotJwt?: string }) => void;
|
||||||
private refreshingTokenPromise: Promise<string> | null = null;
|
private refreshingTokenPromise: Promise<string> | null = null;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
const jwt = store.getState().getIn(['user', 'jwt']);
|
|
||||||
const siteId = store.getState().getIn(['site', 'siteId']);
|
|
||||||
this.init = {
|
this.init = {
|
||||||
headers: new Headers({
|
headers: new Headers({
|
||||||
Accept: 'application/json',
|
Accept: 'application/json',
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
setJwt(jwt: string | null): void {
|
||||||
if (jwt !== null) {
|
if (jwt !== null) {
|
||||||
(this.init.headers as Headers).set('Authorization', `Bearer ${jwt}`);
|
(this.init.headers as Headers).set('Authorization', `Bearer ${jwt}`);
|
||||||
}
|
}
|
||||||
this.siteId = siteId;
|
}
|
||||||
|
|
||||||
|
setOnUpdateJwt(onUpdateJwt: (data: { jwt?: string, spotJwt?: string }) => void): void {
|
||||||
|
this.onUpdateJwt = onUpdateJwt;
|
||||||
|
}
|
||||||
|
|
||||||
|
setJwtChecker(checker: () => string | null): void {
|
||||||
|
this.getJwt = checker;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSiteIdCheck(checker: () => { siteId: string | null }): void {
|
||||||
|
this.siteIdCheck = checker
|
||||||
}
|
}
|
||||||
|
|
||||||
private getInit(method: string = 'GET', params?: any, reqHeaders?: Record<string, any>): RequestInit {
|
private getInit(method: string = 'GET', params?: any, reqHeaders?: Record<string, any>): RequestInit {
|
||||||
// Always fetch the latest JWT from the store
|
// Always fetch the latest JWT from the store
|
||||||
const jwt = store.getState().getIn(['user', 'jwt']);
|
const jwt = this.getJwt()
|
||||||
const headers = new Headers({
|
const headers = new Headers({
|
||||||
'Accept': 'application/json',
|
'Accept': 'application/json',
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
|
@ -101,6 +114,9 @@ export default class APIClient {
|
||||||
delete init.body; // GET requests shouldn't have a body
|
delete init.body; // GET requests shouldn't have a body
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// /:id/path
|
||||||
|
// const idFromPath = window.location.pathname.split('/')[1];
|
||||||
|
this.siteId = this.siteIdCheck?.().siteId ?? undefined;
|
||||||
return init;
|
return init;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -131,7 +147,7 @@ export default class APIClient {
|
||||||
clean?: boolean
|
clean?: boolean
|
||||||
} = { clean: true }, headers?: Record<string, any>): Promise<Response> {
|
} = { clean: true }, headers?: Record<string, any>): Promise<Response> {
|
||||||
let _path = path;
|
let _path = path;
|
||||||
let jwt = store.getState().getIn(['user', 'jwt']);
|
let jwt = this.getJwt();
|
||||||
if (!path.includes('/refresh') && jwt && this.isTokenExpired(jwt)) {
|
if (!path.includes('/refresh') && jwt && this.isTokenExpired(jwt)) {
|
||||||
jwt = await this.handleTokenRefresh();
|
jwt = await this.handleTokenRefresh();
|
||||||
(this.init.headers as Headers).set('Authorization', `Bearer ${jwt}`);
|
(this.init.headers as Headers).set('Authorization', `Bearer ${jwt}`);
|
||||||
|
|
@ -158,9 +174,9 @@ export default class APIClient {
|
||||||
path !== '/targets_temp' &&
|
path !== '/targets_temp' &&
|
||||||
!path.includes('/metadata/session_search') &&
|
!path.includes('/metadata/session_search') &&
|
||||||
!path.includes('/assist/credentials') &&
|
!path.includes('/assist/credentials') &&
|
||||||
!!this.siteId &&
|
|
||||||
siteIdRequiredPaths.some(sidPath => path.startsWith(sidPath))
|
siteIdRequiredPaths.some(sidPath => path.startsWith(sidPath))
|
||||||
) {
|
) {
|
||||||
|
if (!this.siteId) console.trace('no id', path)
|
||||||
edp = `${edp}/${this.siteId}`;
|
edp = `${edp}/${this.siteId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -202,11 +218,11 @@ export default class APIClient {
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
const refreshedJwt = data.jwt;
|
const refreshedJwt = data.jwt;
|
||||||
store.dispatch(setJwt({ jwt: refreshedJwt, }));
|
this.onUpdateJwt({ jwt: refreshedJwt });
|
||||||
return refreshedJwt;
|
return refreshedJwt;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error refreshing token:', error);
|
console.error('Error refreshing token:', error);
|
||||||
store.dispatch(setJwt({ jwt: null }));
|
this.onUpdateJwt({ jwt: undefined });
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,60 +0,0 @@
|
||||||
import logger from 'App/logger';
|
|
||||||
import APIClient from './api_client';
|
|
||||||
import { FETCH_ACCOUNT, UPDATE_JWT } from 'Duck/user';
|
|
||||||
import { handleSpotJWT } from "App/utils";
|
|
||||||
|
|
||||||
export default () => {
|
|
||||||
return (next: any) => async (action: any) => {
|
|
||||||
const { types, call, ...rest } = action;
|
|
||||||
|
|
||||||
if (!call) {
|
|
||||||
return next(action);
|
|
||||||
}
|
|
||||||
|
|
||||||
const [REQUEST, SUCCESS, FAILURE] = types;
|
|
||||||
next({ ...rest, type: REQUEST });
|
|
||||||
|
|
||||||
try {
|
|
||||||
const client = new APIClient();
|
|
||||||
const response = await call(client);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const text = await response.text();
|
|
||||||
throw new Error(text);
|
|
||||||
}
|
|
||||||
|
|
||||||
const json = await response.json() || {}; // TEMP TODO on server: no empty responses
|
|
||||||
const { jwt, spotJwt, errors, data } = json;
|
|
||||||
|
|
||||||
if (errors) {
|
|
||||||
next({ type: FAILURE, errors, data });
|
|
||||||
} else {
|
|
||||||
next({ type: SUCCESS, data, ...rest });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (jwt) {
|
|
||||||
next({ type: UPDATE_JWT, data: { jwt } });
|
|
||||||
}
|
|
||||||
if (spotJwt) {
|
|
||||||
handleSpotJWT(spotJwt);
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (e) {
|
|
||||||
if (e.response?.status === 403) {
|
|
||||||
next({ type: FETCH_ACCOUNT.FAILURE });
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await e.response?.json();
|
|
||||||
logger.error('Error during API request. ', e);
|
|
||||||
return next({ type: FAILURE, errors: data ? parseError(data.errors) : [] });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export function parseError(e: any) {
|
|
||||||
try {
|
|
||||||
return [...JSON.parse(e).errors] || [];
|
|
||||||
} catch {
|
|
||||||
return Array.isArray(e) ? e : [e];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,28 +1,15 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { withRouter, RouteComponentProps } from 'react-router-dom';
|
|
||||||
import withPageTitle from 'HOCs/withPageTitle';
|
import withPageTitle from 'HOCs/withPageTitle';
|
||||||
import withPermissions from 'HOCs/withPermissions';
|
import withPermissions from 'HOCs/withPermissions';
|
||||||
import AssistRouter from './AssistRouter';
|
import AssistRouter from './AssistRouter';
|
||||||
import { connect } from 'react-redux';
|
|
||||||
|
|
||||||
interface Props extends RouteComponentProps {
|
function Assist() {
|
||||||
siteId: string;
|
|
||||||
history: any;
|
|
||||||
isEnterprise: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
function Assist(props: Props) {
|
|
||||||
return (
|
return (
|
||||||
<AssistRouter />
|
<AssistRouter />
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const Cont = connect((state: any) => ({
|
|
||||||
isEnterprise:
|
|
||||||
state.getIn(['user', 'account', 'edition']) === 'ee' ||
|
|
||||||
state.getIn(['user', 'authDetails', 'edition']) === 'ee'
|
|
||||||
}))(Assist);
|
|
||||||
|
|
||||||
export default withPageTitle('Assist - OpenReplay')(
|
export default withPageTitle('Assist - OpenReplay')(
|
||||||
withPermissions(['ASSIST_LIVE', 'SERVICE_ASSIST_LIVE'], '', false, false)(withRouter(Cont))
|
withPermissions(['ASSIST_LIVE', 'SERVICE_ASSIST_LIVE'], '', false, false)(Assist)
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,7 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { withRouter, RouteComponentProps } from 'react-router-dom';
|
|
||||||
import AssistView from './AssistView'
|
import AssistView from './AssistView'
|
||||||
|
|
||||||
interface Props extends RouteComponentProps {
|
function AssistRouter() {
|
||||||
match: any;
|
|
||||||
}
|
|
||||||
|
|
||||||
function AssistRouter(props: Props) {
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<AssistView />
|
<AssistView />
|
||||||
|
|
@ -14,4 +9,4 @@ function AssistRouter(props: Props) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default withRouter(AssistRouter);
|
export default AssistRouter;
|
||||||
|
|
|
||||||
|
|
@ -1,55 +1,46 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { connect } from 'react-redux';
|
|
||||||
|
|
||||||
import {
|
|
||||||
addFilterByKeyAndValue,
|
|
||||||
clearSearch,
|
|
||||||
edit as editFilter,
|
|
||||||
fetchFilterSearch,
|
|
||||||
} from 'Duck/liveSearch';
|
|
||||||
import { Button } from 'antd';
|
import { Button } from 'antd';
|
||||||
import { useModal } from 'App/components/Modal';
|
import { useModal } from 'App/components/Modal';
|
||||||
import SessionSearchField from 'Shared/SessionSearchField';
|
import SessionSearchField from 'Shared/SessionSearchField';
|
||||||
import { MODULES } from 'Components/Client/Modules';
|
import { MODULES } from 'Components/Client/Modules';
|
||||||
|
|
||||||
import AssistStats from '../../AssistStats';
|
import AssistStats from '../../AssistStats';
|
||||||
import Recordings from '../RecordingsList/Recordings'
|
import Recordings from '../RecordingsList/Recordings';
|
||||||
|
import { useStore } from 'App/mstore';
|
||||||
|
import { observer } from 'mobx-react-lite';
|
||||||
|
|
||||||
interface Props {
|
function AssistSearchField() {
|
||||||
appliedFilter: any;
|
const { searchStoreLive, userStore } = useStore();
|
||||||
fetchFilterSearch: any;
|
const modules = userStore.account.settings?.modules ?? [];
|
||||||
addFilterByKeyAndValue: any;
|
const isEnterprise = userStore.isEnterprise
|
||||||
clearSearch: any;
|
|
||||||
isEnterprise: boolean;
|
|
||||||
modules: string[]
|
|
||||||
}
|
|
||||||
function AssistSearchField(props: Props) {
|
|
||||||
const hasEvents =
|
const hasEvents =
|
||||||
props.appliedFilter.filters.filter((i: any) => i.isEvent).size > 0;
|
searchStoreLive.instance.filters.filter((i: any) => i.isEvent).length > 0;
|
||||||
const hasFilters =
|
const hasFilters =
|
||||||
props.appliedFilter.filters.filter((i: any) => !i.isEvent).size > 0;
|
searchStoreLive.instance.filters.filter((i: any) => !i.isEvent).length > 0;
|
||||||
const { showModal, hideModal } = useModal();
|
const { showModal } = useModal();
|
||||||
|
|
||||||
const showStats = () => {
|
const showStats = () => {
|
||||||
showModal(<AssistStats />, { right: true, width: 960 })
|
showModal(<AssistStats />, { right: true, width: 960 });
|
||||||
}
|
};
|
||||||
const showRecords = () => {
|
const showRecords = () => {
|
||||||
showModal(<Recordings />, { right: true, width: 960 })
|
showModal(<Recordings />, { right: true, width: 960 });
|
||||||
}
|
};
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center w-full gap-2">
|
<div className="flex items-center w-full gap-2">
|
||||||
<div style={{ width: '60%' }}>
|
<div style={{ width: '60%' }}>
|
||||||
<SessionSearchField />
|
<SessionSearchField />
|
||||||
</div>
|
</div>
|
||||||
{props.isEnterprise && props.modules.includes(MODULES.OFFLINE_RECORDINGS)
|
{isEnterprise && modules.includes(MODULES.OFFLINE_RECORDINGS)
|
||||||
? <Button type="primary" ghost onClick={showRecords}>Training Videos</Button> : null
|
? <Button type="primary" ghost onClick={showRecords}>Training Videos</Button> : null
|
||||||
}
|
}
|
||||||
<Button type="primary" ghost onClick={showStats} disabled={props.modules.includes(MODULES.ASSIST_STATS) || props.modules.includes(MODULES.ASSIST)}>Co-Browsing Reports</Button>
|
<Button type="primary" ghost onClick={showStats}
|
||||||
|
disabled={modules.includes(MODULES.ASSIST_STATS) || modules.includes(MODULES.ASSIST)}>Co-Browsing
|
||||||
|
Reports</Button>
|
||||||
<Button
|
<Button
|
||||||
type="link"
|
type="link"
|
||||||
className="ml-auto font-medium"
|
className="ml-auto font-medium"
|
||||||
disabled={!hasFilters && !hasEvents}
|
disabled={!hasFilters && !hasEvents}
|
||||||
onClick={() => props.clearSearch()}
|
onClick={() => searchStoreLive.clearSearch()}
|
||||||
>
|
>
|
||||||
Clear Search
|
Clear Search
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -57,18 +48,4 @@ function AssistSearchField(props: Props) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default connect(
|
export default observer(AssistSearchField);
|
||||||
(state: any) => ({
|
|
||||||
appliedFilter: state.getIn(['liveSearch', 'instance']),
|
|
||||||
modules: state.getIn(['user', 'account', 'settings', 'modules']) || [],
|
|
||||||
isEnterprise:
|
|
||||||
state.getIn(['user', 'account', 'edition']) === 'ee' ||
|
|
||||||
state.getIn(['user', 'authDetails', 'edition']) === 'ee'
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
fetchFilterSearch,
|
|
||||||
editFilter,
|
|
||||||
addFilterByKeyAndValue,
|
|
||||||
clearSearch,
|
|
||||||
}
|
|
||||||
)(AssistSearchField);
|
|
||||||
|
|
|
||||||
|
|
@ -4,17 +4,12 @@ import Select from 'Shared/Select';
|
||||||
import RecordingsSearch from './RecordingsSearch';
|
import RecordingsSearch from './RecordingsSearch';
|
||||||
import RecordingsList from './RecordingsList';
|
import RecordingsList from './RecordingsList';
|
||||||
import { useStore } from 'App/mstore';
|
import { useStore } from 'App/mstore';
|
||||||
import { connect } from 'react-redux';
|
|
||||||
import SelectDateRange from 'Shared/SelectDateRange/SelectDateRange';
|
import SelectDateRange from 'Shared/SelectDateRange/SelectDateRange';
|
||||||
import { observer } from 'mobx-react-lite';
|
import { observer } from 'mobx-react-lite';
|
||||||
|
|
||||||
interface Props {
|
function Recordings() {
|
||||||
userId: string;
|
const { recordingsStore, userStore } = useStore();
|
||||||
}
|
const userId = userStore.account.id;
|
||||||
|
|
||||||
function Recordings(props: Props) {
|
|
||||||
const { userId } = props;
|
|
||||||
const { recordingsStore } = useStore();
|
|
||||||
|
|
||||||
const recordingsOwner = [
|
const recordingsOwner = [
|
||||||
{ value: '0', label: 'All Videos' },
|
{ value: '0', label: 'All Videos' },
|
||||||
|
|
@ -51,6 +46,4 @@ function Recordings(props: Props) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default connect((state: any) => ({
|
export default observer(Recordings);
|
||||||
userId: state.getIn(['user', 'account', 'id'])
|
|
||||||
}))(observer(Recordings));
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { INDEXES } from 'App/constants/zindex';
|
import { INDEXES } from 'App/constants/zindex';
|
||||||
import { connect } from 'react-redux';
|
|
||||||
import { Button, Loader, Icon } from 'UI';
|
import { Button, Loader, Icon } from 'UI';
|
||||||
import { PlayerContext } from 'App/components/Session/playerContext';
|
import { PlayerContext } from 'App/components/Session/playerContext';
|
||||||
|
import { useStore } from "App/mstore";
|
||||||
|
import { observer } from 'mobx-react-lite';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
userDisplayName: string;
|
userDisplayName: string;
|
||||||
|
|
@ -42,7 +43,9 @@ const WIN_VARIANTS = {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
function RequestingWindow({ userDisplayName, getWindowType }: Props) {
|
function RequestingWindow({ getWindowType }: Props) {
|
||||||
|
const { sessionStore } = useStore();
|
||||||
|
const userDisplayName = sessionStore.current.userDisplayName;
|
||||||
const windowType = getWindowType()
|
const windowType = getWindowType()
|
||||||
if (!windowType) return;
|
if (!windowType) return;
|
||||||
const { player } = React.useContext(PlayerContext)
|
const { player } = React.useContext(PlayerContext)
|
||||||
|
|
@ -81,6 +84,4 @@ function RequestingWindow({ userDisplayName, getWindowType }: Props) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default connect((state: any) => ({
|
export default observer(RequestingWindow);
|
||||||
userDisplayName: state.getIn(['sessions', 'current']).userDisplayName,
|
|
||||||
}))(RequestingWindow);
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Button, Tooltip } from 'UI';
|
import { Button, Tooltip } from 'UI';
|
||||||
import { connect } from 'react-redux';
|
|
||||||
import cn from 'classnames';
|
import cn from 'classnames';
|
||||||
import ChatWindow from '../../ChatWindow';
|
import ChatWindow from '../../ChatWindow';
|
||||||
import { CallingState, ConnectionStatus, RemoteControlStatus, RequestLocalStream } from 'Player';
|
import { CallingState, ConnectionStatus, RemoteControlStatus, RequestLocalStream } from 'Player';
|
||||||
|
|
@ -12,6 +11,7 @@ import { confirm } from 'UI';
|
||||||
import stl from './AassistActions.module.css';
|
import stl from './AassistActions.module.css';
|
||||||
import ScreenRecorder from 'App/components/Session_/ScreenRecorder/ScreenRecorder';
|
import ScreenRecorder from 'App/components/Session_/ScreenRecorder/ScreenRecorder';
|
||||||
import { audioContextManager } from 'App/utils/screenRecorder';
|
import { audioContextManager } from 'App/utils/screenRecorder';
|
||||||
|
import { useStore } from "App/mstore";
|
||||||
|
|
||||||
function onReject() {
|
function onReject() {
|
||||||
toast.info(`Call was rejected.`);
|
toast.info(`Call was rejected.`);
|
||||||
|
|
@ -31,12 +31,9 @@ function onError(e: any) {
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
userId: string;
|
userId: string;
|
||||||
hasPermission: boolean;
|
|
||||||
isEnterprise: boolean;
|
|
||||||
isCallActive: boolean;
|
isCallActive: boolean;
|
||||||
agentIds: string[];
|
agentIds: string[];
|
||||||
userDisplayName: string;
|
userDisplayName: string;
|
||||||
agentId: number,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const AssistActionsPing = {
|
const AssistActionsPing = {
|
||||||
|
|
@ -52,15 +49,17 @@ const AssistActionsPing = {
|
||||||
|
|
||||||
function AssistActions({
|
function AssistActions({
|
||||||
userId,
|
userId,
|
||||||
hasPermission,
|
|
||||||
isEnterprise,
|
|
||||||
isCallActive,
|
isCallActive,
|
||||||
agentIds,
|
agentIds,
|
||||||
userDisplayName,
|
|
||||||
agentId,
|
|
||||||
}: Props) {
|
}: Props) {
|
||||||
// @ts-ignore ???
|
// @ts-ignore ???
|
||||||
const { player, store } = React.useContext<ILivePlayerContext>(PlayerContext);
|
const { player, store } = React.useContext<ILivePlayerContext>(PlayerContext);
|
||||||
|
const { sessionStore, userStore } = useStore();
|
||||||
|
const permissions = userStore.account.permissions || [];
|
||||||
|
const hasPermission = permissions.includes('ASSIST_CALL') || permissions.includes('SERVICE_ASSIST_CALL');
|
||||||
|
const isEnterprise = userStore.isEnterprise;
|
||||||
|
const agentId = userStore.account.id;
|
||||||
|
const userDisplayName = sessionStore.current.userDisplayName;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
assistManager: {
|
assistManager: {
|
||||||
|
|
@ -289,14 +288,4 @@ function AssistActions({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const con = connect((state: any) => {
|
export default observer(AssistActions);
|
||||||
const permissions = state.getIn(['user', 'account', 'permissions']) || [];
|
|
||||||
return {
|
|
||||||
hasPermission: permissions.includes('ASSIST_CALL') || permissions.includes('SERVICE_ASSIST_CALL'),
|
|
||||||
isEnterprise: state.getIn(['user', 'account', 'edition']) === 'ee',
|
|
||||||
userDisplayName: state.getIn(['sessions', 'current']).userDisplayName,
|
|
||||||
agentId: state.getIn(['user', 'account', 'id'])
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
export default con(observer(AssistActions));
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import React, { useEffect } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import { connect } from 'react-redux';
|
import { observer } from 'mobx-react-lite'
|
||||||
import { fetchLiveList } from 'Duck/sessions';
|
import { useStore } from 'App/mstore';
|
||||||
import { Loader, NoContent, Label } from 'UI';
|
import { Loader, NoContent, Label } from 'UI';
|
||||||
import SessionItem from 'Shared/SessionItem';
|
import SessionItem from 'Shared/SessionItem';
|
||||||
import { useModal } from 'App/components/Modal';
|
import { useModal } from 'App/components/Modal';
|
||||||
|
|
@ -11,16 +11,20 @@ interface Props {
|
||||||
list: any;
|
list: any;
|
||||||
session: any;
|
session: any;
|
||||||
userId: any;
|
userId: any;
|
||||||
fetchLiveList: (params: any) => void;
|
|
||||||
}
|
}
|
||||||
function SessionList(props: Props) {
|
function SessionList(props: Props) {
|
||||||
const { hideModal } = useModal();
|
const { hideModal } = useModal();
|
||||||
|
const { sessionStore } = useStore();
|
||||||
|
const fetchLiveList = sessionStore.fetchLiveSessions;
|
||||||
|
const session = sessionStore.current;
|
||||||
|
const list = sessionStore.liveSessions.filter((i: any) => i.userId === session.userId && i.sessionId !== session.sessionId);
|
||||||
|
const loading = sessionStore.loadingLiveSessions;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const params: any = {};
|
const params: any = {};
|
||||||
if (props.session.userId) {
|
if (props.session.userId) {
|
||||||
params.userId = props.session.userId;
|
params.userId = props.session.userId;
|
||||||
}
|
}
|
||||||
props.fetchLiveList(params);
|
void fetchLiveList(params);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -33,9 +37,9 @@ function SessionList(props: Props) {
|
||||||
{props.userId}'s <span className="color-gray-medium">Live Sessions</span>{' '}
|
{props.userId}'s <span className="color-gray-medium">Live Sessions</span>{' '}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Loader loading={props.loading}>
|
<Loader loading={loading}>
|
||||||
<NoContent
|
<NoContent
|
||||||
show={!props.loading && props.list.length === 0}
|
show={!loading && list.length === 0}
|
||||||
title={
|
title={
|
||||||
<div className="flex items-center justify-center flex-col">
|
<div className="flex items-center justify-center flex-col">
|
||||||
<AnimatedSVG name={ICONS.NO_LIVE_SESSIONS} size={60} />
|
<AnimatedSVG name={ICONS.NO_LIVE_SESSIONS} size={60} />
|
||||||
|
|
@ -45,7 +49,7 @@ function SessionList(props: Props) {
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
{props.list.map((session: any) => (
|
{list.map((session: any) => (
|
||||||
<div className="mb-6" key={session.sessionId}>
|
<div className="mb-6" key={session.sessionId}>
|
||||||
{session.pageTitle && session.pageTitle !== '' && (
|
{session.pageTitle && session.pageTitle !== '' && (
|
||||||
<div className="flex items-center mb-2">
|
<div className="flex items-center mb-2">
|
||||||
|
|
@ -65,14 +69,4 @@ function SessionList(props: Props) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default connect(
|
export default observer(SessionList);
|
||||||
(state: any) => {
|
|
||||||
const session = state.getIn(['sessions', 'current']);
|
|
||||||
return {
|
|
||||||
session,
|
|
||||||
list: state.getIn(['sessions', 'liveSessions']).filter((i: any) => i.userId === session.userId && i.sessionId !== session.sessionId),
|
|
||||||
loading: state.getIn(['sessions', 'fetchLiveListRequest', 'loading']),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
{ fetchLiveList }
|
|
||||||
)(SessionList);
|
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,7 @@ const CustomFieldForm = ({ field, saving, errors, edit, save, onSave, onClose, o
|
||||||
const mapStateToProps = (state) => ({
|
const mapStateToProps = (state) => ({
|
||||||
field: state.getIn(['customFields', 'instance']),
|
field: state.getIn(['customFields', 'instance']),
|
||||||
saving: state.getIn(['customFields', 'saveRequest', 'loading']),
|
saving: state.getIn(['customFields', 'saveRequest', 'loading']),
|
||||||
errors: state.getIn(['customFields', 'saveRequest', 'errors']),
|
errors: state.getIn(['customFields', 'saveRequest', 'errors'])
|
||||||
});
|
});
|
||||||
|
|
||||||
export default connect(mapStateToProps, { edit, save })(CustomFieldForm);
|
export default connect(mapStateToProps, { edit, save })(CustomFieldForm);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,92 @@
|
||||||
|
import React, { useRef, useState } from 'react';
|
||||||
|
import { Form, Input, confirm } from 'UI';
|
||||||
|
import styles from './customFieldForm.module.css';
|
||||||
|
import { useStore } from 'App/mstore';
|
||||||
|
import { useModal } from 'Components/Modal';
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
|
import { Button } from 'antd';
|
||||||
|
import { Trash } from 'UI/Icons';
|
||||||
|
import { observer } from 'mobx-react-lite';
|
||||||
|
|
||||||
|
interface CustomFieldFormProps {
|
||||||
|
siteId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CustomFieldForm: React.FC<CustomFieldFormProps> = ({ siteId }) => {
|
||||||
|
console.log('siteId', siteId);
|
||||||
|
const focusElementRef = useRef<HTMLInputElement>(null);
|
||||||
|
const { customFieldStore: store } = useStore();
|
||||||
|
const field = store.instance;
|
||||||
|
const { hideModal } = useModal();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const write = ({ target: { value, name } }: any) => store.edit({ [name]: value });
|
||||||
|
const exists = field?.exists();
|
||||||
|
|
||||||
|
const onDelete = async () => {
|
||||||
|
if (
|
||||||
|
await confirm({
|
||||||
|
header: 'Metadata',
|
||||||
|
confirmation: `Are you sure you want to remove?`
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
store.remove(siteId, field?.index!).then(() => {
|
||||||
|
hideModal();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSave = (field: any) => {
|
||||||
|
setLoading(true);
|
||||||
|
store.save(siteId, field).then((response) => {
|
||||||
|
if (!response || !response.errors || response.errors.size === 0) {
|
||||||
|
hideModal();
|
||||||
|
toast.success('Metadata added successfully!');
|
||||||
|
} else {
|
||||||
|
toast.error(response.errors[0]);
|
||||||
|
}
|
||||||
|
}).finally(() => {
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white h-screen overflow-y-auto">
|
||||||
|
<h3 className="p-5 text-2xl">{exists ? 'Update' : 'Add'} Metadata Field</h3>
|
||||||
|
<Form className={styles.wrapper}>
|
||||||
|
<Form.Field>
|
||||||
|
<label>{'Field Name'}</label>
|
||||||
|
<Input
|
||||||
|
ref={focusElementRef}
|
||||||
|
name="key"
|
||||||
|
value={field?.key}
|
||||||
|
onChange={write}
|
||||||
|
placeholder="Field Name"
|
||||||
|
maxLength={50}
|
||||||
|
/>
|
||||||
|
</Form.Field>
|
||||||
|
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Button
|
||||||
|
onClick={() => onSave(field)}
|
||||||
|
disabled={!field?.validate()}
|
||||||
|
loading={loading}
|
||||||
|
type="primary"
|
||||||
|
className="float-left mr-2"
|
||||||
|
>
|
||||||
|
{exists ? 'Update' : 'Add'}
|
||||||
|
</Button>
|
||||||
|
<Button data-hidden={!exists} onClick={hideModal}>
|
||||||
|
{'Cancel'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button type="text" icon={<Trash />} data-hidden={!exists} onClick={onDelete}></Button>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default observer(CustomFieldForm);
|
||||||
|
|
@ -14,124 +14,124 @@ import { useModal } from 'App/components/Modal';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
|
|
||||||
function CustomFields(props) {
|
function CustomFields(props) {
|
||||||
const [currentSite, setCurrentSite] = React.useState(props.sites.get(0));
|
const [currentSite, setCurrentSite] = React.useState(props.sites.get(0));
|
||||||
const [deletingItem, setDeletingItem] = React.useState(null);
|
const [deletingItem, setDeletingItem] = React.useState(null);
|
||||||
const { showModal, hideModal } = useModal();
|
const { showModal, hideModal } = useModal();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const activeSite = props.sites.get(0);
|
const activeSite = props.sites.get(0);
|
||||||
if (!activeSite) return;
|
if (!activeSite) return;
|
||||||
|
|
||||||
props.fetchList(activeSite.id);
|
props.fetchList(activeSite.id);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const save = (field) => {
|
const save = (field) => {
|
||||||
props.save(currentSite.id, field).then((response) => {
|
props.save(currentSite.id, field).then((response) => {
|
||||||
if (!response || !response.errors || response.errors.size === 0) {
|
if (!response || !response.errors || response.errors.size === 0) {
|
||||||
hideModal();
|
hideModal();
|
||||||
toast.success('Metadata added successfully!');
|
toast.success('Metadata added successfully!');
|
||||||
} else {
|
} else {
|
||||||
toast.error(response.errors[0]);
|
toast.error(response.errors[0]);
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const init = (field) => {
|
||||||
|
props.init(field);
|
||||||
|
showModal(<CustomFieldForm onClose={hideModal} onSave={save} onDelete={() => removeMetadata(field)} />);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onChangeSelect = ({ value }) => {
|
||||||
|
const site = props.sites.find((s) => s.id === value.value);
|
||||||
|
setCurrentSite(site);
|
||||||
|
props.fetchList(site.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeMetadata = async (field) => {
|
||||||
|
if (
|
||||||
|
await confirm({
|
||||||
|
header: 'Metadata',
|
||||||
|
confirmation: `Are you sure you want to remove?`
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
setDeletingItem(field.index);
|
||||||
|
props
|
||||||
|
.remove(currentSite.id, field.index)
|
||||||
|
.then(() => {
|
||||||
|
hideModal();
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setDeletingItem(null);
|
||||||
});
|
});
|
||||||
};
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const init = (field) => {
|
const { fields, loading } = props;
|
||||||
props.init(field);
|
return (
|
||||||
showModal(<CustomFieldForm onClose={hideModal} onSave={save} onDelete={() => removeMetadata(field)} />);
|
<div className="bg-white rounded-lg shadow-sm border p-5 ">
|
||||||
};
|
<div className={cn(styles.tabHeader)}>
|
||||||
|
<h3 className={cn(styles.tabTitle, 'text-2xl')}>{'Metadata'}</h3>
|
||||||
const onChangeSelect = ({ value }) => {
|
<div style={{ marginRight: '15px' }}>
|
||||||
const site = props.sites.find((s) => s.id === value.value);
|
<SiteDropdown value={currentSite && currentSite.id} onChange={onChangeSelect} />
|
||||||
setCurrentSite(site);
|
|
||||||
props.fetchList(site.id);
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeMetadata = async (field) => {
|
|
||||||
if (
|
|
||||||
await confirm({
|
|
||||||
header: 'Metadata',
|
|
||||||
confirmation: `Are you sure you want to remove?`,
|
|
||||||
})
|
|
||||||
) {
|
|
||||||
setDeletingItem(field.index);
|
|
||||||
props
|
|
||||||
.remove(currentSite.id, field.index)
|
|
||||||
.then(() => {
|
|
||||||
hideModal();
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
setDeletingItem(null);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const { fields, loading } = props;
|
|
||||||
return (
|
|
||||||
<div className="bg-white rounded-lg shadow-sm border p-5 ">
|
|
||||||
<div className={cn(styles.tabHeader)}>
|
|
||||||
<h3 className={cn(styles.tabTitle, 'text-2xl')}>{'Metadata'}</h3>
|
|
||||||
<div style={{ marginRight: '15px' }}>
|
|
||||||
<SiteDropdown value={currentSite && currentSite.id} onChange={onChangeSelect} />
|
|
||||||
</div>
|
|
||||||
<div className="ml-auto">
|
|
||||||
<Tooltip title="You've reached the limit of 10 metadata." disabled={fields.size < 10}>
|
|
||||||
<Button disabled={fields.size >= 10} variant="primary" onClick={() => init()}>Add Metadata</Button>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-base text-disabled-text flex px-5 items-center my-3">
|
|
||||||
<Icon name="info-circle-fill" className="mr-2" size={16} />
|
|
||||||
See additonal user information in sessions.
|
|
||||||
<a href="https://docs.openreplay.com/installation/metadata" className="link ml-1" target="_blank">Learn more</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Loader loading={loading}>
|
|
||||||
<NoContent
|
|
||||||
title={
|
|
||||||
<div className="flex flex-col items-center justify-center">
|
|
||||||
<AnimatedSVG name={ICONS.NO_METADATA} size={60} />
|
|
||||||
{/* <div className="mt-4" /> */}
|
|
||||||
<div className="text-center my-4">None added yet</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
size="small"
|
|
||||||
show={fields.size === 0}
|
|
||||||
>
|
|
||||||
<div className={styles.list}>
|
|
||||||
{fields
|
|
||||||
.filter((i) => i.index)
|
|
||||||
.map((field) => (
|
|
||||||
<>
|
|
||||||
<ListItem
|
|
||||||
disabled={deletingItem && deletingItem === field.index}
|
|
||||||
key={field._key}
|
|
||||||
field={field}
|
|
||||||
onEdit={init}
|
|
||||||
// onDelete={ () => removeMetadata(field) }
|
|
||||||
/>
|
|
||||||
<Divider className="m-0" />
|
|
||||||
</>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</NoContent>
|
|
||||||
</Loader>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
<div className="ml-auto">
|
||||||
|
<Tooltip title="You've reached the limit of 10 metadata." disabled={fields.size < 10}>
|
||||||
|
<Button disabled={fields.size >= 10} variant="primary" onClick={() => init()}>Add Metadata</Button>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-base text-disabled-text flex px-5 items-center my-3">
|
||||||
|
<Icon name="info-circle-fill" className="mr-2" size={16} />
|
||||||
|
See additonal user information in sessions.
|
||||||
|
<a href="https://docs.openreplay.com/installation/metadata" className="link ml-1" target="_blank">Learn more</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Loader loading={loading}>
|
||||||
|
<NoContent
|
||||||
|
title={
|
||||||
|
<div className="flex flex-col items-center justify-center">
|
||||||
|
<AnimatedSVG name={ICONS.NO_METADATA} size={60} />
|
||||||
|
{/* <div className="mt-4" /> */}
|
||||||
|
<div className="text-center my-4">None added yet</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
size="small"
|
||||||
|
show={fields.size === 0}
|
||||||
|
>
|
||||||
|
<div className={styles.list}>
|
||||||
|
{fields
|
||||||
|
.filter((i) => i.index)
|
||||||
|
.map((field) => (
|
||||||
|
<>
|
||||||
|
<ListItem
|
||||||
|
disabled={deletingItem && deletingItem === field.index}
|
||||||
|
key={field._key}
|
||||||
|
field={field}
|
||||||
|
onEdit={init}
|
||||||
|
// onDelete={ () => removeMetadata(field) }
|
||||||
|
/>
|
||||||
|
<Divider className="m-0" />
|
||||||
|
</>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</NoContent>
|
||||||
|
</Loader>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default connect(
|
export default connect(
|
||||||
(state) => ({
|
(state) => ({
|
||||||
fields: state.getIn(['customFields', 'list']).sortBy((i) => i.index),
|
fields: state.getIn(['customFields', 'list']).sortBy((i) => i.index),
|
||||||
field: state.getIn(['customFields', 'instance']),
|
field: state.getIn(['customFields', 'instance']),
|
||||||
loading: state.getIn(['customFields', 'fetchRequest', 'loading']),
|
loading: state.getIn(['customFields', 'fetchRequest', 'loading']),
|
||||||
sites: state.getIn(['site', 'list']),
|
sites: state.getIn(['site', 'list']),
|
||||||
errors: state.getIn(['customFields', 'saveRequest', 'errors']),
|
errors: state.getIn(['customFields', 'saveRequest', 'errors'])
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
init,
|
init,
|
||||||
fetchList,
|
fetchList,
|
||||||
save,
|
save,
|
||||||
remove,
|
remove
|
||||||
}
|
}
|
||||||
)(withPageTitle('Metadata - OpenReplay Preferences')(CustomFields));
|
)(withPageTitle('Metadata - OpenReplay Preferences')(CustomFields));
|
||||||
|
|
|
||||||
108
frontend/app/components/Client/CustomFields/CustomFields.tsx
Normal file
108
frontend/app/components/Client/CustomFields/CustomFields.tsx
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import cn from 'classnames';
|
||||||
|
import withPageTitle from 'HOCs/withPageTitle';
|
||||||
|
import { Button, Loader, NoContent, Icon, Tooltip, Divider } from 'UI';
|
||||||
|
import SiteDropdown from 'Shared/SiteDropdown';
|
||||||
|
import styles from './customFields.module.css';
|
||||||
|
import CustomFieldForm from './CustomFieldForm';
|
||||||
|
import ListItem from './ListItem';
|
||||||
|
import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG';
|
||||||
|
import { useModal } from 'App/components/Modal';
|
||||||
|
import { useStore } from 'App/mstore';
|
||||||
|
import { observer } from 'mobx-react-lite';
|
||||||
|
|
||||||
|
const CustomFields = () => {
|
||||||
|
const { customFieldStore: store, projectsStore } = useStore();
|
||||||
|
const sites = projectsStore.list;
|
||||||
|
const [currentSite, setCurrentSite] = useState(sites[0]);
|
||||||
|
const [deletingItem, setDeletingItem] = useState<number | null>(null);
|
||||||
|
const { showModal, hideModal } = useModal();
|
||||||
|
const fields = store.list;
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const activeSite = sites[0];
|
||||||
|
if (!activeSite) return;
|
||||||
|
|
||||||
|
setCurrentSite(activeSite);
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
store.fetchList(activeSite.id).finally(() => {
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
}, [sites]);
|
||||||
|
|
||||||
|
const handleInit = (field?: any) => {
|
||||||
|
console.log('field', field);
|
||||||
|
store.init(field);
|
||||||
|
showModal(<CustomFieldForm siteId={currentSite.id} />, {
|
||||||
|
title: field ? 'Edit Metadata' : 'Add Metadata', right: true
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onChangeSelect = ({ value }: { value: { value: number } }) => {
|
||||||
|
const site = sites.find((s: any) => s.id === value.value);
|
||||||
|
setCurrentSite(site);
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
store.fetchList(site.id).finally(() => {
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border p-5">
|
||||||
|
<div className={cn(styles.tabHeader)}>
|
||||||
|
<h3 className={cn(styles.tabTitle, 'text-2xl')}>{'Metadata'}</h3>
|
||||||
|
<div style={{ marginRight: '15px' }}>
|
||||||
|
<SiteDropdown value={currentSite && currentSite.id} onChange={onChangeSelect} />
|
||||||
|
</div>
|
||||||
|
<div className="ml-auto">
|
||||||
|
<Tooltip title="You've reached the limit of 10 metadata." disabled={fields.length < 10}>
|
||||||
|
<Button disabled={fields.length >= 10} variant="primary" onClick={() => handleInit()}>
|
||||||
|
Add Metadata
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-base text-disabled-text flex px-5 items-center my-3">
|
||||||
|
<Icon name="info-circle-fill" className="mr-2" size={16} />
|
||||||
|
See additional user information in sessions.
|
||||||
|
<a href="https://docs.openreplay.com/installation/metadata" className="link ml-1" target="_blank">
|
||||||
|
Learn more
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Loader loading={loading}>
|
||||||
|
<NoContent
|
||||||
|
title={
|
||||||
|
<div className="flex flex-col items-center justify-center">
|
||||||
|
<AnimatedSVG name={ICONS.NO_METADATA} size={60} />
|
||||||
|
<div className="text-center my-4">None added yet</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
size="small"
|
||||||
|
show={fields.length === 0}
|
||||||
|
>
|
||||||
|
<div className={styles.list}>
|
||||||
|
{fields
|
||||||
|
.filter((i: any) => i.index)
|
||||||
|
.map((field: any) => (
|
||||||
|
<>
|
||||||
|
<ListItem
|
||||||
|
disabled={deletingItem !== null && deletingItem === field.index}
|
||||||
|
key={field._key}
|
||||||
|
field={field}
|
||||||
|
onEdit={handleInit}
|
||||||
|
/>
|
||||||
|
<Divider className="m-0" />
|
||||||
|
</>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</NoContent>
|
||||||
|
</Loader>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default withPageTitle('Metadata - OpenReplay Preferences')(observer(CustomFields));
|
||||||
|
|
@ -4,23 +4,23 @@ import { Button } from 'UI';
|
||||||
import styles from './listItem.module.css';
|
import styles from './listItem.module.css';
|
||||||
|
|
||||||
const ListItem = ({ field, onEdit, disabled }) => {
|
const ListItem = ({ field, onEdit, disabled }) => {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'group hover:bg-active-blue flex items-center justify-between py-3 px-5 cursor-pointer',
|
'group hover:bg-active-blue flex items-center justify-between py-3 px-5 cursor-pointer',
|
||||||
field.index === 0 ? styles.preDefined : '',
|
field.index === 0 ? styles.preDefined : '',
|
||||||
{
|
{
|
||||||
[styles.disabled]: disabled,
|
[styles.disabled]: disabled
|
||||||
}
|
}
|
||||||
)}
|
)}
|
||||||
onClick={() => field.index != 0 && onEdit(field)}
|
onClick={() => field.index !== 0 && onEdit(field)}
|
||||||
>
|
>
|
||||||
<span>{field.key}</span>
|
<span>{field.key}</span>
|
||||||
<div className="invisible group-hover:visible" data-hidden={field.index === 0}>
|
<div className="invisible group-hover:visible" data-hidden={field.index === 0}>
|
||||||
<Button variant="text-primary" icon="pencil" />
|
<Button variant="text-primary" icon="pencil" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ListItem;
|
export default ListItem;
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
|
import { useStore } from "App/mstore";
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import DocLink from 'Shared/DocLink/DocLink';
|
import DocLink from 'Shared/DocLink/DocLink';
|
||||||
import AssistScript from './AssistScript';
|
import AssistScript from './AssistScript';
|
||||||
import AssistNpm from './AssistNpm';
|
import AssistNpm from './AssistNpm';
|
||||||
import { Tabs, CodeBlock } from 'UI';
|
import { Tabs, CodeBlock } from 'UI';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { connect } from 'react-redux';
|
import { observer } from 'mobx-react-lite'
|
||||||
|
|
||||||
const NPM = 'NPM';
|
const NPM = 'NPM';
|
||||||
const SCRIPT = 'SCRIPT';
|
const SCRIPT = 'SCRIPT';
|
||||||
|
|
@ -13,8 +14,11 @@ const TABS = [
|
||||||
{ key: NPM, text: NPM },
|
{ key: NPM, text: NPM },
|
||||||
];
|
];
|
||||||
|
|
||||||
const AssistDoc = (props) => {
|
const AssistDoc = () => {
|
||||||
const { projectKey } = props;
|
const { integrationsStore, projectsStore } = useStore();
|
||||||
|
const sites = projectsStore.list;
|
||||||
|
const siteId = integrationsStore.integrations.siteId
|
||||||
|
const projectKey = siteId ? sites.find((site) => site.id === siteId)?.projectKey : sites[0]?.projectKey
|
||||||
const [activeTab, setActiveTab] = useState(SCRIPT);
|
const [activeTab, setActiveTab] = useState(SCRIPT);
|
||||||
|
|
||||||
const renderActiveTab = () => {
|
const renderActiveTab = () => {
|
||||||
|
|
@ -53,10 +57,4 @@ const AssistDoc = (props) => {
|
||||||
|
|
||||||
AssistDoc.displayName = 'AssistDoc';
|
AssistDoc.displayName = 'AssistDoc';
|
||||||
|
|
||||||
export default connect((state) => {
|
export default observer(AssistDoc);
|
||||||
const siteId = state.getIn(['integrations', 'siteId']);
|
|
||||||
const sites = state.getIn(['site', 'list']);
|
|
||||||
return {
|
|
||||||
projectKey: sites.find((site) => site.get('id') === siteId).get('projectKey'),
|
|
||||||
};
|
|
||||||
})(AssistDoc);
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { tokenRE } from 'Types/integrations/bugsnagConfig';
|
import { tokenRE } from 'Types/integrations/bugsnagConfig';
|
||||||
import IntegrationForm from '../IntegrationForm';
|
import IntegrationForm from '../IntegrationForm';
|
||||||
import ProjectListDropdown from './ProjectListDropdown';
|
// import ProjectListDropdown from './ProjectListDropdown';
|
||||||
import DocLink from 'Shared/DocLink/DocLink';
|
import DocLink from 'Shared/DocLink/DocLink';
|
||||||
import IntegrationModalCard from 'Components/Client/Integrations/IntegrationModalCard';
|
import IntegrationModalCard from 'Components/Client/Integrations/IntegrationModalCard';
|
||||||
|
|
||||||
|
|
@ -31,7 +31,7 @@ const BugsnagForm = (props) => (
|
||||||
key: 'bugsnagProjectId',
|
key: 'bugsnagProjectId',
|
||||||
label: 'Project',
|
label: 'Project',
|
||||||
checkIfDisplayed: (config) => tokenRE.test(config.authorizationToken),
|
checkIfDisplayed: (config) => tokenRE.test(config.authorizationToken),
|
||||||
component: ProjectListDropdown
|
// component: ProjectListDropdown
|
||||||
}
|
}
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,20 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import { tokenRE } from 'Types/integrations/bugsnagConfig';
|
import { tokenRE } from 'Types/integrations/bugsnagConfig';
|
||||||
import { edit } from 'Duck/integrations/actions';
|
|
||||||
import Select from 'Shared/Select';
|
import Select from 'Shared/Select';
|
||||||
import { withRequest } from 'HOCs';
|
import { withRequest } from 'HOCs';
|
||||||
|
|
||||||
|
function ProjectListDropdown(props) {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
@connect(state => ({
|
@connect(state => ({
|
||||||
token: state.getIn([ 'bugsnag', 'instance', 'authorizationToken' ])
|
token: state.getIn([ 'bugsnag', 'instance', 'authorizationToken' ])
|
||||||
}), { edit })
|
}))
|
||||||
@withRequest({
|
@withRequest({
|
||||||
dataName: "projects",
|
dataName: "projects",
|
||||||
initialData: [],
|
initialData: [],
|
||||||
dataWrapper: (data = [], prevData) => {
|
dataWrapper: (data = []) => {
|
||||||
if (!Array.isArray(data)) throw new Error('Wrong responce format.');
|
if (!Array.isArray(data)) throw new Error('Wrong responce format.');
|
||||||
const withOrgName = data.length > 1;
|
const withOrgName = data.length > 1;
|
||||||
return data.reduce((accum, { name: orgName, projects }) => {
|
return data.reduce((accum, { name: orgName, projects }) => {
|
||||||
|
|
@ -35,15 +38,7 @@ export default class ProjectListDropdown extends React.PureComponent {
|
||||||
if (!tokenRE.test(token)) return;
|
if (!tokenRE.test(token)) return;
|
||||||
this.props.fetchProjectList({
|
this.props.fetchProjectList({
|
||||||
authorizationToken: token,
|
authorizationToken: token,
|
||||||
}).then(() => {
|
})
|
||||||
const { value, projects } = this.props;
|
|
||||||
const values = projects.map(p => p.id);
|
|
||||||
if (!values.includes(value) && values.length > 0) {
|
|
||||||
this.props.edit("bugsnag", {
|
|
||||||
projectId: values[0],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
componentDidUpdate(prevProps) {
|
componentDidUpdate(prevProps) {
|
||||||
if (prevProps.token !== this.props.token) {
|
if (prevProps.token !== this.props.token) {
|
||||||
|
|
|
||||||
|
|
@ -1,41 +1,53 @@
|
||||||
|
import {
|
||||||
|
ACCESS_KEY_ID_LENGTH,
|
||||||
|
SECRET_ACCESS_KEY_LENGTH,
|
||||||
|
} from 'Types/integrations/cloudwatchConfig';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { ACCESS_KEY_ID_LENGTH, SECRET_ACCESS_KEY_LENGTH } from 'Types/integrations/cloudwatchConfig';
|
|
||||||
|
import IntegrationModalCard from 'Components/Client/Integrations/IntegrationModalCard';
|
||||||
|
|
||||||
|
import DocLink from 'Shared/DocLink/DocLink';
|
||||||
|
|
||||||
import IntegrationForm from '../IntegrationForm';
|
import IntegrationForm from '../IntegrationForm';
|
||||||
import LogGroupDropdown from './LogGroupDropdown';
|
import LogGroupDropdown from './LogGroupDropdown';
|
||||||
import RegionDropdown from './RegionDropdown';
|
import RegionDropdown from './RegionDropdown';
|
||||||
import DocLink from 'Shared/DocLink/DocLink';
|
|
||||||
import IntegrationModalCard from 'Components/Client/Integrations/IntegrationModalCard';
|
|
||||||
|
|
||||||
const CloudwatchForm = (props) => (
|
const CloudwatchForm = (props) => (
|
||||||
<div className='bg-white h-screen overflow-y-auto' style={{ width: '350px' }}>
|
<div className="bg-white h-screen overflow-y-auto" style={{ width: '350px' }}>
|
||||||
<IntegrationModalCard title='Cloud Watch' icon='integrations/aws'
|
<IntegrationModalCard
|
||||||
description='Integrate CloudWatch to see backend logs and errors alongside session replay.' />
|
title="Cloud Watch"
|
||||||
<div className='p-5 border-b mb-4'>
|
icon="integrations/aws"
|
||||||
<div className='font-medium mb-1'>How it works?</div>
|
description="Integrate CloudWatch to see backend logs and errors alongside session replay."
|
||||||
|
/>
|
||||||
|
<div className="p-5 border-b mb-4">
|
||||||
|
<div className="font-medium mb-1">How it works?</div>
|
||||||
<ol className="list-decimal list-inside">
|
<ol className="list-decimal list-inside">
|
||||||
<li>Create a Service Account</li>
|
<li>Create a Service Account</li>
|
||||||
<li>Enter the details below</li>
|
<li>Enter the details below</li>
|
||||||
<li>Propagate openReplaySessionToken</li>
|
<li>Propagate openReplaySessionToken</li>
|
||||||
</ol>
|
</ol>
|
||||||
<DocLink className='mt-4' label='Integrate CloudWatch'
|
<DocLink
|
||||||
url='https://docs.openreplay.com/integrations/cloudwatch' />
|
className="mt-4"
|
||||||
|
label="Integrate CloudWatch"
|
||||||
|
url="https://docs.openreplay.com/integrations/cloudwatch"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<IntegrationForm
|
<IntegrationForm
|
||||||
{...props}
|
{...props}
|
||||||
name='cloudwatch'
|
name="cloudwatch"
|
||||||
formFields={[
|
formFields={[
|
||||||
{
|
{
|
||||||
key: 'awsAccessKeyId',
|
key: 'awsAccessKeyId',
|
||||||
label: 'AWS Access Key ID'
|
label: 'AWS Access Key ID',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'awsSecretAccessKey',
|
key: 'awsSecretAccessKey',
|
||||||
label: 'AWS Secret Access Key'
|
label: 'AWS Secret Access Key',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'region',
|
key: 'region',
|
||||||
label: 'Region',
|
label: 'Region',
|
||||||
component: RegionDropdown
|
component: RegionDropdown,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'logGroupName',
|
key: 'logGroupName',
|
||||||
|
|
@ -44,8 +56,8 @@ const CloudwatchForm = (props) => (
|
||||||
checkIfDisplayed: (config) =>
|
checkIfDisplayed: (config) =>
|
||||||
config.awsSecretAccessKey.length === SECRET_ACCESS_KEY_LENGTH &&
|
config.awsSecretAccessKey.length === SECRET_ACCESS_KEY_LENGTH &&
|
||||||
config.region !== '' &&
|
config.region !== '' &&
|
||||||
config.awsAccessKeyId.length === ACCESS_KEY_ID_LENGTH
|
config.awsAccessKeyId.length === ACCESS_KEY_ID_LENGTH,
|
||||||
}
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,77 +1,93 @@
|
||||||
import React from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import { connect } from 'react-redux';
|
|
||||||
import { ACCESS_KEY_ID_LENGTH, SECRET_ACCESS_KEY_LENGTH } from 'Types/integrations/cloudwatchConfig';
|
import { ACCESS_KEY_ID_LENGTH, SECRET_ACCESS_KEY_LENGTH } from 'Types/integrations/cloudwatchConfig';
|
||||||
import { edit } from 'Duck/integrations/actions';
|
import { observer } from 'mobx-react-lite';
|
||||||
|
import { useStore } from 'App/mstore';
|
||||||
import Select from 'Shared/Select';
|
import Select from 'Shared/Select';
|
||||||
import { withRequest } from 'HOCs';
|
import { integrationsService } from "App/services";
|
||||||
|
|
||||||
@connect(state => ({
|
const LogGroupDropdown = (props) => {
|
||||||
config: state.getIn([ 'cloudwatch', 'instance' ])
|
const { integrationsStore } = useStore();
|
||||||
}), { edit })
|
const config = integrationsStore.cloudwatch.instance;
|
||||||
@withRequest({
|
const edit = integrationsStore.cloudwatch.edit;
|
||||||
dataName: "values",
|
const {
|
||||||
initialData: [],
|
value,
|
||||||
resetBeforeRequest: true,
|
name,
|
||||||
requestName: "fetchLogGroups",
|
placeholder,
|
||||||
endpoint: '/integrations/cloudwatch/list_groups',
|
onChange,
|
||||||
method: 'POST',
|
} = props;
|
||||||
})
|
|
||||||
export default class LogGroupDropdown extends React.PureComponent {
|
const [values, setValues] = useState([]);
|
||||||
constructor(props) {
|
const [loading, setLoading] = useState(false);
|
||||||
super(props);
|
const [error, setError] = useState(false);
|
||||||
this.fetchLogGroups()
|
|
||||||
}
|
const { region, awsSecretAccessKey, awsAccessKeyId } = config;
|
||||||
fetchLogGroups() {
|
|
||||||
const { config } = this.props;
|
const fetchLogGroups = useCallback(() => {
|
||||||
if (config.region === "" ||
|
if (
|
||||||
config.awsSecretAccessKey.length !== SECRET_ACCESS_KEY_LENGTH ||
|
region === '' ||
|
||||||
config.awsAccessKeyId.length !== ACCESS_KEY_ID_LENGTH
|
awsSecretAccessKey.length !== SECRET_ACCESS_KEY_LENGTH ||
|
||||||
) return;
|
awsAccessKeyId.length !== ACCESS_KEY_ID_LENGTH
|
||||||
this.props.fetchLogGroups({
|
) {
|
||||||
region: config.region,
|
return;
|
||||||
awsSecretAccessKey: config.awsSecretAccessKey,
|
|
||||||
awsAccessKeyId: config.awsAccessKeyId,
|
|
||||||
}).then(() => {
|
|
||||||
const { value, values, name } = this.props;
|
|
||||||
if (!values.includes(value) && values.length > 0) {
|
|
||||||
this.props.edit("cloudwatch", {
|
|
||||||
[ name ]: values[0],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
componentDidUpdate(prevProps) {
|
|
||||||
const { config } = this.props;
|
|
||||||
if (prevProps.config.region !== config.region ||
|
|
||||||
prevProps.config.awsSecretAccessKey !== config.awsSecretAccessKey ||
|
|
||||||
prevProps.config.awsAccessKeyId !== config.awsAccessKeyId) {
|
|
||||||
this.fetchLogGroups();
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
onChange = (target) => {
|
setLoading(true);
|
||||||
if (typeof this.props.onChange === 'function') {
|
setError(false);
|
||||||
this.props.onChange({ target });
|
setValues([]); // Reset values before request
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
region: region,
|
||||||
|
awsSecretAccessKey: awsSecretAccessKey,
|
||||||
|
awsAccessKeyId: awsAccessKeyId,
|
||||||
|
};
|
||||||
|
|
||||||
|
integrationsService.client
|
||||||
|
.post('/integrations/cloudwatch/list_groups', params)
|
||||||
|
.then((response) => response.json())
|
||||||
|
.then(({ errors, data }) => {
|
||||||
|
if (errors) {
|
||||||
|
setError(true);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setValues(data);
|
||||||
|
setLoading(false);
|
||||||
|
|
||||||
|
// If current value is not in the new values list, update it
|
||||||
|
if (!data.includes(value) && data.length > 0) {
|
||||||
|
edit({
|
||||||
|
[name]: data[0],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setError(true);
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
}, [region, awsSecretAccessKey, awsAccessKeyId, value, name, edit]);
|
||||||
|
|
||||||
|
// Fetch log groups on mount and when config changes
|
||||||
|
useEffect(() => {
|
||||||
|
fetchLogGroups();
|
||||||
|
}, [fetchLogGroups]);
|
||||||
|
|
||||||
|
const handleChange = (target) => {
|
||||||
|
if (typeof onChange === 'function') {
|
||||||
|
onChange({ target });
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
render() {
|
|
||||||
const {
|
const options = values.map((g) => ({ text: g, value: g }));
|
||||||
values,
|
return (
|
||||||
name,
|
<Select
|
||||||
value,
|
options={options}
|
||||||
placeholder,
|
name={name}
|
||||||
loading,
|
value={options.find((o) => o.value === value)}
|
||||||
} = this.props;
|
placeholder={placeholder}
|
||||||
const options = values.map(g => ({ text: g, value: g }));
|
onChange={handleChange}
|
||||||
return (
|
loading={loading}
|
||||||
<Select
|
/>
|
||||||
// selection
|
);
|
||||||
options={ options }
|
};
|
||||||
name={ name }
|
|
||||||
value={ options.find(o => o.value === value) }
|
export default observer(LogGroupDropdown);
|
||||||
placeholder={ placeholder }
|
|
||||||
onChange={ this.onChange }
|
|
||||||
loading={ loading }
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,97 +1,64 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { connect } from 'react-redux';
|
|
||||||
import IntegrationForm from './IntegrationForm';
|
|
||||||
import { withRequest } from 'HOCs';
|
|
||||||
import { edit } from 'Duck/integrations/actions';
|
|
||||||
import DocLink from 'Shared/DocLink/DocLink';
|
|
||||||
import IntegrationModalCard from 'Components/Client/Integrations/IntegrationModalCard';
|
import IntegrationModalCard from 'Components/Client/Integrations/IntegrationModalCard';
|
||||||
|
|
||||||
@connect(
|
import DocLink from 'Shared/DocLink/DocLink';
|
||||||
(state) => ({
|
|
||||||
config: state.getIn(['elasticsearch', 'instance'])
|
|
||||||
}),
|
|
||||||
{ edit }
|
|
||||||
)
|
|
||||||
@withRequest({
|
|
||||||
dataName: 'isValid',
|
|
||||||
initialData: false,
|
|
||||||
dataWrapper: (data) => data.state,
|
|
||||||
requestName: 'validateConfig',
|
|
||||||
endpoint: '/integrations/elasticsearch/test',
|
|
||||||
method: 'POST'
|
|
||||||
})
|
|
||||||
export default class ElasticsearchForm extends React.PureComponent {
|
|
||||||
componentWillReceiveProps(newProps) {
|
|
||||||
const {
|
|
||||||
config: { host, port, apiKeyId, apiKey }
|
|
||||||
} = this.props;
|
|
||||||
const { loading, config } = newProps;
|
|
||||||
const valuesChanged = host !== config.host || port !== config.port || apiKeyId !== config.apiKeyId || apiKey !== config.apiKey;
|
|
||||||
if (!loading && valuesChanged && newProps.config.validateKeys() && newProps) {
|
|
||||||
this.validateConfig(newProps);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
validateConfig = (newProps) => {
|
import IntegrationForm from './IntegrationForm';
|
||||||
const { config } = newProps;
|
|
||||||
this.props
|
|
||||||
.validateConfig({
|
|
||||||
host: config.host,
|
|
||||||
port: config.port,
|
|
||||||
apiKeyId: config.apiKeyId,
|
|
||||||
apiKey: config.apiKey
|
|
||||||
})
|
|
||||||
.then((res) => {
|
|
||||||
const { isValid } = this.props;
|
|
||||||
this.props.edit('elasticsearch', { isValid: isValid });
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
render() {
|
const ElasticsearchForm = (props) => {
|
||||||
const props = this.props;
|
return (
|
||||||
return (
|
<div
|
||||||
<div className='bg-white h-screen overflow-y-auto' style={{ width: '350px' }}>
|
className="bg-white h-screen overflow-y-auto"
|
||||||
<IntegrationModalCard title='Elasticsearch' icon='integrations/elasticsearch'
|
style={{ width: '350px' }}
|
||||||
description='Integrate Elasticsearch with session replays to seamlessly observe backend errors.' />
|
>
|
||||||
|
<IntegrationModalCard
|
||||||
|
title="Elasticsearch"
|
||||||
|
icon="integrations/elasticsearch"
|
||||||
|
description="Integrate Elasticsearch with session replays to seamlessly observe backend errors."
|
||||||
|
/>
|
||||||
|
|
||||||
<div className='p-5 border-b mb-4'>
|
<div className="p-5 border-b mb-4">
|
||||||
<div className='font-medium mb-1'>How it works?</div>
|
<div className="font-medium mb-1">How it works?</div>
|
||||||
<ol className="list-decimal list-inside">
|
<ol className="list-decimal list-inside">
|
||||||
<li>Create a new Elastic API key</li>
|
<li>Create a new Elastic API key</li>
|
||||||
<li>Enter the API key below</li>
|
<li>Enter the API key below</li>
|
||||||
<li>Propagate openReplaySessionToken</li>
|
<li>Propagate openReplaySessionToken</li>
|
||||||
</ol>
|
</ol>
|
||||||
<DocLink className='mt-4' label='Integrate Elasticsearch'
|
<DocLink
|
||||||
url='https://docs.openreplay.com/integrations/elastic' />
|
className="mt-4"
|
||||||
</div>
|
label="Integrate Elasticsearch"
|
||||||
<IntegrationForm
|
url="https://docs.openreplay.com/integrations/elastic"
|
||||||
{...props}
|
|
||||||
name='elasticsearch'
|
|
||||||
formFields={[
|
|
||||||
{
|
|
||||||
key: 'host',
|
|
||||||
label: 'Host'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'apiKeyId',
|
|
||||||
label: 'API Key ID'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'apiKey',
|
|
||||||
label: 'API Key'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'indexes',
|
|
||||||
label: 'Indexes'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'port',
|
|
||||||
label: 'Port',
|
|
||||||
type: 'number'
|
|
||||||
}
|
|
||||||
]}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
<IntegrationForm
|
||||||
}
|
{...props}
|
||||||
}
|
name="elasticsearch"
|
||||||
|
formFields={[
|
||||||
|
{
|
||||||
|
key: 'host',
|
||||||
|
label: 'Host',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'apiKeyId',
|
||||||
|
label: 'API Key ID',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'apiKey',
|
||||||
|
label: 'API Key',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'indexes',
|
||||||
|
label: 'Indexes',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'port',
|
||||||
|
label: 'Port',
|
||||||
|
type: 'number',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ElasticsearchForm;
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,15 @@
|
||||||
|
import { useStore } from "App/mstore";
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { CodeBlock } from "UI";
|
import { CodeBlock } from "UI";
|
||||||
import DocLink from 'Shared/DocLink/DocLink';
|
import DocLink from 'Shared/DocLink/DocLink';
|
||||||
import ToggleContent from 'Shared/ToggleContent';
|
import ToggleContent from 'Shared/ToggleContent';
|
||||||
import { connect } from 'react-redux';
|
import { observer } from 'mobx-react-lite'
|
||||||
|
|
||||||
const GraphQLDoc = (props) => {
|
const GraphQLDoc = () => {
|
||||||
const { projectKey } = props;
|
const { integrationsStore, projectsStore } = useStore();
|
||||||
|
const sites = projectsStore.list;
|
||||||
|
const siteId = integrationsStore.integrations.siteId
|
||||||
|
const projectKey = siteId ? sites.find((site) => site.id === siteId)?.projectKey : sites[0]?.projectKey
|
||||||
const usage = `import OpenReplay from '@openreplay/tracker';
|
const usage = `import OpenReplay from '@openreplay/tracker';
|
||||||
import trackerGraphQL from '@openreplay/tracker-graphql';
|
import trackerGraphQL from '@openreplay/tracker-graphql';
|
||||||
//...
|
//...
|
||||||
|
|
@ -70,10 +74,4 @@ export const recordGraphQL = tracker.use(trackerGraphQL());`
|
||||||
|
|
||||||
GraphQLDoc.displayName = 'GraphQLDoc';
|
GraphQLDoc.displayName = 'GraphQLDoc';
|
||||||
|
|
||||||
export default connect((state) => {
|
export default observer(GraphQLDoc);
|
||||||
const siteId = state.getIn(['integrations', 'siteId']);
|
|
||||||
const sites = state.getIn(['site', 'list']);
|
|
||||||
return {
|
|
||||||
projectKey: sites.find((site) => site.get('id') === siteId).get('projectKey'),
|
|
||||||
};
|
|
||||||
})(GraphQLDoc);
|
|
||||||
|
|
|
||||||
|
|
@ -1,142 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import { connect } from 'react-redux';
|
|
||||||
import { Input, Form, Button, Checkbox, Loader } from 'UI';
|
|
||||||
import { save, init, edit, remove } from 'Duck/integrations/actions';
|
|
||||||
import { fetchIntegrationList } from 'Duck/integrations/integrations';
|
|
||||||
|
|
||||||
@connect(
|
|
||||||
(state, { name, customPath }) => ({
|
|
||||||
sites: state.getIn(['site', 'list']),
|
|
||||||
initialSiteId: state.getIn(['site', 'siteId']),
|
|
||||||
list: state.getIn([name, 'list']),
|
|
||||||
config: state.getIn([name, 'instance']),
|
|
||||||
loading: state.getIn([name, 'fetchRequest', 'loading']),
|
|
||||||
saving: state.getIn([customPath || name, 'saveRequest', 'loading']),
|
|
||||||
removing: state.getIn([name, 'removeRequest', 'loading']),
|
|
||||||
siteId: state.getIn(['integrations', 'siteId']),
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
save,
|
|
||||||
init,
|
|
||||||
edit,
|
|
||||||
remove,
|
|
||||||
// fetchList,
|
|
||||||
fetchIntegrationList,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
export default class IntegrationForm extends React.PureComponent {
|
|
||||||
constructor(props) {
|
|
||||||
super(props);
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchList = () => {
|
|
||||||
const { siteId, initialSiteId } = this.props;
|
|
||||||
if (!siteId) {
|
|
||||||
this.props.fetchIntegrationList(initialSiteId);
|
|
||||||
} else {
|
|
||||||
this.props.fetchIntegrationList(siteId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
write = ({ target: { value, name: key, type, checked } }) => {
|
|
||||||
if (type === 'checkbox') this.props.edit(this.props.name, { [key]: checked });
|
|
||||||
else this.props.edit(this.props.name, { [key]: value });
|
|
||||||
};
|
|
||||||
|
|
||||||
// onChangeSelect = ({ value }) => {
|
|
||||||
// const { sites, list, name } = this.props;
|
|
||||||
// const site = sites.find((s) => s.id === value.value);
|
|
||||||
// this.setState({ currentSiteId: site.id });
|
|
||||||
// this.init(value.value);
|
|
||||||
// };
|
|
||||||
|
|
||||||
// init = (siteId) => {
|
|
||||||
// const { list, name } = this.props;
|
|
||||||
// const config = parseInt(siteId) > 0 ? list.find((s) => s.projectId === siteId) : undefined;
|
|
||||||
// this.props.init(name, config ? config : list.first());
|
|
||||||
// };
|
|
||||||
|
|
||||||
save = () => {
|
|
||||||
const { config, name, customPath, ignoreProject } = this.props;
|
|
||||||
const isExists = config.exists();
|
|
||||||
// const { currentSiteId } = this.state;
|
|
||||||
this.props.save(customPath || name, !ignoreProject ? this.props.siteId : null, config).then(() => {
|
|
||||||
// this.props.fetchList(name);
|
|
||||||
this.fetchList();
|
|
||||||
this.props.onClose();
|
|
||||||
if (isExists) return;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
remove = () => {
|
|
||||||
const { name, config, ignoreProject } = this.props;
|
|
||||||
this.props.remove(name, !ignoreProject ? config.projectId : null).then(() => {
|
|
||||||
this.props.onClose();
|
|
||||||
this.fetchList();
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const { config, saving, removing, formFields, name, loading, integrated } = this.props;
|
|
||||||
return (
|
|
||||||
<Loader loading={loading}>
|
|
||||||
<div className="ph-20">
|
|
||||||
<Form>
|
|
||||||
{formFields.map(
|
|
||||||
({
|
|
||||||
key,
|
|
||||||
label,
|
|
||||||
placeholder = label,
|
|
||||||
component: Component = 'input',
|
|
||||||
type = 'text',
|
|
||||||
checkIfDisplayed,
|
|
||||||
autoFocus = false,
|
|
||||||
}) =>
|
|
||||||
(typeof checkIfDisplayed !== 'function' || checkIfDisplayed(config)) &&
|
|
||||||
(type === 'checkbox' ? (
|
|
||||||
<Form.Field key={key}>
|
|
||||||
<Checkbox
|
|
||||||
label={label}
|
|
||||||
name={key}
|
|
||||||
value={config[key]}
|
|
||||||
onChange={this.write}
|
|
||||||
placeholder={placeholder}
|
|
||||||
type={Component === 'input' ? type : null}
|
|
||||||
/>
|
|
||||||
</Form.Field>
|
|
||||||
) : (
|
|
||||||
<Form.Field key={key}>
|
|
||||||
<label>{label}</label>
|
|
||||||
<Input
|
|
||||||
name={key}
|
|
||||||
value={config[key]}
|
|
||||||
onChange={this.write}
|
|
||||||
placeholder={placeholder}
|
|
||||||
type={Component === 'input' ? type : null}
|
|
||||||
autoFocus={autoFocus}
|
|
||||||
/>
|
|
||||||
</Form.Field>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button
|
|
||||||
onClick={this.save}
|
|
||||||
disabled={!config.validate()}
|
|
||||||
loading={saving || loading}
|
|
||||||
variant="primary"
|
|
||||||
className="float-left mr-2"
|
|
||||||
>
|
|
||||||
{config.exists() ? 'Update' : 'Add'}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{integrated && (
|
|
||||||
<Button loading={removing} onClick={this.remove}>
|
|
||||||
{'Delete'}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</Form>
|
|
||||||
</div>
|
|
||||||
</Loader>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
107
frontend/app/components/Client/Integrations/IntegrationForm.tsx
Normal file
107
frontend/app/components/Client/Integrations/IntegrationForm.tsx
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
import { observer } from 'mobx-react-lite';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
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 initialSiteId = projectsStore.siteId;
|
||||||
|
const integrationStore = integrationsStore[name as unknown as namedStore];
|
||||||
|
const config = integrationStore.instance;
|
||||||
|
const loading = integrationStore.loading;
|
||||||
|
const onSave = integrationStore.saveIntegration;
|
||||||
|
const onRemove = integrationStore.deleteIntegration;
|
||||||
|
const edit = integrationStore.edit;
|
||||||
|
const fetchIntegrationList = integrationsStore.integrations.fetchIntegrations;
|
||||||
|
|
||||||
|
const fetchList = () => {
|
||||||
|
void fetchIntegrationList(initialSiteId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const write = ({ target: { value, name: key, type, checked } }) => {
|
||||||
|
if (type === 'checkbox') edit({ [key]: checked });
|
||||||
|
else edit({ [key]: value });
|
||||||
|
};
|
||||||
|
|
||||||
|
const save = () => {
|
||||||
|
const { name, customPath } = props;
|
||||||
|
onSave(customPath || name).then(() => {
|
||||||
|
fetchList();
|
||||||
|
props.onClose();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = () => {
|
||||||
|
onRemove().then(() => {
|
||||||
|
props.onClose();
|
||||||
|
fetchList();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Loader loading={loading}>
|
||||||
|
<div className="ph-20">
|
||||||
|
<Form>
|
||||||
|
{formFields.map(
|
||||||
|
({
|
||||||
|
key,
|
||||||
|
label,
|
||||||
|
placeholder = label,
|
||||||
|
component: Component = 'input',
|
||||||
|
type = 'text',
|
||||||
|
checkIfDisplayed,
|
||||||
|
autoFocus = false,
|
||||||
|
}) =>
|
||||||
|
(typeof checkIfDisplayed !== 'function' ||
|
||||||
|
checkIfDisplayed(config)) &&
|
||||||
|
(type === 'checkbox' ? (
|
||||||
|
<Form.Field key={key}>
|
||||||
|
<Checkbox
|
||||||
|
label={label}
|
||||||
|
name={key}
|
||||||
|
value={config[key]}
|
||||||
|
onChange={write}
|
||||||
|
placeholder={placeholder}
|
||||||
|
type={Component === 'input' ? type : null}
|
||||||
|
/>
|
||||||
|
</Form.Field>
|
||||||
|
) : (
|
||||||
|
<Form.Field key={key}>
|
||||||
|
<label>{label}</label>
|
||||||
|
<Input
|
||||||
|
name={key}
|
||||||
|
value={config[key]}
|
||||||
|
onChange={write}
|
||||||
|
placeholder={placeholder}
|
||||||
|
type={Component === 'input' ? type : null}
|
||||||
|
autoFocus={autoFocus}
|
||||||
|
/>
|
||||||
|
</Form.Field>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={save}
|
||||||
|
disabled={!config?.validate()}
|
||||||
|
loading={loading}
|
||||||
|
variant="primary"
|
||||||
|
className="float-left mr-2"
|
||||||
|
>
|
||||||
|
{config?.exists() ? 'Update' : 'Add'}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{integrated && (
|
||||||
|
<Button loading={loading} onClick={remove}>
|
||||||
|
{'Delete'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
</Loader>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default observer(IntegrationForm);
|
||||||
|
|
@ -1,88 +1,94 @@
|
||||||
import React, { useEffect, useState } from 'react';
|
|
||||||
import { connect } from 'react-redux';
|
|
||||||
import { useModal } from 'App/components/Modal';
|
|
||||||
import cn from 'classnames';
|
|
||||||
|
|
||||||
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 withPageTitle from 'HOCs/withPageTitle';
|
||||||
|
import cn from 'classnames';
|
||||||
|
import { observer } from 'mobx-react-lite';
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { useModal } from 'App/components/Modal';
|
||||||
|
import { useStore } from 'App/mstore';
|
||||||
|
import IntegrationFilters from 'Components/Client/Integrations/IntegrationFilters';
|
||||||
|
import { PageTitle } from 'UI';
|
||||||
|
|
||||||
|
import DocCard from 'Shared/DocCard/DocCard';
|
||||||
|
|
||||||
|
import AssistDoc from './AssistDoc';
|
||||||
import BugsnagForm from './BugsnagForm';
|
import BugsnagForm from './BugsnagForm';
|
||||||
import CloudwatchForm from './CloudwatchForm';
|
import CloudwatchForm from './CloudwatchForm';
|
||||||
import DatadogForm from './DatadogForm';
|
import DatadogForm from './DatadogForm';
|
||||||
import ElasticsearchForm from './ElasticsearchForm';
|
import ElasticsearchForm from './ElasticsearchForm';
|
||||||
import GithubForm from './GithubForm';
|
import GithubForm from './GithubForm';
|
||||||
|
import GraphQLDoc from './GraphQLDoc';
|
||||||
import IntegrationItem from './IntegrationItem';
|
import IntegrationItem from './IntegrationItem';
|
||||||
import JiraForm from './JiraForm';
|
import JiraForm from './JiraForm';
|
||||||
|
import MobxDoc from './MobxDoc';
|
||||||
import NewrelicForm from './NewrelicForm';
|
import NewrelicForm from './NewrelicForm';
|
||||||
|
import NgRxDoc from './NgRxDoc';
|
||||||
|
import PiniaDoc from './PiniaDoc';
|
||||||
|
import ProfilerDoc from './ProfilerDoc';
|
||||||
|
import ReduxDoc from './ReduxDoc';
|
||||||
import RollbarForm from './RollbarForm';
|
import RollbarForm from './RollbarForm';
|
||||||
import SentryForm from './SentryForm';
|
import SentryForm from './SentryForm';
|
||||||
import SlackForm from './SlackForm';
|
import SlackForm from './SlackForm';
|
||||||
import StackdriverForm from './StackdriverForm';
|
import StackdriverForm from './StackdriverForm';
|
||||||
import SumoLogicForm from './SumoLogicForm';
|
import SumoLogicForm from './SumoLogicForm';
|
||||||
import IntegrationFilters from 'Components/Client/Integrations/IntegrationFilters';
|
import MSTeams from './Teams';
|
||||||
|
import VueDoc from './VueDoc';
|
||||||
|
import ZustandDoc from './ZustandDoc';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
fetch: (name: string, siteId: string) => void;
|
|
||||||
init: () => void;
|
|
||||||
fetchIntegrationList: (siteId: any) => void;
|
|
||||||
integratedList: any;
|
|
||||||
initialSiteId: string;
|
|
||||||
setSiteId: (siteId: string) => void;
|
|
||||||
siteId: string;
|
siteId: string;
|
||||||
hideHeader?: boolean;
|
hideHeader?: boolean;
|
||||||
loading?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function Integrations(props: Props) {
|
function Integrations(props: Props) {
|
||||||
const { initialSiteId, hideHeader = false, loading = false } = props;
|
const { integrationsStore, projectsStore } = useStore();
|
||||||
|
const siteId = projectsStore.siteId;
|
||||||
|
const fetchIntegrationList = integrationsStore.integrations.fetchIntegrations;
|
||||||
|
const storeIntegratedList = integrationsStore.integrations.list;
|
||||||
|
const { hideHeader = false } = props;
|
||||||
const { showModal } = useModal();
|
const { showModal } = useModal();
|
||||||
const [integratedList, setIntegratedList] = useState<string[]>([]);
|
const [integratedList, setIntegratedList] = useState<string[]>([]);
|
||||||
const [activeFilter, setActiveFilter] = useState<string>('all');
|
const [activeFilter, setActiveFilter] = useState<string>('all');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const list = props.integratedList
|
const list = storeIntegratedList
|
||||||
.filter((item: any) => item.integrated)
|
.filter((item: any) => item.integrated)
|
||||||
.map((item: any) => item.name);
|
.map((item: any) => item.name);
|
||||||
setIntegratedList(list);
|
setIntegratedList(list);
|
||||||
}, [props.integratedList]);
|
}, [storeIntegratedList]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
props.fetchIntegrationList(initialSiteId);
|
void fetchIntegrationList(siteId);
|
||||||
props.setSiteId(initialSiteId);
|
}, [siteId]);
|
||||||
}, []);
|
|
||||||
|
|
||||||
const onClick = (integration: any, width: number) => {
|
const onClick = (integration: any, width: number) => {
|
||||||
if (integration.slug && integration.slug !== 'slack' && integration.slug !== 'msteams') {
|
if (
|
||||||
props.fetch(integration.slug, props.siteId);
|
integration.slug &&
|
||||||
|
integration.slug !== 'slack' &&
|
||||||
|
integration.slug !== 'msteams'
|
||||||
|
) {
|
||||||
|
const intName = integration.slug as
|
||||||
|
| 'sentry'
|
||||||
|
| 'bugsnag'
|
||||||
|
| 'rollbar'
|
||||||
|
| 'elasticsearch'
|
||||||
|
| 'datadog'
|
||||||
|
| 'sumologic'
|
||||||
|
| 'stackdriver'
|
||||||
|
| 'cloudwatch'
|
||||||
|
| 'newrelic';
|
||||||
|
if (integrationsStore[intName]) {
|
||||||
|
void integrationsStore[intName].fetchIntegration(siteId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
showModal(
|
showModal(
|
||||||
React.cloneElement(integration.component, {
|
React.cloneElement(integration.component, {
|
||||||
integrated: integratedList.includes(integration.slug)
|
integrated: integratedList.includes(integration.slug),
|
||||||
}),
|
}),
|
||||||
{ right: true, width }
|
{ right: true, width }
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onChangeSelect = ({ value }: any) => {
|
|
||||||
props.setSiteId(value.value);
|
|
||||||
props.fetchIntegrationList(value.value);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onChange = (key: string) => {
|
const onChange = (key: string) => {
|
||||||
setActiveFilter(key);
|
setActiveFilter(key);
|
||||||
};
|
};
|
||||||
|
|
@ -99,83 +105,92 @@ function Integrations(props: Props) {
|
||||||
key: cat.key,
|
key: cat.key,
|
||||||
title: cat.title,
|
title: cat.title,
|
||||||
label: cat.title,
|
label: cat.title,
|
||||||
icon: cat.icon
|
icon: cat.icon,
|
||||||
}))
|
}));
|
||||||
|
|
||||||
|
|
||||||
const allIntegrations = filteredIntegrations.flatMap(cat => cat.integrations);
|
|
||||||
|
|
||||||
|
const allIntegrations = filteredIntegrations.flatMap(
|
||||||
|
(cat) => cat.integrations
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
allIntegrations,
|
||||||
|
integratedList
|
||||||
|
)
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className='bg-white rounded-lg border shadow-sm p-5 mb-4'>
|
<div className="bg-white rounded-lg border shadow-sm p-5 mb-4">
|
||||||
{!hideHeader && <PageTitle title={<div>Integrations</div>} />}
|
{!hideHeader && <PageTitle title={<div>Integrations</div>} />}
|
||||||
|
|
||||||
<IntegrationFilters onChange={onChange} activeItem={activeFilter} filters={filters} />
|
<IntegrationFilters
|
||||||
|
onChange={onChange}
|
||||||
|
activeItem={activeFilter}
|
||||||
|
filters={filters}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='mb-4' />
|
<div className="mb-4" />
|
||||||
|
|
||||||
<div className={cn(`
|
<div
|
||||||
|
className={cn(`
|
||||||
mt-4 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3
|
mt-4 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3
|
||||||
`)}>
|
`)}
|
||||||
|
>
|
||||||
{allIntegrations.map((integration: any) => (
|
{allIntegrations.map((integration: any) => (
|
||||||
<IntegrationItem
|
<IntegrationItem
|
||||||
integrated={integratedList.includes(integration.slug)}
|
integrated={integratedList.includes(integration.slug)}
|
||||||
integration={integration}
|
integration={integration}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
onClick(integration, filteredIntegrations.find(cat => cat.integrations.includes(integration)).title === 'Plugins' ? 500 : 350)
|
onClick(
|
||||||
|
integration,
|
||||||
|
filteredIntegrations.find((cat) =>
|
||||||
|
cat.integrations.includes(integration)
|
||||||
|
).title === 'Plugins'
|
||||||
|
? 500
|
||||||
|
: 350
|
||||||
|
)
|
||||||
}
|
}
|
||||||
hide={
|
hide={
|
||||||
(integration.slug === 'github' &&
|
(integration.slug === 'github' &&
|
||||||
integratedList.includes('jira')) ||
|
integratedList.includes('jira')) ||
|
||||||
(integration.slug === 'jira' &&
|
(integration.slug === 'jira' && integratedList.includes('github'))
|
||||||
integratedList.includes('github'))
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default connect(
|
export default withPageTitle('Integrations - OpenReplay Preferences')(observer(Integrations))
|
||||||
(state: any) => ({
|
|
||||||
initialSiteId: state.getIn(['site', 'siteId']),
|
|
||||||
integratedList: state.getIn(['integrations', 'list']) || [],
|
|
||||||
loading: state.getIn(['integrations', 'fetchRequest', 'loading']),
|
|
||||||
siteId: state.getIn(['integrations', 'siteId'])
|
|
||||||
}),
|
|
||||||
{ fetch, init, fetchIntegrationList, setSiteId }
|
|
||||||
)(withPageTitle('Integrations - OpenReplay Preferences')(Integrations));
|
|
||||||
|
|
||||||
|
|
||||||
const integrations = [
|
const integrations = [
|
||||||
{
|
{
|
||||||
title: 'Issue Reporting',
|
title: 'Issue Reporting',
|
||||||
key: 'issue-reporting',
|
key: 'issue-reporting',
|
||||||
description: 'Seamlessly report issues or share issues with your team right from OpenReplay.',
|
description:
|
||||||
|
'Seamlessly report issues or share issues with your team right from OpenReplay.',
|
||||||
isProject: false,
|
isProject: false,
|
||||||
icon: 'exclamation-triangle',
|
icon: 'exclamation-triangle',
|
||||||
integrations: [
|
integrations: [
|
||||||
{
|
{
|
||||||
title: 'Jira',
|
title: 'Jira',
|
||||||
subtitle: 'Integrate Jira with OpenReplay to enable the creation of a new ticket directly from a session.',
|
subtitle:
|
||||||
|
'Integrate Jira with OpenReplay to enable the creation of a new ticket directly from a session.',
|
||||||
slug: 'jira',
|
slug: 'jira',
|
||||||
category: 'Errors',
|
category: 'Errors',
|
||||||
icon: 'integrations/jira',
|
icon: 'integrations/jira',
|
||||||
component: <JiraForm />
|
component: <JiraForm />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Github',
|
title: 'Github',
|
||||||
subtitle: 'Integrate GitHub with OpenReplay to enable the direct creation of a new issue from a session.',
|
subtitle:
|
||||||
|
'Integrate GitHub with OpenReplay to enable the direct creation of a new issue from a session.',
|
||||||
slug: 'github',
|
slug: 'github',
|
||||||
category: 'Errors',
|
category: 'Errors',
|
||||||
icon: 'integrations/github',
|
icon: 'integrations/github',
|
||||||
component: <GithubForm />
|
component: <GithubForm />,
|
||||||
}
|
},
|
||||||
]
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Backend Logging',
|
title: 'Backend Logging',
|
||||||
|
|
@ -186,106 +201,119 @@ const integrations = [
|
||||||
'Sync your backend errors with sessions replays and see what happened front-to-back.',
|
'Sync your backend errors with sessions replays and see what happened front-to-back.',
|
||||||
docs: () => (
|
docs: () => (
|
||||||
<DocCard
|
<DocCard
|
||||||
title='Why use integrations?'
|
title="Why use integrations?"
|
||||||
icon='question-lg'
|
icon="question-lg"
|
||||||
iconBgColor='bg-red-lightest'
|
iconBgColor="bg-red-lightest"
|
||||||
iconColor='red'
|
iconColor="red"
|
||||||
>
|
>
|
||||||
Sync your backend errors with sessions replays and see what happened front-to-back.
|
Sync your backend errors with sessions replays and see what happened
|
||||||
|
front-to-back.
|
||||||
</DocCard>
|
</DocCard>
|
||||||
),
|
),
|
||||||
integrations: [
|
integrations: [
|
||||||
{
|
{
|
||||||
title: 'Sentry',
|
title: 'Sentry',
|
||||||
subtitle: 'Integrate Sentry with session replays to seamlessly observe backend errors.',
|
subtitle:
|
||||||
|
'Integrate Sentry with session replays to seamlessly observe backend errors.',
|
||||||
slug: 'sentry',
|
slug: 'sentry',
|
||||||
icon: 'integrations/sentry',
|
icon: 'integrations/sentry',
|
||||||
component: <SentryForm />
|
component: <SentryForm />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Bugsnag',
|
title: 'Bugsnag',
|
||||||
subtitle: 'Integrate Bugsnag to access the OpenReplay session linked to the JS exception within its interface.',
|
subtitle:
|
||||||
|
'Integrate Bugsnag to access the OpenReplay session linked to the JS exception within its interface.',
|
||||||
slug: 'bugsnag',
|
slug: 'bugsnag',
|
||||||
icon: 'integrations/bugsnag',
|
icon: 'integrations/bugsnag',
|
||||||
component: <BugsnagForm />
|
component: <BugsnagForm />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Rollbar',
|
title: 'Rollbar',
|
||||||
subtitle: 'Integrate Rollbar with session replays to seamlessly observe backend errors.',
|
subtitle:
|
||||||
|
'Integrate Rollbar with session replays to seamlessly observe backend errors.',
|
||||||
slug: 'rollbar',
|
slug: 'rollbar',
|
||||||
icon: 'integrations/rollbar',
|
icon: 'integrations/rollbar',
|
||||||
component: <RollbarForm />
|
component: <RollbarForm />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Elasticsearch',
|
title: 'Elasticsearch',
|
||||||
subtitle: 'Integrate Elasticsearch with session replays to seamlessly observe backend errors.',
|
subtitle:
|
||||||
|
'Integrate Elasticsearch with session replays to seamlessly observe backend errors.',
|
||||||
slug: 'elasticsearch',
|
slug: 'elasticsearch',
|
||||||
icon: 'integrations/elasticsearch',
|
icon: 'integrations/elasticsearch',
|
||||||
component: <ElasticsearchForm />
|
component: <ElasticsearchForm />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Datadog',
|
title: 'Datadog',
|
||||||
subtitle: 'Incorporate DataDog to visualize backend errors alongside session replay, for easy troubleshooting.',
|
subtitle:
|
||||||
|
'Incorporate DataDog to visualize backend errors alongside session replay, for easy troubleshooting.',
|
||||||
slug: 'datadog',
|
slug: 'datadog',
|
||||||
icon: 'integrations/datadog',
|
icon: 'integrations/datadog',
|
||||||
component: <DatadogForm />
|
component: <DatadogForm />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Sumo Logic',
|
title: 'Sumo Logic',
|
||||||
subtitle: 'Integrate Sumo Logic with session replays to seamlessly observe backend errors.',
|
subtitle:
|
||||||
|
'Integrate Sumo Logic with session replays to seamlessly observe backend errors.',
|
||||||
slug: 'sumologic',
|
slug: 'sumologic',
|
||||||
icon: 'integrations/sumologic',
|
icon: 'integrations/sumologic',
|
||||||
component: <SumoLogicForm />
|
component: <SumoLogicForm />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Google Cloud',
|
title: 'Google Cloud',
|
||||||
subtitle: 'Integrate Google Cloud to view backend logs and errors in conjunction with session replay',
|
subtitle:
|
||||||
|
'Integrate Google Cloud to view backend logs and errors in conjunction with session replay',
|
||||||
slug: 'stackdriver',
|
slug: 'stackdriver',
|
||||||
icon: 'integrations/google-cloud',
|
icon: 'integrations/google-cloud',
|
||||||
component: <StackdriverForm />
|
component: <StackdriverForm />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'CloudWatch',
|
title: 'CloudWatch',
|
||||||
subtitle: 'Integrate CloudWatch to see backend logs and errors alongside session replay.',
|
subtitle:
|
||||||
|
'Integrate CloudWatch to see backend logs and errors alongside session replay.',
|
||||||
slug: 'cloudwatch',
|
slug: 'cloudwatch',
|
||||||
icon: 'integrations/aws',
|
icon: 'integrations/aws',
|
||||||
component: <CloudwatchForm />
|
component: <CloudwatchForm />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Newrelic',
|
title: 'Newrelic',
|
||||||
subtitle: 'Integrate NewRelic with session replays to seamlessly observe backend errors.',
|
subtitle:
|
||||||
|
'Integrate NewRelic with session replays to seamlessly observe backend errors.',
|
||||||
slug: 'newrelic',
|
slug: 'newrelic',
|
||||||
icon: 'integrations/newrelic',
|
icon: 'integrations/newrelic',
|
||||||
component: <NewrelicForm />
|
component: <NewrelicForm />,
|
||||||
}
|
},
|
||||||
]
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Collaboration',
|
title: 'Collaboration',
|
||||||
key: 'collaboration',
|
key: 'collaboration',
|
||||||
isProject: false,
|
isProject: false,
|
||||||
icon: 'file-code',
|
icon: 'file-code',
|
||||||
description: 'Share your sessions with your team and collaborate on issues.',
|
description:
|
||||||
|
'Share your sessions with your team and collaborate on issues.',
|
||||||
integrations: [
|
integrations: [
|
||||||
{
|
{
|
||||||
title: 'Slack',
|
title: 'Slack',
|
||||||
subtitle: 'Integrate Slack to empower every user in your org with the ability to send sessions to any Slack channel.',
|
subtitle:
|
||||||
|
'Integrate Slack to empower every user in your org with the ability to send sessions to any Slack channel.',
|
||||||
slug: 'slack',
|
slug: 'slack',
|
||||||
category: 'Errors',
|
category: 'Errors',
|
||||||
icon: 'integrations/slack',
|
icon: 'integrations/slack',
|
||||||
component: <SlackForm />,
|
component: <SlackForm />,
|
||||||
shared: true
|
shared: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'MS Teams',
|
title: 'MS Teams',
|
||||||
subtitle: 'Integrate MS Teams to empower every user in your org with the ability to send sessions to any MS Teams channel.',
|
subtitle:
|
||||||
|
'Integrate MS Teams to empower every user in your org with the ability to send sessions to any MS Teams channel.',
|
||||||
slug: 'msteams',
|
slug: 'msteams',
|
||||||
category: 'Errors',
|
category: 'Errors',
|
||||||
icon: 'integrations/teams',
|
icon: 'integrations/teams',
|
||||||
component: <MSTeams />,
|
component: <MSTeams />,
|
||||||
shared: true
|
shared: true,
|
||||||
}
|
},
|
||||||
]
|
],
|
||||||
},
|
},
|
||||||
// {
|
// {
|
||||||
// title: 'State Management',
|
// title: 'State Management',
|
||||||
|
|
@ -302,72 +330,82 @@ const integrations = [
|
||||||
icon: 'chat-left-text',
|
icon: 'chat-left-text',
|
||||||
docs: () => (
|
docs: () => (
|
||||||
<DocCard
|
<DocCard
|
||||||
title='What are plugins?'
|
title="What are plugins?"
|
||||||
icon='question-lg'
|
icon="question-lg"
|
||||||
iconBgColor='bg-red-lightest'
|
iconBgColor="bg-red-lightest"
|
||||||
iconColor='red'
|
iconColor="red"
|
||||||
>
|
>
|
||||||
Plugins capture your application’s store, monitor queries, track performance issues and even
|
Plugins capture your application’s store, monitor queries, track
|
||||||
assist your end user through live sessions.
|
performance issues and even assist your end user through live sessions.
|
||||||
</DocCard>
|
</DocCard>
|
||||||
),
|
),
|
||||||
description:
|
description:
|
||||||
'Reproduce issues as if they happened in your own browser. Plugins help capture your application\'s store, HTTP requeets, GraphQL queries, and more.',
|
"Reproduce issues as if they happened in your own browser. Plugins help capture your application's store, HTTP requeets, GraphQL queries, and more.",
|
||||||
integrations: [
|
integrations: [
|
||||||
{
|
{
|
||||||
title: 'Redux',
|
title: 'Redux',
|
||||||
subtitle: 'Capture Redux actions/state and inspect them later on while replaying session recordings.',
|
subtitle:
|
||||||
icon: 'integrations/redux', component: <ReduxDoc />
|
'Capture Redux actions/state and inspect them later on while replaying session recordings.',
|
||||||
|
icon: 'integrations/redux',
|
||||||
|
component: <ReduxDoc />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'VueX',
|
title: 'VueX',
|
||||||
subtitle: 'Capture VueX mutations/state and inspect them later on while replaying session recordings.',
|
subtitle:
|
||||||
|
'Capture VueX mutations/state and inspect them later on while replaying session recordings.',
|
||||||
icon: 'integrations/vuejs',
|
icon: 'integrations/vuejs',
|
||||||
component: <VueDoc />
|
component: <VueDoc />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Pinia',
|
title: 'Pinia',
|
||||||
subtitle: 'Capture Pinia mutations/state and inspect them later on while replaying session recordings.',
|
subtitle:
|
||||||
|
'Capture Pinia mutations/state and inspect them later on while replaying session recordings.',
|
||||||
icon: 'integrations/pinia',
|
icon: 'integrations/pinia',
|
||||||
component: <PiniaDoc />
|
component: <PiniaDoc />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'GraphQL',
|
title: 'GraphQL',
|
||||||
subtitle: 'Capture GraphQL requests and inspect them later on while replaying session recordings. This plugin is compatible with Apollo and Relay implementations.',
|
subtitle:
|
||||||
|
'Capture GraphQL requests and inspect them later on while replaying session recordings. This plugin is compatible with Apollo and Relay implementations.',
|
||||||
icon: 'integrations/graphql',
|
icon: 'integrations/graphql',
|
||||||
component: <GraphQLDoc />
|
component: <GraphQLDoc />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'NgRx',
|
title: 'NgRx',
|
||||||
subtitle: 'Capture NgRx actions/state and inspect them later on while replaying session recordings.\n',
|
subtitle:
|
||||||
|
'Capture NgRx actions/state and inspect them later on while replaying session recordings.\n',
|
||||||
icon: 'integrations/ngrx',
|
icon: 'integrations/ngrx',
|
||||||
component: <NgRxDoc />
|
component: <NgRxDoc />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'MobX',
|
title: 'MobX',
|
||||||
subtitle: 'Capture MobX mutations and inspect them later on while replaying session recordings.',
|
subtitle:
|
||||||
|
'Capture MobX mutations and inspect them later on while replaying session recordings.',
|
||||||
icon: 'integrations/mobx',
|
icon: 'integrations/mobx',
|
||||||
component: <MobxDoc />
|
component: <MobxDoc />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Profiler',
|
title: 'Profiler',
|
||||||
subtitle: 'Plugin allows you to measure your JS functions performance and capture both arguments and result for each call.',
|
subtitle:
|
||||||
|
'Plugin allows you to measure your JS functions performance and capture both arguments and result for each call.',
|
||||||
icon: 'integrations/openreplay',
|
icon: 'integrations/openreplay',
|
||||||
component: <ProfilerDoc />
|
component: <ProfilerDoc />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Assist',
|
title: 'Assist',
|
||||||
subtitle: 'OpenReplay Assist allows you to support your users by seeing their live screen and instantly hopping on call (WebRTC) with them without requiring any 3rd-party screen sharing software.\n',
|
subtitle:
|
||||||
|
'OpenReplay Assist allows you to support your users by seeing their live screen and instantly hopping on call (WebRTC) with them without requiring any 3rd-party screen sharing software.\n',
|
||||||
icon: 'integrations/openreplay',
|
icon: 'integrations/openreplay',
|
||||||
component: <AssistDoc />
|
component: <AssistDoc />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Zustand',
|
title: 'Zustand',
|
||||||
subtitle: 'Capture Zustand mutations/state and inspect them later on while replaying session recordings.',
|
subtitle:
|
||||||
|
'Capture Zustand mutations/state and inspect them later on while replaying session recordings.',
|
||||||
icon: 'integrations/zustand',
|
icon: 'integrations/zustand',
|
||||||
// header: '🐻',
|
// header: '🐻',
|
||||||
component: <ZustandDoc />
|
component: <ZustandDoc />,
|
||||||
}
|
},
|
||||||
]
|
],
|
||||||
}
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,15 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ToggleContent from 'Shared/ToggleContent';
|
import ToggleContent from 'Shared/ToggleContent';
|
||||||
import DocLink from 'Shared/DocLink/DocLink';
|
import DocLink from 'Shared/DocLink/DocLink';
|
||||||
import { connect } from 'react-redux';
|
|
||||||
import { CodeBlock } from "UI";
|
import { CodeBlock } from "UI";
|
||||||
|
import { useStore } from 'App/mstore';
|
||||||
|
import { observer } from 'mobx-react-lite';
|
||||||
|
|
||||||
const MobxDoc = (props) => {
|
const MobxDoc = () => {
|
||||||
const { projectKey } = props;
|
const { integrationsStore, projectsStore } = useStore();
|
||||||
|
const sites = projectsStore.list;
|
||||||
|
const siteId = integrationsStore.integrations.siteId
|
||||||
|
const projectKey = siteId ? sites.find((site) => site.id === siteId)?.projectKey : sites[0]?.projectKey
|
||||||
|
|
||||||
const mobxUsage = `import OpenReplay from '@openreplay/tracker';
|
const mobxUsage = `import OpenReplay from '@openreplay/tracker';
|
||||||
import trackerMobX from '@openreplay/tracker-mobx';
|
import trackerMobX from '@openreplay/tracker-mobx';
|
||||||
|
|
@ -67,10 +71,4 @@ function SomeFunctionalComponent() {
|
||||||
|
|
||||||
MobxDoc.displayName = 'MobxDoc';
|
MobxDoc.displayName = 'MobxDoc';
|
||||||
|
|
||||||
export default connect((state) => {
|
export default observer(MobxDoc)
|
||||||
const siteId = state.getIn(['integrations', 'siteId']);
|
|
||||||
const sites = state.getIn(['site', 'list']);
|
|
||||||
return {
|
|
||||||
projectKey: sites.find((site) => site.get('id') === siteId).get('projectKey'),
|
|
||||||
};
|
|
||||||
})(MobxDoc);
|
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,15 @@
|
||||||
|
import { useStore } from "App/mstore";
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { CodeBlock } from "UI";
|
import { CodeBlock } from "UI";
|
||||||
import ToggleContent from 'Shared/ToggleContent';
|
import ToggleContent from 'Shared/ToggleContent';
|
||||||
import DocLink from 'Shared/DocLink/DocLink';
|
import DocLink from 'Shared/DocLink/DocLink';
|
||||||
import { connect } from 'react-redux';
|
import { observer } from 'mobx-react-lite'
|
||||||
|
|
||||||
const NgRxDoc = (props) => {
|
const NgRxDoc = () => {
|
||||||
const { projectKey } = props;
|
const { integrationsStore, projectsStore } = useStore();
|
||||||
|
const sites = projectsStore.list;
|
||||||
|
const siteId = integrationsStore.integrations.siteId
|
||||||
|
const projectKey = siteId ? sites.find((site) => site.id === siteId)?.projectKey : sites[0]?.projectKey
|
||||||
const usage = `import { StoreModule } from '@ngrx/store';
|
const usage = `import { StoreModule } from '@ngrx/store';
|
||||||
import { reducers } from './reducers';
|
import { reducers } from './reducers';
|
||||||
import OpenReplay from '@openreplay/tracker';
|
import OpenReplay from '@openreplay/tracker';
|
||||||
|
|
@ -80,10 +84,4 @@ const metaReducers = [tracker.use(trackerNgRx(<options>))]; // check list of ava
|
||||||
|
|
||||||
NgRxDoc.displayName = 'NgRxDoc';
|
NgRxDoc.displayName = 'NgRxDoc';
|
||||||
|
|
||||||
export default connect((state) => {
|
export default observer(NgRxDoc);
|
||||||
const siteId = state.getIn(['integrations', 'siteId']);
|
|
||||||
const sites = state.getIn(['site', 'list']);
|
|
||||||
return {
|
|
||||||
projectKey: sites.find((site) => site.get('id') === siteId).get('projectKey'),
|
|
||||||
};
|
|
||||||
})(NgRxDoc);
|
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,19 @@
|
||||||
|
import { observer } from 'mobx-react-lite';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { CodeBlock } from "UI";
|
|
||||||
import ToggleContent from '../../../shared/ToggleContent';
|
|
||||||
import DocLink from 'Shared/DocLink/DocLink';
|
|
||||||
import { connect } from 'react-redux';
|
|
||||||
|
|
||||||
const PiniaDoc = (props) => {
|
import { useStore } from 'App/mstore';
|
||||||
const { projectKey } = props;
|
import ToggleContent from 'Components/shared/ToggleContent';
|
||||||
|
import { CodeBlock } from 'UI';
|
||||||
|
|
||||||
|
import DocLink from 'Shared/DocLink/DocLink';
|
||||||
|
|
||||||
|
const PiniaDoc = () => {
|
||||||
|
const { integrationsStore, projectsStore } = useStore();
|
||||||
|
const sites = projectsStore.list;
|
||||||
|
const siteId = integrationsStore.integrations.siteId;
|
||||||
|
const projectKey = siteId
|
||||||
|
? sites.find((site) => site.id === siteId)?.projectKey
|
||||||
|
: sites[0]?.projectKey;
|
||||||
const usage = `import Vuex from 'vuex'
|
const usage = `import Vuex from 'vuex'
|
||||||
import OpenReplay from '@openreplay/tracker';
|
import OpenReplay from '@openreplay/tracker';
|
||||||
import trackerVuex from '@openreplay/tracker-vuex';
|
import trackerVuex from '@openreplay/tracker-vuex';
|
||||||
|
|
@ -28,7 +36,7 @@ piniaStorePlugin(examplePiniaStore)
|
||||||
// now you can use examplePiniaStore as
|
// now you can use examplePiniaStore as
|
||||||
// usual pinia store
|
// usual pinia store
|
||||||
// (destructure values or return it as a whole etc)
|
// (destructure values or return it as a whole etc)
|
||||||
`
|
`;
|
||||||
const usageCjs = `import Vuex from 'vuex'
|
const usageCjs = `import Vuex from 'vuex'
|
||||||
import OpenReplay from '@openreplay/tracker/cjs';
|
import OpenReplay from '@openreplay/tracker/cjs';
|
||||||
import trackerVuex from '@openreplay/tracker-vuex/cjs';
|
import trackerVuex from '@openreplay/tracker-vuex/cjs';
|
||||||
|
|
@ -55,34 +63,38 @@ piniaStorePlugin(examplePiniaStore)
|
||||||
// now you can use examplePiniaStore as
|
// now you can use examplePiniaStore as
|
||||||
// usual pinia store
|
// usual pinia store
|
||||||
// (destructure values or return it as a whole etc)
|
// (destructure values or return it as a whole etc)
|
||||||
}`
|
}`;
|
||||||
return (
|
return (
|
||||||
<div className="bg-white h-screen overflow-y-auto" style={{ width: '500px' }}>
|
<div
|
||||||
|
className="bg-white h-screen overflow-y-auto"
|
||||||
|
style={{ width: '500px' }}
|
||||||
|
>
|
||||||
<h3 className="p-5 text-2xl">VueX</h3>
|
<h3 className="p-5 text-2xl">VueX</h3>
|
||||||
<div className="p-5">
|
<div className="p-5">
|
||||||
<div>
|
<div>
|
||||||
This plugin allows you to capture Pinia mutations + state and inspect them later on while
|
This plugin allows you to capture Pinia mutations + state and inspect
|
||||||
replaying session recordings. This is very useful for understanding and fixing issues.
|
them later on while replaying session recordings. This is very useful
|
||||||
|
for understanding and fixing issues.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="font-bold my-2 text-lg">Installation</div>
|
<div className="font-bold my-2 text-lg">Installation</div>
|
||||||
<CodeBlock code={`npm i @openreplay/tracker-vuex --save`} language="bash" />
|
<CodeBlock
|
||||||
|
code={`npm i @openreplay/tracker-vuex --save`}
|
||||||
|
language="bash"
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="font-bold my-2 text-lg">Usage</div>
|
<div className="font-bold my-2 text-lg">Usage</div>
|
||||||
<p>
|
<p>
|
||||||
Initialize the @openreplay/tracker package as usual and load the plugin into it. Then put
|
Initialize the @openreplay/tracker package as usual and load the
|
||||||
the generated plugin into your plugins field of your store.
|
plugin into it. Then put the generated plugin into your plugins field
|
||||||
|
of your store.
|
||||||
</p>
|
</p>
|
||||||
<div className="py-3" />
|
<div className="py-3" />
|
||||||
|
|
||||||
<ToggleContent
|
<ToggleContent
|
||||||
label="Server-Side-Rendered (SSR)?"
|
label="Server-Side-Rendered (SSR)?"
|
||||||
first={
|
first={<CodeBlock code={usage} language="js" />}
|
||||||
<CodeBlock code={usage} language="js" />
|
second={<CodeBlock code={usageCjs} language="js" />}
|
||||||
}
|
|
||||||
second={
|
|
||||||
<CodeBlock code={usageCjs} language="js" />
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<DocLink
|
<DocLink
|
||||||
|
|
@ -97,10 +109,4 @@ piniaStorePlugin(examplePiniaStore)
|
||||||
|
|
||||||
PiniaDoc.displayName = 'PiniaDoc';
|
PiniaDoc.displayName = 'PiniaDoc';
|
||||||
|
|
||||||
export default connect((state: any) => {
|
export default observer(PiniaDoc);
|
||||||
const siteId = state.getIn(['integrations', 'siteId']);
|
|
||||||
const sites = state.getIn(['site', 'list']);
|
|
||||||
return {
|
|
||||||
projectKey: sites.find((site: any) => site.get('id') === siteId).get('projectKey'),
|
|
||||||
};
|
|
||||||
})(PiniaDoc);
|
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,16 @@
|
||||||
|
import { useStore } from "App/mstore";
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { connect } from 'react-redux';
|
import { observer } from 'mobx-react-lite';
|
||||||
|
|
||||||
import { CodeBlock } from 'UI';
|
import { CodeBlock } from 'UI';
|
||||||
|
|
||||||
import DocLink from 'Shared/DocLink/DocLink';
|
import DocLink from 'Shared/DocLink/DocLink';
|
||||||
import ToggleContent from 'Shared/ToggleContent';
|
import ToggleContent from 'Shared/ToggleContent';
|
||||||
|
|
||||||
const ProfilerDoc = (props) => {
|
const ProfilerDoc = () => {
|
||||||
const { projectKey } = props;
|
const { integrationsStore, projectsStore } = useStore();
|
||||||
|
const sites = projectsStore.list;
|
||||||
|
const siteId = integrationsStore.integrations.siteId
|
||||||
|
const projectKey = siteId ? sites.find((site) => site.id === siteId)?.projectKey : sites[0]?.projectKey
|
||||||
|
|
||||||
const usage = `import OpenReplay from '@openreplay/tracker';
|
const usage = `import OpenReplay from '@openreplay/tracker';
|
||||||
import trackerProfiler from '@openreplay/tracker-profiler';
|
import trackerProfiler from '@openreplay/tracker-profiler';
|
||||||
|
|
@ -87,12 +90,4 @@ const fn = profiler('call_name')(() => {
|
||||||
|
|
||||||
ProfilerDoc.displayName = 'ProfilerDoc';
|
ProfilerDoc.displayName = 'ProfilerDoc';
|
||||||
|
|
||||||
export default connect((state) => {
|
export default observer(ProfilerDoc);
|
||||||
const siteId = state.getIn(['integrations', 'siteId']);
|
|
||||||
const sites = state.getIn(['site', 'list']);
|
|
||||||
return {
|
|
||||||
projectKey: sites
|
|
||||||
.find((site) => site.get('id') === siteId)
|
|
||||||
.get('projectKey'),
|
|
||||||
};
|
|
||||||
})(ProfilerDoc);
|
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,15 @@
|
||||||
|
import { useStore } from "App/mstore";
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { CodeBlock } from 'UI'
|
import { CodeBlock } from 'UI'
|
||||||
import ToggleContent from '../../../shared/ToggleContent';
|
import ToggleContent from 'Components/shared/ToggleContent';
|
||||||
import DocLink from 'Shared/DocLink/DocLink';
|
import DocLink from 'Shared/DocLink/DocLink';
|
||||||
import { connect } from 'react-redux';
|
import { observer } from 'mobx-react-lite'
|
||||||
|
|
||||||
const ReduxDoc = (props) => {
|
const ReduxDoc = () => {
|
||||||
const { projectKey } = props;
|
const { integrationsStore, projectsStore } = useStore();
|
||||||
|
const sites = projectsStore.list;
|
||||||
|
const siteId = integrationsStore.integrations.siteId
|
||||||
|
const projectKey = siteId ? sites.find((site) => site.id === siteId)?.projectKey : sites[0]?.projectKey
|
||||||
|
|
||||||
const usage = `import { applyMiddleware, createStore } from 'redux';
|
const usage = `import { applyMiddleware, createStore } from 'redux';
|
||||||
import OpenReplay from '@openreplay/tracker';
|
import OpenReplay from '@openreplay/tracker';
|
||||||
|
|
@ -74,10 +78,4 @@ const store = createStore(
|
||||||
|
|
||||||
ReduxDoc.displayName = 'ReduxDoc';
|
ReduxDoc.displayName = 'ReduxDoc';
|
||||||
|
|
||||||
export default connect((state) => {
|
export default observer(ReduxDoc);
|
||||||
const siteId = state.getIn(['integrations', 'siteId']);
|
|
||||||
const sites = state.getIn(['site', 'list']);
|
|
||||||
return {
|
|
||||||
projectKey: sites.find((site) => site.get('id') === siteId).get('projectKey'),
|
|
||||||
};
|
|
||||||
})(ReduxDoc);
|
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,35 @@
|
||||||
import React from 'react';
|
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 { Form, Input, Button, Message } from 'UI';
|
||||||
import { confirm } from 'UI';
|
import { confirm } from 'UI';
|
||||||
import { remove } from 'Duck/integrations/slack';
|
import { observer } from 'mobx-react-lite'
|
||||||
|
import { useStore } from 'App/mstore'
|
||||||
|
|
||||||
class SlackAddForm extends React.PureComponent {
|
function SlackAddForm(props) {
|
||||||
componentWillUnmount() {
|
const { onClose } = props;
|
||||||
this.props.init({});
|
const { integrationsStore } = useStore();
|
||||||
}
|
const instance = integrationsStore.slack.instance;
|
||||||
|
const saving = integrationsStore.slack.loading;
|
||||||
|
const errors = integrationsStore.slack.errors;
|
||||||
|
const edit = integrationsStore.slack.edit;
|
||||||
|
const onSave = integrationsStore.slack.saveIntegration;
|
||||||
|
const update = integrationsStore.slack.update;
|
||||||
|
const init = integrationsStore.slack.init;
|
||||||
|
const onRemove = integrationsStore.slack.removeInt;
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
return () => init({})
|
||||||
|
}, [])
|
||||||
|
|
||||||
save = () => {
|
|
||||||
const instance = this.props.instance;
|
const save = () => {
|
||||||
if (instance.exists()) {
|
if (instance.exists()) {
|
||||||
this.props.update(this.props.instance);
|
void update(instance);
|
||||||
} else {
|
} else {
|
||||||
this.props.save(this.props.instance);
|
void onSave(instance);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
remove = async (id) => {
|
const remove = async (id) => {
|
||||||
if (
|
if (
|
||||||
await confirm({
|
await confirm({
|
||||||
header: 'Confirm',
|
header: 'Confirm',
|
||||||
|
|
@ -27,79 +37,68 @@ class SlackAddForm extends React.PureComponent {
|
||||||
confirmation: `Are you sure you want to permanently delete this channel?`,
|
confirmation: `Are you sure you want to permanently delete this channel?`,
|
||||||
})
|
})
|
||||||
) {
|
) {
|
||||||
this.props.remove(id);
|
await onRemove(id);
|
||||||
|
onClose();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
write = ({ target: { name, value } }) => this.props.edit({ [name]: value });
|
const write = ({ target: { name, value } }) => edit({ [name]: value });
|
||||||
|
|
||||||
render() {
|
return (
|
||||||
const { instance, saving, errors, onClose } = this.props;
|
<div className="p-5" style={{ minWidth: '300px' }}>
|
||||||
return (
|
<Form>
|
||||||
<div className="p-5" style={{ minWidth: '300px' }}>
|
<Form.Field>
|
||||||
<Form>
|
<label>Name</label>
|
||||||
<Form.Field>
|
<Input
|
||||||
<label>Name</label>
|
name="name"
|
||||||
<Input
|
value={instance.name}
|
||||||
name="name"
|
onChange={write}
|
||||||
value={instance.name}
|
placeholder="Enter any name"
|
||||||
onChange={this.write}
|
type="text"
|
||||||
placeholder="Enter any name"
|
/>
|
||||||
type="text"
|
</Form.Field>
|
||||||
/>
|
<Form.Field>
|
||||||
</Form.Field>
|
<label>URL</label>
|
||||||
<Form.Field>
|
<Input
|
||||||
<label>URL</label>
|
name="endpoint"
|
||||||
<Input
|
value={instance.endpoint}
|
||||||
name="endpoint"
|
onChange={write}
|
||||||
value={instance.endpoint}
|
placeholder="Slack webhook URL"
|
||||||
onChange={this.write}
|
type="text"
|
||||||
placeholder="Slack webhook URL"
|
/>
|
||||||
type="text"
|
</Form.Field>
|
||||||
/>
|
<div className="flex justify-between">
|
||||||
</Form.Field>
|
<div className="flex">
|
||||||
<div className="flex justify-between">
|
<Button
|
||||||
<div className="flex">
|
onClick={save}
|
||||||
<Button
|
disabled={!instance.validate()}
|
||||||
onClick={this.save}
|
loading={saving}
|
||||||
disabled={!instance.validate()}
|
variant="primary"
|
||||||
loading={saving}
|
className="float-left mr-2"
|
||||||
variant="primary"
|
>
|
||||||
className="float-left mr-2"
|
{instance.exists() ? 'Update' : 'Add'}
|
||||||
>
|
|
||||||
{instance.exists() ? 'Update' : 'Add'}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button onClick={onClose}>{'Cancel'}</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button onClick={() => this.remove(instance.webhookId)} disabled={!instance.exists()}>
|
|
||||||
{'Delete'}
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
|
||||||
</Form>
|
|
||||||
|
|
||||||
{errors && (
|
<Button onClick={onClose}>{'Cancel'}</Button>
|
||||||
<div className="my-3">
|
|
||||||
{errors.map((error) => (
|
|
||||||
<Message visible={errors} size="mini" error key={error}>
|
|
||||||
{error}
|
|
||||||
</Message>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
<Button onClick={() => remove(instance.webhookId)} disabled={!instance.exists()}>
|
||||||
);
|
{'Delete'}
|
||||||
}
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
{errors && (
|
||||||
|
<div className="my-3">
|
||||||
|
{errors.map((error) => (
|
||||||
|
<Message visible={errors} size="mini" error key={error}>
|
||||||
|
{error}
|
||||||
|
</Message>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default connect(
|
export default observer(SlackAddForm);
|
||||||
(state) => ({
|
|
||||||
instance: state.getIn(['slack', 'instance']),
|
|
||||||
saving:
|
|
||||||
state.getIn(['slack', 'saveRequest', 'loading']) ||
|
|
||||||
state.getIn(['slack', 'updateRequest', 'loading']),
|
|
||||||
errors: state.getIn(['slack', 'saveRequest', 'errors']),
|
|
||||||
}),
|
|
||||||
{ edit, save, init, remove, update }
|
|
||||||
)(SlackAddForm);
|
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,16 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { connect } from 'react-redux';
|
|
||||||
import { NoContent } from 'UI';
|
import { NoContent } from 'UI';
|
||||||
import { remove, edit, init } from 'Duck/integrations/slack';
|
|
||||||
import DocLink from 'Shared/DocLink/DocLink';
|
import DocLink from 'Shared/DocLink/DocLink';
|
||||||
|
import { observer } from 'mobx-react-lite'
|
||||||
|
import { useStore } from 'App/mstore'
|
||||||
|
|
||||||
function SlackChannelList(props) {
|
function SlackChannelList(props) {
|
||||||
const { list } = props;
|
const { integrationsStore } = useStore();
|
||||||
|
const list = integrationsStore.slack.list;
|
||||||
|
const edit = integrationsStore.slack.edit;
|
||||||
|
|
||||||
const onEdit = (instance) => {
|
const onEdit = (instance) => {
|
||||||
props.edit(instance);
|
edit(instance.toData());
|
||||||
props.onEdit();
|
props.onEdit();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -24,7 +26,7 @@ function SlackChannelList(props) {
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
size="small"
|
size="small"
|
||||||
show={list.size === 0}
|
show={list.length === 0}
|
||||||
>
|
>
|
||||||
{list.map((c) => (
|
{list.map((c) => (
|
||||||
<div
|
<div
|
||||||
|
|
@ -43,9 +45,4 @@ function SlackChannelList(props) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default connect(
|
export default observer(SlackChannelList);
|
||||||
(state) => ({
|
|
||||||
list: state.getIn(['slack', 'list']),
|
|
||||||
}),
|
|
||||||
{ remove, edit, init }
|
|
||||||
)(SlackChannelList);
|
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,14 @@
|
||||||
import React, { useEffect } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import SlackChannelList from './SlackChannelList/SlackChannelList';
|
import SlackChannelList from './SlackChannelList/SlackChannelList';
|
||||||
import { fetchList, init } from 'Duck/integrations/slack';
|
|
||||||
import { connect } from 'react-redux';
|
|
||||||
import SlackAddForm from './SlackAddForm';
|
import SlackAddForm from './SlackAddForm';
|
||||||
import { Button } from 'UI';
|
import { Button } from 'UI';
|
||||||
|
import { observer } from 'mobx-react-lite'
|
||||||
|
import { useStore } from 'App/mstore'
|
||||||
|
|
||||||
interface Props {
|
const SlackForm = () => {
|
||||||
onEdit?: (integration: any) => void;
|
const { integrationsStore } = useStore();
|
||||||
istance: any;
|
const init = integrationsStore.slack.init;
|
||||||
fetchList: any;
|
const fetchList = integrationsStore.slack.fetchIntegrations;
|
||||||
init: any;
|
|
||||||
}
|
|
||||||
const SlackForm = (props: Props) => {
|
|
||||||
const [active, setActive] = React.useState(false);
|
const [active, setActive] = React.useState(false);
|
||||||
|
|
||||||
const onEdit = () => {
|
const onEdit = () => {
|
||||||
|
|
@ -20,11 +17,11 @@ const SlackForm = (props: Props) => {
|
||||||
|
|
||||||
const onNew = () => {
|
const onNew = () => {
|
||||||
setActive(true);
|
setActive(true);
|
||||||
props.init({});
|
init({});
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
props.fetchList();
|
void fetchList();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -47,9 +44,4 @@ const SlackForm = (props: Props) => {
|
||||||
|
|
||||||
SlackForm.displayName = 'SlackForm';
|
SlackForm.displayName = 'SlackForm';
|
||||||
|
|
||||||
export default connect(
|
export default observer(SlackForm);
|
||||||
(state: any) => ({
|
|
||||||
istance: state.getIn(['slack', 'instance']),
|
|
||||||
}),
|
|
||||||
{ fetchList, init }
|
|
||||||
)(SlackForm);
|
|
||||||
|
|
@ -1,36 +1,38 @@
|
||||||
|
import { observer } from 'mobx-react-lite';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { connect } from 'react-redux';
|
|
||||||
import { edit, save, init, update, remove } from 'Duck/integrations/teams';
|
import { useStore } from 'App/mstore';
|
||||||
import { Form, Input, Button, Message } from 'UI';
|
import { Button, Form, Input, Message } from 'UI';
|
||||||
import { confirm } from 'UI';
|
import { confirm } from 'UI';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
edit: (inst: any) => void;
|
|
||||||
save: (inst: any) => void;
|
|
||||||
init: (inst: any) => void;
|
|
||||||
update: (inst: any) => void;
|
|
||||||
remove: (id: string) => void;
|
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
instance: any;
|
|
||||||
saving: boolean;
|
|
||||||
errors: any;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class TeamsAddForm extends React.PureComponent<Props> {
|
function TeamsAddForm({ onClose }: Props) {
|
||||||
componentWillUnmount() {
|
const { integrationsStore } = useStore();
|
||||||
this.props.init({});
|
const instance = integrationsStore.msteams.instance;
|
||||||
}
|
const saving = integrationsStore.msteams.loading;
|
||||||
|
const errors = integrationsStore.msteams.errors;
|
||||||
|
const edit = integrationsStore.msteams.edit;
|
||||||
|
const onSave = integrationsStore.msteams.saveIntegration;
|
||||||
|
const init = integrationsStore.msteams.init;
|
||||||
|
const onRemove = integrationsStore.msteams.removeInt;
|
||||||
|
const update = integrationsStore.msteams.update;
|
||||||
|
|
||||||
save = () => {
|
React.useEffect(() => {
|
||||||
const instance = this.props.instance;
|
return () => init({});
|
||||||
if (instance.exists()) {
|
}, []);
|
||||||
this.props.update(this.props.instance);
|
|
||||||
|
const save = () => {
|
||||||
|
if (instance?.exists()) {
|
||||||
|
void update();
|
||||||
} else {
|
} else {
|
||||||
this.props.save(this.props.instance);
|
void onSave();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
remove = async (id: string) => {
|
const remove = async (id: string) => {
|
||||||
if (
|
if (
|
||||||
await confirm({
|
await confirm({
|
||||||
header: 'Confirm',
|
header: 'Confirm',
|
||||||
|
|
@ -38,80 +40,74 @@ class TeamsAddForm extends React.PureComponent<Props> {
|
||||||
confirmation: `Are you sure you want to permanently delete this channel?`,
|
confirmation: `Are you sure you want to permanently delete this channel?`,
|
||||||
})
|
})
|
||||||
) {
|
) {
|
||||||
this.props.remove(id);
|
void onRemove(id);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
write = ({ target: { name, value } }: { target: { name: string; value: string } }) =>
|
const write = ({
|
||||||
this.props.edit({ [name]: value });
|
target: { name, value },
|
||||||
|
}: {
|
||||||
|
target: { name: string; value: string };
|
||||||
|
}) => edit({ [name]: value });
|
||||||
|
|
||||||
render() {
|
return (
|
||||||
const { instance, saving, errors, onClose } = this.props;
|
<div className="p-5" style={{ minWidth: '300px' }}>
|
||||||
return (
|
<Form>
|
||||||
<div className="p-5" style={{ minWidth: '300px' }}>
|
<Form.Field>
|
||||||
<Form>
|
<label>Name</label>
|
||||||
<Form.Field>
|
<Input
|
||||||
<label>Name</label>
|
name="name"
|
||||||
<Input
|
value={instance?.name}
|
||||||
name="name"
|
onChange={write}
|
||||||
value={instance.name}
|
placeholder="Enter any name"
|
||||||
onChange={this.write}
|
type="text"
|
||||||
placeholder="Enter any name"
|
/>
|
||||||
type="text"
|
</Form.Field>
|
||||||
/>
|
<Form.Field>
|
||||||
</Form.Field>
|
<label>URL</label>
|
||||||
<Form.Field>
|
<Input
|
||||||
<label>URL</label>
|
name="endpoint"
|
||||||
<Input
|
value={instance?.endpoint}
|
||||||
name="endpoint"
|
onChange={write}
|
||||||
value={instance.endpoint}
|
placeholder="Teams webhook URL"
|
||||||
onChange={this.write}
|
type="text"
|
||||||
placeholder="Teams webhook URL"
|
/>
|
||||||
type="text"
|
</Form.Field>
|
||||||
/>
|
<div className="flex justify-between">
|
||||||
</Form.Field>
|
<div className="flex">
|
||||||
<div className="flex justify-between">
|
<Button
|
||||||
<div className="flex">
|
onClick={save}
|
||||||
<Button
|
disabled={!instance?.validate()}
|
||||||
onClick={this.save}
|
loading={saving}
|
||||||
disabled={!instance.validate()}
|
variant="primary"
|
||||||
loading={saving}
|
className="float-left mr-2"
|
||||||
variant="primary"
|
>
|
||||||
className="float-left mr-2"
|
{instance?.exists() ? 'Update' : 'Add'}
|
||||||
>
|
|
||||||
{instance.exists() ? 'Update' : 'Add'}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button onClick={onClose}>{'Cancel'}</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button onClick={() => this.remove(instance.webhookId)} disabled={!instance.exists()}>
|
|
||||||
{'Delete'}
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
|
||||||
</Form>
|
|
||||||
|
|
||||||
{errors && (
|
<Button onClick={onClose}>{'Cancel'}</Button>
|
||||||
<div className="my-3">
|
|
||||||
{errors.map((error: any) => (
|
|
||||||
<Message visible={errors} key={error}>
|
|
||||||
{error}
|
|
||||||
</Message>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
<Button
|
||||||
);
|
onClick={() => remove(instance?.webhookId)}
|
||||||
}
|
disabled={!instance.exists()}
|
||||||
|
>
|
||||||
|
{'Delete'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
{errors && (
|
||||||
|
<div className="my-3">
|
||||||
|
{errors.map((error: any) => (
|
||||||
|
<Message visible={errors} key={error}>
|
||||||
|
{error}
|
||||||
|
</Message>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default connect(
|
export default observer(TeamsAddForm);
|
||||||
(state: any) => ({
|
|
||||||
instance: state.getIn(['teams', 'instance']),
|
|
||||||
saving:
|
|
||||||
state.getIn(['teams', 'saveRequest', 'loading']) ||
|
|
||||||
state.getIn(['teams', 'updateRequest', 'loading']),
|
|
||||||
errors: state.getIn(['teams', 'saveRequest', 'errors']),
|
|
||||||
}),
|
|
||||||
{ edit, save, init, remove, update }
|
|
||||||
)(TeamsAddForm);
|
|
||||||
|
|
|
||||||
|
|
@ -1,51 +1,57 @@
|
||||||
|
import { observer } from 'mobx-react-lite';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { connect } from 'react-redux';
|
|
||||||
|
import { useStore } from 'App/mstore';
|
||||||
import { NoContent } from 'UI';
|
import { NoContent } from 'UI';
|
||||||
import { remove, edit, init } from 'Duck/integrations/teams';
|
|
||||||
import DocLink from 'Shared/DocLink/DocLink';
|
import DocLink from 'Shared/DocLink/DocLink';
|
||||||
|
|
||||||
function TeamsChannelList(props: { list: any, edit: (inst: any) => any, onEdit: () => void }) {
|
function TeamsChannelList(props: { onEdit: () => void }) {
|
||||||
const { list } = props;
|
const { integrationsStore } = useStore();
|
||||||
|
const list = integrationsStore.msteams.list;
|
||||||
|
const edit = integrationsStore.msteams.edit;
|
||||||
|
|
||||||
const onEdit = (instance: Record<string, any>) => {
|
const onEdit = (instance: Record<string, any>) => {
|
||||||
props.edit(instance);
|
edit(instance);
|
||||||
props.onEdit();
|
props.onEdit();
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
<NoContent
|
<NoContent
|
||||||
title={
|
title={
|
||||||
<div className="p-5 mb-4">
|
<div className="p-5 mb-4">
|
||||||
<div className="text-base text-left">
|
<div className="text-base text-left">
|
||||||
Integrate MS Teams with OpenReplay and share insights with the rest of the team, directly from the recording page.
|
Integrate MS Teams with OpenReplay and share insights with the
|
||||||
</div>
|
rest of the team, directly from the recording page.
|
||||||
<DocLink className="mt-4 text-base" label="Integrate MS Teams" url="https://docs.openreplay.com/integrations/msteams" />
|
</div>
|
||||||
</div>
|
<DocLink
|
||||||
}
|
className="mt-4 text-base"
|
||||||
size="small"
|
label="Integrate MS Teams"
|
||||||
show={list.size === 0}
|
url="https://docs.openreplay.com/integrations/msteams"
|
||||||
>
|
/>
|
||||||
{list.map((c: any) => (
|
</div>
|
||||||
<div
|
}
|
||||||
key={c.webhookId}
|
size="small"
|
||||||
className="border-t px-5 py-2 flex items-center justify-between cursor-pointer hover:bg-active-blue"
|
show={list.length === 0}
|
||||||
onClick={() => onEdit(c)}
|
>
|
||||||
>
|
{list.map((c: any) => (
|
||||||
<div className="flex-grow-0" style={{ maxWidth: '90%' }}>
|
<div
|
||||||
<div>{c.name}</div>
|
key={c.webhookId}
|
||||||
<div className="truncate test-xs color-gray-medium">{c.endpoint}</div>
|
className="border-t px-5 py-2 flex items-center justify-between cursor-pointer hover:bg-active-blue"
|
||||||
</div>
|
onClick={() => onEdit(c)}
|
||||||
</div>
|
>
|
||||||
))}
|
<div className="flex-grow-0" style={{ maxWidth: '90%' }}>
|
||||||
</NoContent>
|
<div>{c.name}</div>
|
||||||
</div>
|
<div className="truncate test-xs color-gray-medium">
|
||||||
);
|
{c.endpoint}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</NoContent>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default connect(
|
export default observer(TeamsChannelList);
|
||||||
(state: any) => ({
|
|
||||||
list: state.getIn(['teams', 'list']),
|
|
||||||
}),
|
|
||||||
{ remove, edit, init }
|
|
||||||
)(TeamsChannelList);
|
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,15 @@
|
||||||
import React, { useEffect } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import TeamsChannelList from './TeamsChannelList';
|
import TeamsChannelList from './TeamsChannelList';
|
||||||
import { fetchList, init } from 'Duck/integrations/teams';
|
import { useStore } from 'App/mstore';
|
||||||
import { connect } from 'react-redux';
|
import { observer } from 'mobx-react-lite';
|
||||||
|
|
||||||
import TeamsAddForm from './TeamsAddForm';
|
import TeamsAddForm from './TeamsAddForm';
|
||||||
import { Button } from 'UI';
|
import { Button } from 'UI';
|
||||||
|
|
||||||
interface Props {
|
const MSTeams = () => {
|
||||||
onEdit?: (integration: any) => void;
|
const { integrationsStore } = useStore();
|
||||||
istance: any;
|
const fetchList = integrationsStore.msteams.fetchIntegrations;
|
||||||
fetchList: any;
|
const init = integrationsStore.msteams.init;
|
||||||
init: any;
|
|
||||||
}
|
|
||||||
const MSTeams = (props: Props) => {
|
|
||||||
const [active, setActive] = React.useState(false);
|
const [active, setActive] = React.useState(false);
|
||||||
|
|
||||||
const onEdit = () => {
|
const onEdit = () => {
|
||||||
|
|
@ -20,11 +18,11 @@ const MSTeams = (props: Props) => {
|
||||||
|
|
||||||
const onNew = () => {
|
const onNew = () => {
|
||||||
setActive(true);
|
setActive(true);
|
||||||
props.init({});
|
init({});
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
props.fetchList();
|
void fetchList();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -47,9 +45,4 @@ const MSTeams = (props: Props) => {
|
||||||
|
|
||||||
MSTeams.displayName = 'MSTeams';
|
MSTeams.displayName = 'MSTeams';
|
||||||
|
|
||||||
export default connect(
|
export default observer(MSTeams);
|
||||||
(state: any) => ({
|
|
||||||
istance: state.getIn(['teams', 'instance']),
|
|
||||||
}),
|
|
||||||
{ fetchList, init }
|
|
||||||
)(MSTeams);
|
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,15 @@
|
||||||
|
import { useStore } from "App/mstore";
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { CodeBlock } from "UI";
|
import { CodeBlock } from "UI";
|
||||||
import ToggleContent from '../../../shared/ToggleContent';
|
import ToggleContent from 'Components/shared/ToggleContent';
|
||||||
import DocLink from 'Shared/DocLink/DocLink';
|
import DocLink from 'Shared/DocLink/DocLink';
|
||||||
import { connect } from 'react-redux';
|
import { observer } from 'mobx-react-lite';
|
||||||
|
|
||||||
const VueDoc = (props) => {
|
const VueDoc = () => {
|
||||||
const { projectKey, siteId } = props;
|
const { integrationsStore, projectsStore } = useStore();
|
||||||
|
const sites = projectsStore.list;
|
||||||
|
const siteId = integrationsStore.integrations.siteId
|
||||||
|
const projectKey = siteId ? sites.find((site) => site.id === siteId)?.projectKey : sites[0]?.projectKey
|
||||||
|
|
||||||
const usage = `import Vuex from 'vuex'
|
const usage = `import Vuex from 'vuex'
|
||||||
import OpenReplay from '@openreplay/tracker';
|
import OpenReplay from '@openreplay/tracker';
|
||||||
|
|
@ -81,10 +85,4 @@ const store = new Vuex.Store({
|
||||||
|
|
||||||
VueDoc.displayName = 'VueDoc';
|
VueDoc.displayName = 'VueDoc';
|
||||||
|
|
||||||
export default connect((state) => {
|
export default observer(VueDoc);
|
||||||
const siteId = state.getIn(['integrations', 'siteId']);
|
|
||||||
const sites = state.getIn(['site', 'list']);
|
|
||||||
return {
|
|
||||||
projectKey: sites.find((site) => site.get('id') === siteId).get('projectKey'),
|
|
||||||
};
|
|
||||||
})(VueDoc);
|
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,15 @@
|
||||||
|
import { useStore } from "App/mstore";
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { CodeBlock } from "UI";
|
import { CodeBlock } from "UI";
|
||||||
import ToggleContent from '../../../shared/ToggleContent';
|
import ToggleContent from 'Components//shared/ToggleContent';
|
||||||
import DocLink from 'Shared/DocLink/DocLink';
|
import DocLink from 'Shared/DocLink/DocLink';
|
||||||
import { connect } from 'react-redux';
|
import { observer } from 'mobx-react-lite'
|
||||||
|
|
||||||
const ZustandDoc = (props) => {
|
const ZustandDoc = (props) => {
|
||||||
const { projectKey } = props;
|
const { integrationsStore, projectsStore } = useStore();
|
||||||
|
const sites = projectsStore.list;
|
||||||
|
const siteId = integrationsStore.integrations.siteId
|
||||||
|
const projectKey = siteId ? sites.find((site) => site.id === siteId)?.projectKey : sites[0]?.projectKey
|
||||||
|
|
||||||
const usage = `import create from "zustand";
|
const usage = `import create from "zustand";
|
||||||
import Tracker from '@openreplay/tracker';
|
import Tracker from '@openreplay/tracker';
|
||||||
|
|
@ -97,10 +101,4 @@ const useBearStore = create(
|
||||||
|
|
||||||
ZustandDoc.displayName = 'ZustandDoc';
|
ZustandDoc.displayName = 'ZustandDoc';
|
||||||
|
|
||||||
export default connect((state) => {
|
export default observer(ZustandDoc);
|
||||||
const siteId = state.getIn(['integrations', 'siteId']);
|
|
||||||
const sites = state.getIn(['site', 'list']);
|
|
||||||
return {
|
|
||||||
projectKey: sites.find((site) => site.get('id') === siteId).get('projectKey'),
|
|
||||||
};
|
|
||||||
})(ZustandDoc);
|
|
||||||
|
|
|
||||||
|
|
@ -2,20 +2,17 @@ import React, { useEffect } from 'react';
|
||||||
import ModuleCard from 'Components/Client/Modules/ModuleCard';
|
import ModuleCard from 'Components/Client/Modules/ModuleCard';
|
||||||
import { modules as list } from './';
|
import { modules as list } from './';
|
||||||
import withPageTitle from 'HOCs/withPageTitle';
|
import withPageTitle from 'HOCs/withPageTitle';
|
||||||
import { connect } from 'react-redux';
|
|
||||||
import { userService } from 'App/services';
|
import { userService } from 'App/services';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
import { updateModule } from 'Duck/user';
|
import { useStore } from "App/mstore";
|
||||||
|
import { observer } from 'mobx-react-lite';
|
||||||
|
|
||||||
interface Props {
|
function Modules() {
|
||||||
modules: string[];
|
const { userStore } = useStore();
|
||||||
updateModule: (moduleKey: string) => void;
|
const updateModule = userStore.updateModule;
|
||||||
isEnterprise: boolean;
|
const modules = userStore.account.settings?.modules ?? [];
|
||||||
}
|
const isEnterprise = userStore.account.edition === 'ee';
|
||||||
|
const [modulesState, setModulesState] = React.useState<any[]>([]);
|
||||||
function Modules(props: Props) {
|
|
||||||
const { modules } = props;
|
|
||||||
const [modulesState, setModulesState, isEnterprise = false] = React.useState<any[]>([]);
|
|
||||||
|
|
||||||
const onToggle = async (module: any) => {
|
const onToggle = async (module: any) => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -26,7 +23,7 @@ function Modules(props: Props) {
|
||||||
module: module.key,
|
module: module.key,
|
||||||
status: isEnabled,
|
status: isEnabled,
|
||||||
});
|
});
|
||||||
props.updateModule(module.key);
|
updateModule(module.key);
|
||||||
toast.success(`Module ${module.label} ${!isEnabled ? 'enabled' : 'disabled'}`);
|
toast.success(`Module ${module.label} ${!isEnabled ? 'enabled' : 'disabled'}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|
@ -66,7 +63,4 @@ function Modules(props: Props) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export default withPageTitle('Modules - OpenReplay Preferences')(connect((state: any) => ({
|
export default withPageTitle('Modules - OpenReplay Preferences')(observer(Modules));
|
||||||
modules: state.getIn(['user', 'account', 'settings', 'modules']) || [],
|
|
||||||
isEnterprise: state.getIn(['user', 'account', 'edition']) === 'ee'
|
|
||||||
}), { updateModule })(Modules));
|
|
||||||
|
|
|
||||||
|
|
@ -1,46 +1,25 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import copy from 'copy-to-clipboard';
|
import { observer } from 'mobx-react-lite'
|
||||||
import { connect } from 'react-redux';
|
import { useStore } from 'App/mstore';
|
||||||
import styles from './profileSettings.module.css';
|
import { CopyButton, Form, Input } from 'UI';
|
||||||
import { Form, Input, Button, CopyButton } from 'UI';
|
|
||||||
|
|
||||||
@connect(state => ({
|
function ApiKeySettings() {
|
||||||
apiKey: state.getIn([ 'user', 'account', 'apiKey' ]),
|
const { userStore } = useStore();
|
||||||
loading: state.getIn([ 'user', 'updateAccountRequest', 'loading' ]) ||
|
|
||||||
state.getIn([ 'user', 'putClientRequest', 'loading' ]),
|
|
||||||
}))
|
|
||||||
export default class Api extends React.PureComponent {
|
|
||||||
state = { copied: false }
|
|
||||||
|
|
||||||
copyHandler = () => {
|
const apiKey = userStore.account.apiKey;
|
||||||
const { apiKey } = this.props;
|
return (
|
||||||
this.setState({ copied: true });
|
<Form.Field>
|
||||||
copy(apiKey);
|
<label htmlFor="apiKey">{'Organization API Key'}</label>
|
||||||
setTimeout(() => {
|
<Input
|
||||||
this.setState({ copied: false });
|
name="apiKey"
|
||||||
}, 1000);
|
id="apiKey"
|
||||||
};
|
type="text"
|
||||||
|
readOnly={true}
|
||||||
render() {
|
value={apiKey}
|
||||||
const { apiKey } = this.props;
|
leadingButton={<CopyButton content={apiKey} />}
|
||||||
const { copied } = this.state;
|
/>
|
||||||
|
</Form.Field>
|
||||||
return (
|
);
|
||||||
<Form onSubmit={ this.handleSubmit } className={ styles.form }>
|
|
||||||
<Form.Field>
|
|
||||||
<label htmlFor="apiKey">{ 'Organization API Key' }</label>
|
|
||||||
<Input
|
|
||||||
name="apiKey"
|
|
||||||
id="apiKey"
|
|
||||||
type="text"
|
|
||||||
readOnly={ true }
|
|
||||||
value={ apiKey }
|
|
||||||
leadingButton={
|
|
||||||
<CopyButton content={ apiKey } />
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Form.Field>
|
|
||||||
</Form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default observer(ApiKeySettings);
|
||||||
|
|
@ -1,18 +1,20 @@
|
||||||
import React, { useState, useCallback } from 'react';
|
import React, { useState, useCallback } from 'react';
|
||||||
import { connect, ConnectedProps } from 'react-redux';
|
|
||||||
import { Button, Message, Form, Input } from 'UI';
|
import { Button, Message, Form, Input } from 'UI';
|
||||||
import styles from './profileSettings.module.css';
|
import styles from './profileSettings.module.css';
|
||||||
import { updatePassword } from 'Duck/user';
|
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
import { validatePassword } from 'App/validate';
|
import { validatePassword } from 'App/validate';
|
||||||
import { PASSWORD_POLICY } from 'App/constants';
|
import { PASSWORD_POLICY } from 'App/constants';
|
||||||
|
import { useStore } from 'App/mstore';
|
||||||
|
import { observer } from 'mobx-react-lite';
|
||||||
|
|
||||||
const ERROR_DOESNT_MATCH = "Passwords don't match";
|
const ERROR_DOESNT_MATCH = "Passwords don't match";
|
||||||
const MIN_LENGTH = 8;
|
const MIN_LENGTH = 8;
|
||||||
|
|
||||||
type PropsFromRedux = ConnectedProps<typeof connector>;
|
const ChangePassword = () => {
|
||||||
|
const { userStore } = useStore();
|
||||||
const ChangePassword: React.FC<PropsFromRedux> = ({ passwordErrors, loading, updatePassword }) => {
|
const updatePassword = userStore.updatePassword;
|
||||||
|
const passwordErrors = userStore.updatePasswordRequest.errors;
|
||||||
|
const loading = userStore.updatePasswordRequest.loading;
|
||||||
const [oldPassword, setOldPassword] = useState<string>('');
|
const [oldPassword, setOldPassword] = useState<string>('');
|
||||||
const [newPassword, setNewPassword] = useState<{ value: string; error: boolean }>({
|
const [newPassword, setNewPassword] = useState<{ value: string; error: boolean }>({
|
||||||
value: '',
|
value: '',
|
||||||
|
|
@ -22,7 +24,6 @@ const ChangePassword: React.FC<PropsFromRedux> = ({ passwordErrors, loading, upd
|
||||||
value: '',
|
value: '',
|
||||||
error: false,
|
error: false,
|
||||||
});
|
});
|
||||||
const [success, setSuccess] = useState<boolean>(false);
|
|
||||||
const [show, setShow] = useState<boolean>(false);
|
const [show, setShow] = useState<boolean>(false);
|
||||||
|
|
||||||
const checkDoesntMatch = useCallback((newPassword: string, newPasswordRepeat: string) => {
|
const checkDoesntMatch = useCallback((newPassword: string, newPasswordRepeat: string) => {
|
||||||
|
|
@ -55,7 +56,6 @@ const ChangePassword: React.FC<PropsFromRedux> = ({ passwordErrors, loading, upd
|
||||||
newPassword: newPassword.value,
|
newPassword: newPassword.value,
|
||||||
}).then((e: any) => {
|
}).then((e: any) => {
|
||||||
const success = !e || !e.errors || e.errors.length === 0;
|
const success = !e || !e.errors || e.errors.length === 0;
|
||||||
setSuccess(success);
|
|
||||||
setShow(!success);
|
setShow(!success);
|
||||||
if (success) {
|
if (success) {
|
||||||
toast.success(`Successfully changed password`);
|
toast.success(`Successfully changed password`);
|
||||||
|
|
@ -133,7 +133,6 @@ const ChangePassword: React.FC<PropsFromRedux> = ({ passwordErrors, loading, upd
|
||||||
setOldPassword('');
|
setOldPassword('');
|
||||||
setNewPassword({ value: '', error: false });
|
setNewPassword({ value: '', error: false });
|
||||||
setNewPasswordRepeat({ value: '', error: false });
|
setNewPasswordRepeat({ value: '', error: false });
|
||||||
setSuccess(false);
|
|
||||||
setShow(false);
|
setShow(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
@ -148,15 +147,4 @@ const ChangePassword: React.FC<PropsFromRedux> = ({ passwordErrors, loading, upd
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const mapStateToProps = (state: any) => ({
|
export default observer(ChangePassword);
|
||||||
passwordErrors: state.getIn(['user', 'passwordErrors']),
|
|
||||||
loading: state.getIn(['user', 'updatePasswordRequest', 'loading']),
|
|
||||||
});
|
|
||||||
|
|
||||||
const mapDispatchToProps = {
|
|
||||||
updatePassword,
|
|
||||||
};
|
|
||||||
|
|
||||||
const connector = connect(mapStateToProps, mapDispatchToProps);
|
|
||||||
|
|
||||||
export default connector(ChangePassword);
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { connect } from 'react-redux'
|
import { observer } from 'mobx-react-lite'
|
||||||
|
import { useStore } from 'App/mstore'
|
||||||
|
|
||||||
function Licenses({ account }) {
|
function Licenses() {
|
||||||
|
const { userStore } = useStore()
|
||||||
|
const account = userStore.account
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div>{account.license}</div>
|
<div>{account.license}</div>
|
||||||
|
|
@ -14,6 +17,4 @@ function Licenses({ account }) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default connect(state => ({
|
export default observer(Licenses)
|
||||||
account: state.getIn([ 'user', 'account' ]),
|
|
||||||
}))(Licenses)
|
|
||||||
|
|
|
||||||
|
|
@ -1,28 +1,29 @@
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { connect } from 'react-redux';
|
|
||||||
import { Checkbox } from 'UI'
|
import { Checkbox } from 'UI'
|
||||||
import { updateClient } from 'Duck/user'
|
import { observer } from 'mobx-react-lite'
|
||||||
|
import { useStore } from "App/mstore";
|
||||||
|
|
||||||
|
function OptOut() {
|
||||||
|
const { userStore } = useStore();
|
||||||
|
const optOut = userStore.account.optOut;
|
||||||
|
const updateClient = userStore.updateClient;
|
||||||
|
|
||||||
function OptOut(props) {
|
|
||||||
const { optOut } = props;
|
|
||||||
const onChange = () => {
|
const onChange = () => {
|
||||||
props.updateClient({ optOut: !optOut })
|
void updateClient({ optOut: !optOut });
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
name="isPublic"
|
name="isPublic"
|
||||||
className="font-medium"
|
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={ optOut }
|
checked={ optOut }
|
||||||
onClick={ onChange }
|
onClick={ onChange }
|
||||||
className="mr-8"
|
className="font-medium mr-8"
|
||||||
label="Anonymize"
|
label="Anonymize"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default connect(state => ({
|
export default observer(OptOut);
|
||||||
optOut: state.getIn([ 'user', 'account', 'optOut' ]),
|
|
||||||
}), { updateClient })(OptOut);
|
|
||||||
|
|
|
||||||
|
|
@ -7,106 +7,104 @@ import Api from './Api';
|
||||||
import TenantKey from './TenantKey';
|
import TenantKey from './TenantKey';
|
||||||
import OptOut from './OptOut';
|
import OptOut from './OptOut';
|
||||||
import Licenses from './Licenses';
|
import Licenses from './Licenses';
|
||||||
import { connect } from 'react-redux';
|
|
||||||
import { PageTitle } from 'UI';
|
import { PageTitle } from 'UI';
|
||||||
|
import { observer } from 'mobx-react-lite';
|
||||||
|
import { useStore } from 'App/mstore';
|
||||||
|
|
||||||
@withPageTitle('Account - OpenReplay Preferences')
|
function ProfileSettings() {
|
||||||
@connect((state) => ({
|
const { userStore } = useStore();
|
||||||
account: state.getIn(['user', 'account']),
|
const account = userStore.account;
|
||||||
isEnterprise: state.getIn(['user', 'account', 'edition']) === 'ee',
|
const isEnterprise = userStore.isEnterprise;
|
||||||
}))
|
return (
|
||||||
export default class ProfileSettings extends React.PureComponent {
|
<div className="bg-white rounded-lg border shadow-sm p-5">
|
||||||
render() {
|
<PageTitle title={<div>Account</div>} />
|
||||||
const { account, isEnterprise } = this.props;
|
<div className="flex items-center">
|
||||||
return (
|
<div className={styles.left}>
|
||||||
<div className="bg-white rounded-lg border shadow-sm p-5">
|
<h4 className="text-lg mb-4">{'Profile'}</h4>
|
||||||
<PageTitle title={<div>Account</div>} />
|
<div className={styles.info}>{'Your email address is your identity on OpenReplay and is used to login.'}</div>
|
||||||
<div className="flex items-center">
|
</div>
|
||||||
<div className={styles.left}>
|
<div>
|
||||||
<h4 className="text-lg mb-4">{'Profile'}</h4>
|
<Settings />
|
||||||
<div className={styles.info}>{'Your email address is your identity on OpenReplay and is used to login.'}</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<Settings />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="border-b my-10" />
|
<div className="border-b my-10" />
|
||||||
|
|
||||||
{account.hasPassword && (
|
{account.hasPassword && (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<div className={styles.left}>
|
<div className={styles.left}>
|
||||||
<h4 className="text-lg mb-4">{'Change Password'}</h4>
|
<h4 className="text-lg mb-4">{'Change Password'}</h4>
|
||||||
<div className={styles.info}>{'Updating your password from time to time enhances your account’s security.'}</div>
|
<div className={styles.info}>{'Updating your password from time to time enhances your account’s security.'}</div>
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<ChangePassword />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="border-b my-10" />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex items-center">
|
|
||||||
<div className={styles.left}>
|
|
||||||
<h4 className="text-lg mb-4">{'Organization API Key'}</h4>
|
|
||||||
<div className={styles.info}>{'Your API key gives you access to an extra set of services.'}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Api />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isEnterprise && (account.admin || account.superAdmin) && (
|
|
||||||
<>
|
|
||||||
<div className="border-b my-10" />
|
|
||||||
<div className="flex items-center">
|
|
||||||
<div className={styles.left}>
|
|
||||||
<h4 className="text-lg mb-4">{'Tenant Key'}</h4>
|
|
||||||
<div className={styles.info}>{'For SSO (SAML) authentication.'}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<TenantKey />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!isEnterprise && (
|
|
||||||
<>
|
|
||||||
<div className="border-b my-10" />
|
|
||||||
<div className="flex items-center">
|
|
||||||
<div className={styles.left}>
|
|
||||||
<h4 className="text-lg mb-4">{'Data Collection'}</h4>
|
|
||||||
<div className={styles.info}>
|
|
||||||
{'Enables you to control how OpenReplay captures data on your organization’s usage to improve our product.'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<OptOut />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{account.license && (
|
|
||||||
<>
|
|
||||||
<div className="border-b my-10" />
|
|
||||||
|
|
||||||
<div className="flex items-center">
|
|
||||||
<div className={styles.left}>
|
|
||||||
<h4 className="text-lg mb-4">{'License'}</h4>
|
|
||||||
<div className={styles.info}>{'License key and expiration date.'}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Licenses />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
<div>
|
||||||
}
|
<ChangePassword />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-b my-10" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className={styles.left}>
|
||||||
|
<h4 className="text-lg mb-4">{'Organization API Key'}</h4>
|
||||||
|
<div className={styles.info}>{'Your API key gives you access to an extra set of services.'}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Api />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isEnterprise && (account.admin || account.superAdmin) && (
|
||||||
|
<>
|
||||||
|
<div className="border-b my-10" />
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className={styles.left}>
|
||||||
|
<h4 className="text-lg mb-4">{'Tenant Key'}</h4>
|
||||||
|
<div className={styles.info}>{'For SSO (SAML) authentication.'}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<TenantKey />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isEnterprise && (
|
||||||
|
<>
|
||||||
|
<div className="border-b my-10" />
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className={styles.left}>
|
||||||
|
<h4 className="text-lg mb-4">{'Data Collection'}</h4>
|
||||||
|
<div className={styles.info}>
|
||||||
|
{'Enables you to control how OpenReplay captures data on your organization’s usage to improve our product.'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<OptOut />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{account.license && (
|
||||||
|
<>
|
||||||
|
<div className="border-b my-10" />
|
||||||
|
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className={styles.left}>
|
||||||
|
<h4 className="text-lg mb-4">{'License'}</h4>
|
||||||
|
<div className={styles.info}>{'License key and expiration date.'}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Licenses />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default withPageTitle('Account - OpenReplay Preferences')(observer(ProfileSettings));
|
||||||
|
|
|
||||||
|
|
@ -1,74 +1,66 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { connect } from 'react-redux';
|
|
||||||
import { Button, Input, Form } from 'UI';
|
import { Button, Input, Form } from 'UI';
|
||||||
import { updateAccount, updateClient } from 'Duck/user';
|
|
||||||
import styles from './profileSettings.module.css';
|
import styles from './profileSettings.module.css';
|
||||||
|
import { observer } from 'mobx-react-lite';
|
||||||
|
import { useStore } from 'App/mstore';
|
||||||
|
|
||||||
@connect(
|
function Settings() {
|
||||||
(state) => ({
|
const { userStore } = useStore();
|
||||||
accountName: state.getIn(['user', 'account', 'name']),
|
const updateClient = userStore.updateClient;
|
||||||
organizationName: state.getIn(['user', 'account', 'tenantName']),
|
const storeAccountName = userStore.account.name;
|
||||||
loading:
|
const storeOrganizationName = userStore.account.tenantName;
|
||||||
state.getIn(['user', 'updateAccountRequest', 'loading']) ||
|
const loading = userStore.loading;
|
||||||
state.getIn(['user', 'putClientRequest', 'loading']),
|
const [accountName, setAccountName] = React.useState(storeAccountName);
|
||||||
}),
|
const [organizationName, setOrganizationName] = React.useState(storeOrganizationName);
|
||||||
{
|
const [changed, setChanged] = React.useState(false);
|
||||||
updateAccount,
|
|
||||||
updateClient,
|
const onAccNameChange = (e) => {
|
||||||
|
setAccountName(e.target.value);
|
||||||
|
setChanged(true);
|
||||||
}
|
}
|
||||||
)
|
|
||||||
export default class Settings extends React.PureComponent {
|
|
||||||
state = {
|
|
||||||
accountName: this.props.accountName,
|
|
||||||
organizationName: this.props.organizationName,
|
|
||||||
};
|
|
||||||
|
|
||||||
onChange = ({ target: { value, name } }) => {
|
const onOrgNameChange = (e) => {
|
||||||
this.setState({ changed: true, [name]: value });
|
setOrganizationName(e.target.value);
|
||||||
};
|
setChanged(true);
|
||||||
|
}
|
||||||
|
|
||||||
handleSubmit = (e) => {
|
const handleSubmit = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const { accountName, organizationName } = this.state;
|
await updateClient({ name: accountName, tenantName: organizationName });
|
||||||
this.props
|
setChanged(false);
|
||||||
.updateClient({ name: accountName, tenantName: organizationName })
|
|
||||||
.then(() => this.setState({ changed: false }));
|
|
||||||
};
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const { loading } = this.props;
|
|
||||||
const { accountName, organizationName, changed, copied } = this.state;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Form onSubmit={this.handleSubmit} className={styles.form}>
|
|
||||||
<Form.Field>
|
|
||||||
<label htmlFor="accountName">{'Name'}</label>
|
|
||||||
<Input
|
|
||||||
name="accountName"
|
|
||||||
id="accountName"
|
|
||||||
type="text"
|
|
||||||
onChange={this.onChange}
|
|
||||||
value={accountName}
|
|
||||||
maxLength={50}
|
|
||||||
/>
|
|
||||||
</Form.Field>
|
|
||||||
|
|
||||||
<Form.Field>
|
|
||||||
<label htmlFor="organizationName">{'Organization'}</label>
|
|
||||||
<Input
|
|
||||||
name="organizationName"
|
|
||||||
id="organizationName"
|
|
||||||
type="text"
|
|
||||||
onChange={this.onChange}
|
|
||||||
value={organizationName}
|
|
||||||
maxLength={50}
|
|
||||||
/>
|
|
||||||
</Form.Field>
|
|
||||||
|
|
||||||
<Button variant="outline" loading={loading} disabled={!changed} type="submit">
|
|
||||||
{'Update'}
|
|
||||||
</Button>
|
|
||||||
</Form>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form onSubmit={handleSubmit} className={styles.form}>
|
||||||
|
<Form.Field>
|
||||||
|
<label htmlFor="accountName">{'Name'}</label>
|
||||||
|
<Input
|
||||||
|
name="accountName"
|
||||||
|
id="accountName"
|
||||||
|
type="text"
|
||||||
|
onChange={onAccNameChange}
|
||||||
|
value={accountName}
|
||||||
|
maxLength={50}
|
||||||
|
/>
|
||||||
|
</Form.Field>
|
||||||
|
|
||||||
|
<Form.Field>
|
||||||
|
<label htmlFor="organizationName">{'Organization'}</label>
|
||||||
|
<Input
|
||||||
|
name="organizationName"
|
||||||
|
id="organizationName"
|
||||||
|
type="text"
|
||||||
|
onChange={onOrgNameChange}
|
||||||
|
value={organizationName}
|
||||||
|
maxLength={50}
|
||||||
|
/>
|
||||||
|
</Form.Field>
|
||||||
|
|
||||||
|
<Button variant="outline" loading={loading} disabled={!changed} type="submit">
|
||||||
|
{'Update'}
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default observer(Settings);
|
||||||
|
|
|
||||||
|
|
@ -1,51 +1,43 @@
|
||||||
// TODO this can be deleted
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import copy from 'copy-to-clipboard';
|
import copy from 'copy-to-clipboard';
|
||||||
import { connect } from 'react-redux';
|
|
||||||
import styles from './profileSettings.module.css';
|
|
||||||
import { Form, Input, Button } from "UI";
|
import { Form, Input, Button } from "UI";
|
||||||
|
import { observer } from 'mobx-react-lite';
|
||||||
|
import { useStore } from 'App/mstore';
|
||||||
|
|
||||||
@connect(state => ({
|
|
||||||
tenantKey: state.getIn([ 'user', 'account', 'tenantKey' ]),
|
|
||||||
}))
|
|
||||||
export default class TenantKey extends React.PureComponent {
|
|
||||||
state = { copied: false }
|
|
||||||
|
|
||||||
copyHandler = () => {
|
function TenantKey() {
|
||||||
const { tenantKey } = this.props;
|
const [ copied, setCopied ] = React.useState(false);
|
||||||
this.setState({ copied: true });
|
const { userStore } = useStore();
|
||||||
|
const tenantKey = userStore.account.tenantKey;
|
||||||
|
|
||||||
|
const copyHandler = () => {
|
||||||
|
setCopied(true);
|
||||||
copy(tenantKey);
|
copy(tenantKey);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.setState({ copied: false });
|
setCopied(false);
|
||||||
}, 1000);
|
}, 1000);
|
||||||
};
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const { tenantKey } = this.props;
|
|
||||||
const { copied } = this.state;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Form onSubmit={ this.handleSubmit } className={ styles.form }>
|
|
||||||
<Form.Field>
|
|
||||||
<label htmlFor="tenantKey">{ 'Tenant Key' }</label>
|
|
||||||
<Input
|
|
||||||
name="tenantKey"
|
|
||||||
id="tenantKey"
|
|
||||||
type="text"
|
|
||||||
readOnly={ true }
|
|
||||||
value={ tenantKey }
|
|
||||||
leadingButton={
|
|
||||||
<Button
|
|
||||||
variant="text-primary"
|
|
||||||
role="button"
|
|
||||||
onClick={ this.copyHandler }
|
|
||||||
>
|
|
||||||
{ copied ? 'Copied' : 'Copy' }
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Form.Field>
|
|
||||||
</Form>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
return (
|
||||||
|
<Form.Field>
|
||||||
|
<label htmlFor="tenantKey">{ 'Tenant Key' }</label>
|
||||||
|
<Input
|
||||||
|
name="tenantKey"
|
||||||
|
id="tenantKey"
|
||||||
|
type="text"
|
||||||
|
readOnly={ true }
|
||||||
|
value={ tenantKey }
|
||||||
|
leadingButton={
|
||||||
|
<Button
|
||||||
|
variant="text-primary"
|
||||||
|
role="button"
|
||||||
|
onClick={ copyHandler }
|
||||||
|
>
|
||||||
|
{ copied ? 'Copied' : 'Copy' }
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Form.Field>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default observer(TenantKey);
|
||||||
|
|
|
||||||
|
|
@ -1,133 +1,117 @@
|
||||||
import React, { useEffect } from 'react';
|
|
||||||
import cn from 'classnames';
|
|
||||||
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 withPageTitle from 'HOCs/withPageTitle';
|
||||||
|
import cn from 'classnames';
|
||||||
|
import { observer } from 'mobx-react-lite';
|
||||||
|
import React, { useEffect } from 'react';
|
||||||
|
|
||||||
import { useModal } from 'App/components/Modal';
|
import { useModal } from 'App/components/Modal';
|
||||||
|
import { useStore } from 'App/mstore';
|
||||||
|
import { Button, Loader, NoContent, Tooltip } from 'UI';
|
||||||
|
import { confirm } from 'UI';
|
||||||
|
|
||||||
interface Props {
|
import RoleForm from './components/RoleForm';
|
||||||
loading: boolean;
|
import RoleItem from './components/RoleItem';
|
||||||
init: (role?: any) => void;
|
import stl from './roles.module.css';
|
||||||
edit: (role: any) => void;
|
|
||||||
instance: any;
|
|
||||||
roles: any[];
|
|
||||||
deleteRole: (id: any) => Promise<void>;
|
|
||||||
fetchList: () => Promise<void>;
|
|
||||||
account: any;
|
|
||||||
permissionsMap: any;
|
|
||||||
removeErrors: any;
|
|
||||||
resetErrors: () => void;
|
|
||||||
projectsMap: any;
|
|
||||||
}
|
|
||||||
|
|
||||||
function Roles(props: Props) {
|
function Roles() {
|
||||||
const { loading, roles, init, edit, deleteRole, account, permissionsMap, projectsMap, removeErrors } = props;
|
const { roleStore, projectsStore, userStore } = useStore();
|
||||||
const { showModal, hideModal } = useModal();
|
const account = userStore.account;
|
||||||
const isAdmin = account.admin || account.superAdmin;
|
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 { showModal, hideModal } = useModal();
|
||||||
|
const isAdmin = account.admin || account.superAdmin;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
props.fetchList();
|
void roleStore.fetchRoles();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
const editHandler = (role: any) => {
|
||||||
if (removeErrors && removeErrors.size > 0) {
|
init(role);
|
||||||
removeErrors.forEach((e: any) => {
|
showModal(
|
||||||
toast.error(e);
|
<RoleForm
|
||||||
});
|
closeModal={hideModal}
|
||||||
}
|
permissionsMap={permissionsMap}
|
||||||
return () => {
|
deleteHandler={deleteHandler}
|
||||||
props.resetErrors();
|
/>,
|
||||||
};
|
{ right: true }
|
||||||
}, [removeErrors]);
|
|
||||||
|
|
||||||
const editHandler = (role: any) => {
|
|
||||||
init(role);
|
|
||||||
showModal(<RoleForm closeModal={hideModal} permissionsMap={permissionsMap} deleteHandler={deleteHandler} />, { right: true });
|
|
||||||
};
|
|
||||||
|
|
||||||
const deleteHandler = async (role: any) => {
|
|
||||||
if (
|
|
||||||
await confirm({
|
|
||||||
header: 'Roles',
|
|
||||||
confirmation: `Are you sure you want to remove this role?`,
|
|
||||||
})
|
|
||||||
) {
|
|
||||||
deleteRole(role.roleId).then(hideModal);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<React.Fragment>
|
|
||||||
<Loader loading={loading}>
|
|
||||||
<div className="bg-white rounded-lg shadow-sm border">
|
|
||||||
<div className={cn(stl.tabHeader, 'flex items-center')}>
|
|
||||||
<div className="flex items-center mr-auto px-5 pt-5">
|
|
||||||
<h3 className={cn(stl.tabTitle, 'text-2xl')}>Roles and Access</h3>
|
|
||||||
<Tooltip title="You don’t have the permissions to perform this action." disabled={isAdmin}>
|
|
||||||
<Button variant="primary" onClick={() => editHandler({})}>Add</Button>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<NoContent title="No roles are available" size="small" show={false}>
|
|
||||||
<div className={''}>
|
|
||||||
<div className={cn('flex items-start py-3 border-b px-5 pr-20 font-medium')}>
|
|
||||||
<div className="" style={{ width: '20%' }}>
|
|
||||||
Title
|
|
||||||
</div>
|
|
||||||
<div className="" style={{ width: '30%' }}>
|
|
||||||
Project Access
|
|
||||||
</div>
|
|
||||||
<div className="" style={{ width: '50%' }}>
|
|
||||||
Feature Access
|
|
||||||
</div>
|
|
||||||
<div></div>
|
|
||||||
</div>
|
|
||||||
{roles.map((role) => (
|
|
||||||
<RoleItem
|
|
||||||
key={role.roleId}
|
|
||||||
role={role}
|
|
||||||
isAdmin={isAdmin}
|
|
||||||
permissions={permissionsMap}
|
|
||||||
projects={projectsMap}
|
|
||||||
editHandler={editHandler}
|
|
||||||
deleteHandler={deleteHandler}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</NoContent>
|
|
||||||
</div>
|
|
||||||
</Loader>
|
|
||||||
</React.Fragment>
|
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteHandler = async (role: any) => {
|
||||||
|
if (
|
||||||
|
await confirm({
|
||||||
|
header: 'Roles',
|
||||||
|
confirmation: `Are you sure you want to remove this role?`,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
deleteRole(role.roleId).then(hideModal);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment>
|
||||||
|
<Loader loading={loading}>
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border">
|
||||||
|
<div className={cn(stl.tabHeader, 'flex items-center')}>
|
||||||
|
<div className="flex items-center mr-auto px-5 pt-5">
|
||||||
|
<h3 className={cn(stl.tabTitle, 'text-2xl')}>Roles and Access</h3>
|
||||||
|
<Tooltip
|
||||||
|
title="You don’t have the permissions to perform this action."
|
||||||
|
disabled={isAdmin}
|
||||||
|
>
|
||||||
|
<Button variant="primary" onClick={() => editHandler({})}>
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<NoContent title="No roles are available" size="small" show={false}>
|
||||||
|
<div className={''}>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex items-start py-3 border-b px-5 pr-20 font-medium'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="" style={{ width: '20%' }}>
|
||||||
|
Title
|
||||||
|
</div>
|
||||||
|
<div className="" style={{ width: '30%' }}>
|
||||||
|
Project Access
|
||||||
|
</div>
|
||||||
|
<div className="" style={{ width: '50%' }}>
|
||||||
|
Feature Access
|
||||||
|
</div>
|
||||||
|
<div></div>
|
||||||
|
</div>
|
||||||
|
{roles.map((role) => (
|
||||||
|
<RoleItem
|
||||||
|
key={role.roleId}
|
||||||
|
role={role}
|
||||||
|
isAdmin={isAdmin}
|
||||||
|
permissions={permissionsMap}
|
||||||
|
projects={projectsMap}
|
||||||
|
editHandler={editHandler}
|
||||||
|
deleteHandler={deleteHandler}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</NoContent>
|
||||||
|
</div>
|
||||||
|
</Loader>
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default connect(
|
export default withPageTitle('Roles & Access - OpenReplay Preferences')(
|
||||||
(state: any) => {
|
observer(Roles)
|
||||||
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));
|
|
||||||
|
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import Role from 'Types/role'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
role: Role
|
|
||||||
}
|
|
||||||
function Permissions(props: Props) {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Permissions;
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
export { default } from './Permissions';
|
|
||||||
|
|
@ -1,196 +1,222 @@
|
||||||
import React, { useRef, useEffect } from 'react';
|
import { observer } from 'mobx-react-lite';
|
||||||
import { connect } from 'react-redux';
|
import React, { useEffect, useRef } from 'react';
|
||||||
import stl from './roleForm.module.css';
|
|
||||||
import { save, edit } from 'Duck/roles';
|
import { useStore } from 'App/mstore';
|
||||||
import { Form, Input, Button, Checkbox, Icon } from 'UI';
|
import { Button, Checkbox, Form, Icon, Input } from 'UI';
|
||||||
|
|
||||||
import Select from 'Shared/Select';
|
import Select from 'Shared/Select';
|
||||||
|
|
||||||
interface Permission {
|
import stl from './roleForm.module.css';
|
||||||
name: string;
|
|
||||||
value: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
role: any;
|
closeModal: (toastMessage?: string) => void;
|
||||||
edit: (role: any) => void;
|
permissionsMap: any;
|
||||||
save: (role: any) => Promise<void>;
|
deleteHandler: (id: any) => Promise<void>;
|
||||||
closeModal: (toastMessage?: string) => void;
|
|
||||||
saving: boolean;
|
|
||||||
permissions: Array<Permission>[];
|
|
||||||
projectOptions: Array<any>[];
|
|
||||||
permissionsMap: any;
|
|
||||||
projectsMap: any;
|
|
||||||
deleteHandler: (id: any) => Promise<void>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const RoleForm = (props: Props) => {
|
const RoleForm = (props: Props) => {
|
||||||
const { role, edit, save, closeModal, saving, permissions, projectOptions, permissionsMap, projectsMap } = props;
|
const { roleStore, projectsStore } = useStore();
|
||||||
let focusElement = useRef<any>(null);
|
const projects = projectsStore.list;
|
||||||
const _save = () => {
|
const role = roleStore.instance;
|
||||||
save(role).then(() => {
|
const saving = roleStore.loading;
|
||||||
closeModal(role.exists() ? 'Role updated' : 'Role created');
|
const { closeModal, permissionsMap } = props;
|
||||||
});
|
const projectOptions = projects
|
||||||
};
|
.filter(({ value }) => !role.projects.includes(value))
|
||||||
|
.map((p: any) => ({
|
||||||
|
key: p.id,
|
||||||
|
value: p.id,
|
||||||
|
label: p.name,
|
||||||
|
}))
|
||||||
|
.filter(({ value }: any) => !role.projects.includes(value));
|
||||||
|
const projectsMap = projects.reduce((acc: any, p: any) => {
|
||||||
|
acc[p.id] = p.name;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
const write = ({ target: { value, name } }: any) => edit({ [name]: value });
|
let focusElement = useRef<any>(null);
|
||||||
|
const permissions: {}[] = roleStore.permissions
|
||||||
|
.filter(({ value }) => !role.permissions.includes(value))
|
||||||
|
.map((p) => ({
|
||||||
|
label: p.text,
|
||||||
|
value: p.value,
|
||||||
|
}));
|
||||||
|
const _save = () => {
|
||||||
|
roleStore.saveRole(role).then(() => {
|
||||||
|
closeModal(role.exists() ? 'Role updated' : 'Role created');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const onChangePermissions = (e: any) => {
|
const write = ({ target: { value, name } }: any) =>
|
||||||
const { permissions } = role;
|
roleStore.editRole({ [name]: value });
|
||||||
const index = permissions.indexOf(e);
|
|
||||||
const _perms = permissions.contains(e) ? permissions.remove(index) : permissions.push(e);
|
|
||||||
edit({ permissions: _perms });
|
|
||||||
};
|
|
||||||
|
|
||||||
const onChangeProjects = (e: any) => {
|
const onChangePermissions = (e: any) => {
|
||||||
const { projects } = role;
|
const { permissions } = role;
|
||||||
const index = projects.indexOf(e);
|
const index = permissions.indexOf(e);
|
||||||
const _projects = index === -1 ? projects.push(e) : projects.remove(index);
|
let _perms;
|
||||||
edit({ projects: _projects });
|
if (permissions.includes(e)) {
|
||||||
};
|
permissions.splice(index, 1);
|
||||||
|
_perms = permissions;
|
||||||
|
} else {
|
||||||
|
_perms = permissions.concat(e);
|
||||||
|
}
|
||||||
|
roleStore.editRole({ permissions: _perms });
|
||||||
|
};
|
||||||
|
|
||||||
const writeOption = ({ name, value }: any) => {
|
const onChangeProjects = (e: any) => {
|
||||||
if (name === 'permissions') {
|
const { projects } = role;
|
||||||
onChangePermissions(value);
|
const index = projects.indexOf(e);
|
||||||
} else if (name === 'projects') {
|
let _projects;
|
||||||
onChangeProjects(value);
|
if (index === -1) {
|
||||||
}
|
_projects = projects.concat(e);
|
||||||
};
|
} else {
|
||||||
|
projects.splice(index, 1);
|
||||||
|
_projects = projects;
|
||||||
|
}
|
||||||
|
roleStore.editRole({ projects: _projects });
|
||||||
|
};
|
||||||
|
|
||||||
const toggleAllProjects = () => {
|
const writeOption = ({ name, value }: any) => {
|
||||||
const { allProjects } = role;
|
if (name === 'permissions') {
|
||||||
edit({ allProjects: !allProjects });
|
onChangePermissions(value);
|
||||||
};
|
} else if (name === 'projects') {
|
||||||
|
onChangeProjects(value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
const toggleAllProjects = () => {
|
||||||
focusElement && focusElement.current && focusElement.current.focus();
|
const { allProjects } = role;
|
||||||
}, []);
|
roleStore.editRole({ allProjects: !allProjects });
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
useEffect(() => {
|
||||||
<div className="bg-white h-screen overflow-y-auto" style={{ width: '350px' }}>
|
focusElement && focusElement.current && focusElement.current.focus();
|
||||||
<h3 className="p-5 text-2xl">{role.exists() ? 'Edit Role' : 'Create Role'}</h3>
|
}, []);
|
||||||
<div className="px-5">
|
|
||||||
<Form onSubmit={_save}>
|
|
||||||
<Form.Field>
|
|
||||||
<label>{'Title'}</label>
|
|
||||||
<Input
|
|
||||||
ref={focusElement}
|
|
||||||
name="name"
|
|
||||||
value={role.name}
|
|
||||||
onChange={write}
|
|
||||||
maxLength={40}
|
|
||||||
className={stl.input}
|
|
||||||
id="name-field"
|
|
||||||
placeholder="Ex. Admin"
|
|
||||||
/>
|
|
||||||
</Form.Field>
|
|
||||||
|
|
||||||
<Form.Field>
|
return (
|
||||||
<label>{'Project Access'}</label>
|
<div
|
||||||
|
className="bg-white h-screen overflow-y-auto"
|
||||||
|
style={{ width: '350px' }}
|
||||||
|
>
|
||||||
|
<h3 className="p-5 text-2xl">
|
||||||
|
{role.exists() ? 'Edit Role' : 'Create Role'}
|
||||||
|
</h3>
|
||||||
|
<div className="px-5">
|
||||||
|
<Form onSubmit={_save}>
|
||||||
|
<Form.Field>
|
||||||
|
<label>{'Title'}</label>
|
||||||
|
<Input
|
||||||
|
ref={focusElement}
|
||||||
|
name="name"
|
||||||
|
value={role.name}
|
||||||
|
onChange={write}
|
||||||
|
maxLength={40}
|
||||||
|
className={stl.input}
|
||||||
|
id="name-field"
|
||||||
|
placeholder="Ex. Admin"
|
||||||
|
/>
|
||||||
|
</Form.Field>
|
||||||
|
|
||||||
<div className="flex my-3">
|
<Form.Field>
|
||||||
<Checkbox
|
<label>{'Project Access'}</label>
|
||||||
name="allProjects"
|
|
||||||
className="font-medium mr-3"
|
|
||||||
type="checkbox"
|
|
||||||
checked={role.allProjects}
|
|
||||||
onClick={toggleAllProjects}
|
|
||||||
label={''}
|
|
||||||
/>
|
|
||||||
<div className="cursor-pointer leading-none select-none" onClick={toggleAllProjects}>
|
|
||||||
<div>All Projects</div>
|
|
||||||
<span className="text-xs text-gray-600">(Uncheck to select specific projects)</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{!role.allProjects && (
|
|
||||||
<>
|
|
||||||
<Select
|
|
||||||
isSearchable
|
|
||||||
name="projects"
|
|
||||||
options={projectOptions}
|
|
||||||
onChange={({ value }: any) => writeOption({ name: 'projects', value: value.value })}
|
|
||||||
value={null}
|
|
||||||
/>
|
|
||||||
{role.projects.size > 0 && (
|
|
||||||
<div className="flex flex-row items-start flex-wrap mt-4">
|
|
||||||
{role.projects.map((p: any) => OptionLabel(projectsMap, p, onChangeProjects))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Form.Field>
|
|
||||||
|
|
||||||
<Form.Field>
|
<div className="flex my-3">
|
||||||
<label>{'Capability Access'}</label>
|
<Checkbox
|
||||||
<Select
|
name="allProjects"
|
||||||
isSearchable
|
className="font-medium mr-3"
|
||||||
name="permissions"
|
type="checkbox"
|
||||||
options={permissions}
|
checked={role.allProjects}
|
||||||
onChange={({ value }: any) => writeOption({ name: 'permissions', value: value.value })}
|
onClick={toggleAllProjects}
|
||||||
value={null}
|
label={''}
|
||||||
/>
|
/>
|
||||||
{role.permissions.size > 0 && (
|
<div
|
||||||
<div className="flex flex-row items-start flex-wrap mt-4">
|
className="cursor-pointer leading-none select-none"
|
||||||
{role.permissions.map((p: any) => OptionLabel(permissionsMap, p, onChangePermissions))}
|
onClick={toggleAllProjects}
|
||||||
</div>
|
>
|
||||||
)}
|
<div>All Projects</div>
|
||||||
</Form.Field>
|
<span className="text-xs text-gray-600">
|
||||||
</Form>
|
(Uncheck to select specific projects)
|
||||||
|
</span>
|
||||||
<div className="flex items-center">
|
</div>
|
||||||
<div className="flex items-center mr-auto">
|
|
||||||
<Button onClick={_save} disabled={!role.validate()} loading={saving} variant="primary" className="float-left mr-2">
|
|
||||||
{role.exists() ? 'Update' : 'Add'}
|
|
||||||
</Button>
|
|
||||||
{role.exists() && <Button onClick={closeModal}>{'Cancel'}</Button>}
|
|
||||||
</div>
|
|
||||||
{role.exists() && (
|
|
||||||
<Button variant="text" onClick={() => props.deleteHandler(role)}>
|
|
||||||
<Icon name="trash" size="18" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
{!role.allProjects && (
|
||||||
|
<>
|
||||||
|
<Select
|
||||||
|
isSearchable
|
||||||
|
name="projects"
|
||||||
|
options={projectOptions}
|
||||||
|
onChange={({ value }: any) =>
|
||||||
|
writeOption({ name: 'projects', value: value.value })
|
||||||
|
}
|
||||||
|
value={null}
|
||||||
|
/>
|
||||||
|
{role.projects.size > 0 && (
|
||||||
|
<div className="flex flex-row items-start flex-wrap mt-4">
|
||||||
|
{role.projects.map((p: any) =>
|
||||||
|
OptionLabel(projectsMap, p, onChangeProjects)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Form.Field>
|
||||||
|
|
||||||
|
<Form.Field>
|
||||||
|
<label>{'Capability Access'}</label>
|
||||||
|
<Select
|
||||||
|
isSearchable
|
||||||
|
name="permissions"
|
||||||
|
options={permissions}
|
||||||
|
onChange={({ value }: any) =>
|
||||||
|
writeOption({ name: 'permissions', value: value.value })
|
||||||
|
}
|
||||||
|
value={null}
|
||||||
|
/>
|
||||||
|
{role.permissions.length > 0 && (
|
||||||
|
<div className="flex flex-row items-start flex-wrap mt-4">
|
||||||
|
{role.permissions.map((p: any) =>
|
||||||
|
OptionLabel(permissionsMap, p, onChangePermissions)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Form.Field>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="flex items-center mr-auto">
|
||||||
|
<Button
|
||||||
|
onClick={_save}
|
||||||
|
disabled={!role.validate}
|
||||||
|
loading={saving}
|
||||||
|
variant="primary"
|
||||||
|
className="float-left mr-2"
|
||||||
|
>
|
||||||
|
{role.exists() ? 'Update' : 'Add'}
|
||||||
|
</Button>
|
||||||
|
{role.exists() && <Button onClick={closeModal}>{'Cancel'}</Button>}
|
||||||
|
</div>
|
||||||
|
{role.exists() && (
|
||||||
|
<Button variant="text" onClick={() => props.deleteHandler(role)}>
|
||||||
|
<Icon name="trash" size="18" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default connect(
|
export default observer(RoleForm);
|
||||||
(state: any) => {
|
|
||||||
const role = state.getIn(['roles', 'instance']);
|
|
||||||
const projects = state.getIn(['site', 'list']);
|
|
||||||
return {
|
|
||||||
role,
|
|
||||||
projectOptions: projects
|
|
||||||
.map((p: any) => ({
|
|
||||||
key: p.get('id'),
|
|
||||||
value: p.get('id'),
|
|
||||||
label: p.get('name'),
|
|
||||||
// isDisabled: role.projects.includes(p.get('id')),
|
|
||||||
}))
|
|
||||||
.filter(({ value }: any) => !role.projects.includes(value))
|
|
||||||
.toJS(),
|
|
||||||
permissions: state
|
|
||||||
.getIn(['roles', 'permissions'])
|
|
||||||
.filter(({ value }: any) => !role.permissions.includes(value))
|
|
||||||
.map(({ text, value }: any) => ({ label: text, value }))
|
|
||||||
.toJS(),
|
|
||||||
saving: state.getIn(['roles', 'saveRequest', 'loading']),
|
|
||||||
projectsMap: projects.reduce((acc: any, p: any) => {
|
|
||||||
acc[p.get('id')] = p.get('name');
|
|
||||||
return acc;
|
|
||||||
}, {}),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
{ edit, save }
|
|
||||||
)(RoleForm);
|
|
||||||
|
|
||||||
function OptionLabel(nameMap: any, p: any, onChangeOption: (e: any) => void) {
|
function OptionLabel(nameMap: any, p: any, onChangeOption: (e: any) => void) {
|
||||||
return (
|
return (
|
||||||
<div className="px-2 py-1 rounded bg-gray-lightest mr-2 mb-2 border flex items-center justify-between" key={p.roleId}>
|
<div
|
||||||
<div>{nameMap[p]}</div>
|
className="px-2 py-1 rounded bg-gray-lightest mr-2 mb-2 border flex items-center justify-between"
|
||||||
<div className="cursor-pointer ml-2" onClick={() => onChangeOption(p)}>
|
key={p.roleId}
|
||||||
<Icon name="close" size="12" />
|
>
|
||||||
</div>
|
<div>{nameMap[p]}</div>
|
||||||
</div>
|
<div className="cursor-pointer ml-2" onClick={() => onChangeOption(p)}>
|
||||||
);
|
<Icon name="close" size="12" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,41 +1,29 @@
|
||||||
|
import withPageTitle from 'HOCs/withPageTitle';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { connect } from 'react-redux';
|
import { Divider, PageTitle } from 'UI';
|
||||||
import { PageTitle, Divider } from 'UI';
|
|
||||||
import ListingVisibility from 'Shared/SessionSettings/components/ListingVisibility';
|
|
||||||
import DefaultPlaying from 'Shared/SessionSettings/components/DefaultPlaying';
|
import DefaultPlaying from 'Shared/SessionSettings/components/DefaultPlaying';
|
||||||
import DefaultTimezone from 'Shared/SessionSettings/components/DefaultTimezone';
|
import DefaultTimezone from 'Shared/SessionSettings/components/DefaultTimezone';
|
||||||
import withPageTitle from 'HOCs/withPageTitle';
|
import ListingVisibility from 'Shared/SessionSettings/components/ListingVisibility';
|
||||||
import MouseTrailSettings from 'Shared/SessionSettings/components/MouseTrailSettings';
|
import MouseTrailSettings from 'Shared/SessionSettings/components/MouseTrailSettings';
|
||||||
|
|
||||||
|
function SessionsListingSettings() {
|
||||||
type Props = {}
|
|
||||||
|
|
||||||
const mapStateToProps = (state: any) => ({
|
|
||||||
isEnterprise: state.getIn(['user', 'account', 'edition']) === 'ee',
|
|
||||||
account: state.getIn(['user', 'account'])
|
|
||||||
});
|
|
||||||
|
|
||||||
const connector = connect(mapStateToProps);
|
|
||||||
|
|
||||||
function SessionsListingSettings(props: Props) {
|
|
||||||
return (
|
return (
|
||||||
<div className='bg-white rounded-lg border shadow-sm p-5'>
|
<div className="bg-white rounded-lg border shadow-sm p-5">
|
||||||
<PageTitle title={<div>Sessions Listing</div>} />
|
<PageTitle title={<div>Sessions Listing</div>} />
|
||||||
|
|
||||||
<div className='flex flex-col mt-4'>
|
<div className="flex flex-col mt-4">
|
||||||
<div className='max-w-lg'>
|
<div className="max-w-lg">
|
||||||
<ListingVisibility />
|
<ListingVisibility />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Divider />
|
<Divider />
|
||||||
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<DefaultPlaying />
|
<DefaultPlaying />
|
||||||
</div>
|
</div>
|
||||||
<Divider />
|
<Divider />
|
||||||
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<DefaultTimezone />
|
<DefaultTimezone />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -44,12 +32,11 @@ function SessionsListingSettings(props: Props) {
|
||||||
<div>
|
<div>
|
||||||
<MouseTrailSettings />
|
<MouseTrailSettings />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default connector(
|
export default withPageTitle('Sessions Listings - OpenReplay Preferences')(
|
||||||
withPageTitle('Sessions Listings - OpenReplay Preferences')(SessionsListingSettings)
|
SessionsListingSettings
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,22 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Tooltip, Button } from 'UI';
|
import { Tooltip, Button } from 'UI';
|
||||||
import { useStore } from 'App/mstore';
|
import { useStore } from 'App/mstore';
|
||||||
import { useObserver } from 'mobx-react-lite';
|
import { observer } from 'mobx-react-lite';
|
||||||
import { init, remove, fetchGDPR } from 'Duck/site';
|
|
||||||
import { connect } from 'react-redux';
|
|
||||||
import { useModal } from 'App/components/Modal';
|
import { useModal } from 'App/components/Modal';
|
||||||
import NewSiteForm from '../NewSiteForm';
|
import NewSiteForm from '../NewSiteForm';
|
||||||
|
|
||||||
const PERMISSION_WARNING = 'You don’t have the permissions to perform this action.';
|
const PERMISSION_WARNING = 'You don’t have the permissions to perform this action.';
|
||||||
const LIMIT_WARNING = 'You have reached site limit.';
|
const LIMIT_WARNING = 'You have reached site limit.';
|
||||||
|
|
||||||
function AddProjectButton({ isAdmin = false, init = () => {} }: any) {
|
function AddProjectButton({ isAdmin = false }: any) {
|
||||||
const { userStore } = useStore();
|
const { userStore, projectsStore } = useStore();
|
||||||
|
const init = projectsStore.initProject;
|
||||||
const { showModal, hideModal } = useModal();
|
const { showModal, hideModal } = useModal();
|
||||||
const limtis = useObserver(() => userStore.limits);
|
const limits = userStore.limits;
|
||||||
const canAddProject = useObserver(
|
const canAddProject = isAdmin && (limits.projects === -1 || limits.projects > 0)
|
||||||
() => isAdmin && (limtis.projects === -1 || limtis.projects > 0)
|
|
||||||
);
|
|
||||||
|
|
||||||
const onClick = () => {
|
const onClick = () => {
|
||||||
init();
|
init({});
|
||||||
showModal(<NewSiteForm onClose={hideModal} />, { right: true });
|
showModal(<NewSiteForm onClose={hideModal} />, { right: true });
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
|
|
@ -34,4 +31,4 @@ function AddProjectButton({ isAdmin = false, init = () => {} }: any) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default connect(null, { init, remove, fetchGDPR })(AddProjectButton);
|
export default observer(AddProjectButton);
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { connect } from 'react-redux';
|
import { observer } from 'mobx-react-lite';
|
||||||
|
import { useStore } from "App/mstore";
|
||||||
import { Form, Button, Input, Icon } from 'UI';
|
import { Form, Button, Input, Icon } from 'UI';
|
||||||
import { editGDPR, saveGDPR } from 'Duck/site';
|
|
||||||
import { validateNumber } from 'App/validate';
|
import { validateNumber } from 'App/validate';
|
||||||
import styles from './siteForm.module.css';
|
import styles from './siteForm.module.css';
|
||||||
import Select from 'Shared/Select';
|
import Select from 'Shared/Select';
|
||||||
|
|
@ -12,124 +12,118 @@ const inputModeOptions = [
|
||||||
{ label: 'Obscure all inputs', value: 'hidden' },
|
{ label: 'Obscure all inputs', value: 'hidden' },
|
||||||
];
|
];
|
||||||
|
|
||||||
@connect(state => ({
|
function GDPRForm(props) {
|
||||||
site: state.getIn([ 'site', 'instance' ]),
|
const { projectsStore } = useStore();
|
||||||
gdpr: state.getIn([ 'site', 'instance', 'gdpr' ]),
|
const site = projectsStore.instance;
|
||||||
saving: state.getIn([ 'site', 'saveGDPR', 'loading' ]),
|
const gdpr = site.gdpr;
|
||||||
}), {
|
const saving = false //projectsStore.;
|
||||||
editGDPR,
|
const editGDPR = projectsStore.editGDPR;
|
||||||
saveGDPR,
|
const saveGDPR = projectsStore.saveGDPR;
|
||||||
})
|
|
||||||
export default class GDPRForm extends React.PureComponent {
|
|
||||||
onChange = ({ target: { name, value } }) => {
|
const onChange = ({ target: { name, value } }) => {
|
||||||
if (name === "sampleRate") {
|
if (name === "sampleRate") {
|
||||||
if (!validateNumber(value, { min: 0, max: 100 })) return;
|
if (!validateNumber(value, { min: 0, max: 100 })) return;
|
||||||
if (value.length > 1 && value[0] === "0") {
|
if (value.length > 1 && value[0] === "0") {
|
||||||
value = value.slice(1);
|
value = value.slice(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.props.editGDPR({ [ name ]: value });
|
editGDPR({ [ name ]: value });
|
||||||
}
|
}
|
||||||
|
|
||||||
onSampleRateBlur = ({ target: { name, value } }) => { //TODO: editState hoc
|
const onSampleRateBlur = ({ target: { name, value } }) => { //TODO: editState hoc
|
||||||
if (value === ''){
|
if (value === ''){
|
||||||
this.props.editGDPR({ sampleRate: 100 });
|
editGDPR({ sampleRate: 100 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onChangeSelect = ({ name, value }) => {
|
const onChangeSelect = ({ name, value }) => {
|
||||||
this.props.editGDPR({ [ name ]: value });
|
props.editGDPR({ [ name ]: value });
|
||||||
};
|
};
|
||||||
|
|
||||||
onChangeOption = ({ target: { checked, name } }) => {
|
const onChangeOption = ({ target: { checked, name } }) => {
|
||||||
this.props.editGDPR({ [ name ]: checked });
|
editGDPR({ [ name ]: checked });
|
||||||
}
|
}
|
||||||
|
|
||||||
onSubmit = (e) => {
|
const onSubmit = (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const { site, gdpr } = this.props;
|
void saveGDPR(site.id);
|
||||||
this.props.saveGDPR(site.id, gdpr);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form className={ styles.formWrapper } onSubmit={ onSubmit }>
|
||||||
|
<div className={ styles.content }>
|
||||||
|
<Form.Field>
|
||||||
|
<label>{ 'Name' }</label>
|
||||||
|
<div>{ site.host }</div>
|
||||||
|
</Form.Field>
|
||||||
|
<Form.Field>
|
||||||
|
<label>{ 'Session Capture Rate' }</label>
|
||||||
|
<Input
|
||||||
|
icon="percent"
|
||||||
|
name="sampleRate"
|
||||||
|
value={ gdpr.sampleRate }
|
||||||
|
onChange={ onChange }
|
||||||
|
onBlur={ onSampleRateBlur }
|
||||||
|
className={ styles.sampleRate }
|
||||||
|
/>
|
||||||
|
</Form.Field>
|
||||||
|
|
||||||
render() {
|
<Form.Field>
|
||||||
const {
|
<label htmlFor="defaultInputMode">{ 'Data Recording Options' }</label>
|
||||||
site, onClose, saving, gdpr,
|
<Select
|
||||||
} = this.props;
|
name="defaultInputMode"
|
||||||
|
options={ inputModeOptions }
|
||||||
|
onChange={ onChangeSelect }
|
||||||
|
placeholder="Default Input Mode"
|
||||||
|
value={ gdpr.defaultInputMode }
|
||||||
|
/>
|
||||||
|
</Form.Field>
|
||||||
|
|
||||||
return (
|
<Form.Field>
|
||||||
<Form className={ styles.formWrapper } onSubmit={ this.onSubmit }>
|
<label>
|
||||||
<div className={ styles.content }>
|
<input
|
||||||
<Form.Field>
|
name="maskNumbers"
|
||||||
<label>{ 'Name' }</label>
|
type="checkbox"
|
||||||
<div>{ site.host }</div>
|
checked={ gdpr.maskNumbers }
|
||||||
</Form.Field>
|
onChange={ onChangeOption }
|
||||||
<Form.Field>
|
|
||||||
<label>{ 'Session Capture Rate' }</label>
|
|
||||||
<Input
|
|
||||||
icon="percent"
|
|
||||||
name="sampleRate"
|
|
||||||
value={ gdpr.sampleRate }
|
|
||||||
onChange={ this.onChange }
|
|
||||||
onBlur={ this.onSampleRateBlur }
|
|
||||||
className={ styles.sampleRate }
|
|
||||||
/>
|
/>
|
||||||
</Form.Field>
|
{ 'Do not record any numeric text' }
|
||||||
|
<div className={ styles.controlSubtext }>{ 'If enabled, OpenReplay will not record or store any numeric text for all sessions.' }</div>
|
||||||
|
</label>
|
||||||
|
</Form.Field>
|
||||||
|
|
||||||
<Form.Field>
|
<Form.Field>
|
||||||
<label htmlFor="defaultInputMode">{ 'Data Recording Options' }</label>
|
<label>
|
||||||
<Select
|
<input
|
||||||
name="defaultInputMode"
|
name="maskEmails"
|
||||||
options={ inputModeOptions }
|
type="checkbox"
|
||||||
onChange={ this.onChangeSelect }
|
checked={ gdpr.maskEmails }
|
||||||
placeholder="Default Input Mode"
|
onChange={ onChangeOption }
|
||||||
value={ gdpr.defaultInputMode }
|
|
||||||
// className={ styles.dropdown }
|
|
||||||
/>
|
/>
|
||||||
</Form.Field>
|
{ 'Do not record email addresses ' }
|
||||||
|
<div className={ styles.controlSubtext }>{ 'If enabled, OpenReplay will not record or store any email address for all sessions.' }</div>
|
||||||
|
</label>
|
||||||
|
</Form.Field>
|
||||||
|
|
||||||
<Form.Field>
|
<div className={ styles.blockIpWarapper }>
|
||||||
<label>
|
<div className={ styles.button } onClick={ props.toggleBlockedIp }>
|
||||||
<input
|
{ 'Block IP' } <Icon name="next1" size="18" />
|
||||||
name="maskNumbers"
|
|
||||||
type="checkbox"
|
|
||||||
checked={ gdpr.maskNumbers }
|
|
||||||
onChange={ this.onChangeOption }
|
|
||||||
/>
|
|
||||||
{ 'Do not record any numeric text' }
|
|
||||||
<div className={ styles.controlSubtext }>{ 'If enabled, OpenReplay will not record or store any numeric text for all sessions.' }</div>
|
|
||||||
</label>
|
|
||||||
</Form.Field>
|
|
||||||
|
|
||||||
<Form.Field>
|
|
||||||
<label>
|
|
||||||
<input
|
|
||||||
name="maskEmails"
|
|
||||||
type="checkbox"
|
|
||||||
checked={ gdpr.maskEmails }
|
|
||||||
onChange={ this.onChangeOption }
|
|
||||||
/>
|
|
||||||
{ 'Do not record email addresses ' }
|
|
||||||
<div className={ styles.controlSubtext }>{ 'If enabled, OpenReplay will not record or store any email address for all sessions.' }</div>
|
|
||||||
</label>
|
|
||||||
</Form.Field>
|
|
||||||
|
|
||||||
<div className={ styles.blockIpWarapper }>
|
|
||||||
<div className={ styles.button } onClick={ this.props.toggleBlockedIp }>
|
|
||||||
{ 'Block IP' } <Icon name="next1" size="18" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className={ styles.footer }>
|
<div className={ styles.footer }>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="float-left mr-2"
|
className="float-left mr-2"
|
||||||
loading={ saving }
|
loading={ saving }
|
||||||
content="Update"
|
content="Update"
|
||||||
/>
|
/>
|
||||||
<Button onClick={ onClose } content="Cancel" />
|
<Button onClick={ onClose } content="Cancel" />
|
||||||
</div>
|
</div>
|
||||||
</Form>
|
</Form>
|
||||||
);
|
)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default observer(GDPRForm);
|
||||||
|
|
@ -1,61 +1,48 @@
|
||||||
import { Segmented } from 'antd';
|
import { Segmented } from 'antd';
|
||||||
import React, { ChangeEvent, FormEvent, useEffect, useState } from 'react';
|
import React, { ChangeEvent, FormEvent, useEffect, useState } from 'react';
|
||||||
import { ConnectedProps, connect } from 'react-redux';
|
|
||||||
import { RouteComponentProps, withRouter } from 'react-router-dom';
|
import { RouteComponentProps, withRouter } from 'react-router-dom';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
|
import { useStore } from 'App/mstore';
|
||||||
import { withStore } from 'App/mstore';
|
import { Button, Form, Icon, Input } from 'UI';
|
||||||
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 { confirm } from 'UI';
|
||||||
|
import { observer } from 'mobx-react-lite';
|
||||||
|
|
||||||
import styles from './siteForm.module.css';
|
import styles from './siteForm.module.css';
|
||||||
|
|
||||||
type OwnProps = {
|
type OwnProps = {
|
||||||
onClose: (arg: any) => void;
|
onClose: (arg: any) => void;
|
||||||
mstore: any;
|
|
||||||
canDelete: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type PropsFromRedux = ConnectedProps<typeof connector>;
|
type Props = RouteComponentProps & OwnProps;
|
||||||
|
|
||||||
type Props = PropsFromRedux & RouteComponentProps & OwnProps;
|
|
||||||
|
|
||||||
const NewSiteForm = ({
|
const NewSiteForm = ({
|
||||||
site,
|
location: { pathname },
|
||||||
loading,
|
onClose
|
||||||
save,
|
}: Props) => {
|
||||||
remove,
|
const mstore = useStore();
|
||||||
edit,
|
const { projectsStore } = mstore;
|
||||||
update,
|
const activeSiteId = projectsStore.active?.id;
|
||||||
pushNewSite,
|
const site = projectsStore.instance;
|
||||||
fetchList,
|
const siteList = projectsStore.list;
|
||||||
setSiteId,
|
const loading = projectsStore.loading;
|
||||||
clearSearch,
|
const canDelete = siteList.length > 1;
|
||||||
clearSearchLive,
|
const setSiteId = projectsStore.setSiteId;
|
||||||
location: { pathname },
|
const saveProject = projectsStore.save;
|
||||||
onClose,
|
const fetchList = projectsStore.fetchList;
|
||||||
mstore,
|
|
||||||
activeSiteId,
|
|
||||||
canDelete,
|
|
||||||
}: Props) => {
|
|
||||||
const [existsError, setExistsError] = useState(false);
|
const [existsError, setExistsError] = useState(false);
|
||||||
|
const { searchStore } = useStore();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (pathname.includes('onboarding')) {
|
if (pathname.includes('onboarding') && site?.id) {
|
||||||
setSiteId(site.id);
|
setSiteId(site.id);
|
||||||
}
|
}
|
||||||
|
if (!site) projectsStore.initProject({});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const onSubmit = (e: FormEvent) => {
|
const onSubmit = (e: FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
if (site?.id && site.exists()) {
|
||||||
if (site.exists()) {
|
projectsStore.updateProject(site.id, site.toData()).then((response: any) => {
|
||||||
update(site, site.id).then((response: any) => {
|
|
||||||
if (!response || !response.errors || response.errors.size === 0) {
|
if (!response || !response.errors || response.errors.size === 0) {
|
||||||
onClose(null);
|
onClose(null);
|
||||||
if (!pathname.includes('onboarding')) {
|
if (!pathname.includes('onboarding')) {
|
||||||
|
|
@ -67,11 +54,11 @@ const NewSiteForm = ({
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
save(site).then((response: any) => {
|
saveProject(site!).then((response: any) => {
|
||||||
if (!response || !response.errors || response.errors.size === 0) {
|
if (!response || !response.errors || response.errors.size === 0) {
|
||||||
onClose(null);
|
onClose(null);
|
||||||
clearSearch();
|
searchStore.clearSearch();
|
||||||
clearSearchLive();
|
mstore.searchStoreLive.clearSearch();
|
||||||
mstore.initClient();
|
mstore.initClient();
|
||||||
toast.success('Project added successfully');
|
toast.success('Project added successfully');
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -87,10 +74,11 @@ const NewSiteForm = ({
|
||||||
header: 'Project Deletion Alert',
|
header: 'Project Deletion Alert',
|
||||||
confirmation: `Are you sure you want to delete this project? Deleting it will permanently remove the project, along with all associated sessions and data.`,
|
confirmation: `Are you sure you want to delete this project? Deleting it will permanently remove the project, along with all associated sessions and data.`,
|
||||||
confirmButton: 'Yes, delete',
|
confirmButton: 'Yes, delete',
|
||||||
cancelButton: 'Cancel',
|
cancelButton: 'Cancel'
|
||||||
})
|
})
|
||||||
|
&& site?.id
|
||||||
) {
|
) {
|
||||||
remove(site.id).then(() => {
|
projectsStore.removeProject(site.id).then(() => {
|
||||||
onClose(null);
|
onClose(null);
|
||||||
if (site.id === activeSiteId) {
|
if (site.id === activeSiteId) {
|
||||||
setSiteId(null);
|
setSiteId(null);
|
||||||
|
|
@ -100,12 +88,15 @@ const NewSiteForm = ({
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEdit = ({
|
const handleEdit = ({
|
||||||
target: { name, value },
|
target: { name, value }
|
||||||
}: ChangeEvent<HTMLInputElement>) => {
|
}: ChangeEvent<HTMLInputElement>) => {
|
||||||
setExistsError(false);
|
setExistsError(false);
|
||||||
edit({ [name]: value });
|
projectsStore.editInstance({ [name]: value });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (!site) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="bg-white h-screen overflow-y-auto"
|
className="bg-white h-screen overflow-y-auto"
|
||||||
|
|
@ -116,7 +107,7 @@ const NewSiteForm = ({
|
||||||
</h3>
|
</h3>
|
||||||
<Form
|
<Form
|
||||||
className={styles.formWrapper}
|
className={styles.formWrapper}
|
||||||
onSubmit={site.validate() && onSubmit}
|
onSubmit={site.validate && onSubmit}
|
||||||
>
|
>
|
||||||
<div className={styles.content}>
|
<div className={styles.content}>
|
||||||
<Form.Field>
|
<Form.Field>
|
||||||
|
|
@ -137,16 +128,16 @@ const NewSiteForm = ({
|
||||||
options={[
|
options={[
|
||||||
{
|
{
|
||||||
value: 'web',
|
value: 'web',
|
||||||
label: 'Web',
|
label: 'Web'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 'ios',
|
value: 'ios',
|
||||||
label: 'Mobile',
|
label: 'Mobile'
|
||||||
},
|
}
|
||||||
]}
|
]}
|
||||||
value={site.platform}
|
value={site.platform}
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
edit({ platform: value });
|
projectsStore.editInstance({ platform: value });
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -157,9 +148,9 @@ const NewSiteForm = ({
|
||||||
type="submit"
|
type="submit"
|
||||||
className="float-left mr-2"
|
className="float-left mr-2"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
disabled={!site.validate()}
|
disabled={!site.validate}
|
||||||
>
|
>
|
||||||
{site.exists() ? 'Update' : 'Add'}
|
{site?.exists() ? 'Update' : 'Add'}
|
||||||
</Button>
|
</Button>
|
||||||
{site.exists() && (
|
{site.exists() && (
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -183,26 +174,4 @@ const NewSiteForm = ({
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const mapStateToProps = (state: any) => ({
|
export default withRouter(observer(NewSiteForm));
|
||||||
activeSiteId: state.getIn(['site', 'active', 'id']),
|
|
||||||
site: state.getIn(['site', 'instance']),
|
|
||||||
siteList: state.getIn(['site', 'list']),
|
|
||||||
loading:
|
|
||||||
state.getIn(['site', 'save', 'loading']) ||
|
|
||||||
state.getIn(['site', 'remove', 'loading']),
|
|
||||||
canDelete: state.getIn(['site', 'list']).size > 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
const connector = connect(mapStateToProps, {
|
|
||||||
save,
|
|
||||||
remove,
|
|
||||||
edit,
|
|
||||||
update,
|
|
||||||
pushNewSite,
|
|
||||||
fetchList,
|
|
||||||
setSiteId,
|
|
||||||
clearSearch,
|
|
||||||
clearSearchLive,
|
|
||||||
});
|
|
||||||
|
|
||||||
export default connector(withRouter(withStore(NewSiteForm)));
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,7 @@
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { connect, ConnectedProps } from 'react-redux';
|
|
||||||
import { Tag } from 'antd';
|
import { Tag } from 'antd';
|
||||||
import cn from 'classnames';
|
import cn from 'classnames';
|
||||||
import { Loader, Button, TextLink, NoContent, Pagination, PageTitle, Divider, Icon } from 'UI';
|
import { Loader, Button, TextLink, NoContent, Pagination, PageTitle, Divider, Icon } from 'UI';
|
||||||
import { init, remove, fetchGDPR, setSiteId } from 'Duck/site';
|
|
||||||
import withPageTitle from 'HOCs/withPageTitle';
|
import withPageTitle from 'HOCs/withPageTitle';
|
||||||
import stl from './sites.module.css';
|
import stl from './sites.module.css';
|
||||||
import NewSiteForm from './NewSiteForm';
|
import NewSiteForm from './NewSiteForm';
|
||||||
|
|
@ -16,9 +14,11 @@ import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG';
|
||||||
import { useModal } from 'App/components/Modal';
|
import { useModal } from 'App/components/Modal';
|
||||||
import CaptureRate from 'Shared/SessionSettings/components/CaptureRate';
|
import CaptureRate from 'Shared/SessionSettings/components/CaptureRate';
|
||||||
import { BranchesOutlined } from '@ant-design/icons';
|
import { BranchesOutlined } from '@ant-design/icons';
|
||||||
|
import { observer } from 'mobx-react-lite';
|
||||||
|
import { useStore } from 'App/mstore'
|
||||||
|
|
||||||
type Project = {
|
type Project = {
|
||||||
id: number;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
conditionsCount: number;
|
conditionsCount: number;
|
||||||
platform: 'web' | 'mobile';
|
platform: 'web' | 'mobile';
|
||||||
|
|
@ -27,9 +27,12 @@ type Project = {
|
||||||
sampleRate: number;
|
sampleRate: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type PropsFromRedux = ConnectedProps<typeof connector>;
|
const Sites = () => {
|
||||||
|
const { projectsStore, userStore } = useStore();
|
||||||
const Sites = ({ loading, sites, user, init }: PropsFromRedux) => {
|
const user = userStore.account;
|
||||||
|
const sites = projectsStore.list;
|
||||||
|
const loading = projectsStore.sitesLoading;
|
||||||
|
const init = projectsStore.initProject
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [showCaptureRate, setShowCaptureRate] = useState(true);
|
const [showCaptureRate, setShowCaptureRate] = useState(true);
|
||||||
const [activeProject, setActiveProject] = useState<Project | null>(null);
|
const [activeProject, setActiveProject] = useState<Project | null>(null);
|
||||||
|
|
@ -140,7 +143,7 @@ const Sites = ({ loading, sites, user, init }: PropsFromRedux) => {
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
size="small"
|
size="small"
|
||||||
show={!loading && filteredSites.size === 0}
|
show={!loading && filteredSites.length === 0}
|
||||||
>
|
>
|
||||||
<div className="grid grid-cols-12 gap-2 w-full items-center px-5 py-3 font-medium">
|
<div className="grid grid-cols-12 gap-2 w-full items-center px-5 py-3 font-medium">
|
||||||
<div className="col-span-4">Project Name</div>
|
<div className="col-span-4">Project Name</div>
|
||||||
|
|
@ -160,7 +163,7 @@ const Sites = ({ loading, sites, user, init }: PropsFromRedux) => {
|
||||||
<div className="w-full flex items-center justify-center py-10">
|
<div className="w-full flex items-center justify-center py-10">
|
||||||
<Pagination
|
<Pagination
|
||||||
page={page}
|
page={page}
|
||||||
total={filteredSites.size}
|
total={filteredSites.length}
|
||||||
onPageChange={(page) => updatePage(page)}
|
onPageChange={(page) => updatePage(page)}
|
||||||
limit={pageSize}
|
limit={pageSize}
|
||||||
/>
|
/>
|
||||||
|
|
@ -180,19 +183,4 @@ const Sites = ({ loading, sites, user, init }: PropsFromRedux) => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const mapStateToProps = (state: any) => ({
|
export default withPageTitle('Projects - OpenReplay Preferences')(observer(Sites));
|
||||||
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,
|
|
||||||
});
|
|
||||||
|
|
||||||
export default connector(withPageTitle('Projects - OpenReplay Preferences')(Sites));
|
|
||||||
|
|
|
||||||
|
|
@ -6,18 +6,17 @@ import { useObserver } from 'mobx-react-lite';
|
||||||
import UserSearch from './components/UserSearch';
|
import UserSearch from './components/UserSearch';
|
||||||
import { useModal } from 'App/components/Modal';
|
import { useModal } from 'App/components/Modal';
|
||||||
import UserForm from './components/UserForm';
|
import UserForm from './components/UserForm';
|
||||||
import { connect } from 'react-redux';
|
import { observer } from 'mobx-react-lite';
|
||||||
import AddUserButton from './components/AddUserButton';
|
import AddUserButton from './components/AddUserButton';
|
||||||
import withPageTitle from 'HOCs/withPageTitle';
|
import withPageTitle from 'HOCs/withPageTitle';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
isOnboarding?: boolean;
|
isOnboarding?: boolean;
|
||||||
account: any;
|
|
||||||
isEnterprise: boolean;
|
|
||||||
}
|
}
|
||||||
function UsersView(props: Props) {
|
function UsersView({ isOnboarding = false }: Props) {
|
||||||
const { account, isEnterprise, isOnboarding = false } = props;
|
|
||||||
const { userStore, roleStore } = useStore();
|
const { userStore, roleStore } = useStore();
|
||||||
|
const account = userStore.account;
|
||||||
|
const isEnterprise = userStore.isEnterprise;
|
||||||
const userCount = useObserver(() => userStore.list.length);
|
const userCount = useObserver(() => userStore.list.length);
|
||||||
const roles = useObserver(() => roleStore.list);
|
const roles = useObserver(() => roleStore.list);
|
||||||
const { showModal } = useModal();
|
const { showModal } = useModal();
|
||||||
|
|
@ -31,7 +30,7 @@ function UsersView(props: Props) {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (roles.length === 0 && isEnterprise) {
|
if (roles.length === 0 && isEnterprise) {
|
||||||
roleStore.fetchRoles();
|
void roleStore.fetchRoles();
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
@ -60,7 +59,4 @@ function UsersView(props: Props) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default connect((state: any) => ({
|
export default withPageTitle('Team - OpenReplay Preferences')(observer(UsersView));
|
||||||
account: state.getIn(['user', 'account']),
|
|
||||||
isEnterprise: state.getIn(['user', 'account', 'edition']) === 'ee',
|
|
||||||
}))(withPageTitle('Team - OpenReplay Preferences')(UsersView));
|
|
||||||
|
|
|
||||||
|
|
@ -1,162 +1,171 @@
|
||||||
import React from 'react';
|
|
||||||
import { Form, Input, CopyButton, Button, Icon } from 'UI'
|
|
||||||
import cn from 'classnames';
|
import cn from 'classnames';
|
||||||
import { useStore } from 'App/mstore';
|
|
||||||
import { useObserver } from 'mobx-react-lite';
|
import { useObserver } from 'mobx-react-lite';
|
||||||
|
import { observer } from 'mobx-react-lite';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
import { useModal } from 'App/components/Modal';
|
import { useModal } from 'App/components/Modal';
|
||||||
import Select from 'Shared/Select';
|
import { useStore } from 'App/mstore';
|
||||||
|
import { Button, CopyButton, Form, Icon, Input } from 'UI';
|
||||||
import { confirm } from 'UI';
|
import { confirm } from 'UI';
|
||||||
import { connect } from 'react-redux';
|
|
||||||
|
|
||||||
interface Props {
|
import Select from 'Shared/Select';
|
||||||
isSmtp?: boolean;
|
|
||||||
isEnterprise?: boolean;
|
|
||||||
}
|
|
||||||
function UserForm(props: Props) {
|
|
||||||
const { isSmtp = false, isEnterprise = false } = props;
|
|
||||||
const { hideModal } = useModal();
|
|
||||||
const { userStore, roleStore } = useStore();
|
|
||||||
const isSaving = useObserver(() => userStore.saving);
|
|
||||||
const user: any = useObserver(() => userStore.instance || userStore.initUser());
|
|
||||||
const roles = useObserver(() => roleStore.list.filter(r => r.isProtected ? user.isSuperAdmin : true).map(r => ({ label: r.name, value: r.roleId })));
|
|
||||||
|
|
||||||
const onChangeCheckbox = (e: any) => {
|
function UserForm() {
|
||||||
user.updateKey('isAdmin', !user.isAdmin);
|
const { hideModal } = useModal();
|
||||||
|
const { userStore, roleStore } = useStore();
|
||||||
|
const isEnterprise = userStore.isEnterprise;
|
||||||
|
const isSmtp = userStore.account.smtp;
|
||||||
|
const isSaving = userStore.saving;
|
||||||
|
const user: any = userStore.instance || userStore.initUser();
|
||||||
|
const roles = roleStore.list
|
||||||
|
.filter((r) => (r.isProtected ? user.isSuperAdmin : true))
|
||||||
|
.map((r) => ({ label: r.name, value: r.roleId }));
|
||||||
|
|
||||||
|
const onChangeCheckbox = (e: any) => {
|
||||||
|
user.updateKey('isAdmin', !user.isAdmin);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSave = () => {
|
||||||
|
userStore.saveUser(user).then(() => {
|
||||||
|
hideModal();
|
||||||
|
userStore.fetchLimits();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const write = ({ target: { name, value } }) => {
|
||||||
|
user.updateKey(name, value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteHandler = async () => {
|
||||||
|
if (
|
||||||
|
await confirm({
|
||||||
|
header: 'Confirm',
|
||||||
|
confirmButton: 'Yes, delete',
|
||||||
|
confirmation: `Are you sure you want to permanently delete this user?`,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
userStore.deleteUser(user.userId).then(() => {
|
||||||
|
hideModal();
|
||||||
|
userStore.fetchLimits();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const onSave = () => {
|
return useObserver(() => (
|
||||||
userStore.saveUser(user).then(() => {
|
<div className="bg-white h-screen p-6">
|
||||||
hideModal();
|
<div className="">
|
||||||
userStore.fetchLimits();
|
<h1 className="text-2xl mb-4">{`${
|
||||||
});
|
user.exists() ? 'Update' : 'Invite'
|
||||||
}
|
} User`}</h1>
|
||||||
|
</div>
|
||||||
|
<Form onSubmit={onSave}>
|
||||||
|
<Form.Field>
|
||||||
|
<label>{'Full Name'}</label>
|
||||||
|
<Input
|
||||||
|
name="name"
|
||||||
|
autoFocus
|
||||||
|
maxLength="50"
|
||||||
|
value={user.name}
|
||||||
|
onChange={write}
|
||||||
|
className="w-full"
|
||||||
|
id="name-field"
|
||||||
|
/>
|
||||||
|
</Form.Field>
|
||||||
|
|
||||||
const write = ({ target: { name, value } }) => {
|
<div className="form-group">
|
||||||
user.updateKey(name, value);
|
<label>{'Email Address'}</label>
|
||||||
}
|
<Input
|
||||||
|
disabled={user.exists()}
|
||||||
const deleteHandler = async () => {
|
name="email"
|
||||||
if (await confirm({
|
maxLength="320"
|
||||||
header: 'Confirm',
|
value={user.email}
|
||||||
confirmButton: 'Yes, delete',
|
onChange={write}
|
||||||
confirmation: `Are you sure you want to permanently delete this user?`
|
className="w-full"
|
||||||
})) {
|
/>
|
||||||
userStore.deleteUser(user.userId).then(() => {
|
|
||||||
hideModal();
|
|
||||||
userStore.fetchLimits();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return useObserver(() => (
|
|
||||||
<div className="bg-white h-screen p-6">
|
|
||||||
<div className="">
|
|
||||||
<h1 className="text-2xl mb-4">{`${user.exists() ? 'Update' : 'Invite'} User`}</h1>
|
|
||||||
</div>
|
|
||||||
<Form onSubmit={ onSave } >
|
|
||||||
<Form.Field>
|
|
||||||
<label>{ 'Full Name' }</label>
|
|
||||||
<Input
|
|
||||||
name="name"
|
|
||||||
autoFocus
|
|
||||||
maxLength="50"
|
|
||||||
value={ user.name }
|
|
||||||
onChange={ write }
|
|
||||||
className="w-full"
|
|
||||||
id="name-field"
|
|
||||||
/>
|
|
||||||
</Form.Field>
|
|
||||||
|
|
||||||
<div className="form-group">
|
|
||||||
<label>{ 'Email Address' }</label>
|
|
||||||
<Input
|
|
||||||
disabled={user.exists()}
|
|
||||||
name="email"
|
|
||||||
maxLength="320"
|
|
||||||
value={ user.email }
|
|
||||||
onChange={ write }
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{ !isSmtp &&
|
|
||||||
<div className={cn("mb-4 p-2 bg-yellow rounded")}>
|
|
||||||
SMTP is not configured (see <a className="link" href="https://docs.openreplay.com/configuration/configure-smtp" target="_blank">here</a> how to set it up). You can still add new users, but you’d have to manually copy then send them the invitation link.
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
<Form.Field>
|
|
||||||
<label className="flex items-start cursor-pointer">
|
|
||||||
<input
|
|
||||||
name="admin"
|
|
||||||
type="checkbox"
|
|
||||||
checked={ !!user.isAdmin || !!user.isSuperAdmin }
|
|
||||||
onChange={ onChangeCheckbox }
|
|
||||||
disabled={user.isSuperAdmin}
|
|
||||||
className="mt-1"
|
|
||||||
/>
|
|
||||||
<div className="ml-2 select-none">
|
|
||||||
<span>Admin Privileges</span>
|
|
||||||
<div className="text-sm color-gray-medium -mt-1">{ 'Can manage Projects and team members.' }</div>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
</Form.Field>
|
|
||||||
|
|
||||||
{ isEnterprise && (
|
|
||||||
<Form.Field>
|
|
||||||
<label htmlFor="role">{ 'Role' }</label>
|
|
||||||
<Select
|
|
||||||
placeholder="Select Role"
|
|
||||||
selection
|
|
||||||
options={ roles }
|
|
||||||
name="roleId"
|
|
||||||
defaultValue={ user.roleId }
|
|
||||||
onChange={({ value }) => user.updateKey('roleId', value.value)}
|
|
||||||
className="block"
|
|
||||||
isDisabled={user.isSuperAdmin}
|
|
||||||
/>
|
|
||||||
</Form.Field>
|
|
||||||
)}
|
|
||||||
</Form>
|
|
||||||
|
|
||||||
<div className="flex items-center">
|
|
||||||
<div className="flex items-center mr-auto">
|
|
||||||
<Button
|
|
||||||
onClick={ onSave }
|
|
||||||
disabled={ !user.valid(isEnterprise) || isSaving }
|
|
||||||
loading={ isSaving }
|
|
||||||
variant="primary"
|
|
||||||
className="float-left mr-2"
|
|
||||||
>
|
|
||||||
{ user.exists() ? 'Update' : 'Invite' }
|
|
||||||
</Button>
|
|
||||||
{user.exists() && (
|
|
||||||
<Button onClick={ hideModal }>
|
|
||||||
{ 'Cancel' }
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Button
|
|
||||||
disabled={user.isSuperAdmin}
|
|
||||||
data-hidden={ !user.exists() }
|
|
||||||
onClick={ deleteHandler }
|
|
||||||
>
|
|
||||||
<Icon name="trash" size="16" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{ !user.isJoined && user.invitationLink &&
|
|
||||||
<CopyButton
|
|
||||||
content={user.invitationLink}
|
|
||||||
className="link mt-4"
|
|
||||||
btnText="Copy invite link"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
));
|
{!isSmtp && (
|
||||||
|
<div className={cn('mb-4 p-2 bg-yellow rounded')}>
|
||||||
|
SMTP is not configured (see{' '}
|
||||||
|
<a
|
||||||
|
className="link"
|
||||||
|
href="https://docs.openreplay.com/configuration/configure-smtp"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
here
|
||||||
|
</a>{' '}
|
||||||
|
how to set it up). You can still add new users, but you’d have to
|
||||||
|
manually copy then send them the invitation link.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Form.Field>
|
||||||
|
<label className="flex items-start cursor-pointer">
|
||||||
|
<input
|
||||||
|
name="admin"
|
||||||
|
type="checkbox"
|
||||||
|
checked={!!user.isAdmin || !!user.isSuperAdmin}
|
||||||
|
onChange={onChangeCheckbox}
|
||||||
|
disabled={user.isSuperAdmin}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
<div className="ml-2 select-none">
|
||||||
|
<span>Admin Privileges</span>
|
||||||
|
<div className="text-sm color-gray-medium -mt-1">
|
||||||
|
{'Can manage Projects and team members.'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</Form.Field>
|
||||||
|
|
||||||
|
{isEnterprise && (
|
||||||
|
<Form.Field>
|
||||||
|
<label htmlFor="role">{'Role'}</label>
|
||||||
|
<Select
|
||||||
|
placeholder="Select Role"
|
||||||
|
selection
|
||||||
|
options={roles}
|
||||||
|
name="roleId"
|
||||||
|
defaultValue={user.roleId}
|
||||||
|
onChange={({ value }) => user.updateKey('roleId', value.value)}
|
||||||
|
className="block"
|
||||||
|
isDisabled={user.isSuperAdmin}
|
||||||
|
/>
|
||||||
|
</Form.Field>
|
||||||
|
)}
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="flex items-center mr-auto">
|
||||||
|
<Button
|
||||||
|
onClick={onSave}
|
||||||
|
disabled={!user.valid(isEnterprise) || isSaving}
|
||||||
|
loading={isSaving}
|
||||||
|
variant="primary"
|
||||||
|
className="float-left mr-2"
|
||||||
|
>
|
||||||
|
{user.exists() ? 'Update' : 'Invite'}
|
||||||
|
</Button>
|
||||||
|
{user.exists() && <Button onClick={hideModal}>{'Cancel'}</Button>}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
disabled={user.isSuperAdmin}
|
||||||
|
data-hidden={!user.exists()}
|
||||||
|
onClick={deleteHandler}
|
||||||
|
>
|
||||||
|
<Icon name="trash" size="16" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!user.isJoined && user.invitationLink && (
|
||||||
|
<CopyButton
|
||||||
|
content={user.invitationLink}
|
||||||
|
className="link mt-4"
|
||||||
|
btnText="Copy invite link"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
export default connect((state: any) => ({
|
export default observer(UserForm);
|
||||||
isEnterprise: state.getIn([ 'user', 'account', 'edition' ]) === 'ee',
|
|
||||||
isSmtp: state.getIn([ 'user', 'account', 'smtp' ]),
|
|
||||||
}))(UserForm);
|
|
||||||
|
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
import React from 'react'
|
|
||||||
import { connect } from 'react-redux'
|
|
||||||
import cn from 'classnames'
|
|
||||||
import stl from './widgetHolder.module.css'
|
|
||||||
import LazyLoad from 'react-lazyload';
|
|
||||||
|
|
||||||
const WidgetHolder = props => {
|
|
||||||
const { comparing, Component, period, periodCompare, fullWidth = false } = props;
|
|
||||||
const showSync = comparing && period.rangeName === periodCompare.rangeName;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={ cn(stl.wrapper, { 'grid grid-cols-2 gap-4 mb-2' : comparing && !fullWidth })}>
|
|
||||||
<LazyLoad height={300} offset={320} >
|
|
||||||
{<Component showSync={showSync} />}
|
|
||||||
</LazyLoad>
|
|
||||||
{comparing && (
|
|
||||||
<LazyLoad height={300} offset={320}>
|
|
||||||
<React.Fragment>
|
|
||||||
<div className={fullWidth ? 'mt-4' : ''}>
|
|
||||||
<Component compare showSync={showSync} />
|
|
||||||
</div>
|
|
||||||
</React.Fragment>
|
|
||||||
</LazyLoad>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default connect(state => ({
|
|
||||||
comparing: state.getIn([ 'dashboard', 'comparing' ]),
|
|
||||||
period: state.getIn([ 'dashboard', 'period' ]),
|
|
||||||
periodCompare: state.getIn([ 'dashboard', 'periodCompare' ])
|
|
||||||
}))(WidgetHolder)
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
export { default } from './WidgetHolder';
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
.wrapper {
|
|
||||||
border: dotted 2px transparent;
|
|
||||||
border-radius: 3px;
|
|
||||||
margin: -5px;
|
|
||||||
padding: 5px;
|
|
||||||
transition: all 0.3s;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
transition: all 0.2s;
|
|
||||||
border: dotted 2px $gray-medium;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -2,17 +2,13 @@ import React from 'react'
|
||||||
import { useStore } from 'App/mstore'
|
import { useStore } from 'App/mstore'
|
||||||
import { observer } from 'mobx-react-lite'
|
import { observer } from 'mobx-react-lite'
|
||||||
import ClickMapRenderer from 'App/components/Session/Player/ClickMapRenderer'
|
import ClickMapRenderer from 'App/components/Session/Player/ClickMapRenderer'
|
||||||
import { connect } from 'react-redux'
|
|
||||||
import { fetchInsights } from 'App/duck/sessions'
|
|
||||||
import { NoContent, Icon } from 'App/components/ui'
|
import { NoContent, Icon } from 'App/components/ui'
|
||||||
|
|
||||||
function ClickMapCard({
|
function ClickMapCard() {
|
||||||
insights,
|
|
||||||
fetchInsights,
|
|
||||||
insightsFilters,
|
|
||||||
}: any) {
|
|
||||||
const [customSession, setCustomSession] = React.useState<any>(null)
|
const [customSession, setCustomSession] = React.useState<any>(null)
|
||||||
const { metricStore, dashboardStore } = useStore();
|
const { metricStore, dashboardStore, sessionStore } = useStore();
|
||||||
|
const fetchInsights = sessionStore.fetchInsights
|
||||||
|
const insights = sessionStore.insights
|
||||||
const onMarkerClick = (s: string, innerText: string) => {
|
const onMarkerClick = (s: string, innerText: string) => {
|
||||||
metricStore.changeClickMapSearch(s, innerText)
|
metricStore.changeClickMapSearch(s, innerText)
|
||||||
}
|
}
|
||||||
|
|
@ -36,7 +32,7 @@ function ClickMapCard({
|
||||||
const rangeValue = dashboardStore.drillDownPeriod.rangeValue
|
const rangeValue = dashboardStore.drillDownPeriod.rangeValue
|
||||||
const startDate = dashboardStore.drillDownPeriod.start
|
const startDate = dashboardStore.drillDownPeriod.start
|
||||||
const endDate = dashboardStore.drillDownPeriod.end
|
const endDate = dashboardStore.drillDownPeriod.end
|
||||||
fetchInsights({ ...insightsFilters, url: mapUrl || '/', startDate, endDate, rangeValue, clickRage: metricStore.clickMapFilter })
|
void fetchInsights({ url: mapUrl || '/', startDate, endDate, rangeValue, clickRage: metricStore.clickMapFilter })
|
||||||
}, [dashboardStore.drillDownPeriod.start, dashboardStore.drillDownPeriod.end, dashboardStore.drillDownPeriod.rangeValue, metricStore.clickMapFilter])
|
}, [dashboardStore.drillDownPeriod.start, dashboardStore.drillDownPeriod.end, dashboardStore.drillDownPeriod.rangeValue, metricStore.clickMapFilter])
|
||||||
|
|
||||||
if (!metricStore.instance.data.domURL || insights.size === 0) {
|
if (!metricStore.instance.data.domURL || insights.size === 0) {
|
||||||
|
|
@ -76,13 +72,4 @@ function ClickMapCard({
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default connect(
|
export default observer(ClickMapCard)
|
||||||
(state: any) => ({
|
|
||||||
insightsFilters: state.getIn(['sessions', 'insightFilters']),
|
|
||||||
visitedEvents: state.getIn(['sessions', 'visitedEvents']),
|
|
||||||
insights: state.getIn(['sessions', 'insights']),
|
|
||||||
host: state.getIn(['sessions', 'host']),
|
|
||||||
}),
|
|
||||||
{ fetchInsights, }
|
|
||||||
)
|
|
||||||
(observer(ClickMapCard))
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import React, { useEffect } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import { Form, SegmentSelection } from 'UI';
|
import { Form, SegmentSelection } from 'UI';
|
||||||
import { connect } from 'react-redux';
|
|
||||||
import { validateEmail } from 'App/validate';
|
import { validateEmail } from 'App/validate';
|
||||||
import { confirm } from 'UI';
|
import { confirm } from 'UI';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@ import {
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import { observer } from 'mobx-react-lite';
|
import { observer } from 'mobx-react-lite';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { connect } from 'react-redux';
|
|
||||||
import { useHistory } from 'react-router';
|
import { useHistory } from 'react-router';
|
||||||
|
|
||||||
import { checkForRecent } from 'App/date';
|
import { checkForRecent } from 'App/date';
|
||||||
|
|
@ -24,11 +23,12 @@ import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG';
|
||||||
|
|
||||||
import DashboardEditModal from '../DashboardEditModal';
|
import DashboardEditModal from '../DashboardEditModal';
|
||||||
|
|
||||||
function DashboardList({ siteId }: { siteId: string }) {
|
function DashboardList() {
|
||||||
|
const { dashboardStore, projectsStore } = useStore();
|
||||||
|
const siteId = projectsStore.siteId;
|
||||||
const [focusTitle, setFocusedInput] = React.useState(true);
|
const [focusTitle, setFocusedInput] = React.useState(true);
|
||||||
const [showEditModal, setShowEditModal] = React.useState(false);
|
const [showEditModal, setShowEditModal] = React.useState(false);
|
||||||
|
|
||||||
const { dashboardStore } = useStore();
|
|
||||||
const list = dashboardStore.filteredList;
|
const list = dashboardStore.filteredList;
|
||||||
const dashboardsSearch = dashboardStore.filter.query;
|
const dashboardsSearch = dashboardStore.filter.query;
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
|
|
@ -219,6 +219,4 @@ function DashboardList({ siteId }: { siteId: string }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default connect((state: any) => ({
|
export default observer(DashboardList);
|
||||||
siteId: state.getIn(['site', 'siteId']),
|
|
||||||
}))(observer(DashboardList));
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { Modal } from 'antd';
|
import { Modal } from 'antd';
|
||||||
import React, { useEffect } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import { connect } from 'react-redux';
|
|
||||||
import colors from 'tailwindcss/colors';
|
import colors from 'tailwindcss/colors';
|
||||||
|
import { observer } from 'mobx-react-lite';
|
||||||
|
import { useStore } from 'App/mstore';
|
||||||
import CreateCard from 'Components/Dashboard/components/DashboardList/NewDashModal/CreateCard';
|
import CreateCard from 'Components/Dashboard/components/DashboardList/NewDashModal/CreateCard';
|
||||||
|
|
||||||
import SelectCard from './SelectCard';
|
import SelectCard from './SelectCard';
|
||||||
|
|
@ -12,16 +12,16 @@ interface NewDashboardModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
isAddingFromLibrary?: boolean;
|
isAddingFromLibrary?: boolean;
|
||||||
isEnterprise?: boolean;
|
isEnterprise?: boolean;
|
||||||
isMobile?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const NewDashboardModal: React.FC<NewDashboardModalProps> = ({
|
const NewDashboardModal: React.FC<NewDashboardModalProps> = ({
|
||||||
onClose,
|
onClose,
|
||||||
open,
|
open,
|
||||||
isAddingFromLibrary = false,
|
isAddingFromLibrary = false,
|
||||||
isEnterprise = false,
|
|
||||||
isMobile = false,
|
|
||||||
}) => {
|
}) => {
|
||||||
|
const { projectsStore, userStore } = useStore();
|
||||||
|
const isEnterprise = userStore.isEnterprise;
|
||||||
|
const isMobile = projectsStore.isMobile;
|
||||||
const [step, setStep] = React.useState<number>(0);
|
const [step, setStep] = React.useState<number>(0);
|
||||||
const [selectedCategory, setSelectedCategory] =
|
const [selectedCategory, setSelectedCategory] =
|
||||||
React.useState<string>('product-analytics');
|
React.useState<string>('product-analytics');
|
||||||
|
|
@ -74,11 +74,4 @@ const NewDashboardModal: React.FC<NewDashboardModalProps> = ({
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const mapStateToProps = (state: any) => ({
|
export default observer(NewDashboardModal);
|
||||||
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);
|
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,18 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { ItemMenu } from 'UI';
|
import { ItemMenu } from 'UI';
|
||||||
import { connect } from 'react-redux';
|
import { observer } from 'mobx-react-lite';
|
||||||
|
import { useStore } from "App/mstore";
|
||||||
import { ENTERPRISE_REQUEIRED } from 'App/constants';
|
import { ENTERPRISE_REQUEIRED } from 'App/constants';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
editHandler: (isTitle: boolean) => void;
|
editHandler: (isTitle: boolean) => void;
|
||||||
deleteHandler: any;
|
deleteHandler: any;
|
||||||
renderReport: any;
|
renderReport: any;
|
||||||
isEnterprise: boolean;
|
|
||||||
isTitlePresent?: boolean;
|
|
||||||
}
|
}
|
||||||
function DashboardOptions(props: Props) {
|
function DashboardOptions(props: Props) {
|
||||||
const { editHandler, deleteHandler, renderReport, isEnterprise, isTitlePresent } = props;
|
const { userStore } = useStore();
|
||||||
|
const isEnterprise = userStore.isEnterprise;
|
||||||
|
const { editHandler, deleteHandler, renderReport } = props;
|
||||||
const menuItems = [
|
const menuItems = [
|
||||||
{ icon: 'pencil', text: 'Rename', onClick: () => editHandler(true) },
|
{ icon: 'pencil', text: 'Rename', onClick: () => editHandler(true) },
|
||||||
{ icon: 'users', text: 'Visibility & Access', onClick: editHandler },
|
{ icon: 'users', text: 'Visibility & Access', onClick: editHandler },
|
||||||
|
|
@ -27,6 +28,4 @@ function DashboardOptions(props: Props) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default connect(state => ({
|
export default observer(DashboardOptions);
|
||||||
isEnterprise: state.getIn([ 'user', 'account', 'edition' ]) === 'ee' || state.getIn([ 'user', 'account', 'edition' ]) === 'msaas',
|
|
||||||
}))(DashboardOptions);
|
|
||||||
|
|
|
||||||
|
|
@ -2,17 +2,13 @@ import React from 'react';
|
||||||
import { SideMenuitem } from 'UI';
|
import { SideMenuitem } from 'UI';
|
||||||
import { withRouter, RouteComponentProps } from 'react-router-dom';
|
import { withRouter, RouteComponentProps } from 'react-router-dom';
|
||||||
import { withSiteId, metrics, dashboard, alerts } from 'App/routes';
|
import { withSiteId, metrics, dashboard, alerts } from 'App/routes';
|
||||||
import { connect } from 'react-redux';
|
|
||||||
import { compose } from 'redux';
|
|
||||||
import { setShowAlerts } from 'Duck/dashboard';
|
|
||||||
|
|
||||||
interface Props extends RouteComponentProps {
|
interface Props extends RouteComponentProps {
|
||||||
siteId: string;
|
siteId: string;
|
||||||
history: any;
|
history: any;
|
||||||
setShowAlerts: (show: boolean) => void;
|
|
||||||
}
|
}
|
||||||
function DashboardSideMenu(props: Props) {
|
function DashboardSideMenu(props: Props) {
|
||||||
const { history, siteId, setShowAlerts } = props;
|
const { history, siteId } = props;
|
||||||
const isMetric = history.location.pathname.includes('metrics');
|
const isMetric = history.location.pathname.includes('metrics');
|
||||||
const isDashboards = history.location.pathname.includes('dashboard');
|
const isDashboards = history.location.pathname.includes('dashboard');
|
||||||
const isAlerts = history.location.pathname.includes('alerts');
|
const isAlerts = history.location.pathname.includes('alerts');
|
||||||
|
|
@ -57,4 +53,4 @@ function DashboardSideMenu(props: Props) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default compose(withRouter, connect(null, { setShowAlerts }))(DashboardSideMenu);
|
export default withRouter(DashboardSideMenu);
|
||||||
|
|
|
||||||
|
|
@ -1,67 +0,0 @@
|
||||||
import React, { useState } from 'react'
|
|
||||||
import ErrorFrame from './ErrorFrame'
|
|
||||||
import { IconButton, Icon } from 'UI';
|
|
||||||
|
|
||||||
const docLink = 'https://docs.openreplay.com/installation/upload-sourcemaps';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
error: any,
|
|
||||||
errorStack: any,
|
|
||||||
}
|
|
||||||
function ErrorDetails({ className, name = "Error", message, errorStack, sourcemapUploaded }: any) {
|
|
||||||
const [showRaw, setShowRaw] = useState(false)
|
|
||||||
const firstFunc = errorStack.first() && errorStack.first().function
|
|
||||||
|
|
||||||
const openDocs = () => {
|
|
||||||
window.open(docLink, '_blank');
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={className} >
|
|
||||||
{ !sourcemapUploaded && (
|
|
||||||
<div
|
|
||||||
style={{ backgroundColor: 'rgba(204, 0, 0, 0.1)' }}
|
|
||||||
className="font-normal flex items-center text-sm font-regular color-red border p-2 rounded"
|
|
||||||
>
|
|
||||||
<Icon name="info" size="16" color="red" />
|
|
||||||
<div className="ml-2">Source maps must be uploaded to OpenReplay to be able to see stack traces. <a href="#" className="color-red font-medium underline" style={{ textDecoration: 'underline' }} onClick={openDocs}>Learn more.</a></div>
|
|
||||||
</div>
|
|
||||||
) }
|
|
||||||
<div className="flex items-center my-3">
|
|
||||||
<h3 className="text-xl mr-auto">
|
|
||||||
Stacktrace
|
|
||||||
</h3>
|
|
||||||
<div className="flex justify-end mr-2">
|
|
||||||
<IconButton
|
|
||||||
onClick={() => setShowRaw(false) }
|
|
||||||
label="FULL"
|
|
||||||
plain={!showRaw}
|
|
||||||
primaryText={!showRaw}
|
|
||||||
/>
|
|
||||||
<IconButton
|
|
||||||
primaryText={showRaw}
|
|
||||||
onClick={() => setShowRaw(true) }
|
|
||||||
plain={showRaw}
|
|
||||||
label="RAW"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mb-6 code-font" data-hidden={showRaw}>
|
|
||||||
<div className="leading-relaxed font-weight-bold">{ name }</div>
|
|
||||||
<div style={{ wordBreak: 'break-all'}}>{message}</div>
|
|
||||||
</div>
|
|
||||||
{ showRaw &&
|
|
||||||
<div className="mb-3 code-font">{name} : {firstFunc ? firstFunc : '?' }</div>
|
|
||||||
}
|
|
||||||
{ errorStack.map((frame: any, i: any) => (
|
|
||||||
<div className="mb-3" key={frame.key}>
|
|
||||||
<ErrorFrame frame={frame} showRaw={showRaw} isFirst={i == 0} />
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
ErrorDetails.displayName = "ErrorDetails";
|
|
||||||
export default ErrorDetails;
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
import React, { useState } from 'react'
|
|
||||||
import { Icon } from 'UI';
|
|
||||||
import cn from 'classnames';
|
|
||||||
import stl from './errorFrame.module.css';
|
|
||||||
|
|
||||||
function ErrorFrame({ frame = {}, showRaw, isFirst }) {
|
|
||||||
const [open, setOpen] = useState(isFirst)
|
|
||||||
const hasContext = frame.context && frame.context.length > 0;
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{ showRaw ?
|
|
||||||
<div className={stl.rawLine}>at { frame.function ? frame.function : '?' } <span className="color-gray-medium">({`${frame.filename}:${frame.lineNo}:${frame.colNo}`})</span></div>
|
|
||||||
:
|
|
||||||
<div className={stl.formatted}>
|
|
||||||
<div className={cn(stl.header, 'flex items-center cursor-pointer')} onClick={() => setOpen(!open)}>
|
|
||||||
<div className="truncate">
|
|
||||||
<span className="font-medium">{ frame.absPath }</span>
|
|
||||||
{ frame.function &&
|
|
||||||
<>
|
|
||||||
<span>{' in '}</span>
|
|
||||||
<span className="font-medium"> {frame.function} </span>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
<span>{' at line '}</span>
|
|
||||||
<span className="font-medium">
|
|
||||||
{frame.lineNo}:{frame.colNo}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{ hasContext &&
|
|
||||||
<div className="ml-auto mr-3">
|
|
||||||
<Icon name={ open ? 'minus' : 'plus'} size="14" color="gray-medium" />
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
{ open && hasContext &&
|
|
||||||
<ol start={ frame.context[0][0]} className={stl.content}>
|
|
||||||
{ frame.context.map(i => (
|
|
||||||
<li
|
|
||||||
key={i[0]}
|
|
||||||
className={ cn("leading-7 text-sm break-all h-auto pl-2", { [stl.errorLine] :i[0] == frame.lineNo }) }
|
|
||||||
>
|
|
||||||
<span>{ i[1].replace(/ /g, "\u00a0") }</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ol>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ErrorFrame;
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
.rawLine {
|
|
||||||
margin-left: 30px;
|
|
||||||
font-family: 'Menlo', 'monaco', 'consolas', monospace;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
.formatted {
|
|
||||||
border: solid thin #EEE;
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
|
||||||
.header {
|
|
||||||
background-color: $gray-lightest;
|
|
||||||
padding: 8px;
|
|
||||||
border-bottom: solid thin #EEE;
|
|
||||||
}
|
|
||||||
.content {
|
|
||||||
font-family: 'Menlo', 'monaco', 'consolas', monospace;
|
|
||||||
list-style-position: inside;
|
|
||||||
list-style-type: decimal-leading-zero;
|
|
||||||
}
|
|
||||||
|
|
||||||
.errorLine {
|
|
||||||
background-color: $teal;
|
|
||||||
color: white !important;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
export { default } from './ErrorFrame';
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
export { default } from './ErrorDetails';
|
|
||||||
|
|
@ -17,10 +17,7 @@ function ErrorListItem(props: Props) {
|
||||||
const { error, className = '' } = props;
|
const { error, className = '' } = props;
|
||||||
// const { showModal } = useModal();
|
// const { showModal } = useModal();
|
||||||
|
|
||||||
// const onClick = () => {
|
|
||||||
// alert('test')
|
|
||||||
// showModal(<ErrorDetailsModal />, { right: true });
|
|
||||||
// }
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={ cn("p-3 grid grid-cols-12 gap-4 cursor-pointer py-4 hover:bg-active-blue", className) }
|
className={ cn("p-3 grid grid-cols-12 gap-4 cursor-pointer py-4 hover:bg-active-blue", className) }
|
||||||
|
|
@ -49,7 +46,7 @@ function ErrorListItem(props: Props) {
|
||||||
<Bar name="Sessions" minPointSize={1} dataKey="count" fill="#A8E0DA" />
|
<Bar name="Sessions" minPointSize={1} dataKey="count" fill="#A8E0DA" />
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</div>
|
</div>
|
||||||
<ErrorLabel
|
<ErrorLabel
|
||||||
// className={stl.sessions}
|
// className={stl.sessions}
|
||||||
topValue={ error.sessions }
|
topValue={ error.sessions }
|
||||||
bottomValue="Sessions"
|
bottomValue="Sessions"
|
||||||
|
|
@ -84,6 +81,6 @@ const CustomTooltip = ({ active, payload, label }: any) => {
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
import React, { useEffect } from 'react';
|
|
||||||
import ErrorListItem from '../ErrorListItem';
|
|
||||||
import { useStore } from 'App/mstore';
|
|
||||||
import { useObserver } from 'mobx-react-lite';
|
|
||||||
|
|
||||||
function ErrorsList(props) {
|
|
||||||
const { errorStore, metricStore } = useStore();
|
|
||||||
const metric = useObserver(() => metricStore.instance);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
errorStore.fetchErrors();
|
|
||||||
}, []);
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
Errors List
|
|
||||||
<ErrorListItem error={{}} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ErrorsList;
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
export { default } from './ErrorsList';
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import ErrorsList from '../ErrorsList';
|
|
||||||
|
|
||||||
function ErrorsWidget(props) {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<ErrorsList />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ErrorsWidget;
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
export { default } from './ErrorsWidget';
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import filters from 'App/duck/filters';
|
|
||||||
import Filter from 'App/mstore/types/filter';
|
import Filter from 'App/mstore/types/filter';
|
||||||
import { FilterKey } from 'App/types/filter/filterType';
|
import { FilterKey } from 'App/types/filter/filterType';
|
||||||
import { observer } from 'mobx-react-lite';
|
import { observer } from 'mobx-react-lite';
|
||||||
|
|
|
||||||
|
|
@ -6,20 +6,20 @@ import { TYPES, LIBRARY, INSIGHTS } from 'App/constants/card';
|
||||||
import { withRouter, RouteComponentProps } from 'react-router-dom';
|
import { withRouter, RouteComponentProps } from 'react-router-dom';
|
||||||
import { dashboardMetricCreate, metricCreate, withSiteId } from 'App/routes';
|
import { dashboardMetricCreate, metricCreate, withSiteId } from 'App/routes';
|
||||||
import { useStore } from 'App/mstore';
|
import { useStore } from 'App/mstore';
|
||||||
import { connect } from 'react-redux';
|
import { observer } from 'mobx-react-lite';
|
||||||
import { ENTERPRISE_REQUEIRED } from 'App/constants';
|
import { ENTERPRISE_REQUEIRED } from 'App/constants';
|
||||||
|
|
||||||
interface Props extends RouteComponentProps {
|
interface Props extends RouteComponentProps {
|
||||||
dashboardId?: number;
|
dashboardId?: number;
|
||||||
siteId: string;
|
siteId: string;
|
||||||
isEnterprise: boolean;
|
|
||||||
isList?: boolean;
|
isList?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function MetricTypeList(props: Props) {
|
function MetricTypeList(props: Props) {
|
||||||
const { dashboardId, siteId, history, isEnterprise, isList = false } = props;
|
const { dashboardId, siteId, history, isList = false } = props;
|
||||||
const { metricStore } = useStore();
|
const { metricStore, userStore } = useStore();
|
||||||
const { showModal, hideModal } = useModal();
|
const { showModal, hideModal } = useModal();
|
||||||
|
const isEnterprise = userStore.isEnterprise;
|
||||||
|
|
||||||
const list = React.useMemo(() => {
|
const list = React.useMemo(() => {
|
||||||
return TYPES.map((metric: MetricType) => {
|
return TYPES.map((metric: MetricType) => {
|
||||||
|
|
@ -67,6 +67,4 @@ function MetricTypeList(props: Props) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default connect((state: any) => ({
|
export default withRouter(observer(MetricTypeList));
|
||||||
isEnterprise: state.getIn(['user', 'account', 'edition']) === 'ee' || state.getIn(['user', 'account', 'edition']) === 'msaas'
|
|
||||||
}))(withRouter(MetricTypeList));
|
|
||||||
|
|
|
||||||
|
|
@ -7,16 +7,14 @@ import { observer } from 'mobx-react-lite';
|
||||||
import { useStore } from 'App/mstore';
|
import { useStore } from 'App/mstore';
|
||||||
import withLocationHandlers from 'HOCs/withLocationHandlers';
|
import withLocationHandlers from 'HOCs/withLocationHandlers';
|
||||||
import { Icon } from 'UI';
|
import { Icon } from 'UI';
|
||||||
import { connect } from 'react-redux';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
query: Record<string, (key: string) => any>;
|
query: Record<string, (key: string) => any>;
|
||||||
onSelect: (arg: any) => void;
|
onSelect: (arg: any) => void;
|
||||||
isEnterprise?: boolean;
|
|
||||||
}
|
}
|
||||||
function MetricTypeDropdown(props: Props) {
|
function MetricTypeDropdown(props: Props) {
|
||||||
const { isEnterprise } = props;
|
const { metricStore, userStore } = useStore();
|
||||||
const { metricStore } = useStore();
|
const isEnterprise = userStore.isEnterprise;
|
||||||
const metric: any = metricStore.instance;
|
const metric: any = metricStore.instance;
|
||||||
|
|
||||||
const options = React.useMemo(() => {
|
const options = React.useMemo(() => {
|
||||||
|
|
@ -84,6 +82,4 @@ function MetricTypeDropdown(props: Props) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default connect((state: any) => ({
|
export default withLocationHandlers()(observer(MetricTypeDropdown));
|
||||||
isEnterprise: state.getIn(['user', 'account', 'edition']) === 'ee',
|
|
||||||
}))(withLocationHandlers()(observer(MetricTypeDropdown)));
|
|
||||||
|
|
|
||||||
|
|
@ -11,22 +11,20 @@ import useIsMounted from 'App/hooks/useIsMounted';
|
||||||
import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG';
|
import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG';
|
||||||
import { numberWithCommas } from 'App/utils';
|
import { numberWithCommas } from 'App/utils';
|
||||||
import { HEATMAP } from 'App/constants/card';
|
import { HEATMAP } from 'App/constants/card';
|
||||||
import { connect } from 'react-redux';
|
import { Tag } from 'antd';
|
||||||
import { Tag } from "antd";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
className?: string;
|
className?: string;
|
||||||
metaList: any;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function WidgetSessions(props: Props) {
|
function WidgetSessions(props: Props) {
|
||||||
const { className = '', metaList } = props;
|
const { className = '' } = props;
|
||||||
const [activeSeries, setActiveSeries] = useState('all');
|
const [activeSeries, setActiveSeries] = useState('all');
|
||||||
const [data, setData] = useState<any>([]);
|
const [data, setData] = useState<any>([]);
|
||||||
const isMounted = useIsMounted();
|
const isMounted = useIsMounted();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const filteredSessions = getListSessionsBySeries(data, activeSeries);
|
const filteredSessions = getListSessionsBySeries(data, activeSeries);
|
||||||
const { dashboardStore, metricStore, sessionStore } = useStore();
|
const { dashboardStore, metricStore, sessionStore, customFieldStore } = useStore();
|
||||||
const filter = dashboardStore.drillDownFilter;
|
const filter = dashboardStore.drillDownFilter;
|
||||||
const widget = metricStore.instance;
|
const widget = metricStore.instance;
|
||||||
const startTime = DateTime.fromMillis(filter.startTimestamp).toFormat('LLL dd, yyyy HH:mm');
|
const startTime = DateTime.fromMillis(filter.startTimestamp).toFormat('LLL dd, yyyy HH:mm');
|
||||||
|
|
@ -34,6 +32,7 @@ function WidgetSessions(props: Props) {
|
||||||
const [seriesOptions, setSeriesOptions] = useState([{ label: 'All', value: 'all' }]);
|
const [seriesOptions, setSeriesOptions] = useState([{ label: 'All', value: 'all' }]);
|
||||||
const hasFilters = filter.filters.length > 0 || (filter.startTimestamp !== dashboardStore.drillDownPeriod.start || filter.endTimestamp !== dashboardStore.drillDownPeriod.end);
|
const hasFilters = filter.filters.length > 0 || (filter.startTimestamp !== dashboardStore.drillDownPeriod.start || filter.endTimestamp !== dashboardStore.drillDownPeriod.end);
|
||||||
const filterText = filter.filters.length > 0 ? filter.filters[0].value : '';
|
const filterText = filter.filters.length > 0 ? filter.filters[0].value : '';
|
||||||
|
const metaList = customFieldStore.list.map((i: any) => i.key);
|
||||||
|
|
||||||
const writeOption = ({ value }: any) => setActiveSeries(value.value);
|
const writeOption = ({ value }: any) => setActiveSeries(value.value);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -118,38 +117,39 @@ function WidgetSessions(props: Props) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn(className, 'bg-white p-3 pb-0 rounded-lg shadow-sm border mt-3')}>
|
<div className={cn(className, 'bg-white p-3 pb-0 rounded-lg shadow-sm border mt-3')}>
|
||||||
<div className='flex items-center justify-between'>
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<div className='flex items-baseline'>
|
<div className="flex items-baseline">
|
||||||
<h2 className='text-xl'>{metricStore.clickMapSearch ? 'Clicks' : 'Sessions'}</h2>
|
<h2 className="text-xl">{metricStore.clickMapSearch ? 'Clicks' : 'Sessions'}</h2>
|
||||||
<div className='ml-2 color-gray-medium'>
|
<div className="ml-2 color-gray-medium">
|
||||||
{metricStore.clickMapLabel ? `on "${metricStore.clickMapLabel}" ` : null}
|
{metricStore.clickMapLabel ? `on "${metricStore.clickMapLabel}" ` : null}
|
||||||
between <span className='font-medium color-gray-darkest'>{startTime}</span> and{' '}
|
between <span className="font-medium color-gray-darkest">{startTime}</span> and{' '}
|
||||||
<span className='font-medium color-gray-darkest'>{endTime}</span>{' '}
|
<span className="font-medium color-gray-darkest">{endTime}</span>{' '}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{hasFilters && widget.metricType === 'table' && <div className="py-2"><Tag closable onClose={clearFilters}>{filterText}</Tag></div>}
|
{hasFilters && widget.metricType === 'table' &&
|
||||||
|
<div className="py-2"><Tag closable onClose={clearFilters}>{filterText}</Tag></div>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='flex items-center gap-4'>
|
<div className="flex items-center gap-4">
|
||||||
{hasFilters && <Button variant='text-primary' onClick={clearFilters}>Clear Filters</Button>}
|
{hasFilters && <Button variant="text-primary" onClick={clearFilters}>Clear Filters</Button>}
|
||||||
{widget.metricType !== 'table' && widget.metricType !== HEATMAP && (
|
{widget.metricType !== 'table' && widget.metricType !== HEATMAP && (
|
||||||
<div className='flex items-center ml-6'>
|
<div className="flex items-center ml-6">
|
||||||
<span className='mr-2 color-gray-medium'>Filter by Series</span>
|
<span className="mr-2 color-gray-medium">Filter by Series</span>
|
||||||
<Select options={seriesOptions} defaultValue={'all'} onChange={writeOption} plain />
|
<Select options={seriesOptions} defaultValue={'all'} onChange={writeOption} plain />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='mt-3'>
|
<div className="mt-3">
|
||||||
<Loader loading={loading}>
|
<Loader loading={loading}>
|
||||||
<NoContent
|
<NoContent
|
||||||
title={
|
title={
|
||||||
<div className='flex items-center justify-center flex-col'>
|
<div className="flex items-center justify-center flex-col">
|
||||||
<AnimatedSVG name={ICONS.NO_SESSIONS} size={60} />
|
<AnimatedSVG name={ICONS.NO_SESSIONS} size={60} />
|
||||||
<div className='mt-4' />
|
<div className="mt-4" />
|
||||||
<div className='text-center'>
|
<div className="text-center">
|
||||||
No relevant sessions found for the selected time period
|
No relevant sessions found for the selected time period
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -159,22 +159,22 @@ function WidgetSessions(props: Props) {
|
||||||
{filteredSessions.sessions.map((session: any) => (
|
{filteredSessions.sessions.map((session: any) => (
|
||||||
<React.Fragment key={session.sessionId}>
|
<React.Fragment key={session.sessionId}>
|
||||||
<SessionItem session={session} metaList={metaList} />
|
<SessionItem session={session} metaList={metaList} />
|
||||||
<div className='border-b' />
|
<div className="border-b" />
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<div className='flex items-center justify-between p-5'>
|
<div className="flex items-center justify-between p-5">
|
||||||
<div>
|
<div>
|
||||||
Showing{' '}
|
Showing{' '}
|
||||||
<span className='font-medium'>
|
<span className="font-medium">
|
||||||
{(metricStore.sessionsPage - 1) * metricStore.sessionsPageSize + 1}
|
{(metricStore.sessionsPage - 1) * metricStore.sessionsPageSize + 1}
|
||||||
</span>{' '}
|
</span>{' '}
|
||||||
to{' '}
|
to{' '}
|
||||||
<span className='font-medium'>
|
<span className="font-medium">
|
||||||
{(metricStore.sessionsPage - 1) * metricStore.sessionsPageSize +
|
{(metricStore.sessionsPage - 1) * metricStore.sessionsPageSize +
|
||||||
filteredSessions.sessions.length}
|
filteredSessions.sessions.length}
|
||||||
</span>{' '}
|
</span>{' '}
|
||||||
of <span className='font-medium'>{numberWithCommas(filteredSessions.total)}</span>{' '}
|
of <span className="font-medium">{numberWithCommas(filteredSessions.total)}</span>{' '}
|
||||||
sessions.
|
sessions.
|
||||||
</div>
|
</div>
|
||||||
<Pagination
|
<Pagination
|
||||||
|
|
@ -217,8 +217,4 @@ const getListSessionsBySeries = (data: any, seriesId: any) => {
|
||||||
return arr;
|
return arr;
|
||||||
};
|
};
|
||||||
|
|
||||||
const mapStateToProps = (state: any) => ({
|
export default observer(WidgetSessions);
|
||||||
metaList: state.getIn(['customFields', 'list']).map((i: any) => i.key),
|
|
||||||
});
|
|
||||||
|
|
||||||
export default connect(mapStateToProps)(observer(WidgetSessions));
|
|
||||||
|
|
|
||||||
|
|
@ -1,89 +1,48 @@
|
||||||
|
import { observer } from 'mobx-react-lite';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { connect } from 'react-redux';
|
|
||||||
import withSiteIdRouter from 'HOCs/withSiteIdRouter';
|
import { useStore } from 'App/mstore';
|
||||||
import { error as errorRoute } from 'App/routes';
|
import { Loader, NoContent } from 'UI';
|
||||||
import { NoContent, Loader } from 'UI';
|
|
||||||
import { fetch, fetchTrace } from 'Duck/errors';
|
|
||||||
import MainSection from './MainSection';
|
|
||||||
import SideSection from './SideSection';
|
|
||||||
import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG';
|
import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG';
|
||||||
|
|
||||||
@connect(
|
import MainSection from './MainSection';
|
||||||
(state) => ({
|
import SideSection from './SideSection';
|
||||||
errorIdInStore: state.getIn(['errors', 'instance']).errorId,
|
|
||||||
list: state.getIn(['errors', 'instanceTrace']),
|
|
||||||
loading:
|
|
||||||
state.getIn(['errors', 'fetch', 'loading']) ||
|
|
||||||
state.getIn(['errors', 'fetchTrace', 'loading']),
|
|
||||||
errorOnFetch:
|
|
||||||
state.getIn(['errors', 'fetch', 'errors']) || state.getIn(['errors', 'fetchTrace', 'errors']),
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
fetch,
|
|
||||||
fetchTrace,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
@withSiteIdRouter
|
|
||||||
export default class ErrorInfo extends React.PureComponent {
|
|
||||||
ensureInstance() {
|
|
||||||
const { errorId, loading, errorOnFetch } = this.props;
|
|
||||||
if (!loading && this.props.errorIdInStore !== errorId && errorId != null) {
|
|
||||||
this.props.fetch(errorId);
|
|
||||||
this.props.fetchTrace(errorId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
componentDidMount() {
|
|
||||||
this.ensureInstance();
|
|
||||||
}
|
|
||||||
componentDidUpdate(prevProps) {
|
|
||||||
if (prevProps.errorId !== this.props.errorId || prevProps.errorIdInStore !== this.props.errorIdInStore) {
|
|
||||||
this.ensureInstance();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
next = () => {
|
|
||||||
const { list, errorId } = this.props;
|
|
||||||
const curIndex = list.findIndex((e) => e.errorId === errorId);
|
|
||||||
const next = list.get(curIndex + 1);
|
|
||||||
if (next != null) {
|
|
||||||
this.props.history.push(errorRoute(next.errorId));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
prev = () => {
|
|
||||||
const { list, errorId } = this.props;
|
|
||||||
const curIndex = list.findIndex((e) => e.errorId === errorId);
|
|
||||||
const prev = list.get(curIndex - 1);
|
|
||||||
if (prev != null) {
|
|
||||||
this.props.history.push(errorRoute(prev.errorId));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
render() {
|
|
||||||
const { loading, errorIdInStore, list, errorId } = this.props;
|
|
||||||
|
|
||||||
let nextDisabled = true,
|
function ErrorInfo(props) {
|
||||||
prevDisabled = true;
|
const { errorStore } = useStore();
|
||||||
if (list.size > 0) {
|
const instance = errorStore.instance;
|
||||||
nextDisabled = loading || list.last().errorId === errorId;
|
const ensureInstance = () => {
|
||||||
prevDisabled = loading || list.first().errorId === errorId;
|
if (errorStore.isLoading) return;
|
||||||
}
|
errorStore.fetchError(props.errorId);
|
||||||
|
errorStore.fetchErrorTrace(props.errorId);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
React.useEffect(() => {
|
||||||
<NoContent
|
ensureInstance();
|
||||||
title={
|
}, [props.errorId]);
|
||||||
<div className="flex flex-col items-center justify-center">
|
|
||||||
<AnimatedSVG name={ICONS.EMPTY_STATE} size="170" />
|
const errorIdInStore = errorStore.instance?.errorId;
|
||||||
<div className="mt-4">No Error Found!</div>
|
const loading = errorStore.isLoading;
|
||||||
</div>
|
return (
|
||||||
}
|
<NoContent
|
||||||
subtext="Please try to find existing one."
|
title={
|
||||||
show={!loading && errorIdInStore == null}
|
<div className="flex flex-col items-center justify-center">
|
||||||
>
|
<AnimatedSVG name={ICONS.EMPTY_STATE} size="170" />
|
||||||
<div className="flex w-full">
|
<div className="mt-4">No Error Found!</div>
|
||||||
<Loader loading={loading} className="w-full">
|
|
||||||
<MainSection className="w-9/12" />
|
|
||||||
<SideSection className="w-3/12" />
|
|
||||||
</Loader>
|
|
||||||
</div>
|
</div>
|
||||||
</NoContent>
|
}
|
||||||
);
|
subtext="Please try to find existing one."
|
||||||
}
|
show={!loading && errorIdInStore == null}
|
||||||
|
>
|
||||||
|
<div className="flex w-full">
|
||||||
|
<Loader loading={loading || !instance} className="w-full">
|
||||||
|
<MainSection className="w-9/12" />
|
||||||
|
<SideSection className="w-3/12" />
|
||||||
|
</Loader>
|
||||||
|
</div>
|
||||||
|
</NoContent>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default observer(ErrorInfo);
|
||||||
|
|
|
||||||
|
|
@ -1,154 +1,132 @@
|
||||||
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 { RESOLVED } from 'Types/errorInfo';
|
||||||
import { addFilterByKeyAndValue } from 'Duck/search';
|
import { FilterKey } from 'Types/filter/filterType';
|
||||||
import { resolve, unresolve, ignore, toggleFavorite } from 'Duck/errors';
|
import cn from 'classnames';
|
||||||
|
import { observer } from 'mobx-react-lite';
|
||||||
|
import React from 'react';
|
||||||
|
import { withRouter } from 'react-router-dom';
|
||||||
|
|
||||||
import { resentOrDate } from 'App/date';
|
import { resentOrDate } from 'App/date';
|
||||||
|
import { useStore } from 'App/mstore';
|
||||||
|
import { sessions as sessionsRoute } from 'App/routes';
|
||||||
import Divider from 'Components/Errors/ui/Divider';
|
import Divider from 'Components/Errors/ui/Divider';
|
||||||
import ErrorName from 'Components/Errors/ui/ErrorName';
|
import ErrorName from 'Components/Errors/ui/ErrorName';
|
||||||
import Label from 'Components/Errors/ui/Label';
|
import Label from 'Components/Errors/ui/Label';
|
||||||
import { FilterKey } from 'Types/filter/filterType';
|
import { Button, ErrorDetails, Icon, Loader } from 'UI';
|
||||||
|
|
||||||
import SessionBar from './SessionBar';
|
import SessionBar from './SessionBar';
|
||||||
|
|
||||||
@withSiteIdRouter
|
function MainSection(props) {
|
||||||
@connect(
|
const { errorStore, searchStore } = useStore();
|
||||||
(state) => ({
|
const error = errorStore.instance;
|
||||||
error: state.getIn(['errors', 'instance']),
|
const trace = errorStore.instanceTrace;
|
||||||
trace: state.getIn(['errors', 'instanceTrace']),
|
const sourcemapUploaded = errorStore.sourcemapUploaded;
|
||||||
sourcemapUploaded: state.getIn(['errors', 'sourcemapUploaded']),
|
const loading = errorStore.isLoading;
|
||||||
resolveToggleLoading:
|
const className = props.className;
|
||||||
state.getIn(['errors', 'resolve', 'loading']) ||
|
|
||||||
state.getIn(['errors', 'unresolve', 'loading']),
|
|
||||||
ignoreLoading: state.getIn(['errors', 'ignore', 'loading']),
|
|
||||||
toggleFavoriteLoading: state.getIn(['errors', 'toggleFavorite', 'loading']),
|
|
||||||
traceLoading: state.getIn(['errors', 'fetchTrace', 'loading']),
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
resolve,
|
|
||||||
unresolve,
|
|
||||||
ignore,
|
|
||||||
toggleFavorite,
|
|
||||||
addFilterByKeyAndValue,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
export default class MainSection extends React.PureComponent {
|
|
||||||
resolve = () => {
|
|
||||||
const { error } = this.props;
|
|
||||||
this.props.resolve(error.errorId);
|
|
||||||
};
|
|
||||||
|
|
||||||
unresolve = () => {
|
const findSessions = () => {
|
||||||
const { error } = this.props;
|
searchStore.addFilterByKeyAndValue(FilterKey.ERROR, error.message);
|
||||||
this.props.unresolve(error.errorId);
|
props.history.push(sessionsRoute());
|
||||||
};
|
};
|
||||||
|
return (
|
||||||
ignore = () => {
|
<div
|
||||||
const { error } = this.props;
|
className={cn(
|
||||||
this.props.ignore(error.errorId);
|
className,
|
||||||
};
|
'bg-white border-radius-3 thin-gray-border mb-6'
|
||||||
bookmark = () => {
|
)}
|
||||||
const { error } = this.props;
|
>
|
||||||
this.props.toggleFavorite(error.errorId);
|
<div className="m-4">
|
||||||
};
|
<ErrorName
|
||||||
|
className="text-lg leading-relaxed"
|
||||||
findSessions = () => {
|
name={error.name}
|
||||||
this.props.addFilterByKeyAndValue(FilterKey.ERROR, this.props.error.message);
|
message={error.stack0InfoString}
|
||||||
this.props.history.push(sessionsRoute());
|
lineThrough={error.status === RESOLVED}
|
||||||
};
|
/>
|
||||||
|
<div className="flex flex-col">
|
||||||
render() {
|
<div
|
||||||
const {
|
className="flex items-center color-gray-dark font-semibold"
|
||||||
error,
|
style={{ wordBreak: 'break-all' }}
|
||||||
trace,
|
>
|
||||||
sourcemapUploaded,
|
{error.message}
|
||||||
ignoreLoading,
|
</div>
|
||||||
resolveToggleLoading,
|
<div className="flex items-center mt-2">
|
||||||
toggleFavoriteLoading,
|
<div className="flex">
|
||||||
className,
|
<Label
|
||||||
traceLoading,
|
topValue={error.sessions}
|
||||||
} = this.props;
|
horizontal
|
||||||
const isPlayer = window.location.pathname.includes('/session/');
|
topValueSize="text-lg"
|
||||||
|
bottomValue="Sessions"
|
||||||
return (
|
/>
|
||||||
<div className={cn(className, 'bg-white border-radius-3 thin-gray-border mb-6')}>
|
<Label
|
||||||
<div className="m-4">
|
topValue={error.users}
|
||||||
<ErrorName
|
horizontal
|
||||||
className="text-lg leading-relaxed"
|
topValueSize="text-lg"
|
||||||
name={error.name}
|
bottomValue="Users"
|
||||||
message={error.stack0InfoString}
|
/>
|
||||||
lineThrough={error.status === RESOLVED}
|
|
||||||
/>
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<div
|
|
||||||
className="flex items-center color-gray-dark font-semibold"
|
|
||||||
style={{ wordBreak: 'break-all' }}
|
|
||||||
>
|
|
||||||
{error.message}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center mt-2">
|
<div className="text-xs color-gray-medium">
|
||||||
<div className="flex">
|
Over the past 30 days
|
||||||
<Label
|
|
||||||
topValue={error.sessions}
|
|
||||||
horizontal
|
|
||||||
topValueSize="text-lg"
|
|
||||||
bottomValue="Sessions"
|
|
||||||
/>
|
|
||||||
<Label
|
|
||||||
topValue={error.users}
|
|
||||||
horizontal
|
|
||||||
topValueSize="text-lg"
|
|
||||||
bottomValue="Users"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="text-xs color-gray-medium">Over the past 30 days</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Divider />
|
|
||||||
<div className="m-4">
|
|
||||||
<div className="flex items-center">
|
|
||||||
<h3 className="text-xl inline-block mr-2">Last session with this error</h3>
|
|
||||||
<span className="font-thin text-sm">{resentOrDate(error.lastOccurrence)}</span>
|
|
||||||
<Button className="ml-auto" variant="text-primary" onClick={this.findSessions}>
|
|
||||||
Find all sessions with this error
|
|
||||||
<Icon className="ml-1" name="next1" color="teal" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<SessionBar className="my-4" session={error.lastHydratedSession} />
|
|
||||||
{error.customTags.length > 0 ? (
|
|
||||||
<div className="flex items-start flex-col">
|
|
||||||
<div>
|
|
||||||
<span className="font-semibold">More Info</span> <span className="text-disabled-text">(most recent call)</span>
|
|
||||||
</div>
|
|
||||||
<div className="mt-4 flex items-center gap-3 w-full flex-wrap">
|
|
||||||
{error.customTags.map((tag) => (
|
|
||||||
<div className="flex items-center rounded overflow-hidden bg-gray-lightest">
|
|
||||||
<div className="bg-gray-light-shade py-1 px-2 text-disabled-text">{Object.entries(tag)[0][0]}</div> <div className="py-1 px-2 text-gray-dark">{Object.entries(tag)[0][1]}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<Divider />
|
|
||||||
<div className="m-4">
|
|
||||||
<Loader loading={traceLoading}>
|
|
||||||
<ErrorDetails
|
|
||||||
name={error.name}
|
|
||||||
message={error.message}
|
|
||||||
errorStack={trace}
|
|
||||||
error={error}
|
|
||||||
sourcemapUploaded={sourcemapUploaded}
|
|
||||||
/>
|
|
||||||
</Loader>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
|
||||||
}
|
<Divider />
|
||||||
|
<div className="m-4">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<h3 className="text-xl inline-block mr-2">
|
||||||
|
Last session with this error
|
||||||
|
</h3>
|
||||||
|
<span className="font-thin text-sm">
|
||||||
|
{resentOrDate(error.lastOccurrence)}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
className="ml-auto"
|
||||||
|
variant="text-primary"
|
||||||
|
onClick={findSessions}
|
||||||
|
>
|
||||||
|
Find all sessions with this error
|
||||||
|
<Icon className="ml-1" name="next1" color="teal" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<SessionBar className="my-4" session={error.lastHydratedSession} />
|
||||||
|
{error.customTags.length > 0 ? (
|
||||||
|
<div className="flex items-start flex-col">
|
||||||
|
<div>
|
||||||
|
<span className="font-semibold">More Info</span>{' '}
|
||||||
|
<span className="text-disabled-text">(most recent call)</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex items-center gap-3 w-full flex-wrap">
|
||||||
|
{error.customTags.map((tag) => (
|
||||||
|
<div className="flex items-center rounded overflow-hidden bg-gray-lightest">
|
||||||
|
<div className="bg-gray-light-shade py-1 px-2 text-disabled-text">
|
||||||
|
{Object.entries(tag)[0][0]}
|
||||||
|
</div>
|
||||||
|
{' '}
|
||||||
|
<div className="py-1 px-2 text-gray-dark">
|
||||||
|
{Object.entries(tag)[0][1]}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<Divider />
|
||||||
|
<div className="m-4">
|
||||||
|
<Loader loading={loading}>
|
||||||
|
<ErrorDetails
|
||||||
|
name={error.name}
|
||||||
|
message={error.message}
|
||||||
|
errorStack={trace}
|
||||||
|
error={error}
|
||||||
|
sourcemapUploaded={sourcemapUploaded}
|
||||||
|
/>
|
||||||
|
</Loader>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default withRouter(
|
||||||
|
(observer(MainSection))
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,123 +1,120 @@
|
||||||
import React from 'react';
|
|
||||||
import { connect } from 'react-redux';
|
|
||||||
import cn from 'classnames';
|
import cn from 'classnames';
|
||||||
import withRequest from 'HOCs/withRequest';
|
import { observer } from 'mobx-react-lite';
|
||||||
import { Loader } from 'UI';
|
import React from 'react';
|
||||||
|
|
||||||
import { countries } from 'App/constants';
|
import { countries } from 'App/constants';
|
||||||
import Trend from './Trend';
|
import { useStore } from 'App/mstore';
|
||||||
|
import { Loader } from 'UI';
|
||||||
|
|
||||||
import DateAgo from './DateAgo';
|
import DateAgo from './DateAgo';
|
||||||
import DistributionBar from './DistributionBar';
|
import DistributionBar from './DistributionBar';
|
||||||
|
import Trend from './Trend';
|
||||||
|
import { errorService } from 'App/services';
|
||||||
|
|
||||||
const MAX_PERCENTAGE = 3;
|
const MAX_PERCENTAGE = 3;
|
||||||
const MIN_COUNT = 4;
|
const MIN_COUNT = 4;
|
||||||
const MAX_COUNT = 10;
|
const MAX_COUNT = 10;
|
||||||
function hidePredicate(percentage, index) {
|
function hidePredicate(percentage, index) {
|
||||||
if (index < MIN_COUNT) return false;
|
if (index < MIN_COUNT) return false;
|
||||||
if (index < MAX_COUNT && percentage < MAX_PERCENTAGE) return false;
|
if (index < MAX_COUNT && percentage < MAX_PERCENTAGE) return false;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
function partitionsWrapper(partitions = [], mapCountry = false) {
|
function partitionsWrapper(partitions = [], mapCountry = false) {
|
||||||
const counts = partitions.map(({ count }) => count);
|
const counts = partitions.map(({ count }) => count);
|
||||||
const sum = counts.reduce((a,b)=>parseInt(a)+parseInt(b),0);
|
const sum = counts.reduce((a, b) => parseInt(a) + parseInt(b), 0);
|
||||||
if (sum === 0) {
|
if (sum === 0) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
const otherPrcs = counts
|
const otherPrcs = counts.map((c) => (c / sum) * 100).filter(hidePredicate);
|
||||||
.map(c => c/sum * 100)
|
const otherPrcsSum = otherPrcs.reduce((a, b) => a + b, 0);
|
||||||
.filter(hidePredicate);
|
const showLength = partitions.length - otherPrcs.length;
|
||||||
const otherPrcsSum = otherPrcs.reduce((a,b)=>a+b,0);
|
const show = partitions
|
||||||
const showLength = partitions.length - otherPrcs.length;
|
.sort((a, b) => b.count - a.count)
|
||||||
const show = partitions
|
.slice(0, showLength)
|
||||||
.sort((a, b) => b.count - a.count)
|
.map((p) => ({
|
||||||
.slice(0, showLength)
|
label: mapCountry ? countries[p.name] || 'Unknown' : p.name,
|
||||||
.map(p => ({
|
prc: (p.count / sum) * 100,
|
||||||
label: mapCountry
|
}));
|
||||||
? (countries[p.name] || "Unknown")
|
|
||||||
: p.name,
|
|
||||||
prc: p.count/sum * 100,
|
|
||||||
}))
|
|
||||||
|
|
||||||
if (otherPrcsSum > 0) {
|
if (otherPrcsSum > 0) {
|
||||||
show.push({
|
show.push({
|
||||||
label: "Other",
|
label: 'Other',
|
||||||
prc: otherPrcsSum,
|
prc: otherPrcsSum,
|
||||||
other: true,
|
other: true,
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
return show;
|
return show;
|
||||||
}
|
}
|
||||||
function tagsWrapper(tags = []) {
|
function tagsWrapper(tags = []) {
|
||||||
return tags.map(({ name, partitions }) => ({
|
return tags.map(({ name, partitions }) => ({
|
||||||
name,
|
name,
|
||||||
partitions: partitionsWrapper(partitions, name === "country")
|
partitions: partitionsWrapper(partitions, name === 'country'),
|
||||||
}))
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
function dataWrapper(data = {}) {
|
function dataWrapper(data = {}) {
|
||||||
return {
|
return {
|
||||||
chart24: data.chart24 || [],
|
chart24: data.chart24 || [],
|
||||||
chart30: data.chart30 || [],
|
chart30: data.chart30 || [],
|
||||||
tags: tagsWrapper(data.tags),
|
tags: tagsWrapper(data.tags),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@connect(state => ({
|
function SideSection(props) {
|
||||||
error: state.getIn([ "errors", "instance" ])
|
const [data, setData] = React.useState({
|
||||||
}))
|
chart24: [],
|
||||||
@withRequest({
|
chart30: [],
|
||||||
initialData: props => dataWrapper(props.error),
|
tags: [],
|
||||||
endpoint: props => `/errors/${ props.error.errorId }/stats`,
|
});
|
||||||
dataWrapper,
|
const [loading, setLoading] = React.useState(false);
|
||||||
})
|
const { className } = props;
|
||||||
export default class SideSection extends React.PureComponent {
|
const { errorStore } = useStore();
|
||||||
onDateChange = ({ startDate, endDate }) => {
|
const error = errorStore.instance;
|
||||||
this.props.request({ startDate, endDate });
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const {
|
|
||||||
className,
|
|
||||||
error,
|
|
||||||
data,
|
|
||||||
loading,
|
|
||||||
} = this.props;
|
|
||||||
|
|
||||||
return (
|
const grabData = async () => {
|
||||||
<div className={ cn(className, "pl-5") }>
|
setLoading(true);
|
||||||
<h3 className="text-xl mb-2">Overview</h3>
|
errorService.fetchErrorStats(error.errorId)
|
||||||
<Trend
|
.then(data => {
|
||||||
chart={ data.chart24 }
|
setData(dataWrapper(data))
|
||||||
title="Past 24 hours"
|
})
|
||||||
/>
|
.finally(() => setLoading(false));
|
||||||
<div className="mb-6" />
|
}
|
||||||
<Trend
|
|
||||||
chart={ data.chart30 }
|
React.useEffect(() => {
|
||||||
title="Last 30 days"
|
setData(dataWrapper(error))
|
||||||
timeFormat={'l'}
|
}, [error.errorId])
|
||||||
/>
|
|
||||||
<div className="mb-6" />
|
return (
|
||||||
<DateAgo
|
<div className={cn(className, 'pl-5')}>
|
||||||
className="my-4"
|
<h3 className="text-xl mb-2">Overview</h3>
|
||||||
title="First Seen"
|
<Trend chart={data.chart24} title="Past 24 hours" />
|
||||||
timestamp={ error.firstOccurrence }
|
<div className="mb-6" />
|
||||||
/>
|
<Trend chart={data.chart30} title="Last 30 days" timeFormat={'l'} />
|
||||||
<DateAgo
|
<div className="mb-6" />
|
||||||
className="my-4"
|
<DateAgo
|
||||||
title="Last Seen"
|
className="my-4"
|
||||||
timestamp={ error.lastOccurrence }
|
title="First Seen"
|
||||||
/>
|
timestamp={error.firstOccurrence}
|
||||||
{ data.tags.length > 0 && <h4 className="text-xl mt-6 mb-3">Summary</h4> }
|
/>
|
||||||
<Loader loading={loading}>
|
<DateAgo
|
||||||
{ data.tags.map(({ name, partitions }) =>
|
className="my-4"
|
||||||
<DistributionBar
|
title="Last Seen"
|
||||||
key={ name }
|
timestamp={error.lastOccurrence}
|
||||||
title={name}
|
/>
|
||||||
partitions={partitions}
|
{data.tags.length > 0 && <h4 className="text-xl mt-6 mb-3">Summary</h4>}
|
||||||
className="mb-6"
|
<Loader loading={loading}>
|
||||||
/>
|
{data.tags.map(({ name, partitions }) => (
|
||||||
)}
|
<DistributionBar
|
||||||
</Loader>
|
key={name}
|
||||||
</div>
|
title={name}
|
||||||
);
|
partitions={partitions}
|
||||||
}
|
className="mb-6"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Loader>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default observer(SideSection);
|
||||||
|
|
|
||||||
|
|
@ -1,154 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import { connect } from 'react-redux';
|
|
||||||
import withSiteIdRouter from 'HOCs/withSiteIdRouter';
|
|
||||||
import withPermissions from 'HOCs/withPermissions'
|
|
||||||
import { UNRESOLVED, RESOLVED, IGNORED, BOOKMARK } from "Types/errorInfo";
|
|
||||||
import { fetchBookmarks, editOptions } from "Duck/errors";
|
|
||||||
import { applyFilter } from 'Duck/search';
|
|
||||||
import { errors as errorsRoute, isRoute } from "App/routes";
|
|
||||||
import withPageTitle from 'HOCs/withPageTitle';
|
|
||||||
import cn from 'classnames';
|
|
||||||
import SelectDateRange from 'Shared/SelectDateRange';
|
|
||||||
import Period from 'Types/app/period';
|
|
||||||
|
|
||||||
import List from './List/List';
|
|
||||||
import ErrorInfo from './Error/ErrorInfo';
|
|
||||||
import Header from './Header';
|
|
||||||
import SideMenuSection from './SideMenu/SideMenuSection';
|
|
||||||
import SideMenuDividedItem from './SideMenu/SideMenuDividedItem';
|
|
||||||
|
|
||||||
const ERRORS_ROUTE = errorsRoute();
|
|
||||||
|
|
||||||
function getStatusLabel(status) {
|
|
||||||
switch(status) {
|
|
||||||
case UNRESOLVED:
|
|
||||||
return "Unresolved";
|
|
||||||
case RESOLVED:
|
|
||||||
return "Resolved";
|
|
||||||
case IGNORED:
|
|
||||||
return "Ignored";
|
|
||||||
default:
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@withPermissions(['ERRORS'], 'page-margin container-90')
|
|
||||||
@withSiteIdRouter
|
|
||||||
@connect(state => ({
|
|
||||||
list: state.getIn([ "errors", "list" ]),
|
|
||||||
status: state.getIn([ "errors", "options", "status" ]),
|
|
||||||
filter: state.getIn([ 'search', 'instance' ]),
|
|
||||||
}), {
|
|
||||||
fetchBookmarks,
|
|
||||||
applyFilter,
|
|
||||||
editOptions,
|
|
||||||
})
|
|
||||||
@withPageTitle("Errors - OpenReplay")
|
|
||||||
export default class Errors extends React.PureComponent {
|
|
||||||
constructor(props) {
|
|
||||||
super(props)
|
|
||||||
this.state = {
|
|
||||||
filter: '',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ensureErrorsPage() {
|
|
||||||
const { history } = this.props;
|
|
||||||
if (!isRoute(ERRORS_ROUTE, history.location.pathname)) {
|
|
||||||
history.push(ERRORS_ROUTE);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onStatusItemClick = ({ key }) => {
|
|
||||||
this.props.editOptions({ status: key });
|
|
||||||
}
|
|
||||||
|
|
||||||
onBookmarksClick = () => {
|
|
||||||
this.props.editOptions({ status: BOOKMARK });
|
|
||||||
}
|
|
||||||
|
|
||||||
onDateChange = (e) => {
|
|
||||||
const dateValues = e.toJSON();
|
|
||||||
this.props.applyFilter(dateValues);
|
|
||||||
};
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const {
|
|
||||||
count,
|
|
||||||
match: {
|
|
||||||
params: { errorId }
|
|
||||||
},
|
|
||||||
status,
|
|
||||||
list,
|
|
||||||
history,
|
|
||||||
filter,
|
|
||||||
} = this.props;
|
|
||||||
|
|
||||||
const { startDate, endDate, rangeValue } = filter;
|
|
||||||
const period = new Period({ start: startDate, end: endDate, rangeName: rangeValue });
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="page-margin container-90" >
|
|
||||||
<div className={cn("side-menu", {'disabled' : !isRoute(ERRORS_ROUTE, history.location.pathname)})}>
|
|
||||||
<SideMenuSection
|
|
||||||
title="Errors"
|
|
||||||
onItemClick={this.onStatusItemClick}
|
|
||||||
items={[
|
|
||||||
{
|
|
||||||
key: UNRESOLVED,
|
|
||||||
icon: "exclamation-circle",
|
|
||||||
label: getStatusLabel(UNRESOLVED),
|
|
||||||
active: status === UNRESOLVED,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: RESOLVED,
|
|
||||||
icon: "check",
|
|
||||||
label: getStatusLabel(RESOLVED),
|
|
||||||
active: status === RESOLVED,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: IGNORED,
|
|
||||||
icon: "ban",
|
|
||||||
label: getStatusLabel(IGNORED),
|
|
||||||
active: status === IGNORED,
|
|
||||||
}
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<SideMenuDividedItem
|
|
||||||
className="mt-3 mb-4"
|
|
||||||
iconName="star"
|
|
||||||
title="Bookmarks"
|
|
||||||
active={ status === BOOKMARK }
|
|
||||||
onClick={ this.onBookmarksClick }
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="side-menu-margined">
|
|
||||||
{ errorId == null ?
|
|
||||||
<>
|
|
||||||
<div className="mb-5 flex items-baseline">
|
|
||||||
<Header
|
|
||||||
text={ status === BOOKMARK ? "Bookmarks" : getStatusLabel(status) }
|
|
||||||
count={ list.size }
|
|
||||||
/>
|
|
||||||
<div className="ml-3 flex items-center">
|
|
||||||
<span className="mr-2 color-gray-medium">Seen in</span>
|
|
||||||
<SelectDateRange
|
|
||||||
period={period}
|
|
||||||
onChange={this.onDateChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<List
|
|
||||||
status={ status }
|
|
||||||
list={ list }
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
:
|
|
||||||
<ErrorInfo errorId={ errorId } list={ list } />
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
function Header({ text, count }) {
|
|
||||||
return (
|
|
||||||
<h3 className="text-2xl capitalize">
|
|
||||||
<span>{ text }</span>
|
|
||||||
{ count != null && <span className="ml-2 font-normal color-gray-medium">{ count }</span> }
|
|
||||||
</h3>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Header.displayName = "Header";
|
|
||||||
|
|
||||||
export default Header;
|
|
||||||
|
|
||||||
|
|
@ -1,259 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import { connect } from 'react-redux';
|
|
||||||
import { Set } from "immutable";
|
|
||||||
import { NoContent, Loader, Checkbox, IconButton, Input, Pagination } from 'UI';
|
|
||||||
import { merge, resolve, unresolve, ignore, updateCurrentPage, editOptions } from "Duck/errors";
|
|
||||||
import { applyFilter } from 'Duck/filters';
|
|
||||||
import { IGNORED, UNRESOLVED } from 'Types/errorInfo';
|
|
||||||
import Divider from 'Components/Errors/ui/Divider';
|
|
||||||
import ListItem from './ListItem/ListItem';
|
|
||||||
import { debounce } from 'App/utils';
|
|
||||||
import Select from 'Shared/Select';
|
|
||||||
import EmptyStateSvg from '../../../svg/no-results.svg';
|
|
||||||
|
|
||||||
const sortOptionsMap = {
|
|
||||||
'occurrence-desc': 'Last Occurrence',
|
|
||||||
'occurrence-desc': 'First Occurrence',
|
|
||||||
'sessions-asc': 'Sessions Ascending',
|
|
||||||
'sessions-desc': 'Sessions Descending',
|
|
||||||
'users-asc': 'Users Ascending',
|
|
||||||
'users-desc': 'Users Descending',
|
|
||||||
};
|
|
||||||
const sortOptions = Object.entries(sortOptionsMap)
|
|
||||||
.map(([ value, label ]) => ({ value, label }));
|
|
||||||
|
|
||||||
@connect(state => ({
|
|
||||||
loading: state.getIn([ "errors", "loading" ]),
|
|
||||||
resolveToggleLoading: state.getIn(["errors", "resolve", "loading"]) ||
|
|
||||||
state.getIn(["errors", "unresolve", "loading"]),
|
|
||||||
ignoreLoading: state.getIn([ "errors", "ignore", "loading" ]),
|
|
||||||
mergeLoading: state.getIn([ "errors", "merge", "loading" ]),
|
|
||||||
currentPage: state.getIn(["errors", "currentPage"]),
|
|
||||||
limit: state.getIn(["errors", "limit"]),
|
|
||||||
total: state.getIn([ 'errors', 'totalCount' ]),
|
|
||||||
sort: state.getIn([ 'errors', 'options', 'sort' ]),
|
|
||||||
order: state.getIn([ 'errors', 'options', 'order' ]),
|
|
||||||
query: state.getIn([ "errors", "options", "query" ]),
|
|
||||||
}), {
|
|
||||||
merge,
|
|
||||||
resolve,
|
|
||||||
unresolve,
|
|
||||||
ignore,
|
|
||||||
applyFilter,
|
|
||||||
updateCurrentPage,
|
|
||||||
editOptions,
|
|
||||||
})
|
|
||||||
export default class List extends React.PureComponent {
|
|
||||||
constructor(props) {
|
|
||||||
super(props)
|
|
||||||
this.state = {
|
|
||||||
checkedAll: false,
|
|
||||||
checkedIds: Set(),
|
|
||||||
query: props.query,
|
|
||||||
}
|
|
||||||
this.debounceFetch = debounce(this.props.editOptions, 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidMount() {
|
|
||||||
this.props.applyFilter({ });
|
|
||||||
}
|
|
||||||
|
|
||||||
check = ({ errorId }) => {
|
|
||||||
const { checkedIds } = this.state;
|
|
||||||
const newCheckedIds = checkedIds.contains(errorId)
|
|
||||||
? checkedIds.remove(errorId)
|
|
||||||
: checkedIds.add(errorId);
|
|
||||||
this.setState({
|
|
||||||
checkedAll: newCheckedIds.size === this.props.list.size,
|
|
||||||
checkedIds: newCheckedIds
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
checkAll = () => {
|
|
||||||
if (this.state.checkedAll) {
|
|
||||||
this.setState({
|
|
||||||
checkedAll: false,
|
|
||||||
checkedIds: Set(),
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
this.setState({
|
|
||||||
checkedAll: true,
|
|
||||||
checkedIds: this.props.list.map(({ errorId }) => errorId).toSet(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
resetChecked = () => {
|
|
||||||
this.setState({
|
|
||||||
checkedAll: false,
|
|
||||||
checkedIds: Set(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
currentCheckedIds() {
|
|
||||||
return this.state.checkedIds
|
|
||||||
.intersect(this.props.list.map(({ errorId }) => errorId).toSet());
|
|
||||||
}
|
|
||||||
|
|
||||||
merge = () => {
|
|
||||||
this.props.merge(currentCheckedIds().toJS()).then(this.resetChecked);
|
|
||||||
}
|
|
||||||
|
|
||||||
applyToAllChecked(f) {
|
|
||||||
return Promise.all(this.currentCheckedIds().map(f).toJS()).then(this.resetChecked);
|
|
||||||
}
|
|
||||||
|
|
||||||
resolve = () => {
|
|
||||||
this.applyToAllChecked(this.props.resolve);
|
|
||||||
}
|
|
||||||
|
|
||||||
unresolve = () => {
|
|
||||||
this.applyToAllChecked(this.props.unresolve);
|
|
||||||
}
|
|
||||||
|
|
||||||
ignore = () => {
|
|
||||||
this.applyToAllChecked(this.props.ignore);
|
|
||||||
}
|
|
||||||
|
|
||||||
addPage = () => this.props.updateCurrentPage(this.props.currentPage + 1)
|
|
||||||
|
|
||||||
writeOption = ({ name, value }) => {
|
|
||||||
const [ sort, order ] = value.split('-');
|
|
||||||
if (name === 'sort') {
|
|
||||||
this.props.editOptions({ sort, order });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// onQueryChange = ({ target: { value, name } }) => props.edit({ [ name ]: value })
|
|
||||||
|
|
||||||
onQueryChange = ({ target: { value, name } }) => {
|
|
||||||
this.setState({ query: value });
|
|
||||||
this.debounceFetch({ query: value });
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const {
|
|
||||||
list,
|
|
||||||
status,
|
|
||||||
loading,
|
|
||||||
ignoreLoading,
|
|
||||||
resolveToggleLoading,
|
|
||||||
mergeLoading,
|
|
||||||
currentPage,
|
|
||||||
total,
|
|
||||||
sort,
|
|
||||||
order,
|
|
||||||
limit,
|
|
||||||
} = this.props;
|
|
||||||
const {
|
|
||||||
checkedAll,
|
|
||||||
checkedIds,
|
|
||||||
query,
|
|
||||||
} = this.state;
|
|
||||||
const someLoading = loading || ignoreLoading || resolveToggleLoading || mergeLoading;
|
|
||||||
const currentCheckedIds = this.currentCheckedIds();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-white p-5 border-radius-3 thin-gray-border">
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<div className="flex items-center" style={{ height: "36px" }}>
|
|
||||||
<Checkbox
|
|
||||||
className="mr-3"
|
|
||||||
checked={ checkedAll }
|
|
||||||
onChange={ this.checkAll }
|
|
||||||
/>
|
|
||||||
{ status === UNRESOLVED
|
|
||||||
? <IconButton
|
|
||||||
outline
|
|
||||||
className="mr-3"
|
|
||||||
label="Resolve"
|
|
||||||
icon="check"
|
|
||||||
size="small"
|
|
||||||
loading={ resolveToggleLoading }
|
|
||||||
onClick={ this.resolve }
|
|
||||||
disabled={ someLoading || currentCheckedIds.size === 0}
|
|
||||||
/>
|
|
||||||
: <IconButton
|
|
||||||
outline
|
|
||||||
className="mr-3"
|
|
||||||
label="Unresolve"
|
|
||||||
icon="exclamation-circle"
|
|
||||||
size="small"
|
|
||||||
loading={ resolveToggleLoading }
|
|
||||||
onClick={ this.unresolve }
|
|
||||||
disabled={ someLoading || currentCheckedIds.size === 0}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
{ status !== IGNORED &&
|
|
||||||
<IconButton
|
|
||||||
outline
|
|
||||||
className="mr-3"
|
|
||||||
label="Ignore"
|
|
||||||
icon="ban"
|
|
||||||
size="small"
|
|
||||||
loading={ ignoreLoading }
|
|
||||||
onClick={ this.ignore }
|
|
||||||
disabled={ someLoading || currentCheckedIds.size === 0}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center ml-6">
|
|
||||||
<span className="mr-2 color-gray-medium">Sort By</span>
|
|
||||||
<Select
|
|
||||||
defaultValue={ `${sort}-${order}` }
|
|
||||||
name="sort"
|
|
||||||
plain
|
|
||||||
options={ sortOptions }
|
|
||||||
onChange={ this.writeOption }
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
style={{ width: '350px'}}
|
|
||||||
wrapperClassName="ml-3"
|
|
||||||
placeholder="Filter by name or message"
|
|
||||||
icon="search"
|
|
||||||
name="filter"
|
|
||||||
onChange={ this.onQueryChange }
|
|
||||||
value={query}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Divider />
|
|
||||||
<NoContent
|
|
||||||
title={
|
|
||||||
<div className="flex flex-col items-center justify-center">
|
|
||||||
<object style={{ width: "180px"}} type="image/svg+xml" data={EmptyStateSvg} />
|
|
||||||
<span className="mr-2">No Errors Found!</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
subtext="Please try to change your search parameters."
|
|
||||||
// animatedIcon="empty-state"
|
|
||||||
show={ !loading && list.size === 0}
|
|
||||||
>
|
|
||||||
<Loader loading={ loading }>
|
|
||||||
{ list.map(e =>
|
|
||||||
<div key={e.errorId} style={{ opacity: e.disabled ? 0.5 : 1}}>
|
|
||||||
<ListItem
|
|
||||||
disabled={someLoading || e.disabled}
|
|
||||||
key={e.errorId}
|
|
||||||
error={e}
|
|
||||||
checked={ checkedIds.contains(e.errorId) }
|
|
||||||
onCheck={ this.check }
|
|
||||||
/>
|
|
||||||
<Divider/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="w-full flex items-center justify-center mt-4">
|
|
||||||
<Pagination
|
|
||||||
page={currentPage}
|
|
||||||
total={total}
|
|
||||||
onPageChange={(page) => this.props.updateCurrentPage(page)}
|
|
||||||
limit={limit}
|
|
||||||
debounceRequest={500}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Loader>
|
|
||||||
</NoContent>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,87 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import { BarChart, Bar, YAxis, Tooltip, XAxis } from 'recharts';
|
|
||||||
import cn from 'classnames';
|
|
||||||
import { DateTime } from 'luxon'
|
|
||||||
import { diffFromNowString } from 'App/date';
|
|
||||||
import { error as errorRoute } from 'App/routes';
|
|
||||||
import { IGNORED, RESOLVED } from 'Types/errorInfo';
|
|
||||||
import { Checkbox, Link } from 'UI';
|
|
||||||
import ErrorName from 'Components/Errors/ui/ErrorName';
|
|
||||||
import Label from 'Components/Errors/ui/Label';
|
|
||||||
import stl from './listItem.module.css';
|
|
||||||
import { Styles } from '../../../Dashboard/Widgets/common';
|
|
||||||
|
|
||||||
const CustomTooltip = ({ active, payload, label }) => {
|
|
||||||
if (active) {
|
|
||||||
const p = payload[0].payload;
|
|
||||||
const dateStr = p.timestamp ? DateTime.fromMillis(p.timestamp).toFormat('l') : ''
|
|
||||||
return (
|
|
||||||
<div className="rounded border bg-white p-2">
|
|
||||||
<p className="label text-sm color-gray-medium">{dateStr}</p>
|
|
||||||
<p className="text-sm">Sessions: {p.count}</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
function ListItem({ className, onCheck, checked, error, disabled }) {
|
|
||||||
|
|
||||||
const getDateFormat = val => {
|
|
||||||
const d = new Date(val);
|
|
||||||
return (d.getMonth()+ 1) + '/' + d.getDate()
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={ cn("flex justify-between cursor-pointer py-4", className) } id="error-item">
|
|
||||||
<Checkbox
|
|
||||||
disabled={disabled}
|
|
||||||
checked={ checked }
|
|
||||||
onChange={ () => onCheck(error) }
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className={ cn("ml-3 flex-1 leading-tight", stl.name) } >
|
|
||||||
<Link to={errorRoute(error.errorId)} >
|
|
||||||
<ErrorName
|
|
||||||
icon={error.status === IGNORED ? 'ban' : null }
|
|
||||||
lineThrough={error.status === RESOLVED}
|
|
||||||
name={ error.name }
|
|
||||||
message={ error.stack0InfoString }
|
|
||||||
bold={ !error.viewed }
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className={ cn("truncate color-gray-medium", { "line-through" : error.status === RESOLVED}) }
|
|
||||||
>
|
|
||||||
{ error.message }
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
<BarChart width={ 150 } height={ 40 } data={ error.chart }>
|
|
||||||
<XAxis hide dataKey="timestamp" />
|
|
||||||
<YAxis hide domain={[0, 'dataMax + 8']} />
|
|
||||||
<Tooltip {...Styles.tooltip} label="Sessions" content={<CustomTooltip />} />
|
|
||||||
<Bar name="Sessions" minPointSize={1} dataKey="count" fill="#A8E0DA" />
|
|
||||||
</BarChart>
|
|
||||||
<Label
|
|
||||||
className={stl.sessions}
|
|
||||||
topValue={ error.sessions }
|
|
||||||
bottomValue="Sessions"
|
|
||||||
/>
|
|
||||||
<Label
|
|
||||||
className={stl.users}
|
|
||||||
topValue={ error.users }
|
|
||||||
bottomValue="Users"
|
|
||||||
/>
|
|
||||||
<Label
|
|
||||||
className={stl.occurrence}
|
|
||||||
topValue={ `${diffFromNowString(error.lastOccurrence)} ago` }
|
|
||||||
bottomValue="Last Seen"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
ListItem.displayName = "ListItem";
|
|
||||||
export default ListItem;
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
.name {
|
|
||||||
min-width: 55%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sessions {
|
|
||||||
width: 6%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.users {
|
|
||||||
width: 5%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.occurrence {
|
|
||||||
width: 15%;
|
|
||||||
min-width: 152px;
|
|
||||||
}
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import { SideMenuitem } from "UI";
|
|
||||||
import Divider from 'Components/Errors/ui/Divider';
|
|
||||||
function SideMenuDividedItem({ className, noTopDivider = false, noBottomDivider = false, ...props }) {
|
|
||||||
return (
|
|
||||||
<div className={className}>
|
|
||||||
{ !noTopDivider && <Divider /> }
|
|
||||||
<SideMenuitem
|
|
||||||
className="my-3"
|
|
||||||
{ ...props }
|
|
||||||
/>
|
|
||||||
{ !noBottomDivider && <Divider /> }
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
SideMenuDividedItem.displayName = "SideMenuDividedItem";
|
|
||||||
|
|
||||||
export default SideMenuDividedItem;
|
|
||||||
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import cn from 'classnames';
|
|
||||||
import stl from './sideMenuHeader.module.css';
|
|
||||||
|
|
||||||
function SideMenuHeader({ text, className }) {
|
|
||||||
return (
|
|
||||||
<div className={ cn(className, stl.label, "uppercase color-gray") }>
|
|
||||||
{ text }
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
SideMenuHeader.displayName = "SideMenuHeader";
|
|
||||||
export default SideMenuHeader;
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import { SideMenuitem } from 'UI';
|
|
||||||
import SideMenuHeader from './SideMenuHeader';
|
|
||||||
|
|
||||||
function SideMenuSection({ title, items, onItemClick }) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<SideMenuHeader className="mb-4" text={ title }/>
|
|
||||||
{ items.map(item =>
|
|
||||||
<SideMenuitem
|
|
||||||
key={ item.key }
|
|
||||||
active={ item.active }
|
|
||||||
title={ item.label }
|
|
||||||
iconName={ item.icon }
|
|
||||||
onClick={() => onItemClick(item)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
SideMenuSection.displayName = "SideMenuSection";
|
|
||||||
|
|
||||||
export default SideMenuSection;
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
.label {
|
|
||||||
letter-spacing: 0.2em;
|
|
||||||
color: gray;
|
|
||||||
}
|
|
||||||
|
|
@ -2,7 +2,7 @@ import React from 'react';
|
||||||
import { observer } from 'mobx-react-lite';
|
import { observer } from 'mobx-react-lite';
|
||||||
import { Button } from 'UI';
|
import { Button } from 'UI';
|
||||||
import cn from 'classnames';
|
import cn from 'classnames';
|
||||||
import FeatureFlag from 'MOBX/types/FeatureFlag';
|
import FeatureFlag from 'App/mstore/types/FeatureFlag';
|
||||||
|
|
||||||
function Description({
|
function Description({
|
||||||
isDescrEditing,
|
isDescrEditing,
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import React, { useEffect } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import { connect } from 'react-redux';
|
import { observer } from 'mobx-react-lite';
|
||||||
|
import { useStore } from 'App/mstore';
|
||||||
import ReCAPTCHA from 'react-google-recaptcha';
|
import ReCAPTCHA from 'react-google-recaptcha';
|
||||||
import { Form, Input, Loader, Button, Icon, Message } from 'UI';
|
import { Form, Input, Loader, Button, Icon, Message } from 'UI';
|
||||||
import { requestResetPassword, resetPassword, resetErrors } from 'Duck/user';
|
|
||||||
import stl from './forgotPassword.module.css';
|
import stl from './forgotPassword.module.css';
|
||||||
import { validatePassword } from 'App/validate';
|
import { validatePassword } from 'App/validate';
|
||||||
import { PASSWORD_POLICY } from 'App/constants';
|
import { PASSWORD_POLICY } from 'App/constants';
|
||||||
|
|
@ -13,29 +13,26 @@ const CAPTCHA_ENABLED = window.env.CAPTCHA_ENABLED === 'true';
|
||||||
const CAPTCHA_SITE_KEY = window.env.CAPTCHA_SITE_KEY;
|
const CAPTCHA_SITE_KEY = window.env.CAPTCHA_SITE_KEY;
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
errors: any;
|
|
||||||
resetErrors: any;
|
|
||||||
loading: boolean;
|
|
||||||
params: any;
|
params: any;
|
||||||
resetPassword: Function;
|
|
||||||
}
|
}
|
||||||
function CreatePassword(props: Props) {
|
function CreatePassword(props: Props) {
|
||||||
const { loading, params } = props;
|
const { params } = props;
|
||||||
|
const { userStore } = useStore();
|
||||||
|
const loading = userStore.loading;
|
||||||
|
const resetPassword = userStore.resetPassword;
|
||||||
const [error, setError] = React.useState<String | null>(null);
|
const [error, setError] = React.useState<String | null>(null);
|
||||||
const [validationError, setValidationError] = React.useState<String | null>(null);
|
const [validationError, setValidationError] = React.useState<String | null>(null);
|
||||||
const [updated, setUpdated] = React.useState(false);
|
const [updated, setUpdated] = React.useState(false);
|
||||||
const [requested, setRequested] = React.useState(false);
|
|
||||||
const [passwordRepeat, setPasswordRepeat] = React.useState('');
|
const [passwordRepeat, setPasswordRepeat] = React.useState('');
|
||||||
const [password, setPassword] = React.useState('');
|
const [password, setPassword] = React.useState('');
|
||||||
const [doesntMatch, setDoesntMatch] = React.useState(false);
|
|
||||||
const pass = params.get('pass');
|
const pass = params.get('pass');
|
||||||
const invitation = params.get('invitation');
|
const invitation = params.get('invitation');
|
||||||
|
|
||||||
const handleSubmit = (token?: any) => {
|
const handleSubmit = () => {
|
||||||
if (!validatePassword(password)) {
|
if (!validatePassword(password)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
props.resetPassword({ invitation, pass, password }).then((response: any) => {
|
resetPassword({ invitation, pass, password }).then((response: any) => {
|
||||||
if (response && response.errors && response.errors.length > 0) {
|
if (response && response.errors && response.errors.length > 0) {
|
||||||
setError(response.errors[0]);
|
setError(response.errors[0]);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -84,7 +81,6 @@ function CreatePassword(props: Props) {
|
||||||
<ReCAPTCHA
|
<ReCAPTCHA
|
||||||
ref={recaptchaRef}
|
ref={recaptchaRef}
|
||||||
size="invisible"
|
size="invisible"
|
||||||
data-hidden={requested}
|
|
||||||
sitekey={CAPTCHA_SITE_KEY}
|
sitekey={CAPTCHA_SITE_KEY}
|
||||||
onChange={(token: any) => handleSubmit(token)}
|
onChange={(token: any) => handleSubmit(token)}
|
||||||
/>
|
/>
|
||||||
|
|
@ -150,17 +146,4 @@ function CreatePassword(props: Props) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default connect(
|
export default observer(CreatePassword);
|
||||||
(state: any) => ({
|
|
||||||
errors: state.getIn(['user', 'requestResetPassowrd', 'errors']),
|
|
||||||
resetErrors: state.getIn(['user', 'resetPassword', 'errors']),
|
|
||||||
loading:
|
|
||||||
state.getIn(['user', 'requestResetPassowrd', 'loading']) ||
|
|
||||||
state.getIn(['user', 'resetPassword', 'loading']),
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
requestResetPassword,
|
|
||||||
resetPassword,
|
|
||||||
resetErrors,
|
|
||||||
}
|
|
||||||
)(CreatePassword);
|
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,15 @@
|
||||||
import Copyright from 'Shared/Copyright';
|
import Copyright from 'Shared/Copyright';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Form, Input, Loader, Link, Icon, Message } from 'UI';
|
import { Link } from 'UI';
|
||||||
import {Button} from 'antd';
|
import {Button} from 'antd';
|
||||||
import { login as loginRoute } from 'App/routes';
|
import { login as loginRoute } from 'App/routes';
|
||||||
import { connect } from 'react-redux';
|
|
||||||
import ResetPassword from './ResetPasswordRequest';
|
import ResetPassword from './ResetPasswordRequest';
|
||||||
import CreatePassword from './CreatePassword';
|
import CreatePassword from './CreatePassword';
|
||||||
|
|
||||||
const LOGIN = loginRoute();
|
const LOGIN = loginRoute();
|
||||||
|
|
||||||
interface Props {
|
function ForgotPassword(props) {
|
||||||
params: any;
|
const params = new URLSearchParams(props.location.search);
|
||||||
}
|
|
||||||
function ForgotPassword(props: Props) {
|
|
||||||
const { params } = props;
|
|
||||||
const pass = params.get('pass');
|
const pass = params.get('pass');
|
||||||
const invitation = params.get('invitation');
|
const invitation = params.get('invitation');
|
||||||
const creatingNewPassword = pass && invitation;
|
const creatingNewPassword = pass && invitation;
|
||||||
|
|
@ -54,6 +50,4 @@ function ForgotPassword(props: Props) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default connect((state: any, props: any) => ({
|
export default ForgotPassword;
|
||||||
params: new URLSearchParams(props.location.search),
|
|
||||||
}))(ForgotPassword);
|
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue