Add lokalisation (#3092)

* applied eslint

* add locales and lint the project

* removed error boundary

* updated locales

* fix min files

* fix locales
This commit is contained in:
Andrey Babushkin 2025-03-06 17:43:15 +01:00 committed by GitHub
parent b8091b69c2
commit fd5c0c9747
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2045 changed files with 71544 additions and 39553 deletions

View file

@ -5,5 +5,9 @@
"singleQuote": true, "singleQuote": true,
"importOrderSeparation": true, "importOrderSeparation": true,
"importOrderSortSpecifiers": true, "importOrderSortSpecifiers": true,
"importOrder": ["^Components|^App|^UI|^Duck", "^Shared", "^[./]"] "importOrder": ["^Components|^App|^UI|^Duck", "^Shared", "^[./]"],
"bracketSpacing": true,
"arrowParens": "always",
"semi": true,
"trailingComma": "all"
} }

View file

@ -5,13 +5,9 @@ interface Props {
redirect: string; redirect: string;
} }
const AdditionalRoutes = (props: Props) => { function AdditionalRoutes(props: Props) {
const { redirect } = props; const { redirect } = props;
return ( return <Redirect to={redirect} />;
<> }
<Redirect to={redirect} />
</>
);
};
export default AdditionalRoutes; export default AdditionalRoutes;

View file

