add locales and lint the project
This commit is contained in:
parent
aaaf9f07a4
commit
2b1a9f3378
1615 changed files with 51106 additions and 23044 deletions
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,9 +7,7 @@ interface Props {
|
|||
|
||||
function AdditionalRoutes(props: Props) {
|
||||
const { redirect } = props;
|
||||
return (
|
||||
<Redirect to={redirect} />
|
||||
);
|
||||
return <Redirect to={redirect} />;
|
||||
}
|
||||
|
||||
export default AdditionalRoutes;
|
||||
|
|
|
|||
31
frontend/app/ErrorBoundary.tsx
Normal file
31
frontend/app/ErrorBoundary.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import React, { Component } from 'react';
|
||||
|
||||
class ErrorBoundary extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error) {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error, errorInfo) {
|
||||
console.error('Error caught:', error, errorInfo);
|
||||
// Здесь можно отправить ошибку в сервис аналитики
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return this.props.fallback ? (
|
||||
this.props.fallback(this.state.error)
|
||||
) : (
|
||||
<div>Произошла ошибка: {this.state.error?.message}</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export default ErrorBoundary;
|
||||
|
|
@ -107,7 +107,11 @@ function PrivateRoutes() {
|
|||
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;
|
||||
const redirectToOnboarding =
|
||||
!onboarding &&
|
||||
(localStorage.getItem(GLOBAL_HAS_NO_RECORDINGS) === 'true' ||
|
||||
(sites.length > 0 && !hasRecordings)) &&
|
||||
scope > 0;
|
||||
const siteIdList: any = sites.map(({ id }) => id);
|
||||
|
||||
React.useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -13,14 +13,19 @@ 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;
|
||||
const { isEnterprise } = userStore;
|
||||
const hideSupport = isEnterprise || location.pathname.includes('spots') || location.pathname.includes('view-spot');
|
||||
const hideSupport =
|
||||
isEnterprise ||
|
||||
location.pathname.includes('spots') ||
|
||||
location.pathname.includes('view-spot');
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -36,7 +41,12 @@ function PublicRoutes() {
|
|||
<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} />
|
||||
|
|
|
|||
|
|
@ -28,13 +28,14 @@ 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,
|
||||
customFieldStore,
|
||||
projectsStore,
|
||||
sessionStore,
|
||||
searchStore,
|
||||
userStore,
|
||||
} = mstore;
|
||||
const { jwt } = userStore;
|
||||
const { changePassword } = userStore.account;
|
||||
|
|
@ -50,7 +51,9 @@ const Router: React.FC<RouterProps> = (props) => {
|
|||
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;
|
||||
|
||||
|
|
@ -108,10 +111,10 @@ const Router: React.FC<RouterProps> = (props) => {
|
|||
|
||||
const destinationPath = localStorage.getItem(GLOBAL_DESTINATION_PATH);
|
||||
if (
|
||||
destinationPath
|
||||
&& !destinationPath.includes(routes.login())
|
||||
&& !destinationPath.includes(routes.signup())
|
||||
&& destinationPath !== '/'
|
||||
destinationPath &&
|
||||
!destinationPath.includes(routes.login()) &&
|
||||
!destinationPath.includes(routes.signup()) &&
|
||||
destinationPath !== '/'
|
||||
) {
|
||||
const url = new URL(destinationPath, window.location.origin);
|
||||
checkParams(url.search);
|
||||
|
|
@ -192,12 +195,13 @@ const Router: React.FC<RouterProps> = (props) => {
|
|||
const prevIsLoggedIn = usePrevious(isLoggedIn);
|
||||
const previousLocation = usePrevious(location);
|
||||
|
||||
const hideHeader = (location.pathname && location.pathname.includes('/session/'))
|
||||
|| location.pathname.includes('/assist/')
|
||||
|| location.pathname.includes('multiview')
|
||||
|| location.pathname.includes('/view-spot/')
|
||||
|| location.pathname.includes('/spots/')
|
||||
|| location.pathname.includes('/scope-setup');
|
||||
const hideHeader =
|
||||
(location.pathname && location.pathname.includes('/session/')) ||
|
||||
location.pathname.includes('/assist/') ||
|
||||
location.pathname.includes('multiview') ||
|
||||
location.pathname.includes('/view-spot/') ||
|
||||
location.pathname.includes('/spots/') ||
|
||||
location.pathname.includes('/scope-setup');
|
||||
|
||||
if (isIframe) {
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -33,7 +33,10 @@ const siteIdRequiredPaths: string[] = [
|
|||
'/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);
|
||||
|
|
@ -59,7 +62,7 @@ export default class APIClient {
|
|||
|
||||
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;
|
||||
|
||||
|
|
@ -78,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;
|
||||
}
|
||||
|
||||
|
|
@ -90,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({
|
||||
|
|
@ -162,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;
|
||||
|
|
@ -172,16 +185,18 @@ export default class APIClient {
|
|||
delete init.body;
|
||||
}
|
||||
|
||||
const noChalice = path.includes('v1/integrations') || path.includes('/spot') && !path.includes('/login');
|
||||
const noChalice =
|
||||
path.includes('v1/integrations') ||
|
||||
(path.includes('/spot') && !path.includes('/login'));
|
||||
let edp = window.env.API_EDP || `${window.location.origin}/api`;
|
||||
if (noChalice && !edp.includes('api.openreplay.com')) {
|
||||
edp = edp.replace('/api', '');
|
||||
}
|
||||
if (
|
||||
path !== '/targets_temp'
|
||||
&& !path.includes('/metadata/session_search')
|
||||
&& !path.includes('/assist/credentials')
|
||||
&& siteIdRequiredPaths.some((sidPath) => path.startsWith(sidPath))
|
||||
path !== '/targets_temp' &&
|
||||
!path.includes('/metadata/session_search') &&
|
||||
!path.includes('/assist/credentials') &&
|
||||
siteIdRequiredPaths.some((sidPath) => path.startsWith(sidPath))
|
||||
) {
|
||||
edp = `${edp}/${this.siteId ?? ''}`;
|
||||
}
|
||||
|
|
@ -208,9 +223,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');
|
||||
|
|
@ -227,12 +247,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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,397 +0,0 @@
|
|||
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';
|
||||
|
||||
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' },
|
||||
];
|
||||
|
||||
function Circle({ text }) {
|
||||
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,
|
||||
}) {
|
||||
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 {
|
||||
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="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
|
||||
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);
|
||||
462
frontend/app/components/Alerts/AlertForm.tsx
Normal file
462
frontend/app/components/Alerts/AlertForm.tsx
Normal 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);
|
||||
|
|
@ -6,14 +6,14 @@ import { SLACK, TEAMS, WEBHOOK } from 'App/constants/schedule';
|
|||
import AlertForm from '../AlertForm';
|
||||
|
||||
interface Select {
|
||||
label: string;
|
||||
value: string | number
|
||||
label: string;
|
||||
value: string | number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
showModal?: boolean;
|
||||
metricId?: number;
|
||||
onClose?: () => void;
|
||||
showModal?: boolean;
|
||||
metricId?: number;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
function AlertFormModal(props: Props) {
|
||||
|
|
|
|||
|
|
@ -38,7 +38,16 @@ function 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 />;
|
||||
return (
|
||||
<TagBadge
|
||||
className={badgeClassName}
|
||||
key={text}
|
||||
text={text}
|
||||
hashed={false}
|
||||
onRemove={() => onRemove(val)}
|
||||
outline
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -57,7 +66,9 @@ function 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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -4,11 +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),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -7,13 +7,17 @@ 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;
|
||||
const hasEvents = searchStoreLive.instance.filters.filter((i: any) => i.isEvent).length > 0;
|
||||
const hasFilters = searchStoreLive.instance.filters.filter((i: any) => !i.isEvent).length > 0;
|
||||
const hasEvents =
|
||||
searchStoreLive.instance.filters.filter((i: any) => i.isEvent).length > 0;
|
||||
const hasFilters =
|
||||
searchStoreLive.instance.filters.filter((i: any) => !i.isEvent).length > 0;
|
||||
const { showModal } = useModal();
|
||||
|
||||
const showStats = () => {
|
||||
|
|
@ -24,25 +28,31 @@ function AssistSearchActions() {
|
|||
};
|
||||
return (
|
||||
<div className="flex items-center w-full gap-2">
|
||||
{isEnterprise && !modules.includes(MODULES.OFFLINE_RECORDINGS)
|
||||
? <Button type="text" onClick={showRecords}>Training Videos</Button> : null}
|
||||
{isEnterprise && !modules.includes(MODULES.OFFLINE_RECORDINGS) ? (
|
||||
<Button type="text" onClick={showRecords}>
|
||||
{t('Training Videos')}
|
||||
</Button>
|
||||
) : null}
|
||||
{isEnterprise && userStore.account?.admin && (
|
||||
<Button
|
||||
type="text"
|
||||
onClick={showStats}
|
||||
disabled={modules.includes(MODULES.ASSIST_STATS) || modules.includes(MODULES.ASSIST)}
|
||||
disabled={
|
||||
modules.includes(MODULES.ASSIST_STATS) ||
|
||||
modules.includes(MODULES.ASSIST)
|
||||
}
|
||||
>
|
||||
Co-Browsing Reports
|
||||
{t('Co-Browsing Reports')}
|
||||
</Button>
|
||||
)}
|
||||
<Tooltip title="Clear Search Filters">
|
||||
<Tooltip title={t('Clear Search Filters')}>
|
||||
<Button
|
||||
type="text"
|
||||
disabled={!hasFilters && !hasEvents}
|
||||
onClick={() => searchStoreLive.clearSearch()}
|
||||
className="px-2 ml-auto"
|
||||
>
|
||||
Clear
|
||||
{t('Clear')}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -6,26 +6,33 @@ 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) {
|
||||
stream,
|
||||
endCall,
|
||||
videoEnabled,
|
||||
setVideoEnabled,
|
||||
isPrestart,
|
||||
}: Props) {
|
||||
const [audioEnabled, setAudioEnabled] = useState(true);
|
||||
|
||||
const toggleAudio = () => {
|
||||
if (!stream) { return; }
|
||||
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 */
|
||||
|
|
@ -36,17 +43,49 @@ function ChatControls({
|
|||
}, []);
|
||||
|
||||
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>
|
||||
<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>
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -7,9 +7,10 @@ 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;
|
||||
|
|
@ -17,8 +18,13 @@ export interface Props {
|
|||
}
|
||||
|
||||
function ChatWindow({
|
||||
userId, incomeStream, localStream, endCall, isPrestart,
|
||||
userId,
|
||||
incomeStream,
|
||||
localStream,
|
||||
endCall,
|
||||
isPrestart,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { player } = React.useContext(PlayerContext);
|
||||
|
||||
const { toggleVideoLocalStream } = player.assistManager;
|
||||
|
|
@ -33,20 +39,27 @@ function ChatWindow({
|
|||
}, [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 || 'Anonymous User'}
|
||||
<b>{t('Call with')} </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')}
|
||||
|
|
@ -55,13 +68,24 @@ function ChatWindow({
|
|||
{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
|
||||
|
|
|
|||
|
|
@ -1,20 +1,18 @@
|
|||
import { useObserver } from 'mobx-react-lite';
|
||||
import React from 'react';
|
||||
import {
|
||||
Modal, Form, Icon, Input,
|
||||
} from 'UI';
|
||||
import { Modal, Form, Icon, Input } from 'UI';
|
||||
import { Button } from 'antd';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface Props {
|
||||
show: boolean;
|
||||
title: string;
|
||||
closeHandler?: () => void;
|
||||
onSave: (title: string) => void;
|
||||
show: boolean;
|
||||
title: string;
|
||||
closeHandler?: () => void;
|
||||
onSave: (title: string) => void;
|
||||
}
|
||||
function EditRecordingModal(props: Props) {
|
||||
const {
|
||||
show, closeHandler, title, onSave,
|
||||
} = props;
|
||||
const { t } = useTranslation();
|
||||
const { show, closeHandler, title, onSave } = props;
|
||||
const [text, setText] = React.useState(title);
|
||||
|
||||
React.useEffect(() => {
|
||||
|
|
@ -33,20 +31,16 @@ function EditRecordingModal(props: Props) {
|
|||
return useObserver(() => (
|
||||
<Modal open={show} onClose={closeHandler}>
|
||||
<Modal.Header className="flex items-center justify-between">
|
||||
<div>Rename</div>
|
||||
<div>{t('Rename')}</div>
|
||||
<div onClick={closeHandler}>
|
||||
<Icon
|
||||
color="gray-dark"
|
||||
size="14"
|
||||
name="close"
|
||||
/>
|
||||
<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"
|
||||
|
|
@ -61,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>
|
||||
|
|
|
|||
|
|
@ -21,13 +21,20 @@ function Recordings() {
|
|||
};
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: '1360px', margin: 'auto' }} className="bg-white rounded-lg py-4 border h-screen overflow-y-scroll">
|
||||
<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>
|
||||
<div className="ml-auto flex items-center gap-4">
|
||||
<SelectDateRange period={recordingsStore.period} onChange={onDateChange} right />
|
||||
<SelectDateRange
|
||||
period={recordingsStore.period}
|
||||
onChange={onDateChange}
|
||||
right
|
||||
/>
|
||||
<Select
|
||||
name="recsOwner"
|
||||
plain
|
||||
|
|
|
|||
|
|
@ -4,8 +4,10 @@ import { NoContent, Pagination, Loader } from 'UI';
|
|||
import { useStore } from 'App/mstore';
|
||||
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;
|
||||
|
|
@ -23,29 +25,31 @@ function RecordingsList() {
|
|||
return (
|
||||
<NoContent
|
||||
show={length === 0}
|
||||
title={(
|
||||
title={
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<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={(
|
||||
}
|
||||
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>
|
||||
)}
|
||||
}
|
||||
>
|
||||
<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,15 +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')} <span className="font-semibold">{total}</span>
|
||||
{t('Recording')}
|
||||
</div>
|
||||
<Pagination
|
||||
page={page}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,10 @@ 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
|
||||
|
|
@ -21,7 +24,11 @@ function RecordingsSearch() {
|
|||
|
||||
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"
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import { Button } from 'antd';
|
|||
import { PlayerContext } from 'App/components/Session/playerContext';
|
||||
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,28 +25,29 @@ 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;
|
||||
const windowType = getWindowType();
|
||||
|
|
@ -52,11 +55,7 @@ function RequestingWindow({ getWindowType }: Props) {
|
|||
const { player } = React.useContext(PlayerContext);
|
||||
|
||||
const {
|
||||
assistManager: {
|
||||
initiateCallEnd,
|
||||
releaseRemoteControl,
|
||||
stopRecording,
|
||||
},
|
||||
assistManager: { initiateCallEnd, releaseRemoteControl, stopRecording },
|
||||
} = player;
|
||||
|
||||
const actions = {
|
||||
|
|
@ -67,19 +66,29 @@ function RequestingWindow({ getWindowType }: Props) {
|
|||
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
|
||||
{' '}
|
||||
{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>
|
||||
|
|
|
|||
|
|
@ -2,10 +2,16 @@ import React, { useState, useEffect } from 'react';
|
|||
import { Button } from 'antd';
|
||||
import cn from 'classnames';
|
||||
import {
|
||||
CallingState, ConnectionStatus, RemoteControlStatus, RequestLocalStream,
|
||||
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';
|
||||
|
|
@ -14,17 +20,7 @@ import { audioContextManager } from 'App/utils/screenRecorder';
|
|||
import { useStore } from 'App/mstore';
|
||||
import stl from './AassistActions.module.css';
|
||||
import ChatWindow from '../../ChatWindow';
|
||||
|
||||
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 { useTranslation } from 'react-i18next';
|
||||
|
||||
function onError(e: any) {
|
||||
console.log(e);
|
||||
|
|
@ -49,16 +45,15 @@ const AssistActionsPing = {
|
|||
},
|
||||
} 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 hasPermission =
|
||||
permissions.includes('ASSIST_CALL') ||
|
||||
permissions.includes('SERVICE_ASSIST_CALL');
|
||||
const { isEnterprise } = userStore;
|
||||
const agentId = userStore.account.id;
|
||||
const { userDisplayName } = sessionStore.current;
|
||||
|
|
@ -82,15 +77,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);
|
||||
const cannotCall =
|
||||
peerConnectionStatus !== ConnectionStatus.Connected ||
|
||||
(isEnterprise && !hasPermission);
|
||||
|
||||
const remoteRequesting = remoteControlStatus === RemoteControlStatus.Requesting;
|
||||
const remoteRequesting =
|
||||
remoteControlStatus === RemoteControlStatus.Requesting;
|
||||
const remoteActive = remoteControlStatus === RemoteControlStatus.Enabled;
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -126,14 +129,18 @@ function AssistActions({
|
|||
|
||||
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 }];
|
||||
}
|
||||
|
|
@ -144,10 +151,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) => {
|
||||
|
|
@ -157,7 +178,7 @@ 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);
|
||||
},
|
||||
|
|
@ -177,9 +198,9 @@ function AssistActions({
|
|||
|
||||
if (
|
||||
await confirm({
|
||||
header: 'Start Call',
|
||||
confirmButton: 'Call',
|
||||
confirmation: `Are you sure you want to call ${userId || 'User'}?`,
|
||||
header: t('Start Call'),
|
||||
confirmButton: t('Call'),
|
||||
confirmation: `${t('Are you sure you want to call')} ${userId || t('User')}?`,
|
||||
})
|
||||
) {
|
||||
call(agentIds);
|
||||
|
|
@ -220,12 +241,14 @@ function AssistActions({
|
|||
role="button"
|
||||
>
|
||||
<Button
|
||||
icon={<Icon name={annotating ? 'pencil-stop' : 'pencil'} size={16} />}
|
||||
icon={
|
||||
<Icon name={annotating ? 'pencil-stop' : 'pencil'} size={16} />
|
||||
}
|
||||
type="text"
|
||||
style={{ height: '28px' }}
|
||||
className={annotating ? 'text-red' : 'text-main'}
|
||||
>
|
||||
Annotate
|
||||
{t('Annotate')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className={stl.divider} />
|
||||
|
|
@ -240,18 +263,24 @@ function AssistActions({
|
|||
<Tooltip title="Go live 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"
|
||||
>
|
||||
<Button
|
||||
icon={<Icon name={remoteActive ? 'window-x' : 'remote-control'} size={16} />}
|
||||
icon={
|
||||
<Icon
|
||||
name={remoteActive ? 'window-x' : 'remote-control'}
|
||||
size={16}
|
||||
/>
|
||||
}
|
||||
type="text"
|
||||
className={remoteActive ? 'text-red' : 'text-main'}
|
||||
style={{ height: '28px' }}
|
||||
>
|
||||
Remote Control
|
||||
{t('Remote Control')}
|
||||
</Button>
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
|
@ -260,8 +289,8 @@ function AssistActions({
|
|||
<Tooltip
|
||||
title={
|
||||
cannotCall
|
||||
? 'You don\'t have the permissions to perform this action.'
|
||||
: `Call ${userId || 'User'}`
|
||||
? t("You don't have the permissions to perform this action.")
|
||||
: `${t('Call')} ${userId || t('User')}`
|
||||
}
|
||||
disabled={onCall}
|
||||
>
|
||||
|
|
@ -275,10 +304,12 @@ function AssistActions({
|
|||
<Button
|
||||
icon={<Icon name="headset" size={16} />}
|
||||
type="text"
|
||||
className={onCall ? 'text-red' : isPrestart ? 'text-green' : 'text-main'}
|
||||
className={
|
||||
onCall ? 'text-red' : isPrestart ? 'text-green' : 'text-main'
|
||||
}
|
||||
style={{ height: '28px' }}
|
||||
>
|
||||
{onCall ? 'End' : isPrestart ? 'Join Call' : 'Call'}
|
||||
{onCall ? t('End') : isPrestart ? t('Join Call') : t('Call')}
|
||||
</Button>
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
/* eslint-disable i18next/no-literal-string */
|
||||
import React, { useEffect } from 'react';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { useStore } from 'App/mstore';
|
||||
|
|
@ -5,19 +6,25 @@ 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;
|
||||
list: any;
|
||||
session: any;
|
||||
userId: any;
|
||||
loading: boolean;
|
||||
list: any;
|
||||
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 = {};
|
||||
|
|
@ -31,38 +38,44 @@ function SessionList(props: Props) {
|
|||
<div
|
||||
className="border-r shadow h-screen overflow-y-auto"
|
||||
style={{
|
||||
backgroundColor: '#FAFAFA', zIndex: 999, width: '100%', minWidth: '700px',
|
||||
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>
|
||||
{' '}
|
||||
's
|
||||
<span className="color-gray-medium">{t('Live Sessions')}</span>{' '}
|
||||
</div>
|
||||
</div>
|
||||
<Loader loading={loading}>
|
||||
<NoContent
|
||||
show={!loading && list.length === 0}
|
||||
title={(
|
||||
title={
|
||||
<div className="flex items-center justify-center flex-col">
|
||||
<AnimatedSVG name={ICONS.NO_LIVE_SESSIONS} size={60} />
|
||||
<div className="mt-4" />
|
||||
<div className="text-center text-lg font-medium">No live sessions found.</div>
|
||||
<div className="text-center text-lg font-medium">
|
||||
{t('No live sessions found.')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
}
|
||||
>
|
||||
<div className="p-4">
|
||||
{list.map((session: any) => (
|
||||
<div className="mb-6" key={session.sessionId}>
|
||||
{session.pageTitle && session.pageTitle !== '' && (
|
||||
<div className="flex items-center mb-2">
|
||||
<Label size="small" className="p-1">
|
||||
<span className="color-gray-medium">TAB</span>
|
||||
</Label>
|
||||
<span className="ml-2 font-medium">{session.pageTitle}</span>
|
||||
</div>
|
||||
<div className="flex items-center mb-2">
|
||||
<Label size="small" className="p-1">
|
||||
<span className="color-gray-medium">{t('TAB')}</span>
|
||||
</Label>
|
||||
<span className="ml-2 font-medium">
|
||||
{session.pageTitle}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<SessionItem compact onClick={hideModal} session={session} />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
@ -34,9 +36,9 @@ function VideoContainer({
|
|||
const track = stream.getVideoTracks()[0];
|
||||
const settings = track?.getSettings();
|
||||
const isDummyVideoTrack = settings
|
||||
? settings.width === 2
|
||||
|| settings.frameRate === 0
|
||||
|| (!settings.frameRate && !settings.width)
|
||||
? settings.width === 2 ||
|
||||
settings.frameRate === 0 ||
|
||||
(!settings.frameRate && !settings.width)
|
||||
: true;
|
||||
const shouldBeEnabled = track.enabled && !isDummyVideoTrack;
|
||||
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -21,26 +21,34 @@ import { getPdf2 } from 'Components/AssistStats/pdfGenerator';
|
|||
import UserSearch from './components/UserSearch';
|
||||
import Chart from './components/Charts';
|
||||
import StatsTable from './components/Table';
|
||||
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,
|
||||
});
|
||||
|
|
@ -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,15 +167,19 @@ 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()}`,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
|
|
@ -204,16 +217,32 @@ function AssistStats() {
|
|||
|
||||
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
|
||||
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">
|
||||
<UserSearch onUserSelect={onUserSelect} />
|
||||
|
||||
<SelectDateRange period={period} onChange={onChangePeriod} right 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"
|
||||
|
|
@ -229,7 +258,7 @@ function AssistStats() {
|
|||
<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">
|
||||
<Typography.Title style={{ marginBottom: 0 }} level={5}>
|
||||
|
|
@ -240,14 +269,18 @@ function AssistStats() {
|
|||
{graphs.previousPeriod[i] ? (
|
||||
<div
|
||||
className={
|
||||
graphs.currentPeriod[i] > graphs.previousPeriod[i]
|
||||
? 'flex items-center gap-1 text-green'
|
||||
: 'flex items-center gap-2 text-red'
|
||||
}
|
||||
graphs.currentPeriod[i] > graphs.previousPeriod[i]
|
||||
? 'flex items-center gap-1 text-green'
|
||||
: 'flex items-center gap-2 text-red'
|
||||
}
|
||||
>
|
||||
<ArrowUpOutlined
|
||||
rev={undefined}
|
||||
rotate={graphs.currentPeriod[i] > graphs.previousPeriod[i] ? 0 : 180}
|
||||
rotate={
|
||||
graphs.currentPeriod[i] > graphs.previousPeriod[i]
|
||||
? 0
|
||||
: 180
|
||||
}
|
||||
/>
|
||||
{`${Math.round(
|
||||
calculatePercentageDelta(
|
||||
|
|
@ -259,8 +292,15 @@ function AssistStats() {
|
|||
) : 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>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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' }}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,15 +1,26 @@
|
|||
import { DownOutlined, CloudDownloadOutlined, TableOutlined } 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 { Button, Dropdown, Space, Typography, Tooltip } from 'antd';
|
||||
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;
|
||||
|
|
@ -21,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',
|
||||
|
|
@ -45,12 +56,18 @@ const sortItems = [
|
|||
];
|
||||
|
||||
function StatsTable({
|
||||
onSort, isLoading, onPageChange, page, sessions, exportCSV,
|
||||
onSort,
|
||||
isLoading,
|
||||
onPageChange,
|
||||
page,
|
||||
sessions,
|
||||
exportCSV,
|
||||
}: Props) {
|
||||
const [sortValue, setSort] = React.useState(sortItems[0].label);
|
||||
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);
|
||||
};
|
||||
|
||||
|
|
@ -58,10 +75,10 @@ function StatsTable({
|
|||
<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 }}>
|
||||
<Dropdown menu={{ items: sortItems(t), onClick: updateRange }}>
|
||||
<Button size="small">
|
||||
<Space>
|
||||
<Typography.Text>{sortValue}</Typography.Text>
|
||||
|
|
@ -75,22 +92,26 @@ function StatsTable({
|
|||
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>
|
||||
<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">
|
||||
<Loader loading={isLoading} style={{ height: 300 }}>
|
||||
<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={sessions.list && sessions.list.length === 0}
|
||||
style={{ height: '100px' }}
|
||||
>
|
||||
|
|
@ -103,35 +124,24 @@ function StatsTable({
|
|||
<div className="flex items-center justify-between p-4">
|
||||
{sessions.total > 0 ? (
|
||||
<div>
|
||||
Showing
|
||||
{' '}
|
||||
{t('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('to')}
|
||||
<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')} <span className="font-medium">0</span>
|
||||
{t('to')}
|
||||
<span className="font-medium">0</span> {t('of')}
|
||||
<span className="font-medium">0</span> {t('sessions.')}
|
||||
</div>
|
||||
)}
|
||||
<Pagination
|
||||
|
|
@ -151,11 +161,15 @@ function Row({ session }: { session: AssistStatsSession }) {
|
|||
|
||||
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>
|
||||
<Cell size={2}>
|
||||
{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>
|
||||
|
|
@ -172,28 +186,52 @@ function Row({ session }: { session: AssistStatsSession }) {
|
|||
key: recording.recordId,
|
||||
label: recording.name.slice(0, 20),
|
||||
})),
|
||||
onClick: (item) => recordingsService.fetchRecording(item.key as unknown as number),
|
||||
onClick: (item) =>
|
||||
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)}
|
||||
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>;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,28 +1,28 @@
|
|||
import { DownOutlined, TableOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
Button, Dropdown, Space, Typography, Tooltip,
|
||||
} from 'antd';
|
||||
import { Button, Dropdown, Space, Typography, Tooltip } from 'antd';
|
||||
import { durationFromMsFormatted } from 'App/date';
|
||||
import { Member } from 'App/services/AssistStatsService';
|
||||
import { getInitials, exportCSVFile } from 'App/utils';
|
||||
import { TFunction } from 'i18next';
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Loader, NoContent } from 'UI';
|
||||
|
||||
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',
|
||||
},
|
||||
];
|
||||
|
|
@ -38,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) => ({
|
||||
|
|
@ -62,14 +63,18 @@ 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">
|
||||
<Typography.Title style={{ marginBottom: 0 }} level={5}>
|
||||
Team Members
|
||||
{t('Team Members')}
|
||||
</Typography.Title>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<Dropdown menu={{ items, onClick: updateRange }}>
|
||||
|
|
@ -80,7 +85,13 @@ function TeamMembers({
|
|||
</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"
|
||||
|
|
@ -91,18 +102,31 @@ function TeamMembers({
|
|||
</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>}
|
||||
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">
|
||||
|
|
@ -117,7 +141,7 @@ function TeamMembers({
|
|||
<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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -5,7 +5,9 @@ import { observer } from 'mobx-react-lite';
|
|||
import { useStore } from 'App/mstore';
|
||||
|
||||
function UserSearch({ onUserSelect }: { onUserSelect: (id: any) => void }) {
|
||||
const [selectedValue, setSelectedValue] = useState<string | undefined>(undefined);
|
||||
const [selectedValue, setSelectedValue] = useState<string | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const { userStore } = useStore();
|
||||
const allUsers = userStore.list.map((user) => ({
|
||||
value: user.userId,
|
||||
|
|
@ -28,7 +30,11 @@ function 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()),
|
||||
)
|
||||
: [],
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -38,7 +38,14 @@ 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);
|
||||
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',
|
||||
|
|
|
|||
|
|
@ -1,10 +1,6 @@
|
|||
import React from 'react';
|
||||
import { BarChart } from 'echarts/charts';
|
||||
import {
|
||||
DataProps,
|
||||
buildCategories,
|
||||
customTooltipFormatter,
|
||||
} from './utils';
|
||||
import { DataProps, buildCategories, customTooltipFormatter } from './utils';
|
||||
import { buildBarDatasetsAndSeries } from './barUtils';
|
||||
import { defaultOptions, echarts, initWindowStorages } from './init';
|
||||
|
||||
|
|
@ -17,7 +13,9 @@ 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(() => {
|
||||
|
|
@ -29,9 +27,15 @@ function ORBarChart(props: BarChartProps) {
|
|||
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,7 +45,8 @@ 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];
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -61,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,
|
||||
|
|
@ -79,7 +86,9 @@ function ORBarChart(props: BarChartProps) {
|
|||
});
|
||||
chart.on('click', (event) => {
|
||||
const index = event.dataIndex;
|
||||
const timestamp = (window as any).__timestampMap?.[chartUuid.current]?.[index];
|
||||
const timestamp = (window as any).__timestampMap?.[chartUuid.current]?.[
|
||||
index
|
||||
];
|
||||
props.onClick?.({ activePayload: [{ payload: { timestamp } }] });
|
||||
setTimeout(() => {
|
||||
props.onSeriesFocus?.(event.seriesName);
|
||||
|
|
|
|||
|
|
@ -44,7 +44,11 @@ function ColumnChart(props: ColumnChartProps) {
|
|||
(window as any).__seriesColorMap[chartUuid.current] = {};
|
||||
(window as any).__yAxisData = (window as any).__yAxisData ?? {};
|
||||
|
||||
const { yAxisData, series } = buildColumnChart(chartUuid.current, data, compData);
|
||||
const { yAxisData, series } = buildColumnChart(
|
||||
chartUuid.current,
|
||||
data,
|
||||
compData,
|
||||
);
|
||||
(window as any).__yAxisData[chartUuid.current] = yAxisData;
|
||||
|
||||
chart.setOption({
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
import React from 'react';
|
||||
import { LineChart } from 'echarts/charts';
|
||||
import { echarts, defaultOptions, initWindowStorages } from './init';
|
||||
import { customTooltipFormatter, buildCategories, buildDatasetsAndSeries } from './utils';
|
||||
import {
|
||||
customTooltipFormatter,
|
||||
buildCategories,
|
||||
buildDatasetsAndSeries,
|
||||
} from './utils';
|
||||
import type { DataProps } from './utils';
|
||||
|
||||
echarts.use([LineChart]);
|
||||
|
|
@ -16,7 +20,9 @@ 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(() => {
|
||||
|
|
@ -28,7 +34,12 @@ function ORLineChart(props: Props) {
|
|||
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,7 +60,8 @@ 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];
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -61,7 +74,9 @@ function ORLineChart(props: Props) {
|
|||
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',
|
||||
|
|
@ -91,7 +106,9 @@ function ORLineChart(props: Props) {
|
|||
});
|
||||
chart.on('click', (event) => {
|
||||
const index = event.dataIndex;
|
||||
const timestamp = (window as any).__timestampMap?.[chartUuid.current]?.[index];
|
||||
const timestamp = (window as any).__timestampMap?.[chartUuid.current]?.[
|
||||
index
|
||||
];
|
||||
props.onClick?.({ activePayload: [{ payload: { timestamp } }] });
|
||||
setTimeout(() => {
|
||||
props.onSeriesFocus?.(event.seriesName);
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
||||
|
|
@ -22,15 +26,14 @@ interface PieChartProps {
|
|||
}
|
||||
|
||||
function PieChart(props: PieChartProps) {
|
||||
const {
|
||||
data, label, onClick = () => {}, inGrid = false,
|
||||
} = props;
|
||||
const { data, label, onClick = () => {}, inGrid = false } = props;
|
||||
const chartRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
@ -38,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;
|
||||
}
|
||||
|
||||
|
|
@ -119,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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ 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,22 +37,22 @@ interface Props {
|
|||
}
|
||||
|
||||
const EChartsSankey: React.FC<Props> = (props) => {
|
||||
const {
|
||||
data, height = 240, onChartClick, isUngrouped,
|
||||
} = props;
|
||||
const { t } = useTranslation();
|
||||
const { data, height = 240, onChartClick, isUngrouped } = props;
|
||||
const chartRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
if (data.nodes.length === 0 || data.links.length === 0) {
|
||||
return (
|
||||
<NoContent
|
||||
style={{ minHeight: height }}
|
||||
title={(
|
||||
title={
|
||||
<div className="flex items-center relative">
|
||||
<InfoCircleOutlined className="hidden md:inline-block mr-1" />
|
||||
Set a start or end point to visualize the journey. If set, try
|
||||
adjusting filters.
|
||||
{t(
|
||||
'Set a start or end point to visualize the journey. If set, try adjusting filters.',
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
}
|
||||
show
|
||||
/>
|
||||
);
|
||||
|
|
@ -73,8 +74,8 @@ const EChartsSankey: React.FC<Props> = (props) => {
|
|||
const sourceNode = finalNodes.find((n) => n.id === l.source);
|
||||
const targetNode = finalNodes.find((n) => n.id === l.target);
|
||||
return (
|
||||
(sourceNode?.depth ?? 0) <= maxDepth
|
||||
&& (targetNode?.depth ?? 0) <= maxDepth
|
||||
(sourceNode?.depth ?? 0) <= maxDepth &&
|
||||
(targetNode?.depth ?? 0) <= maxDepth
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -91,11 +92,12 @@ const EChartsSankey: React.FC<Props> = (props) => {
|
|||
} else {
|
||||
nodeValues[i] = 0;
|
||||
}
|
||||
const itemColor = computedName === 'Others'
|
||||
? 'rgba(34,44,154,.9)'
|
||||
: n.eventType === 'DROP'
|
||||
? '#B5B7C8'
|
||||
: '#394eff';
|
||||
const itemColor =
|
||||
computedName === 'Others'
|
||||
? 'rgba(34,44,154,.9)'
|
||||
: n.eventType === 'DROP'
|
||||
? '#B5B7C8'
|
||||
: '#394eff';
|
||||
|
||||
return {
|
||||
name: computedName,
|
||||
|
|
@ -181,17 +183,19 @@ const EChartsSankey: React.FC<Props> = (props) => {
|
|||
: '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;
|
||||
const safeName =
|
||||
params.name.length > maxLen
|
||||
? `${params.name.slice(
|
||||
0,
|
||||
maxLen / 2 - 2,
|
||||
)}...${params.name.slice(-(maxLen / 2 - 2))}`
|
||||
: params.name;
|
||||
const nodeType = params.data.type;
|
||||
|
||||
const icon = getIcon(nodeType);
|
||||
return (
|
||||
`${icon}{header| ${safeName}}\n`
|
||||
+ `{body|}{percentage|${percentage}} {sessions|${nodeVal}}`
|
||||
`${icon}{header| ${safeName}}\n` +
|
||||
`{body|}{percentage|${percentage}} {sessions|${nodeVal}}`
|
||||
);
|
||||
},
|
||||
rich: {
|
||||
|
|
@ -223,42 +227,48 @@ 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,
|
||||
|
|
@ -324,9 +334,10 @@ const EChartsSankey: React.FC<Props> = (props) => {
|
|||
|
||||
const updatedNodes = echartNodes.map((node, idx) => {
|
||||
const baseOpacity = connectedChain.has(idx) ? 1 : 0.35;
|
||||
const extraStyle = idx === hoveredIndex
|
||||
? { borderColor: '#000', borderWidth: 1, borderType: 'dotted' }
|
||||
: {};
|
||||
const extraStyle =
|
||||
idx === hoveredIndex
|
||||
? { borderColor: '#000', borderWidth: 1, borderType: 'dotted' }
|
||||
: {};
|
||||
return {
|
||||
...node,
|
||||
itemStyle: {
|
||||
|
|
@ -401,8 +412,8 @@ const EChartsSankey: React.FC<Props> = (props) => {
|
|||
const firstNodeType = firstNode?.eventType?.toLowerCase() ?? 'location';
|
||||
const lastNodeType = lastNode?.eventType?.toLowerCase() ?? 'location';
|
||||
if (
|
||||
unsupported.includes(firstNodeType)
|
||||
|| unsupported.includes(lastNodeType)
|
||||
unsupported.includes(firstNodeType) ||
|
||||
unsupported.includes(lastNodeType)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -455,15 +466,15 @@ const EChartsSankey: React.FC<Props> = (props) => {
|
|||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
maxHeight: 620, overflow: 'auto', maxWidth: 1240, minHeight: 240,
|
||||
}}
|
||||
<div
|
||||
style={{
|
||||
maxHeight: 620,
|
||||
overflow: 'auto',
|
||||
maxWidth: 1240,
|
||||
minHeight: 240,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={chartRef}
|
||||
style={containerStyle}
|
||||
className="min-w-[600px]"
|
||||
/>
|
||||
<div ref={chartRef} style={containerStyle} className="min-w-[600px]" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -113,7 +119,9 @@ export function buildColumnChart(
|
|||
};
|
||||
}
|
||||
|
||||
const series = previousSeries ? [currentSeries, previousSeries] : [currentSeries];
|
||||
const series = previousSeries
|
||||
? [currentSeries, previousSeries]
|
||||
: [currentSeries];
|
||||
|
||||
assignColorsByCategory(series, categories);
|
||||
|
||||
|
|
|
|||
|
|
@ -67,7 +67,12 @@ const defaultOptions = {
|
|||
},
|
||||
};
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -16,9 +16,7 @@ export function buildPieData(
|
|||
}
|
||||
|
||||
export function pieTooltipFormatter(params: any) {
|
||||
const {
|
||||
name, value, marker, percent,
|
||||
} = params;
|
||||
const { name, value, marker, percent } = params;
|
||||
return `
|
||||
<div class="flex flex-col gap-1 bg-white shadow border rounded p-2 z-50">
|
||||
<div style="margin-bottom: 2px;">${marker} <b>${name}</b></div>
|
||||
|
|
|
|||
|
|
@ -25,8 +25,8 @@ export function sankeyTooltip(
|
|||
</div>
|
||||
<div class="flex items-baseline gap-2 text-black">
|
||||
<span>${params.data.value} ( ${params.data.percentage.toFixed(
|
||||
2,
|
||||
)}% )</span>
|
||||
2,
|
||||
)}% )</span>
|
||||
<span class="text-disabled-text">Sessions</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -49,12 +49,13 @@ const shortenString = (str: string) => {
|
|||
const limit = 60;
|
||||
const leftPart = 25;
|
||||
const rightPart = 20;
|
||||
const safeStr = str.length > limit
|
||||
? `${str.slice(0, leftPart)}...${str.slice(
|
||||
str.length - rightPart,
|
||||
str.length,
|
||||
)}`
|
||||
: str;
|
||||
const safeStr =
|
||||
str.length > limit
|
||||
? `${str.slice(0, leftPart)}...${str.slice(
|
||||
str.length - rightPart,
|
||||
str.length,
|
||||
)}`
|
||||
: str;
|
||||
|
||||
return safeStr;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
];
|
||||
|
|
@ -112,8 +111,8 @@ export function customTooltipFormatter(uuid: string) {
|
|||
</div>
|
||||
|
||||
<div style="border-left: 2px solid ${
|
||||
params.color
|
||||
};" class="flex flex-col px-2 ml-2">
|
||||
params.color
|
||||
};" class="flex flex-col px-2 ml-2">
|
||||
<div class="text-neutral-600 text-sm">
|
||||
Total:
|
||||
</div>
|
||||
|
|
@ -124,7 +123,8 @@ export function customTooltipFormatter(uuid: string) {
|
|||
</div>
|
||||
`;
|
||||
if (partnerValue !== undefined) {
|
||||
const partnerColor = (window as any).__seriesColorMap?.[uuid]?.[partnerName] || '#999';
|
||||
const partnerColor =
|
||||
(window as any).__seriesColorMap?.[uuid]?.[partnerName] || '#999';
|
||||
str += `
|
||||
<div style="border-left: 2px dashed ${partnerColor};" class="flex flex-col px-2 ml-2">
|
||||
<div class="text-neutral-600 text-sm">
|
||||
|
|
@ -177,8 +177,8 @@ export function customTooltipFormatter(uuid: string) {
|
|||
</div>
|
||||
|
||||
<div style="border-left: 2px solid ${
|
||||
params.color
|
||||
};" class="flex flex-col px-2 ml-2">
|
||||
params.color
|
||||
};" class="flex flex-col px-2 ml-2">
|
||||
<div class="text-neutral-600 text-sm">
|
||||
${firstTs ? formatTimeOrDate(firstTs) : categoryLabel}
|
||||
</div>
|
||||
|
|
@ -190,7 +190,8 @@ export function customTooltipFormatter(uuid: string) {
|
|||
`;
|
||||
|
||||
if (partnerVal !== undefined) {
|
||||
const partnerColor = (window as any).__seriesColorMap?.[uuid]?.[partnerName] || '#999';
|
||||
const partnerColor =
|
||||
(window as any).__seriesColorMap?.[uuid]?.[partnerName] || '#999';
|
||||
tooltipContent += `
|
||||
<div style="border-left: 2px dashed ${partnerColor};" class="flex flex-col px-2 ml-2">
|
||||
<div class="text-neutral-600 text-sm">
|
||||
|
|
@ -260,7 +261,8 @@ export function createDataset(id: string, data: DataProps['data']) {
|
|||
const source = data.chart.map((item, idx) => {
|
||||
const row: (number | undefined)[] = [idx];
|
||||
data.namesMap.forEach((name) => {
|
||||
const val = typeof item[name] === 'number' ? (item[name] as number) : undefined;
|
||||
const val =
|
||||
typeof item[name] === 'number' ? (item[name] as number) : undefined;
|
||||
row.push(val);
|
||||
});
|
||||
return row;
|
||||
|
|
|
|||
|
|
@ -1,49 +1,56 @@
|
|||
import React from 'react';
|
||||
import { JSONTree } from 'UI';
|
||||
import { checkForRecent } from 'App/date';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface Props {
|
||||
audit: any;
|
||||
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 && (
|
||||
<div className="my-6">
|
||||
<div className="font-medium mb-3">Payload</div>
|
||||
<JSONTree src={audit.payload} collapsed={false} enableClipboard />
|
||||
</div>
|
||||
{audit.payload && (
|
||||
<div className="my-6">
|
||||
<div className="font-medium mb-3">{t('Payload')}</div>
|
||||
<JSONTree src={audit.payload} collapsed={false} enableClipboard />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -6,11 +6,11 @@ import { Loader, Pagination, NoContent } from 'UI';
|
|||
import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG';
|
||||
import AuditDetailModal from '../AuditDetailModal';
|
||||
import AuditListItem from '../AuditListItem';
|
||||
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);
|
||||
|
|
@ -35,26 +35,31 @@ function AuditList(props: Props) {
|
|||
return useObserver(() => (
|
||||
<Loader loading={loading}>
|
||||
<NoContent
|
||||
title={(
|
||||
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,
|
||||
})
|
||||
}
|
||||
/>
|
||||
))}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,16 +2,24 @@ import React from 'react';
|
|||
import { checkForRecent } from 'App/date';
|
||||
|
||||
interface Props {
|
||||
audit: any;
|
||||
onShowDetails: () => void;
|
||||
audit: any;
|
||||
onShowDetails: () => void;
|
||||
}
|
||||
function AuditListItem(props: Props) {
|
||||
const { audit, onShowDetails } = 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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { debounce } from 'App/utils';
|
|||
|
||||
let debounceUpdate: any = () => {};
|
||||
interface Props {
|
||||
onChange: (value: string) => void;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
function AuditSearchField(props: Props) {
|
||||
const { onChange } = props;
|
||||
|
|
@ -19,10 +19,14 @@ function AuditSearchField(props: Props) {
|
|||
|
||||
return (
|
||||
<div className="relative" style={{ width: '220px' }}>
|
||||
<Icon name="search" className="absolute top-0 bottom-0 ml-3 m-auto" size="16" />
|
||||
<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"
|
||||
// className="bg-white p-2 border border-gray-light rounded w-full pl-10"
|
||||
placeholder="Filter by name"
|
||||
onChange={write}
|
||||
icon="search"
|
||||
|
|
|
|||
|
|
@ -9,15 +9,20 @@ 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(() => () => {
|
||||
auditStore.updateKey('searchQuery', '');
|
||||
}, []);
|
||||
useEffect(
|
||||
() => () => {
|
||||
auditStore.updateKey('searchQuery', '');
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const exportToCsv = () => {
|
||||
auditStore.exportToCsv();
|
||||
|
|
@ -30,12 +35,13 @@ function AuditView() {
|
|||
return useObserver(() => (
|
||||
<div className="bg-white rounded-lg shadow-sm border">
|
||||
<div className="flex items-center mb-4 px-5 pt-5">
|
||||
<PageTitle title={(
|
||||
<div className="flex items-center">
|
||||
<span>Audit Trail</span>
|
||||
<span className="color-gray-medium ml-2">{total}</span>
|
||||
</div>
|
||||
)}
|
||||
<PageTitle
|
||||
title={
|
||||
<div className="flex items-center">
|
||||
<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">
|
||||
|
|
@ -48,22 +54,30 @@ function AuditView() {
|
|||
<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) => {
|
||||
auditStore.updateKey('searchQuery', value);
|
||||
auditStore.updateKey('page', 1);
|
||||
}}
|
||||
<AuditSearchField
|
||||
onChange={(value) => {
|
||||
auditStore.updateKey('searchQuery', value);
|
||||
auditStore.updateKey('page', 1);
|
||||
}}
|
||||
/>
|
||||
<div>
|
||||
<Button type="text" icon={<Icon name="grid-3x3" color="teal" />} className="ml-3" onClick={exportToCsv}>
|
||||
<span className="ml-2">Export to CSV</span>
|
||||
<Button
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -7,25 +7,28 @@ 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();
|
||||
|
|
@ -35,36 +38,38 @@ const CustomFieldForm: React.FC<CustomFieldFormProps> = ({ siteId }) => {
|
|||
|
||||
const onSave = (field: any) => {
|
||||
setLoading(true);
|
||||
store.save(siteId, field).then((response) => {
|
||||
if (!response || !response.errors || response.errors.size === 0) {
|
||||
hideModal();
|
||||
toast.success('Metadata added successfully!');
|
||||
} else {
|
||||
toast.error(response.errors[0]);
|
||||
}
|
||||
}).catch(() => {
|
||||
toast.error('An error occurred while saving metadata.');
|
||||
}).finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
store
|
||||
.save(siteId, field)
|
||||
.then((response) => {
|
||||
if (!response || !response.errors || response.errors.size === 0) {
|
||||
hideModal();
|
||||
toast.success(t('Metadata added successfully!'));
|
||||
} else {
|
||||
toast.error(response.errors[0]);
|
||||
}
|
||||
})
|
||||
.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
|
||||
{exists ? t('Update') : 'Add'} {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>
|
||||
|
|
@ -78,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
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button type="text" icon={<Trash />} data-hidden={!exists} onClick={onDelete} />
|
||||
<Button
|
||||
type="text"
|
||||
icon={<Trash />}
|
||||
data-hidden={!exists}
|
||||
onClick={onDelete}
|
||||
/>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,19 +3,19 @@ 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 { List, Space, Typography, Button, Tooltip, Empty } from 'antd';
|
||||
import { PlusIcon, Tags } from 'lucide-react';
|
||||
import { EditOutlined } from '@ant-design/icons';
|
||||
import usePageTitle from '@/hooks/usePageTitle';
|
||||
import CustomFieldForm from './CustomFieldForm';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
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);
|
||||
|
||||
|
|
@ -29,7 +29,8 @@ function CustomFields() {
|
|||
const handleInit = (field?: any) => {
|
||||
store.init(field);
|
||||
showModal(<CustomFieldForm siteId={`${currentSite?.projectId}`} />, {
|
||||
title: field ? 'Edit Metadata' : 'Add Metadata', right: true,
|
||||
title: field ? t('Edit Metadata') : t('Add Metadata'),
|
||||
right: true,
|
||||
});
|
||||
};
|
||||
|
||||
|
|
@ -38,16 +39,24 @@ function 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.
|
||||
<a href="https://docs.openreplay.com/installation/metadata" className="link ml-1" target="_blank" rel="noreferrer">
|
||||
Learn more
|
||||
{t(
|
||||
'Attach key-value pairs to session replays for enhanced filtering, searching, and identifying relevant user sessions.',
|
||||
)}
|
||||
<a
|
||||
href="https://docs.openreplay.com/installation/metadata"
|
||||
className="link ml-1"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{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} />}
|
||||
|
|
@ -56,18 +65,25 @@ function CustomFields() {
|
|||
disabled={remaining === 0}
|
||||
onClick={() => handleInit()}
|
||||
>
|
||||
Add Metadata
|
||||
{t('Add Metadata')}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
{/* {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}
|
||||
|
|
@ -76,13 +92,14 @@ function 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>
|
||||
)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -17,7 +17,10 @@ function ListItem({ field, onEdit, disabled }) {
|
|||
onClick={() => field.index !== 0 && onEdit(field)}
|
||||
>
|
||||
<span>{field.key}</span>
|
||||
<div className="invisible group-hover:visible" data-hidden={field.index === 0}>
|
||||
<div
|
||||
className="invisible group-hover:visible"
|
||||
data-hidden={field.index === 0}
|
||||
>
|
||||
<Button type="text" icon={<Icon name="pencil" size={16} />} />
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
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);
|
||||
|
|
@ -12,6 +13,7 @@ function getDefaults() {
|
|||
}
|
||||
|
||||
function DebugLog() {
|
||||
const { t } = useTranslation();
|
||||
const [showLogs, setShowLogs] = React.useState(getDefaults);
|
||||
|
||||
const onChange = (checked: boolean) => {
|
||||
|
|
@ -20,8 +22,10 @@ function DebugLog() {
|
|||
};
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-lg">Player Debug Logs</h3>
|
||||
<div className="my-1">Show debug information in browser console.</div>
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -30,6 +31,7 @@ function DatadogFormModal({
|
|||
onClose: () => void;
|
||||
integrated: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { integrationsStore } = useStore();
|
||||
const { siteId } = integrationsStore.integrations;
|
||||
|
||||
|
|
@ -39,19 +41,20 @@ function DatadogFormModal({
|
|||
saveMutation,
|
||||
removeMutation,
|
||||
} = useIntegration<DatadogConfig>('datadog', siteId, initialValues);
|
||||
const {
|
||||
values, errors, handleChange, hasErrors, checkErrors,
|
||||
} = useForm(data, {
|
||||
site: {
|
||||
required: true,
|
||||
const { values, errors, handleChange, hasErrors, checkErrors } = useForm(
|
||||
data,
|
||||
{
|
||||
site: {
|
||||
required: true,
|
||||
},
|
||||
api_key: {
|
||||
required: true,
|
||||
},
|
||||
app_key: {
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
api_key: {
|
||||
required: true,
|
||||
},
|
||||
app_key: {
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
);
|
||||
const exists = Boolean(data.api_key);
|
||||
|
||||
const save = async () => {
|
||||
|
|
@ -85,20 +88,20 @@ function 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}
|
||||
|
|
@ -106,14 +109,14 @@ function 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}
|
||||
|
|
@ -126,12 +129,12 @@ function DatadogFormModal({
|
|||
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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -31,6 +32,7 @@ function DynatraceFormModal({
|
|||
onClose: () => void;
|
||||
integrated: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { integrationsStore } = useStore();
|
||||
const { siteId } = integrationsStore.integrations;
|
||||
const {
|
||||
|
|
@ -39,22 +41,23 @@ function DynatraceFormModal({
|
|||
saveMutation,
|
||||
removeMutation,
|
||||
} = useIntegration<DynatraceConfig>('dynatrace', siteId, initialValues);
|
||||
const {
|
||||
values, errors, handleChange, hasErrors, checkErrors,
|
||||
} = useForm(data, {
|
||||
environment: {
|
||||
required: true,
|
||||
const { values, errors, handleChange, hasErrors, checkErrors } = useForm(
|
||||
data,
|
||||
{
|
||||
environment: {
|
||||
required: true,
|
||||
},
|
||||
client_id: {
|
||||
required: true,
|
||||
},
|
||||
client_secret: {
|
||||
required: true,
|
||||
},
|
||||
resource: {
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
client_id: {
|
||||
required: true,
|
||||
},
|
||||
client_secret: {
|
||||
required: true,
|
||||
},
|
||||
resource: {
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
);
|
||||
const exists = Boolean(data.client_id);
|
||||
|
||||
const save = async () => {
|
||||
|
|
@ -83,33 +86,40 @@ function 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}
|
||||
|
|
@ -117,21 +127,21 @@ function 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}
|
||||
|
|
@ -145,12 +155,12 @@ function DynatraceFormModal({
|
|||
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>
|
||||
|
|
|
|||
|
|
@ -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,6 +33,7 @@ function ElasticsearchForm({
|
|||
onClose: () => void;
|
||||
integrated: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { integrationsStore } = useStore();
|
||||
const { siteId } = integrationsStore.integrations;
|
||||
const {
|
||||
|
|
@ -40,19 +42,20 @@ function ElasticsearchForm({
|
|||
saveMutation,
|
||||
removeMutation,
|
||||
} = useIntegration<ElasticConfig>('elasticsearch', siteId, initialValues);
|
||||
const {
|
||||
values, errors, handleChange, hasErrors, checkErrors,
|
||||
} = useForm(data, {
|
||||
url: {
|
||||
required: true,
|
||||
const { values, errors, handleChange, hasErrors, checkErrors } = useForm(
|
||||
data,
|
||||
{
|
||||
url: {
|
||||
required: true,
|
||||
},
|
||||
api_key_id: {
|
||||
required: true,
|
||||
},
|
||||
api_key: {
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
api_key_id: {
|
||||
required: true,
|
||||
},
|
||||
api_key: {
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
);
|
||||
const exists = Boolean(data.api_key_id);
|
||||
|
||||
const save = async () => {
|
||||
|
|
@ -83,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}
|
||||
|
|
@ -108,21 +113,21 @@ 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}
|
||||
|
|
@ -135,12 +140,12 @@ function ElasticsearchForm({
|
|||
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>
|
||||
|
|
|
|||
|
|
@ -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,6 +33,7 @@ function SentryForm({
|
|||
onClose: () => void;
|
||||
integrated: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { integrationsStore } = useStore();
|
||||
const { siteId } = integrationsStore.integrations;
|
||||
const {
|
||||
|
|
@ -40,22 +42,23 @@ function SentryForm({
|
|||
saveMutation,
|
||||
removeMutation,
|
||||
} = useIntegration<SentryConfig>('sentry', siteId, initialValues);
|
||||
const {
|
||||
values, errors, handleChange, hasErrors, checkErrors,
|
||||
} = useForm(data, {
|
||||
url: {
|
||||
required: false,
|
||||
const { values, errors, handleChange, hasErrors, checkErrors } = useForm(
|
||||
data,
|
||||
{
|
||||
url: {
|
||||
required: false,
|
||||
},
|
||||
organization_slug: {
|
||||
required: true,
|
||||
},
|
||||
project_slug: {
|
||||
required: true,
|
||||
},
|
||||
token: {
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
organization_slug: {
|
||||
required: true,
|
||||
},
|
||||
project_slug: {
|
||||
required: true,
|
||||
},
|
||||
token: {
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
);
|
||||
const exists = Boolean(data.token);
|
||||
|
||||
const save = async () => {
|
||||
|
|
@ -89,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}
|
||||
|
|
@ -118,14 +121,14 @@ 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}
|
||||
|
|
@ -139,12 +142,12 @@ function SentryForm({
|
|||
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>
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ export function FormField({
|
|||
errors,
|
||||
}: {
|
||||
label: string;
|
||||
name: string
|
||||
name: string;
|
||||
value: string;
|
||||
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
autoFocus?: boolean;
|
||||
|
|
|
|||
|
|
@ -2,19 +2,34 @@ 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' }}>
|
||||
<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."
|
||||
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>Integrate GitHub with OpenReplay and create issues directly from the recording page.</div>
|
||||
<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" />
|
||||
<DocLink
|
||||
className="mt-4"
|
||||
label="Integrate Github"
|
||||
url="https://docs.openreplay.com/integrations/github"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<IntegrationForm
|
||||
|
|
@ -3,13 +3,13 @@ import React from 'react';
|
|||
|
||||
import { useStore } from 'App/mstore';
|
||||
import { namedStore } from 'App/mstore/integrationsStore';
|
||||
import {
|
||||
Checkbox, Form, Input, Loader,
|
||||
} from 'UI';
|
||||
import { Checkbox, Form, Input, Loader } from 'UI';
|
||||
import { Button } from 'antd';
|
||||
import { 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;
|
||||
|
|
@ -25,30 +25,28 @@ function IntegrationForm(props: any) {
|
|||
void fetchIntegrationList(initialSiteId);
|
||||
};
|
||||
|
||||
const write = ({
|
||||
target: {
|
||||
value, name: key, type, checked,
|
||||
},
|
||||
}) => {
|
||||
const write = ({ target: { value, name: key, type, checked } }) => {
|
||||
if (type === 'checkbox') edit({ [key]: checked });
|
||||
else edit({ [key]: value });
|
||||
};
|
||||
|
||||
const save = () => {
|
||||
const { name, customPath } = props;
|
||||
onSave(customPath || name).then(() => {
|
||||
fetchList();
|
||||
props.onClose();
|
||||
}).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');
|
||||
onSave(customPath || name)
|
||||
.then(() => {
|
||||
fetchList();
|
||||
props.onClose();
|
||||
})
|
||||
.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(t('Failed to save integration'));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const remove = () => {
|
||||
|
|
@ -71,9 +69,10 @@ function IntegrationForm(props: any) {
|
|||
type = 'text',
|
||||
checkIfDisplayed,
|
||||
autoFocus = false,
|
||||
}) => (typeof checkIfDisplayed !== 'function'
|
||||
|| checkIfDisplayed(config))
|
||||
&& (type === 'checkbox' ? (
|
||||
}) =>
|
||||
(typeof checkIfDisplayed !== 'function' ||
|
||||
checkIfDisplayed(config)) &&
|
||||
(type === 'checkbox' ? (
|
||||
<Form.Field key={key}>
|
||||
<Checkbox
|
||||
label={label}
|
||||
|
|
@ -106,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>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
import React from 'react';
|
||||
import cn from 'classnames';
|
||||
import { Icon } from 'UI';
|
||||
import { Tooltip } from 'antd';
|
||||
import stl from './integrationItem.module.css';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface Props {
|
||||
integration: any;
|
||||
|
|
@ -13,30 +12,46 @@ interface Props {
|
|||
}
|
||||
|
||||
function IntegrationItem(props: Props) {
|
||||
const {
|
||||
integration, integrated, hide = false, useIcon,
|
||||
} = 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="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>
|
||||
<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>
|
||||
<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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -9,13 +9,19 @@ interface Props {
|
|||
}
|
||||
|
||||
function IntegrationModalCard(props: Props) {
|
||||
const {
|
||||
title, icon, description, useIcon,
|
||||
} = 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" />}
|
||||
{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>
|
||||
|
|
|
|||
|
|
@ -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,6 +37,7 @@ interface Props {
|
|||
}
|
||||
|
||||
function Integrations(props: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { integrationsStore, projectsStore } = useStore();
|
||||
const initialSiteId = projectsStore.siteId;
|
||||
const { siteId } = integrationsStore.integrations;
|
||||
|
|
@ -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]);
|
||||
|
||||
|
|
@ -61,9 +65,9 @@ function Integrations(props: Props) {
|
|||
|
||||
const onClick = (integration: any, width: number) => {
|
||||
if (
|
||||
integration.slug
|
||||
&& integration.slug !== 'slack'
|
||||
&& integration.slug !== 'msteams'
|
||||
integration.slug &&
|
||||
integration.slug !== 'slack' &&
|
||||
integration.slug !== 'msteams'
|
||||
) {
|
||||
const intName = integration.slug as
|
||||
| 'sentry'
|
||||
|
|
@ -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,
|
||||
|
|
@ -121,7 +125,7 @@ function Integrations(props: Props) {
|
|||
<>
|
||||
<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>} />}
|
||||
{!hideHeader && <PageTitle title={<div>{t('Integrations')}</div>} />}
|
||||
<SiteDropdown value={siteId} onChange={onChangeSelect} />
|
||||
</div>
|
||||
<IntegrationFilters
|
||||
|
|
@ -133,26 +137,28 @@ 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
|
||||
integrated={integratedList.includes(integration.slug)}
|
||||
integration={integration}
|
||||
useIcon={integration.useIcon}
|
||||
onClick={() => onClick(
|
||||
integration,
|
||||
filteredIntegrations.find((cat) => cat.integrations.includes(integration))?.title === 'Plugins'
|
||||
? 500
|
||||
: 350,
|
||||
)}
|
||||
onClick={() =>
|
||||
onClick(
|
||||
integration,
|
||||
filteredIntegrations.find((cat) =>
|
||||
cat.integrations.includes(integration),
|
||||
)?.title === 'Plugins'
|
||||
? 500
|
||||
: 350,
|
||||
)
|
||||
}
|
||||
hide={
|
||||
(integration.slug === 'github'
|
||||
&& integratedList.includes('jira'))
|
||||
|| (integration.slug === 'jira'
|
||||
&& integratedList.includes('github'))
|
||||
(integration.slug === 'github' &&
|
||||
integratedList.includes('jira')) ||
|
||||
(integration.slug === 'jira' &&
|
||||
integratedList.includes('github'))
|
||||
}
|
||||
/>
|
||||
</React.Fragment>
|
||||
|
|
@ -166,28 +172,31 @@ export default withPageTitle('Integrations - OpenReplay Preferences')(
|
|||
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',
|
||||
|
|
@ -196,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,
|
||||
|
|
@ -250,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',
|
||||
|
|
@ -268,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',
|
||||
|
|
@ -288,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 application’s store, monitor queries, track
|
||||
performance issues and even assist your end user through live sessions.
|
||||
{t(
|
||||
'Plugins capture your application’s 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 />,
|
||||
|
|
|
|||
|
|
@ -3,27 +3,34 @@ 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' }}>
|
||||
<div
|
||||
className="bg-white h-screen overflow-y-auto"
|
||||
style={{ width: '350px' }}
|
||||
>
|
||||
<IntegrationModalCard
|
||||
title="Jira"
|
||||
title={t('Jira')}
|
||||
icon="integrations/jira"
|
||||
description="Integrate Jira with OpenReplay to enable the creation of a new ticket directly from a session."
|
||||
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">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 API token</li>
|
||||
<li>Enter the token below</li>
|
||||
<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="Integrate Jira Cloud"
|
||||
label={t('Integrate Jira Cloud')}
|
||||
url="https://docs.openreplay.com/integrations/jira"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -38,16 +45,16 @@ function JiraForm(props) {
|
|||
formFields={[
|
||||
{
|
||||
key: 'username',
|
||||
label: 'Username',
|
||||
label: t('Username'),
|
||||
autoFocus: true,
|
||||
},
|
||||
{
|
||||
key: 'token',
|
||||
label: 'API Token',
|
||||
label: t('API Token'),
|
||||
},
|
||||
{
|
||||
key: 'url',
|
||||
label: 'JIRA URL',
|
||||
label: t('JIRA URL'),
|
||||
placeholder: 'E.x. https://myjira.atlassian.net',
|
||||
},
|
||||
]}
|
||||
|
|
@ -5,12 +5,16 @@ import { CodeBlock } from 'UI';
|
|||
|
||||
import DocLink from 'Shared/DocLink/DocLink';
|
||||
import ToggleContent from 'Shared/ToggleContent';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
function ProfilerDoc() {
|
||||
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 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,37 +52,39 @@ 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"
|
||||
/>
|
||||
|
||||
<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)?"
|
||||
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>
|
||||
|
|
@ -1,12 +1,12 @@
|
|||
import React from 'react';
|
||||
import {
|
||||
Form, Input, Message, confirm,
|
||||
} from 'UI';
|
||||
import { Form, Input, Message, confirm } from 'UI';
|
||||
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;
|
||||
|
|
@ -31,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);
|
||||
|
|
@ -47,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>
|
||||
|
|
@ -75,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>
|
||||
|
|
@ -3,8 +3,10 @@ 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;
|
||||
|
|
@ -17,14 +19,18 @@ function SlackChannelList(props) {
|
|||
return (
|
||||
<div className="mt-6">
|
||||
<NoContent
|
||||
title={(
|
||||
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.
|
||||
{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="Integrate Slack" url="https://docs.openreplay.com/integrations/slack" />
|
||||
<DocLink
|
||||
className="mt-4 text-base"
|
||||
label={t('Integrate Slack')}
|
||||
url="https://docs.openreplay.com/integrations/slack"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
}
|
||||
size="small"
|
||||
show={list.length === 0}
|
||||
>
|
||||
|
|
@ -36,7 +42,9 @@ function SlackChannelList(props) {
|
|||
>
|
||||
<div className="flex-grow-0" style={{ maxWidth: '90%' }}>
|
||||
<div>{c.name}</div>
|
||||
<div className="truncate test-xs color-gray-medium">{c.endpoint}</div>
|
||||
<div className="truncate test-xs color-gray-medium">
|
||||
{c.endpoint}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
|
@ -5,8 +5,10 @@ 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';
|
||||
|
||||
function SlackForm() {
|
||||
const { t } = useTranslation();
|
||||
const { integrationsStore } = useStore();
|
||||
const { init } = integrationsStore.slack;
|
||||
const fetchList = integrationsStore.slack.fetchIntegrations;
|
||||
|
|
@ -26,7 +28,10 @@ function SlackForm() {
|
|||
}, []);
|
||||
|
||||
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,8 +39,13 @@ function 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>
|
||||
|
|
|
|||
|
|
@ -2,16 +2,16 @@ import { observer } from 'mobx-react-lite';
|
|||
import React from 'react';
|
||||
|
||||
import { useStore } from 'App/mstore';
|
||||
import {
|
||||
confirm, Form, Input, Message,
|
||||
} from 'UI';
|
||||
import { confirm, Form, Input, Message } from 'UI';
|
||||
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;
|
||||
const saving = integrationsStore.msteams.loading;
|
||||
|
|
@ -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);
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -5,8 +5,10 @@ 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;
|
||||
const { edit } = integrationsStore.msteams;
|
||||
|
|
@ -19,19 +21,18 @@ function TeamsChannelList(props: { onEdit: () => void }) {
|
|||
return (
|
||||
<div className="mt-6">
|
||||
<NoContent
|
||||
title={(
|
||||
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>
|
||||
)}
|
||||
}
|
||||
size="small"
|
||||
show={list.length === 0}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -6,8 +6,10 @@ import { Button } from 'antd';
|
|||
|
||||
import TeamsChannelList from './TeamsChannelList';
|
||||
import TeamsAddForm from './TeamsAddForm';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
function MSTeams() {
|
||||
const { t } = useTranslation();
|
||||
const { integrationsStore } = useStore();
|
||||
const fetchList = integrationsStore.msteams.fetchIntegrations;
|
||||
const { init } = integrationsStore.msteams;
|
||||
|
|
@ -27,7 +29,10 @@ function MSTeams() {
|
|||
}, []);
|
||||
|
||||
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,8 +40,13 @@ function 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>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ 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';
|
||||
|
|
@ -14,10 +15,13 @@ const TABS = [
|
|||
];
|
||||
|
||||
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 projectKey = siteId
|
||||
? sites.find((site) => site.id === siteId)?.projectKey
|
||||
: sites[0]?.projectKey;
|
||||
const [activeTab, setActiveTab] = useState(SCRIPT);
|
||||
|
||||
const renderActiveTab = () => {
|
||||
|
|
@ -31,24 +35,36 @@ function AssistDoc() {
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white h-screen overflow-y-auto" style={{ width: '500px' }}>
|
||||
<h3 className="p-5 text-2xl">Assist</h3>
|
||||
<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>
|
||||
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.
|
||||
{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">Installation</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">Usage</div>
|
||||
<Tabs tabs={TABS} active={activeTab} onClick={(tab) => setActiveTab(tab)} />
|
||||
<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="Install Assist" url="https://docs.openreplay.com/installation/assist" />
|
||||
<DocLink
|
||||
className="mt-4"
|
||||
label={t('Install Assist')}
|
||||
url="https://docs.openreplay.com/installation/assist"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -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({
|
||||
|
|
@ -60,17 +62,19 @@ type ButtonOptions = HTMLButtonElement | string | {
|
|||
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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import React from 'react';
|
||||
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){
|
||||
|
|
@ -21,7 +23,11 @@ function AssistScript(props) {
|
|||
</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" />
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
function GraphQLDoc() {
|
||||
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">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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -4,12 +4,16 @@ 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 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';
|
||||
|
|
@ -36,32 +40,44 @@ function SomeFunctionalComponent() {
|
|||
}`;
|
||||
|
||||
return (
|
||||
<div className="bg-white h-screen overflow-y-auto" style={{ width: '500px' }}>
|
||||
<h3 className="p-5 text-2xl">MobX</h3>
|
||||
<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>
|
||||
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.
|
||||
{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">Installation</div>
|
||||
<CodeBlock language="bash" code="npm i @openreplay/tracker-mobx --save" />
|
||||
<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">Usage</div>
|
||||
<div className="font-bold my-2">{t('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.
|
||||
{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">Usage</div>
|
||||
<div className="font-bold my-2">{t('Usage')}</div>
|
||||
<ToggleContent
|
||||
label="Server-Side-Rendered (SSR)?"
|
||||
label={t('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" />
|
||||
<DocLink
|
||||
className="mt-4"
|
||||
label={t('Integrate MobX')}
|
||||
url="https://docs.openreplay.com/plugins/mobx"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -4,12 +4,16 @@ import { CodeBlock } from 'UI';
|
|||
import ToggleContent from 'Shared/ToggleContent';
|
||||
import DocLink from 'Shared/DocLink/DocLink';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
function NgRxDoc() {
|
||||
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 projectKey = siteId
|
||||
? sites.find((site) => site.id === siteId)?.projectKey
|
||||
: sites[0]?.projectKey;
|
||||
const usage = `import { StoreModule } from '@ngrx/store';
|
||||
import { reducers } from './reducers';
|
||||
import OpenReplay from '@openreplay/tracker';
|
||||
|
|
@ -48,33 +52,44 @@ const metaReducers = [tracker.use(trackerNgRx(<options>))]; // check list of ava
|
|||
export class AppModule {}
|
||||
}`;
|
||||
return (
|
||||
<div className="bg-white h-screen overflow-y-auto" style={{ width: '500px' }}>
|
||||
<h3 className="p-5 text-2xl">NgRx</h3>
|
||||
<div
|
||||
className="bg-white h-screen overflow-y-auto"
|
||||
style={{ width: '500px' }}
|
||||
>
|
||||
<h3 className="p-5 text-2xl">{t('NgRx')}</h3>
|
||||
<div className="p-5">
|
||||
<div>
|
||||
This plugin allows you to capture NgRx actions/state and inspect them later on while replaying session recordings. This is very
|
||||
useful for understanding and fixing issues.
|
||||
{t(
|
||||
'This plugin allows you to capture NgRx actions/state and inspect them later on while replaying session recordings. This is very useful for understanding and fixing issues.',
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="font-bold my-2">Installation</div>
|
||||
<CodeBlock code="npm i @openreplay/tracker-ngrx --save" language="bash" />
|
||||
|
||||
<div className="font-bold my-2">Usage</div>
|
||||
<p>Add the generated meta-reducer into your imports. See NgRx documentation for more details.</p>
|
||||
<div className="py-3" />
|
||||
|
||||
<div className="font-bold my-2">Usage</div>
|
||||
<ToggleContent
|
||||
label="Server-Side-Rendered (SSR)?"
|
||||
first={
|
||||
<CodeBlock language="js" code={usage} />
|
||||
}
|
||||
second={
|
||||
<CodeBlock language="jsx" code={usageCjs} />
|
||||
}
|
||||
<div className="font-bold my-2">{t('Installation')}</div>
|
||||
<CodeBlock
|
||||
code="npm i @openreplay/tracker-ngrx --save"
|
||||
language="bash"
|
||||
/>
|
||||
|
||||
<DocLink className="mt-4" label="Integrate NgRx" url="https://docs.openreplay.com/plugins/ngrx" />
|
||||
<div className="font-bold my-2">{t('Usage')}</div>
|
||||
<p>
|
||||
{t(
|
||||
'Add the generated meta-reducer into your imports. See NgRx documentation for more details.',
|
||||
)}
|
||||
</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={usage} />}
|
||||
second={<CodeBlock language="jsx" code={usageCjs} />}
|
||||
/>
|
||||
|
||||
<DocLink
|
||||
className="mt-4"
|
||||
label={t('Integrate NgRx')}
|
||||
url="https://docs.openreplay.com/plugins/ngrx"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -6,8 +6,10 @@ import ToggleContent from 'Components/shared/ToggleContent';
|
|||
import { CodeBlock } from 'UI';
|
||||
|
||||
import DocLink from 'Shared/DocLink/DocLink';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
function PiniaDoc() {
|
||||
const { t } = useTranslation();
|
||||
const { integrationsStore, projectsStore } = useStore();
|
||||
const sites = projectsStore.list;
|
||||
const { siteId } = integrationsStore.integrations;
|
||||
|
|
@ -67,37 +69,37 @@ piniaStorePlugin(examplePiniaStore)
|
|||
className="bg-white h-screen overflow-y-auto"
|
||||
style={{ width: '500px' }}
|
||||
>
|
||||
<h3 className="p-5 text-2xl">VueX</h3>
|
||||
<h3 className="p-5 text-2xl">{t('Pinia')}</h3>
|
||||
<div className="p-5">
|
||||
<div>
|
||||
This plugin allows you to capture Pinia mutations + state and inspect
|
||||
them later on while replaying session recordings. This is very useful
|
||||
for understanding and fixing issues.
|
||||
{t(
|
||||
'This plugin allows you to capture Pinia mutations + state and inspect them later on while replaying session recordings. This is very useful for understanding and fixing issues.',
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="font-bold my-2 text-lg">Installation</div>
|
||||
<div className="font-bold my-2 text-lg">{t('Installation')}</div>
|
||||
<CodeBlock
|
||||
code="npm i @openreplay/tracker-vuex --save"
|
||||
language="bash"
|
||||
/>
|
||||
|
||||
<div className="font-bold my-2 text-lg">Usage</div>
|
||||
<div className="font-bold my-2 text-lg">{t('Usage')}</div>
|
||||
<p>
|
||||
Initialize the @openreplay/tracker package as usual and load the
|
||||
plugin into it. Then put the generated plugin into your plugins field
|
||||
of your store.
|
||||
{t(
|
||||
'Initialize the @openreplay/tracker package as usual and load the plugin into it. Then put the generated plugin into your plugins field of your store.',
|
||||
)}
|
||||
</p>
|
||||
<div className="py-3" />
|
||||
|
||||
<ToggleContent
|
||||
label="Server-Side-Rendered (SSR)?"
|
||||
label={t('Server-Side-Rendered (SSR)?')}
|
||||
first={<CodeBlock code={usage} language="js" />}
|
||||
second={<CodeBlock code={usageCjs} language="js" />}
|
||||
/>
|
||||
|
||||
<DocLink
|
||||
className="mt-4"
|
||||
label="Integrate Pinia"
|
||||
label={t('Integrate Pinia')}
|
||||
url="https://docs.openreplay.com/plugins/pinia"
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -4,12 +4,16 @@ import { CodeBlock } from 'UI';
|
|||
import ToggleContent from 'Components/shared/ToggleContent';
|
||||
import DocLink from 'Shared/DocLink/DocLink';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
function ReduxDoc() {
|
||||
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 projectKey = siteId
|
||||
? sites.find((site) => site.id === siteId)?.projectKey
|
||||
: sites[0]?.projectKey;
|
||||
|
||||
const usage = `import { applyMiddleware, createStore } from 'redux';
|
||||
import OpenReplay from '@openreplay/tracker';
|
||||
|
|
@ -43,32 +47,43 @@ const store = createStore(
|
|||
);
|
||||
}`;
|
||||
return (
|
||||
<div className="bg-white h-screen overflow-y-auto" style={{ width: '500px' }}>
|
||||
<h3 className="p-5 text-2xl">Redux</h3>
|
||||
<div
|
||||
className="bg-white h-screen overflow-y-auto"
|
||||
style={{ width: '500px' }}
|
||||
>
|
||||
<h3 className="p-5 text-2xl">{t('Redux')}</h3>
|
||||
|
||||
<div className="p-5">
|
||||
<div>
|
||||
This plugin allows you to capture Redux actions/state and inspect them later on while replaying session recordings. This is very
|
||||
useful for understanding and fixing issues.
|
||||
{t(
|
||||
'This plugin allows you to capture Redux actions/state and inspect them later on while replaying session recordings. This is very useful for understanding and fixing issues.',
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="font-bold my-2 text-lg">Installation</div>
|
||||
<CodeBlock code="npm i @openreplay/tracker-redux --save" language="bash" />
|
||||
|
||||
<div className="font-bold my-2 text-lg">Usage</div>
|
||||
<p>Initialize the tracker then put the generated middleware into your Redux chain.</p>
|
||||
<div className="py-3" />
|
||||
<ToggleContent
|
||||
label="Server-Side-Rendered (SSR)?"
|
||||
first={
|
||||
<CodeBlock language="js" code={usage} />
|
||||
}
|
||||
second={
|
||||
<CodeBlock language="jsx" code={usageCjs} />
|
||||
}
|
||||
<div className="font-bold my-2 text-lg">{t('Installation')}</div>
|
||||
<CodeBlock
|
||||
code="npm i @openreplay/tracker-redux --save"
|
||||
language="bash"
|
||||
/>
|
||||
|
||||
<DocLink className="mt-4" label="Integrate Redux" url="https://docs.openreplay.com/plugins/redux" />
|
||||
<div className="font-bold my-2 text-lg">{t('Usage')}</div>
|
||||
<p>
|
||||
{t(
|
||||
'Initialize the tracker then put the generated middleware into your Redux chain.',
|
||||
)}
|
||||
</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={t('Integrate Redux')}
|
||||
url="https://docs.openreplay.com/plugins/redux"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -1 +1 @@
|
|||
export { default } from './ReduxDoc';
|
||||
export { default } from './ReduxDoc.tsx';
|
||||
|
|
|
|||
|
|
@ -4,12 +4,16 @@ import { CodeBlock } from 'UI';
|
|||
import ToggleContent from 'Components/shared/ToggleContent';
|
||||
import DocLink from 'Shared/DocLink/DocLink';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
function VueDoc() {
|
||||
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 projectKey = siteId
|
||||
? sites.find((site) => site.id === siteId)?.projectKey
|
||||
: sites[0]?.projectKey;
|
||||
|
||||
const usage = `import Vuex from 'vuex'
|
||||
import OpenReplay from '@openreplay/tracker';
|
||||
|
|
@ -43,37 +47,41 @@ const store = new Vuex.Store({
|
|||
});
|
||||
}`;
|
||||
return (
|
||||
<div className="bg-white h-screen overflow-y-auto" style={{ width: '500px' }}>
|
||||
<h3 className="p-5 text-2xl">VueX</h3>
|
||||
<div
|
||||
className="bg-white h-screen overflow-y-auto"
|
||||
style={{ width: '500px' }}
|
||||
>
|
||||
<h3 className="p-5 text-2xl">{t('VueX')}</h3>
|
||||
<div className="p-5">
|
||||
<div>
|
||||
This plugin allows you to capture VueX mutations/state and inspect them later on while
|
||||
replaying session recordings. This is very useful for understanding and fixing issues.
|
||||
{t(
|
||||
'This plugin allows you to capture VueX mutations/state and inspect them later on while replaying session recordings. This is very useful for understanding and fixing issues.',
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="font-bold my-2 text-lg">Installation</div>
|
||||
<CodeBlock code="npm i @openreplay/tracker-vuex --save" language="bash" />
|
||||
<div className="font-bold my-2 text-lg">{t('Installation')}</div>
|
||||
<CodeBlock
|
||||
code="npm i @openreplay/tracker-vuex --save"
|
||||
language="bash"
|
||||
/>
|
||||
|
||||
<div className="font-bold my-2 text-lg">Usage</div>
|
||||
<div className="font-bold my-2 text-lg">{t('Usage')}</div>
|
||||
<p>
|
||||
Initialize the @openreplay/tracker package as usual and load the plugin into it. Then put
|
||||
the generated plugin into your plugins field of your store.
|
||||
{t(
|
||||
'Initialize the @openreplay/tracker package as usual and load the plugin into it. Then put the generated plugin into your plugins field of your store.',
|
||||
)}
|
||||
</p>
|
||||
<div className="py-3" />
|
||||
|
||||
<ToggleContent
|
||||
label="Server-Side-Rendered (SSR)?"
|
||||
first={
|
||||
<CodeBlock 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 Vuex"
|
||||
label={t('Integrate Vuex')}
|
||||
url="https://docs.openreplay.com/plugins/vuex"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -4,12 +4,16 @@ import { CodeBlock } from 'UI';
|
|||
import ToggleContent from 'Components//shared/ToggleContent';
|
||||
import DocLink from 'Shared/DocLink/DocLink';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
function ZustandDoc(props) {
|
||||
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 projectKey = siteId
|
||||
? sites.find((site) => site.id === siteId)?.projectKey
|
||||
: sites[0]?.projectKey;
|
||||
|
||||
const usage = `import create from "zustand";
|
||||
import Tracker from '@openreplay/tracker';
|
||||
|
|
@ -65,35 +69,43 @@ const useBearStore = create(
|
|||
)
|
||||
)`;
|
||||
return (
|
||||
<div className="bg-white h-screen overflow-y-auto" style={{ width: '500px' }}>
|
||||
<h3 className="p-5 text-2xl">Zustand</h3>
|
||||
<div
|
||||
className="bg-white h-screen overflow-y-auto"
|
||||
style={{ width: '500px' }}
|
||||
>
|
||||
<h3 className="p-5 text-2xl">{t('Zustand')}</h3>
|
||||
<div className="p-5">
|
||||
<div>
|
||||
This plugin allows you to capture Zustand mutations/state and inspect them later on while replaying session recordings. This is very
|
||||
useful for understanding and fixing issues.
|
||||
{t(
|
||||
'This plugin allows you to capture Zustand mutations/state and inspect them later on while replaying session recordings. This is very useful for understanding and fixing issues.',
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="font-bold my-2 text-lg">Installation</div>
|
||||
<CodeBlock language="bash" code="npm i @openreplay/tracker-zustand --save" />
|
||||
<div className="font-bold my-2 text-lg">{t('Installation')}</div>
|
||||
<CodeBlock
|
||||
language="bash"
|
||||
code="npm i @openreplay/tracker-zustand --save"
|
||||
/>
|
||||
|
||||
<div className="font-bold my-2 text-lg">Usage</div>
|
||||
<div className="font-bold my-2 text-lg">{t('Usage')}</div>
|
||||
<p>
|
||||
Initialize the @openreplay/tracker package as usual and load the plugin into it. Then put the generated plugin into your plugins
|
||||
field of your store.
|
||||
{t(
|
||||
'Initialize the @openreplay/tracker package as usual and load the plugin into it. Then put the generated plugin into your plugins field of your store.',
|
||||
)}
|
||||
</p>
|
||||
<div className="py-3" />
|
||||
|
||||
<ToggleContent
|
||||
label="Server-Side-Rendered (SSR)?"
|
||||
first={
|
||||
<CodeBlock 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 Zustand" url="https://docs.openreplay.com/plugins/zustand" />
|
||||
<DocLink
|
||||
className="mt-4"
|
||||
label={t('Integrate Zustand')}
|
||||
url="https://docs.openreplay.com/plugins/zustand"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -56,7 +56,8 @@ export function useIntegration<T>(
|
|||
}) => saveIntegration(name, values, siteId, exists),
|
||||
});
|
||||
const removeMutation = useMutation({
|
||||
mutationFn: ({ siteId }: { siteId: string }) => removeIntegration(name, siteId),
|
||||
mutationFn: ({ siteId }: { siteId: string }) =>
|
||||
removeIntegration(name, siteId),
|
||||
});
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -6,8 +6,10 @@ import { toast } from 'react-toastify';
|
|||
import { useStore } from 'App/mstore';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { modules as list } from '.';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
function Modules() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { userStore } = useStore();
|
||||
const { updateModule } = userStore;
|
||||
const modules = userStore.account.settings?.modules ?? [];
|
||||
|
|
@ -24,29 +26,45 @@ function Modules() {
|
|||
status: isEnabled,
|
||||
});
|
||||
updateModule(module.key);
|
||||
toast.success(`Module ${module.label} ${!isEnabled ? 'enabled' : 'disabled'}`);
|
||||
toast.success(
|
||||
`${t('Module')} ${module.label} ${!isEnabled ? t('enabled') : t('disabled')}`,
|
||||
);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
toast.error(`Failed to ${module.isEnabled ? 'disable' : 'enable'} module ${module.label}`);
|
||||
toast.error(
|
||||
`${t('Failed to')} ${module.isEnabled ? t('disable') : t('enable')} module ${module.label}`,
|
||||
);
|
||||
module.isEnabled = !module.isEnabled;
|
||||
setModulesState((prevState) => [...prevState]);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
list.forEach((module) => {
|
||||
list(t).forEach((module) => {
|
||||
module.isEnabled = modules.includes(module.key);
|
||||
});
|
||||
setModulesState(list.filter((module) => !module.hidden && (!module.enterprise || isEnterprise)));
|
||||
}, [modules]);
|
||||
setModulesState(
|
||||
list(t).filter(
|
||||
(module) => !module.hidden && (!module.enterprise || isEnterprise),
|
||||
),
|
||||
);
|
||||
}, [modules, i18n.language]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="bg-white rounded-lg border shadow-sm p-4">
|
||||
<h3 className="text-2xl">Modules</h3>
|
||||
<h3 className="text-2xl">{t('Modules')}</h3>
|
||||
<ul className="mt-3 ml-4 list-disc">
|
||||
<li>OpenReplay's modules are a collection of advanced features that provide enhanced functionality.</li>
|
||||
<li>Easily enable any desired module within the user interface to access its capabilities</li>
|
||||
<li>
|
||||
{t(
|
||||
"OpenReplay's modules are a collection of advanced features that provide enhanced functionality.",
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
{t(
|
||||
'Easily enable any desired module within the user interface to access its capabilities',
|
||||
)}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
|
|
@ -61,4 +79,6 @@ function Modules() {
|
|||
);
|
||||
}
|
||||
|
||||
export default withPageTitle('Modules - OpenReplay Preferences')(observer(Modules));
|
||||
export default withPageTitle('Modules - OpenReplay Preferences')(
|
||||
observer(Modules),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
import { TFunction } from 'i18next';
|
||||
|
||||
export { default } from './Modules';
|
||||
|
||||
export const enum MODULES {
|
||||
ASSIST = 'assist',
|
||||
HIGHLIGHTS = 'notes',
|
||||
BUG_REPORTS = 'bug-reports',
|
||||
OFFLINE_RECORDINGS = 'offline-recordings',
|
||||
OFFLINE_RECORDINGS = 'offline-recordings',
|
||||
ALERTS = 'alerts',
|
||||
ASSIST_STATS = 'assist-stats',
|
||||
FEATURE_FLAGS = 'feature-flags',
|
||||
|
|
@ -22,55 +24,69 @@ export interface Module {
|
|||
enterprise?: boolean;
|
||||
}
|
||||
|
||||
export const modules = [
|
||||
export const modules = (t: TFunction) => [
|
||||
{
|
||||
label: 'Co-Browse',
|
||||
description: 'Enable live session replay, remote control, annotations and webRTC call/video.',
|
||||
label: t('Co-Browse'),
|
||||
description: t(
|
||||
'Enable live session replay, remote control, annotations and webRTC call/video.',
|
||||
),
|
||||
key: MODULES.ASSIST,
|
||||
icon: 'broadcast',
|
||||
},
|
||||
{
|
||||
label: 'Recordings',
|
||||
description: 'Record live sessions while co-browsing with users and share it with your team for training purposes.',
|
||||
label: t('Recordings'),
|
||||
description: t(
|
||||
'Record live sessions while co-browsing with users and share it with your team for training purposes.',
|
||||
),
|
||||
key: MODULES.OFFLINE_RECORDINGS,
|
||||
icon: 'record2',
|
||||
},
|
||||
{
|
||||
label: 'Cobrowsing Reports',
|
||||
description: 'Keep an eye on cobrowsing metrics across your team and generate reports.',
|
||||
label: t('Cobrowsing Reports'),
|
||||
description: t(
|
||||
'Keep an eye on cobrowsing metrics across your team and generate reports.',
|
||||
),
|
||||
key: MODULES.ASSIST_STATS,
|
||||
icon: 'file-bar-graph',
|
||||
enterprise: true,
|
||||
},
|
||||
{
|
||||
label: 'Highlights',
|
||||
description: 'Add highlights to sessions and share with your team.',
|
||||
label: t('Highlights'),
|
||||
description: t('Add highlights to sessions and share with your team.'),
|
||||
key: MODULES.HIGHLIGHTS,
|
||||
icon: 'chat-square-quote',
|
||||
isEnabled: true,
|
||||
},
|
||||
{
|
||||
label: 'Alerts',
|
||||
description: 'Create alerts on cards and get notified when a metric hits a certain threshold.',
|
||||
label: t('Alerts'),
|
||||
description: t(
|
||||
'Create alerts on cards and get notified when a metric hits a certain threshold.',
|
||||
),
|
||||
key: MODULES.ALERTS,
|
||||
icon: 'bell',
|
||||
},
|
||||
{
|
||||
label: 'Feature Flags',
|
||||
description: 'Make gradual releases and A/B test all of your new features without redeploying your app.',
|
||||
label: t('Feature Flags'),
|
||||
description: t(
|
||||
'Make gradual releases and A/B test all of your new features without redeploying your app.',
|
||||
),
|
||||
key: MODULES.FEATURE_FLAGS,
|
||||
icon: 'toggles',
|
||||
},
|
||||
{
|
||||
label: 'Recommendations',
|
||||
description: 'Get personalized recommendations for sessions to watch, based on your replay history and search preferences.',
|
||||
label: t('Recommendations'),
|
||||
description: t(
|
||||
'Get personalized recommendations for sessions to watch, based on your replay history and search preferences.',
|
||||
),
|
||||
key: MODULES.RECOMMENDATIONS,
|
||||
icon: 'magic',
|
||||
hidden: true,
|
||||
},
|
||||
{
|
||||
label: 'Usability Tests',
|
||||
description: 'Get feedback from your users by creating usability tests and sharing them with your team.',
|
||||
label: t('Usability Tests'),
|
||||
description: t(
|
||||
'Get feedback from your users by creating usability tests and sharing them with your team.',
|
||||
),
|
||||
key: MODULES.USABILITY_TESTS,
|
||||
icon: 'clipboard-check',
|
||||
},
|
||||
|
|
|
|||
|
|
@ -5,9 +5,11 @@ import { useStore } from 'App/mstore';
|
|||
import { observer } from 'mobx-react-lite';
|
||||
import withPageTitle from 'HOCs/withPageTitle';
|
||||
import stl from './notifications.module.css';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
function Notifications() {
|
||||
const { weeklyReportStore } = useStore();
|
||||
const { t } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
void weeklyReportStore.fetchReport();
|
||||
|
|
@ -20,20 +22,26 @@ function Notifications() {
|
|||
|
||||
return (
|
||||
<div className="bg-white rounded-lg p-5">
|
||||
<div className={stl.tabHeader}><h3 className={cn(stl.tabTitle, 'text-2xl')}>Weekly Report</h3></div>
|
||||
<div className={stl.tabHeader}>
|
||||
<h3 className={cn(stl.tabTitle, 'text-2xl')}>{t('Weekly Report')}</h3>
|
||||
</div>
|
||||
<div className="">
|
||||
<div className="text-lg font-medium">Weekly project summary</div>
|
||||
<div className="mb-4">Receive weekly report for each project on email.</div>
|
||||
<div className="text-lg font-medium">{t('Weekly project summary')}</div>
|
||||
<div className="mb-4">
|
||||
{t('Receive weekly report for each project on email.')}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={weeklyReportStore.weeklyReport}
|
||||
onChange={onChange}
|
||||
/>
|
||||
<span>{weeklyReportStore.weeklyReport ? 'Yes' : 'No'}</span>
|
||||
<span>{weeklyReportStore.weeklyReport ? t('Yes') : t('No')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default withPageTitle('Weekly Report - OpenReplay Preferences')(observer(Notifications));
|
||||
export default withPageTitle('Weekly Report - OpenReplay Preferences')(
|
||||
observer(Notifications),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -2,14 +2,16 @@ import React from 'react';
|
|||
import { observer } from 'mobx-react-lite';
|
||||
import { useStore } from 'App/mstore';
|
||||
import { CopyButton, Form, Input } from 'UI';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
function ApiKeySettings() {
|
||||
const { t } = useTranslation();
|
||||
const { userStore } = useStore();
|
||||
|
||||
const { apiKey } = userStore.account;
|
||||
return (
|
||||
<Form.Field>
|
||||
<label htmlFor="apiKey">Organization API Key</label>
|
||||
<label htmlFor="apiKey">{t('Organization API Key')}</label>
|
||||
<Input
|
||||
name="apiKey"
|
||||
id="apiKey"
|
||||
|
|
@ -6,33 +6,45 @@ import { PASSWORD_POLICY } from 'App/constants';
|
|||
import { useStore } from 'App/mstore';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import styles from './profileSettings.module.css';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const ERROR_DOESNT_MATCH = 'Passwords don\'t match';
|
||||
const ERROR_DOESNT_MATCH = (t) => "Passwords don't match";
|
||||
const MIN_LENGTH = 8;
|
||||
|
||||
function ChangePassword() {
|
||||
const { t } = useTranslation();
|
||||
const { userStore } = useStore();
|
||||
const { updatePassword } = userStore;
|
||||
const passwordErrors = userStore.updatePasswordRequest.errors;
|
||||
const { loading } = userStore.updatePasswordRequest;
|
||||
const [oldPassword, setOldPassword] = useState<string>('');
|
||||
const [newPassword, setNewPassword] = useState<{ value: string; error: boolean }>({
|
||||
const [newPassword, setNewPassword] = useState<{
|
||||
value: string;
|
||||
error: boolean;
|
||||
}>({
|
||||
value: '',
|
||||
error: false,
|
||||
});
|
||||
const [newPasswordRepeat, setNewPasswordRepeat] = useState<{ value: string; error: boolean }>({
|
||||
const [newPasswordRepeat, setNewPasswordRepeat] = useState<{
|
||||
value: string;
|
||||
error: boolean;
|
||||
}>({
|
||||
value: '',
|
||||
error: false,
|
||||
});
|
||||
const [show, setShow] = useState<boolean>(false);
|
||||
|
||||
const checkDoesntMatch = useCallback((newPassword: string, newPasswordRepeat: string) => newPasswordRepeat.length > 0 && newPasswordRepeat !== newPassword, []);
|
||||
const checkDoesntMatch = useCallback(
|
||||
(newPassword: string, newPasswordRepeat: string) =>
|
||||
newPasswordRepeat.length > 0 && newPasswordRepeat !== newPassword,
|
||||
[],
|
||||
);
|
||||
|
||||
const isSubmitDisabled = useCallback(() => {
|
||||
if (
|
||||
newPassword.value !== newPasswordRepeat.value
|
||||
|| newPassword.value.length < MIN_LENGTH
|
||||
|| oldPassword.length === 0
|
||||
newPassword.value !== newPasswordRepeat.value ||
|
||||
newPassword.value.length < MIN_LENGTH ||
|
||||
oldPassword.length === 0
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
|
@ -52,14 +64,14 @@ function ChangePassword() {
|
|||
updatePassword({
|
||||
oldPassword,
|
||||
newPassword: newPassword.value,
|
||||
}).then(() => {
|
||||
setShow(false);
|
||||
setOldPassword('');
|
||||
setNewPassword({ value: '', error: false });
|
||||
setNewPasswordRepeat({ value: '', error: false });
|
||||
}).catch((e) => {
|
||||
|
||||
});
|
||||
})
|
||||
.then(() => {
|
||||
setShow(false);
|
||||
setOldPassword('');
|
||||
setNewPassword({ value: '', error: false });
|
||||
setNewPasswordRepeat({ value: '', error: false });
|
||||
})
|
||||
.catch((e) => {});
|
||||
},
|
||||
[isSubmitDisabled, oldPassword, newPassword, updatePassword],
|
||||
);
|
||||
|
|
@ -73,7 +85,9 @@ function ChangePassword() {
|
|||
name="oldPassword"
|
||||
value={oldPassword}
|
||||
type="password"
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setOldPassword(e.target.value)}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setOldPassword(e.target.value)
|
||||
}
|
||||
/>
|
||||
</Form.Field>
|
||||
<Form.Field>
|
||||
|
|
@ -92,14 +106,17 @@ function ChangePassword() {
|
|||
/>
|
||||
</Form.Field>
|
||||
<Form.Field>
|
||||
<label htmlFor="newPasswordRepeat">{'Repeat New Password: '}</label>
|
||||
<label htmlFor="newPasswordRepeat">
|
||||
{t('Repeat New Password')}
|
||||
</label>
|
||||
<Input
|
||||
id="newPasswordRepeat"
|
||||
name="newPasswordRepeat"
|
||||
value={newPasswordRepeat.value}
|
||||
type="password"
|
||||
error={
|
||||
newPasswordRepeat.error || checkDoesntMatch(newPassword.value, newPasswordRepeat.value)
|
||||
newPasswordRepeat.error ||
|
||||
checkDoesntMatch(newPassword.value, newPasswordRepeat.value)
|
||||
}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newValue = e.target.value;
|
||||
|
|
@ -113,15 +130,23 @@ function ChangePassword() {
|
|||
{err}
|
||||
</Message>
|
||||
))}
|
||||
<Message error hidden={!checkDoesntMatch(newPassword.value, newPasswordRepeat.value)}>
|
||||
{ERROR_DOESNT_MATCH}
|
||||
<Message
|
||||
error
|
||||
hidden={!checkDoesntMatch(newPassword.value, newPasswordRepeat.value)}
|
||||
>
|
||||
{ERROR_DOESNT_MATCH(t)}
|
||||
</Message>
|
||||
<Message error hidden={!newPassword.error}>
|
||||
{PASSWORD_POLICY}
|
||||
{PASSWORD_POLICY(t)}
|
||||
</Message>
|
||||
<div className="flex items-center pt-3">
|
||||
<Button htmlType="submit" type="default" disabled={isSubmitDisabled()} loading={loading}>
|
||||
Change Password
|
||||
<Button
|
||||
htmlType="submit"
|
||||
type="default"
|
||||
disabled={isSubmitDisabled()}
|
||||
loading={loading}
|
||||
>
|
||||
{t('Change Password')}
|
||||
</Button>
|
||||
<Button
|
||||
className="ml-2"
|
||||
|
|
@ -132,13 +157,13 @@ function ChangePassword() {
|
|||
setShow(false);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
) : (
|
||||
<div onClick={() => setShow(true)}>
|
||||
<Button type="text">Change Password</Button>
|
||||
<Button type="text">{t('Change Password')}</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import React from 'react';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { useStore } from 'App/mstore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
function Licenses() {
|
||||
const { t } = useTranslation();
|
||||
const { userStore } = useStore();
|
||||
const { account } = userStore;
|
||||
return (
|
||||
|
|
@ -10,10 +12,8 @@ function Licenses() {
|
|||
<div>{account.license}</div>
|
||||
{account.expirationDate && (
|
||||
<div className="">
|
||||
(Expires on
|
||||
{' '}
|
||||
{account.expirationDate.toFormat('LLL dd, yyyy')}
|
||||
)
|
||||
({t('Expires on')}
|
||||
{account.expirationDate.toFormat('LLL dd, yyyy')})
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -11,12 +11,16 @@ function OptOut() {
|
|||
|
||||
const onChange = () => {
|
||||
setOptOut(!optOut);
|
||||
void updateClient({ optOut: !optOut }).then(() => {
|
||||
toast('Account settings updated successfully', { type: 'success' });
|
||||
}).catch((e) => {
|
||||
toast(e.message || 'Failed to update account settings', { type: 'error' });
|
||||
setOptOut(optOut);
|
||||
});
|
||||
void updateClient({ optOut: !optOut })
|
||||
.then(() => {
|
||||
toast('Account settings updated successfully', { type: 'success' });
|
||||
})
|
||||
.catch((e) => {
|
||||
toast(e.message || 'Failed to update account settings', {
|
||||
type: 'error',
|
||||
});
|
||||
setOptOut(optOut);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -10,18 +10,24 @@ import Api from './Api';
|
|||
import TenantKey from './TenantKey';
|
||||
import OptOut from './OptOut';
|
||||
import Licenses from './Licenses';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
function ProfileSettings() {
|
||||
const { t } = useTranslation();
|
||||
const { userStore } = useStore();
|
||||
const { account } = userStore;
|
||||
const { isEnterprise } = userStore;
|
||||
return (
|
||||
<div className="bg-white rounded-lg border shadow-sm p-5">
|
||||
<PageTitle title={<div>Account</div>} />
|
||||
<PageTitle title={<div>{t('Account')}</div>} />
|
||||
<div className="flex items-center">
|
||||
<div className={styles.left}>
|
||||
<h4 className="text-lg mb-4">Profile</h4>
|
||||
<div className={styles.info}>Your email address is your identity on OpenReplay and is used to login.</div>
|
||||
<h4 className="text-lg mb-4">{t('Profile')}</h4>
|
||||
<div className={styles.info}>
|
||||
{t(
|
||||
'Your email address is your identity on OpenReplay and is used to login.',
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Settings />
|
||||
|
|
@ -34,8 +40,10 @@ function ProfileSettings() {
|
|||
<>
|
||||
<div className="flex items-center">
|
||||
<div className={styles.left}>
|
||||
<h4 className="text-lg mb-4">Change Password</h4>
|
||||
<div className={styles.info}>Updating your password from time to time enhances your account’s security.</div>
|
||||
<h4 className="text-lg mb-4">{t('Change Password')}</h4>
|
||||
<div className={styles.info}>
|
||||
{t('Updating your password from time to time enhances your account’s security.')}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<ChangePassword />
|
||||
|
|
@ -48,8 +56,10 @@ function ProfileSettings() {
|
|||
|
||||
<div className="flex items-center">
|
||||
<div className={styles.left}>
|
||||
<h4 className="text-lg mb-4">Organization API Key</h4>
|
||||
<div className={styles.info}>Your API key gives you access to an extra set of services.</div>
|
||||
<h4 className="text-lg mb-4">{t('Organization API Key')}</h4>
|
||||
<div className={styles.info}>
|
||||
{t('Your API key gives you access to an extra set of services.')}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Api />
|
||||
|
|
@ -61,8 +71,10 @@ function ProfileSettings() {
|
|||
<div className="border-b my-10" />
|
||||
<div className="flex items-center">
|
||||
<div className={styles.left}>
|
||||
<h4 className="text-lg mb-4">Tenant Key</h4>
|
||||
<div className={styles.info}>For SSO (SAML) authentication.</div>
|
||||
<h4 className="text-lg mb-4">{t('Tenant Key')}</h4>
|
||||
<div className={styles.info}>
|
||||
{t('For SSO (SAML) authentication.')}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<TenantKey />
|
||||
|
|
@ -76,9 +88,9 @@ function ProfileSettings() {
|
|||
<div className="border-b my-10" />
|
||||
<div className="flex items-center">
|
||||
<div className={styles.left}>
|
||||
<h4 className="text-lg mb-4">Data Collection</h4>
|
||||
<h4 className="text-lg mb-4">{t('Data Collection')}</h4>
|
||||
<div className={styles.info}>
|
||||
Enables you to control how OpenReplay captures data on your organization’s usage to improve our product.
|
||||
{t('Enables you to control how OpenReplay captures data on your organization’s usage to improve our product.')}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
|
|
@ -94,8 +106,10 @@ function ProfileSettings() {
|
|||
|
||||
<div className="flex items-center">
|
||||
<div className={styles.left}>
|
||||
<h4 className="text-lg mb-4">License</h4>
|
||||
<div className={styles.info}>License key and expiration date.</div>
|
||||
<h4 className="text-lg mb-4">{t('License')}</h4>
|
||||
<div className={styles.info}>
|
||||
{t('License key and expiration date.')}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Licenses />
|
||||
|
|
@ -107,4 +121,6 @@ function ProfileSettings() {
|
|||
);
|
||||
}
|
||||
|
||||
export default withPageTitle('Account - OpenReplay Preferences')(observer(ProfileSettings));
|
||||
export default withPageTitle('Account - OpenReplay Preferences')(
|
||||
observer(ProfileSettings),
|
||||
);
|
||||
|
|
@ -5,15 +5,19 @@ import { observer } from 'mobx-react-lite';
|
|||
import { useStore } from 'App/mstore';
|
||||
import { toast } from 'react-toastify';
|
||||
import styles from './profileSettings.module.css';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
function Settings() {
|
||||
const { t } = useTranslation();
|
||||
const { userStore } = useStore();
|
||||
const { updateClient } = userStore;
|
||||
const storeAccountName = userStore.account.name;
|
||||
const storeOrganizationName = userStore.account.tenantName;
|
||||
const { loading } = userStore;
|
||||
const [accountName, setAccountName] = React.useState(storeAccountName);
|
||||
const [organizationName, setOrganizationName] = React.useState(storeOrganizationName);
|
||||
const [organizationName, setOrganizationName] = React.useState(
|
||||
storeOrganizationName,
|
||||
);
|
||||
const [changed, setChanged] = React.useState(false);
|
||||
|
||||
const onAccNameChange = (e) => {
|
||||
|
|
@ -28,18 +32,22 @@ function Settings() {
|
|||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
await updateClient({ name: accountName, tenantName: organizationName }).then(() => {
|
||||
setChanged(false);
|
||||
toast('Profile settings updated successfully', { type: 'success' });
|
||||
}).catch((e) => {
|
||||
toast(e.message || 'Failed to update account settings', { type: 'error' });
|
||||
});
|
||||
await updateClient({ name: accountName, tenantName: organizationName })
|
||||
.then(() => {
|
||||
setChanged(false);
|
||||
toast(t('Profile settings updated successfully'), { type: 'success' });
|
||||
})
|
||||
.catch((e) => {
|
||||
toast(e.message || t('Failed to update account settings'), {
|
||||
type: 'error',
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Form onSubmit={handleSubmit} className={styles.form}>
|
||||
<Form.Field>
|
||||
<label htmlFor="accountName">Name</label>
|
||||
<label htmlFor="accountName">{t('Name')}</label>
|
||||
<Input
|
||||
name="accountName"
|
||||
id="accountName"
|
||||
|
|
@ -51,7 +59,7 @@ function Settings() {
|
|||
</Form.Field>
|
||||
|
||||
<Form.Field>
|
||||
<label htmlFor="organizationName">Organization</label>
|
||||
<label htmlFor="organizationName">{t('Organization')}</label>
|
||||
<Input
|
||||
name="organizationName"
|
||||
id="organizationName"
|
||||
|
|
@ -62,8 +70,13 @@ function Settings() {
|
|||
/>
|
||||
</Form.Field>
|
||||
|
||||
<Button type="default" loading={loading} disabled={!changed} htmlType="submit">
|
||||
Update
|
||||
<Button
|
||||
type="default"
|
||||
loading={loading}
|
||||
disabled={!changed}
|
||||
htmlType="submit"
|
||||
>
|
||||
{t('Update')}
|
||||
</Button>
|
||||
</Form>
|
||||
);
|
||||
|
|
@ -4,8 +4,10 @@ import { Form, Input } from 'UI';
|
|||
import { Button } from 'antd';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { useStore } from 'App/mstore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
function TenantKey() {
|
||||
const { t } = useTranslation();
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
const { userStore } = useStore();
|
||||
const { tenantKey } = userStore.account;
|
||||
|
|
@ -19,7 +21,7 @@ function TenantKey() {
|
|||
};
|
||||
return (
|
||||
<Form.Field>
|
||||
<label htmlFor="tenantKey">Tenant Key</label>
|
||||
<label htmlFor="tenantKey">{t('Tenant Key')}</label>
|
||||
<Input
|
||||
name="tenantKey"
|
||||
id="tenantKey"
|
||||
|
|
@ -27,14 +29,11 @@ function TenantKey() {
|
|||
className="!w-72"
|
||||
readOnly
|
||||
value={tenantKey}
|
||||
leadingButton={(
|
||||
<Button
|
||||
type="text"
|
||||
onClick={copyHandler}
|
||||
>
|
||||
{ copied ? 'Copied' : 'Copy' }
|
||||
leadingButton={
|
||||
<Button type="text" onClick={copyHandler}>
|
||||
{copied ? t('Copied') : t('Copy')}
|
||||
</Button>
|
||||
)}
|
||||
}
|
||||
/>
|
||||
</Form.Field>
|
||||
);
|
||||
|
|
@ -1,7 +1,5 @@
|
|||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Button, Space, Switch, Tooltip, Input, Typography,
|
||||
} from 'antd';
|
||||
import { Button, Space, Switch, Tooltip, Input, Typography } from 'antd';
|
||||
import { Icon, Loader } from 'UI';
|
||||
import cn from 'classnames';
|
||||
import ConditionalRecordingSettings from 'Shared/SessionSettings/components/ConditionalRecordingSettings';
|
||||
|
|
@ -9,12 +7,14 @@ import { Conditions } from '@/mstore/types/FeatureFlag';
|
|||
import { useStore } from '@/mstore';
|
||||
import Project from '@/mstore/types/project';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface Props {
|
||||
project: Project;
|
||||
}
|
||||
|
||||
function ProjectCaptureRate(props: Props) {
|
||||
const { t } = useTranslation();
|
||||
const [conditions, setConditions] = React.useState<Conditions[]>([]);
|
||||
const { projectId, platform } = props.project;
|
||||
const isMobile = platform !== 'web';
|
||||
|
|
@ -40,7 +40,7 @@ function ProjectCaptureRate(props: Props) {
|
|||
setChanged(false);
|
||||
const fetchData = async () => {
|
||||
if (isEnterprise) {
|
||||
await customFieldStore.fetchListActive(projectId + '');
|
||||
await customFieldStore.fetchListActive(`${projectId}`);
|
||||
}
|
||||
void fetchCaptureConditions(projectId);
|
||||
};
|
||||
|
|
@ -49,7 +49,11 @@ function ProjectCaptureRate(props: Props) {
|
|||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
setConditions(captureConditions.map((condition: any) => new Conditions(condition, true, isMobile)));
|
||||
setConditions(
|
||||
captureConditions.map(
|
||||
(condition: any) => new Conditions(condition, true, isMobile),
|
||||
),
|
||||
);
|
||||
}, [captureConditions]);
|
||||
|
||||
const onCaptureRateChange = (input: string) => {
|
||||
|
|
@ -70,23 +74,32 @@ function ProjectCaptureRate(props: Props) {
|
|||
updateCaptureConditions(projectId!, {
|
||||
rate: parseInt(captureRate, 10),
|
||||
conditionalCapture,
|
||||
conditions: isEnterprise ? conditions.map((c) => c.toCaptureCondition()) : [],
|
||||
conditions: isEnterprise
|
||||
? conditions.map((c) => c.toCaptureCondition())
|
||||
: [],
|
||||
});
|
||||
setChanged(false);
|
||||
};
|
||||
|
||||
const updateDisabled = !changed || !isAdmin || (isEnterprise && (conditionalCapture && conditions.length === 0));
|
||||
const updateDisabled =
|
||||
!changed ||
|
||||
!isAdmin ||
|
||||
(isEnterprise && conditionalCapture && conditions.length === 0);
|
||||
|
||||
return (
|
||||
<Loader loading={loadingCaptureRate || !projectId}>
|
||||
<Tooltip title={isAdmin ? '' : 'You don\'t have permission to change.'}>
|
||||
<Tooltip title={isAdmin ? '' : t("You don't have permission to change.")}>
|
||||
<div className="flex flex-col gap-4 border-b pb-4">
|
||||
<Space>
|
||||
<Typography.Text>Define percentage of sessions you want to capture</Typography.Text>
|
||||
<Typography.Text>
|
||||
{t('Define percentage of sessions you want to capture')}
|
||||
</Typography.Text>
|
||||
<Tooltip
|
||||
title={
|
||||
'Define the percentage of user sessions to be recorded for detailed replay and analysis.'
|
||||
+ '\nSessions exceeding this specified limit will not be captured or stored.'
|
||||
t(
|
||||
'Define the percentage of user sessions to be recorded for detailed replay and analysis.',
|
||||
) +
|
||||
`\n${t('Sessions exceeding this specified limit will not be captured or stored.')}`
|
||||
}
|
||||
>
|
||||
<Icon size={16} color="black" name="info-circle" />
|
||||
|
|
@ -97,9 +110,11 @@ function ProjectCaptureRate(props: Props) {
|
|||
<Switch
|
||||
checked={conditionalCapture}
|
||||
onChange={toggleRate}
|
||||
checkedChildren={!isEnterprise ? '100%' : 'Conditional'}
|
||||
checkedChildren={!isEnterprise ? '100%' : t('Conditional')}
|
||||
disabled={!isAdmin}
|
||||
unCheckedChildren={!isEnterprise ? 'Custom' : 'Capture Rate'}
|
||||
unCheckedChildren={
|
||||
!isEnterprise ? t('Custom') : t('Capture Rate')
|
||||
}
|
||||
/>
|
||||
|
||||
{!conditionalCapture ? (
|
||||
|
|
@ -131,7 +146,7 @@ function ProjectCaptureRate(props: Props) {
|
|||
onClick={onUpdate}
|
||||
disabled={updateDisabled}
|
||||
>
|
||||
Update
|
||||
{t('Update')}
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -10,26 +10,26 @@ import CodeSnippet from 'Shared/CodeSnippet';
|
|||
import CircleNumber from 'Components/Onboarding/components/CircleNumber';
|
||||
import Project from '@/mstore/types/project';
|
||||
import stl from './projectCodeSnippet.module.css';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { TFunction } from 'i18next';
|
||||
|
||||
interface InputModeOption {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
const inputModeOptions: InputModeOption[] = [
|
||||
{ label: 'Record all inputs', value: 'plain' },
|
||||
{ label: 'Ignore all inputs', value: 'obscured' },
|
||||
{ label: 'Obscure all inputs', value: 'hidden' },
|
||||
const inputModeOptions: (t: TFunction) => InputModeOption[] = (t) => [
|
||||
{ label: t('Record all inputs'), value: 'plain' },
|
||||
{ label: t('Ignore all inputs'), value: 'obscured' },
|
||||
{ label: t('Obscure all inputs'), value: 'hidden' },
|
||||
];
|
||||
|
||||
const inputModeOptionsMap: Record<string, number> = {};
|
||||
inputModeOptions.forEach((o, i) => (inputModeOptionsMap[o.value] = i));
|
||||
|
||||
interface Props {
|
||||
project: Project;
|
||||
}
|
||||
|
||||
const ProjectCodeSnippet: React.FC<Props> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
const { projectsStore } = useStore();
|
||||
const { siteId } = projectsStore;
|
||||
const site = props.project;
|
||||
|
|
@ -77,15 +77,17 @@ const ProjectCodeSnippet: React.FC<Props> = (props) => {
|
|||
<div>
|
||||
<div className="font-medium mb-2 flex gap-2 items-center">
|
||||
<CircleNumber text="1" />
|
||||
<span>Choose data recording options</span>
|
||||
<span>{t('Choose data recording options')}</span>
|
||||
</div>
|
||||
|
||||
<div className="ml-8 mb-4 w-fit">
|
||||
<Select
|
||||
name="defaultInputMode"
|
||||
options={inputModeOptions}
|
||||
onChange={({ value }) => onChangeSelect({ name: 'defaultInputMode', value: value.value })}
|
||||
placeholder="Default Input Mode"
|
||||
options={inputModeOptions(t)}
|
||||
onChange={({ value }) =>
|
||||
onChangeSelect({ name: 'defaultInputMode', value: value.value })
|
||||
}
|
||||
placeholder={t('Default Input Mode')}
|
||||
defaultValue={gdpr.defaultInputMode}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -96,7 +98,7 @@ const ProjectCodeSnippet: React.FC<Props> = (props) => {
|
|||
onChange={(e) => onChangeOption('maskNumbers', e.target.checked)}
|
||||
className="mr-2"
|
||||
>
|
||||
Do not record any numeric text
|
||||
{t('Do not record any numeric text')}
|
||||
</Checkbox>
|
||||
|
||||
<div className="mx-4" />
|
||||
|
|
@ -106,18 +108,31 @@ const ProjectCodeSnippet: React.FC<Props> = (props) => {
|
|||
onChange={(e) => onChangeOption('maskEmails', e.target.checked)}
|
||||
className="mr-2"
|
||||
>
|
||||
Do not record email addresses
|
||||
{t('Do not record email addresses')}
|
||||
</Checkbox>
|
||||
</div>
|
||||
<div className={cn(stl.info, 'rounded-lg bg-gray mb-4 ml-8 bg-amber-50 w-fit text-sm mt-2', { hidden: !changed })}>
|
||||
The code snippet below changes based on the selected data recording options and should be used for implementation.
|
||||
<div
|
||||
className={cn(
|
||||
stl.info,
|
||||
'rounded-lg bg-gray mb-4 ml-8 bg-amber-50 w-fit text-sm mt-2',
|
||||
{ hidden: !changed },
|
||||
)}
|
||||
>
|
||||
{t(
|
||||
'The code snippet below changes based on the selected data recording options and should be used for implementation.',
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={cn(stl.instructions, 'flex flex-col !items-start !justify-start')}>
|
||||
<div
|
||||
className={cn(
|
||||
stl.instructions,
|
||||
'flex flex-col !items-start !justify-start',
|
||||
)}
|
||||
>
|
||||
<div className="font-medium flex gap-1 items-center">
|
||||
<CircleNumber text="2" />
|
||||
<span>Enable Assist (Optional)</span>
|
||||
<span>{t('Enable Assist (Optional)')}</span>
|
||||
</div>
|
||||
|
||||
<div className="ml-7">
|
||||
|
|
@ -128,13 +143,13 @@ const ProjectCodeSnippet: React.FC<Props> = (props) => {
|
|||
onChange={() => setAssistEnabled(!isAssistEnabled)}
|
||||
size="small"
|
||||
/>
|
||||
<span>Enable</span>
|
||||
<span>{t('Enable')}</span>
|
||||
</div>
|
||||
|
||||
<span className="text-sm text-neutral-400">
|
||||
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.
|
||||
{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.',
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -144,20 +159,23 @@ const ProjectCodeSnippet: React.FC<Props> = (props) => {
|
|||
<div className="flex flex-col items-start justify-start gap-2">
|
||||
<div className="font-medium flex gap-2 items-center">
|
||||
<CircleNumber text="3" />
|
||||
<span>Install SDK</span>
|
||||
<span>{t('Install SDK')}</span>
|
||||
</div>
|
||||
|
||||
<div className="ml-8 flex gap-2 items-center">
|
||||
<div>
|
||||
Paste this snippet
|
||||
<span>{'before the '}</span>
|
||||
{t('Paste this snippet')}
|
||||
<span>{t('before the')} </span>
|
||||
</div>
|
||||
<Tag color="red" bordered={false} className="rounded-lg text-base mr-0">
|
||||
{' '}
|
||||
{'</head>'}
|
||||
<Tag
|
||||
color="red"
|
||||
bordered={false}
|
||||
className="rounded-lg text-base mr-0"
|
||||
>
|
||||
{' '}
|
||||
{'</head>'}{' '}
|
||||
</Tag>
|
||||
<span>{' tag of your page.'}</span>
|
||||
<span> {t('tag of your page.')} </span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={cn(stl.snippetsWrapper, 'ml-8')}>
|
||||
|
|
@ -178,9 +196,7 @@ const ProjectCodeSnippet: React.FC<Props> = (props) => {
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,11 +2,10 @@ import React, { ChangeEvent, FormEvent, useEffect } from 'react';
|
|||
import { Icon } from 'UI';
|
||||
import Project from '@/mstore/types/project';
|
||||
import { projectStore, useStore } from '@/mstore';
|
||||
import {
|
||||
App, Segmented, Form, Input, Button, Tooltip,
|
||||
} from 'antd';
|
||||
import { App, Segmented, Form, Input, Button, Tooltip } from 'antd';
|
||||
import { toast } from 'react-toastify';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface Props {
|
||||
project?: Project;
|
||||
|
|
@ -14,18 +13,23 @@ interface Props {
|
|||
}
|
||||
|
||||
function ProjectForm(props: Props) {
|
||||
const { t } = useTranslation();
|
||||
const [form] = Form.useForm();
|
||||
const { onClose } = props;
|
||||
const { projectsStore } = useStore();
|
||||
const [project, setProject] = React.useState<Project>(new Project(props.project || {}));
|
||||
const [project, setProject] = React.useState<Project>(
|
||||
new Project(props.project || {}),
|
||||
);
|
||||
const { loading } = projectsStore;
|
||||
const canDelete = projectsStore.list.length > 1;
|
||||
// const pathname = window.location.pathname;
|
||||
const mstore = useStore();
|
||||
const { modal } = App.useApp();
|
||||
|
||||
const handleEdit = ({ target: { name, value } }: ChangeEvent<HTMLInputElement>) => {
|
||||
setProject((prev: Project) => (new Project({ ...prev, [name]: value })));
|
||||
const handleEdit = ({
|
||||
target: { name, value },
|
||||
}: ChangeEvent<HTMLInputElement>) => {
|
||||
setProject((prev: Project) => new Project({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const onSubmit = (e: FormEvent) => {
|
||||
|
|
@ -34,17 +38,19 @@ function ProjectForm(props: Props) {
|
|||
projectsStore
|
||||
.updateProject(project.id, project)
|
||||
.then((response: any) => {
|
||||
toast.success('Project updated successfully');
|
||||
toast.success(t('Project updated successfully'));
|
||||
onClose?.(null);
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
toast.error(error.message || 'An error occurred while updating the project');
|
||||
toast.error(
|
||||
error.message || t('An error occurred while updating the project'),
|
||||
);
|
||||
});
|
||||
} else {
|
||||
projectsStore
|
||||
.save(project!)
|
||||
.then((resp: Project) => {
|
||||
toast.success('Project created successfully');
|
||||
toast.success(t('Project created successfully'));
|
||||
onClose?.(resp);
|
||||
|
||||
// mstore.searchStore.clearSearch();
|
||||
|
|
@ -54,23 +60,33 @@ function ProjectForm(props: Props) {
|
|||
projectsStore.setConfigProject(parseInt(resp.id!));
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
toast.error(error.message || 'An error occurred while creating the project');
|
||||
toast.error(
|
||||
error.message || t('An error occurred while creating the project'),
|
||||
);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = async () => {
|
||||
modal.confirm({
|
||||
title: 'Project Deletion Alert',
|
||||
content: 'Are you sure you want to delete this project? Deleting it will permanently remove the project, along with all associated sessions and data.',
|
||||
title: t('Project Deletion Alert'),
|
||||
content: t(
|
||||
'Are you sure you want to delete this project? Deleting it will permanently remove the project, along with all associated sessions and data.',
|
||||
),
|
||||
onOk: () => {
|
||||
projectsStore.removeProject(project.id!).then(() => {
|
||||
if (onClose) {
|
||||
onClose(null);
|
||||
}
|
||||
}).catch((error: Error) => {
|
||||
toast.error(error.message || 'An error occurred while deleting the project');
|
||||
});
|
||||
projectsStore
|
||||
.removeProject(project.id!)
|
||||
.then(() => {
|
||||
if (onClose) {
|
||||
onClose(null);
|
||||
}
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
toast.error(
|
||||
error.message ||
|
||||
t('An error occurred while deleting the project'),
|
||||
);
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
@ -91,13 +107,13 @@ function ProjectForm(props: Props) {
|
|||
initialValues={{ ...project }}
|
||||
>
|
||||
<Form.Item
|
||||
label="Name"
|
||||
label={t('Name')}
|
||||
name="name"
|
||||
rules={[{ required: true, message: 'Please enter a name' }]}
|
||||
rules={[{ required: true, message: t('Please enter a name') }]}
|
||||
className="font-medium"
|
||||
>
|
||||
<Input
|
||||
placeholder="Ex. OpenReplay"
|
||||
placeholder={t('Ex. OpenReplay')}
|
||||
name="name"
|
||||
maxLength={40}
|
||||
value={project.name}
|
||||
|
|
@ -105,23 +121,25 @@ function ProjectForm(props: Props) {
|
|||
className="font-normal rounded-lg"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="Project Type" className="font-medium">
|
||||
<Form.Item label={t('Project Type')} className="font-medium">
|
||||
<div>
|
||||
<Segmented
|
||||
options={[
|
||||
{
|
||||
value: 'web',
|
||||
label: 'Web',
|
||||
label: t('Web'),
|
||||
},
|
||||
{
|
||||
value: 'ios',
|
||||
label: 'Mobile',
|
||||
label: t('Mobile'),
|
||||
},
|
||||
]}
|
||||
value={project.platform}
|
||||
onChange={(value) => {
|
||||
// projectsStore.editInstance({ platform: value });
|
||||
setProject((prev: Project) => (new Project({ ...prev, platform: value })));
|
||||
setProject(
|
||||
(prev: Project) => new Project({ ...prev, platform: value }),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -135,18 +153,18 @@ function ProjectForm(props: Props) {
|
|||
loading={loading}
|
||||
// disabled={!project.validate}
|
||||
>
|
||||
{project.exists() ? 'Save' : 'Add'}
|
||||
{project.exists() ? t('Save') : t('Add')}
|
||||
</Button>
|
||||
<Button
|
||||
type="text"
|
||||
onClick={handleCancel}
|
||||
className="btn-cancel-project"
|
||||
>
|
||||
Cancel
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
{project.exists() && (
|
||||
<Tooltip title="Delete project" placement="top">
|
||||
<Tooltip title={t('Delete project')} placement="top">
|
||||
<Button
|
||||
type="text"
|
||||
onClick={handleRemove}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,13 @@
|
|||
import React from 'react';
|
||||
import {
|
||||
Avatar, Button, Input, Menu, MenuProps, Progress, Typography, Tooltip,
|
||||
Avatar,
|
||||
Button,
|
||||
Input,
|
||||
Menu,
|
||||
MenuProps,
|
||||
Progress,
|
||||
Typography,
|
||||
Tooltip,
|
||||
} from 'antd';
|
||||
import { useStore } from '@/mstore';
|
||||
import Project from '@/mstore/types/project';
|
||||
|
|
@ -17,7 +24,9 @@ const ProjectList: React.FC = () => {
|
|||
const [search, setSearch] = React.useState('');
|
||||
const { openModal, closeModal } = useModal();
|
||||
|
||||
const filteredProjects = projectsStore.list.filter((project: Project) => project.name.toLowerCase().includes(search.toLowerCase()));
|
||||
const filteredProjects = projectsStore.list.filter((project: Project) =>
|
||||
project.name.toLowerCase().includes(search.toLowerCase()),
|
||||
);
|
||||
|
||||
const handleSearch = (value: string) => setSearch(value);
|
||||
|
||||
|
|
@ -38,14 +47,20 @@ const ProjectList: React.FC = () => {
|
|||
|
||||
const menuItems: MenuItem[] = filteredProjects.map((project) => ({
|
||||
key: `${project.id}`,
|
||||
label: <Typography.Text style={{ color: 'inherit' }} ellipsis>{project.name}</Typography.Text>,
|
||||
extra: <Button
|
||||
onClick={(e) => projectEditHandler(e, project)}
|
||||
className="flex opacity-0 group-hover:!opacity-100"
|
||||
size="small"
|
||||
type="link"
|
||||
icon={<EditOutlined size={14} />}
|
||||
/>,
|
||||
label: (
|
||||
<Typography.Text style={{ color: 'inherit' }} ellipsis>
|
||||
{project.name}
|
||||
</Typography.Text>
|
||||
),
|
||||
extra: (
|
||||
<Button
|
||||
onClick={(e) => projectEditHandler(e, project)}
|
||||
className="flex opacity-0 group-hover:!opacity-100"
|
||||
size="small"
|
||||
type="link"
|
||||
icon={<EditOutlined size={14} />}
|
||||
/>
|
||||
),
|
||||
className: 'group',
|
||||
icon: (
|
||||
<ProjectIconWithProgress
|
||||
|
|
@ -105,12 +120,12 @@ const ProjectIconWithProgress: React.FC<{
|
|||
className="bg-tealx-light"
|
||||
size={26}
|
||||
icon={
|
||||
platform === 'web' ? (
|
||||
<Globe size={16} color="teal" />
|
||||
) : (
|
||||
<Smartphone size={16} color="teal" />
|
||||
)
|
||||
}
|
||||
platform === 'web' ? (
|
||||
<Globe size={16} color="teal" />
|
||||
) : (
|
||||
<Smartphone size={16} color="teal" />
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -30,11 +30,7 @@ const ProjectTabContent: React.FC = () => {
|
|||
[project],
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{tabContent[tab] || <Empty description="Tab not found" />}
|
||||
</div>
|
||||
);
|
||||
return <div>{tabContent[tab] || <Empty description="Tab not found" />}</div>;
|
||||
};
|
||||
|
||||
export default observer(ProjectTabContent);
|
||||
|
|
|
|||
|
|
@ -2,7 +2,10 @@ import React from 'react';
|
|||
import Project from '@/mstore/types/project';
|
||||
import { Tabs } from 'UI';
|
||||
import {
|
||||
AppleOutlined, AndroidOutlined, CodeOutlined, JavaScriptOutlined,
|
||||
AppleOutlined,
|
||||
AndroidOutlined,
|
||||
CodeOutlined,
|
||||
JavaScriptOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import usePageTitle from '@/hooks/usePageTitle';
|
||||
import InstallDocs from 'Components/Onboarding/components/OnboardingTabs/InstallDocs';
|
||||
|
|
@ -10,13 +13,7 @@ import ProjectCodeSnippet from 'Components/Client/Projects/ProjectCodeSnippet';
|
|||
import MobileInstallDocs from 'Components/Onboarding/components/OnboardingTabs/InstallDocs/MobileInstallDocs';
|
||||
import { Segmented } from 'antd';
|
||||
import AndroidInstallDocs from 'Components/Onboarding/components/OnboardingTabs/InstallDocs/AndroidInstallDocs';
|
||||
|
||||
const JAVASCRIPT = 'Using Script';
|
||||
const NPM = 'Using NPM';
|
||||
const TABS = [
|
||||
{ key: NPM, text: NPM },
|
||||
{ key: JAVASCRIPT, text: JAVASCRIPT },
|
||||
];
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface Props {
|
||||
project: Project;
|
||||
|
|
@ -40,6 +37,7 @@ function ProjectTabTracking(props: Props) {
|
|||
export default ProjectTabTracking;
|
||||
|
||||
function WebSnippet({ project }: { project: Project }) {
|
||||
const { t } = useTranslation();
|
||||
const [isNpm, setIsNpm] = React.useState(true);
|
||||
|
||||
return (
|
||||
|
|
@ -50,7 +48,7 @@ function WebSnippet({ project }: { project: Project }) {
|
|||
label: (
|
||||
<div className="flex items-center gap-2">
|
||||
<CodeOutlined />
|
||||
<span>NPM</span>
|
||||
<span>{t('NPM')}</span>
|
||||
</div>
|
||||
),
|
||||
value: true,
|
||||
|
|
@ -59,7 +57,7 @@ function WebSnippet({ project }: { project: Project }) {
|
|||
label: (
|
||||
<div className="flex items-center gap-2">
|
||||
<JavaScriptOutlined />
|
||||
<span>Script</span>
|
||||
<span>{t('Script')}</span>
|
||||
</div>
|
||||
),
|
||||
value: false,
|
||||
|
|
@ -80,6 +78,7 @@ function WebSnippet({ project }: { project: Project }) {
|
|||
}
|
||||
|
||||
function MobileSnippet({ project }: { project: Project }) {
|
||||
const { t } = useTranslation();
|
||||
const [isIos, setIsIos] = React.useState(true);
|
||||
const ingestPoint = `https://${window.location.hostname}/ingest`;
|
||||
|
||||
|
|
@ -91,7 +90,7 @@ function MobileSnippet({ project }: { project: Project }) {
|
|||
label: (
|
||||
<div className="flex items-center gap-2">
|
||||
<AppleOutlined />
|
||||
<span>iOS</span>
|
||||
<span>{t('iOS')}</span>
|
||||
</div>
|
||||
),
|
||||
value: true,
|
||||
|
|
@ -100,7 +99,7 @@ function MobileSnippet({ project }: { project: Project }) {
|
|||
label: (
|
||||
<div className="flex items-center gap-2">
|
||||
<AndroidOutlined />
|
||||
<span>Android</span>
|
||||
<span>{t('Android')}</span>
|
||||
</div>
|
||||
),
|
||||
value: false,
|
||||
|
|
@ -116,7 +115,6 @@ function MobileSnippet({ project }: { project: Project }) {
|
|||
) : (
|
||||
<AndroidInstallDocs site={project} ingestPoint={ingestPoint} />
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,20 +2,34 @@ import React from 'react';
|
|||
import { Tabs, TabsProps } from 'antd';
|
||||
import { useStore } from '@/mstore';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const customTabBar: TabsProps['renderTabBar'] = (props, DefaultTabBar) => (
|
||||
<DefaultTabBar {...props} className="!mb-0" />
|
||||
);
|
||||
|
||||
function ProjectTabs() {
|
||||
const { t } = useTranslation();
|
||||
const { projectsStore } = useStore();
|
||||
const activeTab = projectsStore.config.tab;
|
||||
|
||||
const tabItems = [
|
||||
{ key: 'installation', label: 'Installation', content: <div>Installation Content</div> },
|
||||
{ key: 'captureRate', label: 'Capture Rate', content: <div>Capture Rate Content</div> },
|
||||
{ key: 'metadata', label: 'Metadata', content: <div>Metadata Content</div> },
|
||||
{ key: 'tags', label: 'Tags', content: <div>Tags Content</div> },
|
||||
{
|
||||
key: 'installation',
|
||||
label: t('Installation'),
|
||||
content: <div>{t('Installation Content')}</div>,
|
||||
},
|
||||
{
|
||||
key: 'captureRate',
|
||||
label: t('Capture Rate'),
|
||||
content: <div>{t('Capture Rate Content')}</div>,
|
||||
},
|
||||
{
|
||||
key: 'metadata',
|
||||
label: t('Metadata'),
|
||||
content: <div>{t('Metadata Content')}</div>,
|
||||
},
|
||||
{ key: 'tags', label: t('Tags'), content: <div>{t('Tags Content')}</div> },
|
||||
// { key: 'groupKeys', label: 'Group Keys', content: <div>Group Keys Content</div> }
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -1,16 +1,16 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import { useStore } from '@/mstore';
|
||||
import {
|
||||
List, Button, Typography, Space, Empty,
|
||||
} from 'antd';
|
||||
import { List, Button, Typography, Space, Empty } from 'antd';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { ScanSearch } from 'lucide-react';
|
||||
import { EditOutlined } from '@ant-design/icons';
|
||||
import { useModal } from 'Components/ModalContext';
|
||||
import TagForm from 'Components/Client/Projects/TagForm';
|
||||
import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
function ProjectTags() {
|
||||
const { t } = useTranslation();
|
||||
const { tagWatchStore, projectsStore } = useStore();
|
||||
const list = tagWatchStore.tags;
|
||||
const { openModal } = useModal();
|
||||
|
|
@ -22,7 +22,7 @@ function ProjectTags() {
|
|||
|
||||
const handleInit = (tag?: any) => {
|
||||
openModal(<TagForm tag={tag} projectId={pid!} />, {
|
||||
title: tag ? 'Edit Tag' : 'Add Tag',
|
||||
title: tag ? t('Edit Tag') : t('Add Tag'),
|
||||
});
|
||||
};
|
||||
|
||||
|
|
@ -30,20 +30,31 @@ function ProjectTags() {
|
|||
<div className="flex flex-col gap-6">
|
||||
<Space direction="vertical">
|
||||
<Typography.Text>
|
||||
Manage Tag Elements here. Rename tags for easy identification or delete those you no longer need.
|
||||
{t(
|
||||
'Manage Tag Elements here. Rename tags for easy identification or delete those you no longer need.',
|
||||
)}
|
||||
</Typography.Text>
|
||||
<ul className="!list-disc list-inside">
|
||||
<li>
|
||||
<Typography.Text>To create new tags, navigate to the Tags tab while playing a session.</Typography.Text>
|
||||
<Typography.Text>
|
||||
{t(' To create new tags, navigate to the Tags tab while playing a session')}
|
||||
</Typography.Text>
|
||||
</li>
|
||||
<li>
|
||||
<Typography.Text>Use tags in OmniSearch to quickly find relevant sessions.</Typography.Text>
|
||||
<Typography.Text>
|
||||
{t('Use tags in OmniSearch to quickly find relevant sessions.')}
|
||||
</Typography.Text>
|
||||
</li>
|
||||
</ul>
|
||||
</Space>
|
||||
<List
|
||||
locale={{
|
||||
emptyText: <Empty description="No tags found" image={<AnimatedSVG name={ICONS.NO_METADATA} size={60} />} />,
|
||||
emptyText: (
|
||||
<Empty
|
||||
description={t('No tags found')}
|
||||
image={<AnimatedSVG name={ICONS.NO_METADATA} size={60} />}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
loading={tagWatchStore.isLoading}
|
||||
dataSource={list}
|
||||
|
|
@ -51,7 +62,11 @@ function ProjectTags() {
|
|||
<List.Item
|
||||
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} />}
|
||||
/>,
|
||||
]}
|
||||
onClick={() => handleInit(item)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
import React from 'react';
|
||||
import {
|
||||
App, Button, Card, Layout, Space, Tooltip, Typography,
|
||||
} from 'antd';
|
||||
import { App, Button, Card, Layout, Space, Tooltip, Typography } from 'antd';
|
||||
import ProjectList from 'Components/Client/Projects/ProjectList';
|
||||
import ProjectTabs from 'Components/Client/Projects/ProjectTabs';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
|
|
@ -12,8 +10,10 @@ import ProjectTabContent from 'Components/Client/Projects/ProjectTabContent';
|
|||
import { useModal } from 'Components/ModalContext';
|
||||
import ProjectForm from 'Components/Client/Projects/ProjectForm';
|
||||
import Project from '@/mstore/types/project';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
function Projects() {
|
||||
const { t } = useTranslation();
|
||||
const { projectsStore, customFieldStore } = useStore();
|
||||
const history = useHistory();
|
||||
const { project, pid, tab } = projectsStore.config;
|
||||
|
|
@ -27,8 +27,8 @@ function Projects() {
|
|||
projectsStore.setConfigTab(tab);
|
||||
|
||||
return () => {
|
||||
void customFieldStore.fetchListActive(projectsStore.activeSiteId + '');
|
||||
}
|
||||
void customFieldStore.fetchListActive(`${projectsStore.activeSiteId}`);
|
||||
};
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
|
|
@ -45,7 +45,7 @@ function Projects() {
|
|||
|
||||
const createProject = () => {
|
||||
openModal(<ProjectForm onClose={closeModal} project={new Project()} />, {
|
||||
title: 'Add Project',
|
||||
title: t('Add Project'),
|
||||
});
|
||||
};
|
||||
|
||||
|
|
@ -57,15 +57,24 @@ function Projects() {
|
|||
header: '!border-b !px-4',
|
||||
body: '!p-0 !border-t',
|
||||
}}
|
||||
title={<Typography.Title level={4} className="!m-0">Projects</Typography.Title>}
|
||||
extra={<Button onClick={createProject} type="default" size="middle" icon={<PlusOutlined size={16} />}>Add Project</Button>}
|
||||
title={
|
||||
<Typography.Title level={4} className="!m-0">
|
||||
{t('Projects')}
|
||||
</Typography.Title>
|
||||
}
|
||||
extra={
|
||||
<Button
|
||||
onClick={createProject}
|
||||
type="default"
|
||||
size="middle"
|
||||
icon={<PlusOutlined size={16} />}
|
||||
>
|
||||
{t('Add Project')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Layout>
|
||||
<Layout.Sider
|
||||
width={260}
|
||||
trigger={null}
|
||||
className="!bg-white border-r"
|
||||
>
|
||||
<Layout.Sider width={260} trigger={null} className="!bg-white border-r">
|
||||
<ProjectList />
|
||||
</Layout.Sider>
|
||||
|
||||
|
|
@ -104,18 +113,19 @@ export default observer(Projects);
|
|||
|
||||
function ProjectKeyButton({ project }: { project: Project | null }) {
|
||||
const { message } = App.useApp();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const copyKey = () => {
|
||||
if (!project || !project.projectKey) {
|
||||
void message.error('Project key not found');
|
||||
void message.error(t('Project key not found'));
|
||||
return;
|
||||
}
|
||||
void navigator.clipboard.writeText(project?.projectKey || '');
|
||||
void message.success('Project key copied to clipboard');
|
||||
void message.success(t('Project key copied to clipboard'));
|
||||
};
|
||||
|
||||
return (
|
||||
<Tooltip title="Copy Project Key">
|
||||
<Tooltip title={t('Copy Project Key')}>
|
||||
<Button onClick={copyKey} icon={<KeyOutlined size={14} />} size="small" />
|
||||
</Tooltip>
|
||||
);
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue