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,
"importOrderSeparation": 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;
}
const AdditionalRoutes = (props: Props) => {
function AdditionalRoutes(props: Props) {
const { redirect } = props;
return (
<>
<Redirect to={redirect} />
</>
);
};
return <Redirect to={redirect} />;
}
export default AdditionalRoutes;

View file

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

View file

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

View file

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

View file

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

View file

@ -30,15 +30,18 @@ const siteIdRequiredPaths: string[] = [
'/check-recording-status',
'/usability-tests',
'/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)
? new Array(obj.length).fill().map((_, i) => i)
: Object.keys(obj);
const retObj = Array.isArray(obj) ? [] : {};
keys.map(key => {
keys.map((key) => {
const value = obj[key];
if (typeof value === 'object' && value !== null) {
retObj[key] = clean(value);
@ -52,18 +55,23 @@ export const clean = (obj: any, forbiddenValues: any[] = [undefined, '']): any =
export default class APIClient {
private init: RequestInit;
private siteId: string | undefined;
private siteIdCheck: (() => { siteId: string | null }) | undefined;
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;
constructor() {
this.init = {
headers: new Headers({
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;
}
@ -85,7 +95,11 @@ export default class APIClient {
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
const jwt = this.getJwt();
const headers = new Headers({
@ -148,7 +162,7 @@ export default class APIClient {
params?: any,
method: string = 'GET',
options: { clean?: boolean } = { clean: true },
headers?: Record<string, any>
headers?: Record<string, any>,
): Promise<Response> {
let _path = path;
let jwt = this.getJwt();
@ -157,7 +171,11 @@ export default class APIClient {
(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) {
const cleanedParams = options.clean ? clean(params) : params;
@ -193,7 +211,7 @@ export default class APIClient {
edp = `${edp}/${this.siteId ?? ''}`;
}
if (path.includes('PROJECT_ID')) {
_path = _path.replace('PROJECT_ID', this.siteId + '');
_path = _path.replace('PROJECT_ID', `${this.siteId}`);
}
const fullUrl = edp + _path;
@ -205,7 +223,7 @@ export default class APIClient {
if (response.ok) {
return response;
}
let errorMsg = `Something went wrong.`;
let errorMsg = 'Something went wrong.';
try {
const errorData = await response.json();
errorMsg = errorData.errors?.[0] || errorMsg;
@ -216,9 +234,14 @@ export default class APIClient {
async refreshToken(): Promise<string> {
try {
const response = await this.fetch('/refresh', {
headers: this.init.headers
}, 'GET', { clean: false });
const response = await this.fetch(
'/refresh',
{
headers: this.init.headers,
},
'GET',
{ clean: false },
);
if (!response.ok) {
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';
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';
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,17 +1,15 @@
import React, {useEffect, useState} from 'react';
import {SlideModal} from 'UI';
import {useStore} from 'App/mstore'
import {observer} from 'mobx-react-lite'
import React, { useEffect, useState } from 'react';
import { SlideModal, confirm } from 'UI';
import { useStore } from 'App/mstore';
import { observer } from 'mobx-react-lite';
import { SLACK, TEAMS, WEBHOOK } from 'App/constants/schedule';
import AlertForm from '../AlertForm';
import {SLACK, TEAMS, WEBHOOK} from 'App/constants/schedule';
import {confirm} from 'UI';
interface Select {
label: string;
value: string | number
value: string | number;
}
interface Props {
showModal?: boolean;
metricId?: number;
@ -19,31 +17,30 @@ interface Props {
}
function AlertFormModal(props: Props) {
const {alertsStore, settingsStore} = useStore()
const {metricId = null, showModal = false} = props;
const { alertsStore, settingsStore } = useStore();
const { metricId = null, showModal = false } = props;
const [showForm, setShowForm] = useState(false);
const webhooks = settingsStore.webhooks
const { webhooks } = settingsStore;
useEffect(() => {
settingsStore.fetchWebhooks();
}, []);
const slackChannels: Select[] = []
const hooks: Select[] = []
const msTeamsChannels: Select[] = []
const slackChannels: Select[] = [];
const hooks: Select[] = [];
const msTeamsChannels: Select[] = [];
webhooks.forEach((hook) => {
const option = {value: hook.webhookId, label: hook.name}
const option = { value: hook.webhookId, label: hook.name };
if (hook.type === SLACK) {
slackChannels.push(option)
slackChannels.push(option);
}
if (hook.type === WEBHOOK) {
hooks.push(option)
hooks.push(option);
}
if (hook.type === TEAMS) {
msTeamsChannels.push(option)
msTeamsChannels.push(option);
}
})
});
const saveAlert = (instance) => {
const wasUpdating = instance.exists();
@ -62,7 +59,7 @@ function AlertFormModal(props: Props) {
await confirm({
header: 'Confirm',
confirmButton: 'Yes, delete',
confirmation: `Are you sure you want to permanently delete this alert?`,
confirmation: 'Are you sure you want to permanently delete this alert?',
})
) {
alertsStore.remove(instance.alertId).then(() => {
@ -75,7 +72,7 @@ function AlertFormModal(props: Props) {
if (instance) {
alertsStore.init(instance);
}
return setShowForm(state ? state : !showForm);
return setShowForm(state || !showForm);
};
return (

View file

@ -2,7 +2,7 @@ import React from 'react';
import { Input, TagBadge } from 'UI';
import Select from 'Shared/Select';
const DropdownChips = ({
function DropdownChips({
textFiled = false,
validate = null,
placeholder = '',
@ -11,7 +11,7 @@ const DropdownChips = ({
badgeClassName = 'lowercase',
onChange = () => null,
...props
}) => {
}) {
const onRemove = (id) => {
onChange(selected.filter((i) => i !== id));
};
@ -38,7 +38,16 @@ const DropdownChips = ({
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} />;
return (
<TagBadge
className={badgeClassName}
key={text}
text={text}
hashed={false}
onRemove={() => onRemove(val)}
outline
/>
);
};
return (
@ -48,7 +57,7 @@ const DropdownChips = ({
) : (
<Select
placeholder={placeholder}
isSearchable={true}
isSearchable
options={_options}
name="webhookInput"
value={null}
@ -57,10 +66,12 @@ const DropdownChips = ({
/>
)}
<div className="flex flex-wrap mt-3">
{textFiled ? selected.map(renderBadge) : options.filter((i) => selected.includes(i.value)).map(renderBadge)}
{textFiled
? selected.map(renderBadge)
: options.filter((i) => selected.includes(i.value)).map(renderBadge)}
</div>
</div>
);
};
}
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 { BellOutlined } from '@ant-design/icons';
const AUTOREFRESH_INTERVAL = 5 * 60 * 1000;
function Notifications() {
@ -28,12 +27,13 @@ function Notifications() {
}, []);
return (
<Badge dot={count > 0} size='small'>
<Tooltip title='Alerts'>
<Badge dot={count > 0} size="small">
<Tooltip title="Alerts">
<Button
icon={<BellOutlined />}
onClick={() => showModal(<AlertTriggersModal />, { right: true })}>
{/*<Icon name='bell' size='18' color='gray-dark' />*/}
onClick={() => showModal(<AlertTriggersModal />, { right: true })}
>
{/* <Icon name='bell' size='18' color='gray-dark' /> */}
</Button>
</Tooltip>
</Badge>

View file

@ -4,12 +4,14 @@ import withPermissions from 'HOCs/withPermissions';
import AssistRouter from './AssistRouter';
function Assist() {
return (
<AssistRouter />
);
return <AssistRouter />;
}
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 AssistView from './AssistView'
import AssistView from './AssistView';
function AssistRouter() {
return (

View file

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

View file

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

View file

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

View file

@ -1,50 +1,91 @@
import React, { useState } from 'react'
import stl from './ChatControls.module.css'
import cn from 'classnames'
import { Icon } from 'UI'
import { Button } from 'antd'
import React, { useState } from 'react';
import cn from 'classnames';
import { Icon } from 'UI';
import { Button } from 'antd';
import type { LocalStream } from 'Player';
import stl from './ChatControls.module.css';
interface Props {
stream: LocalStream | null,
endCall: () => void,
videoEnabled: boolean,
isPrestart?: boolean,
setVideoEnabled: (isEnabled: boolean) => void
stream: LocalStream | null;
endCall: () => void;
videoEnabled: boolean;
isPrestart?: boolean;
setVideoEnabled: (isEnabled: boolean) => void;
}
function ChatControls({ stream, endCall, videoEnabled, setVideoEnabled, isPrestart } : Props) {
const [audioEnabled, setAudioEnabled] = useState(true)
function ChatControls({
stream,
endCall,
videoEnabled,
setVideoEnabled,
isPrestart,
}: Props) {
const [audioEnabled, setAudioEnabled] = useState(true);
const toggleAudio = () => {
if (!stream) { return; }
setAudioEnabled(stream.toggleAudio());
if (!stream) {
return;
}
setAudioEnabled(stream.toggleAudio());
};
const toggleVideo = () => {
if (!stream) { return; }
stream.toggleVideo()
.then((v) => setVideoEnabled(v))
if (!stream) {
return;
}
stream.toggleVideo().then((v) => setVideoEnabled(v));
};
/** muting user if he is auto connected to the call */
React.useEffect(() => {
if (isPrestart) {
audioEnabled && toggleAudio();
}
}, [])
}, []);
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={cn(stl.btnWrapper, { [stl.disabled]: audioEnabled})}>
<Button 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>
<div className={cn(stl.btnWrapper, { [stl.disabled]: audioEnabled })}>
<Button
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>
</div>
<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" />}>
<span className={cn("ml-1 color-gray-medium text-sm", { 'color-red' : videoEnabled })}>{videoEnabled ? 'Stop Video' : 'Start Video'}</span>
<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"
/>
}
>
<span
className={cn('ml-1 color-gray-medium text-sm', {
'color-red': videoEnabled,
})}
>
{videoEnabled ? 'Stop Video' : 'Start Video'}
</span>
</Button>
</div>
</div>
@ -54,7 +95,7 @@ function ChatControls({ stream, endCall, videoEnabled, setVideoEnabled, isPresta
</button>
</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 VideoContainer from '../components/VideoContainer';
import cn from 'classnames';
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 type { LocalStream } from 'Player';
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 {
incomeStream: { stream: MediaStream, isAgent: boolean }[] | null;
incomeStream: { stream: MediaStream; isAgent: boolean }[] | null;
localStream: LocalStream | null;
userId: string;
isPrestart?: boolean;
endCall: () => void;
}
function ChatWindow({ userId, incomeStream, localStream, endCall, isPrestart }: Props) {
const { player } = React.useContext(PlayerContext)
function ChatWindow({
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 [anyRemoteEnabled, setRemoteEnabled] = useState(false);
@ -27,22 +35,31 @@ function ChatWindow({ userId, incomeStream, localStream, endCall, isPrestart }:
const onlyLocalEnabled = localVideoEnabled && !anyRemoteEnabled;
useEffect(() => {
toggleVideoLocalStream(localVideoEnabled)
}, [localVideoEnabled])
toggleVideoLocalStream(localVideoEnabled);
}, [localVideoEnabled]);
return (
<Draggable handle=".handle" bounds="body" defaultPosition={{ x: 50, y: 200 }}>
<Draggable
handle=".handle"
bounds="body"
defaultPosition={{ x: 50, y: 200 }}
>
<div
className={cn(stl.wrapper, 'fixed radius bg-white shadow-xl mt-16')}
style={{ width: '280px' }}
>
<div className="handle flex items-center p-2 cursor-move select-none border-b">
<div className={stl.headerTitle}>
<b>Call with </b> {userId ? userId : 'Anonymous User'}
<b>{t('Call with')}&nbsp;</b> {userId || t('Anonymous User')}
<br />
{incomeStream && incomeStream.length > 2 ? ' (+ other agents in the call)' : ''}
{incomeStream && incomeStream.length > 2
? t(' (+ other agents in the call)')
: ''}
</div>
<Counter startTime={new Date().getTime()} className="text-sm ml-auto" />
<Counter
startTime={new Date().getTime()}
className="text-sm ml-auto"
/>
</div>
<div
className={cn(stl.videoWrapper, 'relative')}
@ -51,13 +68,24 @@ function ChatWindow({ userId, incomeStream, localStream, endCall, isPrestart }:
{incomeStream ? (
incomeStream.map((stream) => (
<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>
))
) : (
<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
stream={localStream ? localStream.stream : null}
muted

View file

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

View file

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

View file

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

View file

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

View file

@ -4,24 +4,31 @@ import { useStore } from 'App/mstore';
import { Icon } from 'UI';
import { debounce } from 'App/utils';
let debounceUpdate: any = () => {}
let debounceUpdate: any = () => {};
function RecordingsSearch() {
const { recordingsStore } = useStore();
const [query, setQuery] = useState(recordingsStore.search);
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" />
<Icon
name="search"
className="absolute top-0 bottom-0 ml-2 m-auto"
size="16"
/>
<input
value={query}
name="recordsSearch"

View file

@ -6,12 +6,14 @@ import { useStore } from 'App/mstore';
import { toast } from 'react-toastify';
import cn from 'classnames';
import EditRecordingModal from './EditRecordingModal';
import { useTranslation } from 'react-i18next';
interface Props {
record: IRecord;
}
function RecordsListItem(props: Props) {
const { t } = useTranslation();
const { record } = props;
const { recordingsStore, settingsStore } = useStore();
const { timezone } = settingsStore.sessionSettings;
@ -34,17 +36,19 @@ function RecordsListItem(props: Props) {
const onDelete = () => {
recordingsStore.deleteRecording(record.recordId).then(() => {
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 = [
{ icon: 'pencil', text: 'Rename', onClick: () => setEdit(true) },
{ icon: 'pencil', text: t('Rename'), onClick: () => setEdit(true) },
{
icon: 'trash',
text: 'Delete',
text: t('Delete'),
onClick: onDelete,
},
];
@ -54,9 +58,9 @@ function RecordsListItem(props: Props) {
.updateRecordingName(record.recordId, title)
.then(() => {
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);
};
@ -78,7 +82,9 @@ function RecordsListItem(props: Props) {
</div>
<div className="flex flex-col">
<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>
@ -95,14 +101,19 @@ function RecordsListItem(props: Props) {
className="group flex items-center gap-1 cursor-pointer link"
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
name="play-fill-new"
size={18}
color="teal"
className="!hidden group-hover:!block"
/>
<div>Play Video</div>
<div>{t('Play Video')}</div>
</div>
<div className="hover:border-teal border border-transparent rounded-full">
<ItemMenu bold items={menuItems} sm />

View file

@ -1,10 +1,12 @@
import React from 'react';
import { INDEXES } from 'App/constants/zindex';
import { Loader, Icon } from 'UI';
import { Button } from 'antd'
import { Button } from 'antd';
import { PlayerContext } from 'App/components/Session/playerContext';
import { useStore } from "App/mstore";
import { useStore } from 'App/mstore';
import { observer } from 'mobx-react-lite';
import { useTranslation } from 'react-i18next';
import { TFunction } from 'i18next';
interface Props {
userDisplayName: string;
@ -23,62 +25,70 @@ enum Actions {
RecordingEnd,
}
const WIN_VARIANTS = {
const WIN_VARIANTS = (t: TFunction) => ({
[WindowType.Call]: {
text: 'to accept the call',
text: t('to accept the call'),
icon: 'call' as const,
action: Actions.CallEnd,
iconColor: 'teal',
},
[WindowType.Control]: {
text: 'to accept remote control request',
text: t('to accept remote control request'),
icon: 'remote-control' as const,
action: Actions.ControlEnd,
iconColor: 'teal',
},
[WindowType.Record]: {
text: 'to accept recording request',
text: t('to accept recording request'),
icon: 'record-circle' as const,
iconColor: 'red',
action: Actions.RecordingEnd,
}
};
},
});
function RequestingWindow({ getWindowType }: Props) {
const { t } = useTranslation();
const { sessionStore } = useStore();
const userDisplayName = sessionStore.current.userDisplayName;
const windowType = getWindowType()
const { userDisplayName } = sessionStore.current;
const windowType = getWindowType();
if (!windowType) return;
const { player } = React.useContext(PlayerContext)
const { player } = React.useContext(PlayerContext);
const {
assistManager: {
initiateCallEnd,
releaseRemoteControl,
stopRecording,
}
} = player
assistManager: { initiateCallEnd, releaseRemoteControl, stopRecording },
} = player;
const actions = {
[Actions.CallEnd]: initiateCallEnd,
[Actions.ControlEnd]: releaseRemoteControl,
[Actions.RecordingEnd]: stopRecording,
}
};
return (
<div
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">
<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>
Waiting for <span className="font-semibold">{userDisplayName}</span>
{t('Waiting for')}{' '}
<span className="font-semibold">{userDisplayName}</span>
</div>
<span>{WIN_VARIANTS[windowType].text}</span>
<span>{WIN_VARIANTS(t)[windowType].text}</span>
<Loader size={30} style={{ minHeight: 60 }} />
<Button variant="text" onClick={actions[WIN_VARIANTS[windowType].action]}>
Cancel
<Button
variant="text"
onClick={actions[WIN_VARIANTS(t)[windowType].action]}
>
{t('Cancel')}
</Button>
</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 {Headset} from 'lucide-react';
import cn from 'classnames';
import ChatWindow from '../../ChatWindow';
import { CallingState, ConnectionStatus, RemoteControlStatus, RequestLocalStream } from 'Player';
import {
CallingState,
ConnectionStatus,
RemoteControlStatus,
RequestLocalStream,
} 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 { toast } from 'react-toastify';
import { confirm, Icon, Tooltip } from 'UI';
import stl from './AassistActions.module.css';
import ScreenRecorder from 'App/components/Session_/ScreenRecorder/ScreenRecorder';
import { audioContextManager } from 'App/utils/screenRecorder';
import { useStore } from "App/mstore";
function onReject() {
toast.info(`Call was rejected.`);
}
function onControlReject() {
toast.info('Remote control request was rejected by user');
}
function onControlBusy() {
toast.info('Remote control busy');
}
import { useStore } from 'App/mstore';
import stl from './AassistActions.module.css';
import ChatWindow from '../../ChatWindow';
import { useTranslation } from 'react-i18next';
function onError(e: any) {
console.log(e);
@ -40,27 +38,26 @@ interface Props {
const AssistActionsPing = {
control: {
start: 's_control_started',
end: 's_control_ended'
end: 's_control_ended',
},
call: {
start: 's_call_started',
end: 's_call_ended'
end: 's_call_ended',
},
} as const
} as const;
function AssistActions({
userId,
isCallActive,
agentIds,
}: Props) {
function AssistActions({ userId, isCallActive, agentIds }: Props) {
// @ts-ignore ???
const { t } = useTranslation();
const { player, store } = React.useContext<ILivePlayerContext>(PlayerContext);
const { sessionStore, userStore } = useStore();
const permissions = userStore.account.permissions || [];
const hasPermission = permissions.includes('ASSIST_CALL') || permissions.includes('SERVICE_ASSIST_CALL');
const isEnterprise = userStore.isEnterprise;
const hasPermission =
permissions.includes('ASSIST_CALL') ||
permissions.includes('SERVICE_ASSIST_CALL');
const { isEnterprise } = userStore;
const agentId = userStore.account.id;
const userDisplayName = sessionStore.current.userDisplayName;
const { userDisplayName } = sessionStore.current;
const {
assistManager: {
@ -81,16 +78,23 @@ function AssistActions({
} = store.get();
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 [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 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;
useEffect(() => {
@ -122,20 +126,22 @@ function AssistActions({
}
}, [remoteActive]);
useEffect(() => {
return callObject?.end();
}, []);
useEffect(() => callObject?.end(), []);
useEffect(() => {
if (peerConnectionStatus == ConnectionStatus.Disconnected) {
toast.info(`Live session was closed.`);
toast.info(t('Live session was closed.'));
}
}, [peerConnectionStatus]);
const addIncomeStream = (stream: MediaStream, isAgent: boolean) => {
setIncomeStream((oldState) => {
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);
return [...oldState, { stream, isAgent }];
}
@ -146,10 +152,24 @@ function AssistActions({
const removeIncomeStream = (stream: MediaStream) => {
setIncomeStream((prevState) => {
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() {
RequestLocalStream()
.then((lStream) => {
@ -159,12 +179,12 @@ function AssistActions({
lStream,
addIncomeStream,
() => {
player.assistManager.ping(AssistActionsPing.call.end, agentId)
player.assistManager.ping(AssistActionsPing.call.end, agentId);
lStream.stop.apply(lStream);
removeIncomeStream(lStream.stream);
},
onReject,
onError
onError,
);
setCallObject(callPeer());
// if (additionalAgentIds) {
@ -179,9 +199,9 @@ function AssistActions({
if (
await confirm({
header: 'Start Call',
confirmButton: 'Call',
confirmation: `Are you sure you want to call ${userId ? userId : 'User'}?`,
header: t('Start Call'),
confirmButton: t('Call'),
confirmation: `${t('Are you sure you want to call')} ${userId || t('User')}?`,
})
) {
call(agentIds);
@ -190,15 +210,15 @@ function AssistActions({
const requestControl = () => {
const onStart = () => {
player.assistManager.ping(AssistActionsPing.control.start, agentId)
}
player.assistManager.ping(AssistActionsPing.control.start, agentId);
};
const onEnd = () => {
player.assistManager.ping(AssistActionsPing.control.end, agentId)
}
player.assistManager.ping(AssistActionsPing.control.end, agentId);
};
setRemoteControlCallbacks({
onReject: onControlReject,
onStart: onStart,
onEnd: onEnd,
onStart,
onEnd,
onBusy: onControlBusy,
});
requestReleaseRemoteControl();
@ -206,9 +226,9 @@ function AssistActions({
React.useEffect(() => {
if (onCall) {
player.assistManager.ping(AssistActionsPing.call.start, agentId)
player.assistManager.ping(AssistActionsPing.call.start, agentId);
}
}, [onCall])
}, [onCall]);
return (
<div className="flex items-center">
@ -227,7 +247,7 @@ function AssistActions({
size='small'
className={annotating ? 'text-red' : 'text-main'}
>
Annotate
{t('Annotate')}
</Button>
</div>
<div className={stl.divider} />
@ -241,7 +261,8 @@ function AssistActions({
<Tooltip title="Call user to initiate remote control" disabled={livePlay}>
<div
className={cn('cursor-pointer p-2 flex items-center', {
[stl.disabled]: cannotCall || !livePlay || callRequesting || remoteRequesting,
[stl.disabled]:
cannotCall || !livePlay || callRequesting || remoteRequesting,
})}
onClick={requestControl}
role="button"
@ -252,7 +273,7 @@ function AssistActions({
icon={<Icon name={remoteActive ? 'window-x' : 'remote-control'} size={16} color={remoteActive ? 'red' : 'main'} />}
size='small'
>
Remote Control
{t('Remote Control')}
</Button>
</div>
</Tooltip>
@ -260,8 +281,8 @@ function AssistActions({
<Tooltip
title={
cannotCall
? `You don't have the permissions to perform this action.`
: `Call ${userId ? userId : 'User'}`
? t("You don't have the permissions to perform this action.")
: `${t('Call')} ${userId || t('User')}`
}
disabled={onCall}
>
@ -278,7 +299,7 @@ function AssistActions({
className={onCall ? 'text-red' : isPrestart ? 'text-green' : 'text-main'}
size='small'
>
{onCall ? 'End' : isPrestart ? 'Join Call' : 'Call'}
{onCall ? t('End') : isPrestart ? t('Join Call') : t('Call')}
</Button>
</div>
</Tooltip>

View file

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

View file

@ -1,10 +1,12 @@
/* eslint-disable i18next/no-literal-string */
import React, { useEffect } from 'react';
import { observer } from 'mobx-react-lite'
import { observer } from 'mobx-react-lite';
import { useStore } from 'App/mstore';
import { Loader, NoContent, Label } from 'UI';
import SessionItem from 'Shared/SessionItem';
import { useModal } from 'App/components/Modal';
import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG';
import { useTranslation } from 'react-i18next';
interface Props {
loading: boolean;
@ -12,12 +14,17 @@ interface Props {
session: any;
userId: any;
}
function SessionList(props: Props) {
const { t } = useTranslation();
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 list = sessionStore.liveSessions.filter(
(i: any) =>
i.userId === session.userId && i.sessionId !== session.sessionId,
);
const loading = sessionStore.loadingLiveSessions;
useEffect(() => {
const params: any = {};
@ -30,11 +37,18 @@ function SessionList(props: Props) {
return (
<div
className="border-r shadow h-screen overflow-y-auto"
style={{ backgroundColor: '#FAFAFA', zIndex: 999, width: '100%', minWidth: '700px' }}
style={{
backgroundColor: '#FAFAFA',
zIndex: 999,
width: '100%',
minWidth: '700px',
}}
>
<div className="p-4">
<div className="text-2xl">
{props.userId}'s <span className="color-gray-medium">Live Sessions</span>{' '}
{props.userId}
&apos;s
<span className="color-gray-medium">{t('Live Sessions')}</span>{' '}
</div>
</div>
<Loader loading={loading}>
@ -44,7 +58,9 @@ function SessionList(props: Props) {
<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">No live sessions found.</div>
<div className="text-center text-lg font-medium">
{t('No live sessions found.')}
</div>
</div>
}
>
@ -54,12 +70,14 @@ function SessionList(props: Props) {
{session.pageTitle && session.pageTitle !== '' && (
<div className="flex items-center mb-2">
<Label size="small" className="p-1">
<span className="color-gray-medium">TAB</span>
<span className="color-gray-medium">{t('TAB')}</span>
</Label>
<span className="ml-2 font-medium">{session.pageTitle}</span>
<span className="ml-2 font-medium">
{session.pageTitle}
</span>
</div>
)}
<SessionItem compact={true} onClick={hideModal} session={session} />
<SessionItem compact onClick={hideModal} session={session} />
</div>
))}
</div>

View file

@ -1,4 +1,5 @@
import React, { useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
interface Props {
stream: MediaStream | null;
@ -17,6 +18,7 @@ function VideoContainer({
local,
isAgent,
}: Props) {
const { t } = useTranslation();
const ref = useRef<HTMLVideoElement>(null);
const [isEnabled, setEnabled] = React.useState(false);
@ -50,7 +52,7 @@ function VideoContainer({
return (
<div
className={'flex-1'}
className="flex-1"
style={{
display: isEnabled ? undefined : 'none',
width: isEnabled ? undefined : '0px!important',
@ -59,14 +61,14 @@ function VideoContainer({
transform: local ? 'scaleX(-1)' : undefined,
}}
>
<video autoPlay ref={ref} muted={muted} style={{ height: height }} />
<video autoPlay ref={ref} muted={muted} style={{ height }} />
{isAgent ? (
<div
style={{
position: 'absolute',
}}
>
Agent
{t('Agent')}
</div>
) : null}
</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 SelectDateRange from 'Shared/SelectDateRange/SelectDateRange';
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 { assistStatsService } from 'App/services';
import { getPdf2 } from 'Components/AssistStats/pdfGenerator';
import UserSearch from './components/UserSearch';
import Chart from './components/Charts';
import StatsTable from './components/Table';
import { getPdf2 } from "Components/AssistStats/pdfGenerator";
import { useTranslation } from 'react-i18next';
import { TFunction } from 'i18next';
const chartNames = {
assistTotal: 'Total Live Duration',
assistAvg: 'Avg Live Duration',
callTotal: 'Total Call Duration',
callAvg: 'Avg Call Duration',
controlTotal: 'Total Remote Duration',
controlAvg: 'Avg Remote Duration',
};
const chartNames = (t: TFunction) => ({
assistTotal: t('Total Live Duration'),
assistAvg: t('Avg Live Duration'),
callTotal: t('Total Call Duration'),
callAvg: t('Avg Call Duration'),
controlTotal: t('Total Remote Duration'),
controlAvg: t('Avg Remote Duration'),
});
function calculatePercentageDelta(currP: number, prevP: number) {
return ((currP - prevP) / prevP) * 100;
}
function AssistStats() {
const { t } = useTranslation();
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 [tableSort, setTableSort] = React.useState('timestamp');
const [topMembers, setTopMembers] = React.useState<{ list: Member[]; total: number }>({
const [topMembers, setTopMembers] = React.useState<{
list: Member[];
total: number;
}>({
list: [],
total: 0,
});
@ -68,7 +76,7 @@ function AssistStats() {
const topMembersPr = assistStatsService.getTopMembers({
startTimestamp: usedP.start,
endTimestamp: usedP.end,
userId: selectedUser ? selectedUser : undefined,
userId: selectedUser || undefined,
sort: membersSort,
order: 'desc',
});
@ -79,7 +87,7 @@ function AssistStats() {
endTimestamp: usedP.end,
sort: tableSort,
order: 'desc',
userId: selectedUser ? selectedUser : undefined,
userId: selectedUser || undefined,
page: 1,
limit: 10,
});
@ -88,7 +96,7 @@ function AssistStats() {
topMembers.status === 'fulfilled' && setTopMembers(topMembers.value);
graphs.status === 'fulfilled' && setGraphs(graphs.value);
sessions.status === 'fulfilled' && setSessions(sessions.value);
}
},
);
setIsLoading(false);
};
@ -148,7 +156,8 @@ function AssistStats() {
order: 'desc',
page: 1,
limit: 10000,
}).then((sessions) => {
})
.then((sessions) => {
const data = sessions.list.map((s) => ({
...s,
members: `"${s.teamMembers.map((m) => m.name).join(', ')}"`,
@ -158,17 +167,20 @@ function AssistStats() {
controlDuration: `"${durationFromMsFormatted(s.controlDuration)}"`,
}));
const headers = [
{ label: 'Date', key: 'dateStr' },
{ label: 'Team Members', key: 'members' },
{ label: 'Live Duration', key: 'assistDuration' },
{ label: 'Call Duration', key: 'callDuration' },
{ label: 'Remote Duration', key: 'controlDuration' },
{ label: 'Session ID', key: 'sessionId' }
{ label: t('Date'), key: 'dateStr' },
{ label: t('Team Members'), key: 'members' },
{ label: t('Live Duration'), key: 'assistDuration' },
{ label: t('Call Duration'), key: 'callDuration' },
{ 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) => {
@ -191,49 +203,64 @@ function AssistStats() {
order: 'desc',
page: 1,
limit: 10,
})
});
Promise.allSettled([topMembersPr, graphsPr, sessionsPr]).then(
([topMembers, graphs, sessions]) => {
topMembers.status === 'fulfilled' && setTopMembers(topMembers.value);
graphs.status === 'fulfilled' && setGraphs(graphs.value);
sessions.status === 'fulfilled' && setSessions(sessions.value);
}
},
);
setIsLoading(false);
};
return (
<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 id={'pdf-ignore'} className={'w-full flex items-center mb-2'}>
<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 id="pdf-ignore" className="w-full flex items-center mb-2">
<Typography.Title style={{ marginBottom: 0 }} level={4}>
Co-browsing Reports
{t('Co-browsing Reports')}
</Typography.Title>
<div className={'ml-auto flex items-center gap-2'}>
<div className="ml-auto flex items-center gap-2">
<UserSearch onUserSelect={onUserSelect} />
<SelectDateRange period={period} onChange={onChangePeriod} right={true} isAnt small />
<Tooltip title={!sessions || sessions.total === 0 ? 'No data at the moment to export.' : 'Export PDF'}>
<SelectDateRange
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
onClick={getPdf2}
shape={'default'}
size={'small'}
shape="default"
size="small"
disabled={!sessions || sessions.total === 0}
icon={<FilePdfOutlined rev={undefined} />}
/>
</Tooltip>
</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) => (
<div className={'bg-white rounded border'}>
<div className={'pt-2 px-2'}>
<div className="bg-white rounded border">
<div className="pt-2 px-2">
<Typography.Text strong style={{ marginBottom: 0 }}>
{chartNames[i]}
{chartNames(t)[i]}
</Typography.Text>
<div className={'flex gap-1 items-center'}>
<div className="flex gap-1 items-center">
<Typography.Title style={{ marginBottom: 0 }} level={5}>
{graphs.currentPeriod[i]
? durationFromMsFormatted(graphs.currentPeriod[i])
@ -249,25 +276,36 @@ function AssistStats() {
>
<ArrowUpOutlined
rev={undefined}
rotate={graphs.currentPeriod[i] > graphs.previousPeriod[i] ? 0 : 180}
rotate={
graphs.currentPeriod[i] > graphs.previousPeriod[i]
? 0
: 180
}
/>
{`${Math.round(
calculatePercentageDelta(
graphs.currentPeriod[i],
graphs.previousPeriod[i]
)
graphs.previousPeriod[i],
),
)}%`}
</div>
) : null}
</div>
</div>
<Loader loading={isLoading} style={{ minHeight: 90, height: 90 }} size={36}>
<Chart data={generateListData(graphs.list, i)} label={chartNames[i]} />
<Loader
loading={isLoading}
style={{ minHeight: 90, height: 90 }}
size={36}
>
<Chart
data={generateListData(graphs.list, i)}
label={chartNames(t)[i]}
/>
</Loader>
</div>
))}
</div>
<div className={'w-full mt-2'}>
<div className="w-full mt-2">
<TeamMembers
isLoading={isLoading}
topMembers={topMembers}
@ -275,7 +313,7 @@ function AssistStats() {
membersSort={membersSort}
/>
</div>
<div className={'w-full mt-2'}>
<div className="w-full mt-2">
<StatsTable
exportCSV={exportCSV}
sessions={sessions}
@ -286,7 +324,7 @@ function AssistStats() {
/>
</div>
</div>
<div id={'stats-layer'} />
<div id="stats-layer" />
</div>
);
}

View file

@ -1,13 +1,8 @@
import React from 'react';
import { NoContent } from 'UI';
import { Styles } from 'Components/Dashboard/Widgets/common';
import {
AreaChart,
Area,
ResponsiveContainer,
XAxis,
YAxis,
} from 'recharts';
import { AreaChart, Area, ResponsiveContainer, XAxis, YAxis } from 'recharts';
import { useTranslation } from 'react-i18next';
interface Props {
data: any;
@ -15,13 +10,16 @@ interface Props {
}
function Chart(props: Props) {
const { t } = useTranslation();
const { data, label } = props;
const gradientDef = Styles.gradientDef();
return (
<NoContent
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}
style={{ height: '100px' }}
>
@ -51,7 +49,7 @@ function Chart(props: Props) {
fillOpacity={1}
strokeWidth={2}
strokeOpacity={0.8}
fill={'url(#colorCount)'}
fill="url(#colorCount)"
/>
</AreaChart>
</ResponsiveContainer>

View file

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

View file

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

View file

@ -4,8 +4,10 @@ import type { SelectProps } from 'antd/es/select';
import { observer } from 'mobx-react-lite';
import { useStore } from 'App/mstore';
const UserSearch = ({ onUserSelect }: { onUserSelect: (id: any) => void }) => {
const [selectedValue, setSelectedValue] = useState<string | undefined>(undefined);
function UserSearch({ onUserSelect }: { onUserSelect: (id: any) => void }) {
const [selectedValue, setSelectedValue] = useState<string | undefined>(
undefined,
);
const { userStore } = useStore();
const allUsers = userStore.list.map((user) => ({
value: user.userId,
@ -20,7 +22,7 @@ const UserSearch = ({ onUserSelect }: { onUserSelect: (id: any) => void }) => {
r.map((user: any) => ({
value: user.userId,
label: user.name,
}))
})),
);
});
}
@ -28,12 +30,16 @@ const UserSearch = ({ onUserSelect }: { onUserSelect: (id: any) => void }) => {
const handleSearch = (value: string) => {
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) => {
onUserSelect(value)
onUserSelect(value);
setSelectedValue(allUsers.find((u) => u.value === value)?.label || '');
};
@ -46,8 +52,8 @@ const UserSearch = ({ onUserSelect }: { onUserSelect: (id: any) => void }) => {
onSearch={handleSearch}
value={selectedValue}
onChange={(e) => {
setSelectedValue(e)
if (!e) onUserSelect(undefined)
setSelectedValue(e);
if (!e) onUserSelect(undefined);
}}
onClear={() => onSelect(undefined)}
onDeselect={() => onSelect(undefined)}
@ -56,12 +62,12 @@ const UserSearch = ({ onUserSelect }: { onUserSelect: (id: any) => void }) => {
<Input.Search
allowClear
placeholder="Filter by team member name"
size={'small'}
size="small"
classNames={{ input: '!border-0 focus:!border-0' }}
style={{ width: 200 }}
/>
</AutoComplete>
);
};
}
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) => {
const imgData = canvas.toDataURL('img/png');
let imgWidth = 290;
let pageHeight = 200;
let imgHeight = (canvas.height * imgWidth) / canvas.width;
const imgWidth = 290;
const pageHeight = 200;
const imgHeight = (canvas.height * imgWidth) / canvas.width;
let heightLeft = imgHeight - pageHeight;
let position = 0;
const A4Height = 295;
@ -38,16 +38,24 @@ export const getPdf2 = async () => {
const logoWidth = 55;
doc.addImage(imgData, 'PNG', 3, 10, imgWidth, imgHeight);
doc.addImage('/assets/img/cobrowising-report-head.png', 'png', A4Height / 2 - headerW / 2, 2, 45, 5);
if (position === 0 && heightLeft === 0)
doc.addImage(
'/assets/img/cobrowising-report-head.png',
'png',
A4Height / 2 - headerW / 2,
2,
45,
5,
);
if (position === 0 && heightLeft === 0) {
doc.addImage(
'/assets/img/report-head.png',
'png',
imgWidth / 2 - headerW / 2,
pageHeight - 5,
logoWidth,
5
5,
);
}
while (heightLeft >= 0) {
position = heightLeft - imgHeight;
@ -59,12 +67,12 @@ export const getPdf2 = async () => {
A4Height / 2 - headerW / 2,
pageHeight - 5,
logoWidth,
5
5,
);
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 {
DataProps,
buildCategories,
customTooltipFormatter
} from './utils';
import { buildBarDatasetsAndSeries } from './barUtils';
import { defaultOptions, echarts, initWindowStorages } from "./init";
import { BarChart } from 'echarts/charts';
import { DataProps, buildCategories, customTooltipFormatter } from './utils';
import { buildBarDatasetsAndSeries } from './barUtils';
import { defaultOptions, echarts, initWindowStorages } from './init';
echarts.use([BarChart]);
@ -17,21 +13,29 @@ interface BarChartProps extends DataProps {
}
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);
React.useEffect(() => {
if (!chartRef.current) return;
const chart = echarts.init(chartRef.current);
const obs = new ResizeObserver(() => chart.resize())
const obs = new ResizeObserver(() => chart.resize());
obs.observe(chartRef.current);
const categories = buildCategories(props.data);
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) => {
(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);
if (!ds) return;
const yDim = s.encode.y;
@ -41,11 +45,11 @@ function ORBarChart(props: BarChartProps) {
(window as any).__seriesValueMap[chartUuid.current][s.name] = {};
ds.source.forEach((row: any[]) => {
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 = {
type: 'category',
data: categories,
@ -62,7 +66,9 @@ function ORBarChart(props: BarChartProps) {
...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: {
...defaultOptions.tooltip,
@ -80,12 +86,14 @@ function ORBarChart(props: BarChartProps) {
});
chart.on('click', (event) => {
const index = event.dataIndex;
const timestamp = (window as any).__timestampMap?.[chartUuid.current]?.[index];
props.onClick?.({ activePayload: [{ payload: { timestamp }}]})
const timestamp = (window as any).__timestampMap?.[chartUuid.current]?.[
index
];
props.onClick?.({ activePayload: [{ payload: { timestamp } }] });
setTimeout(() => {
props.onSeriesFocus?.(event.seriesName)
}, 0)
})
props.onSeriesFocus?.(event.seriesName);
}, 0);
});
return () => {
chart.dispose();

View file

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

View file

@ -1,8 +1,12 @@
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 { echarts, defaultOptions, initWindowStorages } from './init';
import {
customTooltipFormatter,
buildCategories,
buildDatasetsAndSeries,
} from './utils';
import type { DataProps } from './utils';
echarts.use([LineChart]);
@ -16,19 +20,26 @@ interface Props extends DataProps {
}
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);
React.useEffect(() => {
if (!chartRef.current) return;
const chart = echarts.init(chartRef.current);
const obs = new ResizeObserver(() => chart.resize())
const obs = new ResizeObserver(() => chart.resize());
obs.observe(chartRef.current);
const categories = buildCategories(props.data);
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) => {
if (props.isArea) {
@ -37,7 +48,8 @@ function ORLineChart(props: Props) {
} else {
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 ds = datasets.find((d) => d.id === datasetId);
if (!ds) return;
@ -48,20 +60,23 @@ function ORLineChart(props: Props) {
(window as any).__seriesValueMap[chartUuid.current][s.name] = {};
ds.source.forEach((row: any[]) => {
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({
...defaultOptions,
title: {
text: props.chartName ?? "Line Chart",
text: props.chartName ?? 'Line Chart',
show: false,
},
legend: {
...defaultOptions.legend,
// 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: {
type: 'category',
@ -75,7 +90,7 @@ function ORLineChart(props: Props) {
nameTextStyle: {
padding: [0, 0, 0, 15],
},
minInterval: 1
minInterval: 1,
},
tooltip: {
...defaultOptions.tooltip,
@ -91,12 +106,14 @@ function ORLineChart(props: Props) {
});
chart.on('click', (event) => {
const index = event.dataIndex;
const timestamp = (window as any).__timestampMap?.[chartUuid.current]?.[index];
props.onClick?.({ activePayload: [{ payload: { timestamp }}]})
const timestamp = (window as any).__timestampMap?.[chartUuid.current]?.[
index
];
props.onClick?.({ activePayload: [{ payload: { timestamp } }] });
setTimeout(() => {
props.onSeriesFocus?.(event.seriesName)
}, 0)
})
props.onSeriesFocus?.(event.seriesName);
}, 0);
});
return () => {
chart.dispose();

View file

@ -1,7 +1,11 @@
import React, { useEffect, useRef } from 'react';
import { PieChart as EchartsPieChart } from 'echarts/charts';
import { echarts, defaultOptions } from './init';
import { buildPieData, pieTooltipFormatter, pickColorByIndex } from './pieUtils';
import {
buildPieData,
pieTooltipFormatter,
pickColorByIndex,
} from './pieUtils';
echarts.use([EchartsPieChart]);
@ -28,7 +32,8 @@ function PieChart(props: PieChartProps) {
useEffect(() => {
if (!chartRef.current) return;
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;
}
@ -36,7 +41,8 @@ function PieChart(props: PieChartProps) {
const pieData = buildPieData(data.chart, data.namesMap);
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;
}
@ -75,16 +81,13 @@ function PieChart(props: PieChartProps) {
name: label ?? 'Data',
radius: [50, 100],
center: ['50%', '55%'],
data: pieData.map((d, idx) => {
return {
data: pieData.map((d, idx) => ({
name: d.name,
value: d.value,
label: {
show: false, //d.value / largestVal >= 0.03,
show: false, // d.value / largestVal >= 0.03,
position: 'outside',
formatter: (params: any) => {
return params.value;
},
formatter: (params: any) => params.value,
},
labelLine: {
show: false, // d.value / largestVal >= 0.03,
@ -95,8 +98,7 @@ function PieChart(props: PieChartProps) {
itemStyle: {
color: pickColorByIndex(idx),
},
};
}),
})),
emphasis: {
scale: true,
scaleSize: 4,
@ -106,11 +108,11 @@ function PieChart(props: PieChartProps) {
};
chartInstance.setOption(option);
const obs = new ResizeObserver(() => chartInstance.resize())
const obs = new ResizeObserver(() => chartInstance.resize());
obs.observe(chartRef.current);
chartInstance.on('click', function (params) {
const focusedSeriesName = params.name
chartInstance.on('click', (params) => {
const focusedSeriesName = params.name;
props.onSeriesFocus?.(focusedSeriesName);
});
@ -121,7 +123,10 @@ function PieChart(props: PieChartProps) {
}, [data, label, onClick, inGrid]);
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 { echarts, defaultOptions } from './init';
import { SankeyChart } from 'echarts/charts';
import { sankeyTooltip, getEventPriority, getNodeName } from './sankeyUtils';
import { NoContent } from 'App/components/ui';
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]);
@ -36,6 +37,7 @@ interface Props {
}
const EChartsSankey: React.FC<Props> = (props) => {
const { t } = useTranslation();
const { data, height = 240, onChartClick, isUngrouped } = props;
const chartRef = React.useRef<HTMLDivElement>(null);
@ -44,8 +46,8 @@ const EChartsSankey: React.FC<Props> = (props) => {
React.useEffect(() => {
if (!chartRef.current || data.nodes.length === 0 || data.links.length === 0) return;
let finalNodes = data.nodes;
let finalLinks = data.links;
const finalNodes = data.nodes;
const finalLinks = data.links;
const chart = echarts.init(chartRef.current);
@ -94,9 +96,8 @@ const EChartsSankey: React.FC<Props> = (props) => {
return (
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) => ({
@ -158,22 +159,23 @@ const EChartsSankey: React.FC<Props> = (props) => {
maxWidth: 30,
distance: 3,
offset: [-20, 0],
formatter: function (params: any) {
formatter(params: any) {
const nodeVal = params.value;
const percentage = startNodeValue
? ((nodeVal / startNodeValue) * 100).toFixed(1) + '%'
? `${((nodeVal / startNodeValue) * 100).toFixed(1)}%`
: '0%';
const maxLen = 20;
const safeName =
params.name.length > maxLen
? params.name.slice(0, maxLen / 2 - 2) +
'...' +
params.name.slice(-(maxLen / 2 - 2))
? `${params.name.slice(
0,
maxLen / 2 - 2,
)}...${params.name.slice(-(maxLen / 2 - 2))}`
: params.name;
const nodeType = params.data.type;
const icon = getIcon(nodeType)
const icon = getIcon(nodeType);
return (
`${icon}{header| ${safeName}}\n` +
`{body|}{percentage|${percentage}} {sessions|${nodeVal}}`
@ -208,46 +210,52 @@ const EChartsSankey: React.FC<Props> = (props) => {
},
clickIcon: {
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,
width: 14,
},
locationIcon: {
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,
width: 14,
},
inputIcon: {
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,
width: 14,
},
customEventIcon: {
backgroundColor: {
image: 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGNsYXNzPSJsdWNpZGUgbHVjaWRlLWNvZGUiPjxwb2x5bGluZSBwb2ludHM9IjE2IDE4IDIyIDEyIDE2IDYiLz48cG9seWxpbmUgcG9pbnRzPSI4IDYgMiAxMiA4IDE4Ii8+PC9zdmc+'
image:
'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGNsYXNzPSJsdWNpZGUgbHVjaWRlLWNvZGUiPjxwb2x5bGluZSBwb2ludHM9IjE2IDE4IDIyIDEyIDE2IDYiLz48cG9seWxpbmUgcG9pbnRzPSI4IDYgMiAxMiA4IDE4Ii8+PC9zdmc+',
},
height: 20,
width: 14,
},
dropEventIcon: {
backgroundColor: {
image: 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGNsYXNzPSJsdWNpZGUgbHVjaWRlLWNpcmNsZS1hcnJvdy1kb3duIj48Y2lyY2xlIGN4PSIxMiIgY3k9IjEyIiByPSIxMCIvPjxwYXRoIGQ9Ik0xMiA4djgiLz48cGF0aCBkPSJtOCAxMiA0IDQgNC00Ii8+PC9zdmc+',
image:
'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGNsYXNzPSJsdWNpZGUgbHVjaWRlLWNpcmNsZS1hcnJvdy1kb3duIj48Y2lyY2xlIGN4PSIxMiIgY3k9IjEyIiByPSIxMCIvPjxwYXRoIGQ9Ik0xMiA4djgiLz48cGF0aCBkPSJtOCAxMiA0IDQgNC00Ii8+PC9zdmc+',
},
height: 20,
width: 14,
},
groupIcon: {
backgroundColor: {
image: 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGNsYXNzPSJsdWNpZGUgbHVjaWRlLWNvbXBvbmVudCI+PHBhdGggZD0iTTE1LjUzNiAxMS4yOTNhMSAxIDAgMCAwIDAgMS40MTRsMi4zNzYgMi4zNzdhMSAxIDAgMCAwIDEuNDE0IDBsMi4zNzctMi4zNzdhMSAxIDAgMCAwIDAtMS40MTRsLTIuMzc3LTIuMzc3YTEgMSAwIDAgMC0xLjQxNCAweiIvPjxwYXRoIGQ9Ik0yLjI5NyAxMS4yOTNhMSAxIDAgMCAwIDAgMS40MTRsMi4zNzcgMi4zNzdhMSAxIDAgMCAwIDEuNDE0IDBsMi4zNzctMi4zNzdhMSAxIDAgMCAwIDAtMS40MTRMNi4wODggOC45MTZhMSAxIDAgMCAwLTEuNDE0IDB6Ii8+PHBhdGggZD0iTTguOTE2IDE3LjkxMmExIDEgMCAwIDAgMCAxLjQxNWwyLjM3NyAyLjM3NmExIDEgMCAwIDAgMS40MTQgMGwyLjM3Ny0yLjM3NmExIDEgMCAwIDAgMC0xLjQxNWwtMi4zNzctMi4zNzZhMSAxIDAgMCAwLTEuNDE0IDB6Ii8+PHBhdGggZD0iTTguOTE2IDQuNjc0YTEgMSAwIDAgMCAwIDEuNDE0bDIuMzc3IDIuMzc2YTEgMSAwIDAgMCAxLjQxNCAwbDIuMzc3LTIuMzc2YTEgMSAwIDAgMCAwLTEuNDE0bC0yLjM3Ny0yLjM3N2ExIDEgMCAwIDAtMS40MTQgMHoiLz48L3N2Zz4=',
image:
'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGNsYXNzPSJsdWNpZGUgbHVjaWRlLWNvbXBvbmVudCI+PHBhdGggZD0iTTE1LjUzNiAxMS4yOTNhMSAxIDAgMCAwIDAgMS40MTRsMi4zNzYgMi4zNzdhMSAxIDAgMCAwIDEuNDE0IDBsMi4zNzctMi4zNzdhMSAxIDAgMCAwIDAtMS40MTRsLTIuMzc3LTIuMzc3YTEgMSAwIDAgMC0xLjQxNCAweiIvPjxwYXRoIGQ9Ik0yLjI5NyAxMS4yOTNhMSAxIDAgMCAwIDAgMS40MTRsMi4zNzcgMi4zNzdhMSAxIDAgMCAwIDEuNDE0IDBsMi4zNzctMi4zNzdhMSAxIDAgMCAwIDAtMS40MTRMNi4wODggOC45MTZhMSAxIDAgMCAwLTEuNDE0IDB6Ii8+PHBhdGggZD0iTTguOTE2IDE3LjkxMmExIDEgMCAwIDAgMCAxLjQxNWwyLjM3NyAyLjM3NmExIDEgMCAwIDAgMS40MTQgMGwyLjM3Ny0yLjM3NmExIDEgMCAwIDAgMC0xLjQxNWwtMi4zNzctMi4zNzZhMSAxIDAgMCAwLTEuNDE0IDB6Ii8+PHBhdGggZD0iTTguOTE2IDQuNjc0YTEgMSAwIDAgMCAwIDEuNDE0bDIuMzc3IDIuMzc2YTEgMSAwIDAgMCAxLjQxNCAwbDIuMzc3LTIuMzc2YTEgMSAwIDAgMCAwLTEuNDE0bC0yLjM3Ny0yLjM3N2ExIDEgMCAwIDAtMS40MTQgMHoiLz48L3N2Zz4=',
},
height: 20,
width: 14,
}
},
},
},
tooltip: {
@ -302,7 +310,7 @@ const EChartsSankey: React.FC<Props> = (props) => {
const originalNodes = [...echartNodes];
const originalLinks = [...echartLinks];
chart.on('mouseover', function (params: any) {
chart.on('mouseover', (params: any) => {
if (params.dataType === 'node') {
const hoveredIndex = params.dataIndex;
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') {
chart.setOption({
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;
const unsupported = ['other', 'drop'];
@ -372,7 +380,7 @@ const EChartsSankey: React.FC<Props> = (props) => {
}
filters.push({
operator: 'is',
type: type,
type,
value: [node.name],
isEvent: true,
});
@ -456,25 +464,28 @@ const EChartsSankey: React.FC<Props> = (props) => {
}
return (
<div style={{ maxHeight: 620, overflow: 'auto', maxWidth: 1240, minHeight: 240 }}>
<div
ref={chartRef}
style={containerStyle}
className="min-w-[600px]"
/>
style={{
maxHeight: 620,
overflow: 'auto',
maxWidth: 1240,
minHeight: 240,
}}
>
<div ref={chartRef} style={containerStyle} className="min-w-[600px]" />
</div>
);
};
function getIcon(type: string) {
if (type === 'LOCATION') {
return '{locationIcon|}'
return '{locationIcon|}';
}
if (type === 'INPUT') {
return '{inputIcon|}'
return '{inputIcon|}';
}
if (type === 'CUSTOM_EVENT') {
return '{customEventIcon|}'
return '{customEventIcon|}';
}
if (type === 'CLICK') {
return '{clickIcon|}';
@ -485,7 +496,7 @@ function getIcon(type: string) {
if (type === 'OTHER') {
return '{groupIcon|}';
}
return ''
return '';
}
export default EChartsSankey;

View file

@ -1,5 +1,9 @@
import type { DataProps, DataItem } from './utils';
import { createDataset, assignColorsByBaseName, assignColorsByCategory } from './utils';
import {
createDataset,
assignColorsByBaseName,
assignColorsByCategory,
} from './utils';
export function createBarSeries(
data: DataProps['data'],
@ -13,7 +17,9 @@ export function createBarSeries(
const encode = { x: 'idx', y: fullName };
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 {
name: fullName,
_baseName: baseName,
@ -47,7 +53,6 @@ export function buildBarDatasetsAndSeries(props: DataProps) {
return { datasets, series };
}
// START GEN
function sumSeries(chart: DataItem[], seriesName: string): number {
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(
chartUuid: string,
data: DataProps['data'],
compData: DataProps['compData']
compData: DataProps['compData'],
) {
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);

View file

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

View file

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

View file

@ -1,6 +1,6 @@
export function sankeyTooltip(
echartNodes: any[],
nodeValues: Record<string, number>
nodeValues: Record<string, number>,
) {
return (params: any) => {
if ('source' in params.data && 'target' in params.data) {
@ -25,7 +25,7 @@ export function sankeyTooltip(
</div>
<div class="flex items-baseline gap-2 text-black">
<span>${params.data.value} ( ${params.data.percentage.toFixed(
2
2,
)}% )</span>
<span class="text-disabled-text">Sessions</span>
</div>
@ -53,7 +53,7 @@ const shortenString = (str: string) => {
str.length > limit
? `${str.slice(0, leftPart)}...${str.slice(
str.length - rightPart,
str.length
str.length,
)}`
: str;
@ -73,7 +73,7 @@ export const getEventPriority = (type: string): number => {
export const getNodeName = (
eventType: string,
nodeName: string | null
nodeName: string | null,
): string => {
if (!nodeName) {
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
* with `value` and `itemStyle.color` based on the category index.
*/
export function assignColorsByCategory(
series: any[],
categories: string[]
) {
export function assignColorsByCategory(series: any[], categories: string[]) {
const categoryColorMap = buildCategoryColorMap(categories);
series.forEach((s, si) => {
@ -94,7 +91,9 @@ export function customTooltipFormatter(uuid: string) {
const isPrevious = /Previous/.test(seriesName);
const categoryName = (window as any).__yAxisData?.[uuid]?.[dataIndex];
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]?.[
partnerName
];
@ -279,7 +278,7 @@ export function createSeries(
data: DataProps['data'],
datasetId: string,
dashed: boolean,
hideFromLegend: boolean
hideFromLegend: boolean,
) {
return data.namesMap.filter(Boolean).map((fullName) => {
const baseName = fullName.replace(/^Previous\s+/, '');

View file

@ -1,48 +1,55 @@
import React from 'react';
import { JSONTree } from 'UI';
import { checkForRecent } from 'App/date';
import { useTranslation } from 'react-i18next';
interface Props {
audit: any;
}
function AuditDetailModal(props: Props) {
const { t } = useTranslation();
const { audit } = props;
// const jsonResponse = typeof audit.payload === 'string' ? JSON.parse(audit.payload) : audit.payload;
// console.log('jsonResponse', jsonResponse)
return (
<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">
<h5 className="mb-2">{ 'URL'}</h5>
<div className="color-gray-darkest p-2 bg-gray-lightest rounded">{ audit.endPoint }</div>
<h5 className="mb-2">{t('URL')}</h5>
<div className="color-gray-darkest p-2 bg-gray-lightest rounded">
{audit.endPoint}
</div>
<div className="grid grid-cols-2 my-6">
<div className="">
<div className="font-medium mb-2">Username</div>
<div className="font-medium mb-2">{t('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 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">Action</div>
<div className="font-medium mb-2">{t('Action')}</div>
<div>{audit.action}</div>
</div>
<div className="">
<div className="font-medium mb-2">Method</div>
<div className="font-medium mb-2">{t('Method')}</div>
<div>{audit.method}</div>
</div>
</div>
{ audit.payload && (
{audit.payload && (
<div className="my-6">
<div className="font-medium mb-3">Payload</div>
<JSONTree src={ audit.payload } collapsed={ false } enableClipboard />
<div className="font-medium mb-3">{t('Payload')}</div>
<JSONTree src={audit.payload} collapsed={false} enableClipboard />
</div>
)}
</div>

View file

@ -3,14 +3,14 @@ import { useStore } from 'App/mstore';
import { useObserver } from 'mobx-react-lite';
import React, { useEffect } from 'react';
import { Loader, Pagination, NoContent } from 'UI';
import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG';
import AuditDetailModal from '../AuditDetailModal';
import AuditListItem from '../AuditListItem';
import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG';
import { useTranslation } from 'react-i18next';
interface Props {
}
interface Props {}
function AuditList(props: Props) {
const { t } = useTranslation();
const { auditStore } = useStore();
const loading = useObserver(() => auditStore.isLoading);
const list = useObserver(() => auditStore.list);
@ -38,23 +38,28 @@ function AuditList(props: Props) {
title={
<div className="flex flex-col items-center justify-center">
<AnimatedSVG name={ICONS.NO_AUDIT_TRAIL} size={60} />
<div className="text-center my-4">No data available</div>
<div className="text-center my-4">{t('No data available')}</div>
</div>
}
size="small"
show={list.length === 0}
>
<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 className="col-span-5">{t('Name')}</div>
<div className="col-span-4">{t('Action')}</div>
<div className="col-span-3">{t('Time')}</div>
</div>
{list.map((item, index) => (
<AuditListItem
key={index}
audit={item}
onShowDetails={() => showModal(<AuditDetailModal audit={item} />, { right: true, width: 500 })}
onShowDetails={() =>
showModal(<AuditDetailModal audit={item} />, {
right: true,
width: 500,
})
}
/>
))}

View file

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

View file

@ -10,8 +10,16 @@ function AuditListItem(props: Props) {
return (
<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-4 link cursor-pointer select-none" onClick={onShowDetails}>{audit.action}</div>
<div className="col-span-3">{audit.createdAt && checkForRecent(audit.createdAt, 'LLL dd, yyyy, hh:mm a')}</div>
<div
className="col-span-4 link cursor-pointer select-none"
onClick={onShowDetails}
>
{audit.action}
</div>
<div className="col-span-3">
{audit.createdAt &&
checkForRecent(audit.createdAt, 'LLL dd, yyyy, hh:mm a')}
</div>
</div>
);
}

View file

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

View file

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

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 { 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 Integrations from './Integrations';
import UserView from './Users/UsersView';
@ -13,8 +15,6 @@ import CustomFields from './CustomFields';
import Webhooks from './Webhooks';
import Notifications from './Notifications';
import Roles from './Roles';
import SessionsListingSettings from 'Components/Client/SessionsListingSettings';
import Modules from 'Components/Client/Modules';
@withRouter
export default class Client extends React.PureComponent {
@ -28,17 +28,72 @@ export default class Client extends React.PureComponent {
renderActiveTab = () => (
<Switch>
<Route exact strict path={clientRoute(CLIENT_TABS.PROFILE)} component={ProfileSettings} />
<Route exact strict path={clientRoute(CLIENT_TABS.SESSIONS_LISTING)} 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} />
<Route
exact
strict
path={clientRoute(CLIENT_TABS.PROFILE)}
component={ProfileSettings}
/>
<Route
exact
strict
path={clientRoute(CLIENT_TABS.SESSIONS_LISTING)}
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)} />
</Switch>
);
@ -46,11 +101,11 @@ export default class Client extends React.PureComponent {
render() {
const {
match: {
params: { activeTab }
}
params: { activeTab },
},
} = this.props;
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()}
</div>
);

View file

@ -4,7 +4,9 @@ import { edit, save } from 'Duck/customField';
import { Form, Input, Button } from 'UI';
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 setFocus = () => focusElementRef.current.focus();
@ -15,10 +17,14 @@ const CustomFieldForm = ({ field, saving, errors, edit, save, onSave, onClose, o
return (
<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.Field>
<label>{'Field Name'}</label>
<label>Field Name</label>
<Input
ref={focusElementRef}
name="key"
@ -41,21 +47,21 @@ const CustomFieldForm = ({ field, saving, errors, edit, save, onSave, onClose, o
{exists ? 'Update' : 'Add'}
</Button>
<Button data-hidden={!exists} onClick={onClose}>
{'Cancel'}
Cancel
</Button>
</div>
<Button variant="text" icon="trash" data-hidden={!exists} onClick={onDelete}></Button>
<Button variant="text" icon="trash" data-hidden={!exists} onClick={onDelete} />
</div>
</Form>
</div>
);
};
}
const mapStateToProps = (state) => ({
field: state.getIn(['customFields', 'instance']),
saving: state.getIn(['customFields', 'saveRequest', 'loading']),
errors: state.getIn(['customFields', 'saveRequest', 'errors'])
errors: state.getIn(['customFields', 'saveRequest', 'errors']),
});
export default connect(mapStateToProps, { edit, save })(CustomFieldForm);

View file

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

View file

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

View file

@ -4,24 +4,27 @@ import { Icon } from 'UI';
import { Button } from 'antd';
import styles from './listItem.module.css';
const ListItem = ({ field, onEdit, disabled }) => {
function ListItem({ field, onEdit, disabled }) {
return (
<div
className={cn(
'group hover:bg-active-blue flex items-center justify-between py-3 px-5 cursor-pointer',
field.index === 0 ? styles.preDefined : '',
{
[styles.disabled]: disabled
}
[styles.disabled]: disabled,
},
)}
onClick={() => field.index !== 0 && onEdit(field)}
>
<span>{field.key}</span>
<div className="invisible group-hover:visible" data-hidden={field.index === 0}>
<Button type="text" icon={<Icon name={"pencil"} size={16} />} />
<div
className="invisible group-hover:visible"
data-hidden={field.index === 0}
>
<Button type="text" icon={<Icon name="pencil" size={16} />} />
</div>
</div>
);
};
}
export default ListItem;

View file

@ -1,33 +1,36 @@
import React from 'react'
import { KEY, options } from 'App/dev/console'
import React from 'react';
import { KEY, options } from 'App/dev/console';
import { Switch } from 'UI';
import { useTranslation } from 'react-i18next';
function getDefaults() {
const storedString = localStorage.getItem(KEY)
const storedString = localStorage.getItem(KEY);
if (storedString) {
const storedOptions = JSON.parse(storedString)
return storedOptions.verbose
} else {
return false
const storedOptions = JSON.parse(storedString);
return storedOptions.verbose;
}
return false;
}
function DebugLog() {
const [showLogs, setShowLogs] = React.useState(getDefaults)
const { t } = useTranslation();
const [showLogs, setShowLogs] = React.useState(getDefaults);
const onChange = (checked: boolean) => {
setShowLogs(checked)
options.logStuff(checked)
}
setShowLogs(checked);
options.logStuff(checked);
};
return (
<div>
<h3 className={'text-lg'}>Player Debug Logs</h3>
<div className={'my-1'}>Show debug information in browser console.</div>
<div className={'mt-2'}>
<h3 className="text-lg">{t('Player Debug Logs')}</h3>
<div className="my-1">
{t('Show debug information in browser console.')}
</div>
<div className="mt-2">
<Switch checked={showLogs} onChange={onChange} />
</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 DocLink from 'Shared/DocLink/DocLink';
import { useTranslation } from 'react-i18next';
interface DatadogConfig {
site: string;
@ -23,15 +24,16 @@ const initialValues = {
app_key: '',
};
const DatadogFormModal = ({
function DatadogFormModal({
onClose,
integrated,
}: {
onClose: () => void;
integrated: boolean;
}) => {
}) {
const { t } = useTranslation();
const { integrationsStore } = useStore();
const siteId = integrationsStore.integrations.siteId;
const { siteId } = integrationsStore.integrations;
const {
data = initialValues,
@ -39,7 +41,9 @@ const DatadogFormModal = ({
saveMutation,
removeMutation,
} = useIntegration<DatadogConfig>('datadog', siteId, initialValues);
const { values, errors, handleChange, hasErrors, checkErrors } = useForm(data, {
const { values, errors, handleChange, hasErrors, checkErrors } = useForm(
data,
{
site: {
required: true,
},
@ -49,7 +53,8 @@ const DatadogFormModal = ({
app_key: {
required: true,
},
});
},
);
const exists = Boolean(data.api_key);
const save = async () => {
@ -59,7 +64,7 @@ const DatadogFormModal = ({
try {
await saveMutation.mutateAsync({ values, siteId, exists });
} catch (e) {
console.error(e)
console.error(e);
}
onClose();
};
@ -68,7 +73,7 @@ const DatadogFormModal = ({
try {
await removeMutation.mutateAsync({ siteId });
} catch (e) {
console.error(e)
console.error(e);
}
onClose();
};
@ -83,20 +88,20 @@ const DatadogFormModal = ({
description="Incorporate DataDog to visualize backend errors alongside session replay, for easy troubleshooting."
/>
<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">
<li>Generate Datadog API Key & Application Key</li>
<li>Enter the API key below</li>
<li>Propagate openReplaySessionToken</li>
<li>{t('Generate Datadog API Key & Application Key')}</li>
<li>{t('Enter the API key below')}</li>
<li>{t('Propagate openReplaySessionToken')}</li>
</ol>
<DocLink
className="mt-4"
label="Integrate Datadog"
label={t('Integrate Datadog')}
url="https://docs.openreplay.com/integrations/datadog"
/>
<Loader loading={isPending}>
<FormField
label="Site"
label={t('Site')}
name="site"
value={values.site}
onChange={handleChange}
@ -104,32 +109,32 @@ const DatadogFormModal = ({
errors={errors.site}
/>
<FormField
label="API Key"
label={t('API Key')}
name="api_key"
value={values.api_key}
onChange={handleChange}
errors={errors.api_key}
/>
<FormField
label="Application Key"
label={t('Application Key')}
name="app_key"
value={values.app_key}
onChange={handleChange}
errors={errors.app_key}
/>
<div className={'flex items-center gap-2'}>
<div className="flex items-center gap-2">
<Button
onClick={save}
disabled={hasErrors}
loading={saveMutation.isPending}
type="primary"
>
{exists ? 'Update' : 'Add'}
{exists ? t('Update') : t('Add')}
</Button>
{integrated && (
<Button loading={removeMutation.isPending} onClick={remove}>
{'Delete'}
{t('Delete')}
</Button>
)}
</div>
@ -137,7 +142,7 @@ const DatadogFormModal = ({
</div>
</div>
);
};
}
DatadogFormModal.displayName = 'DatadogForm';

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

@ -1,8 +1,7 @@
import React from 'react';
import cn from 'classnames';
import { Icon } from 'UI';
import stl from './integrationItem.module.css';
import { Tooltip } from 'antd';
import { useTranslation } from 'react-i18next';
interface Props {
integration: any;
@ -12,32 +11,50 @@ interface Props {
useIcon?: boolean;
}
const IntegrationItem = (props: Props) => {
function IntegrationItem(props: Props) {
const { t } = useTranslation();
const { integration, integrated, hide = false, useIcon } = props;
return hide ? null : (
<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)}
style={{ height: '136px' }}
>
<div className='flex gap-3'>
<div className="flex gap-3">
<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 className='flex flex-col'>
<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>
<div className="flex flex-col">
<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>
</div>
</div>
{integrated && (
<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" />
<span>Integrated</span>
<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"
/>
<span>{t('Integrated')}</span>
</div>
)}
</div>
);
};
}
export default IntegrationItem;

View file

@ -11,12 +11,20 @@ interface Props {
function IntegrationModalCard(props: Props) {
const { title, icon, description, useIcon } = props;
return (
<div className='flex items-start p-5 gap-4'>
<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" />}
<div className="flex items-start p-5 gap-4">
<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"
/>
)}
</div>
<div>
<h3 className='text-2xl'>{title}</h3>
<h3 className="text-2xl">{title}</h3>
<div>{description}</div>
</div>
</div>

View file

@ -28,6 +28,8 @@ import PiniaDoc from './Tracker/PiniaDoc';
import ReduxDoc from './Tracker/ReduxDoc';
import VueDoc from './Tracker/VueDoc';
import ZustandDoc from './Tracker/ZustandDoc';
import { TFunction } from 'i18next';
import { useTranslation } from 'react-i18next';
interface Props {
siteId: string;
@ -35,9 +37,10 @@ interface Props {
}
function Integrations(props: Props) {
const { t } = useTranslation();
const { integrationsStore, projectsStore } = useStore();
const initialSiteId = projectsStore.siteId;
const siteId = integrationsStore.integrations.siteId;
const { siteId } = integrationsStore.integrations;
const fetchIntegrationList = integrationsStore.integrations.fetchIntegrations;
const storeIntegratedList = integrationsStore.integrations.list;
const { hideHeader = false } = props;
@ -46,8 +49,9 @@ function Integrations(props: Props) {
const [activeFilter, setActiveFilter] = useState<string>('all');
useEffect(() => {
const list = integrationsStore.integrations.integratedServices
.map((item: any) => item.name);
const list = integrationsStore.integrations.integratedServices.map(
(item: any) => item.name,
);
setIntegratedList(list);
}, [storeIntegratedList]);
@ -86,7 +90,7 @@ function Integrations(props: Props) {
siteId,
onClose: hideModal,
}),
{ right: true, width }
{ right: true, width },
);
};
@ -94,7 +98,7 @@ function Integrations(props: Props) {
setActiveFilter(key);
};
const filteredIntegrations = integrations.filter((cat: any) => {
const filteredIntegrations = integrations(t).filter((cat: any) => {
if (activeFilter === 'all') {
return true;
}
@ -102,7 +106,7 @@ function Integrations(props: Props) {
return cat.key === activeFilter;
});
const filters = integrations.map((cat: any) => ({
const filters = integrations(t).map((cat: any) => ({
key: cat.key,
title: cat.title,
label: cat.title,
@ -110,7 +114,7 @@ function Integrations(props: Props) {
}));
const allIntegrations = filteredIntegrations.flatMap(
(cat) => cat.integrations
(cat) => cat.integrations,
);
const onChangeSelect = ({ value }: any) => {
@ -120,8 +124,8 @@ function Integrations(props: Props) {
return (
<>
<div className="bg-white rounded-lg border shadow-sm p-5 mb-4">
<div className={'flex items-center gap-4 mb-2'}>
{!hideHeader && <PageTitle title={<div>Integrations</div>} />}
<div className="flex items-center gap-4 mb-2">
{!hideHeader && <PageTitle title={<div>{t('Integrations')}</div>} />}
<SiteDropdown value={siteId} onChange={onChangeSelect} />
</div>
<IntegrationFilters
@ -133,9 +137,7 @@ function Integrations(props: Props) {
<div className="mb-4" />
<div
className={'mt-4 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3'}
>
<div className="mt-4 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{allIntegrations.map((integration, i) => (
<React.Fragment key={`${integration.slug}+${i}`}>
<IntegrationItem
@ -146,10 +148,10 @@ function Integrations(props: Props) {
onClick(
integration,
filteredIntegrations.find((cat) =>
cat.integrations.includes(integration)
cat.integrations.includes(integration),
)?.title === 'Plugins'
? 500
: 350
: 350,
)
}
hide={
@ -167,31 +169,34 @@ function Integrations(props: Props) {
}
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',
description:
description: t(
'Seamlessly report issues or share issues with your team right from OpenReplay.',
),
isProject: false,
icon: 'exclamation-triangle',
integrations: [
{
title: 'Jira',
subtitle:
title: t('Jira'),
subtitle: t(
'Integrate Jira with OpenReplay to enable the creation of a new ticket directly from a session.',
),
slug: 'jira',
category: 'Errors',
icon: 'integrations/jira',
component: <JiraForm />,
},
{
title: 'Github',
subtitle:
title: t('Github'),
subtitle: t(
'Integrate GitHub with OpenReplay to enable the direct creation of a new issue from a session.',
),
slug: 'github',
category: 'Errors',
icon: 'integrations/github',
@ -200,52 +205,58 @@ const integrations = [
],
},
{
title: 'Backend Logging',
title: t('Backend Logging'),
key: 'backend-logging',
isProject: true,
icon: 'terminal',
description:
description: t(
'Sync your backend errors with sessions replays and see what happened front-to-back.',
),
docs: () => (
<DocCard
title="Why use integrations?"
title={t('Why use integrations?')}
icon="question-lg"
iconBgColor="bg-red-lightest"
iconColor="red"
>
Sync your backend errors with sessions replays and see what happened
front-to-back.
{t(
'Sync your backend errors with sessions replays and see what happened front-to-back.',
)}
</DocCard>
),
integrations: [
{
title: 'Sentry',
subtitle:
title: t('Sentry'),
subtitle: t(
'Integrate Sentry with session replays to seamlessly observe backend errors.',
),
slug: 'sentry',
icon: 'integrations/sentry',
component: <SentryForm />,
},
{
title: 'Elasticsearch',
subtitle:
title: t('Elasticsearch'),
subtitle: t(
'Integrate Elasticsearch with session replays to seamlessly observe backend errors.',
),
slug: 'elasticsearch',
icon: 'integrations/elasticsearch',
component: <ElasticsearchForm />,
},
{
title: 'Datadog',
subtitle:
title: t('Datadog'),
subtitle: t(
'Incorporate DataDog to visualize backend errors alongside session replay, for easy troubleshooting.',
),
slug: 'datadog',
icon: 'integrations/datadog',
component: <DatadogForm />,
},
{
title: 'Dynatrace',
subtitle:
title: t('Dynatrace'),
subtitle: t(
'Integrate Dynatrace with session replays to link backend logs with user sessions for faster issue resolution.',
),
slug: 'dynatrace',
icon: 'integrations/dynatrace',
useIcon: true,
@ -254,17 +265,19 @@ const integrations = [
],
},
{
title: 'Collaboration',
title: t('Collaboration'),
key: 'collaboration',
isProject: false,
icon: 'file-code',
description:
description: t(
'Share your sessions with your team and collaborate on issues.',
),
integrations: [
{
title: 'Slack',
subtitle:
title: t('Slack'),
subtitle: t(
'Integrate Slack to empower every user in your org with the ability to send sessions to any Slack channel.',
),
slug: 'slack',
category: 'Errors',
icon: 'integrations/slack',
@ -272,9 +285,10 @@ const integrations = [
shared: true,
},
{
title: 'MS Teams',
subtitle:
title: t('MS Teams'),
subtitle: t(
'Integrate MS Teams to empower every user in your org with the ability to send sessions to any MS Teams channel.',
),
slug: 'msteams',
category: 'Errors',
icon: 'integrations/teams',
@ -292,84 +306,95 @@ const integrations = [
// integrations: []
// },
{
title: 'Plugins',
title: t('Plugins'),
key: 'plugins',
isProject: true,
icon: 'chat-left-text',
docs: () => (
<DocCard
title="What are plugins?"
title={t('What are plugins?')}
icon="question-lg"
iconBgColor="bg-red-lightest"
iconColor="red"
>
Plugins capture your applications store, monitor queries, track
performance issues and even assist your end user through live sessions.
{t(
'Plugins capture your applications store, monitor queries, track performance issues and even assist your end user through live sessions.',
)}
</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.",
),
integrations: [
{
title: 'Redux',
subtitle:
title: t('Redux'),
subtitle: t(
'Capture Redux actions/state and inspect them later on while replaying session recordings.',
),
icon: 'integrations/redux',
component: <ReduxDoc />,
},
{
title: 'VueX',
subtitle:
title: t('VueX'),
subtitle: t(
'Capture VueX mutations/state and inspect them later on while replaying session recordings.',
),
icon: 'integrations/vuejs',
component: <VueDoc />,
},
{
title: 'Pinia',
subtitle:
title: t('Pinia'),
subtitle: t(
'Capture Pinia mutations/state and inspect them later on while replaying session recordings.',
),
icon: 'integrations/pinia',
component: <PiniaDoc />,
},
{
title: 'GraphQL',
subtitle:
title: t('GraphQL'),
subtitle: t(
'Capture GraphQL requests and inspect them later on while replaying session recordings. This plugin is compatible with Apollo and Relay implementations.',
),
icon: 'integrations/graphql',
component: <GraphQLDoc />,
},
{
title: 'NgRx',
subtitle:
title: t('NgRx'),
subtitle: t(
'Capture NgRx actions/state and inspect them later on while replaying session recordings.\n',
),
icon: 'integrations/ngrx',
component: <NgRxDoc />,
},
{
title: 'MobX',
subtitle:
title: t('MobX'),
subtitle: t(
'Capture MobX mutations and inspect them later on while replaying session recordings.',
),
icon: 'integrations/mobx',
component: <MobxDoc />,
},
{
title: 'Profiler',
subtitle:
title: t('Profiler'),
subtitle: t(
'Plugin allows you to measure your JS functions performance and capture both arguments and result for each call.',
),
icon: 'integrations/openreplay',
component: <ProfilerDoc />,
},
{
title: 'Assist',
subtitle:
title: t('Assist'),
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',
),
icon: 'integrations/openreplay',
component: <AssistDoc />,
},
{
title: 'Zustand',
subtitle:
title: t('Zustand'),
subtitle: t(
'Capture Zustand mutations/state and inspect them later on while replaying session recordings.',
),
icon: 'integrations/zustand',
// header: '🐻',
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,16 +1,20 @@
import { useStore } from "App/mstore";
import { useStore } from 'App/mstore';
import React from 'react';
import { observer } from 'mobx-react-lite';
import { CodeBlock } from 'UI';
import DocLink from 'Shared/DocLink/DocLink';
import ToggleContent from 'Shared/ToggleContent';
import { useTranslation } from 'react-i18next';
const ProfilerDoc = () => {
function ProfilerDoc() {
const { t } = useTranslation();
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 { siteId } = integrationsStore.integrations;
const projectKey = siteId
? sites.find((site) => site.id === siteId)?.projectKey
: sites[0]?.projectKey;
const usage = `import OpenReplay from '@openreplay/tracker';
import trackerProfiler from '@openreplay/tracker-profiler';
@ -48,43 +52,45 @@ const fn = profiler('call_name')(() => {
className="bg-white h-screen overflow-y-auto"
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>
The profiler plugin allows you to measure your JS functions'
performance and capture both arguments and result for each function
call.
{t(
'The profiler plugin allows you to measure your JS functions performance and capture both arguments and result for each function call',
)}
.
</div>
<div className="font-bold my-2">Installation</div>
<div className="font-bold my-2">{t('Installation')}</div>
<CodeBlock
code={`npm i @openreplay/tracker-profiler --save`}
language={'bash'}
code="npm i @openreplay/tracker-profiler --save"
language="bash"
/>
<div className="font-bold my-2">Usage</div>
<div className="font-bold my-2">{t('Usage')}</div>
<p>
Initialize the tracker and load the plugin into it. Then decorate any
function inside your code with the generated function.
{t(
'Initialize the tracker and load the plugin into it. Then decorate any function inside your code with the generated function.',
)}
</p>
<div className="py-3" />
<div className="font-bold my-2">Usage</div>
<div className="font-bold my-2">{t('Usage')}</div>
<ToggleContent
label="Server-Side-Rendered (SSR)?"
first={<CodeBlock language={'js'} code={usage} />}
second={<CodeBlock language={'jsx'} code={usageCjs} />}
label={t('Server-Side-Rendered (SSR)?')}
first={<CodeBlock language="js" code={usage} />}
second={<CodeBlock language="jsx" code={usageCjs} />}
/>
<DocLink
className="mt-4"
label="Integrate Profiler"
label={t('Integrate Profiler')}
url="https://docs.openreplay.com/plugins/profiler"
/>
</div>
</div>
);
};
}
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 { Form, Input, Message, confirm } from 'UI';
import { Button } from 'antd'
import { observer } from 'mobx-react-lite'
import { useStore } from 'App/mstore'
import { Button } from 'antd';
import { observer } from 'mobx-react-lite';
import { useStore } from 'App/mstore';
import { useTranslation } from 'react-i18next';
function SlackAddForm(props) {
const { t } = useTranslation();
const { onClose } = props;
const { integrationsStore } = useStore();
const instance = integrationsStore.slack.instance;
const { instance } = integrationsStore.slack;
const saving = integrationsStore.slack.loading;
const errors = integrationsStore.slack.errors;
const edit = integrationsStore.slack.edit;
const { errors } = integrationsStore.slack;
const { edit } = integrationsStore.slack;
const onSave = integrationsStore.slack.saveIntegration;
const update = integrationsStore.slack.update;
const init = integrationsStore.slack.init;
const { update } = integrationsStore.slack;
const { init } = integrationsStore.slack;
const onRemove = integrationsStore.slack.removeInt;
React.useEffect(() => {
return () => init({})
}, [])
React.useEffect(() => () => init({}), []);
const save = () => {
if (instance.exists()) {
@ -32,9 +31,11 @@ function SlackAddForm(props) {
const remove = async (id) => {
if (
await confirm({
header: 'Confirm',
confirmButton: 'Yes, delete',
confirmation: `Are you sure you want to permanently delete this channel?`,
header: t('Confirm'),
confirmButton: t('Yes, delete'),
confirmation: t(
'Are you sure you want to permanently delete this channel?',
),
})
) {
await onRemove(id);
@ -48,22 +49,22 @@ function SlackAddForm(props) {
<div className="p-5" style={{ minWidth: '300px' }}>
<Form>
<Form.Field>
<label>Name</label>
<label>{t('Name')}</label>
<Input
name="name"
value={instance.name}
onChange={write}
placeholder="Enter any name"
placeholder={t('Enter any name')}
type="text"
/>
</Form.Field>
<Form.Field>
<label>URL</label>
<label>{t('URL')}</label>
<Input
name="endpoint"
value={instance.endpoint}
onChange={write}
placeholder="Slack webhook URL"
placeholder={t('Slack webhook URL')}
type="text"
/>
</Form.Field>
@ -76,14 +77,17 @@ function SlackAddForm(props) {
type="primary"
className="float-left mr-2"
>
{instance.exists() ? 'Update' : 'Add'}
{instance.exists() ? t('Update') : t('Add')}
</Button>
<Button onClick={onClose}>{'Cancel'}</Button>
<Button onClick={onClose}>{t('Cancel')}</Button>
</div>
<Button onClick={() => remove(instance.webhookId)} disabled={!instance.exists()}>
{'Delete'}
<Button
onClick={() => remove(instance.webhookId)}
disabled={!instance.exists()}
>
{t('Delete')}
</Button>
</div>
</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,14 +1,16 @@
import React, { useEffect } from 'react';
import SlackChannelList from './SlackChannelList/SlackChannelList';
import SlackAddForm from './SlackAddForm';
import { Icon } from 'UI';
import { Button } from 'antd';
import { observer } from 'mobx-react-lite'
import { useStore } from 'App/mstore'
import { observer } from 'mobx-react-lite';
import { useStore } from 'App/mstore';
import SlackAddForm from './SlackAddForm';
import SlackChannelList from './SlackChannelList/SlackChannelList';
import { useTranslation } from 'react-i18next';
const SlackForm = () => {
function SlackForm() {
const { t } = useTranslation();
const { integrationsStore } = useStore();
const init = integrationsStore.slack.init;
const { init } = integrationsStore.slack;
const fetchList = integrationsStore.slack.fetchIntegrations;
const [active, setActive] = React.useState(false);
@ -19,14 +21,17 @@ const SlackForm = () => {
const onNew = () => {
setActive(true);
init({});
}
};
useEffect(() => {
void fetchList();
}, []);
return (
<div className="bg-white h-screen overflow-y-auto flex items-start" style={{ width: active ? '700px' : '350px' }}>
<div
className="bg-white h-screen overflow-y-auto flex items-start"
style={{ width: active ? '700px' : '350px' }}
>
{active && (
<div className="border-r h-full" style={{ width: '350px' }}>
<SlackAddForm onClose={() => setActive(false)} />
@ -34,14 +39,19 @@ const SlackForm = () => {
)}
<div className="shrink-0" style={{ width: '350px' }}>
<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}/>
<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';

View file

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

View file

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

View file

@ -2,15 +2,17 @@ import React, { useEffect } from 'react';
import { useStore } from 'App/mstore';
import { observer } from 'mobx-react-lite';
import { Icon } from 'UI';
import { Button } from 'antd'
import { Button } from 'antd';
import TeamsChannelList from './TeamsChannelList';
import TeamsAddForm from './TeamsAddForm';
import { useTranslation } from 'react-i18next';
const MSTeams = () => {
function MSTeams() {
const { t } = useTranslation();
const { integrationsStore } = useStore();
const fetchList = integrationsStore.msteams.fetchIntegrations;
const init = integrationsStore.msteams.init;
const { init } = integrationsStore.msteams;
const [active, setActive] = React.useState(false);
const onEdit = () => {
@ -20,14 +22,17 @@ const MSTeams = () => {
const onNew = () => {
setActive(true);
init({});
}
};
useEffect(() => {
void fetchList();
}, []);
return (
<div className="bg-white h-screen overflow-y-auto flex items-start" style={{ width: active ? '700px' : '350px' }}>
<div
className="bg-white h-screen overflow-y-auto flex items-start"
style={{ width: active ? '700px' : '350px' }}
>
{active && (
<div className="border-r h-full" style={{ width: '350px' }}>
<TeamsAddForm onClose={() => setActive(false)} />
@ -35,14 +40,19 @@ const MSTeams = () => {
)}
<div className="shrink-0" style={{ width: '350px' }}>
<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}/>
<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';

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

View file

@ -3,8 +3,10 @@ import React from 'react';
import { CodeBlock } from 'UI';
import ToggleContent from 'Shared/ToggleContent';
import { useTranslation } from 'react-i18next';
function AssistNpm(props) {
const { t } = useTranslation();
const usage = `import OpenReplay from '@openreplay/tracker';
import trackerAssist from '@openreplay/tracker-assist';
const tracker = new OpenReplay({
@ -56,21 +58,23 @@ type ButtonOptions = HTMLButtonElement | string | {
innerHTML?: string, // to pass an svg string or text
style?: StyleObject, // style object (i.e {color: 'red', borderRadius: '10px'})
}
`
`;
return (
<div>
<p>
Initialize the tracker then load the @openreplay/tracker-assist plugin.
{t(
'Initialize the tracker then load the @openreplay/tracker-assist plugin.',
)}
</p>
<div className="font-bold my-2">Usage</div>
<div className="font-bold my-2">{t('Usage')}</div>
<ToggleContent
label="Server-Side-Rendered (SSR)?"
first={<CodeBlock code={usage} language="javascript" />}
second={<CodeBlock code={usageCjs} language="jsx" />}
/>
<div className="font-bold my-2">Options</div>
<div className="font-bold my-2">{t('Options')}</div>
<CodeBlock code={options} language="typescript" />
</div>
);

View file

@ -1,7 +1,9 @@
import React from 'react';
import { CodeBlock } from "UI";
import { useTranslation } from 'react-i18next';
import { CodeBlock } from 'UI';
function AssistScript(props) {
const { t } = useTranslation();
const scriptCode = `<!-- OpenReplay Tracking Code -->
<script>
(function(A,s,a,y,e,r){
@ -18,13 +20,17 @@ function AssistScript(props) {
r.isActive=function(){return false};
r.getSessionToken=function(){};
})(0, "${props.projectKey}", "${window.env.TRACKER_HOST || '//static.openreplay.com'}/${window.env.TRACKER_VERSION}/openreplay-assist.js", 1, 28);
</script>`
</script>`;
return (
<div>
<p>If your OpenReplay tracker is set up using the JS snippet, then simply replace the .../openreplay.js occurrence with .../openreplay-assist.js. Below is an example of how the script should like after the change:</p>
<p>
{t(
'If your OpenReplay tracker is set up using the JS snippet, then simply replace the .../openreplay.js occurrence with .../openreplay-assist.js. Below is an example of how the script should like after the change:',
)}
</p>
<div className="py-3" />
<CodeBlock code={scriptCode} language={'js'} />
<CodeBlock code={scriptCode} language="js" />
</div>
);
}

View file

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

View file

@ -1,75 +0,0 @@
import { useStore } from "App/mstore";
import React from 'react';
import { CodeBlock } from "UI";
import DocLink from 'Shared/DocLink/DocLink';
import ToggleContent from 'Shared/ToggleContent';
import { observer } from 'mobx-react-lite'
const GraphQLDoc = () => {
const { integrationsStore, projectsStore } = useStore();
const sites = projectsStore.list;
const siteId = integrationsStore.integrations.siteId
const projectKey = siteId ? sites.find((site) => site.id === siteId)?.projectKey : sites[0]?.projectKey
const usage = `import OpenReplay from '@openreplay/tracker';
import trackerGraphQL from '@openreplay/tracker-graphql';
//...
const tracker = new OpenReplay({
projectKey: '${projectKey}'
});
tracker.start()
//...
export const recordGraphQL = tracker.use(trackerGraphQL());`
const usageCjs = `import OpenReplay from '@openreplay/tracker/cjs';
import trackerGraphQL from '@openreplay/tracker-graphql/cjs';
//...
const tracker = new OpenReplay({
projectKey: '${projectKey}'
});
//...
function SomeFunctionalComponent() {
useEffect(() => { // or componentDidMount in case of Class approach
tracker.start()
}, [])
}
//...
export const recordGraphQL = tracker.use(trackerGraphQL());`
return (
<div className="bg-white h-screen overflow-y-auto" style={{ width: '500px' }}>
<h3 className="p-5 text-2xl">GraphQL</h3>
<div className="p-5">
<p>
This plugin allows you to capture GraphQL requests and inspect them later on while replaying session recordings. This is very
useful for understanding and fixing issues.
</p>
<p>GraphQL plugin is compatible with Apollo and Relay implementations.</p>
<div className="font-bold my-2">Installation</div>
<CodeBlock code={'npm i @openreplay/tracker-graphql --save'} language={'bash'} />
<div className="font-bold my-2">Usage</div>
<p>
The plugin call will return the function, which receives four variables operationKind, operationName, variables and result. It
returns result without changes.
</p>
<div className="py-3" />
<ToggleContent
label="Server-Side-Rendered (SSR)?"
first={
<CodeBlock language={'js'} code={usage} />
}
second={
<CodeBlock language={'jsx'} code={usageCjs} />
}
/>
<DocLink className="mt-4" label="Integrate GraphQL" url="https://docs.openreplay.com/plugins/graphql" />
</div>
</div>
);
};
GraphQLDoc.displayName = 'GraphQLDoc';
export default observer(GraphQLDoc);

View file

@ -0,0 +1,91 @@
import { useStore } from 'App/mstore';
import React from 'react';
import { CodeBlock } from 'UI';
import DocLink from 'Shared/DocLink/DocLink';
import ToggleContent from 'Shared/ToggleContent';
import { observer } from 'mobx-react-lite';
import { useTranslation } from 'react-i18next';
function GraphQLDoc() {
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 usage = `import OpenReplay from '@openreplay/tracker';
import trackerGraphQL from '@openreplay/tracker-graphql';
//...
const tracker = new OpenReplay({
projectKey: '${projectKey}'
});
tracker.start()
//...
export const recordGraphQL = tracker.use(trackerGraphQL());`;
const usageCjs = `import OpenReplay from '@openreplay/tracker/cjs';
import trackerGraphQL from '@openreplay/tracker-graphql/cjs';
//...
const tracker = new OpenReplay({
projectKey: '${projectKey}'
});
//...
function SomeFunctionalComponent() {
useEffect(() => { // or componentDidMount in case of Class approach
tracker.start()
}, [])
}
//...
export const recordGraphQL = tracker.use(trackerGraphQL());`;
return (
<div
className="bg-white h-screen overflow-y-auto"
style={{ width: '500px' }}
>
<h3 className="p-5 text-2xl">{t('GraphQL')}</h3>
<div className="p-5">
<p>
{t(
'This plugin allows you to capture GraphQL requests and inspect them later on while replaying session recordings. This is very useful for understanding and fixing issues.',
)}
</p>
<p>
{t(
'GraphQL plugin is compatible with Apollo and Relay implementations.',
)}
</p>
<div className="font-bold my-2">{t('Installation')}</div>
<CodeBlock
code="npm i @openreplay/tracker-graphql --save"
language="bash"
/>
<div className="font-bold my-2">{t('Usage')}</div>
<p>
{t(
'The plugin call will return the function, which receives four variables operationKind, operationName, variables and result. It returns result without changes.',
)}
</p>
<div className="py-3" />
<ToggleContent
label={t('Server-Side-Rendered (SSR)?')}
first={<CodeBlock language="js" code={usage} />}
second={<CodeBlock language="jsx" code={usageCjs} />}
/>
<DocLink
className="mt-4"
label="Integrate GraphQL"
url="https://docs.openreplay.com/plugins/graphql"
/>
</div>
</div>
);
}
GraphQLDoc.displayName = 'GraphQLDoc';
export default observer(GraphQLDoc);

View file

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

View file

@ -1,72 +0,0 @@
import React from 'react';
import ToggleContent from 'Shared/ToggleContent';
import DocLink from 'Shared/DocLink/DocLink';
import { CodeBlock } from "UI";
import { useStore } from 'App/mstore';
import { observer } from 'mobx-react-lite';
const MobxDoc = () => {
const { integrationsStore, projectsStore } = useStore();
const sites = projectsStore.list;
const siteId = integrationsStore.integrations.siteId
const projectKey = siteId ? sites.find((site) => site.id === siteId)?.projectKey : sites[0]?.projectKey
const mobxUsage = `import OpenReplay from '@openreplay/tracker';
import trackerMobX from '@openreplay/tracker-mobx';
//...
const tracker = new OpenReplay({
projectKey: '${projectKey}'
});
tracker.use(trackerMobX(<options>)); // check list of available options below
tracker.start();
`
const mobxUsageCjs = `import OpenReplay from '@openreplay/tracker/cjs';
import trackerMobX from '@openreplay/tracker-mobx/cjs';
//...
const tracker = new OpenReplay({
projectKey: '${projectKey}'
});
tracker.use(trackerMobX(<options>)); // check list of available options below
//...
function SomeFunctionalComponent() {
useEffect(() => { // or componentDidMount in case of Class approach
tracker.start()
}, [])
}`
return (
<div className="bg-white h-screen overflow-y-auto" style={{ width: '500px' }}>
<h3 className="p-5 text-2xl">MobX</h3>
<div className="p-5">
<div>
This plugin allows you to capture MobX events and inspect them later on while replaying session recordings. This is very useful
for understanding and fixing issues.
</div>
<div className="font-bold my-2">Installation</div>
<CodeBlock language={'bash'} code={`npm i @openreplay/tracker-mobx --save`} />
<div className="font-bold my-2">Usage</div>
<p>
Initialize the @openreplay/tracker package as usual and load the plugin into it. Then put the generated middleware into your Redux
chain.
</p>
<div className="py-3" />
<div className="font-bold my-2">Usage</div>
<ToggleContent
label="Server-Side-Rendered (SSR)?"
first={<CodeBlock language={'js'} code={mobxUsage} />}
second={<CodeBlock language={'jsx'} code={mobxUsageCjs} />}
/>
<DocLink className="mt-4" label="Integrate MobX" url="https://docs.openreplay.com/plugins/mobx" />
</div>
</div>
);
};
MobxDoc.displayName = 'MobxDoc';
export default observer(MobxDoc)

View file

@ -0,0 +1,88 @@
import React from 'react';
import ToggleContent from 'Shared/ToggleContent';
import DocLink from 'Shared/DocLink/DocLink';
import { CodeBlock } from 'UI';
import { useStore } from 'App/mstore';
import { observer } from 'mobx-react-lite';
import { useTranslation } from 'react-i18next';
function MobxDoc() {
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 mobxUsage = `import OpenReplay from '@openreplay/tracker';
import trackerMobX from '@openreplay/tracker-mobx';
//...
const tracker = new OpenReplay({
projectKey: '${projectKey}'
});
tracker.use(trackerMobX(<options>)); // check list of available options below
tracker.start();
`;
const mobxUsageCjs = `import OpenReplay from '@openreplay/tracker/cjs';
import trackerMobX from '@openreplay/tracker-mobx/cjs';
//...
const tracker = new OpenReplay({
projectKey: '${projectKey}'
});
tracker.use(trackerMobX(<options>)); // check list of available options below
//...
function SomeFunctionalComponent() {
useEffect(() => { // or componentDidMount in case of Class approach
tracker.start()
}, [])
}`;
return (
<div
className="bg-white h-screen overflow-y-auto"
style={{ width: '500px' }}
>
<h3 className="p-5 text-2xl">{t('MobX')}</h3>
<div className="p-5">
<div>
{t(
'This plugin allows you to capture MobX events and inspect them later on while replaying session recordings. This is very useful for understanding and fixing issues.',
)}
</div>
<div className="font-bold my-2">{t('Installation')}</div>
<CodeBlock
language="bash"
code="npm i @openreplay/tracker-mobx --save"
/>
<div className="font-bold my-2">{t('Usage')}</div>
<p>
{t(
'Initialize the @openreplay/tracker package as usual and load the plugin into it. Then put the generated middleware into your Redux chain.',
)}
</p>
<div className="py-3" />
<div className="font-bold my-2">{t('Usage')}</div>
<ToggleContent
label={t('Server-Side-Rendered (SSR)?')}
first={<CodeBlock language="js" code={mobxUsage} />}
second={<CodeBlock language="jsx" code={mobxUsageCjs} />}
/>
<DocLink
className="mt-4"
label={t('Integrate MobX')}
url="https://docs.openreplay.com/plugins/mobx"
/>
</div>
</div>
);
}
MobxDoc.displayName = 'MobxDoc';
export default observer(MobxDoc);

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