@ -3,31 +3,29 @@ import { Switch, Route } from 'react-router-dom';
import { Loader } from 'UI'; import { Loader } from 'UI';
import withSiteIdUpdater from 'HOCs/withSiteIdUpdater'; import withSiteIdUpdater from 'HOCs/withSiteIdUpdater';
import * as routes from './routes';
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 { useStore } from 'App/mstore';
import { observer } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite';
import * as routes from './routes';
const components: any = { const components: any = {
SessionPure: lazy(() => import('Components/Session/Session')), SessionPure: lazy(() => import('Components/Session/Session')),
LiveSessionPure: lazy(() => import('Components/Session/LiveSession')) LiveSessionPure: lazy(() => import('Components/Session/LiveSession')),
}; };
const enhancedComponents: any = { const enhancedComponents: any = {
Session: withSiteIdUpdater(components.SessionPure), Session: withSiteIdUpdater(components.SessionPure),
LiveSession: withSiteIdUpdater(components.LiveSessionPure) LiveSession: withSiteIdUpdater(components.LiveSessionPure),
}; };
const withSiteId = routes.withSiteId; const { withSiteId } = routes;
const SESSION_PATH = routes.session(); const SESSION_PATH = routes.session();
const LIVE_SESSION_PATH = routes.liveSession(); const LIVE_SESSION_PATH = routes.liveSession();
interface Props { interface Props {
isJwt?: boolean; isJwt?: boolean;
isLoggedIn?: boolean; isLoggedIn?: boolean;
@ -43,15 +41,23 @@ function IFrameRoutes(props: Props) {
if (isLoggedIn) { if (isLoggedIn) {
return ( return (
<ModalProvider> <ModalProvider>
<Layout hideHeader={true}> <Layout hideHeader>
<Loader loading={!!loading} className='flex-1'> <Loader loading={!!loading} className="flex-1">
<Suspense fallback={<Loader loading={true} className='flex-1' />}> <Suspense fallback={<Loader loading className="flex-1" />}>
<Switch key='content'> <Switch key="content">
<Route exact strict path={withSiteId(SESSION_PATH, siteIdList)} <Route
component={enhancedComponents.Session} /> exact
<Route exact strict path={withSiteId(LIVE_SESSION_PATH, siteIdList)} strict
component={enhancedComponents.LiveSession} /> path={withSiteId(SESSION_PATH, siteIdList)}
<Route path='*' render={NotFoundPage} /> component={enhancedComponents.Session}
/>
<Route
exact
strict
path={withSiteId(LIVE_SESSION_PATH, siteIdList)}
component={enhancedComponents.LiveSession}
/>
<Route path="*" render={NotFoundPage} />
</Switch> </Switch>
</Suspense> </Suspense>
</Loader> </Loader>
@ -67,5 +73,4 @@ function IFrameRoutes(props: Props) {
return <PublicRoutes />; return <PublicRoutes />;
} }
export default observer(IFrameRoutes); export default observer(IFrameRoutes);

View file

@ -21,13 +21,13 @@ const components: any = {
DashboardPure: lazy(() => import('Components/Dashboard/NewDashboard')), DashboardPure: lazy(() => import('Components/Dashboard/NewDashboard')),
MultiviewPure: lazy(() => import('Components/Session_/Multiview/Multiview')), MultiviewPure: lazy(() => import('Components/Session_/Multiview/Multiview')),
UsabilityTestingPure: lazy( UsabilityTestingPure: lazy(
() => import('Components/UsabilityTesting/UsabilityTesting') () => import('Components/UsabilityTesting/UsabilityTesting'),
), ),
UsabilityTestEditPure: lazy( UsabilityTestEditPure: lazy(
() => import('Components/UsabilityTesting/TestEdit') () => import('Components/UsabilityTesting/TestEdit'),
), ),
UsabilityTestOverviewPure: lazy( UsabilityTestOverviewPure: lazy(
() => import('Components/UsabilityTesting/TestOverview') () => import('Components/UsabilityTesting/TestOverview'),
), ),
SpotsListPure: lazy(() => import('Components/Spots/SpotsList')), SpotsListPure: lazy(() => import('Components/Spots/SpotsList')),
SpotPure: lazy(() => import('Components/Spots/SpotPlayer')), SpotPure: lazy(() => import('Components/Spots/SpotPlayer')),
@ -47,7 +47,7 @@ const enhancedComponents: any = {
UsabilityTesting: withSiteIdUpdater(components.UsabilityTestingPure), UsabilityTesting: withSiteIdUpdater(components.UsabilityTestingPure),
UsabilityTestEdit: withSiteIdUpdater(components.UsabilityTestEditPure), UsabilityTestEdit: withSiteIdUpdater(components.UsabilityTestEditPure),
UsabilityTestOverview: withSiteIdUpdater( UsabilityTestOverview: withSiteIdUpdater(
components.UsabilityTestOverviewPure components.UsabilityTestOverviewPure,
), ),
SpotsList: withSiteIdUpdater(components.SpotsListPure), SpotsList: withSiteIdUpdater(components.SpotsListPure),
Spot: components.SpotPure, Spot: components.SpotPure,
@ -55,7 +55,7 @@ const enhancedComponents: any = {
Highlights: withSiteIdUpdater(components.HighlightsPure) Highlights: withSiteIdUpdater(components.HighlightsPure)
}; };
const withSiteId = routes.withSiteId; const { withSiteId } = routes;
const METRICS_PATH = routes.metrics(); const METRICS_PATH = routes.metrics();
const METRICS_DETAILS = routes.metricDetails(); const METRICS_DETAILS = routes.metricDetails();
@ -104,13 +104,16 @@ function PrivateRoutes() {
const { projectsStore, userStore, integrationsStore, searchStore } = useStore(); const { projectsStore, userStore, integrationsStore, searchStore } = useStore();
const onboarding = userStore.onboarding; const onboarding = userStore.onboarding;
const scope = userStore.scopeState; const scope = userStore.scopeState;
const tenantId = userStore.account.tenantId; const { tenantId } = userStore.account;
const sites = projectsStore.list; const sites = projectsStore.list;
const siteId = projectsStore.siteId; const { siteId } = projectsStore;
const hasRecordings = sites.some(s => s.recorded); const hasRecordings = sites.some((s) => s.recorded);
const redirectToSetup = scope === 0; const redirectToSetup = scope === 0;
const redirectToOnboarding = const redirectToOnboarding =
!onboarding && (localStorage.getItem(GLOBAL_HAS_NO_RECORDINGS) === 'true' || (sites.length > 0 && !hasRecordings)) && scope > 0; !onboarding &&
(localStorage.getItem(GLOBAL_HAS_NO_RECORDINGS) === 'true' ||
(sites.length > 0 && !hasRecordings)) &&
scope > 0;
const siteIdList: any = sites.map(({ id }) => id); const siteIdList: any = sites.map(({ id }) => id);
React.useEffect(() => { React.useEffect(() => {
@ -130,7 +133,7 @@ function PrivateRoutes() {
}, [searchStore.instance.filters, searchStore.instance.eventsOrder]); }, [searchStore.instance.filters, searchStore.instance.eventsOrder]);
return ( return (
<Suspense fallback={<Loader loading={true} className="flex-1" />}> <Suspense fallback={<Loader loading className="flex-1" />}>
<Switch key="content"> <Switch key="content">
<Route <Route
exact exact

View file

@ -7,21 +7,25 @@ import { useStore } from 'App/mstore';
import { observer } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite';
import * as routes from 'App/routes'; import * as routes from 'App/routes';
const LOGIN_PATH = routes.login(); const LOGIN_PATH = routes.login();
const SIGNUP_PATH = routes.signup(); const SIGNUP_PATH = routes.signup();
const FORGOT_PASSWORD = routes.forgotPassword(); const FORGOT_PASSWORD = routes.forgotPassword();
const SPOT_PATH = routes.spot(); 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 Spot = lazy(() => import('Components/Spots/SpotPlayer/SpotPlayer')); const Spot = lazy(() => import('Components/Spots/SpotPlayer/SpotPlayer'));
function PublicRoutes() { function PublicRoutes() {
const { userStore } = useStore(); const { userStore } = useStore();
const authDetails = userStore.authStore.authDetails; const { authDetails } = userStore.authStore;
const isEnterprise = userStore.isEnterprise; const { isEnterprise } = userStore;
const hideSupport = isEnterprise || location.pathname.includes('spots') || location.pathname.includes('view-spot'); const hideSupport =
isEnterprise ||
location.pathname.includes('spots') ||
location.pathname.includes('view-spot');
const [loading, setLoading] = React.useState(true); const [loading, setLoading] = React.useState(true);
useEffect(() => { useEffect(() => {
@ -34,10 +38,15 @@ function PublicRoutes() {
return ( return (
<Loader loading={loading} className="flex-1"> <Loader loading={loading} className="flex-1">
<Suspense fallback={<Loader loading={true} className="flex-1" />}> <Suspense fallback={<Loader loading className="flex-1" />}>
<Switch> <Switch>
<Route exact strict path={SPOT_PATH} component={Spot} /> <Route exact strict path={SPOT_PATH} component={Spot} />
<Route exact strict path={FORGOT_PASSWORD} component={ForgotPassword} /> <Route
exact
strict
path={FORGOT_PASSWORD}
component={ForgotPassword}
/>
<Route exact strict path={LOGIN_PATH} component={Login} /> <Route exact strict path={LOGIN_PATH} component={Login} />
<Route exact strict path={SIGNUP_PATH} component={Signup} /> <Route exact strict path={SIGNUP_PATH} component={Signup} />
<Redirect to={LOGIN_PATH} /> <Redirect to={LOGIN_PATH} />
@ -48,5 +57,4 @@ function PublicRoutes() {
); );
} }
export default observer(PublicRoutes); export default observer(PublicRoutes);

View file

@ -8,7 +8,7 @@ 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 { useStore } from 'App/mstore'; import { useStore } from 'App/mstore';
@ -16,8 +16,8 @@ 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 { Loader } from 'UI'; import { Loader } from 'UI';
import { observer } from 'mobx-react-lite';
import * as routes from './routes'; import * as routes from './routes';
import { observer } from 'mobx-react-lite'
interface RouterProps extends RouteComponentProps { interface RouterProps extends RouteComponentProps {
match: { match: {
@ -28,27 +28,32 @@ interface RouterProps extends RouteComponentProps {
} }
const Router: React.FC<RouterProps> = (props) => { const Router: React.FC<RouterProps> = (props) => {
const { const { location, history } = props;
location,
history,
} = props;
const mstore = useStore(); const mstore = useStore();
const { customFieldStore, projectsStore, sessionStore, searchStore, userStore } = mstore; const {
const jwt = userStore.jwt; customFieldStore,
const changePassword = userStore.account.changePassword; projectsStore,
sessionStore,
searchStore,
userStore,
} = mstore;
const { jwt } = userStore;
const { changePassword } = userStore.account;
const userInfoLoading = userStore.fetchInfoRequest.loading; const userInfoLoading = userStore.fetchInfoRequest.loading;
const scopeSetup = userStore.scopeState === 0; const scopeSetup = userStore.scopeState === 0;
const localSpotJwt = userStore.spotJwt; const localSpotJwt = userStore.spotJwt;
const isLoggedIn = Boolean(jwt && !changePassword); const isLoggedIn = Boolean(jwt && !changePassword);
const fetchUserInfo = userStore.fetchUserInfo; const { fetchUserInfo } = userStore;
const setJwt = userStore.updateJwt; const setJwt = userStore.updateJwt;
const logout = userStore.logout; const { logout } = userStore;
const setSessionPath = sessionStore.setSessionPath; const { setSessionPath } = sessionStore;
const siteId = projectsStore.siteId; const { siteId } = projectsStore;
const sitesLoading = projectsStore.sitesLoading; const { sitesLoading } = projectsStore;
const sites = projectsStore.list; const sites = projectsStore.list;
const loading = Boolean(userInfoLoading || (!scopeSetup && !siteId) || sitesLoading); const loading = Boolean(
userInfoLoading || (!scopeSetup && !siteId) || sitesLoading,
);
const initSite = projectsStore.initProject; const initSite = projectsStore.initProject;
const fetchSiteList = projectsStore.fetchList; const fetchSiteList = projectsStore.fetchList;
@ -75,10 +80,10 @@ const Router: React.FC<RouterProps> = (props) => {
const handleSpotLogin = (jwt: string) => { const handleSpotLogin = (jwt: string) => {
if (spotReqSent.current) { if (spotReqSent.current) {
return; return;
} else {
spotReqSent.current = true;
setIsSpotCb(false);
} }
spotReqSent.current = true;
setIsSpotCb(false);
handleSpotJWT(jwt); handleSpotJWT(jwt);
}; };
@ -86,7 +91,7 @@ const Router: React.FC<RouterProps> = (props) => {
if (!isLoggedIn && location.pathname !== routes.login()) { if (!isLoggedIn && location.pathname !== routes.login()) {
localStorage.setItem( localStorage.setItem(
GLOBAL_DESTINATION_PATH, GLOBAL_DESTINATION_PATH,
location.pathname + location.search location.pathname + location.search,
); );
} }
}; };
@ -143,7 +148,7 @@ const Router: React.FC<RouterProps> = (props) => {
useEffect(() => { useEffect(() => {
handleDestinationPath(); handleDestinationPath();
setSessionPath(previousLocation ? previousLocation : location); setSessionPath(previousLocation || location);
}, [location]); }, [location]);
useEffect(() => { useEffect(() => {
@ -163,14 +168,14 @@ const Router: React.FC<RouterProps> = (props) => {
}, [isSpotCb, isLoggedIn, localSpotJwt, isSignup]); }, [isSpotCb, isLoggedIn, localSpotJwt, isSignup]);
useEffect(() => { useEffect(() => {
if (!isLoggedIn) return if (!isLoggedIn) return;
const fetchData = async () => { const fetchData = async () => {
if (siteId && siteId !== lastFetchedSiteIdRef.current) { if (siteId && siteId !== lastFetchedSiteIdRef.current) {
const activeSite = sites.find((s) => s.id == siteId); const activeSite = sites.find((s) => s.id == siteId);
initSite(activeSite ?? {}); initSite(activeSite ?? {});
lastFetchedSiteIdRef.current = activeSite?.id; lastFetchedSiteIdRef.current = activeSite?.id;
await customFieldStore.fetchListActive(siteId + ''); await customFieldStore.fetchListActive(`${siteId}`);
await searchStore.fetchSavedSearchList() await searchStore.fetchSavedSearchList();
} }
}; };

View file

@ -30,15 +30,18 @@ const siteIdRequiredPaths: string[] = [
'/check-recording-status', '/check-recording-status',
'/usability-tests', '/usability-tests',
'/tags', '/tags',
'/intelligent' '/intelligent',
]; ];
export const clean = (obj: any, forbiddenValues: any[] = [undefined, '']): any => { export const clean = (
obj: any,
forbiddenValues: any[] = [undefined, ''],
): any => {
const keys = Array.isArray(obj) const keys = Array.isArray(obj)
? new Array(obj.length).fill().map((_, i) => i) ? new Array(obj.length).fill().map((_, i) => i)
: Object.keys(obj); : Object.keys(obj);
const retObj = Array.isArray(obj) ? [] : {}; const retObj = Array.isArray(obj) ? [] : {};
keys.map(key => { keys.map((key) => {
const value = obj[key]; const value = obj[key];
if (typeof value === 'object' && value !== null) { if (typeof value === 'object' && value !== null) {
retObj[key] = clean(value); retObj[key] = clean(value);
@ -52,18 +55,23 @@ export const clean = (obj: any, forbiddenValues: any[] = [undefined, '']): any =
export default class APIClient { export default class APIClient {
private init: RequestInit; private init: RequestInit;
private siteId: string | undefined; private siteId: string | undefined;
private siteIdCheck: (() => { siteId: string | null }) | undefined; private siteIdCheck: (() => { siteId: string | null }) | undefined;
private getJwt: () => string | null = () => null; private getJwt: () => string | null = () => null;
private onUpdateJwt: (data: { jwt?: string, spotJwt?: string }) => void;
private onUpdateJwt: (data: { jwt?: string; spotJwt?: string }) => void;
private refreshingTokenPromise: Promise<string> | null = null; private refreshingTokenPromise: Promise<string> | null = null;
constructor() { constructor() {
this.init = { this.init = {
headers: new Headers({ headers: new Headers({
Accept: 'application/json', Accept: 'application/json',
'Content-Type': 'application/json' 'Content-Type': 'application/json',
}) }),
}; };
} }
@ -73,7 +81,9 @@ export default class APIClient {
} }
} }
setOnUpdateJwt(onUpdateJwt: (data: { jwt?: string, spotJwt?: string }) => void): void { setOnUpdateJwt(
onUpdateJwt: (data: { jwt?: string; spotJwt?: string }) => void,
): void {
this.onUpdateJwt = onUpdateJwt; this.onUpdateJwt = onUpdateJwt;
} }
@ -85,7 +95,11 @@ export default class APIClient {
this.siteIdCheck = checker; 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 = this.getJwt(); const jwt = this.getJwt();
const headers = new Headers({ const headers = new Headers({
@ -148,7 +162,7 @@ export default class APIClient {
params?: any, params?: any,
method: string = 'GET', method: string = 'GET',
options: { clean?: boolean } = { clean: true }, options: { clean?: boolean } = { clean: true },
headers?: Record<string, any> headers?: Record<string, any>,
): Promise<Response> { ): Promise<Response> {
let _path = path; let _path = path;
let jwt = this.getJwt(); let jwt = this.getJwt();
@ -157,7 +171,11 @@ export default class APIClient {
(this.init.headers as Headers).set('Authorization', `Bearer ${jwt}`); (this.init.headers as Headers).set('Authorization', `Bearer ${jwt}`);
} }
const init = this.getInit(method, options.clean && params ? clean(params) : params, headers); const init = this.getInit(
method,
options.clean && params ? clean(params) : params,
headers,
);
if (params !== undefined) { if (params !== undefined) {
const cleanedParams = options.clean ? clean(params) : params; const cleanedParams = options.clean ? clean(params) : params;
@ -193,7 +211,7 @@ export default class APIClient {
edp = `${edp}/${this.siteId ?? ''}`; edp = `${edp}/${this.siteId ?? ''}`;
} }
if (path.includes('PROJECT_ID')) { if (path.includes('PROJECT_ID')) {
_path = _path.replace('PROJECT_ID', this.siteId + ''); _path = _path.replace('PROJECT_ID', `${this.siteId}`);
} }
const fullUrl = edp + _path; const fullUrl = edp + _path;
@ -205,7 +223,7 @@ export default class APIClient {
if (response.ok) { if (response.ok) {
return response; return response;
} }
let errorMsg = `Something went wrong.`; let errorMsg = 'Something went wrong.';
try { try {
const errorData = await response.json(); const errorData = await response.json();
errorMsg = errorData.errors?.[0] || errorMsg; errorMsg = errorData.errors?.[0] || errorMsg;
@ -216,9 +234,14 @@ export default class APIClient {
async refreshToken(): Promise<string> { async refreshToken(): Promise<string> {
try { try {
const response = await this.fetch('/refresh', { const response = await this.fetch(
headers: this.init.headers '/refresh',
}, 'GET', { clean: false }); {
headers: this.init.headers,
},
'GET',
{ clean: false },
);
if (!response.ok) { if (!response.ok) {
throw new Error('Failed to refresh token'); throw new Error('Failed to refresh token');
@ -235,12 +258,28 @@ export default class APIClient {
} }
} }
get(path: string, params?: any, options?: any, headers?: Record<string, any>): Promise<Response> { get(
path: string,
params?: any,
options?: any,
headers?: Record<string, any>,
): Promise<Response> {
this.init.method = 'GET'; this.init.method = 'GET';
return this.fetch(queried(path, params), options, 'GET', undefined, headers); return this.fetch(
queried(path, params),
options,
'GET',
undefined,
headers,
);
} }
post(path: string, params?: any, options?: any, headers?: Record<string, any>): Promise<Response> { post(
path: string,
params?: any,
options?: any,
headers?: Record<string, any>,
): Promise<Response> {
this.init.method = 'POST'; this.init.method = 'POST';
return this.fetch(path, params, 'POST', options, headers); return this.fetch(path, params, 'POST', options, headers);
} }

View file

@ -1,395 +0,0 @@
import React, {useEffect} from 'react';
import {Form, Input, SegmentSelection, Checkbox, Icon} from 'UI';
import {alertConditions as conditions} from 'App/constants';
import stl from './alertForm.module.css';
import DropdownChips from './DropdownChips';
import {validateEmail} from 'App/validate';
import cn from 'classnames';
import {useStore} from 'App/mstore'
import {observer} from 'mobx-react-lite'
import Select from 'Shared/Select';
import {Button} from "antd";
const thresholdOptions = [
{label: '15 minutes', value: 15},
{label: '30 minutes', value: 30},
{label: '1 hour', value: 60},
{label: '2 hours', value: 120},
{label: '4 hours', value: 240},
{label: '1 day', value: 1440},
];
const changeOptions = [
{label: 'change', value: 'change'},
{label: '% change', value: 'percent'},
];
const Circle = ({text}) => (
<div className="circle mr-4 w-6 h-6 rounded-full bg-gray-light flex items-center justify-center">
{text}
</div>
);
const Section = ({index, title, description, content}) => (
<div className="w-full">
<div className="flex items-start">
<Circle text={index}/>
<div>
<span className="font-medium">{title}</span>
{description && <div className="text-sm color-gray-medium">{description}</div>}
</div>
</div>
<div className="ml-10">{content}</div>
</div>
);
function AlertForm(props) {
const {
slackChannels,
msTeamsChannels,
webhooks,
onDelete,
style = {height: "calc('100vh - 40px')"},
} = props;
const {alertsStore, metricStore} = useStore()
const {
triggerOptions: allTriggerSeries,
loading,
} = alertsStore
const triggerOptions = metricStore.instance.series.length > 0 ? allTriggerSeries.filter(s => {
return metricStore.instance.series.findIndex(ms => ms.seriesId === s.value) !== -1
}).map(v => {
const labelArr = v.label.split('.')
labelArr.shift()
return {
...v,
label: labelArr.join('.')
}
}) : allTriggerSeries
const instance = alertsStore.instance
const deleting = loading
const write = ({target: {value, name}}) => alertsStore.edit({[name]: value});
const writeOption = (e, {name, value}) => alertsStore.edit({[name]: value.value});
const onChangeCheck = ({target: {checked, name}}) => alertsStore.edit({[name]: checked});
useEffect(() => {
void alertsStore.fetchTriggerOptions();
}, []);
const writeQueryOption = (e, {name, value}) => {
const {query} = instance;
alertsStore.edit({query: {...query, [name]: value}});
};
const writeQuery = ({target: {value, name}}) => {
const {query} = instance;
alertsStore.edit({query: {...query, [name]: value}});
};
const metric =
instance && instance.query.left
? triggerOptions.find((i) => i.value === instance.query.left)
: null;
const unit = metric ? metric.unit : '';
const isThreshold = instance.detectionMethod === 'threshold';
return (
<Form
className={cn('pb-10', stl.wrapper)}
style={style}
onSubmit={() => props.onSubmit(instance)}
id="alert-form"
>
<div className={cn('-mx-6 px-6 pb-12')}>
<input
autoFocus={true}
className="text-lg border border-gray-light rounded w-full"
name="name"
style={{fontSize: '18px', padding: '10px', fontWeight: '600'}}
value={instance && instance.name}
onChange={write}
placeholder="Untiltled Alert"
id="name-field"
/>
<div className="mb-8"/>
<Section
index="1"
title={'What kind of alert do you want to set?'}
content={
<div>
<SegmentSelection
primary
name="detectionMethod"
className="my-3"
onSelect={(e, {name, value}) => alertsStore.edit({[name]: value})}
value={{value: instance.detectionMethod}}
list={[
{name: 'Threshold', value: 'threshold'},
{name: 'Change', value: 'change'},
]}
/>
<div className="text-sm color-gray-medium">
{isThreshold &&
'Eg. Alert me if memory.avg is greater than 500mb over the past 4 hours.'}
{!isThreshold &&
'Eg. Alert me if % change of memory.avg is greater than 10% over the past 4 hours compared to the previous 4 hours.'}
</div>
<div className="my-4"/>
</div>
}
/>
<hr className="my-8"/>
<Section
index="2"
title="Condition"
content={
<div>
{!isThreshold && (
<div className="flex items-center my-3">
<label className="w-2/6 flex-shrink-0 font-normal">{'Trigger when'}</label>
<Select
className="w-4/6"
placeholder="change"
options={changeOptions}
name="change"
defaultValue={instance.change}
onChange={({value}) => writeOption(null, {name: 'change', value})}
id="change-dropdown"
/>
</div>
)}
<div className="flex items-center my-3">
<label className="w-2/6 flex-shrink-0 font-normal">
{isThreshold ? 'Trigger when' : 'of'}
</label>
<Select
className="w-4/6"
placeholder="Select Metric"
isSearchable={true}
options={triggerOptions}
name="left"
value={triggerOptions.find((i) => i.value === instance.query.left)}
// onChange={ writeQueryOption }
onChange={({value}) =>
writeQueryOption(null, {name: 'left', value: value.value})
}
/>
</div>
<div className="flex items-center my-3">
<label className="w-2/6 flex-shrink-0 font-normal">{'is'}</label>
<div className="w-4/6 flex items-center">
<Select
placeholder="Select Condition"
options={conditions}
name="operator"
defaultValue={instance.query.operator}
// onChange={ writeQueryOption }
onChange={({value}) =>
writeQueryOption(null, {name: 'operator', value: value.value})
}
/>
{unit && (
<>
<Input
className="px-4"
style={{marginRight: '31px'}}
// label={{ basic: true, content: unit }}
// labelPosition='right'
name="right"
value={instance.query.right}
onChange={writeQuery}
placeholder="E.g. 3"
/>
<span className="ml-2">{'test'}</span>
</>
)}
{!unit && (
<Input
wrapperClassName="ml-2"
// className="pl-4"
name="right"
value={instance.query.right}
onChange={writeQuery}
placeholder="Specify value"
/>
)}
</div>
</div>
<div className="flex items-center my-3">
<label className="w-2/6 flex-shrink-0 font-normal">{'over the past'}</label>
<Select
className="w-2/6"
placeholder="Select timeframe"
options={thresholdOptions}
name="currentPeriod"
defaultValue={instance.currentPeriod}
// onChange={ writeOption }
onChange={({value}) => writeOption(null, {name: 'currentPeriod', value})}
/>
</div>
{!isThreshold && (
<div className="flex items-center my-3">
<label className="w-2/6 flex-shrink-0 font-normal">
{'compared to previous'}
</label>
<Select
className="w-2/6"
placeholder="Select timeframe"
options={thresholdOptions}
name="previousPeriod"
defaultValue={instance.previousPeriod}
// onChange={ writeOption }
onChange={({value}) => writeOption(null, {name: 'previousPeriod', value})}
/>
</div>
)}
</div>
}
/>
<hr className="my-8"/>
<Section
index="3"
title="Notify Through"
description="You'll be noticed in app notifications. Additionally opt in to receive alerts on:"
content={
<div className="flex flex-col">
<div className="flex items-center my-4">
<Checkbox
name="slack"
className="mr-8"
type="checkbox"
checked={instance.slack}
onClick={onChangeCheck}
label="Slack"
/>
<Checkbox
name="msteams"
className="mr-8"
type="checkbox"
checked={instance.msteams}
onClick={onChangeCheck}
label="MS Teams"
/>
<Checkbox
name="email"
type="checkbox"
checked={instance.email}
onClick={onChangeCheck}
className="mr-8"
label="Email"
/>
<Checkbox
name="webhook"
type="checkbox"
checked={instance.webhook}
onClick={onChangeCheck}
label="Webhook"
/>
</div>
{instance.slack && (
<div className="flex items-start my-4">
<label className="w-2/6 flex-shrink-0 font-normal pt-2">{'Slack'}</label>
<div className="w-4/6">
<DropdownChips
fluid
selected={instance.slackInput}
options={slackChannels}
placeholder="Select Channel"
onChange={(selected) => alertsStore.edit({slackInput: selected})}
/>
</div>
</div>
)}
{instance.msteams && (
<div className="flex items-start my-4">
<label className="w-2/6 flex-shrink-0 font-normal pt-2">{'MS Teams'}</label>
<div className="w-4/6">
<DropdownChips
fluid
selected={instance.msteamsInput}
options={msTeamsChannels}
placeholder="Select Channel"
onChange={(selected) => alertsStore.edit({msteamsInput: selected})}
/>
</div>
</div>
)}
{instance.email && (
<div className="flex items-start my-4">
<label className="w-2/6 flex-shrink-0 font-normal pt-2">{'Email'}</label>
<div className="w-4/6">
<DropdownChips
textFiled
validate={validateEmail}
selected={instance.emailInput}
placeholder="Type and press Enter key"
onChange={(selected) => alertsStore.edit({emailInput: selected})}
/>
</div>
</div>
)}
{instance.webhook && (
<div className="flex items-start my-4">
<label className="w-2/6 flex-shrink-0 font-normal pt-2">{'Webhook'}</label>
<DropdownChips
fluid
selected={instance.webhookInput}
options={webhooks}
placeholder="Select Webhook"
onChange={(selected) => alertsStore.edit({webhookInput: selected})}
/>
</div>
)}
</div>
}
/>
</div>
<div
className="flex items-center justify-between absolute bottom-0 left-0 right-0 p-6 border-t z-10 bg-white">
<div className="flex items-center">
<Button
loading={loading}
type="primary"
htmlType="submit"
disabled={loading || !instance.validate()}
id="submit-button"
>
{instance.exists() ? 'Update' : 'Create'}
</Button>
<div className="mx-1"/>
<Button onClick={props.onClose}>Cancel</Button>
</div>
<div>
{instance.exists() && (
<Button
hover
primary="text"
loading={deleting}
type="button"
onClick={() => onDelete(instance)}
id="trash-button"
>
<Icon name="trash" color="gray-medium" size="18"/>
</Button>
)}
</div>
</div>
</Form>
);
};
export default observer(AlertForm);

View file

@ -0,0 +1,462 @@
import React, { useEffect } from 'react';
import { Form, Input, SegmentSelection, Checkbox, Icon } from 'UI';
import { alertConditions as conditions } from 'App/constants';
import { validateEmail } from 'App/validate';
import cn from 'classnames';
import { useStore } from 'App/mstore';
import { observer } from 'mobx-react-lite';
import Select from 'Shared/Select';
import { Button } from 'antd';
import DropdownChips from './DropdownChips';
import stl from './alertForm.module.css';
import { useTranslation } from 'react-i18next';
import { TFunction } from 'i18next';
const thresholdOptions = (t: TFunction) => [
{ label: t('15 minutes'), value: 15 },
{ label: t('30 minutes'), value: 30 },
{ label: t('1 hour'), value: 60 },
{ label: t('2 hours'), value: 120 },
{ label: t('4 hours'), value: 240 },
{ label: t('1 day'), value: 1440 },
];
const changeOptions = (t: TFunction) => [
{ label: t('change'), value: 'change' },
{ label: t('% change'), value: 'percent' },
];
function Circle({ text }: { text: string }) {
return (
<div className="circle mr-4 w-6 h-6 rounded-full bg-gray-light flex items-center justify-center">
{text}
</div>
);
}
function Section({
index,
title,
description,
content,
}: {
index: string;
title: string;
description?: string;
content: any;
}) {
return (
<div className="w-full">
<div className="flex items-start">
<Circle text={index} />
<div>
<span className="font-medium">{title}</span>
{description && (
<div className="text-sm color-gray-medium">{description}</div>
)}
</div>
</div>
<div className="ml-10">{content}</div>
</div>
);
}
function AlertForm(props) {
const { t } = useTranslation();
const {
slackChannels,
msTeamsChannels,
webhooks,
onDelete,
style = { height: "calc('100vh - 40px')" },
} = props;
const { alertsStore, metricStore } = useStore();
const { triggerOptions: allTriggerSeries, loading } = alertsStore;
const triggerOptions =
metricStore.instance.series.length > 0
? allTriggerSeries
.filter(
(s) =>
metricStore.instance.series.findIndex(
(ms) => ms.seriesId === s.value,
) !== -1,
)
.map((v) => {
const labelArr = v.label.split('.');
labelArr.shift();
return {
...v,
label: labelArr.join('.'),
};
})
: allTriggerSeries;
const { instance } = alertsStore;
const deleting = loading;
const write = ({ target: { value, name } }) =>
alertsStore.edit({ [name]: value });
const writeOption = (e, { name, value }) =>
alertsStore.edit({ [name]: value.value });
const onChangeCheck = ({ target: { checked, name } }) =>
alertsStore.edit({ [name]: checked });
useEffect(() => {
void alertsStore.fetchTriggerOptions();
}, []);
const writeQueryOption = (e, { name, value }) => {
const { query } = instance;
alertsStore.edit({ query: { ...query, [name]: value } });
};
const writeQuery = ({ target: { value, name } }) => {
const { query } = instance;
alertsStore.edit({ query: { ...query, [name]: value } });
};
const metric =
instance && instance.query.left
? triggerOptions.find((i) => i.value === instance.query.left)
: null;
const unit = metric ? metric.unit : '';
const isThreshold = instance.detectionMethod === 'threshold';
return (
<Form
className={cn('pb-10', stl.wrapper)}
style={style}
onSubmit={() => props.onSubmit(instance)}
id="alert-form"
>
<div className={cn('-mx-6 px-6 pb-12')}>
<input
autoFocus
className="text-lg border border-gray-light rounded w-full"
name="name"
style={{ fontSize: '18px', padding: '10px', fontWeight: '600' }}
value={instance && instance.name}
onChange={write}
placeholder={t('Untiltled Alert')}
id="name-field"
/>
<div className="mb-8" />
<Section
index="1"
title={t('What kind of alert do you want to set?')}
content={
<div>
<SegmentSelection
primary
name="detectionMethod"
className="my-3"
onSelect={(e, { name, value }) =>
alertsStore.edit({ [name]: value })
}
value={{ value: instance.detectionMethod }}
list={[
{ name: t('Threshold'), value: 'threshold' },
{ name: t('Change'), value: 'change' },
]}
/>
<div className="text-sm color-gray-medium">
{isThreshold &&
t(
'Eg. Alert me if memory.avg is greater than 500mb over the past 4 hours.',
)}
{!isThreshold &&
t(
'Eg. Alert me if % change of memory.avg is greater than 10% over the past 4 hours compared to the previous 4 hours.',
)}
</div>
<div className="my-4" />
</div>
}
/>
<hr className="my-8" />
<Section
index="2"
title={t('Condition')}
content={
<div>
{!isThreshold && (
<div className="flex items-center my-3">
<label className="w-2/6 flex-shrink-0 font-normal">
{t('Trigger when')}
</label>
<Select
className="w-4/6"
placeholder="change"
options={changeOptions(t)}
name="change"
defaultValue={instance.change}
onChange={({ value }) =>
writeOption(null, { name: 'change', value })
}
id="change-dropdown"
/>
</div>
)}
<div className="flex items-center my-3">
<label className="w-2/6 flex-shrink-0 font-normal">
{isThreshold ? t('Trigger when') : t('of')}
</label>
<Select
className="w-4/6"
placeholder={t('Select Metric')}
isSearchable
options={triggerOptions}
name="left"
value={triggerOptions.find(
(i) => i.value === instance.query.left,
)}
// onChange={ writeQueryOption }
onChange={({ value }) =>
writeQueryOption(null, { name: 'left', value: value.value })
}
/>
</div>
<div className="flex items-center my-3">
<label className="w-2/6 flex-shrink-0 font-normal">
{t('is')}
</label>
<div className="w-4/6 flex items-center">
<Select
placeholder={t('Select Condition')}
options={conditions}
name="operator"
defaultValue={instance.query.operator}
// onChange={ writeQueryOption }
onChange={({ value }) =>
writeQueryOption(null, {
name: 'operator',
value: value.value,
})
}
/>
{unit && (
<>
<Input
className="px-4"
style={{ marginRight: '31px' }}
// label={{ basic: true, content: unit }}
// labelPosition='right'
name="right"
value={instance.query.right}
onChange={writeQuery}
placeholder="E.g. 3"
/>
<span className="ml-2">{t('test')}</span>
</>
)}
{!unit && (
<Input
wrapperClassName="ml-2"
// className="pl-4"
name="right"
value={instance.query.right}
onChange={writeQuery}
placeholder="Specify value"
/>
)}
</div>
</div>
<div className="flex items-center my-3">
<label className="w-2/6 flex-shrink-0 font-normal">
{t('over the past')}
</label>
<Select
className="w-2/6"
placeholder={t('Select timeframe')}
options={thresholdOptions(t)}
name="currentPeriod"
defaultValue={instance.currentPeriod}
// onChange={ writeOption }
onChange={({ value }) =>
writeOption(null, { name: 'currentPeriod', value })
}
/>
</div>
{!isThreshold && (
<div className="flex items-center my-3">
<label className="w-2/6 flex-shrink-0 font-normal">
{t('compared to previous')}
</label>
<Select
className="w-2/6"
placeholder={t('Select timeframe')}
options={thresholdOptions(t)}
name="previousPeriod"
defaultValue={instance.previousPeriod}
// onChange={ writeOption }
onChange={({ value }) =>
writeOption(null, { name: 'previousPeriod', value })
}
/>
</div>
)}
</div>
}
/>
<hr className="my-8" />
<Section
index="3"
title={t('Notify Through')}
description={t(
"You'll be noticed in app notifications. Additionally opt in to receive alerts on:",
)}
content={
<div className="flex flex-col">
<div className="flex items-center my-4">
<Checkbox
name="slack"
className="mr-8"
type="checkbox"
checked={instance.slack}
onClick={onChangeCheck}
label={t('Slack')}
/>
<Checkbox
name="msteams"
className="mr-8"
type="checkbox"
checked={instance.msteams}
onClick={onChangeCheck}
label={t('MS Teams')}
/>
<Checkbox
name="email"
type="checkbox"
checked={instance.email}
onClick={onChangeCheck}
className="mr-8"
label={t('Email')}
/>
<Checkbox
name="webhook"
type="checkbox"
checked={instance.webhook}
onClick={onChangeCheck}
label="Webhook"
/>
</div>
{instance.slack && (
<div className="flex items-start my-4">
<label className="w-2/6 flex-shrink-0 font-normal pt-2">
{t('Slack')}
</label>
<div className="w-4/6">
<DropdownChips
fluid
selected={instance.slackInput}
options={slackChannels}
placeholder={t('Select Channel')}
onChange={(selected) =>
alertsStore.edit({ slackInput: selected })
}
/>
</div>
</div>
)}
{instance.msteams && (
<div className="flex items-start my-4">
<label className="w-2/6 flex-shrink-0 font-normal pt-2">
{t('MS Teams')}
</label>
<div className="w-4/6">
<DropdownChips
fluid
selected={instance.msteamsInput}
options={msTeamsChannels}
placeholder={t('Select Channel')}
onChange={(selected) =>
alertsStore.edit({ msteamsInput: selected })
}
/>
</div>
</div>
)}
{instance.email && (
<div className="flex items-start my-4">
<label className="w-2/6 flex-shrink-0 font-normal pt-2">
{t('Email')}
</label>
<div className="w-4/6">
<DropdownChips
textFiled
validate={validateEmail}
selected={instance.emailInput}
placeholder={t('Type and press Enter key')}
onChange={(selected) =>
alertsStore.edit({ emailInput: selected })
}
/>
</div>
</div>
)}
{instance.webhook && (
<div className="flex items-start my-4">
<label className="w-2/6 flex-shrink-0 font-normal pt-2">
{t('Webhook')}
</label>
<DropdownChips
fluid
selected={instance.webhookInput}
options={webhooks}
placeholder={t('Select Webhook')}
onChange={(selected) =>
alertsStore.edit({ webhookInput: selected })
}
/>
</div>
)}
</div>
}
/>
</div>
<div className="flex items-center justify-between absolute bottom-0 left-0 right-0 p-6 border-t z-10 bg-white">
<div className="flex items-center">
<Button
loading={loading}
type="primary"
htmlType="submit"
disabled={loading || !instance.validate()}
id="submit-button"
>
{instance.exists() ? t('Update') : t('Create')}
</Button>
<div className="mx-1" />
<Button onClick={props.onClose}>{t('Cancel')}</Button>
</div>
<div>
{instance.exists() && (
<Button
hover
primary="text"
loading={deleting}
type="button"
onClick={() => onDelete(instance)}
id="trash-button"
>
<Icon name="trash" color="gray-medium" size="18" />
</Button>
)}
</div>
</div>
</Form>
);
}
export default observer(AlertForm);

View file

@ -1,95 +1,92 @@
import React, {useEffect, useState} from 'react'; import React, { useEffect, useState } from 'react';
import {SlideModal} from 'UI'; import { SlideModal, confirm } from 'UI';
import {useStore} from 'App/mstore' import { useStore } from 'App/mstore';
import {observer} from 'mobx-react-lite' import { observer } from 'mobx-react-lite';
import { SLACK, TEAMS, WEBHOOK } from 'App/constants/schedule';
import AlertForm from '../AlertForm'; import AlertForm from '../AlertForm';
import {SLACK, TEAMS, WEBHOOK} from 'App/constants/schedule';
import {confirm} from 'UI';
interface Select { interface Select {
label: string; label: string;
value: string | number value: string | number;
} }
interface Props { interface Props {
showModal?: boolean; showModal?: boolean;
metricId?: number; metricId?: number;
onClose?: () => void; onClose?: () => void;
} }
function AlertFormModal(props: Props) { function AlertFormModal(props: Props) {
const {alertsStore, settingsStore} = useStore() const { alertsStore, settingsStore } = useStore();
const {metricId = null, showModal = false} = props; const { metricId = null, showModal = false } = props;
const [showForm, setShowForm] = useState(false); const [showForm, setShowForm] = useState(false);
const webhooks = settingsStore.webhooks const { webhooks } = settingsStore;
useEffect(() => { useEffect(() => {
settingsStore.fetchWebhooks(); settingsStore.fetchWebhooks();
}, []); }, []);
const slackChannels: Select[] = [];
const hooks: Select[] = [];
const msTeamsChannels: Select[] = [];
const slackChannels: Select[] = [] webhooks.forEach((hook) => {
const hooks: Select[] = [] const option = { value: hook.webhookId, label: hook.name };
const msTeamsChannels: Select[] = [] if (hook.type === SLACK) {
slackChannels.push(option);
}
if (hook.type === WEBHOOK) {
hooks.push(option);
}
if (hook.type === TEAMS) {
msTeamsChannels.push(option);
}
});
webhooks.forEach((hook) => { const saveAlert = (instance) => {
const option = {value: hook.webhookId, label: hook.name} const wasUpdating = instance.exists();
if (hook.type === SLACK) { alertsStore.save(instance).then(() => {
slackChannels.push(option) if (!wasUpdating) {
} toggleForm(null, false);
if (hook.type === WEBHOOK) { }
hooks.push(option) if (props.onClose) {
} props.onClose();
if (hook.type === TEAMS) { }
msTeamsChannels.push(option) });
} };
})
const saveAlert = (instance) => { const onDelete = async (instance) => {
const wasUpdating = instance.exists(); if (
alertsStore.save(instance).then(() => { await confirm({
if (!wasUpdating) { header: 'Confirm',
toggleForm(null, false); confirmButton: 'Yes, delete',
} confirmation: 'Are you sure you want to permanently delete this alert?',
if (props.onClose) { })
props.onClose(); ) {
} alertsStore.remove(instance.alertId).then(() => {
}); toggleForm(null, false);
}; });
}
};
const onDelete = async (instance) => { const toggleForm = (instance, state) => {
if ( if (instance) {
await confirm({ alertsStore.init(instance);
header: 'Confirm', }
confirmButton: 'Yes, delete', return setShowForm(state || !showForm);
confirmation: `Are you sure you want to permanently delete this alert?`, };
})
) {
alertsStore.remove(instance.alertId).then(() => {
toggleForm(null, false);
});
}
};
const toggleForm = (instance, state) => { return (
if (instance) { <AlertForm
alertsStore.init(instance); metricId={metricId}
} edit={alertsStore.edit}
return setShowForm(state ? state : !showForm); slackChannels={slackChannels}
}; msTeamsChannels={msTeamsChannels}
webhooks={hooks}
return ( onSubmit={saveAlert}
<AlertForm onClose={props.onClose}
metricId={metricId} onDelete={onDelete}
edit={alertsStore.edit} />
slackChannels={slackChannels} );
msTeamsChannels={msTeamsChannels}
webhooks={hooks}
onSubmit={saveAlert}
onClose={props.onClose}
onDelete={onDelete}
/>
);
} }
export default observer(AlertFormModal); export default observer(AlertFormModal);

View file

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

View file

@ -2,65 +2,76 @@ import React from 'react';
import { Input, TagBadge } from 'UI'; import { Input, TagBadge } from 'UI';
import Select from 'Shared/Select'; import Select from 'Shared/Select';
const DropdownChips = ({ function DropdownChips({
textFiled = false, textFiled = false,
validate = null, validate = null,
placeholder = '', placeholder = '',
selected = [], selected = [],
options = [], options = [],
badgeClassName = 'lowercase', badgeClassName = 'lowercase',
onChange = () => null, onChange = () => null,
...props ...props
}) => { }) {
const onRemove = (id) => { const onRemove = (id) => {
onChange(selected.filter((i) => i !== id)); onChange(selected.filter((i) => i !== id));
}; };
const onSelect = ({ value }) => { const onSelect = ({ value }) => {
const newSlected = selected.concat(value.value); const newSlected = selected.concat(value.value);
onChange(newSlected); onChange(newSlected);
}; };
const onKeyPress = (e) => { const onKeyPress = (e) => {
const val = e.target.value; const val = e.target.value;
if (e.key !== 'Enter' || selected.includes(val)) return; if (e.key !== 'Enter' || selected.includes(val)) return;
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
if (validate && !validate(val)) return; if (validate && !validate(val)) return;
const newSlected = selected.concat(val); const newSlected = selected.concat(val);
e.target.value = ''; e.target.value = '';
onChange(newSlected); onChange(newSlected);
}; };
const _options = options.filter((item) => !selected.includes(item.value)); const _options = options.filter((item) => !selected.includes(item.value));
const renderBadge = (item) => {
const val = typeof item === 'string' ? item : item.value;
const text = typeof item === 'string' ? item : item.label;
return <TagBadge className={badgeClassName} key={text} text={text} hashed={false} onRemove={() => onRemove(val)} outline={true} />;
};
const renderBadge = (item) => {
const val = typeof item === 'string' ? item : item.value;
const text = typeof item === 'string' ? item : item.label;
return ( return (
<div className="w-full"> <TagBadge
{textFiled ? ( className={badgeClassName}
<Input type="text" onKeyPress={onKeyPress} placeholder={placeholder} /> key={text}
) : ( text={text}
<Select hashed={false}
placeholder={placeholder} onRemove={() => onRemove(val)}
isSearchable={true} outline
options={_options} />
name="webhookInput"
value={null}
onChange={onSelect}
{...props}
/>
)}
<div className="flex flex-wrap mt-3">
{textFiled ? selected.map(renderBadge) : options.filter((i) => selected.includes(i.value)).map(renderBadge)}
</div>
</div>
); );
}; };
return (
<div className="w-full">
{textFiled ? (
<Input type="text" onKeyPress={onKeyPress} placeholder={placeholder} />
) : (
<Select
placeholder={placeholder}
isSearchable
options={_options}
name="webhookInput"
value={null}
onChange={onSelect}
{...props}
/>
)}
<div className="flex flex-wrap mt-3">
{textFiled
? selected.map(renderBadge)
: options.filter((i) => selected.includes(i.value)).map(renderBadge)}
</div>
</div>
);
}
export default DropdownChips; export default DropdownChips;

View file

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

View file

@ -7,7 +7,6 @@ import { observer } from 'mobx-react-lite';
import { Badge, Button, Tooltip } from 'antd'; import { Badge, Button, Tooltip } from 'antd';
import { BellOutlined } from '@ant-design/icons'; import { BellOutlined } from '@ant-design/icons';
const AUTOREFRESH_INTERVAL = 5 * 60 * 1000; const AUTOREFRESH_INTERVAL = 5 * 60 * 1000;
function Notifications() { function Notifications() {
@ -28,16 +27,17 @@ function Notifications() {
}, []); }, []);
return ( return (
<Badge dot={count > 0} size='small'> <Badge dot={count > 0} size="small">
<Tooltip title='Alerts'> <Tooltip title="Alerts">
<Button <Button
icon={<BellOutlined />} icon={<BellOutlined />}
onClick={() => showModal(<AlertTriggersModal />, { right: true })}> onClick={() => showModal(<AlertTriggersModal />, { right: true })}
{/*<Icon name='bell' size='18' color='gray-dark' />*/} >
{/* <Icon name='bell' size='18' color='gray-dark' /> */}
</Button> </Button>
</Tooltip> </Tooltip>
</Badge> </Badge>
); );
} }
export default observer(Notifications); export default observer(Notifications);

View file

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

View file

@ -4,12 +4,14 @@ import withPermissions from 'HOCs/withPermissions';
import AssistRouter from './AssistRouter'; import AssistRouter from './AssistRouter';
function Assist() { function Assist() {
return ( return <AssistRouter />;
<AssistRouter />
);
} }
export default withPageTitle('Assist - OpenReplay')( export default withPageTitle('Assist - OpenReplay')(
withPermissions(['ASSIST_LIVE', 'SERVICE_ASSIST_LIVE'], '', false, false)(Assist) withPermissions(
['ASSIST_LIVE', 'SERVICE_ASSIST_LIVE'],
'',
false,
false,
)(Assist),
); );

View file

@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import AssistView from './AssistView' import AssistView from './AssistView';
function AssistRouter() { function AssistRouter() {
return ( return (

View file

@ -3,15 +3,17 @@ import { Button, Tooltip } from 'antd';
import { useModal } from 'App/components/Modal'; import { useModal } from 'App/components/Modal';
import { MODULES } from 'Components/Client/Modules'; import { MODULES } from 'Components/Client/Modules';
import AssistStats from '../../AssistStats';
import Recordings from '../RecordingsList/Recordings';
import { useStore } from 'App/mstore'; import { useStore } from 'App/mstore';
import { observer } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite';
import AssistStats from '../../AssistStats';
import Recordings from '../RecordingsList/Recordings';
import { useTranslation } from 'react-i18next';
function AssistSearchActions() { function AssistSearchActions() {
const { t } = useTranslation();
const { searchStoreLive, userStore } = useStore(); const { searchStoreLive, userStore } = useStore();
const modules = userStore.account.settings?.modules ?? []; const modules = userStore.account.settings?.modules ?? [];
const isEnterprise = userStore.isEnterprise const { isEnterprise } = userStore;
const hasEvents = const hasEvents =
searchStoreLive.instance.filters.filter((i: any) => i.isEvent).length > 0; searchStoreLive.instance.filters.filter((i: any) => i.isEvent).length > 0;
const hasFilters = const hasFilters =
@ -30,7 +32,7 @@ function AssistSearchActions() {
return ( return (
<div className="flex items-center w-full gap-2"> <div className="flex items-center w-full gap-2">
<h3 className="text-2xl capitalize mr-2"> <h3 className="text-2xl capitalize mr-2">
<span>Co-Browse</span> <span>{t('Co-Browse')}</span>
</h3> </h3>
<Tooltip title='Clear Search Filters'> <Tooltip title='Clear Search Filters'>
<Button <Button
@ -39,7 +41,7 @@ function AssistSearchActions() {
onClick={() => searchStoreLive.clearSearch()} onClick={() => searchStoreLive.clearSearch()}
className="px-2 ml-auto" className="px-2 ml-auto"
> >
Clear {t('Clear')}
</Button> </Button>
</Tooltip> </Tooltip>
{!isSaas && isEnterprise && !modules.includes(MODULES.OFFLINE_RECORDINGS) {!isSaas && isEnterprise && !modules.includes(MODULES.OFFLINE_RECORDINGS)
@ -48,7 +50,8 @@ function AssistSearchActions() {
{isEnterprise && userStore.account?.admin && ( {isEnterprise && userStore.account?.admin && (
<Button size={'small'} onClick={showStats} <Button size={'small'} onClick={showStats}
disabled={modules.includes(MODULES.ASSIST_STATS) || modules.includes(MODULES.ASSIST)}> disabled={modules.includes(MODULES.ASSIST_STATS) || modules.includes(MODULES.ASSIST)}>
Co-Browsing Reports</Button> {t('Co-Browsing Reports')}
</Button>
)} )}
</div> </div>
); );

View file

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

View file

@ -1,19 +1,19 @@
import React from 'react'; import React from 'react';
import LiveSessionList from 'Shared/LiveSessionList'; import LiveSessionList from 'Shared/LiveSessionList';
import LiveSessionSearch from 'Shared/LiveSessionSearch'; import LiveSessionSearch from 'Shared/LiveSessionSearch';
import AssistSearchActions from './AssistSearchActions';
import usePageTitle from '@/hooks/usePageTitle'; import usePageTitle from '@/hooks/usePageTitle';
import AssistSearchActions from './AssistSearchActions';
function AssistView() { function AssistView() {
usePageTitle('Co-Browse - OpenReplay'); usePageTitle('Co-Browse - OpenReplay');
return ( return (
<div className="w-full mx-auto" style={{ maxWidth: '1360px'}}> <div className="w-full mx-auto" style={{ maxWidth: '1360px' }}>
<AssistSearchActions /> <AssistSearchActions />
<LiveSessionSearch /> <LiveSessionSearch />
<div className="my-4" /> <div className="my-4" />
<LiveSessionList /> <LiveSessionList />
</div> </div>
) );
} }
export default AssistView; export default AssistView;

View file

@ -1,50 +1,91 @@
import React, { useState } from 'react' import React, { useState } from 'react';
import stl from './ChatControls.module.css' import cn from 'classnames';
import cn from 'classnames' import { Icon } from 'UI';
import { Icon } from 'UI' import { Button } from 'antd';
import { Button } from 'antd'
import type { LocalStream } from 'Player'; import type { LocalStream } from 'Player';
import stl from './ChatControls.module.css';
interface Props { interface Props {
stream: LocalStream | null, stream: LocalStream | null;
endCall: () => void, endCall: () => void;
videoEnabled: boolean, videoEnabled: boolean;
isPrestart?: boolean, isPrestart?: boolean;
setVideoEnabled: (isEnabled: boolean) => void setVideoEnabled: (isEnabled: boolean) => void;
} }
function ChatControls({ stream, endCall, videoEnabled, setVideoEnabled, isPrestart } : Props) { function ChatControls({
const [audioEnabled, setAudioEnabled] = useState(true) stream,
endCall,
videoEnabled,
setVideoEnabled,
isPrestart,
}: Props) {
const [audioEnabled, setAudioEnabled] = useState(true);
const toggleAudio = () => { const toggleAudio = () => {
if (!stream) { return; } if (!stream) {
return;
}
setAudioEnabled(stream.toggleAudio()); setAudioEnabled(stream.toggleAudio());
} };
const toggleVideo = () => { const toggleVideo = () => {
if (!stream) { return; } if (!stream) {
stream.toggleVideo() return;
.then((v) => setVideoEnabled(v)) }
} stream.toggleVideo().then((v) => setVideoEnabled(v));
};
/** muting user if he is auto connected to the call */ /** muting user if he is auto connected to the call */
React.useEffect(() => { React.useEffect(() => {
if (isPrestart) { if (isPrestart) {
audioEnabled && toggleAudio(); audioEnabled && toggleAudio();
} }
}, []) }, []);
return ( return (
<div className={cn(stl.controls, "flex items-center w-full justify-start bottom-0 px-2")}> <div
className={cn(
stl.controls,
'flex items-center w-full justify-start bottom-0 px-2',
)}
>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className={cn(stl.btnWrapper, { [stl.disabled]: audioEnabled})}> <div className={cn(stl.btnWrapper, { [stl.disabled]: audioEnabled })}>
<Button size={'small'} variant="text" onClick={toggleAudio} icon={<Icon name={audioEnabled ? 'mic' : 'mic-mute'} size="16" />}> <Button
<span className={cn("ml-1 color-gray-medium text-sm", { 'color-red' : audioEnabled })}>{audioEnabled ? 'Mute' : 'Unmute'}</span> size="small"
variant="text"
onClick={toggleAudio}
icon={<Icon name={audioEnabled ? 'mic' : 'mic-mute'} size="16" />}
>
<span
className={cn('ml-1 color-gray-medium text-sm', {
'color-red': audioEnabled,
})}
>
{audioEnabled ? 'Mute' : 'Unmute'}
</span>
</Button> </Button>
</div> </div>
<div className={cn(stl.btnWrapper, { [stl.disabled]: videoEnabled})}> <div className={cn(stl.btnWrapper, { [stl.disabled]: videoEnabled })}>
<Button size={'small'} variant="text" onClick={toggleVideo} icon={<Icon name={ videoEnabled ? 'camera-video' : 'camera-video-off' } size="16" />}> <Button
<span className={cn("ml-1 color-gray-medium text-sm", { 'color-red' : videoEnabled })}>{videoEnabled ? 'Stop Video' : 'Start Video'}</span> size="small"
variant="text"
onClick={toggleVideo}
icon={
<Icon
name={videoEnabled ? 'camera-video' : 'camera-video-off'}
size="16"
/>
}
>
<span
className={cn('ml-1 color-gray-medium text-sm', {
'color-red': videoEnabled,
})}
>
{videoEnabled ? 'Stop Video' : 'Start Video'}
</span>
</Button> </Button>
</div> </div>
</div> </div>
@ -54,7 +95,7 @@ function ChatControls({ stream, endCall, videoEnabled, setVideoEnabled, isPresta
</button> </button>
</div> </div>
</div> </div>
) );
} }
export default ChatControls export default ChatControls;

View file

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

View file

@ -1,25 +1,33 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import VideoContainer from '../components/VideoContainer';
import cn from 'classnames'; import cn from 'classnames';
import Counter from 'App/components/shared/SessionItem/Counter'; import Counter from 'App/components/shared/SessionItem/Counter';
import stl from './chatWindow.module.css';
import ChatControls from '../ChatControls/ChatControls';
import Draggable from 'react-draggable'; import Draggable from 'react-draggable';
import type { LocalStream } from 'Player'; import type { LocalStream } from 'Player';
import { PlayerContext } from 'App/components/Session/playerContext'; import { PlayerContext } from 'App/components/Session/playerContext';
import ChatControls from '../ChatControls/ChatControls';
import stl from './chatWindow.module.css';
import VideoContainer from '../components/VideoContainer';
import { useTranslation } from 'react-i18next';
export interface Props { export interface Props {
incomeStream: { stream: MediaStream, isAgent: boolean }[] | null; incomeStream: { stream: MediaStream; isAgent: boolean }[] | null;
localStream: LocalStream | null; localStream: LocalStream | null;
userId: string; userId: string;
isPrestart?: boolean; isPrestart?: boolean;
endCall: () => void; endCall: () => void;
} }
function ChatWindow({ userId, incomeStream, localStream, endCall, isPrestart }: Props) { function ChatWindow({
const { player } = React.useContext(PlayerContext) userId,
incomeStream,
localStream,
endCall,
isPrestart,
}: Props) {
const { t } = useTranslation();
const { player } = React.useContext(PlayerContext);
const toggleVideoLocalStream = player.assistManager.toggleVideoLocalStream; const { toggleVideoLocalStream } = player.assistManager;
const [localVideoEnabled, setLocalVideoEnabled] = useState(false); const [localVideoEnabled, setLocalVideoEnabled] = useState(false);
const [anyRemoteEnabled, setRemoteEnabled] = useState(false); const [anyRemoteEnabled, setRemoteEnabled] = useState(false);
@ -27,22 +35,31 @@ function ChatWindow({ userId, incomeStream, localStream, endCall, isPrestart }:
const onlyLocalEnabled = localVideoEnabled && !anyRemoteEnabled; const onlyLocalEnabled = localVideoEnabled && !anyRemoteEnabled;
useEffect(() => { useEffect(() => {
toggleVideoLocalStream(localVideoEnabled) toggleVideoLocalStream(localVideoEnabled);
}, [localVideoEnabled]) }, [localVideoEnabled]);
return ( return (
<Draggable handle=".handle" bounds="body" defaultPosition={{ x: 50, y: 200 }}> <Draggable
handle=".handle"
bounds="body"
defaultPosition={{ x: 50, y: 200 }}
>
<div <div
className={cn(stl.wrapper, 'fixed radius bg-white shadow-xl mt-16')} className={cn(stl.wrapper, 'fixed radius bg-white shadow-xl mt-16')}
style={{ width: '280px' }} style={{ width: '280px' }}
> >
<div className="handle flex items-center p-2 cursor-move select-none border-b"> <div className="handle flex items-center p-2 cursor-move select-none border-b">
<div className={stl.headerTitle}> <div className={stl.headerTitle}>
<b>Call with </b> {userId ? userId : 'Anonymous User'} <b>{t('Call with')}&nbsp;</b> {userId || t('Anonymous User')}
<br /> <br />
{incomeStream && incomeStream.length > 2 ? ' (+ other agents in the call)' : ''} {incomeStream && incomeStream.length > 2
? t(' (+ other agents in the call)')
: ''}
</div> </div>
<Counter startTime={new Date().getTime()} className="text-sm ml-auto" /> <Counter
startTime={new Date().getTime()}
className="text-sm ml-auto"
/>
</div> </div>
<div <div
className={cn(stl.videoWrapper, 'relative')} className={cn(stl.videoWrapper, 'relative')}
@ -51,13 +68,24 @@ function ChatWindow({ userId, incomeStream, localStream, endCall, isPrestart }:
{incomeStream ? ( {incomeStream ? (
incomeStream.map((stream) => ( incomeStream.map((stream) => (
<React.Fragment key={stream.stream.id}> <React.Fragment key={stream.stream.id}>
<VideoContainer stream={stream.stream} setRemoteEnabled={setRemoteEnabled} isAgent={stream.isAgent} /> <VideoContainer
stream={stream.stream}
setRemoteEnabled={setRemoteEnabled}
isAgent={stream.isAgent}
/>
</React.Fragment> </React.Fragment>
)) ))
) : ( ) : (
<div className={stl.noVideo}>Error obtaining incoming streams</div> <div className={stl.noVideo}>
{t('Error obtaining incoming streams')}
</div>
)} )}
<div className={cn('absolute bottom-0 right-0 z-50', localVideoEnabled ? '' : '!hidden')}> <div
className={cn(
'absolute bottom-0 right-0 z-50',
localVideoEnabled ? '' : '!hidden',
)}
>
<VideoContainer <VideoContainer
stream={localStream ? localStream.stream : null} stream={localStream ? localStream.stream : null}
muted muted

View file

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

View file

@ -1,74 +1,70 @@
import { useObserver } from 'mobx-react-lite'; import { useObserver } from 'mobx-react-lite';
import React from 'react'; import React from 'react';
import { Modal, Form, Icon, Input } from 'UI'; import { Modal, Form, Icon, Input } from 'UI';
import { Button } from 'antd' import { Button } from 'antd';
import { useTranslation } from 'react-i18next';
interface Props { interface Props {
show: boolean; show: boolean;
title: string; title: string;
closeHandler?: () => void; closeHandler?: () => void;
onSave: (title: string) => void; onSave: (title: string) => void;
} }
function EditRecordingModal(props: Props) { function EditRecordingModal(props: Props) {
const { show, closeHandler, title, onSave } = props; const { t } = useTranslation();
const [text, setText] = React.useState(title) const { show, closeHandler, title, onSave } = props;
const [text, setText] = React.useState(title);
React.useEffect(() => { React.useEffect(() => {
const handleEsc = (e: any) => e.key === 'Escape' && closeHandler?.() const handleEsc = (e: any) => e.key === 'Escape' && closeHandler?.();
document.addEventListener("keydown", handleEsc, false); document.addEventListener('keydown', handleEsc, false);
return () => { return () => {
document.removeEventListener("keydown", handleEsc, false); document.removeEventListener('keydown', handleEsc, false);
} };
}, []) }, []);
const write = ({ target: { value, name } }: any) => setText(value) const write = ({ target: { value, name } }: any) => setText(value);
const save = () => { const save = () => {
onSave(text) onSave(text);
} };
return useObserver(() => ( return useObserver(() => (
<Modal open={ show } onClose={closeHandler}> <Modal open={show} onClose={closeHandler}>
<Modal.Header className="flex items-center justify-between"> <Modal.Header className="flex items-center justify-between">
<div>{ 'Rename' }</div> <div>{t('Rename')}</div>
<div onClick={ closeHandler }> <div onClick={closeHandler}>
<Icon <Icon color="gray-dark" size="14" name="close" />
color="gray-dark" </div>
size="14" </Modal.Header>
name="close"
/>
</div>
</Modal.Header>
<Modal.Content> <Modal.Content>
<Form onSubmit={save}> <Form onSubmit={save}>
<Form.Field> <Form.Field>
<label>{'Title:'}</label> <label>{t('Title:')}</label>
<Input <Input
className="" className=""
name="name" name="name"
value={ text } value={text}
onChange={write} onChange={write}
placeholder="Title" placeholder="Title"
maxLength={100} maxLength={100}
autoFocus autoFocus
/> />
</Form.Field> </Form.Field>
</Form> </Form>
</Modal.Content> </Modal.Content>
<Modal.Footer> <Modal.Footer>
<div className="-mx-2 px-2"> <div className="-mx-2 px-2">
<Button <Button type="primary" onClick={save} className="float-left mr-2">
type="primary" {t('Save')}
onClick={ save } </Button>
className="float-left mr-2" <Button className="mr-2" onClick={closeHandler}>
> {t('Cancel')}
Save </Button>
</Button> </div>
<Button className="mr-2" onClick={ closeHandler }>{ 'Cancel' }</Button> </Modal.Footer>
</div> </Modal>
</Modal.Footer> ));
</Modal>
));
} }
export default EditRecordingModal; export default EditRecordingModal;

View file

@ -1,19 +1,21 @@
import React from 'react'; import React from 'react';
import { PageTitle } from 'UI'; import { PageTitle } from 'UI';
import Select from 'Shared/Select'; import Select from 'Shared/Select';
import RecordingsSearch from './RecordingsSearch';
import RecordingsList from './RecordingsList';
import { useStore } from 'App/mstore'; import { useStore } from 'App/mstore';
import SelectDateRange from 'Shared/SelectDateRange/SelectDateRange'; import SelectDateRange from 'Shared/SelectDateRange/SelectDateRange';
import { observer } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite';
import RecordingsList from './RecordingsList';
import RecordingsSearch from './RecordingsSearch';
import { useTranslation } from 'react-i18next';
function Recordings() { function Recordings() {
const { t } = useTranslation();
const { recordingsStore, userStore } = useStore(); const { recordingsStore, userStore } = useStore();
const userId = userStore.account.id; const userId = userStore.account.id;
const recordingsOwner = [ const recordingsOwner = [
{ value: '0', label: 'All Videos' }, { value: '0', label: t('All Videos') },
{ value: userId, label: 'My Videos' } { value: userId, label: t('My Videos') },
]; ];
const onDateChange = (e: any) => { const onDateChange = (e: any) => {
@ -21,22 +23,29 @@ function Recordings() {
}; };
return ( return (
<div style={{ maxWidth: '1360px', margin: 'auto' }} className='bg-white rounded-lg py-4 border h-screen overflow-y-scroll'> <div
<div className='flex items-center mb-4 justify-between px-6'> style={{ maxWidth: '1360px', margin: 'auto' }}
<div className='flex items-baseline mr-3'> className="bg-white rounded-lg py-4 border h-screen overflow-y-scroll"
<PageTitle title='Training Videos' /> >
<div className="flex items-center mb-4 justify-between px-6">
<div className="flex items-baseline mr-3">
<PageTitle title={t('Training Videos')} />
</div> </div>
<div className='ml-auto flex items-center gap-4'> <div className="ml-auto flex items-center gap-4">
<SelectDateRange period={recordingsStore.period} onChange={onDateChange} right={true} /> <SelectDateRange
period={recordingsStore.period}
onChange={onDateChange}
right
/>
<Select <Select
name='recsOwner' name="recsOwner"
plain plain
right right
options={recordingsOwner} options={recordingsOwner}
onChange={({ value }) => recordingsStore.setUserId(value.value)} onChange={({ value }) => recordingsStore.setUserId(value.value)}
defaultValue={recordingsOwner[0].value} defaultValue={recordingsOwner[0].value}
/> />
<div className='w-1/4' style={{ minWidth: 300 }}> <div className="w-1/4" style={{ minWidth: 300 }}>
<RecordingsSearch /> <RecordingsSearch />
</div> </div>
</div> </div>

View file

@ -2,23 +2,25 @@ import { observer } from 'mobx-react-lite';
import React from 'react'; import React from 'react';
import { NoContent, Pagination, Loader } from 'UI'; import { NoContent, Pagination, Loader } from 'UI';
import { useStore } from 'App/mstore'; import { useStore } from 'App/mstore';
import RecordsListItem from './RecordsListItem';
import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG'; import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG';
import RecordsListItem from './RecordsListItem';
import { useTranslation } from 'react-i18next';
function RecordingsList() { function RecordingsList() {
const { t } = useTranslation();
const { recordingsStore } = useStore(); const { recordingsStore } = useStore();
// const [shownRecordings, setRecordings] = React.useState<any[]>([]); // const [shownRecordings, setRecordings] = React.useState<any[]>([]);
const recordings = recordingsStore.recordings; const { recordings } = recordingsStore;
const recordsSearch = recordingsStore.search; const recordsSearch = recordingsStore.search;
const page = recordingsStore.page; const { page } = recordingsStore;
const pageSize = recordingsStore.pageSize; const { pageSize } = recordingsStore;
const total = recordingsStore.total; const { total } = recordingsStore;
React.useEffect(() => { React.useEffect(() => {
recordingsStore.fetchRecordings(); recordingsStore.fetchRecordings();
}, [page, recordingsStore.period, recordsSearch, recordingsStore.userId]); }, [page, recordingsStore.period, recordsSearch, recordingsStore.userId]);
const length = recordings.length; const { length } = recordings;
return ( return (
<NoContent <NoContent
@ -28,15 +30,17 @@ function RecordingsList() {
<AnimatedSVG name={ICONS.NO_RECORDINGS} size={60} /> <AnimatedSVG name={ICONS.NO_RECORDINGS} size={60} />
<div className="text-center mt-4"> <div className="text-center mt-4">
{recordsSearch !== '' {recordsSearch !== ''
? 'No matching results' ? t('No matching results')
: 'No videos have been recorded in your co-browsing sessions.'} : t('No videos have been recorded in your co-browsing sessions.')}
</div> </div>
</div> </div>
} }
subtext={ subtext={
<div className="text-center flex justify-center items-center flex-col"> <div className="text-center flex justify-center items-center flex-col">
<span> <span>
Capture and share video recordings of co-browsing sessions with your team for product feedback and training. {t(
'Capture and share video recordings of co-browsing sessions with your team for product feedback and training.',
)}
</span> </span>
</div> </div>
} }
@ -44,8 +48,8 @@ function RecordingsList() {
<div className="mt-3 border-b"> <div className="mt-3 border-b">
<Loader loading={recordingsStore.loading}> <Loader loading={recordingsStore.loading}>
<div className="grid grid-cols-12 py-2 font-medium px-6"> <div className="grid grid-cols-12 py-2 font-medium px-6">
<div className="col-span-8">Name</div> <div className="col-span-8">{t('Name')}</div>
<div className="col-span-4">Recorded by</div> <div className="col-span-4">{t('Recorded by')}</div>
</div> </div>
{recordings.map((record: any) => ( {recordings.map((record: any) => (
@ -58,8 +62,10 @@ function RecordingsList() {
<div className="w-full flex items-center justify-between pt-4 px-6"> <div className="w-full flex items-center justify-between pt-4 px-6">
<div className="text-disabled-text"> <div className="text-disabled-text">
Showing <span className="font-semibold">{Math.min(length, pageSize)}</span> out of{' '} {t('Showing')}{' '}
<span className="font-semibold">{total}</span> Recording <span className="font-semibold">{Math.min(length, pageSize)}</span>{' '}
{t('out of')}&nbsp;<span className="font-semibold">{total}</span>
&nbsp;{t('Recording')}
</div> </div>
<Pagination <Pagination
page={page} page={page}

View file

@ -4,33 +4,40 @@ import { useStore } from 'App/mstore';
import { Icon } from 'UI'; import { Icon } from 'UI';
import { debounce } from 'App/utils'; import { debounce } from 'App/utils';
let debounceUpdate: any = () => {} let debounceUpdate: any = () => {};
function RecordingsSearch() { function RecordingsSearch() {
const { recordingsStore } = useStore(); const { recordingsStore } = useStore();
const [query, setQuery] = useState(recordingsStore.search); const [query, setQuery] = useState(recordingsStore.search);
useEffect(() => { useEffect(() => {
debounceUpdate = debounce((value: any) => recordingsStore.updateSearch(value), 500); debounceUpdate = debounce(
}, []) (value: any) => recordingsStore.updateSearch(value),
500,
// @ts-ignore
const write = ({ target: { value } }) => {
setQuery(value);
debounceUpdate(value);
}
return (
<div className="relative">
<Icon name="search" className="absolute top-0 bottom-0 ml-2 m-auto" size="16" />
<input
value={query}
name="recordsSearch"
className="bg-white p-2 border border-borderColor-gray-light-shade rounded w-full pl-10"
placeholder="Filter by title or description"
onChange={write}
/>
</div>
); );
}, []);
// @ts-ignore
const write = ({ target: { value } }) => {
setQuery(value);
debounceUpdate(value);
};
return (
<div className="relative">
<Icon
name="search"
className="absolute top-0 bottom-0 ml-2 m-auto"
size="16"
/>
<input
value={query}
name="recordsSearch"
className="bg-white p-2 border border-borderColor-gray-light-shade rounded w-full pl-10"
placeholder="Filter by title or description"
onChange={write}
/>
</div>
);
} }
export default observer(RecordingsSearch); export default observer(RecordingsSearch);

View file

@ -6,12 +6,14 @@ import { useStore } from 'App/mstore';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import cn from 'classnames'; import cn from 'classnames';
import EditRecordingModal from './EditRecordingModal'; import EditRecordingModal from './EditRecordingModal';
import { useTranslation } from 'react-i18next';
interface Props { interface Props {
record: IRecord; record: IRecord;
} }
function RecordsListItem(props: Props) { function RecordsListItem(props: Props) {
const { t } = useTranslation();
const { record } = props; const { record } = props;
const { recordingsStore, settingsStore } = useStore(); const { recordingsStore, settingsStore } = useStore();
const { timezone } = settingsStore.sessionSettings; const { timezone } = settingsStore.sessionSettings;
@ -34,17 +36,19 @@ function RecordsListItem(props: Props) {
const onDelete = () => { const onDelete = () => {
recordingsStore.deleteRecording(record.recordId).then(() => { recordingsStore.deleteRecording(record.recordId).then(() => {
recordingsStore.setRecordings( recordingsStore.setRecordings(
recordingsStore.recordings.filter((rec) => rec.recordId !== record.recordId) recordingsStore.recordings.filter(
(rec) => rec.recordId !== record.recordId,
),
); );
toast.success('Recording deleted'); toast.success(t('Recording deleted'));
}); });
}; };
const menuItems = [ const menuItems = [
{ icon: 'pencil', text: 'Rename', onClick: () => setEdit(true) }, { icon: 'pencil', text: t('Rename'), onClick: () => setEdit(true) },
{ {
icon: 'trash', icon: 'trash',
text: 'Delete', text: t('Delete'),
onClick: onDelete, onClick: onDelete,
}, },
]; ];
@ -54,9 +58,9 @@ function RecordsListItem(props: Props) {
.updateRecordingName(record.recordId, title) .updateRecordingName(record.recordId, title)
.then(() => { .then(() => {
setRecordingTitle(title); setRecordingTitle(title);
toast.success('Recording name updated'); toast.success(t('Recording name updated'));
}) })
.catch(() => toast.error("Couldn't update recording name")); .catch(() => toast.error(t("Couldn't update recording name")));
setEdit(false); setEdit(false);
}; };
@ -78,7 +82,9 @@ function RecordsListItem(props: Props) {
</div> </div>
<div className="flex flex-col"> <div className="flex flex-col">
<div className={cn('pt-1 w-fit -mt-2')}>{recordingTitle}</div> <div className={cn('pt-1 w-fit -mt-2')}>{recordingTitle}</div>
<div className="text-gray-medium text-sm">{durationFromMs(record.duration)}</div> <div className="text-gray-medium text-sm">
{durationFromMs(record.duration)}
</div>
</div> </div>
</div> </div>
</div> </div>
@ -95,14 +101,19 @@ function RecordsListItem(props: Props) {
className="group flex items-center gap-1 cursor-pointer link" className="group flex items-center gap-1 cursor-pointer link"
onClick={onRecordClick} onClick={onRecordClick}
> >
<Icon name="play" size={18} color="teal" className="!block group-hover:!hidden" /> <Icon
name="play"
size={18}
color="teal"
className="!block group-hover:!hidden"
/>
<Icon <Icon
name="play-fill-new" name="play-fill-new"
size={18} size={18}
color="teal" color="teal"
className="!hidden group-hover:!block" className="!hidden group-hover:!block"
/> />
<div>Play Video</div> <div>{t('Play Video')}</div>
</div> </div>
<div className="hover:border-teal border border-transparent rounded-full"> <div className="hover:border-teal border border-transparent rounded-full">
<ItemMenu bold items={menuItems} sm /> <ItemMenu bold items={menuItems} sm />

View file

@ -1,10 +1,12 @@
import React from 'react'; import React from 'react';
import { INDEXES } from 'App/constants/zindex'; import { INDEXES } from 'App/constants/zindex';
import { Loader, Icon } from 'UI'; import { Loader, Icon } from 'UI';
import { Button } from 'antd' import { Button } from 'antd';
import { PlayerContext } from 'App/components/Session/playerContext'; import { PlayerContext } from 'App/components/Session/playerContext';
import { useStore } from "App/mstore"; import { useStore } from 'App/mstore';
import { observer } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite';
import { useTranslation } from 'react-i18next';
import { TFunction } from 'i18next';
interface Props { interface Props {
userDisplayName: string; userDisplayName: string;
@ -23,62 +25,70 @@ enum Actions {
RecordingEnd, RecordingEnd,
} }
const WIN_VARIANTS = { const WIN_VARIANTS = (t: TFunction) => ({
[WindowType.Call]: { [WindowType.Call]: {
text: 'to accept the call', text: t('to accept the call'),
icon: 'call' as const, icon: 'call' as const,
action: Actions.CallEnd, action: Actions.CallEnd,
iconColor: 'teal', iconColor: 'teal',
}, },
[WindowType.Control]: { [WindowType.Control]: {
text: 'to accept remote control request', text: t('to accept remote control request'),
icon: 'remote-control' as const, icon: 'remote-control' as const,
action: Actions.ControlEnd, action: Actions.ControlEnd,
iconColor: 'teal', iconColor: 'teal',
}, },
[WindowType.Record]: { [WindowType.Record]: {
text: 'to accept recording request', text: t('to accept recording request'),
icon: 'record-circle' as const, icon: 'record-circle' as const,
iconColor: 'red', iconColor: 'red',
action: Actions.RecordingEnd, action: Actions.RecordingEnd,
} },
}; });
function RequestingWindow({ getWindowType }: Props) { function RequestingWindow({ getWindowType }: Props) {
const { t } = useTranslation();
const { sessionStore } = useStore(); const { sessionStore } = useStore();
const userDisplayName = sessionStore.current.userDisplayName; const { userDisplayName } = sessionStore.current;
const windowType = getWindowType() const windowType = getWindowType();
if (!windowType) return; if (!windowType) return;
const { player } = React.useContext(PlayerContext) const { player } = React.useContext(PlayerContext);
const { const {
assistManager: { assistManager: { initiateCallEnd, releaseRemoteControl, stopRecording },
initiateCallEnd, } = player;
releaseRemoteControl,
stopRecording,
}
} = player
const actions = { const actions = {
[Actions.CallEnd]: initiateCallEnd, [Actions.CallEnd]: initiateCallEnd,
[Actions.ControlEnd]: releaseRemoteControl, [Actions.ControlEnd]: releaseRemoteControl,
[Actions.RecordingEnd]: stopRecording, [Actions.RecordingEnd]: stopRecording,
} };
return ( return (
<div <div
className="w-full h-full absolute top-0 left-0 flex items-center justify-center" className="w-full h-full absolute top-0 left-0 flex items-center justify-center"
style={{ background: 'rgba(0,0,0, 0.30)', zIndex: INDEXES.PLAYER_REQUEST_WINDOW }} style={{
background: 'rgba(0,0,0, 0.30)',
zIndex: INDEXES.PLAYER_REQUEST_WINDOW,
}}
> >
<div className="rounded bg-white pt-4 pb-2 px-8 flex flex-col text-lg items-center max-w-lg text-center"> <div className="rounded bg-white pt-4 pb-2 px-8 flex flex-col text-lg items-center max-w-lg text-center">
<Icon size={40} color={WIN_VARIANTS[windowType].iconColor} name={WIN_VARIANTS[windowType].icon} className="mb-4" /> <Icon
size={40}
color={WIN_VARIANTS(t)[windowType].iconColor}
name={WIN_VARIANTS(t)[windowType].icon}
className="mb-4"
/>
<div> <div>
Waiting for <span className="font-semibold">{userDisplayName}</span> {t('Waiting for')}{' '}
<span className="font-semibold">{userDisplayName}</span>
</div> </div>
<span>{WIN_VARIANTS[windowType].text}</span> <span>{WIN_VARIANTS(t)[windowType].text}</span>
<Loader size={30} style={{ minHeight: 60 }} /> <Loader size={30} style={{ minHeight: 60 }} />
<Button variant="text" onClick={actions[WIN_VARIANTS[windowType].action]}> <Button
Cancel variant="text"
onClick={actions[WIN_VARIANTS(t)[windowType].action]}
>
{t('Cancel')}
</Button> </Button>
</div> </div>
</div> </div>

View file

@ -1 +1 @@
export { default, WindowType } from './RequestingWindow' export { default, WindowType } from './RequestingWindow';

View file

@ -2,28 +2,26 @@ import React, { useState, useEffect } from 'react';
import { Button } from 'antd'; import { Button } from 'antd';
import {Headset} from 'lucide-react'; import {Headset} from 'lucide-react';
import cn from 'classnames'; import cn from 'classnames';
import ChatWindow from '../../ChatWindow'; import {
import { CallingState, ConnectionStatus, RemoteControlStatus, RequestLocalStream } from 'Player'; CallingState,
ConnectionStatus,
RemoteControlStatus,
RequestLocalStream,
} from 'Player';
import type { LocalStream } from 'Player'; import type { LocalStream } from 'Player';
import { PlayerContext, ILivePlayerContext } from 'App/components/Session/playerContext'; import {
PlayerContext,
ILivePlayerContext,
} from 'App/components/Session/playerContext';
import { observer } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { confirm, Icon, Tooltip } from 'UI'; import { confirm, Icon, Tooltip } from 'UI';
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"; import { useStore } from 'App/mstore';
import stl from './AassistActions.module.css';
function onReject() { import ChatWindow from '../../ChatWindow';
toast.info(`Call was rejected.`); import { useTranslation } from 'react-i18next';
}
function onControlReject() {
toast.info('Remote control request was rejected by user');
}
function onControlBusy() {
toast.info('Remote control busy');
}
function onError(e: any) { function onError(e: any) {
console.log(e); console.log(e);
@ -40,27 +38,26 @@ interface Props {
const AssistActionsPing = { const AssistActionsPing = {
control: { control: {
start: 's_control_started', start: 's_control_started',
end: 's_control_ended' end: 's_control_ended',
}, },
call: { call: {
start: 's_call_started', start: 's_call_started',
end: 's_call_ended' end: 's_call_ended',
}, },
} as const } as const;
function AssistActions({ function AssistActions({ userId, isCallActive, agentIds }: Props) {
userId,
isCallActive,
agentIds,
}: Props) {
// @ts-ignore ??? // @ts-ignore ???
const { t } = useTranslation();
const { player, store } = React.useContext<ILivePlayerContext>(PlayerContext); const { player, store } = React.useContext<ILivePlayerContext>(PlayerContext);
const { sessionStore, userStore } = useStore(); const { sessionStore, userStore } = useStore();
const permissions = userStore.account.permissions || []; const permissions = userStore.account.permissions || [];
const hasPermission = permissions.includes('ASSIST_CALL') || permissions.includes('SERVICE_ASSIST_CALL'); const hasPermission =
const isEnterprise = userStore.isEnterprise; permissions.includes('ASSIST_CALL') ||
permissions.includes('SERVICE_ASSIST_CALL');
const { isEnterprise } = userStore;
const agentId = userStore.account.id; const agentId = userStore.account.id;
const userDisplayName = sessionStore.current.userDisplayName; const { userDisplayName } = sessionStore.current;
const { const {
assistManager: { assistManager: {
@ -81,16 +78,23 @@ function AssistActions({
} = store.get(); } = store.get();
const [isPrestart, setPrestart] = useState(false); const [isPrestart, setPrestart] = useState(false);
const [incomeStream, setIncomeStream] = useState<{ stream: MediaStream; isAgent: boolean }[] | null>([]); const [incomeStream, setIncomeStream] = useState<
{ stream: MediaStream; isAgent: boolean }[] | null
>([]);
const [localStream, setLocalStream] = useState<LocalStream | null>(null); const [localStream, setLocalStream] = useState<LocalStream | null>(null);
const [callObject, setCallObject] = useState<{ end: () => void } | null>(null); const [callObject, setCallObject] = useState<{ end: () => void } | null>(
null,
);
const onCall = calling === CallingState.OnCall || calling === CallingState.Reconnecting; const onCall =
calling === CallingState.OnCall || calling === CallingState.Reconnecting;
const callRequesting = calling === CallingState.Connecting; const callRequesting = calling === CallingState.Connecting;
const cannotCall = const cannotCall =
peerConnectionStatus !== ConnectionStatus.Connected || (isEnterprise && !hasPermission); peerConnectionStatus !== ConnectionStatus.Connected ||
(isEnterprise && !hasPermission);
const remoteRequesting = remoteControlStatus === RemoteControlStatus.Requesting; const remoteRequesting =
remoteControlStatus === RemoteControlStatus.Requesting;
const remoteActive = remoteControlStatus === RemoteControlStatus.Enabled; const remoteActive = remoteControlStatus === RemoteControlStatus.Enabled;
useEffect(() => { useEffect(() => {
@ -122,20 +126,22 @@ function AssistActions({
} }
}, [remoteActive]); }, [remoteActive]);
useEffect(() => { useEffect(() => callObject?.end(), []);
return callObject?.end();
}, []);
useEffect(() => { useEffect(() => {
if (peerConnectionStatus == ConnectionStatus.Disconnected) { if (peerConnectionStatus == ConnectionStatus.Disconnected) {
toast.info(`Live session was closed.`); toast.info(t('Live session was closed.'));
} }
}, [peerConnectionStatus]); }, [peerConnectionStatus]);
const addIncomeStream = (stream: MediaStream, isAgent: boolean) => { const addIncomeStream = (stream: MediaStream, isAgent: boolean) => {
setIncomeStream((oldState) => { setIncomeStream((oldState) => {
if (oldState === null) return [{ stream, isAgent }]; if (oldState === null) return [{ stream, isAgent }];
if (!oldState.find((existingStream) => existingStream.stream.id === stream.id)) { if (
!oldState.find(
(existingStream) => existingStream.stream.id === stream.id,
)
) {
audioContextManager.mergeAudioStreams(stream); audioContextManager.mergeAudioStreams(stream);
return [...oldState, { stream, isAgent }]; return [...oldState, { stream, isAgent }];
} }
@ -146,10 +152,24 @@ function AssistActions({
const removeIncomeStream = (stream: MediaStream) => { const removeIncomeStream = (stream: MediaStream) => {
setIncomeStream((prevState) => { setIncomeStream((prevState) => {
if (!prevState) return []; if (!prevState) return [];
return prevState.filter((existingStream) => existingStream.stream.id !== stream.id); return prevState.filter(
(existingStream) => existingStream.stream.id !== stream.id,
);
}); });
}; };
function onReject() {
toast.info(t('Call was rejected.'));
}
function onControlReject() {
toast.info(t('Remote control request was rejected by user'));
}
function onControlBusy() {
toast.info(t('Remote control busy'));
}
function call() { function call() {
RequestLocalStream() RequestLocalStream()
.then((lStream) => { .then((lStream) => {
@ -159,12 +179,12 @@ function AssistActions({
lStream, lStream,
addIncomeStream, addIncomeStream,
() => { () => {
player.assistManager.ping(AssistActionsPing.call.end, agentId) player.assistManager.ping(AssistActionsPing.call.end, agentId);
lStream.stop.apply(lStream); lStream.stop.apply(lStream);
removeIncomeStream(lStream.stream); removeIncomeStream(lStream.stream);
}, },
onReject, onReject,
onError onError,
); );
setCallObject(callPeer()); setCallObject(callPeer());
// if (additionalAgentIds) { // if (additionalAgentIds) {
@ -179,9 +199,9 @@ function AssistActions({
if ( if (
await confirm({ await confirm({
header: 'Start Call', header: t('Start Call'),
confirmButton: 'Call', confirmButton: t('Call'),
confirmation: `Are you sure you want to call ${userId ? userId : 'User'}?`, confirmation: `${t('Are you sure you want to call')} ${userId || t('User')}?`,
}) })
) { ) {
call(agentIds); call(agentIds);
@ -190,15 +210,15 @@ function AssistActions({
const requestControl = () => { const requestControl = () => {
const onStart = () => { const onStart = () => {
player.assistManager.ping(AssistActionsPing.control.start, agentId) player.assistManager.ping(AssistActionsPing.control.start, agentId);
} };
const onEnd = () => { const onEnd = () => {
player.assistManager.ping(AssistActionsPing.control.end, agentId) player.assistManager.ping(AssistActionsPing.control.end, agentId);
} };
setRemoteControlCallbacks({ setRemoteControlCallbacks({
onReject: onControlReject, onReject: onControlReject,
onStart: onStart, onStart,
onEnd: onEnd, onEnd,
onBusy: onControlBusy, onBusy: onControlBusy,
}); });
requestReleaseRemoteControl(); requestReleaseRemoteControl();
@ -206,9 +226,9 @@ function AssistActions({
React.useEffect(() => { React.useEffect(() => {
if (onCall) { if (onCall) {
player.assistManager.ping(AssistActionsPing.call.start, agentId) player.assistManager.ping(AssistActionsPing.call.start, agentId);
} }
}, [onCall]) }, [onCall]);
return ( return (
<div className="flex items-center"> <div className="flex items-center">
@ -227,7 +247,7 @@ function AssistActions({
size='small' size='small'
className={annotating ? 'text-red' : 'text-main'} className={annotating ? 'text-red' : 'text-main'}
> >
Annotate {t('Annotate')}
</Button> </Button>
</div> </div>
<div className={stl.divider} /> <div className={stl.divider} />
@ -241,7 +261,8 @@ function AssistActions({
<Tooltip title="Call user to initiate remote control" disabled={livePlay}> <Tooltip title="Call user to initiate remote control" disabled={livePlay}>
<div <div
className={cn('cursor-pointer p-2 flex items-center', { className={cn('cursor-pointer p-2 flex items-center', {
[stl.disabled]: cannotCall || !livePlay || callRequesting || remoteRequesting, [stl.disabled]:
cannotCall || !livePlay || callRequesting || remoteRequesting,
})} })}
onClick={requestControl} onClick={requestControl}
role="button" role="button"
@ -252,7 +273,7 @@ function AssistActions({
icon={<Icon name={remoteActive ? 'window-x' : 'remote-control'} size={16} color={remoteActive ? 'red' : 'main'} />} icon={<Icon name={remoteActive ? 'window-x' : 'remote-control'} size={16} color={remoteActive ? 'red' : 'main'} />}
size='small' size='small'
> >
Remote Control {t('Remote Control')}
</Button> </Button>
</div> </div>
</Tooltip> </Tooltip>
@ -260,8 +281,8 @@ function AssistActions({
<Tooltip <Tooltip
title={ title={
cannotCall cannotCall
? `You don't have the permissions to perform this action.` ? t("You don't have the permissions to perform this action.")
: `Call ${userId ? userId : 'User'}` : `${t('Call')} ${userId || t('User')}`
} }
disabled={onCall} disabled={onCall}
> >
@ -278,7 +299,7 @@ function AssistActions({
className={onCall ? 'text-red' : isPrestart ? 'text-green' : 'text-main'} className={onCall ? 'text-red' : isPrestart ? 'text-green' : 'text-main'}
size='small' size='small'
> >
{onCall ? 'End' : isPrestart ? 'Join Call' : 'Call'} {onCall ? t('End') : isPrestart ? t('Join Call') : t('Call')}
</Button> </Button>
</div> </div>
</Tooltip> </Tooltip>

View file

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

View file

@ -1,72 +1,90 @@
/* eslint-disable i18next/no-literal-string */
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import { observer } from 'mobx-react-lite' import { observer } from 'mobx-react-lite';
import { useStore } from 'App/mstore'; 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';
import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG'; import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG';
import { useTranslation } from 'react-i18next';
interface Props { interface Props {
loading: boolean; loading: boolean;
list: any; list: any;
session: any; session: any;
userId: any; userId: any;
} }
function SessionList(props: Props) {
const { hideModal } = useModal();
const { sessionStore } = useStore();
const fetchLiveList = sessionStore.fetchLiveSessions;
const session = sessionStore.current;
const list = sessionStore.liveSessions.filter((i: any) => i.userId === session.userId && i.sessionId !== session.sessionId);
const loading = sessionStore.loadingLiveSessions;
useEffect(() => {
const params: any = {};
if (props.session.userId) {
params.userId = props.session.userId;
}
void fetchLiveList(params);
}, []);
return ( function SessionList(props: Props) {
<div const { t } = useTranslation();
className="border-r shadow h-screen overflow-y-auto" const { hideModal } = useModal();
style={{ backgroundColor: '#FAFAFA', zIndex: 999, width: '100%', minWidth: '700px' }} const { sessionStore } = useStore();
> const fetchLiveList = sessionStore.fetchLiveSessions;
<div className="p-4"> const session = sessionStore.current;
<div className="text-2xl"> const list = sessionStore.liveSessions.filter(
{props.userId}'s <span className="color-gray-medium">Live Sessions</span>{' '} (i: any) =>
</div> i.userId === session.userId && i.sessionId !== session.sessionId,
</div> );
<Loader loading={loading}> const loading = sessionStore.loadingLiveSessions;
<NoContent useEffect(() => {
show={!loading && list.length === 0} const params: any = {};
title={ if (props.session.userId) {
<div className="flex items-center justify-center flex-col"> params.userId = props.session.userId;
<AnimatedSVG name={ICONS.NO_LIVE_SESSIONS} size={60} /> }
<div className="mt-4" /> void fetchLiveList(params);
<div className="text-center text-lg font-medium">No live sessions found.</div> }, []);
</div>
} return (
> <div
<div className="p-4"> className="border-r shadow h-screen overflow-y-auto"
{list.map((session: any) => ( style={{
<div className="mb-6" key={session.sessionId}> backgroundColor: '#FAFAFA',
{session.pageTitle && session.pageTitle !== '' && ( zIndex: 999,
<div className="flex items-center mb-2"> width: '100%',
<Label size="small" className="p-1"> minWidth: '700px',
<span className="color-gray-medium">TAB</span> }}
</Label> >
<span className="ml-2 font-medium">{session.pageTitle}</span> <div className="p-4">
</div> <div className="text-2xl">
)} {props.userId}
<SessionItem compact={true} onClick={hideModal} session={session} /> &apos;s
</div> <span className="color-gray-medium">{t('Live Sessions')}</span>{' '}
))}
</div>
</NoContent>
</Loader>
</div> </div>
); </div>
<Loader loading={loading}>
<NoContent
show={!loading && list.length === 0}
title={
<div className="flex items-center justify-center flex-col">
<AnimatedSVG name={ICONS.NO_LIVE_SESSIONS} size={60} />
<div className="mt-4" />
<div className="text-center text-lg font-medium">
{t('No live sessions found.')}
</div>
</div>
}
>
<div className="p-4">
{list.map((session: any) => (
<div className="mb-6" key={session.sessionId}>
{session.pageTitle && session.pageTitle !== '' && (
<div className="flex items-center mb-2">
<Label size="small" className="p-1">
<span className="color-gray-medium">{t('TAB')}</span>
</Label>
<span className="ml-2 font-medium">
{session.pageTitle}
</span>
</div>
)}
<SessionItem compact onClick={hideModal} session={session} />
</div>
))}
</div>
</NoContent>
</Loader>
</div>
);
} }
export default observer(SessionList); export default observer(SessionList);

View file

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

View file

@ -1,4 +1,5 @@
import React, { useEffect, useRef } from 'react'; import React, { useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
interface Props { interface Props {
stream: MediaStream | null; stream: MediaStream | null;
@ -17,6 +18,7 @@ function VideoContainer({
local, local,
isAgent, isAgent,
}: Props) { }: Props) {
const { t } = useTranslation();
const ref = useRef<HTMLVideoElement>(null); const ref = useRef<HTMLVideoElement>(null);
const [isEnabled, setEnabled] = React.useState(false); const [isEnabled, setEnabled] = React.useState(false);
@ -50,7 +52,7 @@ function VideoContainer({
return ( return (
<div <div
className={'flex-1'} className="flex-1"
style={{ style={{
display: isEnabled ? undefined : 'none', display: isEnabled ? undefined : 'none',
width: isEnabled ? undefined : '0px!important', width: isEnabled ? undefined : '0px!important',
@ -59,14 +61,14 @@ function VideoContainer({
transform: local ? 'scaleX(-1)' : undefined, transform: local ? 'scaleX(-1)' : undefined,
}} }}
> >
<video autoPlay ref={ref} muted={muted} style={{ height: height }} /> <video autoPlay ref={ref} muted={muted} style={{ height }} />
{isAgent ? ( {isAgent ? (
<div <div
style={{ style={{
position: 'absolute', position: 'absolute',
}} }}
> >
Agent {t('Agent')}
</div> </div>
) : null} ) : null}
</div> </div>

View file

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

View file

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

View file

@ -13,34 +13,42 @@ import { FilePdfOutlined, ArrowUpOutlined } from '@ant-design/icons';
import Period, { LAST_24_HOURS } from 'Types/app/period'; import Period, { LAST_24_HOURS } from 'Types/app/period';
import SelectDateRange from 'Shared/SelectDateRange/SelectDateRange'; import SelectDateRange from 'Shared/SelectDateRange/SelectDateRange';
import TeamMembers from 'Components/AssistStats/components/TeamMembers'; import TeamMembers from 'Components/AssistStats/components/TeamMembers';
import { durationFromMsFormatted, formatTimeOrDate } from 'App/date' import { durationFromMsFormatted, formatTimeOrDate } from 'App/date';
import { exportCSVFile } from 'App/utils'; import { exportCSVFile } from 'App/utils';
import { assistStatsService } from 'App/services'; import { assistStatsService } from 'App/services';
import { getPdf2 } from 'Components/AssistStats/pdfGenerator';
import UserSearch from './components/UserSearch'; import UserSearch from './components/UserSearch';
import Chart from './components/Charts'; import Chart from './components/Charts';
import StatsTable from './components/Table'; import StatsTable from './components/Table';
import { getPdf2 } from "Components/AssistStats/pdfGenerator"; import { useTranslation } from 'react-i18next';
import { TFunction } from 'i18next';
const chartNames = { const chartNames = (t: TFunction) => ({
assistTotal: 'Total Live Duration', assistTotal: t('Total Live Duration'),
assistAvg: 'Avg Live Duration', assistAvg: t('Avg Live Duration'),
callTotal: 'Total Call Duration', callTotal: t('Total Call Duration'),
callAvg: 'Avg Call Duration', callAvg: t('Avg Call Duration'),
controlTotal: 'Total Remote Duration', controlTotal: t('Total Remote Duration'),
controlAvg: 'Avg Remote Duration', controlAvg: t('Avg Remote Duration'),
}; });
function calculatePercentageDelta(currP: number, prevP: number) { function calculatePercentageDelta(currP: number, prevP: number) {
return ((currP - prevP) / prevP) * 100; return ((currP - prevP) / prevP) * 100;
} }
function AssistStats() { function AssistStats() {
const { t } = useTranslation();
const [selectedUser, setSelectedUser] = React.useState<any>(null); const [selectedUser, setSelectedUser] = React.useState<any>(null);
const [period, setPeriod] = React.useState<any>(Period({ rangeName: LAST_24_HOURS })); const [period, setPeriod] = React.useState<any>(
Period({ rangeName: LAST_24_HOURS }),
);
const [membersSort, setMembersSort] = React.useState('sessionsAssisted'); const [membersSort, setMembersSort] = React.useState('sessionsAssisted');
const [tableSort, setTableSort] = React.useState('timestamp'); const [tableSort, setTableSort] = React.useState('timestamp');
const [topMembers, setTopMembers] = React.useState<{ list: Member[]; total: number }>({ const [topMembers, setTopMembers] = React.useState<{
list: Member[];
total: number;
}>({
list: [], list: [],
total: 0, total: 0,
}); });
@ -68,7 +76,7 @@ function AssistStats() {
const topMembersPr = assistStatsService.getTopMembers({ const topMembersPr = assistStatsService.getTopMembers({
startTimestamp: usedP.start, startTimestamp: usedP.start,
endTimestamp: usedP.end, endTimestamp: usedP.end,
userId: selectedUser ? selectedUser : undefined, userId: selectedUser || undefined,
sort: membersSort, sort: membersSort,
order: 'desc', order: 'desc',
}); });
@ -79,7 +87,7 @@ function AssistStats() {
endTimestamp: usedP.end, endTimestamp: usedP.end,
sort: tableSort, sort: tableSort,
order: 'desc', order: 'desc',
userId: selectedUser ? selectedUser : undefined, userId: selectedUser || undefined,
page: 1, page: 1,
limit: 10, limit: 10,
}); });
@ -88,7 +96,7 @@ function AssistStats() {
topMembers.status === 'fulfilled' && setTopMembers(topMembers.value); topMembers.status === 'fulfilled' && setTopMembers(topMembers.value);
graphs.status === 'fulfilled' && setGraphs(graphs.value); graphs.status === 'fulfilled' && setGraphs(graphs.value);
sessions.status === 'fulfilled' && setSessions(sessions.value); sessions.status === 'fulfilled' && setSessions(sessions.value);
} },
); );
setIsLoading(false); setIsLoading(false);
}; };
@ -148,27 +156,31 @@ function AssistStats() {
order: 'desc', order: 'desc',
page: 1, page: 1,
limit: 10000, limit: 10000,
}).then((sessions) => { })
const data = sessions.list.map((s) => ({ .then((sessions) => {
...s, const data = sessions.list.map((s) => ({
members: `"${s.teamMembers.map((m) => m.name).join(', ')}"`, ...s,
dateStr: `"${formatTimeOrDate(s.timestamp, undefined, true)}"`, members: `"${s.teamMembers.map((m) => m.name).join(', ')}"`,
assistDuration: `"${durationFromMsFormatted(s.assistDuration)}"`, dateStr: `"${formatTimeOrDate(s.timestamp, undefined, true)}"`,
callDuration: `"${durationFromMsFormatted(s.callDuration)}"`, assistDuration: `"${durationFromMsFormatted(s.assistDuration)}"`,
controlDuration: `"${durationFromMsFormatted(s.controlDuration)}"`, callDuration: `"${durationFromMsFormatted(s.callDuration)}"`,
})); controlDuration: `"${durationFromMsFormatted(s.controlDuration)}"`,
const headers = [ }));
{ label: 'Date', key: 'dateStr' }, const headers = [
{ label: 'Team Members', key: 'members' }, { label: t('Date'), key: 'dateStr' },
{ label: 'Live Duration', key: 'assistDuration' }, { label: t('Team Members'), key: 'members' },
{ label: 'Call Duration', key: 'callDuration' }, { label: t('Live Duration'), key: 'assistDuration' },
{ label: 'Remote Duration', key: 'controlDuration' }, { label: t('Call Duration'), key: 'callDuration' },
{ label: 'Session ID', key: 'sessionId' } { label: t('Remote Duration'), key: 'controlDuration' },
]; { label: t('Session ID'), key: 'sessionId' },
];
exportCSVFile(headers, data, `Assist_Stats_${new Date().toLocaleDateString()}`) exportCSVFile(
headers,
}) data,
`Assist_Stats_${new Date().toLocaleDateString()}`,
);
});
}; };
const onUserSelect = (id: any) => { const onUserSelect = (id: any) => {
@ -191,83 +203,109 @@ function AssistStats() {
order: 'desc', order: 'desc',
page: 1, page: 1,
limit: 10, limit: 10,
}) });
Promise.allSettled([topMembersPr, graphsPr, sessionsPr]).then( Promise.allSettled([topMembersPr, graphsPr, sessionsPr]).then(
([topMembers, graphs, sessions]) => { ([topMembers, graphs, sessions]) => {
topMembers.status === 'fulfilled' && setTopMembers(topMembers.value); topMembers.status === 'fulfilled' && setTopMembers(topMembers.value);
graphs.status === 'fulfilled' && setGraphs(graphs.value); graphs.status === 'fulfilled' && setGraphs(graphs.value);
sessions.status === 'fulfilled' && setSessions(sessions.value); sessions.status === 'fulfilled' && setSessions(sessions.value);
} },
); );
setIsLoading(false); setIsLoading(false);
}; };
return ( return (
<div className={'w-full h-screen overflow-y-auto'}> <div className="w-full h-screen overflow-y-auto">
<div className={'mx-auto p-4 bg-white rounded border'} style={{ maxWidth: 1360 }} id={'pdf-anchor'}> <div
<div id={'pdf-ignore'} className={'w-full flex items-center mb-2'}> className="mx-auto p-4 bg-white rounded border"
style={{ maxWidth: 1360 }}
id="pdf-anchor"
>
<div id="pdf-ignore" className="w-full flex items-center mb-2">
<Typography.Title style={{ marginBottom: 0 }} level={4}> <Typography.Title style={{ marginBottom: 0 }} level={4}>
Co-browsing Reports {t('Co-browsing Reports')}
</Typography.Title> </Typography.Title>
<div className={'ml-auto flex items-center gap-2'}> <div className="ml-auto flex items-center gap-2">
<UserSearch onUserSelect={onUserSelect} /> <UserSearch onUserSelect={onUserSelect} />
<SelectDateRange period={period} onChange={onChangePeriod} right={true} isAnt small /> <SelectDateRange
<Tooltip title={!sessions || sessions.total === 0 ? 'No data at the moment to export.' : 'Export PDF'}> period={period}
onChange={onChangePeriod}
right
isAnt
small
/>
<Tooltip
title={
!sessions || sessions.total === 0
? t('No data at the moment to export.')
: t('Export PDF')
}
>
<Button <Button
onClick={getPdf2} onClick={getPdf2}
shape={'default'} shape="default"
size={'small'} size="small"
disabled={!sessions || sessions.total === 0} disabled={!sessions || sessions.total === 0}
icon={<FilePdfOutlined rev={undefined} />} icon={<FilePdfOutlined rev={undefined} />}
/> />
</Tooltip> </Tooltip>
</div> </div>
</div> </div>
<div className={'w-full grid grid-cols-3 gap-2 flex-2 col-span-2'}> <div className="w-full grid grid-cols-3 gap-2 flex-2 col-span-2">
{Object.keys(graphs.currentPeriod).map((i: PeriodKeys) => ( {Object.keys(graphs.currentPeriod).map((i: PeriodKeys) => (
<div className={'bg-white rounded border'}> <div className="bg-white rounded border">
<div className={'pt-2 px-2'}> <div className="pt-2 px-2">
<Typography.Text strong style={{ marginBottom: 0 }}> <Typography.Text strong style={{ marginBottom: 0 }}>
{chartNames[i]} {chartNames(t)[i]}
</Typography.Text> </Typography.Text>
<div className={'flex gap-1 items-center'}> <div className="flex gap-1 items-center">
<Typography.Title style={{ marginBottom: 0 }} level={5}> <Typography.Title style={{ marginBottom: 0 }} level={5}>
{graphs.currentPeriod[i] {graphs.currentPeriod[i]
? durationFromMsFormatted(graphs.currentPeriod[i]) ? durationFromMsFormatted(graphs.currentPeriod[i])
: null} : null}
</Typography.Title> </Typography.Title>
{graphs.previousPeriod[i] ? ( {graphs.previousPeriod[i] ? (
<div <div
className={ className={
graphs.currentPeriod[i] > graphs.previousPeriod[i] graphs.currentPeriod[i] > graphs.previousPeriod[i]
? 'flex items-center gap-1 text-green' ? 'flex items-center gap-1 text-green'
: 'flex items-center gap-2 text-red' : 'flex items-center gap-2 text-red'
}
>
<ArrowUpOutlined
rev={undefined}
rotate={
graphs.currentPeriod[i] > graphs.previousPeriod[i]
? 0
: 180
} }
> />
<ArrowUpOutlined {`${Math.round(
rev={undefined} calculatePercentageDelta(
rotate={graphs.currentPeriod[i] > graphs.previousPeriod[i] ? 0 : 180} graphs.currentPeriod[i],
/> graphs.previousPeriod[i],
{`${Math.round( ),
calculatePercentageDelta( )}%`}
graphs.currentPeriod[i], </div>
graphs.previousPeriod[i] ) : null}
)
)}%`}
</div>
) : null}
</div>
</div> </div>
<Loader loading={isLoading} style={{ minHeight: 90, height: 90 }} size={36}>
<Chart data={generateListData(graphs.list, i)} label={chartNames[i]} />
</Loader>
</div> </div>
))} <Loader
loading={isLoading}
style={{ minHeight: 90, height: 90 }}
size={36}
>
<Chart
data={generateListData(graphs.list, i)}
label={chartNames(t)[i]}
/>
</Loader>
</div>
))}
</div> </div>
<div className={'w-full mt-2'}> <div className="w-full mt-2">
<TeamMembers <TeamMembers
isLoading={isLoading} isLoading={isLoading}
topMembers={topMembers} topMembers={topMembers}
@ -275,7 +313,7 @@ function AssistStats() {
membersSort={membersSort} membersSort={membersSort}
/> />
</div> </div>
<div className={'w-full mt-2'}> <div className="w-full mt-2">
<StatsTable <StatsTable
exportCSV={exportCSV} exportCSV={exportCSV}
sessions={sessions} sessions={sessions}
@ -286,7 +324,7 @@ function AssistStats() {
/> />
</div> </div>
</div> </div>
<div id={'stats-layer'} /> <div id="stats-layer" />
</div> </div>
); );
} }

View file

@ -1,13 +1,8 @@
import React from 'react'; import React from 'react';
import { NoContent } from 'UI'; import { NoContent } from 'UI';
import { Styles } from 'Components/Dashboard/Widgets/common'; import { Styles } from 'Components/Dashboard/Widgets/common';
import { import { AreaChart, Area, ResponsiveContainer, XAxis, YAxis } from 'recharts';
AreaChart, import { useTranslation } from 'react-i18next';
Area,
ResponsiveContainer,
XAxis,
YAxis,
} from 'recharts';
interface Props { interface Props {
data: any; data: any;
@ -15,13 +10,16 @@ interface Props {
} }
function Chart(props: Props) { function Chart(props: Props) {
const { t } = useTranslation();
const { data, label } = props; const { data, label } = props;
const gradientDef = Styles.gradientDef(); const gradientDef = Styles.gradientDef();
return ( return (
<NoContent <NoContent
size="small" size="small"
title={<div className={'text-base font-normal'}>No data available</div>} title={
<div className="text-base font-normal">{t('No data available')}</div>
}
show={data && data.length === 0} show={data && data.length === 0}
style={{ height: '100px' }} style={{ height: '100px' }}
> >
@ -51,7 +49,7 @@ function Chart(props: Props) {
fillOpacity={1} fillOpacity={1}
strokeWidth={2} strokeWidth={2}
strokeOpacity={0.8} strokeOpacity={0.8}
fill={'url(#colorCount)'} fill="url(#colorCount)"
/> />
</AreaChart> </AreaChart>
</ResponsiveContainer> </ResponsiveContainer>

View file

@ -1,14 +1,26 @@
import { DownOutlined } from '@ant-design/icons'; import {
import { AssistStatsSession, SessionsResponse } from 'App/services/AssistStatsService'; DownOutlined,
CloudDownloadOutlined,
TableOutlined,
} from '@ant-design/icons';
import {
AssistStatsSession,
SessionsResponse,
} from 'App/services/AssistStatsService';
import { numberWithCommas } from 'App/utils'; import { numberWithCommas } from 'App/utils';
import React from 'react'; import React from 'react';
import { Button, Dropdown, Space, Typography, Tooltip } from 'antd'; import { Button, Dropdown, Space, Typography, Tooltip } from 'antd';
import { CloudDownloadOutlined, TableOutlined } from '@ant-design/icons';
import { Loader, Pagination, NoContent } from 'UI'; import { Loader, Pagination, NoContent } from 'UI';
import PlayLink from 'Shared/SessionItem/PlayLink'; import PlayLink from 'Shared/SessionItem/PlayLink';
import { recordingsService } from 'App/services'; import { recordingsService } from 'App/services';
import { checkForRecent, durationFromMsFormatted, getDateFromMill } from 'App/date'; import {
checkForRecent,
durationFromMsFormatted,
getDateFromMill,
} from 'App/date';
import { useModal } from 'Components/Modal'; import { useModal } from 'Components/Modal';
import { useTranslation } from 'react-i18next';
import { TFunction } from 'i18next';
interface Props { interface Props {
onSort: (v: string) => void; onSort: (v: string) => void;
@ -20,22 +32,22 @@ interface Props {
} }
const PER_PAGE = 10; const PER_PAGE = 10;
const sortItems = [ const sortItems = (t: TFunction) => [
{ {
key: 'timestamp', key: 'timestamp',
label: 'Newest First', label: t('Newest First'),
}, },
{ {
key: 'assist_duration', key: 'assist_duration',
label: 'Live Duration', label: t('Live Duration'),
}, },
{ {
key: 'call_duration', key: 'call_duration',
label: 'Call Duration', label: t('Call Duration'),
}, },
{ {
key: 'control_duration', key: 'control_duration',
label: 'Remote Duration', label: t('Remote Duration'),
}, },
// { // {
// key: '5', // key: '5',
@ -43,23 +55,31 @@ const sortItems = [
// }, // },
]; ];
function StatsTable({ onSort, isLoading, onPageChange, page, sessions, exportCSV }: Props) { function StatsTable({
const [sortValue, setSort] = React.useState(sortItems[0].label); onSort,
isLoading,
onPageChange,
page,
sessions,
exportCSV,
}: Props) {
const { t } = useTranslation();
const [sortValue, setSort] = React.useState(sortItems(t)[0].label);
const updateRange = ({ key }: { key: string }) => { const updateRange = ({ key }: { key: string }) => {
const item = sortItems.find((item) => item.key === key); const item = sortItems(t).find((item) => item.key === key);
setSort(item?.label || sortItems[0].label); setSort(item?.label || sortItems(t)[0].label);
item?.key && onSort(item.key); item?.key && onSort(item.key);
}; };
return ( return (
<div className={'rounded bg-white border'}> <div className="rounded bg-white border">
<div className={'flex items-center p-4 gap-2'}> <div className="flex items-center p-4 gap-2">
<Typography.Title level={5} style={{ marginBottom: 0 }}> <Typography.Title level={5} style={{ marginBottom: 0 }}>
Assisted Sessions {t('Assisted Sessions')}
</Typography.Title> </Typography.Title>
<div className={'ml-auto'} /> <div className="ml-auto" />
<Dropdown menu={{ items: sortItems, onClick: updateRange }}> <Dropdown menu={{ items: sortItems(t), onClick: updateRange }}>
<Button size={'small'}> <Button size="small">
<Space> <Space>
<Typography.Text>{sortValue}</Typography.Text> <Typography.Text>{sortValue}</Typography.Text>
<DownOutlined rev={undefined} /> <DownOutlined rev={undefined} />
@ -67,47 +87,61 @@ function StatsTable({ onSort, isLoading, onPageChange, page, sessions, exportCSV
</Button> </Button>
</Dropdown> </Dropdown>
<Button <Button
size={'small'} size="small"
icon={<TableOutlined rev={undefined} />} icon={<TableOutlined rev={undefined} />}
onClick={exportCSV} onClick={exportCSV}
disabled={sessions?.list.length === 0} disabled={sessions?.list.length === 0}
> >
Export CSV {t('Export CSV')}
</Button> </Button>
</div> </div>
<div className={'bg-gray-lightest grid grid-cols-9 items-center font-semibold p-4'}> <div className="bg-gray-lightest grid grid-cols-9 items-center font-semibold p-4">
<Cell size={2}>Date</Cell> <Cell size={2}>{t('Date')}</Cell>
<Cell size={2}>Team Members</Cell> <Cell size={2}>{t('Team Members')}</Cell>
<Cell size={1}>Live Duration</Cell> <Cell size={1}>{t('Live Duration')}</Cell>
<Cell size={1}>Call Duration</Cell> <Cell size={1}>{t('Call Duration')}</Cell>
<Cell size={2}>Remote Duration</Cell> <Cell size={2}>{t('Remote Duration')}</Cell>
<Cell size={1}>{/* BUTTONS */}</Cell> <Cell size={1}>{/* BUTTONS */}</Cell>
</div> </div>
<div className={'bg-white'}> <div className="bg-white">
<Loader loading={isLoading} style={{ height: 300 }}> <Loader loading={isLoading} style={{ height: 300 }}>
<NoContent <NoContent
size={'small'} size="small"
title={<div className={'text-base font-normal'}>No data available</div>} title={
<div className="text-base font-normal">
{t('No data available')}
</div>
}
show={sessions.list && sessions.list.length === 0} show={sessions.list && sessions.list.length === 0}
style={{ height: '100px' }} style={{ height: '100px' }}
> >
{sessions.list.map((session) => ( {sessions.list.map((session) => (
<Row session={session} /> <Row session={session} />
))} ))}
</NoContent> </NoContent>
</Loader> </Loader>
</div> </div>
<div className={'flex items-center justify-between p-4'}> <div className="flex items-center justify-between p-4">
{sessions.total > 0 ? ( {sessions.total > 0 ? (
<div> <div>
Showing <span className="font-medium">{(page - 1) * PER_PAGE + 1}</span> to{' '} {t('Showing')}{' '}
<span className="font-medium">{(page - 1) * PER_PAGE + sessions.list.length}</span> of{' '} <span className="font-medium">{(page - 1) * PER_PAGE + 1}</span>
<span className="font-medium">{numberWithCommas(sessions.total)}</span> sessions. &nbsp;{t('to')}&nbsp;
<span className="font-medium">
{(page - 1) * PER_PAGE + sessions.list.length}
</span>{' '}
{t('of')}{' '}
<span className="font-medium">
{numberWithCommas(sessions.total)}
</span>{' '}
{t('sessions.')}
</div> </div>
) : ( ) : (
<div> <div>
Showing <span className="font-medium">0</span> to <span className="font-medium">0</span>{' '} {t('Showing')}&nbsp;<span className="font-medium">0</span>&nbsp;
of <span className="font-medium">0</span> sessions. {t('to')}&nbsp;
<span className="font-medium">0</span>&nbsp;{t('of')}&nbsp;
<span className="font-medium">0</span>&nbsp;{t('sessions.')}
</div> </div>
)} )}
<Pagination <Pagination
@ -124,14 +158,18 @@ function StatsTable({ onSort, isLoading, onPageChange, page, sessions, exportCSV
function Row({ session }: { session: AssistStatsSession }) { function Row({ session }: { session: AssistStatsSession }) {
const { hideModal } = useModal(); const { hideModal } = useModal();
return ( return (
<div className={'grid grid-cols-9 p-4 border-b hover:bg-active-blue'}> <div className="grid grid-cols-9 p-4 border-b hover:bg-active-blue">
<Cell size={2}>{checkForRecent(getDateFromMill(session.timestamp)!, 'LLL dd, hh:mm a')}</Cell>
<Cell size={2}> <Cell size={2}>
<div className={'flex gap-2 flex-wrap'}> {checkForRecent(getDateFromMill(session.timestamp)!, 'LLL dd, hh:mm a')}
</Cell>
<Cell size={2}>
<div className="flex gap-2 flex-wrap">
{session.teamMembers.map((member) => ( {session.teamMembers.map((member) => (
<div className={'p-1 rounded border bg-gray-lightest w-fit'}>{member.name}</div> <div className="p-1 rounded border bg-gray-lightest w-fit">
{member.name}
</div>
))} ))}
</div> </div>
</Cell> </Cell>
@ -139,7 +177,7 @@ function Row({ session }: { session: AssistStatsSession }) {
<Cell size={1}>{durationFromMsFormatted(session.callDuration)}</Cell> <Cell size={1}>{durationFromMsFormatted(session.callDuration)}</Cell>
<Cell size={2}>{durationFromMsFormatted(session.controlDuration)}</Cell> <Cell size={2}>{durationFromMsFormatted(session.controlDuration)}</Cell>
<Cell size={1}> <Cell size={1}>
<div className={'w-full flex justify-end gap-4'}> <div className="w-full flex justify-end gap-4">
{session.recordings?.length > 0 ? ( {session.recordings?.length > 0 ? (
session.recordings?.length > 1 ? ( session.recordings?.length > 1 ? (
<Dropdown <Dropdown
@ -149,28 +187,51 @@ function Row({ session }: { session: AssistStatsSession }) {
label: recording.name.slice(0, 20), label: recording.name.slice(0, 20),
})), })),
onClick: (item) => onClick: (item) =>
recordingsService.fetchRecording(item.key as unknown as number), recordingsService.fetchRecording(
item.key as unknown as number,
),
}} }}
> >
<CloudDownloadOutlined rev={undefined} style={{ fontSize: 22, color: '#8C8C8C' }} /> <CloudDownloadOutlined
rev={undefined}
style={{ fontSize: 22, color: '#8C8C8C' }}
/>
</Dropdown> </Dropdown>
) : ( ) : (
<div <div
className={'cursor-pointer'} className="cursor-pointer"
onClick={() => recordingsService.fetchRecording(session.recordings[0].recordId)} onClick={() =>
recordingsService.fetchRecording(
session.recordings[0].recordId,
)
}
> >
<CloudDownloadOutlined rev={undefined} style={{ fontSize: 22, color: '#8C8C8C' }} /> <CloudDownloadOutlined
rev={undefined}
style={{ fontSize: 22, color: '#8C8C8C' }}
/>
</div> </div>
) )
) : null} ) : null}
<PlayLink isAssist={false} viewed={false} sessionId={session.sessionId} onClick={hideModal} /> <PlayLink
isAssist={false}
viewed={false}
sessionId={session.sessionId}
onClick={hideModal}
/>
</div> </div>
</Cell> </Cell>
</div> </div>
); );
} }
function Cell({ size, children }: { size: number; children?: React.ReactNode }) { function Cell({
size,
children,
}: {
size: number;
children?: React.ReactNode;
}) {
return <div className={`col-span-${size} capitalize`}>{children}</div>; return <div className={`col-span-${size} capitalize`}>{children}</div>;
} }

View file

@ -2,26 +2,27 @@ import { DownOutlined, TableOutlined } from '@ant-design/icons';
import { Button, Dropdown, Space, Typography, Tooltip } from 'antd'; import { Button, Dropdown, Space, Typography, Tooltip } from 'antd';
import { durationFromMsFormatted } from 'App/date'; import { durationFromMsFormatted } from 'App/date';
import { Member } from 'App/services/AssistStatsService'; import { Member } from 'App/services/AssistStatsService';
import { getInitials } from 'App/utils'; import { getInitials, exportCSVFile } from 'App/utils';
import { TFunction } from 'i18next';
import React from 'react'; import React from 'react';
import { useTranslation } from 'react-i18next';
import { Loader, NoContent } from 'UI'; import { Loader, NoContent } from 'UI';
import { exportCSVFile } from 'App/utils';
const items = [ const items = (t: TFunction) => [
{ {
label: 'Sessions Assisted', label: t('Sessions Assisted'),
key: 'sessionsAssisted', key: 'sessionsAssisted',
}, },
{ {
label: 'Live Duration', label: t('Live Duration'),
key: 'assistDuration', key: 'assistDuration',
}, },
{ {
label: 'Call Duration', label: t('Call Duration'),
key: 'callDuration', key: 'callDuration',
}, },
{ {
label: 'Remote Duration', label: t('Remote Duration'),
key: 'controlDuration', key: 'controlDuration',
}, },
]; ];
@ -37,20 +38,21 @@ function TeamMembers({
onMembersSort: (v: string) => void; onMembersSort: (v: string) => void;
membersSort: string; membersSort: string;
}) { }) {
const [dateRange, setDateRange] = React.useState(items[0].label); const { t } = useTranslation();
const [dateRange, setDateRange] = React.useState(items(t)[0].label);
const updateRange = ({ key }: { key: string }) => { const updateRange = ({ key }: { key: string }) => {
const item = items.find((item) => item.key === key); const item = items(t).find((item) => item.key === key);
setDateRange(item?.label || items[0].label); setDateRange(item?.label || items(t)[0].label);
onMembersSort(item?.key || items[0].key); onMembersSort(item?.key || items(t)[0].key);
}; };
const onExport = () => { const onExport = () => {
const headers = [ const headers = [
{ label: 'Team Member', key: 'name' }, { label: t('Team Member'), key: 'name' },
{ label: 'Sessions Assisted', key: 'sessionsAssisted' }, { label: t('Sessions Assisted'), key: 'sessionsAssisted' },
{ label: 'Live Duration', key: 'assistDuration' }, { label: t('Live Duration'), key: 'assistDuration' },
{ label: 'Call Duration', key: 'callDuration' }, { label: t('Call Duration'), key: 'callDuration' },
{ label: 'Remote Duration', key: 'controlDuration' }, { label: t('Remote Duration'), key: 'controlDuration' },
]; ];
const data = topMembers.list.map((member) => ({ const data = topMembers.list.map((member) => ({
@ -61,50 +63,73 @@ function TeamMembers({
controlDuration: `"${durationFromMsFormatted(member.controlDuration)}"`, controlDuration: `"${durationFromMsFormatted(member.controlDuration)}"`,
})); }));
exportCSVFile(headers, data, `Team_Members_${new Date().toLocaleDateString()}`); exportCSVFile(
headers,
data,
`Team_Members_${new Date().toLocaleDateString()}`,
);
}; };
return ( return (
<div className={'rounded bg-white border p-2 h-full w-full flex flex-col'}> <div className="rounded bg-white border p-2 h-full w-full flex flex-col">
<div className={'flex items-center'}> <div className="flex items-center">
<Typography.Title style={{ marginBottom: 0 }} level={5}> <Typography.Title style={{ marginBottom: 0 }} level={5}>
Team Members {t('Team Members')}
</Typography.Title> </Typography.Title>
<div className={'ml-auto flex items-center gap-2'}> <div className="ml-auto flex items-center gap-2">
<Dropdown menu={{ items, onClick: updateRange }}> <Dropdown menu={{ items, onClick: updateRange }}>
<Button size={'small'}> <Button size="small">
<Space> <Space>
<Typography.Text>{dateRange}</Typography.Text> <Typography.Text>{dateRange}</Typography.Text>
<DownOutlined rev={undefined} /> <DownOutlined rev={undefined} />
</Space> </Space>
</Button> </Button>
</Dropdown> </Dropdown>
<Tooltip title={topMembers.list.length === 0 ? 'No data at the moment to export.' : 'Export CSV'}> <Tooltip
title={
topMembers.list.length === 0
? t('No data at the moment to export.')
: t('Export CSV')
}
>
<Button <Button
onClick={onExport} onClick={onExport}
shape={'default'} shape="default"
size={'small'} size="small"
disabled={topMembers.list.length === 0} disabled={topMembers.list.length === 0}
icon={<TableOutlined rev={undefined} />} icon={<TableOutlined rev={undefined} />}
/> />
</Tooltip> </Tooltip>
</div> </div>
</div> </div>
<Loader loading={isLoading} style={{ minHeight: 150, height: 300 }} size={48}> <Loader
loading={isLoading}
style={{ minHeight: 150, height: 300 }}
size={48}
>
<NoContent <NoContent
size={'small'} size="small"
title={<div className={'text-base font-normal'}>No data available</div>} title={
<div className="text-base font-normal">
{t('No data available')}
</div>
}
show={topMembers.list && topMembers.list.length === 0} show={topMembers.list && topMembers.list.length === 0}
style={{ height: '100px' }} style={{ height: '100px' }}
> >
{topMembers.list.map((member) => ( {topMembers.list.map((member) => (
<div key={member.name} className={'w-full flex items-center gap-2 border-b pt-2 pb-1'}> <div
key={member.name}
className="w-full flex items-center gap-2 border-b pt-2 pb-1"
>
<div className="relative flex items-center justify-center w-10 h-10"> <div className="relative flex items-center justify-center w-10 h-10">
<div className="absolute left-0 right-0 top-0 bottom-0 mx-auto w-10 h-10 rounded-full opacity-30 bg-tealx" /> <div className="absolute left-0 right-0 top-0 bottom-0 mx-auto w-10 h-10 rounded-full opacity-30 bg-tealx" />
<div className="text-lg uppercase color-tealx">{getInitials(member.name)}</div> <div className="text-lg uppercase color-tealx">
{getInitials(member.name)}
</div>
</div> </div>
<div>{member.name}</div> <div>{member.name}</div>
<div className={'ml-auto'}> <div className="ml-auto">
{membersSort === 'sessionsAssisted' {membersSort === 'sessionsAssisted'
? member.count ? member.count
: durationFromMsFormatted(member.count)} : durationFromMsFormatted(member.count)}
@ -113,10 +138,10 @@ function TeamMembers({
))} ))}
</NoContent> </NoContent>
</Loader> </Loader>
<div className={'flex items-center justify-center text-disabled-text p-2 mt-auto'}> <div className="flex items-center justify-center text-disabled-text p-2 mt-auto">
{isLoading || topMembers.list.length === 0 {isLoading || topMembers.list.length === 0
? '' ? ''
: `Showing 1 to ${topMembers.total} of the total`} : `${t('Showing 1 to')} ${topMembers.total} ${t('of the total')}`}
</div> </div>
</div> </div>
); );

View file

@ -4,8 +4,10 @@ import type { SelectProps } from 'antd/es/select';
import { observer } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite';
import { useStore } from 'App/mstore'; import { useStore } from 'App/mstore';
const UserSearch = ({ onUserSelect }: { onUserSelect: (id: any) => void }) => { function UserSearch({ onUserSelect }: { onUserSelect: (id: any) => void }) {
const [selectedValue, setSelectedValue] = useState<string | undefined>(undefined); const [selectedValue, setSelectedValue] = useState<string | undefined>(
undefined,
);
const { userStore } = useStore(); const { userStore } = useStore();
const allUsers = userStore.list.map((user) => ({ const allUsers = userStore.list.map((user) => ({
value: user.userId, value: user.userId,
@ -20,7 +22,7 @@ const UserSearch = ({ onUserSelect }: { onUserSelect: (id: any) => void }) => {
r.map((user: any) => ({ r.map((user: any) => ({
value: user.userId, value: user.userId,
label: user.name, label: user.name,
})) })),
); );
}); });
} }
@ -28,12 +30,16 @@ const UserSearch = ({ onUserSelect }: { onUserSelect: (id: any) => void }) => {
const handleSearch = (value: string) => { const handleSearch = (value: string) => {
setOptions( setOptions(
value ? allUsers.filter((u) => u.label.toLowerCase().includes(value.toLocaleLowerCase())) : [] value
? allUsers.filter((u) =>
u.label.toLowerCase().includes(value.toLocaleLowerCase()),
)
: [],
); );
}; };
const onSelect = (value?: string) => { const onSelect = (value?: string) => {
onUserSelect(value) onUserSelect(value);
setSelectedValue(allUsers.find((u) => u.value === value)?.label || ''); setSelectedValue(allUsers.find((u) => u.value === value)?.label || '');
}; };
@ -46,8 +52,8 @@ const UserSearch = ({ onUserSelect }: { onUserSelect: (id: any) => void }) => {
onSearch={handleSearch} onSearch={handleSearch}
value={selectedValue} value={selectedValue}
onChange={(e) => { onChange={(e) => {
setSelectedValue(e) setSelectedValue(e);
if (!e) onUserSelect(undefined) if (!e) onUserSelect(undefined);
}} }}
onClear={() => onSelect(undefined)} onClear={() => onSelect(undefined)}
onDeselect={() => onSelect(undefined)} onDeselect={() => onSelect(undefined)}
@ -56,12 +62,12 @@ const UserSearch = ({ onUserSelect }: { onUserSelect: (id: any) => void }) => {
<Input.Search <Input.Search
allowClear allowClear
placeholder="Filter by team member name" placeholder="Filter by team member name"
size={'small'} size="small"
classNames={{ input: '!border-0 focus:!border-0' }} classNames={{ input: '!border-0 focus:!border-0' }}
style={{ width: 200 }} style={{ width: 200 }}
/> />
</AutoComplete> </AutoComplete>
); );
}; }
export default observer(UserSearch); export default observer(UserSearch);

View file

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

View file

@ -28,9 +28,9 @@ export const getPdf2 = async () => {
}).then((canvas) => { }).then((canvas) => {
const imgData = canvas.toDataURL('img/png'); const imgData = canvas.toDataURL('img/png');
let imgWidth = 290; const imgWidth = 290;
let pageHeight = 200; const pageHeight = 200;
let imgHeight = (canvas.height * imgWidth) / canvas.width; const imgHeight = (canvas.height * imgWidth) / canvas.width;
let heightLeft = imgHeight - pageHeight; let heightLeft = imgHeight - pageHeight;
let position = 0; let position = 0;
const A4Height = 295; const A4Height = 295;
@ -38,16 +38,24 @@ export const getPdf2 = async () => {
const logoWidth = 55; const logoWidth = 55;
doc.addImage(imgData, 'PNG', 3, 10, imgWidth, imgHeight); doc.addImage(imgData, 'PNG', 3, 10, imgWidth, imgHeight);
doc.addImage('/assets/img/cobrowising-report-head.png', 'png', A4Height / 2 - headerW / 2, 2, 45, 5); doc.addImage(
if (position === 0 && heightLeft === 0) '/assets/img/cobrowising-report-head.png',
'png',
A4Height / 2 - headerW / 2,
2,
45,
5,
);
if (position === 0 && heightLeft === 0) {
doc.addImage( doc.addImage(
'/assets/img/report-head.png', '/assets/img/report-head.png',
'png', 'png',
imgWidth / 2 - headerW / 2, imgWidth / 2 - headerW / 2,
pageHeight - 5, pageHeight - 5,
logoWidth, logoWidth,
5 5,
); );
}
while (heightLeft >= 0) { while (heightLeft >= 0) {
position = heightLeft - imgHeight; position = heightLeft - imgHeight;
@ -59,12 +67,12 @@ export const getPdf2 = async () => {
A4Height / 2 - headerW / 2, A4Height / 2 - headerW / 2,
pageHeight - 5, pageHeight - 5,
logoWidth, logoWidth,
5 5,
); );
heightLeft -= pageHeight; heightLeft -= pageHeight;
} }
doc.save(fileNameFormat('Assist_Stats_' + Date.now(), '.pdf')); doc.save(fileNameFormat(`Assist_Stats_${Date.now()}`, '.pdf'));
}); });
} }

View file

@ -1,12 +1,8 @@
import React from 'react'; import React from 'react';
import {
DataProps,
buildCategories,
customTooltipFormatter
} from './utils';
import { buildBarDatasetsAndSeries } from './barUtils';
import { defaultOptions, echarts, initWindowStorages } from "./init";
import { BarChart } from 'echarts/charts'; import { BarChart } from 'echarts/charts';
import { DataProps, buildCategories, customTooltipFormatter } from './utils';
import { buildBarDatasetsAndSeries } from './barUtils';
import { defaultOptions, echarts, initWindowStorages } from './init';
echarts.use([BarChart]); echarts.use([BarChart]);
@ -17,21 +13,29 @@ interface BarChartProps extends DataProps {
} }
function ORBarChart(props: BarChartProps) { function ORBarChart(props: BarChartProps) {
const chartUuid = React.useRef<string>(Math.random().toString(36).substring(7)); const chartUuid = React.useRef<string>(
Math.random().toString(36).substring(7),
);
const chartRef = React.useRef<HTMLDivElement>(null); const chartRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => { React.useEffect(() => {
if (!chartRef.current) return; if (!chartRef.current) return;
const chart = echarts.init(chartRef.current); const chart = echarts.init(chartRef.current);
const obs = new ResizeObserver(() => chart.resize()) const obs = new ResizeObserver(() => chart.resize());
obs.observe(chartRef.current); obs.observe(chartRef.current);
const categories = buildCategories(props.data); const categories = buildCategories(props.data);
const { datasets, series } = buildBarDatasetsAndSeries(props); const { datasets, series } = buildBarDatasetsAndSeries(props);
initWindowStorages(chartUuid.current, categories, props.data.chart, props.compData?.chart ?? []); initWindowStorages(
chartUuid.current,
categories,
props.data.chart,
props.compData?.chart ?? [],
);
series.forEach((s: any) => { series.forEach((s: any) => {
(window as any).__seriesColorMap[chartUuid.current][s.name] = s.itemStyle?.color ?? '#999'; (window as any).__seriesColorMap[chartUuid.current][s.name] =
s.itemStyle?.color ?? '#999';
const ds = datasets.find((d) => d.id === s.datasetId); const ds = datasets.find((d) => d.id === s.datasetId);
if (!ds) return; if (!ds) return;
const yDim = s.encode.y; const yDim = s.encode.y;
@ -41,11 +45,11 @@ function ORBarChart(props: BarChartProps) {
(window as any).__seriesValueMap[chartUuid.current][s.name] = {}; (window as any).__seriesValueMap[chartUuid.current][s.name] = {};
ds.source.forEach((row: any[]) => { ds.source.forEach((row: any[]) => {
const rowIdx = row[0]; // 'idx' const rowIdx = row[0]; // 'idx'
(window as any).__seriesValueMap[chartUuid.current][s.name][rowIdx] = row[yDimIndex]; (window as any).__seriesValueMap[chartUuid.current][s.name][rowIdx] =
row[yDimIndex];
}); });
}); });
const xAxis: any = { const xAxis: any = {
type: 'category', type: 'category',
data: categories, data: categories,
@ -62,7 +66,9 @@ function ORBarChart(props: BarChartProps) {
...defaultOptions, ...defaultOptions,
legend: { legend: {
...defaultOptions.legend, ...defaultOptions.legend,
data: series.filter((s: any) => !s._hideInLegend).map((s: any) => s.name), data: series
.filter((s: any) => !s._hideInLegend)
.map((s: any) => s.name),
}, },
tooltip: { tooltip: {
...defaultOptions.tooltip, ...defaultOptions.tooltip,
@ -80,12 +86,14 @@ function ORBarChart(props: BarChartProps) {
}); });
chart.on('click', (event) => { chart.on('click', (event) => {
const index = event.dataIndex; const index = event.dataIndex;
const timestamp = (window as any).__timestampMap?.[chartUuid.current]?.[index]; const timestamp = (window as any).__timestampMap?.[chartUuid.current]?.[
props.onClick?.({ activePayload: [{ payload: { timestamp }}]}) index
];
props.onClick?.({ activePayload: [{ payload: { timestamp } }] });
setTimeout(() => { setTimeout(() => {
props.onSeriesFocus?.(event.seriesName) props.onSeriesFocus?.(event.seriesName);
}, 0) }, 0);
}) });
return () => { return () => {
chart.dispose(); chart.dispose();

View file

@ -1,8 +1,8 @@
import React from 'react'; import React from 'react';
import { defaultOptions, echarts } from './init';
import { BarChart } from 'echarts/charts'; import { BarChart } from 'echarts/charts';
import { defaultOptions, echarts } from './init';
import { customTooltipFormatter } from './utils'; import { customTooltipFormatter } from './utils';
import { buildColumnChart } from './barUtils' import { buildColumnChart } from './barUtils';
echarts.use([BarChart]); echarts.use([BarChart]);
@ -32,7 +32,7 @@ function ColumnChart(props: ColumnChartProps) {
const { data, compData, label } = props; const { data, compData, label } = props;
const chartRef = React.useRef<HTMLDivElement>(null); const chartRef = React.useRef<HTMLDivElement>(null);
const chartUuid = React.useRef<string>( const chartUuid = React.useRef<string>(
Math.random().toString(36).substring(7) Math.random().toString(36).substring(7),
); );
React.useEffect(() => { React.useEffect(() => {
@ -42,10 +42,14 @@ function ColumnChart(props: ColumnChartProps) {
(window as any).__seriesValueMap[chartUuid.current] = {}; (window as any).__seriesValueMap[chartUuid.current] = {};
(window as any).__seriesColorMap = (window as any).__seriesColorMap ?? {}; (window as any).__seriesColorMap = (window as any).__seriesColorMap ?? {};
(window as any).__seriesColorMap[chartUuid.current] = {}; (window as any).__seriesColorMap[chartUuid.current] = {};
(window as any).__yAxisData = (window as any).__yAxisData ?? {} (window as any).__yAxisData = (window as any).__yAxisData ?? {};
const { yAxisData, series } = buildColumnChart(chartUuid.current, data, compData); const { yAxisData, series } = buildColumnChart(
(window as any).__yAxisData[chartUuid.current] = yAxisData chartUuid.current,
data,
compData,
);
(window as any).__yAxisData[chartUuid.current] = yAxisData;
chart.setOption({ chart.setOption({
...defaultOptions, ...defaultOptions,
@ -89,7 +93,7 @@ function ColumnChart(props: ColumnChartProps) {
chart.on('click', (event) => { chart.on('click', (event) => {
const focusedSeriesName = event.name; const focusedSeriesName = event.name;
props.onSeriesFocus?.(focusedSeriesName); props.onSeriesFocus?.(focusedSeriesName);
}) });
return () => { return () => {
chart.dispose(); chart.dispose();

View file

@ -1,8 +1,12 @@
import React from 'react'; import React from 'react';
import { echarts, defaultOptions, initWindowStorages } from './init';
import { customTooltipFormatter, buildCategories, buildDatasetsAndSeries } from './utils'
import type { DataProps } from './utils'
import { LineChart } from 'echarts/charts'; import { LineChart } from 'echarts/charts';
import { echarts, defaultOptions, initWindowStorages } from './init';
import {
customTooltipFormatter,
buildCategories,
buildDatasetsAndSeries,
} from './utils';
import type { DataProps } from './utils';
echarts.use([LineChart]); echarts.use([LineChart]);
@ -16,19 +20,26 @@ interface Props extends DataProps {
} }
function ORLineChart(props: Props) { function ORLineChart(props: Props) {
const chartUuid = React.useRef<string>(Math.random().toString(36).substring(7)); const chartUuid = React.useRef<string>(
Math.random().toString(36).substring(7),
);
const chartRef = React.useRef<HTMLDivElement>(null); const chartRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => { React.useEffect(() => {
if (!chartRef.current) return; if (!chartRef.current) return;
const chart = echarts.init(chartRef.current); const chart = echarts.init(chartRef.current);
const obs = new ResizeObserver(() => chart.resize()) const obs = new ResizeObserver(() => chart.resize());
obs.observe(chartRef.current); obs.observe(chartRef.current);
const categories = buildCategories(props.data); const categories = buildCategories(props.data);
const { datasets, series } = buildDatasetsAndSeries(props); const { datasets, series } = buildDatasetsAndSeries(props);
initWindowStorages(chartUuid.current, categories, props.data.chart, props.compData?.chart ?? []); initWindowStorages(
chartUuid.current,
categories,
props.data.chart,
props.compData?.chart ?? [],
);
series.forEach((s: any) => { series.forEach((s: any) => {
if (props.isArea) { if (props.isArea) {
@ -37,7 +48,8 @@ function ORLineChart(props: Props) {
} else { } else {
s.areaStyle = null; s.areaStyle = null;
} }
(window as any).__seriesColorMap[chartUuid.current][s.name] = s.itemStyle?.color ?? '#999'; (window as any).__seriesColorMap[chartUuid.current][s.name] =
s.itemStyle?.color ?? '#999';
const datasetId = s.datasetId || 'current'; const datasetId = s.datasetId || 'current';
const ds = datasets.find((d) => d.id === datasetId); const ds = datasets.find((d) => d.id === datasetId);
if (!ds) return; if (!ds) return;
@ -48,20 +60,23 @@ function ORLineChart(props: Props) {
(window as any).__seriesValueMap[chartUuid.current][s.name] = {}; (window as any).__seriesValueMap[chartUuid.current][s.name] = {};
ds.source.forEach((row: any[]) => { ds.source.forEach((row: any[]) => {
const rowIdx = row[0]; const rowIdx = row[0];
(window as any).__seriesValueMap[chartUuid.current][s.name][rowIdx] = row[yDimIndex]; (window as any).__seriesValueMap[chartUuid.current][s.name][rowIdx] =
row[yDimIndex];
}); });
}); });
chart.setOption({ chart.setOption({
...defaultOptions, ...defaultOptions,
title: { title: {
text: props.chartName ?? "Line Chart", text: props.chartName ?? 'Line Chart',
show: false, show: false,
}, },
legend: { legend: {
...defaultOptions.legend, ...defaultOptions.legend,
// Only show legend for “current” series // Only show legend for “current” series
data: series.filter((s: any) => !s._hideInLegend).map((s: any) => s.name), data: series
.filter((s: any) => !s._hideInLegend)
.map((s: any) => s.name),
}, },
xAxis: { xAxis: {
type: 'category', type: 'category',
@ -75,7 +90,7 @@ function ORLineChart(props: Props) {
nameTextStyle: { nameTextStyle: {
padding: [0, 0, 0, 15], padding: [0, 0, 0, 15],
}, },
minInterval: 1 minInterval: 1,
}, },
tooltip: { tooltip: {
...defaultOptions.tooltip, ...defaultOptions.tooltip,
@ -91,12 +106,14 @@ function ORLineChart(props: Props) {
}); });
chart.on('click', (event) => { chart.on('click', (event) => {
const index = event.dataIndex; const index = event.dataIndex;
const timestamp = (window as any).__timestampMap?.[chartUuid.current]?.[index]; const timestamp = (window as any).__timestampMap?.[chartUuid.current]?.[
props.onClick?.({ activePayload: [{ payload: { timestamp }}]}) index
];
props.onClick?.({ activePayload: [{ payload: { timestamp } }] });
setTimeout(() => { setTimeout(() => {
props.onSeriesFocus?.(event.seriesName) props.onSeriesFocus?.(event.seriesName);
}, 0) }, 0);
}) });
return () => { return () => {
chart.dispose(); chart.dispose();

View file

@ -1,7 +1,11 @@
import React, { useEffect, useRef } from 'react'; import React, { useEffect, useRef } from 'react';
import { PieChart as EchartsPieChart } from 'echarts/charts'; import { PieChart as EchartsPieChart } from 'echarts/charts';
import { echarts, defaultOptions } from './init'; import { echarts, defaultOptions } from './init';
import { buildPieData, pieTooltipFormatter, pickColorByIndex } from './pieUtils'; import {
buildPieData,
pieTooltipFormatter,
pickColorByIndex,
} from './pieUtils';
echarts.use([EchartsPieChart]); echarts.use([EchartsPieChart]);
@ -28,7 +32,8 @@ function PieChart(props: PieChartProps) {
useEffect(() => { useEffect(() => {
if (!chartRef.current) return; if (!chartRef.current) return;
if (!data.chart || data.chart.length === 0) { if (!data.chart || data.chart.length === 0) {
chartRef.current.innerHTML = `<div style="text-align:center;padding:20px;">No data available</div>`; chartRef.current.innerHTML =
'<div style="text-align:center;padding:20px;">No data available</div>';
return; return;
} }
@ -36,7 +41,8 @@ function PieChart(props: PieChartProps) {
const pieData = buildPieData(data.chart, data.namesMap); const pieData = buildPieData(data.chart, data.namesMap);
if (!pieData.length) { if (!pieData.length) {
chartRef.current.innerHTML = `<div style="text-align:center;padding:20px;">No data available</div>`; chartRef.current.innerHTML =
'<div style="text-align:center;padding:20px;">No data available</div>';
return; return;
} }
@ -75,28 +81,24 @@ function PieChart(props: PieChartProps) {
name: label ?? 'Data', name: label ?? 'Data',
radius: [50, 100], radius: [50, 100],
center: ['50%', '55%'], center: ['50%', '55%'],
data: pieData.map((d, idx) => { data: pieData.map((d, idx) => ({
return { name: d.name,
name: d.name, value: d.value,
value: d.value, label: {
label: { show: false, // d.value / largestVal >= 0.03,
show: false, //d.value / largestVal >= 0.03, position: 'outside',
position: 'outside', formatter: (params: any) => params.value,
formatter: (params: any) => { },
return params.value; labelLine: {
}, show: false, // d.value / largestVal >= 0.03,
}, length: 10,
labelLine: { length2: 20,
show: false, // d.value / largestVal >= 0.03, lineStyle: { color: '#3EAAAF' },
length: 10, },
length2: 20, itemStyle: {
lineStyle: { color: '#3EAAAF' }, color: pickColorByIndex(idx),
}, },
itemStyle: { })),
color: pickColorByIndex(idx),
},
};
}),
emphasis: { emphasis: {
scale: true, scale: true,
scaleSize: 4, scaleSize: 4,
@ -106,11 +108,11 @@ function PieChart(props: PieChartProps) {
}; };
chartInstance.setOption(option); chartInstance.setOption(option);
const obs = new ResizeObserver(() => chartInstance.resize()) const obs = new ResizeObserver(() => chartInstance.resize());
obs.observe(chartRef.current); obs.observe(chartRef.current);
chartInstance.on('click', function (params) { chartInstance.on('click', (params) => {
const focusedSeriesName = params.name const focusedSeriesName = params.name;
props.onSeriesFocus?.(focusedSeriesName); props.onSeriesFocus?.(focusedSeriesName);
}); });
@ -121,7 +123,10 @@ function PieChart(props: PieChartProps) {
}, [data, label, onClick, inGrid]); }, [data, label, onClick, inGrid]);
return ( return (
<div style={{ width: '100%', height: 240, position: 'relative' }} ref={chartRef} /> <div
style={{ width: '100%', height: 240, position: 'relative' }}
ref={chartRef}
/>
); );
} }

View file

@ -1,9 +1,10 @@
import React from 'react'; import React from 'react';
import { echarts, defaultOptions } from './init';
import { SankeyChart } from 'echarts/charts'; import { SankeyChart } from 'echarts/charts';
import { sankeyTooltip, getEventPriority, getNodeName } from './sankeyUtils';
import { NoContent } from 'App/components/ui'; import { NoContent } from 'App/components/ui';
import { InfoCircleOutlined } from '@ant-design/icons'; import { InfoCircleOutlined } from '@ant-design/icons';
import { sankeyTooltip, getEventPriority, getNodeName } from './sankeyUtils';
import { echarts, defaultOptions } from './init';
import { useTranslation } from 'react-i18next';
echarts.use([SankeyChart]); echarts.use([SankeyChart]);
@ -36,6 +37,7 @@ interface Props {
} }
const EChartsSankey: React.FC<Props> = (props) => { const EChartsSankey: React.FC<Props> = (props) => {
const { t } = useTranslation();
const { data, height = 240, onChartClick, isUngrouped } = props; const { data, height = 240, onChartClick, isUngrouped } = props;
const chartRef = React.useRef<HTMLDivElement>(null); const chartRef = React.useRef<HTMLDivElement>(null);
@ -44,8 +46,8 @@ const EChartsSankey: React.FC<Props> = (props) => {
React.useEffect(() => { React.useEffect(() => {
if (!chartRef.current || data.nodes.length === 0 || data.links.length === 0) return; if (!chartRef.current || data.nodes.length === 0 || data.links.length === 0) return;
let finalNodes = data.nodes; const finalNodes = data.nodes;
let finalLinks = data.links; const finalLinks = data.links;
const chart = echarts.init(chartRef.current); const chart = echarts.init(chartRef.current);
@ -77,8 +79,8 @@ const EChartsSankey: React.FC<Props> = (props) => {
computedName === 'Others' computedName === 'Others'
? 'rgba(34,44,154,.9)' ? 'rgba(34,44,154,.9)'
: n.eventType === 'DROP' : n.eventType === 'DROP'
? '#B5B7C8' ? '#B5B7C8'
: '#394eff'; : '#394eff';
return { return {
name: computedName, name: computedName,
@ -94,9 +96,8 @@ const EChartsSankey: React.FC<Props> = (props) => {
return ( return (
getEventPriority(a.type || '') - getEventPriority(b.type || '') getEventPriority(a.type || '') - getEventPriority(b.type || '')
); );
} else {
return (a.depth as number) - (b.depth as number);
} }
return (a.depth as number) - (b.depth as number);
}); });
const echartLinks = filteredLinks.map((l) => ({ const echartLinks = filteredLinks.map((l) => ({
@ -158,22 +159,23 @@ const EChartsSankey: React.FC<Props> = (props) => {
maxWidth: 30, maxWidth: 30,
distance: 3, distance: 3,
offset: [-20, 0], offset: [-20, 0],
formatter: function (params: any) { formatter(params: any) {
const nodeVal = params.value; const nodeVal = params.value;
const percentage = startNodeValue const percentage = startNodeValue
? ((nodeVal / startNodeValue) * 100).toFixed(1) + '%' ? `${((nodeVal / startNodeValue) * 100).toFixed(1)}%`
: '0%'; : '0%';
const maxLen = 20; const maxLen = 20;
const safeName = const safeName =
params.name.length > maxLen params.name.length > maxLen
? params.name.slice(0, maxLen / 2 - 2) + ? `${params.name.slice(
'...' + 0,
params.name.slice(-(maxLen / 2 - 2)) maxLen / 2 - 2,
)}...${params.name.slice(-(maxLen / 2 - 2))}`
: params.name; : params.name;
const nodeType = params.data.type; const nodeType = params.data.type;
const icon = getIcon(nodeType) const icon = getIcon(nodeType);
return ( return (
`${icon}{header| ${safeName}}\n` + `${icon}{header| ${safeName}}\n` +
`{body|}{percentage|${percentage}} {sessions|${nodeVal}}` `{body|}{percentage|${percentage}} {sessions|${nodeVal}}`
@ -208,46 +210,52 @@ const EChartsSankey: React.FC<Props> = (props) => {
}, },
clickIcon: { clickIcon: {
backgroundColor: { backgroundColor: {
image: 'data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22currentColor%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%20class%3D%22lucide%20lucide-pointer%22%3E%3Cpath%20d%3D%22M22%2014a8%208%200%200%201-8%208%22%2F%3E%3Cpath%20d%3D%22M18%2011v-1a2%202%200%200%200-2-2a2%202%200%200%200-2%202%22%2F%3E%3Cpath%20d%3D%22M14%2010V9a2%202%200%200%200-2-2a2%202%200%200%200-2%202v1%22%2F%3E%3Cpath%20d%3D%22M10%209.5V4a2%202%200%200%200-2-2a2%202%200%200%200-2%202v10%22%2F%3E%3Cpath%20d%3D%22M18%2011a2%202%200%201%201%204%200v3a8%208%200%200%201-8%208h-2c-2.8%200-4.5-.86-5.99-2.34l-3.6-3.6a2%202%200%200%201%202.83-2.82L7%2015%22%2F%3E%3C%2Fsvg%3E', image:
'data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22currentColor%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%20class%3D%22lucide%20lucide-pointer%22%3E%3Cpath%20d%3D%22M22%2014a8%208%200%200%201-8%208%22%2F%3E%3Cpath%20d%3D%22M18%2011v-1a2%202%200%200%200-2-2a2%202%200%200%200-2%202%22%2F%3E%3Cpath%20d%3D%22M14%2010V9a2%202%200%200%200-2-2a2%202%200%200%200-2%202v1%22%2F%3E%3Cpath%20d%3D%22M10%209.5V4a2%202%200%200%200-2-2a2%202%200%200%200-2%202v10%22%2F%3E%3Cpath%20d%3D%22M18%2011a2%202%200%201%201%204%200v3a8%208%200%200%201-8%208h-2c-2.8%200-4.5-.86-5.99-2.34l-3.6-3.6a2%202%200%200%201%202.83-2.82L7%2015%22%2F%3E%3C%2Fsvg%3E',
}, },
height: 20, height: 20,
width: 14, width: 14,
}, },
locationIcon: { locationIcon: {
backgroundColor: { backgroundColor: {
image: 'data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22currentColor%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%20class%3D%22lucide%20lucide-navigation%22%3E%3Cpolygon%20points%3D%223%2011%2022%202%2013%2021%2011%2013%203%2011%22%2F%3E%3C%2Fsvg%3E', image:
'data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22currentColor%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%20class%3D%22lucide%20lucide-navigation%22%3E%3Cpolygon%20points%3D%223%2011%2022%202%2013%2021%2011%2013%203%2011%22%2F%3E%3C%2Fsvg%3E',
}, },
height: 20, height: 20,
width: 14, width: 14,
}, },
inputIcon: { inputIcon: {
backgroundColor: { backgroundColor: {
image: 'data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22currentColor%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%20class%3D%22lucide%20lucide-rectangle-ellipsis%22%3E%3Crect%20width%3D%2220%22%20height%3D%2212%22%20x%3D%222%22%20y%3D%226%22%20rx%3D%222%22%2F%3E%3Cpath%20d%3D%22M12%2012h.01%22%2F%3E%3Cpath%20d%3D%22M17%2012h.01%22%2F%3E%3Cpath%20d%3D%22M7%2012h.01%22%2F%3E%3C%2Fsvg%3E', image:
'data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22currentColor%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%20class%3D%22lucide%20lucide-rectangle-ellipsis%22%3E%3Crect%20width%3D%2220%22%20height%3D%2212%22%20x%3D%222%22%20y%3D%226%22%20rx%3D%222%22%2F%3E%3Cpath%20d%3D%22M12%2012h.01%22%2F%3E%3Cpath%20d%3D%22M17%2012h.01%22%2F%3E%3Cpath%20d%3D%22M7%2012h.01%22%2F%3E%3C%2Fsvg%3E',
}, },
height: 20, height: 20,
width: 14, width: 14,
}, },
customEventIcon: { customEventIcon: {
backgroundColor: { backgroundColor: {
image: 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGNsYXNzPSJsdWNpZGUgbHVjaWRlLWNvZGUiPjxwb2x5bGluZSBwb2ludHM9IjE2IDE4IDIyIDEyIDE2IDYiLz48cG9seWxpbmUgcG9pbnRzPSI4IDYgMiAxMiA4IDE4Ii8+PC9zdmc+' image:
'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGNsYXNzPSJsdWNpZGUgbHVjaWRlLWNvZGUiPjxwb2x5bGluZSBwb2ludHM9IjE2IDE4IDIyIDEyIDE2IDYiLz48cG9seWxpbmUgcG9pbnRzPSI4IDYgMiAxMiA4IDE4Ii8+PC9zdmc+',
}, },
height: 20, height: 20,
width: 14, width: 14,
}, },
dropEventIcon: { dropEventIcon: {
backgroundColor: { backgroundColor: {
image: 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGNsYXNzPSJsdWNpZGUgbHVjaWRlLWNpcmNsZS1hcnJvdy1kb3duIj48Y2lyY2xlIGN4PSIxMiIgY3k9IjEyIiByPSIxMCIvPjxwYXRoIGQ9Ik0xMiA4djgiLz48cGF0aCBkPSJtOCAxMiA0IDQgNC00Ii8+PC9zdmc+', image:
'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGNsYXNzPSJsdWNpZGUgbHVjaWRlLWNpcmNsZS1hcnJvdy1kb3duIj48Y2lyY2xlIGN4PSIxMiIgY3k9IjEyIiByPSIxMCIvPjxwYXRoIGQ9Ik0xMiA4djgiLz48cGF0aCBkPSJtOCAxMiA0IDQgNC00Ii8+PC9zdmc+',
}, },
height: 20, height: 20,
width: 14, width: 14,
}, },
groupIcon: { groupIcon: {
backgroundColor: { backgroundColor: {
image: 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGNsYXNzPSJsdWNpZGUgbHVjaWRlLWNvbXBvbmVudCI+PHBhdGggZD0iTTE1LjUzNiAxMS4yOTNhMSAxIDAgMCAwIDAgMS40MTRsMi4zNzYgMi4zNzdhMSAxIDAgMCAwIDEuNDE0IDBsMi4zNzctMi4zNzdhMSAxIDAgMCAwIDAtMS40MTRsLTIuMzc3LTIuMzc3YTEgMSAwIDAgMC0xLjQxNCAweiIvPjxwYXRoIGQ9Ik0yLjI5NyAxMS4yOTNhMSAxIDAgMCAwIDAgMS40MTRsMi4zNzcgMi4zNzdhMSAxIDAgMCAwIDEuNDE0IDBsMi4zNzctMi4zNzdhMSAxIDAgMCAwIDAtMS40MTRMNi4wODggOC45MTZhMSAxIDAgMCAwLTEuNDE0IDB6Ii8+PHBhdGggZD0iTTguOTE2IDE3LjkxMmExIDEgMCAwIDAgMCAxLjQxNWwyLjM3NyAyLjM3NmExIDEgMCAwIDAgMS40MTQgMGwyLjM3Ny0yLjM3NmExIDEgMCAwIDAgMC0xLjQxNWwtMi4zNzctMi4zNzZhMSAxIDAgMCAwLTEuNDE0IDB6Ii8+PHBhdGggZD0iTTguOTE2IDQuNjc0YTEgMSAwIDAgMCAwIDEuNDE0bDIuMzc3IDIuMzc2YTEgMSAwIDAgMCAxLjQxNCAwbDIuMzc3LTIuMzc2YTEgMSAwIDAgMCAwLTEuNDE0bC0yLjM3Ny0yLjM3N2ExIDEgMCAwIDAtMS40MTQgMHoiLz48L3N2Zz4=', image:
'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGNsYXNzPSJsdWNpZGUgbHVjaWRlLWNvbXBvbmVudCI+PHBhdGggZD0iTTE1LjUzNiAxMS4yOTNhMSAxIDAgMCAwIDAgMS40MTRsMi4zNzYgMi4zNzdhMSAxIDAgMCAwIDEuNDE0IDBsMi4zNzctMi4zNzdhMSAxIDAgMCAwIDAtMS40MTRsLTIuMzc3LTIuMzc3YTEgMSAwIDAgMC0xLjQxNCAweiIvPjxwYXRoIGQ9Ik0yLjI5NyAxMS4yOTNhMSAxIDAgMCAwIDAgMS40MTRsMi4zNzcgMi4zNzdhMSAxIDAgMCAwIDEuNDE0IDBsMi4zNzctMi4zNzdhMSAxIDAgMCAwIDAtMS40MTRMNi4wODggOC45MTZhMSAxIDAgMCAwLTEuNDE0IDB6Ii8+PHBhdGggZD0iTTguOTE2IDE3LjkxMmExIDEgMCAwIDAgMCAxLjQxNWwyLjM3NyAyLjM3NmExIDEgMCAwIDAgMS40MTQgMGwyLjM3Ny0yLjM3NmExIDEgMCAwIDAgMC0xLjQxNWwtMi4zNzctMi4zNzZhMSAxIDAgMCAwLTEuNDE0IDB6Ii8+PHBhdGggZD0iTTguOTE2IDQuNjc0YTEgMSAwIDAgMCAwIDEuNDE0bDIuMzc3IDIuMzc2YTEgMSAwIDAgMCAxLjQxNCAwbDIuMzc3LTIuMzc2YTEgMSAwIDAgMCAwLTEuNDE0bC0yLjM3Ny0yLjM3N2ExIDEgMCAwIDAtMS40MTQgMHoiLz48L3N2Zz4=',
}, },
height: 20, height: 20,
width: 14, width: 14,
} },
}, },
}, },
tooltip: { tooltip: {
@ -302,7 +310,7 @@ const EChartsSankey: React.FC<Props> = (props) => {
const originalNodes = [...echartNodes]; const originalNodes = [...echartNodes];
const originalLinks = [...echartLinks]; const originalLinks = [...echartLinks];
chart.on('mouseover', function (params: any) { chart.on('mouseover', (params: any) => {
if (params.dataType === 'node') { if (params.dataType === 'node') {
const hoveredIndex = params.dataIndex; const hoveredIndex = params.dataIndex;
const connectedChain = getConnectedChain(hoveredIndex); const connectedChain = getConnectedChain(hoveredIndex);
@ -345,7 +353,7 @@ const EChartsSankey: React.FC<Props> = (props) => {
} }
}); });
chart.on('mouseout', function (params: any) { chart.on('mouseout', (params: any) => {
if (params.dataType === 'node') { if (params.dataType === 'node') {
chart.setOption({ chart.setOption({
series: [ series: [
@ -358,7 +366,7 @@ const EChartsSankey: React.FC<Props> = (props) => {
} }
}); });
chart.on('click', function (params: any) { chart.on('click', (params: any) => {
if (!onChartClick) return; if (!onChartClick) return;
const unsupported = ['other', 'drop']; const unsupported = ['other', 'drop'];
@ -372,7 +380,7 @@ const EChartsSankey: React.FC<Props> = (props) => {
} }
filters.push({ filters.push({
operator: 'is', operator: 'is',
type: type, type,
value: [node.name], value: [node.name],
isEvent: true, isEvent: true,
}); });
@ -456,25 +464,28 @@ const EChartsSankey: React.FC<Props> = (props) => {
} }
return ( return (
<div style={{ maxHeight: 620, overflow: 'auto', maxWidth: 1240, minHeight: 240 }}> <div
<div style={{
ref={chartRef} maxHeight: 620,
style={containerStyle} overflow: 'auto',
className="min-w-[600px]" maxWidth: 1240,
/> minHeight: 240,
}}
>
<div ref={chartRef} style={containerStyle} className="min-w-[600px]" />
</div> </div>
); );
}; };
function getIcon(type: string) { function getIcon(type: string) {
if (type === 'LOCATION') { if (type === 'LOCATION') {
return '{locationIcon|}' return '{locationIcon|}';
} }
if (type === 'INPUT') { if (type === 'INPUT') {
return '{inputIcon|}' return '{inputIcon|}';
} }
if (type === 'CUSTOM_EVENT') { if (type === 'CUSTOM_EVENT') {
return '{customEventIcon|}' return '{customEventIcon|}';
} }
if (type === 'CLICK') { if (type === 'CLICK') {
return '{clickIcon|}'; return '{clickIcon|}';
@ -485,7 +496,7 @@ function getIcon(type: string) {
if (type === 'OTHER') { if (type === 'OTHER') {
return '{groupIcon|}'; return '{groupIcon|}';
} }
return '' return '';
} }
export default EChartsSankey; export default EChartsSankey;

View file

@ -1,5 +1,9 @@
import type { DataProps, DataItem } from './utils'; import type { DataProps, DataItem } from './utils';
import { createDataset, assignColorsByBaseName, assignColorsByCategory } from './utils'; import {
createDataset,
assignColorsByBaseName,
assignColorsByCategory,
} from './utils';
export function createBarSeries( export function createBarSeries(
data: DataProps['data'], data: DataProps['data'],
@ -13,7 +17,9 @@ export function createBarSeries(
const encode = { x: 'idx', y: fullName }; const encode = { x: 'idx', y: fullName };
const borderRadius = [6, 6, 0, 0]; const borderRadius = [6, 6, 0, 0];
const decal = dashed ? { symbol: 'line', symbolSize: 10, rotation: 1 } : { symbol: 'none' }; const decal = dashed
? { symbol: 'line', symbolSize: 10, rotation: 1 }
: { symbol: 'none' };
return { return {
name: fullName, name: fullName,
_baseName: baseName, _baseName: baseName,
@ -47,7 +53,6 @@ export function buildBarDatasetsAndSeries(props: DataProps) {
return { datasets, series }; return { datasets, series };
} }
// START GEN // START GEN
function sumSeries(chart: DataItem[], seriesName: string): number { function sumSeries(chart: DataItem[], seriesName: string): number {
return chart.reduce((acc, row) => acc + (Number(row[seriesName]) || 0), 0); return chart.reduce((acc, row) => acc + (Number(row[seriesName]) || 0), 0);
@ -62,7 +67,7 @@ function sumSeries(chart: DataItem[], seriesName: string): number {
export function buildColumnChart( export function buildColumnChart(
chartUuid: string, chartUuid: string,
data: DataProps['data'], data: DataProps['data'],
compData: DataProps['compData'] compData: DataProps['compData'],
) { ) {
const categories = data.namesMap.filter(Boolean); const categories = data.namesMap.filter(Boolean);
@ -114,7 +119,9 @@ export function buildColumnChart(
}; };
} }
const series = previousSeries ? [currentSeries, previousSeries] : [currentSeries]; const series = previousSeries
? [currentSeries, previousSeries]
: [currentSeries];
assignColorsByCategory(series, categories); assignColorsByCategory(series, categories);

View file

@ -18,7 +18,7 @@ echarts.use([
LegendComponent, LegendComponent,
// TransformComponent, // TransformComponent,
SVGRenderer, SVGRenderer,
ToolboxComponent ToolboxComponent,
]); ]);
const defaultOptions = { const defaultOptions = {
@ -38,9 +38,9 @@ const defaultOptions = {
type: 'cross', type: 'cross',
snap: true, snap: true,
label: { label: {
backgroundColor: '#6a7985' backgroundColor: '#6a7985',
}, },
} },
}, },
grid: { grid: {
bottom: 20, bottom: 20,
@ -56,18 +56,23 @@ const defaultOptions = {
feature: { feature: {
saveAsImage: { saveAsImage: {
pixelRatio: 1.5, pixelRatio: 1.5,
} },
} },
}, },
legend: { legend: {
type: 'plain', type: 'plain',
show: true, show: true,
top: 10, top: 10,
icon: 'pin' icon: 'pin',
}, },
} };
export function initWindowStorages(chartUuid: string, categories: string[] = [], chartArr: any[] = [], compChartArr: any[] = []) { export function initWindowStorages(
chartUuid: string,
categories: string[] = [],
chartArr: any[] = [],
compChartArr: any[] = [],
) {
(window as any).__seriesValueMap = (window as any).__seriesValueMap ?? {}; (window as any).__seriesValueMap = (window as any).__seriesValueMap ?? {};
(window as any).__seriesColorMap = (window as any).__seriesColorMap ?? {}; (window as any).__seriesColorMap = (window as any).__seriesColorMap ?? {};
(window as any).__timestampMap = (window as any).__timestampMap ?? {}; (window as any).__timestampMap = (window as any).__timestampMap ?? {};
@ -84,11 +89,15 @@ export function initWindowStorages(chartUuid: string, categories: string[] = [],
(window as any).__categoryMap[chartUuid] = categories; (window as any).__categoryMap[chartUuid] = categories;
} }
if (!(window as any).__timestampMap[chartUuid]) { if (!(window as any).__timestampMap[chartUuid]) {
(window as any).__timestampMap[chartUuid] = chartArr.map((item) => item.timestamp); (window as any).__timestampMap[chartUuid] = chartArr.map(
(item) => item.timestamp,
);
} }
if (!(window as any).__timestampCompMap[chartUuid]) { if (!(window as any).__timestampCompMap[chartUuid]) {
(window as any).__timestampCompMap[chartUuid] = compChartArr.map((item) => item.timestamp); (window as any).__timestampCompMap[chartUuid] = compChartArr.map(
(item) => item.timestamp,
);
} }
} }
export { echarts, defaultOptions }; export { echarts, defaultOptions };

View file

@ -1,10 +1,9 @@
import { colors } from './utils';
import { numberWithCommas } from 'App/utils'; import { numberWithCommas } from 'App/utils';
import { colors } from './utils';
export function buildPieData( export function buildPieData(
chart: Array<Record<string, any>>, chart: Array<Record<string, any>>,
namesMap: string[] namesMap: string[],
) { ) {
const result: { name: string; value: number }[] = namesMap.map((name) => { const result: { name: string; value: number }[] = namesMap.map((name) => {
let sum = 0; let sum = 0;
@ -28,4 +27,4 @@ export function pieTooltipFormatter(params: any) {
export function pickColorByIndex(idx: number) { export function pickColorByIndex(idx: number) {
return colors[idx % colors.length]; return colors[idx % colors.length];
} }

View file

@ -1,6 +1,6 @@
export function sankeyTooltip( export function sankeyTooltip(
echartNodes: any[], echartNodes: any[],
nodeValues: Record<string, number> nodeValues: Record<string, number>,
) { ) {
return (params: any) => { return (params: any) => {
if ('source' in params.data && 'target' in params.data) { if ('source' in params.data && 'target' in params.data) {
@ -25,8 +25,8 @@ export function sankeyTooltip(
</div> </div>
<div class="flex items-baseline gap-2 text-black"> <div class="flex items-baseline gap-2 text-black">
<span>${params.data.value} ( ${params.data.percentage.toFixed( <span>${params.data.value} ( ${params.data.percentage.toFixed(
2 2,
)}% )</span> )}% )</span>
<span class="text-disabled-text">Sessions</span> <span class="text-disabled-text">Sessions</span>
</div> </div>
</div> </div>
@ -53,7 +53,7 @@ const shortenString = (str: string) => {
str.length > limit str.length > limit
? `${str.slice(0, leftPart)}...${str.slice( ? `${str.slice(0, leftPart)}...${str.slice(
str.length - rightPart, str.length - rightPart,
str.length str.length,
)}` )}`
: str; : str;
@ -73,7 +73,7 @@ export const getEventPriority = (type: string): number => {
export const getNodeName = ( export const getNodeName = (
eventType: string, eventType: string,
nodeName: string | null nodeName: string | null,
): string => { ): string => {
if (!nodeName) { if (!nodeName) {
return eventType.charAt(0) + eventType.slice(1).toLowerCase(); return eventType.charAt(0) + eventType.slice(1).toLowerCase();

View file

@ -52,10 +52,7 @@ function buildCategoryColorMap(categories: string[]): Record<number, string> {
* For each series, transform its data array to an array of objects * For each series, transform its data array to an array of objects
* with `value` and `itemStyle.color` based on the category index. * with `value` and `itemStyle.color` based on the category index.
*/ */
export function assignColorsByCategory( export function assignColorsByCategory(series: any[], categories: string[]) {
series: any[],
categories: string[]
) {
const categoryColorMap = buildCategoryColorMap(categories); const categoryColorMap = buildCategoryColorMap(categories);
series.forEach((s, si) => { series.forEach((s, si) => {
@ -94,7 +91,9 @@ export function customTooltipFormatter(uuid: string) {
const isPrevious = /Previous/.test(seriesName); const isPrevious = /Previous/.test(seriesName);
const categoryName = (window as any).__yAxisData?.[uuid]?.[dataIndex]; const categoryName = (window as any).__yAxisData?.[uuid]?.[dataIndex];
const fullname = isPrevious ? `Previous ${categoryName}` : categoryName; const fullname = isPrevious ? `Previous ${categoryName}` : categoryName;
const partnerName = isPrevious ? categoryName : `Previous ${categoryName}`; const partnerName = isPrevious
? categoryName
: `Previous ${categoryName}`;
const partnerValue = (window as any).__seriesValueMap?.[uuid]?.[ const partnerValue = (window as any).__seriesValueMap?.[uuid]?.[
partnerName partnerName
]; ];
@ -279,7 +278,7 @@ export function createSeries(
data: DataProps['data'], data: DataProps['data'],
datasetId: string, datasetId: string,
dashed: boolean, dashed: boolean,
hideFromLegend: boolean hideFromLegend: boolean,
) { ) {
return data.namesMap.filter(Boolean).map((fullName) => { return data.namesMap.filter(Boolean).map((fullName) => {
const baseName = fullName.replace(/^Previous\s+/, ''); const baseName = fullName.replace(/^Previous\s+/, '');

View file

@ -1,53 +1,60 @@
import React from 'react'; import React from 'react';
import { JSONTree } from 'UI'; import { JSONTree } from 'UI';
import { checkForRecent } from 'App/date'; import { checkForRecent } from 'App/date';
import { useTranslation } from 'react-i18next';
interface Props { interface Props {
audit: any; audit: any;
} }
function AuditDetailModal(props: Props) { function AuditDetailModal(props: Props) {
const { audit } = props; const { t } = useTranslation();
// const jsonResponse = typeof audit.payload === 'string' ? JSON.parse(audit.payload) : audit.payload; const { audit } = props;
// console.log('jsonResponse', jsonResponse) // const jsonResponse = typeof audit.payload === 'string' ? JSON.parse(audit.payload) : audit.payload;
// console.log('jsonResponse', jsonResponse)
return ( return (
<div className="bg-white h-screen overflow-y-auto"> <div className="bg-white h-screen overflow-y-auto">
<h1 className="text-2xl p-4">Audit Details</h1> <h1 className="text-2xl p-4">{t('Audit Details')}</h1>
<div className="p-4"> <div className="p-4">
<h5 className="mb-2">{ 'URL'}</h5> <h5 className="mb-2">{t('URL')}</h5>
<div className="color-gray-darkest p-2 bg-gray-lightest rounded">{ audit.endPoint }</div> <div className="color-gray-darkest p-2 bg-gray-lightest rounded">
{audit.endPoint}
<div className="grid grid-cols-2 my-6">
<div className="">
<div className="font-medium mb-2">Username</div>
<div>{audit.username}</div>
</div>
<div className="">
<div className="font-medium mb-2">Created At</div>
<div>{audit.createdAt && checkForRecent(audit.createdAt, 'LLL dd, yyyy, hh:mm a')}</div>
</div>
</div>
<div className="grid grid-cols-2 my-6">
<div className="">
<div className="font-medium mb-2">Action</div>
<div>{audit.action}</div>
</div>
<div className="">
<div className="font-medium mb-2">Method</div>
<div>{audit.method}</div>
</div>
</div>
{ audit.payload && (
<div className="my-6">
<div className="font-medium mb-3">Payload</div>
<JSONTree src={ audit.payload } collapsed={ false } enableClipboard />
</div>
)}
</div>
</div> </div>
);
<div className="grid grid-cols-2 my-6">
<div className="">
<div className="font-medium mb-2">{t('Username')}</div>
<div>{audit.username}</div>
</div>
<div className="">
<div className="font-medium mb-2">{t('Created At')}</div>
<div>
{audit.createdAt &&
checkForRecent(audit.createdAt, 'LLL dd, yyyy, hh:mm a')}
</div>
</div>
</div>
<div className="grid grid-cols-2 my-6">
<div className="">
<div className="font-medium mb-2">{t('Action')}</div>
<div>{audit.action}</div>
</div>
<div className="">
<div className="font-medium mb-2">{t('Method')}</div>
<div>{audit.method}</div>
</div>
</div>
{audit.payload && (
<div className="my-6">
<div className="font-medium mb-3">{t('Payload')}</div>
<JSONTree src={audit.payload} collapsed={false} enableClipboard />
</div>
)}
</div>
</div>
);
} }
export default AuditDetailModal; export default AuditDetailModal;

View file

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

View file

@ -3,73 +3,78 @@ import { useStore } from 'App/mstore';
import { useObserver } from 'mobx-react-lite'; import { useObserver } from 'mobx-react-lite';
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import { Loader, Pagination, NoContent } from 'UI'; import { Loader, Pagination, NoContent } from 'UI';
import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG';
import AuditDetailModal from '../AuditDetailModal'; import AuditDetailModal from '../AuditDetailModal';
import AuditListItem from '../AuditListItem'; import AuditListItem from '../AuditListItem';
import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG'; import { useTranslation } from 'react-i18next';
interface Props { interface Props {}
}
function AuditList(props: Props) { function AuditList(props: Props) {
const { auditStore } = useStore(); const { t } = useTranslation();
const loading = useObserver(() => auditStore.isLoading); const { auditStore } = useStore();
const list = useObserver(() => auditStore.list); const loading = useObserver(() => auditStore.isLoading);
const searchQuery = useObserver(() => auditStore.searchQuery); const list = useObserver(() => auditStore.list);
const page = useObserver(() => auditStore.page); const searchQuery = useObserver(() => auditStore.searchQuery);
const order = useObserver(() => auditStore.order); const page = useObserver(() => auditStore.page);
const period = useObserver(() => auditStore.period); const order = useObserver(() => auditStore.order);
const { showModal } = useModal(); const period = useObserver(() => auditStore.period);
const { showModal } = useModal();
useEffect(() => {
const { startTimestamp, endTimestamp } = period.toTimestamps();
auditStore.fetchAudits({
page: auditStore.page,
limit: auditStore.pageSize,
query: auditStore.searchQuery,
order: auditStore.order,
startDate: startTimestamp,
endDate: endTimestamp,
});
}, [page, searchQuery, order, period]);
return useObserver(() => ( useEffect(() => {
<Loader loading={loading}> const { startTimestamp, endTimestamp } = period.toTimestamps();
<NoContent auditStore.fetchAudits({
title={ page: auditStore.page,
<div className="flex flex-col items-center justify-center"> limit: auditStore.pageSize,
<AnimatedSVG name={ICONS.NO_AUDIT_TRAIL} size={60} /> query: auditStore.searchQuery,
<div className="text-center my-4">No data available</div> order: auditStore.order,
</div> startDate: startTimestamp,
} endDate: endTimestamp,
size="small" });
show={list.length === 0} }, [page, searchQuery, order, period]);
>
<div className="grid grid-cols-12 py-3 px-5 font-medium">
<div className="col-span-5">Name</div>
<div className="col-span-4">Action</div>
<div className="col-span-3">Time</div>
</div>
{list.map((item, index) => ( return useObserver(() => (
<AuditListItem <Loader loading={loading}>
key={index} <NoContent
audit={item} title={
onShowDetails={() => showModal(<AuditDetailModal audit={item} />, { right: true, width: 500 })} <div className="flex flex-col items-center justify-center">
/> <AnimatedSVG name={ICONS.NO_AUDIT_TRAIL} size={60} />
))} <div className="text-center my-4">{t('No data available')}</div>
</div>
<div className="w-full flex items-center justify-center py-10"> }
<Pagination size="small"
page={auditStore.page} show={list.length === 0}
total={auditStore.total} >
onPageChange={(page) => auditStore.updateKey('page', page)} <div className="grid grid-cols-12 py-3 px-5 font-medium">
limit={auditStore.pageSize} <div className="col-span-5">{t('Name')}</div>
debounceRequest={200} <div className="col-span-4">{t('Action')}</div>
/> <div className="col-span-3">{t('Time')}</div>
</div> </div>
</NoContent>
</Loader> {list.map((item, index) => (
)); <AuditListItem
key={index}
audit={item}
onShowDetails={() =>
showModal(<AuditDetailModal audit={item} />, {
right: true,
width: 500,
})
}
/>
))}
<div className="w-full flex items-center justify-center py-10">
<Pagination
page={auditStore.page}
total={auditStore.total}
onPageChange={(page) => auditStore.updateKey('page', page)}
limit={auditStore.pageSize}
debounceRequest={200}
/>
</div>
</NoContent>
</Loader>
));
} }
export default AuditList; export default AuditList;

View file

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

View file

@ -2,18 +2,26 @@ import React from 'react';
import { checkForRecent } from 'App/date'; import { checkForRecent } from 'App/date';
interface Props { interface Props {
audit: any; audit: any;
onShowDetails: () => void; onShowDetails: () => void;
} }
function AuditListItem(props: Props) { function AuditListItem(props: Props) {
const { audit, onShowDetails } = props; const { audit, onShowDetails } = props;
return ( return (
<div className="grid grid-cols-12 py-4 px-5 border-t items-center select-none hover:bg-active-blue group"> <div className="grid grid-cols-12 py-4 px-5 border-t items-center select-none hover:bg-active-blue group">
<div className="col-span-5">{audit.username}</div> <div className="col-span-5">{audit.username}</div>
<div className="col-span-4 link cursor-pointer select-none" onClick={onShowDetails}>{audit.action}</div> <div
<div className="col-span-3">{audit.createdAt && checkForRecent(audit.createdAt, 'LLL dd, yyyy, hh:mm a')}</div> className="col-span-4 link cursor-pointer select-none"
</div> onClick={onShowDetails}
); >
{audit.action}
</div>
<div className="col-span-3">
{audit.createdAt &&
checkForRecent(audit.createdAt, 'LLL dd, yyyy, hh:mm a')}
</div>
</div>
);
} }
export default AuditListItem; export default AuditListItem;

View file

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

View file

@ -2,33 +2,37 @@ import React, { useEffect } from 'react';
import { Icon, Input } from 'UI'; import { Icon, Input } from 'UI';
import { debounce } from 'App/utils'; import { debounce } from 'App/utils';
let debounceUpdate: any = () => {} let debounceUpdate: any = () => {};
interface Props { interface Props {
onChange: (value: string) => void; onChange: (value: string) => void;
} }
function AuditSearchField(props: Props) { function AuditSearchField(props: Props) {
const { onChange } = props; const { onChange } = props;
useEffect(() => {
debounceUpdate = debounce((value) => onChange(value), 500);
}, [])
const write = ({ target: { name, value } }) => { useEffect(() => {
debounceUpdate(value); debounceUpdate = debounce((value) => onChange(value), 500);
} }, []);
return ( const write = ({ target: { name, value } }) => {
<div className="relative" style={{ width: '220px'}}> debounceUpdate(value);
<Icon name="search" className="absolute top-0 bottom-0 ml-3 m-auto" size="16" /> };
<Input
name="searchQuery" return (
// className="bg-white p-2 border border-gray-light rounded w-full pl-10" <div className="relative" style={{ width: '220px' }}>
placeholder="Filter by name" <Icon
onChange={write} name="search"
icon="search" className="absolute top-0 bottom-0 ml-3 m-auto"
/> size="16"
</div> />
); <Input
name="searchQuery"
// className="bg-white p-2 border border-gray-light rounded w-full pl-10"
placeholder="Filter by name"
onChange={write}
icon="search"
/>
</div>
);
} }
export default AuditSearchField; export default AuditSearchField;

View file

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

View file

@ -1,77 +1,91 @@
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import { PageTitle, Icon } from 'UI'; import { PageTitle, Icon } from 'UI';
import { Button } from 'antd' import { Button } from 'antd';
import AuditList from '../AuditList';
import AuditSearchField from '../AuditSearchField';
import { useStore } from 'App/mstore'; import { useStore } from 'App/mstore';
import { useObserver } from 'mobx-react-lite'; import { useObserver } from 'mobx-react-lite';
import Select from 'Shared/Select'; import Select from 'Shared/Select';
import SelectDateRange from 'Shared/SelectDateRange'; import SelectDateRange from 'Shared/SelectDateRange';
import { numberWithCommas } from 'App/utils'; import { numberWithCommas } from 'App/utils';
import withPageTitle from 'HOCs/withPageTitle'; import withPageTitle from 'HOCs/withPageTitle';
import AuditSearchField from '../AuditSearchField';
import AuditList from '../AuditList';
import { useTranslation } from 'react-i18next';
function AuditView() { function AuditView() {
const { auditStore } = useStore(); const { t } = useTranslation();
const order = useObserver(() => auditStore.order); const { auditStore } = useStore();
const total = useObserver(() => numberWithCommas(auditStore.total)); const order = useObserver(() => auditStore.order);
const total = useObserver(() => numberWithCommas(auditStore.total));
useEffect(() => { useEffect(
return () => { () => () => {
auditStore.updateKey('searchQuery', ''); auditStore.updateKey('searchQuery', '');
} },
}, []) [],
);
const exportToCsv = () => { const exportToCsv = () => {
auditStore.exportToCsv(); auditStore.exportToCsv();
} };
const onChange = (data) => { const onChange = (data) => {
auditStore.setDateRange(data); auditStore.setDateRange(data);
} };
return useObserver(() => ( return useObserver(() => (
<div className="bg-white rounded-lg shadow-sm border"> <div className="bg-white rounded-lg shadow-sm border">
<div className="flex items-center mb-4 px-5 pt-5"> <div className="flex items-center mb-4 px-5 pt-5">
<PageTitle title={ <PageTitle
<div className="flex items-center"> title={
<span>Audit Trail</span> <div className="flex items-center">
<span className="color-gray-medium ml-2">{total}</span> <span>{t('Audit Trail')}</span>
</div> <span className="color-gray-medium ml-2">{total}</span>
} />
<div className="flex items-center ml-auto">
<div className="mx-2">
<SelectDateRange
period={auditStore.period}
onChange={onChange}
right={true}
/>
</div>
<div className="mx-2">
<Select
options={[
{ label: 'Newest First', value: 'desc' },
{ label: 'Oldest First', value: 'asc' },
]}
defaultValue={order}
plain
onChange={({ value }) => auditStore.updateKey('order', value.value)}
/>
</div>
<AuditSearchField onChange={(value) => {
auditStore.updateKey('searchQuery', value);
auditStore.updateKey('page', 1)
} }/>
<div>
<Button type="text" icon={<Icon name="grid-3x3" color="teal" />} className="ml-3" onClick={exportToCsv}>
<span className="ml-2">Export to CSV</span>
</Button>
</div>
</div>
</div> </div>
}
<AuditList /> />
<div className="flex items-center ml-auto">
<div className="mx-2">
<SelectDateRange
period={auditStore.period}
onChange={onChange}
right
/>
</div>
<div className="mx-2">
<Select
options={[
{ label: t('Newest First'), value: 'desc' },
{ label: t('Oldest First'), value: 'asc' },
]}
defaultValue={order}
plain
onChange={({ value }) =>
auditStore.updateKey('order', value.value)
}
/>
</div>
<AuditSearchField
onChange={(value) => {
auditStore.updateKey('searchQuery', value);
auditStore.updateKey('page', 1);
}}
/>
<div>
<Button
type="text"
icon={<Icon name="grid-3x3" color="teal" />}
className="ml-3"
onClick={exportToCsv}
>
<span className="ml-2">{t('Export to CSV')}</span>
</Button>
</div>
</div> </div>
)); </div>
<AuditList />
</div>
));
} }
export default withPageTitle('Audit Trail - OpenReplay Preferences')(AuditView); export default withPageTitle('Audit Trail - OpenReplay Preferences')(AuditView);

View file

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

View file

@ -3,6 +3,8 @@ import { withRouter } from 'react-router-dom';
import { Switch, Route, Redirect } from 'react-router'; import { Switch, Route, Redirect } from 'react-router';
import { CLIENT_TABS, client as clientRoute } from 'App/routes'; import { CLIENT_TABS, client as clientRoute } from 'App/routes';
import SessionsListingSettings from 'Components/Client/SessionsListingSettings';
import Modules from 'Components/Client/Modules';
import ProfileSettings from './ProfileSettings'; import ProfileSettings from './ProfileSettings';
import Integrations from './Integrations'; import Integrations from './Integrations';
import UserView from './Users/UsersView'; import UserView from './Users/UsersView';
@ -13,8 +15,6 @@ import CustomFields from './CustomFields';
import Webhooks from './Webhooks'; import Webhooks from './Webhooks';
import Notifications from './Notifications'; import Notifications from './Notifications';
import Roles from './Roles'; import Roles from './Roles';
import SessionsListingSettings from 'Components/Client/SessionsListingSettings';
import Modules from 'Components/Client/Modules';
@withRouter @withRouter
export default class Client extends React.PureComponent { export default class Client extends React.PureComponent {
@ -28,17 +28,72 @@ export default class Client extends React.PureComponent {
renderActiveTab = () => ( renderActiveTab = () => (
<Switch> <Switch>
<Route exact strict path={clientRoute(CLIENT_TABS.PROFILE)} component={ProfileSettings} /> <Route
<Route exact strict path={clientRoute(CLIENT_TABS.SESSIONS_LISTING)} component={SessionsListingSettings} /> exact
<Route exact strict path={clientRoute(CLIENT_TABS.INTEGRATIONS)} component={Integrations} /> strict
<Route exact strict path={clientRoute(CLIENT_TABS.MANAGE_USERS)} component={UserView} /> path={clientRoute(CLIENT_TABS.PROFILE)}
<Route exact strict path={clientRoute(CLIENT_TABS.SITES)} component={Projects} /> component={ProfileSettings}
<Route exact strict path={clientRoute(CLIENT_TABS.CUSTOM_FIELDS)} component={CustomFields} /> />
<Route exact strict path={clientRoute(CLIENT_TABS.WEBHOOKS)} component={Webhooks} /> <Route
<Route exact strict path={clientRoute(CLIENT_TABS.NOTIFICATIONS)} component={Notifications} /> exact
<Route exact strict path={clientRoute(CLIENT_TABS.MANAGE_ROLES)} component={Roles} /> strict
<Route exact strict path={clientRoute(CLIENT_TABS.AUDIT)} component={AuditView} /> path={clientRoute(CLIENT_TABS.SESSIONS_LISTING)}
<Route exact strict path={clientRoute(CLIENT_TABS.MODULES)} component={Modules} /> component={SessionsListingSettings}
/>
<Route
exact
strict
path={clientRoute(CLIENT_TABS.INTEGRATIONS)}
component={Integrations}
/>
<Route
exact
strict
path={clientRoute(CLIENT_TABS.MANAGE_USERS)}
component={UserView}
/>
<Route
exact
strict
path={clientRoute(CLIENT_TABS.SITES)}
component={Projects}
/>
<Route
exact
strict
path={clientRoute(CLIENT_TABS.CUSTOM_FIELDS)}
component={CustomFields}
/>
<Route
exact
strict
path={clientRoute(CLIENT_TABS.WEBHOOKS)}
component={Webhooks}
/>
<Route
exact
strict
path={clientRoute(CLIENT_TABS.NOTIFICATIONS)}
component={Notifications}
/>
<Route
exact
strict
path={clientRoute(CLIENT_TABS.MANAGE_ROLES)}
component={Roles}
/>
<Route
exact
strict
path={clientRoute(CLIENT_TABS.AUDIT)}
component={AuditView}
/>
<Route
exact
strict
path={clientRoute(CLIENT_TABS.MODULES)}
component={Modules}
/>
<Redirect to={clientRoute(CLIENT_TABS.PROFILE)} /> <Redirect to={clientRoute(CLIENT_TABS.PROFILE)} />
</Switch> </Switch>
); );
@ -46,11 +101,11 @@ export default class Client extends React.PureComponent {
render() { render() {
const { const {
match: { match: {
params: { activeTab } params: { activeTab },
} },
} = this.props; } = this.props;
return ( return (
<div className='w-full mx-auto mb-8' style={{ maxWidth: '1360px' }}> <div className="w-full mx-auto mb-8" style={{ maxWidth: '1360px' }}>
{activeTab && this.renderActiveTab()} {activeTab && this.renderActiveTab()}
</div> </div>
); );

View file

@ -4,7 +4,9 @@ import { edit, save } from 'Duck/customField';
import { Form, Input, Button } from 'UI'; import { Form, Input, Button } from 'UI';
import styles from './customFieldForm.module.css'; import styles from './customFieldForm.module.css';
const CustomFieldForm = ({ field, saving, errors, edit, save, onSave, onClose, onDelete }) => { function CustomFieldForm({
field, saving, errors, edit, save, onSave, onClose, onDelete,
}) {
const focusElementRef = useRef(null); const focusElementRef = useRef(null);
const setFocus = () => focusElementRef.current.focus(); const setFocus = () => focusElementRef.current.focus();
@ -15,10 +17,14 @@ const CustomFieldForm = ({ field, saving, errors, edit, save, onSave, onClose, o
return ( return (
<div className="bg-white h-screen overflow-y-auto"> <div className="bg-white h-screen overflow-y-auto">
<h3 className="p-5 text-xl">{exists ? 'Update' : 'Add'} Metadata Field</h3> <h3 className="p-5 text-xl">
{exists ? 'Update' : 'Add'}
{' '}
Metadata Field
</h3>
<Form className={styles.wrapper}> <Form className={styles.wrapper}>
<Form.Field> <Form.Field>
<label>{'Field Name'}</label> <label>Field Name</label>
<Input <Input
ref={focusElementRef} ref={focusElementRef}
name="key" name="key"
@ -41,21 +47,21 @@ const CustomFieldForm = ({ field, saving, errors, edit, save, onSave, onClose, o
{exists ? 'Update' : 'Add'} {exists ? 'Update' : 'Add'}
</Button> </Button>
<Button data-hidden={!exists} onClick={onClose}> <Button data-hidden={!exists} onClick={onClose}>
{'Cancel'} Cancel
</Button> </Button>
</div> </div>
<Button variant="text" icon="trash" data-hidden={!exists} onClick={onDelete}></Button> <Button variant="text" icon="trash" data-hidden={!exists} onClick={onDelete} />
</div> </div>
</Form> </Form>
</div> </div>
); );
}; }
const mapStateToProps = (state) => ({ const mapStateToProps = (state) => ({
field: state.getIn(['customFields', 'instance']), field: state.getIn(['customFields', 'instance']),
saving: state.getIn(['customFields', 'saveRequest', 'loading']), saving: state.getIn(['customFields', 'saveRequest', 'loading']),
errors: state.getIn(['customFields', 'saveRequest', 'errors']) errors: state.getIn(['customFields', 'saveRequest', 'errors']),
}); });
export default connect(mapStateToProps, { edit, save })(CustomFieldForm); export default connect(mapStateToProps, { edit, save })(CustomFieldForm);

View file

@ -1,66 +1,75 @@
import React, { useRef, useState } from 'react'; import React, { useRef, useState } from 'react';
import { Form, Input } from 'UI'; import { Form, Input } from 'UI';
import styles from './customFieldForm.module.css';
import { useStore } from 'App/mstore'; import { useStore } from 'App/mstore';
import { useModal } from 'Components/Modal'; import { useModal } from 'Components/Modal';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { Button, Modal } from 'antd'; import { Button, Modal } from 'antd';
import { Trash } from 'UI/Icons'; import { Trash } from 'UI/Icons';
import { observer } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite';
import styles from './customFieldForm.module.css';
import { useTranslation } from 'react-i18next';
interface CustomFieldFormProps { interface CustomFieldFormProps {
siteId: string; siteId: string;
} }
const CustomFieldForm: React.FC<CustomFieldFormProps> = ({ siteId }) => { const CustomFieldForm: React.FC<CustomFieldFormProps> = ({ siteId }) => {
const { t } = useTranslation();
const focusElementRef = useRef<HTMLInputElement>(null); const focusElementRef = useRef<HTMLInputElement>(null);
const { customFieldStore: store } = useStore(); const { customFieldStore: store } = useStore();
const field = store.instance; const field = store.instance;
const { hideModal } = useModal(); const { hideModal } = useModal();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const write = ({ target: { value, name } }: any) => store.edit({ [name]: value }); const write = ({ target: { value, name } }: any) =>
store.edit({ [name]: value });
const exists = field?.exists(); const exists = field?.exists();
const onDelete = async () => { const onDelete = async () => {
Modal.confirm({ Modal.confirm({
title: 'Metadata', title: t('Metadata'),
content: `Are you sure you want to remove?`, content: t('Are you sure you want to remove?'),
onOk: async () => { onOk: async () => {
await store.remove(siteId, field?.index!); await store.remove(siteId, field?.index!);
hideModal(); hideModal();
} },
}); });
}; };
const onSave = (field: any) => { const onSave = (field: any) => {
setLoading(true); setLoading(true);
store.save(siteId, field).then((response) => { store
if (!response || !response.errors || response.errors.size === 0) { .save(siteId, field)
hideModal(); .then((response) => {
toast.success('Metadata added successfully!'); if (!response || !response.errors || response.errors.size === 0) {
} else { hideModal();
toast.error(response.errors[0]); toast.success(t('Metadata added successfully!'));
} } else {
}).catch(() => { toast.error(response.errors[0]);
toast.error('An error occurred while saving metadata.'); }
}).finally(() => { })
setLoading(false); .catch(() => {
}); toast.error(t('An error occurred while saving metadata.'));
})
.finally(() => {
setLoading(false);
});
}; };
return ( return (
<div className="bg-white h-screen overflow-y-auto"> <div className="bg-white h-screen overflow-y-auto">
<h3 className="p-5 text-xl">{exists ? 'Update' : 'Add'} Metadata Field</h3> <h3 className="p-5 text-xl">
{exists ? t('Update') : 'Add'}&nbsp;{t('Metadata Field')}
</h3>
<Form className={styles.wrapper}> <Form className={styles.wrapper}>
<Form.Field> <Form.Field>
<label>{'Field Name'}</label> <label>{t('Field Name')}</label>
<Input <Input
ref={focusElementRef} ref={focusElementRef}
name="key" name="key"
value={field?.key} value={field?.key}
onChange={write} onChange={write}
placeholder="E.g. plan" placeholder={t('E.g. plan')}
maxLength={50} maxLength={50}
/> />
</Form.Field> </Form.Field>
@ -74,14 +83,19 @@ const CustomFieldForm: React.FC<CustomFieldFormProps> = ({ siteId }) => {
type="primary" type="primary"
className="float-left mr-2" className="float-left mr-2"
> >
{exists ? 'Update' : 'Add'} {exists ? t('Update') : t('Add')}
</Button> </Button>
<Button type='text' data-hidden={!exists} onClick={hideModal}> <Button type="text" data-hidden={!exists} onClick={hideModal}>
{'Cancel'} {t('Cancel')}
</Button> </Button>
</div> </div>
<Button type="text" icon={<Trash />} data-hidden={!exists} onClick={onDelete}></Button> <Button
type="text"
icon={<Trash />}
data-hidden={!exists}
onClick={onDelete}
/>
</div> </div>
</Form> </Form>
</div> </div>

View file

@ -1,20 +1,21 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import CustomFieldForm from './CustomFieldForm';
import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG'; import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG';
import { useModal } from 'App/components/Modal'; import { useModal } from 'App/components/Modal';
import { useStore } from 'App/mstore'; import { useStore } from 'App/mstore';
import { observer } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite';
import { List, Space, Typography, Button, Tooltip, Empty } from 'antd'; import { List, Space, Typography, Button, Tooltip, Empty } from 'antd';
import { PlusIcon, Tags } from 'lucide-react'; import { PlusIcon, Tags } from 'lucide-react';
import {EditOutlined } from '@ant-design/icons'; import { EditOutlined } from '@ant-design/icons';
import usePageTitle from '@/hooks/usePageTitle'; import usePageTitle from '@/hooks/usePageTitle';
import CustomFieldForm from './CustomFieldForm';
import { useTranslation } from 'react-i18next';
function CustomFields() {
const CustomFields = () => {
usePageTitle('Metadata - OpenReplay Preferences'); usePageTitle('Metadata - OpenReplay Preferences');
const { t } = useTranslation();
const { customFieldStore: store, projectsStore } = useStore(); const { customFieldStore: store, projectsStore } = useStore();
const currentSite = projectsStore.config.project; const currentSite = projectsStore.config.project;
const { showModal, hideModal } = useModal(); const { showModal } = useModal();
const fields = store.list; const fields = store.list;
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@ -27,8 +28,9 @@ const CustomFields = () => {
const handleInit = (field?: any) => { const handleInit = (field?: any) => {
store.init(field); store.init(field);
showModal(<CustomFieldForm siteId={currentSite?.projectId + ''} />, { showModal(<CustomFieldForm siteId={`${currentSite?.projectId}`} />, {
title: field ? 'Edit Metadata' : 'Add Metadata', right: true title: field ? t('Edit Metadata') : t('Add Metadata'),
right: true,
}); });
}; };
@ -37,32 +39,44 @@ const CustomFields = () => {
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<Typography.Text> <Typography.Text>
Attach key-value pairs to session replays for enhanced filtering, searching, and identifying relevant user {t('Attach key-value pairs to session replays for enhanced filtering, searching, and identifying relevant user sessions.')}
sessions.
<a href="https://docs.openreplay.com/en/session-replay/metadata" className="link ml-1" target="_blank"> <a href="https://docs.openreplay.com/en/session-replay/metadata" className="link ml-1" target="_blank">
Learn more {t('Learn more')}
</a> </a>
</Typography.Text> </Typography.Text>
<Space> <Space>
<Tooltip <Tooltip
title={remaining > 0 ? '' : 'You\'ve reached the limit of 10 metadata.'} title={
remaining > 0 ? '' : t("You've reached the limit of 10 metadata.")
}
> >
<Button icon={<PlusIcon size={18} />} type="primary" size='small' <Button
disabled={remaining === 0} icon={<PlusIcon size={18} />}
onClick={() => handleInit()}> type="primary"
Add Metadata size="small"
disabled={remaining === 0}
onClick={() => handleInit()}
>
{t('Add Metadata')}
</Button> </Button>
</Tooltip> </Tooltip>
{/*{remaining === 0 && <Icon name="info-circle" size={16} color="black" />}*/} {/* {remaining === 0 && <Icon name="info-circle" size={16} color="black" />} */}
<Typography.Text type="secondary"> <Typography.Text type="secondary">
{remaining === 0 ? 'You have reached the limit of 10 metadata.' : `${remaining}/10 Remaining for this project`} {remaining === 0
? t('You have reached the limit of 10 metadata.')
: `${remaining}${t('/10 Remaining for this project')}`}
</Typography.Text> </Typography.Text>
</Space> </Space>
<List <List
locale={{ locale={{
emptyText: <Empty description="None added yet" image={<AnimatedSVG name={ICONS.NO_METADATA} size={60} />} /> emptyText: (
<Empty
description={t('None added yet')}
image={<AnimatedSVG name={ICONS.NO_METADATA} size={60} />}
/>
),
}} }}
loading={loading} loading={loading}
dataSource={fields} dataSource={fields}
@ -71,17 +85,19 @@ const CustomFields = () => {
onClick={() => handleInit(field)} onClick={() => handleInit(field)}
className="cursor-pointer group hover:bg-active-blue !px-4" className="cursor-pointer group hover:bg-active-blue !px-4"
actions={[ actions={[
<Button type='link' className="opacity-0 group-hover:!opacity-100" icon={<EditOutlined size={14} />} /> <Button
type="link"
className="opacity-0 group-hover:!opacity-100"
icon={<EditOutlined size={14} />}
/>,
]} ]}
> >
<List.Item.Meta <List.Item.Meta title={field.key} avatar={<Tags size={20} />} />
title={field.key}
avatar={<Tags size={20} />}
/>
</List.Item> </List.Item>
)} /> )}
/>
</div> </div>
); );
}; }
export default observer(CustomFields); export default observer(CustomFields);

View file

@ -4,24 +4,27 @@ import { Icon } from 'UI';
import { Button } from 'antd'; import { Button } from 'antd';
import styles from './listItem.module.css'; import styles from './listItem.module.css';
const ListItem = ({ field, onEdit, disabled }) => { function 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
<Button type="text" icon={<Icon name={"pencil"} size={16} />} /> className="invisible group-hover:visible"
data-hidden={field.index === 0}
>
<Button type="text" icon={<Icon name="pencil" size={16} />} />
</div> </div>
</div> </div>
); );
}; }
export default ListItem; export default ListItem;

View file

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

View file

@ -1,33 +1,36 @@
import React from 'react' import React from 'react';
import { KEY, options } from 'App/dev/console' import { KEY, options } from 'App/dev/console';
import { Switch } from 'UI'; import { Switch } from 'UI';
import { useTranslation } from 'react-i18next';
function getDefaults() { function getDefaults() {
const storedString = localStorage.getItem(KEY) const storedString = localStorage.getItem(KEY);
if (storedString) { if (storedString) {
const storedOptions = JSON.parse(storedString) const storedOptions = JSON.parse(storedString);
return storedOptions.verbose return storedOptions.verbose;
} else {
return false
} }
return false;
} }
function DebugLog() { function DebugLog() {
const [showLogs, setShowLogs] = React.useState(getDefaults) const { t } = useTranslation();
const [showLogs, setShowLogs] = React.useState(getDefaults);
const onChange = (checked: boolean) => { const onChange = (checked: boolean) => {
setShowLogs(checked) setShowLogs(checked);
options.logStuff(checked) options.logStuff(checked);
} };
return ( return (
<div> <div>
<h3 className={'text-lg'}>Player Debug Logs</h3> <h3 className="text-lg">{t('Player Debug Logs')}</h3>
<div className={'my-1'}>Show debug information in browser console.</div> <div className="my-1">
<div className={'mt-2'}> {t('Show debug information in browser console.')}
</div>
<div className="mt-2">
<Switch checked={showLogs} onChange={onChange} /> <Switch checked={showLogs} onChange={onChange} />
</div> </div>
</div> </div>
) );
} }
export default DebugLog export default DebugLog;

View file

@ -10,6 +10,7 @@ import IntegrationModalCard from 'Components/Client/Integrations/IntegrationModa
import { Loader } from 'UI'; import { Loader } from 'UI';
import DocLink from 'Shared/DocLink/DocLink'; import DocLink from 'Shared/DocLink/DocLink';
import { useTranslation } from 'react-i18next';
interface DatadogConfig { interface DatadogConfig {
site: string; site: string;
@ -23,15 +24,16 @@ const initialValues = {
app_key: '', app_key: '',
}; };
const DatadogFormModal = ({ function DatadogFormModal({
onClose, onClose,
integrated, integrated,
}: { }: {
onClose: () => void; onClose: () => void;
integrated: boolean; integrated: boolean;
}) => { }) {
const { t } = useTranslation();
const { integrationsStore } = useStore(); const { integrationsStore } = useStore();
const siteId = integrationsStore.integrations.siteId; const { siteId } = integrationsStore.integrations;
const { const {
data = initialValues, data = initialValues,
@ -39,17 +41,20 @@ const DatadogFormModal = ({
saveMutation, saveMutation,
removeMutation, removeMutation,
} = useIntegration<DatadogConfig>('datadog', siteId, initialValues); } = useIntegration<DatadogConfig>('datadog', siteId, initialValues);
const { values, errors, handleChange, hasErrors, checkErrors } = useForm(data, { const { values, errors, handleChange, hasErrors, checkErrors } = useForm(
site: { data,
required: true, {
site: {
required: true,
},
api_key: {
required: true,
},
app_key: {
required: true,
},
}, },
api_key: { );
required: true,
},
app_key: {
required: true,
},
});
const exists = Boolean(data.api_key); const exists = Boolean(data.api_key);
const save = async () => { const save = async () => {
@ -59,7 +64,7 @@ const DatadogFormModal = ({
try { try {
await saveMutation.mutateAsync({ values, siteId, exists }); await saveMutation.mutateAsync({ values, siteId, exists });
} catch (e) { } catch (e) {
console.error(e) console.error(e);
} }
onClose(); onClose();
}; };
@ -68,7 +73,7 @@ const DatadogFormModal = ({
try { try {
await removeMutation.mutateAsync({ siteId }); await removeMutation.mutateAsync({ siteId });
} catch (e) { } catch (e) {
console.error(e) console.error(e);
} }
onClose(); onClose();
}; };
@ -83,20 +88,20 @@ const DatadogFormModal = ({
description="Incorporate DataDog to visualize backend errors alongside session replay, for easy troubleshooting." description="Incorporate DataDog to visualize backend errors alongside session replay, for easy troubleshooting."
/> />
<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">{t('How it works?')}</div>
<ol className="list-decimal list-inside"> <ol className="list-decimal list-inside">
<li>Generate Datadog API Key & Application Key</li> <li>{t('Generate Datadog API Key & Application Key')}</li>
<li>Enter the API key below</li> <li>{t('Enter the API key below')}</li>
<li>Propagate openReplaySessionToken</li> <li>{t('Propagate openReplaySessionToken')}</li>
</ol> </ol>
<DocLink <DocLink
className="mt-4" className="mt-4"
label="Integrate Datadog" label={t('Integrate Datadog')}
url="https://docs.openreplay.com/integrations/datadog" url="https://docs.openreplay.com/integrations/datadog"
/> />
<Loader loading={isPending}> <Loader loading={isPending}>
<FormField <FormField
label="Site" label={t('Site')}
name="site" name="site"
value={values.site} value={values.site}
onChange={handleChange} onChange={handleChange}
@ -104,32 +109,32 @@ const DatadogFormModal = ({
errors={errors.site} errors={errors.site}
/> />
<FormField <FormField
label="API Key" label={t('API Key')}
name="api_key" name="api_key"
value={values.api_key} value={values.api_key}
onChange={handleChange} onChange={handleChange}
errors={errors.api_key} errors={errors.api_key}
/> />
<FormField <FormField
label="Application Key" label={t('Application Key')}
name="app_key" name="app_key"
value={values.app_key} value={values.app_key}
onChange={handleChange} onChange={handleChange}
errors={errors.app_key} errors={errors.app_key}
/> />
<div className={'flex items-center gap-2'}> <div className="flex items-center gap-2">
<Button <Button
onClick={save} onClick={save}
disabled={hasErrors} disabled={hasErrors}
loading={saveMutation.isPending} loading={saveMutation.isPending}
type="primary" type="primary"
> >
{exists ? 'Update' : 'Add'} {exists ? t('Update') : t('Add')}
</Button> </Button>
{integrated && ( {integrated && (
<Button loading={removeMutation.isPending} onClick={remove}> <Button loading={removeMutation.isPending} onClick={remove}>
{'Delete'} {t('Delete')}
</Button> </Button>
)} )}
</div> </div>
@ -137,7 +142,7 @@ const DatadogFormModal = ({
</div> </div>
</div> </div>
); );
}; }
DatadogFormModal.displayName = 'DatadogForm'; DatadogFormModal.displayName = 'DatadogForm';

View file

@ -10,6 +10,7 @@ import IntegrationModalCard from 'Components/Client/Integrations/IntegrationModa
import { Loader } from 'UI'; import { Loader } from 'UI';
import DocLink from 'Shared/DocLink/DocLink'; import DocLink from 'Shared/DocLink/DocLink';
import { useTranslation } from 'react-i18next';
interface DynatraceConfig { interface DynatraceConfig {
environment: string; environment: string;
@ -24,35 +25,39 @@ const initialValues = {
client_secret: '', client_secret: '',
resource: '', resource: '',
}; };
const DynatraceFormModal = ({ function DynatraceFormModal({
onClose, onClose,
integrated, integrated,
}: { }: {
onClose: () => void; onClose: () => void;
integrated: boolean; integrated: boolean;
}) => { }) {
const { t } = useTranslation();
const { integrationsStore } = useStore(); const { integrationsStore } = useStore();
const siteId = integrationsStore.integrations.siteId; const { siteId } = integrationsStore.integrations;
const { const {
data = initialValues, data = initialValues,
isPending, isPending,
saveMutation, saveMutation,
removeMutation, removeMutation,
} = useIntegration<DynatraceConfig>('dynatrace', siteId, initialValues); } = useIntegration<DynatraceConfig>('dynatrace', siteId, initialValues);
const { values, errors, handleChange, hasErrors, checkErrors } = useForm(data, { const { values, errors, handleChange, hasErrors, checkErrors } = useForm(
environment: { data,
required: true, {
environment: {
required: true,
},
client_id: {
required: true,
},
client_secret: {
required: true,
},
resource: {
required: true,
},
}, },
client_id: { );
required: true,
},
client_secret: {
required: true,
},
resource: {
required: true,
},
});
const exists = Boolean(data.client_id); const exists = Boolean(data.client_id);
const save = async () => { const save = async () => {
@ -62,7 +67,7 @@ const DynatraceFormModal = ({
try { try {
await saveMutation.mutateAsync({ values, siteId, exists }); await saveMutation.mutateAsync({ values, siteId, exists });
} catch (e) { } catch (e) {
console.error(e) console.error(e);
} }
onClose(); onClose();
}; };
@ -71,7 +76,7 @@ const DynatraceFormModal = ({
try { try {
await removeMutation.mutateAsync({ siteId }); await removeMutation.mutateAsync({ siteId });
} catch (e) { } catch (e) {
console.error(e) console.error(e);
} }
onClose(); onClose();
}; };
@ -81,33 +86,40 @@ const DynatraceFormModal = ({
style={{ width: '350px' }} style={{ width: '350px' }}
> >
<IntegrationModalCard <IntegrationModalCard
title="Dynatrace" title={t('Dynatrace')}
icon="integrations/dynatrace" icon="integrations/dynatrace"
useIcon useIcon
description="Integrate Dynatrace with session replays to link backend logs with user sessions for faster issue resolution." description={t(
'Integrate Dynatrace with session replays to link backend logs with user sessions for faster issue resolution.',
)}
/> />
<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">{t('How it works?')}</div>
<ol className="list-decimal list-inside"> <ol className="list-decimal list-inside">
<li> <li>
Enter your Environment ID, Client ID, Client Secret, and Account URN {t(
in the form below. 'Enter your Environment ID, Client ID, Client Secret, and Account URN in the form below.',
)}
</li> </li>
<li> <li>
Create a custom Log attribute openReplaySessionToken in Dynatrace. {t(
'Create a custom Log attribute openReplaySessionToken in Dynatrace.',
)}
</li> </li>
<li> <li>
Propagate openReplaySessionToken in your application's backend logs. {t(
"Propagate openReplaySessionToken in your application's backend logs.",
)}
</li> </li>
</ol> </ol>
<DocLink <DocLink
className="mt-4" className="mt-4"
label="See detailed steps" label={t('See detailed steps')}
url="https://docs.openreplay.com/integrations/dynatrace" url="https://docs.openreplay.com/integrations/dynatrace"
/> />
<Loader loading={isPending}> <Loader loading={isPending}>
<FormField <FormField
label="Environment ID" label={t('Environment ID')}
name="environment" name="environment"
value={values.environment} value={values.environment}
onChange={handleChange} onChange={handleChange}
@ -115,40 +127,40 @@ const DynatraceFormModal = ({
autoFocus autoFocus
/> />
<FormField <FormField
label="Client ID" label={t('Client ID')}
name="client_id" name="client_id"
value={values.client_id} value={values.client_id}
onChange={handleChange} onChange={handleChange}
errors={errors.client_id} errors={errors.client_id}
/> />
<FormField <FormField
label="Client Secret" label={t('Client Secret')}
name="client_secret" name="client_secret"
value={values.client_secret} value={values.client_secret}
onChange={handleChange} onChange={handleChange}
errors={errors.client_secret} errors={errors.client_secret}
/> />
<FormField <FormField
label="Dynatrace Account URN" label={t('Dynatrace Account URN')}
name="resource" name="resource"
value={values.resource} value={values.resource}
onChange={handleChange} onChange={handleChange}
errors={errors.resource} errors={errors.resource}
/> />
<div className={'flex items-center gap-2'}> <div className="flex items-center gap-2">
<Button <Button
onClick={save} onClick={save}
disabled={hasErrors} disabled={hasErrors}
loading={saveMutation.isPending} loading={saveMutation.isPending}
type="primary" type="primary"
> >
{exists ? 'Update' : 'Add'} {exists ? t('Update') : t('Add')}
</Button> </Button>
{integrated && ( {integrated && (
<Button loading={removeMutation.isPending} onClick={remove}> <Button loading={removeMutation.isPending} onClick={remove}>
{'Delete'} {t('Delete')}
</Button> </Button>
)} )}
</div> </div>
@ -156,7 +168,7 @@ const DynatraceFormModal = ({
</div> </div>
</div> </div>
); );
}; }
DynatraceFormModal.displayName = 'DynatraceFormModal'; DynatraceFormModal.displayName = 'DynatraceFormModal';

View file

@ -10,6 +10,7 @@ import IntegrationModalCard from 'Components/Client/Integrations/IntegrationModa
import { Loader } from 'UI'; import { Loader } from 'UI';
import DocLink from 'Shared/DocLink/DocLink'; import DocLink from 'Shared/DocLink/DocLink';
import { useTranslation } from 'react-i18next';
interface ElasticConfig { interface ElasticConfig {
url: string; url: string;
@ -32,25 +33,29 @@ function ElasticsearchForm({
onClose: () => void; onClose: () => void;
integrated: boolean; integrated: boolean;
}) { }) {
const { t } = useTranslation();
const { integrationsStore } = useStore(); const { integrationsStore } = useStore();
const siteId = integrationsStore.integrations.siteId; const { siteId } = integrationsStore.integrations;
const { const {
data = initialValues, data = initialValues,
isPending, isPending,
saveMutation, saveMutation,
removeMutation, removeMutation,
} = useIntegration<ElasticConfig>('elasticsearch', siteId, initialValues); } = useIntegration<ElasticConfig>('elasticsearch', siteId, initialValues);
const { values, errors, handleChange, hasErrors, checkErrors } = useForm(data, { const { values, errors, handleChange, hasErrors, checkErrors } = useForm(
url: { data,
required: true, {
url: {
required: true,
},
api_key_id: {
required: true,
},
api_key: {
required: true,
},
}, },
api_key_id: { );
required: true,
},
api_key: {
required: true,
},
});
const exists = Boolean(data.api_key_id); const exists = Boolean(data.api_key_id);
const save = async () => { const save = async () => {
@ -60,7 +65,7 @@ function ElasticsearchForm({
try { try {
await saveMutation.mutateAsync({ values, siteId, exists }); await saveMutation.mutateAsync({ values, siteId, exists });
} catch (e) { } catch (e) {
console.error(e) console.error(e);
} }
onClose(); onClose();
}; };
@ -69,7 +74,7 @@ function ElasticsearchForm({
try { try {
await removeMutation.mutateAsync({ siteId }); await removeMutation.mutateAsync({ siteId });
} catch (e) { } catch (e) {
console.error(e) console.error(e);
} }
onClose(); onClose();
}; };
@ -81,24 +86,26 @@ function ElasticsearchForm({
<IntegrationModalCard <IntegrationModalCard
title="Elasticsearch" title="Elasticsearch"
icon="integrations/elasticsearch" icon="integrations/elasticsearch"
description="Integrate Elasticsearch with session replays to seamlessly observe backend errors." description={t(
'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">{t('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>{t('Create a new Elastic API key')}</li>
<li>Enter the API key below</li> <li>{t('Enter the API key below')}</li>
<li>Propagate openReplaySessionToken</li> <li>{t('Propagate openReplaySessionToken')}</li>
</ol> </ol>
<DocLink <DocLink
className="mt-4" className="mt-4"
label="Integrate Elasticsearch" label={t('Integrate Elasticsearch')}
url="https://docs.openreplay.com/integrations/elastic" url="https://docs.openreplay.com/integrations/elastic"
/> />
<Loader loading={isPending}> <Loader loading={isPending}>
<FormField <FormField
label="URL" label={t('URL')}
name="url" name="url"
value={values.url} value={values.url}
onChange={handleChange} onChange={handleChange}
@ -106,39 +113,39 @@ function ElasticsearchForm({
autoFocus autoFocus
/> />
<FormField <FormField
label="API Key ID" label={t('API Key ID')}
name="api_key_id" name="api_key_id"
value={values.api_key_id} value={values.api_key_id}
onChange={handleChange} onChange={handleChange}
errors={errors.api_key_id} errors={errors.api_key_id}
/> />
<FormField <FormField
label="API Key" label={t('API Key')}
name="api_key" name="api_key"
value={values.api_key} value={values.api_key}
onChange={handleChange} onChange={handleChange}
errors={errors.api_key} errors={errors.api_key}
/> />
<FormField <FormField
label="Indexes" label={t('Indexes')}
name="indexes" name="indexes"
value={values.indexes} value={values.indexes}
onChange={handleChange} onChange={handleChange}
errors={errors.indexes} errors={errors.indexes}
/> />
<div className={'flex items-center gap-2'}> <div className="flex items-center gap-2">
<Button <Button
onClick={save} onClick={save}
disabled={hasErrors} disabled={hasErrors}
loading={saveMutation.isPending} loading={saveMutation.isPending}
type="primary" type="primary"
> >
{exists ? 'Update' : 'Add'} {exists ? t('Update') : t('Add')}
</Button> </Button>
{integrated && ( {integrated && (
<Button loading={removeMutation.isPending} onClick={remove}> <Button loading={removeMutation.isPending} onClick={remove}>
{'Delete'} {t('Delete')}
</Button> </Button>
)} )}
</div> </div>

View file

@ -10,6 +10,7 @@ import IntegrationModalCard from 'Components/Client/Integrations/IntegrationModa
import { Loader } from 'UI'; import { Loader } from 'UI';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import DocLink from 'Shared/DocLink/DocLink'; import DocLink from 'Shared/DocLink/DocLink';
import { useTranslation } from 'react-i18next';
interface SentryConfig { interface SentryConfig {
url: string; url: string;
@ -32,28 +33,32 @@ function SentryForm({
onClose: () => void; onClose: () => void;
integrated: boolean; integrated: boolean;
}) { }) {
const { t } = useTranslation();
const { integrationsStore } = useStore(); const { integrationsStore } = useStore();
const siteId = integrationsStore.integrations.siteId; const { siteId } = integrationsStore.integrations;
const { const {
data = initialValues, data = initialValues,
isPending, isPending,
saveMutation, saveMutation,
removeMutation, removeMutation,
} = useIntegration<SentryConfig>('sentry', siteId, initialValues); } = useIntegration<SentryConfig>('sentry', siteId, initialValues);
const { values, errors, handleChange, hasErrors, checkErrors, } = useForm(data, { const { values, errors, handleChange, hasErrors, checkErrors } = useForm(
url: { data,
required: false, {
url: {
required: false,
},
organization_slug: {
required: true,
},
project_slug: {
required: true,
},
token: {
required: true,
},
}, },
organization_slug: { );
required: true,
},
project_slug: {
required: true,
},
token: {
required: true,
},
});
const exists = Boolean(data.token); const exists = Boolean(data.token);
const save = async () => { const save = async () => {
@ -63,7 +68,7 @@ function SentryForm({
try { try {
await saveMutation.mutateAsync({ values, siteId, exists }); await saveMutation.mutateAsync({ values, siteId, exists });
} catch (e) { } catch (e) {
console.error(e) console.error(e);
} }
onClose(); onClose();
}; };
@ -72,7 +77,7 @@ function SentryForm({
try { try {
await removeMutation.mutateAsync({ siteId }); await removeMutation.mutateAsync({ siteId });
} catch (e) { } catch (e) {
console.error(e) console.error(e);
} }
onClose(); onClose();
}; };
@ -87,28 +92,28 @@ function SentryForm({
description="Integrate Sentry with session replays to seamlessly observe backend errors." description="Integrate Sentry 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">{t('How it works?')}</div>
<ol className="list-decimal list-inside"> <ol className="list-decimal list-inside">
<li>Generate Sentry Auth Token</li> <li>{t('Generate Sentry Auth Token')}</li>
<li>Enter the token below</li> <li>{t('Enter the token below')}</li>
<li>Propagate openReplaySessionToken</li> <li>{t('Propagate openReplaySessionToken')}</li>
</ol> </ol>
<DocLink <DocLink
className="mt-4" className="mt-4"
label="See detailed steps" label={t('See detailed steps')}
url="https://docs.openreplay.com/integrations/sentry" url="https://docs.openreplay.com/integrations/sentry"
/> />
<Loader loading={isPending}> <Loader loading={isPending}>
<FormField <FormField
label="URL" label={t('URL')}
name="url" name="url"
value={values.url} value={values.url}
onChange={handleChange} onChange={handleChange}
errors={errors.url} errors={errors.url}
/> />
<FormField <FormField
label="Organization Slug" label={t('Organization Slug')}
name="organization_slug" name="organization_slug"
value={values.organization_slug} value={values.organization_slug}
onChange={handleChange} onChange={handleChange}
@ -116,33 +121,33 @@ function SentryForm({
autoFocus autoFocus
/> />
<FormField <FormField
label="Project Slug" label={t('Project Slug')}
name="project_slug" name="project_slug"
value={values.project_slug} value={values.project_slug}
onChange={handleChange} onChange={handleChange}
errors={errors.project_slug} errors={errors.project_slug}
/> />
<FormField <FormField
label="Token" label={t('Token')}
name="token" name="token"
value={values.token} value={values.token}
onChange={handleChange} onChange={handleChange}
errors={errors.token} errors={errors.token}
/> />
<div className={'flex items-center gap-2'}> <div className="flex items-center gap-2">
<Button <Button
onClick={save} onClick={save}
disabled={hasErrors} disabled={hasErrors}
loading={saveMutation.isPending} loading={saveMutation.isPending}
type="primary" type="primary"
> >
{exists ? 'Update' : 'Add'} {exists ? t('Update') : t('Add')}
</Button> </Button>
{integrated && ( {integrated && (
<Button loading={removeMutation.isPending} onClick={remove}> <Button loading={removeMutation.isPending} onClick={remove}>
{'Delete'} {t('Delete')}
</Button> </Button>
)} )}
</div> </div>

View file

@ -1,5 +1,5 @@
import React from "react"; import React from 'react';
import { Input } from 'antd' import { Input } from 'antd';
export function FormField({ export function FormField({
label, label,
@ -10,7 +10,7 @@ export function FormField({
errors, errors,
}: { }: {
label: string; label: string;
name: string name: string;
value: string; value: string;
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void; onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
autoFocus?: boolean; autoFocus?: boolean;
@ -30,4 +30,4 @@ export function FormField({
</div> </div>
); );
} }
export default FormField; export default FormField;

View file

@ -1,33 +0,0 @@
import React from 'react';
import IntegrationForm from './IntegrationForm';
import DocLink from 'Shared/DocLink/DocLink';
import IntegrationModalCard from 'Components/Client/Integrations/IntegrationModalCard';
const GithubForm = (props) => (
<div className='bg-white h-screen overflow-y-auto' style={{ width: '350px' }}>
<IntegrationModalCard title='Github' icon='integrations/github'
description='Integrate GitHub with OpenReplay to enable the direct creation of a new issue from a session.' />
<div className='p-5 border-b mb-4'>
<div>Integrate GitHub with OpenReplay and create issues directly from the recording page.</div>
<div className='mt-8'>
<DocLink className='mt-4' label='Integrate Github' url='https://docs.openreplay.com/integrations/github' />
</div>
</div>
<IntegrationForm
{...props}
ignoreProject
name='github'
customPath='github'
formFields={[
{
key: 'token',
label: 'Token'
}
]}
/>
</div>
);
GithubForm.displayName = 'GithubForm';
export default GithubForm;

View file

@ -0,0 +1,53 @@
import React from 'react';
import DocLink from 'Shared/DocLink/DocLink';
import IntegrationModalCard from 'Components/Client/Integrations/IntegrationModalCard';
import IntegrationForm from './IntegrationForm';
import { useTranslation } from 'react-i18next';
function GithubForm(props) {
const { t } = useTranslation();
return (
<div
className="bg-white h-screen overflow-y-auto"
style={{ width: '350px' }}
>
<IntegrationModalCard
title="Github"
icon="integrations/github"
description={t(
'Integrate GitHub with OpenReplay to enable the direct creation of a new issue from a session.',
)}
/>
<div className="p-5 border-b mb-4">
<div>
{t(
'Integrate GitHub with OpenReplay and create issues directly from the recording page.',
)}
</div>
<div className="mt-8">
<DocLink
className="mt-4"
label="Integrate Github"
url="https://docs.openreplay.com/integrations/github"
/>
</div>
</div>
<IntegrationForm
{...props}
ignoreProject
name="github"
customPath="github"
formFields={[
{
key: 'token',
label: 'Token',
},
]}
/>
</div>
);
}
GithubForm.displayName = 'GithubForm';
export default GithubForm;

View file

@ -4,7 +4,6 @@ import React from 'react';
import { Icon } from 'UI'; import { Icon } from 'UI';
interface Props { interface Props {
onChange: any; onChange: any;
activeItem: string; activeItem: string;
@ -14,23 +13,22 @@ interface Props {
const allItem = { key: 'all', title: 'All' }; const allItem = { key: 'all', title: 'All' };
function IntegrationFilters(props: Props) { function IntegrationFilters(props: Props) {
const segmentItems = [allItem, ...props.filters].map((item: any) => ({ const segmentItems = [allItem, ...props.filters].map((item: any) => ({
key: item.key, key: item.key,
value: item.key, value: item.key,
label: ( label: (
<div className={'flex items-center gap-2'}> <div className="flex items-center gap-2">
{item.icon ? <Icon name={item.icon} color={'inherit'} /> : null} {item.icon ? <Icon name={item.icon} color="inherit" /> : null}
<div>{item.title}</div> <div>{item.title}</div>
</div> </div>
), ),
})) }));
const onChange = (val) => { const onChange = (val) => {
props.onChange(val) props.onChange(val);
} };
return ( return (
<div className='flex items-center gap-4'> <div className="flex items-center gap-4">
<Segmented <Segmented
value={props.activeItem} value={props.activeItem}
onChange={onChange} onChange={onChange}
@ -40,4 +38,4 @@ function IntegrationFilters(props: Props) {
); );
} }
export default IntegrationFilters; export default IntegrationFilters;

View file

@ -4,19 +4,21 @@ import React from 'react';
import { useStore } from 'App/mstore'; import { useStore } from 'App/mstore';
import { namedStore } from 'App/mstore/integrationsStore'; import { namedStore } from 'App/mstore/integrationsStore';
import { Checkbox, Form, Input, Loader } from 'UI'; import { Checkbox, Form, Input, Loader } from 'UI';
import { Button } from 'antd' import { Button } from 'antd';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { useTranslation } from 'react-i18next';
function IntegrationForm(props: any) { function IntegrationForm(props: any) {
const { t } = useTranslation();
const { formFields, name, integrated } = props; const { formFields, name, integrated } = props;
const { integrationsStore } = useStore(); const { integrationsStore } = useStore();
const initialSiteId = integrationsStore.integrations.siteId; const initialSiteId = integrationsStore.integrations.siteId;
const integrationStore = integrationsStore[name as unknown as namedStore]; const integrationStore = integrationsStore[name as unknown as namedStore];
const config = integrationStore.instance; const config = integrationStore.instance;
const loading = integrationStore.loading; const { loading } = integrationStore;
const onSave = integrationStore.saveIntegration; const onSave = integrationStore.saveIntegration;
const onRemove = integrationStore.deleteIntegration; const onRemove = integrationStore.deleteIntegration;
const edit = integrationStore.edit; const { edit } = integrationStore;
const fetchIntegrationList = integrationsStore.integrations.fetchIntegrations; const fetchIntegrationList = integrationsStore.integrations.fetchIntegrations;
const fetchList = () => { const fetchList = () => {
@ -30,19 +32,21 @@ function IntegrationForm(props: any) {
const save = () => { const save = () => {
const { name, customPath } = props; const { name, customPath } = props;
onSave(customPath || name).then(() => { onSave(customPath || name)
fetchList(); .then(() => {
props.onClose(); fetchList();
}).catch(async (error) => { props.onClose();
if (error.response) { })
const errorResponse = await error.response.json(); .catch(async (error) => {
if (errorResponse.errors && Array.isArray(errorResponse.errors)) { if (error.response) {
toast.error(errorResponse.errors.map((e: any) => e).join(', ')); const errorResponse = await error.response.json();
} else { if (errorResponse.errors && Array.isArray(errorResponse.errors)) {
toast.error('Failed to save integration'); toast.error(errorResponse.errors.map((e: any) => e).join(', '));
} else {
toast.error(t('Failed to save integration'));
}
} }
} });
});
}; };
const remove = () => { const remove = () => {
@ -91,7 +95,7 @@ function IntegrationForm(props: any) {
autoFocus={autoFocus} autoFocus={autoFocus}
/> />
</Form.Field> </Form.Field>
)) )),
)} )}
<Button <Button
@ -101,12 +105,12 @@ function IntegrationForm(props: any) {
type="primary" type="primary"
className="float-left mr-2" className="float-left mr-2"
> >
{config?.exists() ? 'Update' : 'Add'} {config?.exists() ? t('Update') : t('Add')}
</Button> </Button>
{integrated && ( {integrated && (
<Button loading={loading} onClick={remove}> <Button loading={loading} onClick={remove}>
{'Delete'} {t('Delete')}
</Button> </Button>
)} )}
</Form> </Form>

View file

@ -1,8 +1,7 @@
import React from 'react'; import React from 'react';
import cn from 'classnames'; import cn from 'classnames';
import { Icon } from 'UI'; import { Icon } from 'UI';
import stl from './integrationItem.module.css'; import { useTranslation } from 'react-i18next';
import { Tooltip } from 'antd';
interface Props { interface Props {
integration: any; integration: any;
@ -12,32 +11,50 @@ interface Props {
useIcon?: boolean; useIcon?: boolean;
} }
const IntegrationItem = (props: Props) => { function IntegrationItem(props: Props) {
const { t } = useTranslation();
const { integration, integrated, hide = false, useIcon } = props; const { integration, integrated, hide = false, useIcon } = props;
return hide ? null : ( return hide ? null : (
<div <div
className={cn('flex flex-col border rounded-lg p-3 bg-white relative justify-between cursor-pointer hover:bg-active-blue')} className={cn(
'flex flex-col border rounded-lg p-3 bg-white relative justify-between cursor-pointer hover:bg-active-blue',
)}
onClick={(e) => props.onClick(e)} onClick={(e) => props.onClick(e)}
style={{ height: '136px' }} style={{ height: '136px' }}
> >
<div className='flex gap-3'> <div className="flex gap-3">
<div className="shrink-0"> <div className="shrink-0">
{useIcon ? <Icon name={integration.icon} size={40} /> : <img className="h-10 w-10" src={"/assets/" + integration.icon + ".svg"} alt="integration" />} {useIcon ? (
<Icon name={integration.icon} size={40} />
) : (
<img
className="h-10 w-10"
src={`/assets/${integration.icon}.svg`}
alt="integration"
/>
)}
</div> </div>
<div className='flex flex-col'> <div className="flex flex-col">
<h4 className='text-lg'>{integration.title}</h4> <h4 className="text-lg">{integration.title}</h4>
<p className='text-sm color-gray-medium m-0 p-0 h-3'>{integration.subtitle && integration.subtitle}</p> <p className="text-sm color-gray-medium m-0 p-0 h-3">
{integration.subtitle && integration.subtitle}
</p>
</div> </div>
</div> </div>
{integrated && ( {integrated && (
<div className='ml-12 p-1 flex items-center justify-center color-tealx border rounded w-fit'> <div className="ml-12 p-1 flex items-center justify-center color-tealx border rounded w-fit">
<Icon name='check-circle-fill' size='14' color='tealx' className="mr-2" /> <Icon
<span>Integrated</span> name="check-circle-fill"
</div> size="14"
color="tealx"
className="mr-2"
/>
<span>{t('Integrated')}</span>
</div>
)} )}
</div> </div>
); );
}; }
export default IntegrationItem; export default IntegrationItem;

View file

@ -11,16 +11,24 @@ interface Props {
function IntegrationModalCard(props: Props) { function IntegrationModalCard(props: Props) {
const { title, icon, description, useIcon } = props; const { title, icon, description, useIcon } = props;
return ( return (
<div className='flex items-start p-5 gap-4'> <div className="flex items-start p-5 gap-4">
<div className='border rounded-lg p-2 shrink-0'> <div className="border rounded-lg p-2 shrink-0">
{useIcon ? <Icon name={icon} size={80} /> : <img className="h-20 w-20" src={"/assets/" + icon + ".svg"} alt="integration" />} {useIcon ? (
<Icon name={icon} size={80} />
) : (
<img
className="h-20 w-20"
src={`/assets/${icon}.svg`}
alt="integration"
/>
)}
</div> </div>
<div> <div>
<h3 className='text-2xl'>{title}</h3> <h3 className="text-2xl">{title}</h3>
<div>{description}</div> <div>{description}</div>
</div> </div>
</div> </div>
); );
} }
export default IntegrationModalCard; export default IntegrationModalCard;

View file

@ -28,6 +28,8 @@ import PiniaDoc from './Tracker/PiniaDoc';
import ReduxDoc from './Tracker/ReduxDoc'; import ReduxDoc from './Tracker/ReduxDoc';
import VueDoc from './Tracker/VueDoc'; import VueDoc from './Tracker/VueDoc';
import ZustandDoc from './Tracker/ZustandDoc'; import ZustandDoc from './Tracker/ZustandDoc';
import { TFunction } from 'i18next';
import { useTranslation } from 'react-i18next';
interface Props { interface Props {
siteId: string; siteId: string;
@ -35,9 +37,10 @@ interface Props {
} }
function Integrations(props: Props) { function Integrations(props: Props) {
const { t } = useTranslation();
const { integrationsStore, projectsStore } = useStore(); const { integrationsStore, projectsStore } = useStore();
const initialSiteId = projectsStore.siteId; const initialSiteId = projectsStore.siteId;
const siteId = integrationsStore.integrations.siteId; const { siteId } = integrationsStore.integrations;
const fetchIntegrationList = integrationsStore.integrations.fetchIntegrations; const fetchIntegrationList = integrationsStore.integrations.fetchIntegrations;
const storeIntegratedList = integrationsStore.integrations.list; const storeIntegratedList = integrationsStore.integrations.list;
const { hideHeader = false } = props; const { hideHeader = false } = props;
@ -46,8 +49,9 @@ function Integrations(props: Props) {
const [activeFilter, setActiveFilter] = useState<string>('all'); const [activeFilter, setActiveFilter] = useState<string>('all');
useEffect(() => { useEffect(() => {
const list = integrationsStore.integrations.integratedServices const list = integrationsStore.integrations.integratedServices.map(
.map((item: any) => item.name); (item: any) => item.name,
);
setIntegratedList(list); setIntegratedList(list);
}, [storeIntegratedList]); }, [storeIntegratedList]);
@ -86,7 +90,7 @@ function Integrations(props: Props) {
siteId, siteId,
onClose: hideModal, onClose: hideModal,
}), }),
{ right: true, width } { right: true, width },
); );
}; };
@ -94,7 +98,7 @@ function Integrations(props: Props) {
setActiveFilter(key); setActiveFilter(key);
}; };
const filteredIntegrations = integrations.filter((cat: any) => { const filteredIntegrations = integrations(t).filter((cat: any) => {
if (activeFilter === 'all') { if (activeFilter === 'all') {
return true; return true;
} }
@ -102,7 +106,7 @@ function Integrations(props: Props) {
return cat.key === activeFilter; return cat.key === activeFilter;
}); });
const filters = integrations.map((cat: any) => ({ const filters = integrations(t).map((cat: any) => ({
key: cat.key, key: cat.key,
title: cat.title, title: cat.title,
label: cat.title, label: cat.title,
@ -110,7 +114,7 @@ function Integrations(props: Props) {
})); }));
const allIntegrations = filteredIntegrations.flatMap( const allIntegrations = filteredIntegrations.flatMap(
(cat) => cat.integrations (cat) => cat.integrations,
); );
const onChangeSelect = ({ value }: any) => { const onChangeSelect = ({ value }: any) => {
@ -120,8 +124,8 @@ function Integrations(props: Props) {
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">
<div className={'flex items-center gap-4 mb-2'}> <div className="flex items-center gap-4 mb-2">
{!hideHeader && <PageTitle title={<div>Integrations</div>} />} {!hideHeader && <PageTitle title={<div>{t('Integrations')}</div>} />}
<SiteDropdown value={siteId} onChange={onChangeSelect} /> <SiteDropdown value={siteId} onChange={onChangeSelect} />
</div> </div>
<IntegrationFilters <IntegrationFilters
@ -133,9 +137,7 @@ function Integrations(props: Props) {
<div className="mb-4" /> <div className="mb-4" />
<div <div className="mt-4 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
className={'mt-4 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3'}
>
{allIntegrations.map((integration, i) => ( {allIntegrations.map((integration, i) => (
<React.Fragment key={`${integration.slug}+${i}`}> <React.Fragment key={`${integration.slug}+${i}`}>
<IntegrationItem <IntegrationItem
@ -146,10 +148,10 @@ function Integrations(props: Props) {
onClick( onClick(
integration, integration,
filteredIntegrations.find((cat) => filteredIntegrations.find((cat) =>
cat.integrations.includes(integration) cat.integrations.includes(integration),
)?.title === 'Plugins' )?.title === 'Plugins'
? 500 ? 500
: 350 : 350,
) )
} }
hide={ hide={
@ -167,31 +169,34 @@ function Integrations(props: Props) {
} }
export default withPageTitle('Integrations - OpenReplay Preferences')( export default withPageTitle('Integrations - OpenReplay Preferences')(
observer(Integrations) observer(Integrations),
); );
const integrations = [ const integrations = (t: TFunction) => [
{ {
title: 'Issue Reporting', title: t('Issue Reporting'),
key: 'issue-reporting', key: 'issue-reporting',
description: description: t(
'Seamlessly report issues or share issues with your team right from OpenReplay.', '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: t('Jira'),
subtitle: subtitle: t(
'Integrate Jira with OpenReplay to enable the creation of a new ticket directly from a session.', '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: t('Github'),
subtitle: subtitle: t(
'Integrate GitHub with OpenReplay to enable the direct creation of a new issue from a session.', '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',
@ -200,52 +205,58 @@ const integrations = [
], ],
}, },
{ {
title: 'Backend Logging', title: t('Backend Logging'),
key: 'backend-logging', key: 'backend-logging',
isProject: true, isProject: true,
icon: 'terminal', icon: 'terminal',
description: description: t(
'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={t('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 {t(
front-to-back. 'Sync your backend errors with sessions replays and see what happened front-to-back.',
)}
</DocCard> </DocCard>
), ),
integrations: [ integrations: [
{ {
title: 'Sentry', title: t('Sentry'),
subtitle: subtitle: t(
'Integrate Sentry with session replays to seamlessly observe backend errors.', '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: 'Elasticsearch', title: t('Elasticsearch'),
subtitle: subtitle: t(
'Integrate Elasticsearch with session replays to seamlessly observe backend errors.', '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: t('Datadog'),
subtitle: subtitle: t(
'Incorporate DataDog to visualize backend errors alongside session replay, for easy troubleshooting.', '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: 'Dynatrace', title: t('Dynatrace'),
subtitle: subtitle: t(
'Integrate Dynatrace with session replays to link backend logs with user sessions for faster issue resolution.', 'Integrate Dynatrace with session replays to link backend logs with user sessions for faster issue resolution.',
),
slug: 'dynatrace', slug: 'dynatrace',
icon: 'integrations/dynatrace', icon: 'integrations/dynatrace',
useIcon: true, useIcon: true,
@ -254,17 +265,19 @@ const integrations = [
], ],
}, },
{ {
title: 'Collaboration', title: t('Collaboration'),
key: 'collaboration', key: 'collaboration',
isProject: false, isProject: false,
icon: 'file-code', icon: 'file-code',
description: description: t(
'Share your sessions with your team and collaborate on issues.', 'Share your sessions with your team and collaborate on issues.',
),
integrations: [ integrations: [
{ {
title: 'Slack', title: t('Slack'),
subtitle: subtitle: t(
'Integrate Slack to empower every user in your org with the ability to send sessions to any Slack channel.', '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',
@ -272,9 +285,10 @@ const integrations = [
shared: true, shared: true,
}, },
{ {
title: 'MS Teams', title: t('MS Teams'),
subtitle: subtitle: t(
'Integrate MS Teams to empower every user in your org with the ability to send sessions to any MS Teams channel.', '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',
@ -292,84 +306,95 @@ const integrations = [
// integrations: [] // integrations: []
// }, // },
{ {
title: 'Plugins', title: t('Plugins'),
key: 'plugins', key: 'plugins',
isProject: true, isProject: true,
icon: 'chat-left-text', icon: 'chat-left-text',
docs: () => ( docs: () => (
<DocCard <DocCard
title="What are plugins?" title={t('What are plugins?')}
icon="question-lg" icon="question-lg"
iconBgColor="bg-red-lightest" iconBgColor="bg-red-lightest"
iconColor="red" iconColor="red"
> >
Plugins capture your applications store, monitor queries, track {t(
performance issues and even assist your end user through live sessions. 'Plugins capture your applications store, monitor queries, track performance issues and even assist your end user through live sessions.',
)}
</DocCard> </DocCard>
), ),
description: description: t(
"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: t('Redux'),
subtitle: subtitle: t(
'Capture Redux actions/state and inspect them later on while replaying session recordings.', 'Capture Redux actions/state and inspect them later on while replaying session recordings.',
),
icon: 'integrations/redux', icon: 'integrations/redux',
component: <ReduxDoc />, component: <ReduxDoc />,
}, },
{ {
title: 'VueX', title: t('VueX'),
subtitle: subtitle: t(
'Capture VueX mutations/state and inspect them later on while replaying session recordings.', '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: t('Pinia'),
subtitle: subtitle: t(
'Capture Pinia mutations/state and inspect them later on while replaying session recordings.', '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: t('GraphQL'),
subtitle: subtitle: t(
'Capture GraphQL requests and inspect them later on while replaying session recordings. This plugin is compatible with Apollo and Relay implementations.', '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: t('NgRx'),
subtitle: subtitle: t(
'Capture NgRx actions/state and inspect them later on while replaying session recordings.\n', '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: t('MobX'),
subtitle: subtitle: t(
'Capture MobX mutations and inspect them later on while replaying session recordings.', '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: t('Profiler'),
subtitle: subtitle: t(
'Plugin allows you to measure your JS functions performance and capture both arguments and result for each call.', '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: t('Assist'),
subtitle: subtitle: t(
'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', '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: t('Zustand'),
subtitle: subtitle: t(
'Capture Zustand mutations/state and inspect them later on while replaying session recordings.', 'Capture Zustand mutations/state and inspect them later on while replaying session recordings.',
),
icon: 'integrations/zustand', icon: 'integrations/zustand',
// header: '🐻', // header: '🐻',
component: <ZustandDoc />, component: <ZustandDoc />,

View file

@ -1,57 +0,0 @@
import React from 'react';
import IntegrationForm from '../IntegrationForm';
import DocLink from 'Shared/DocLink/DocLink';
import { useModal } from 'App/components/Modal';
import IntegrationModalCard from 'Components/Client/Integrations/IntegrationModalCard';
const JiraForm = (props) => {
const { hideModal } = useModal();
return (
<div className='bg-white h-screen overflow-y-auto' style={{ width: '350px' }}>
<IntegrationModalCard title='Jira' icon='integrations/jira'
description='Integrate Jira with OpenReplay to enable the creation of a new ticket directly from a session.' />
<div className='border-b my-4 p-5'>
<div className='font-medium mb-1'>How it works?</div>
<ol className='list-decimal list-inside'>
<li>Create a new API token</li>
<li>Enter the token below</li>
</ol>
<div className='mt-8'>
<DocLink className='mt-4' label='Integrate Jira Cloud'
url='https://docs.openreplay.com/integrations/jira' />
</div>
</div>
<IntegrationForm
{...props}
ignoreProject={true}
name='jira'
customPath='jira'
onClose={hideModal}
formFields={[
{
key: 'username',
label: 'Username',
autoFocus: true
},
{
key: 'token',
label: 'API Token'
},
{
key: 'url',
label: 'JIRA URL',
placeholder: 'E.x. https://myjira.atlassian.net'
}
]}
/>
</div>
);
};
JiraForm.displayName = 'JiraForm';
export default JiraForm;

View file

@ -0,0 +1,68 @@
import React from 'react';
import DocLink from 'Shared/DocLink/DocLink';
import { useModal } from 'App/components/Modal';
import IntegrationModalCard from 'Components/Client/Integrations/IntegrationModalCard';
import IntegrationForm from '../IntegrationForm';
import { useTranslation } from 'react-i18next';
function JiraForm(props) {
const { t } = useTranslation();
const { hideModal } = useModal();
return (
<div
className="bg-white h-screen overflow-y-auto"
style={{ width: '350px' }}
>
<IntegrationModalCard
title={t('Jira')}
icon="integrations/jira"
description={t(
'Integrate Jira with OpenReplay to enable the creation of a new ticket directly from a session.',
)}
/>
<div className="border-b my-4 p-5">
<div className="font-medium mb-1">{t('How it works?')}</div>
<ol className="list-decimal list-inside">
<li>{t('Create a new API token')}</li>
<li>{t('Enter the token below')}</li>
</ol>
<div className="mt-8">
<DocLink
className="mt-4"
label={t('Integrate Jira Cloud')}
url="https://docs.openreplay.com/integrations/jira"
/>
</div>
</div>
<IntegrationForm
{...props}
ignoreProject
name="jira"
customPath="jira"
onClose={hideModal}
formFields={[
{
key: 'username',
label: t('Username'),
autoFocus: true,
},
{
key: 'token',
label: t('API Token'),
},
{
key: 'url',
label: t('JIRA URL'),
placeholder: 'E.x. https://myjira.atlassian.net',
},
]}
/>
</div>
);
}
JiraForm.displayName = 'JiraForm';
export default JiraForm;

View file

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

View file

@ -1,16 +1,20 @@
import { useStore } from "App/mstore"; import { useStore } from 'App/mstore';
import React from 'react'; import React from 'react';
import { observer } from 'mobx-react-lite'; 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';
import { useTranslation } from 'react-i18next';
const ProfilerDoc = () => { function ProfilerDoc() {
const { t } = useTranslation();
const { integrationsStore, projectsStore } = useStore(); const { integrationsStore, projectsStore } = useStore();
const sites = projectsStore.list; const sites = projectsStore.list;
const siteId = integrationsStore.integrations.siteId const { siteId } = integrationsStore.integrations;
const projectKey = siteId ? sites.find((site) => site.id === siteId)?.projectKey : sites[0]?.projectKey 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';
@ -48,43 +52,45 @@ const fn = profiler('call_name')(() => {
className="bg-white h-screen overflow-y-auto" className="bg-white h-screen overflow-y-auto"
style={{ width: '500px' }} style={{ width: '500px' }}
> >
<h3 className="p-5 text-2xl">Profiler</h3> <h3 className="p-5 text-2xl">{t('Profiler')}</h3>
<div className="p-5"> <div className="p-5">
<div> <div>
The profiler plugin allows you to measure your JS functions' {t(
performance and capture both arguments and result for each function 'The profiler plugin allows you to measure your JS functions performance and capture both arguments and result for each function call',
call. )}
.
</div> </div>
<div className="font-bold my-2">Installation</div> <div className="font-bold my-2">{t('Installation')}</div>
<CodeBlock <CodeBlock
code={`npm i @openreplay/tracker-profiler --save`} code="npm i @openreplay/tracker-profiler --save"
language={'bash'} language="bash"
/> />
<div className="font-bold my-2">Usage</div> <div className="font-bold my-2">{t('Usage')}</div>
<p> <p>
Initialize the tracker and load the plugin into it. Then decorate any {t(
function inside your code with the generated function. 'Initialize the tracker and load the plugin into it. Then decorate any function inside your code with the generated function.',
)}
</p> </p>
<div className="py-3" /> <div className="py-3" />
<div className="font-bold my-2">Usage</div> <div className="font-bold my-2">{t('Usage')}</div>
<ToggleContent <ToggleContent
label="Server-Side-Rendered (SSR)?" label={t('Server-Side-Rendered (SSR)?')}
first={<CodeBlock language={'js'} code={usage} />} first={<CodeBlock language="js" code={usage} />}
second={<CodeBlock language={'jsx'} code={usageCjs} />} second={<CodeBlock language="jsx" code={usageCjs} />}
/> />
<DocLink <DocLink
className="mt-4" className="mt-4"
label="Integrate Profiler" label={t('Integrate Profiler')}
url="https://docs.openreplay.com/plugins/profiler" url="https://docs.openreplay.com/plugins/profiler"
/> />
</div> </div>
</div> </div>
); );
}; }
ProfilerDoc.displayName = 'ProfilerDoc'; ProfilerDoc.displayName = 'ProfilerDoc';

View file

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

View file

@ -1,25 +1,24 @@
import React from 'react'; import React from 'react';
import { Form, Input, Message, confirm } from 'UI'; import { Form, Input, Message, confirm } from 'UI';
import { Button } from 'antd' import { Button } from 'antd';
import { observer } from 'mobx-react-lite' import { observer } from 'mobx-react-lite';
import { useStore } from 'App/mstore' import { useStore } from 'App/mstore';
import { useTranslation } from 'react-i18next';
function SlackAddForm(props) { function SlackAddForm(props) {
const { t } = useTranslation();
const { onClose } = props; const { onClose } = props;
const { integrationsStore } = useStore(); const { integrationsStore } = useStore();
const instance = integrationsStore.slack.instance; const { instance } = integrationsStore.slack;
const saving = integrationsStore.slack.loading; const saving = integrationsStore.slack.loading;
const errors = integrationsStore.slack.errors; const { errors } = integrationsStore.slack;
const edit = integrationsStore.slack.edit; const { edit } = integrationsStore.slack;
const onSave = integrationsStore.slack.saveIntegration; const onSave = integrationsStore.slack.saveIntegration;
const update = integrationsStore.slack.update; const { update } = integrationsStore.slack;
const init = integrationsStore.slack.init; const { init } = integrationsStore.slack;
const onRemove = integrationsStore.slack.removeInt; const onRemove = integrationsStore.slack.removeInt;
React.useEffect(() => {
return () => init({})
}, [])
React.useEffect(() => () => init({}), []);
const save = () => { const save = () => {
if (instance.exists()) { if (instance.exists()) {
@ -32,9 +31,11 @@ function SlackAddForm(props) {
const remove = async (id) => { const remove = async (id) => {
if ( if (
await confirm({ await confirm({
header: 'Confirm', header: t('Confirm'),
confirmButton: 'Yes, delete', confirmButton: t('Yes, delete'),
confirmation: `Are you sure you want to permanently delete this channel?`, confirmation: t(
'Are you sure you want to permanently delete this channel?',
),
}) })
) { ) {
await onRemove(id); await onRemove(id);
@ -43,27 +44,27 @@ function SlackAddForm(props) {
}; };
const write = ({ target: { name, value } }) => edit({ [name]: value }); const write = ({ target: { name, value } }) => edit({ [name]: value });
return ( return (
<div className="p-5" style={{ minWidth: '300px' }}> <div className="p-5" style={{ minWidth: '300px' }}>
<Form> <Form>
<Form.Field> <Form.Field>
<label>Name</label> <label>{t('Name')}</label>
<Input <Input
name="name" name="name"
value={instance.name} value={instance.name}
onChange={write} onChange={write}
placeholder="Enter any name" placeholder={t('Enter any name')}
type="text" type="text"
/> />
</Form.Field> </Form.Field>
<Form.Field> <Form.Field>
<label>URL</label> <label>{t('URL')}</label>
<Input <Input
name="endpoint" name="endpoint"
value={instance.endpoint} value={instance.endpoint}
onChange={write} onChange={write}
placeholder="Slack webhook URL" placeholder={t('Slack webhook URL')}
type="text" type="text"
/> />
</Form.Field> </Form.Field>
@ -76,14 +77,17 @@ function SlackAddForm(props) {
type="primary" type="primary"
className="float-left mr-2" className="float-left mr-2"
> >
{instance.exists() ? 'Update' : 'Add'} {instance.exists() ? t('Update') : t('Add')}
</Button> </Button>
<Button onClick={onClose}>{'Cancel'}</Button> <Button onClick={onClose}>{t('Cancel')}</Button>
</div> </div>
<Button onClick={() => remove(instance.webhookId)} disabled={!instance.exists()}> <Button
{'Delete'} onClick={() => remove(instance.webhookId)}
disabled={!instance.exists()}
>
{t('Delete')}
</Button> </Button>
</div> </div>
</Form> </Form>

View file

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

View file

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

View file

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

View file

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

View file

@ -1,48 +1,58 @@
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import SlackChannelList from './SlackChannelList/SlackChannelList';
import SlackAddForm from './SlackAddForm';
import { Icon } from 'UI'; import { Icon } from 'UI';
import { Button } from 'antd'; import { Button } from 'antd';
import { observer } from 'mobx-react-lite' import { observer } from 'mobx-react-lite';
import { useStore } from 'App/mstore' import { useStore } from 'App/mstore';
import SlackAddForm from './SlackAddForm';
import SlackChannelList from './SlackChannelList/SlackChannelList';
import { useTranslation } from 'react-i18next';
const SlackForm = () => { function SlackForm() {
const { integrationsStore } = useStore(); const { t } = useTranslation();
const init = integrationsStore.slack.init; const { integrationsStore } = useStore();
const fetchList = integrationsStore.slack.fetchIntegrations; const { init } = integrationsStore.slack;
const [active, setActive] = React.useState(false); const fetchList = integrationsStore.slack.fetchIntegrations;
const [active, setActive] = React.useState(false);
const onEdit = () => { const onEdit = () => {
setActive(true); setActive(true);
}; };
const onNew = () => { const onNew = () => {
setActive(true); setActive(true);
init({}); init({});
} };
useEffect(() => { useEffect(() => {
void fetchList(); void fetchList();
}, []); }, []);
return ( return (
<div className="bg-white h-screen overflow-y-auto flex items-start" style={{ width: active ? '700px' : '350px' }}> <div
{active && ( className="bg-white h-screen overflow-y-auto flex items-start"
<div className="border-r h-full" style={{ width: '350px' }}> style={{ width: active ? '700px' : '350px' }}
<SlackAddForm onClose={() => setActive(false)} /> >
</div> {active && (
)} <div className="border-r h-full" style={{ width: '350px' }}>
<div className="shrink-0" style={{ width: '350px' }}> <SlackAddForm onClose={() => setActive(false)} />
<div className="flex items-center p-5">
<h3 className="text-2xl mr-3">Slack</h3>
<Button shape={'circle'} type={'text'} icon={<Icon name={"plus"} size={24} />} onClick={onNew}/>
</div>
<SlackChannelList onEdit={onEdit} />
</div>
</div> </div>
); )}
}; <div className="shrink-0" style={{ width: '350px' }}>
<div className="flex items-center p-5">
<h3 className="text-2xl mr-3">{t('Slack')}</h3>
<Button
shape="circle"
type="text"
icon={<Icon name="plus" size={24} />}
onClick={onNew}
/>
</div>
<SlackChannelList onEdit={onEdit} />
</div>
</div>
);
}
SlackForm.displayName = 'SlackForm'; SlackForm.displayName = 'SlackForm';
export default observer(SlackForm); export default observer(SlackForm);

View file

@ -3,26 +3,26 @@ import React from 'react';
import { useStore } from 'App/mstore'; import { useStore } from 'App/mstore';
import { confirm, Form, Input, Message } from 'UI'; import { confirm, Form, Input, Message } from 'UI';
import { Button } from 'antd' import { Button } from 'antd';
import { useTranslation } from 'react-i18next';
interface Props { interface Props {
onClose: () => void; onClose: () => void;
} }
function TeamsAddForm({ onClose }: Props) { function TeamsAddForm({ onClose }: Props) {
const { t } = useTranslation();
const { integrationsStore } = useStore(); const { integrationsStore } = useStore();
const instance = integrationsStore.msteams.instance; const { instance } = integrationsStore.msteams;
const saving = integrationsStore.msteams.loading; const saving = integrationsStore.msteams.loading;
const errors = integrationsStore.msteams.errors; const { errors } = integrationsStore.msteams;
const edit = integrationsStore.msteams.edit; const { edit } = integrationsStore.msteams;
const onSave = integrationsStore.msteams.saveIntegration; const onSave = integrationsStore.msteams.saveIntegration;
const init = integrationsStore.msteams.init; const { init } = integrationsStore.msteams;
const onRemove = integrationsStore.msteams.removeInt; const onRemove = integrationsStore.msteams.removeInt;
const update = integrationsStore.msteams.update; const { update } = integrationsStore.msteams;
React.useEffect(() => { React.useEffect(() => () => init({}), []);
return () => init({});
}, []);
const save = () => { const save = () => {
if (instance?.exists()) { if (instance?.exists()) {
@ -39,9 +39,11 @@ function TeamsAddForm({ onClose }: Props) {
const remove = async (id: string) => { const remove = async (id: string) => {
if ( if (
await confirm({ await confirm({
header: 'Confirm', header: t('Confirm'),
confirmButton: 'Yes, delete', confirmButton: t('Yes, delete'),
confirmation: `Are you sure you want to permanently delete this channel?` confirmation: t(
'Are you sure you want to permanently delete this channel?',
),
}) })
) { ) {
void onRemove(id).then(onClose); void onRemove(id).then(onClose);
@ -49,8 +51,8 @@ function TeamsAddForm({ onClose }: Props) {
}; };
const write = ({ const write = ({
target: { name, value } target: { name, value },
}: { }: {
target: { name: string; value: string }; target: { name: string; value: string };
}) => edit({ [name]: value }); }) => edit({ [name]: value });
@ -58,22 +60,22 @@ function TeamsAddForm({ onClose }: Props) {
<div className="p-5" style={{ minWidth: '300px' }}> <div className="p-5" style={{ minWidth: '300px' }}>
<Form> <Form>
<Form.Field> <Form.Field>
<label>Name</label> <label>{t('Name')}</label>
<Input <Input
name="name" name="name"
value={instance?.name} value={instance?.name}
onChange={write} onChange={write}
placeholder="Enter any name" placeholder={t('Enter any name')}
type="text" type="text"
/> />
</Form.Field> </Form.Field>
<Form.Field> <Form.Field>
<label>URL</label> <label>{t('URL')}</label>
<Input <Input
name="endpoint" name="endpoint"
value={instance?.endpoint} value={instance?.endpoint}
onChange={write} onChange={write}
placeholder="Teams webhook URL" placeholder={t('Teams webhook URL')}
type="text" type="text"
/> />
</Form.Field> </Form.Field>
@ -86,17 +88,17 @@ function TeamsAddForm({ onClose }: Props) {
type="primary" type="primary"
className="float-left mr-2" className="float-left mr-2"
> >
{instance?.exists() ? 'Update' : 'Add'} {instance?.exists() ? t('Update') : t('Add')}
</Button> </Button>
<Button onClick={onClose}>{'Cancel'}</Button> <Button onClick={onClose}>{t('Cancel')}</Button>
</div> </div>
<Button <Button
onClick={() => remove(instance?.webhookId)} onClick={() => remove(instance?.webhookId)}
disabled={!instance.exists()} disabled={!instance.exists()}
> >
{'Delete'} {t('Delete')}
</Button> </Button>
</div> </div>
</Form> </Form>

View file

@ -5,11 +5,13 @@ import { useStore } from 'App/mstore';
import { NoContent } from 'UI'; import { NoContent } from 'UI';
import DocLink from 'Shared/DocLink/DocLink'; import DocLink from 'Shared/DocLink/DocLink';
import { useTranslation } from 'react-i18next';
function TeamsChannelList(props: { onEdit: () => void }) { function TeamsChannelList(props: { onEdit: () => void }) {
const { t } = useTranslation();
const { integrationsStore } = useStore(); const { integrationsStore } = useStore();
const list = integrationsStore.msteams.list; const { list } = integrationsStore.msteams;
const edit = integrationsStore.msteams.edit; const { edit } = integrationsStore.msteams;
const onEdit = (instance: Record<string, any>) => { const onEdit = (instance: Record<string, any>) => {
edit(instance); edit(instance);
@ -22,12 +24,11 @@ function TeamsChannelList(props: { onEdit: () => void }) {
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 {t('Integrate MS Teams with OpenReplay and share insights with the rest of the team, directly from the recording page.')}
rest of the team, directly from the recording page.
</div> </div>
<DocLink <DocLink
className="mt-4 text-base" className="mt-4 text-base"
label="Integrate MS Teams" label={t('Integrate MS Teams')}
url="https://docs.openreplay.com/integrations/msteams" url="https://docs.openreplay.com/integrations/msteams"
/> />
</div> </div>

View file

@ -2,47 +2,57 @@ import React, { useEffect } 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 { Icon } from 'UI'; import { Icon } from 'UI';
import { Button } from 'antd' import { Button } from 'antd';
import TeamsChannelList from './TeamsChannelList'; import TeamsChannelList from './TeamsChannelList';
import TeamsAddForm from './TeamsAddForm'; import TeamsAddForm from './TeamsAddForm';
import { useTranslation } from 'react-i18next';
const MSTeams = () => { function MSTeams() {
const { integrationsStore } = useStore(); const { t } = useTranslation();
const fetchList = integrationsStore.msteams.fetchIntegrations; const { integrationsStore } = useStore();
const init = integrationsStore.msteams.init; const fetchList = integrationsStore.msteams.fetchIntegrations;
const [active, setActive] = React.useState(false); const { init } = integrationsStore.msteams;
const [active, setActive] = React.useState(false);
const onEdit = () => { const onEdit = () => {
setActive(true); setActive(true);
}; };
const onNew = () => { const onNew = () => {
setActive(true); setActive(true);
init({}); init({});
} };
useEffect(() => { useEffect(() => {
void fetchList(); void fetchList();
}, []); }, []);
return ( return (
<div className="bg-white h-screen overflow-y-auto flex items-start" style={{ width: active ? '700px' : '350px' }}> <div
{active && ( className="bg-white h-screen overflow-y-auto flex items-start"
<div className="border-r h-full" style={{ width: '350px' }}> style={{ width: active ? '700px' : '350px' }}
<TeamsAddForm onClose={() => setActive(false)} /> >
</div> {active && (
)} <div className="border-r h-full" style={{ width: '350px' }}>
<div className="shrink-0" style={{ width: '350px' }}> <TeamsAddForm onClose={() => setActive(false)} />
<div className="flex items-center p-5">
<h3 className="text-2xl mr-3">Microsoft Teams</h3>
<Button shape={'circle'} icon={<Icon name={'plus'} size={24} />} type="text" onClick={onNew}/>
</div>
<TeamsChannelList onEdit={onEdit} />
</div>
</div> </div>
); )}
}; <div className="shrink-0" style={{ width: '350px' }}>
<div className="flex items-center p-5">
<h3 className="text-2xl mr-3">{t('Microsoft Teams')}</h3>
<Button
shape="circle"
icon={<Icon name="plus" size={24} />}
type="text"
onClick={onNew}
/>
</div>
<TeamsChannelList onEdit={onEdit} />
</div>
</div>
);
}
MSTeams.displayName = 'MSTeams'; MSTeams.displayName = 'MSTeams';

View file

@ -1,60 +0,0 @@
import { useStore } from "App/mstore";
import React from 'react';
import DocLink from 'Shared/DocLink/DocLink';
import AssistScript from './AssistScript';
import AssistNpm from './AssistNpm';
import { Tabs, CodeBlock } from 'UI';
import { useState } from 'react';
import { observer } from 'mobx-react-lite'
const NPM = 'NPM';
const SCRIPT = 'SCRIPT';
const TABS = [
{ key: SCRIPT, text: SCRIPT },
{ key: NPM, text: NPM },
];
const AssistDoc = () => {
const { integrationsStore, projectsStore } = useStore();
const sites = projectsStore.list;
const siteId = integrationsStore.integrations.siteId
const projectKey = siteId ? sites.find((site) => site.id === siteId)?.projectKey : sites[0]?.projectKey
const [activeTab, setActiveTab] = useState(SCRIPT);
const renderActiveTab = () => {
switch (activeTab) {
case SCRIPT:
return <AssistScript projectKey={projectKey} />;
case NPM:
return <AssistNpm projectKey={projectKey} />;
}
return null;
};
return (
<div className="bg-white h-screen overflow-y-auto" style={{ width: '500px' }}>
<h3 className="p-5 text-2xl">Assist</h3>
<div className="p-5">
<div>
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.
</div>
<div className="font-bold my-2">Installation</div>
<CodeBlock language={'bash'} code={`npm i @openreplay/tracker-assist`} />
<div className="mb-4" />
<div className="font-bold my-2">Usage</div>
<Tabs tabs={TABS} active={activeTab} onClick={(tab) => setActiveTab(tab)} />
<div className="py-5">{renderActiveTab()}</div>
<DocLink className="mt-4" label="Install Assist" url="https://docs.openreplay.com/installation/assist" />
</div>
</div>
);
};
AssistDoc.displayName = 'AssistDoc';
export default observer(AssistDoc);

View file

@ -0,0 +1,75 @@
import { useStore } from 'App/mstore';
import React, { useState } from 'react';
import DocLink from 'Shared/DocLink/DocLink';
import { Tabs, CodeBlock } from 'UI';
import { observer } from 'mobx-react-lite';
import AssistScript from './AssistScript';
import AssistNpm from './AssistNpm';
import { useTranslation } from 'react-i18next';
const NPM = 'NPM';
const SCRIPT = 'SCRIPT';
const TABS = [
{ key: SCRIPT, text: SCRIPT },
{ key: NPM, text: NPM },
];
function AssistDoc() {
const { t } = useTranslation();
const { integrationsStore, projectsStore } = useStore();
const sites = projectsStore.list;
const { siteId } = integrationsStore.integrations;
const projectKey = siteId
? sites.find((site) => site.id === siteId)?.projectKey
: sites[0]?.projectKey;
const [activeTab, setActiveTab] = useState(SCRIPT);
const renderActiveTab = () => {
switch (activeTab) {
case SCRIPT:
return <AssistScript projectKey={projectKey} />;
case NPM:
return <AssistNpm projectKey={projectKey} />;
}
return null;
};
return (
<div
className="bg-white h-screen overflow-y-auto"
style={{ width: '500px' }}
>
<h3 className="p-5 text-2xl">{t('Assist')}</h3>
<div className="p-5">
<div>
{t(
'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.',
)}
</div>
<div className="font-bold my-2">{t('Installation')}</div>
<CodeBlock language="bash" code="npm i @openreplay/tracker-assist" />
<div className="mb-4" />
<div className="font-bold my-2">{t('Usage')}</div>
<Tabs
tabs={TABS}
active={activeTab}
onClick={(tab) => setActiveTab(tab)}
/>
<div className="py-5">{renderActiveTab()}</div>
<DocLink
className="mt-4"
label={t('Install Assist')}
url="https://docs.openreplay.com/installation/assist"
/>
</div>
</div>
);
}
AssistDoc.displayName = 'AssistDoc';
export default observer(AssistDoc);

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