change(ui): projects revamp - capture rate tab

This commit is contained in:
Shekar Siri 2025-01-07 16:15:50 +01:00
parent 9374c98669
commit 1e7948bcf7
10 changed files with 357 additions and 178 deletions

View file

@ -0,0 +1,142 @@
import React, { useEffect, useState } from 'react';
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';
import { Conditions } from '@/mstore/types/FeatureFlag';
import { useStore } from '@/mstore';
import Project from '@/mstore/types/project';
import { observer } from 'mobx-react-lite';
interface Props {
project: Project;
}
function ProjectCaptureRate(props: Props) {
const [conditions, setConditions] = React.useState<Conditions[]>([]);
const { projectId, platform } = props.project;
const isMobile = platform !== 'web';
const { settingsStore, userStore } = useStore();
const isAdmin = userStore.account.admin || userStore.account.superAdmin;
const isEnterprise = userStore.isEnterprise;
const [changed, setChanged] = useState(false);
const {
sessionSettings: {
captureRate,
changeCaptureRate,
conditionalCapture,
changeConditionalCapture,
captureConditions
},
loadingCaptureRate,
updateCaptureConditions,
fetchCaptureConditions
} = settingsStore;
useEffect(() => {
if (projectId) {
void fetchCaptureConditions(projectId);
}
}, [projectId]);
React.useEffect(() => {
setConditions(captureConditions.map((condition: any) => new Conditions(condition, true, isMobile)));
}, [captureConditions]);
const onCaptureRateChange = (input: string) => {
setChanged(true);
changeCaptureRate(input);
};
const toggleRate = () => {
setChanged(true);
const newValue = !conditionalCapture;
changeConditionalCapture(newValue);
if (newValue) {
changeCaptureRate('100');
}
};
const onUpdate = () => {
updateCaptureConditions(projectId!, {
rate: parseInt(captureRate, 10),
conditionalCapture: conditionalCapture,
conditions: isEnterprise ? conditions.map((c) => c.toCaptureCondition()) : []
});
setChanged(false);
};
const updateDisabled = !changed || !isAdmin || (isEnterprise && (conditionalCapture && conditions.length === 0));
return (
<Loader loading={loadingCaptureRate || !projectId}>
<Tooltip title={isAdmin ? '' : '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>
<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.'
}
>
<Icon size={16} color={'black'} name={'info-circle'} />
</Tooltip>
</Space>
<Space className="flex items-center gap-6 h-6">
<Switch
checked={conditionalCapture}
onChange={toggleRate}
checkedChildren={!isEnterprise ? '100%' : 'Conditional'}
disabled={!isAdmin}
unCheckedChildren={!isEnterprise ? 'Custom' : 'Capture Rate'}
/>
{!conditionalCapture ? (
<div className={cn('relative', { disabled: !isAdmin })}>
<Input
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
if (/^\d+$/.test(e.target.value) || e.target.value === '') {
onCaptureRateChange(e.target.value);
}
}}
value={captureRate.toString()}
style={{ height: '26px', width: '70px' }}
disabled={conditionalCapture}
min={0}
max={100}
/>
<Icon
className="absolute right-0 mr-2 top-0 bottom-0 m-auto"
name="percent"
color="gray-medium"
size="18"
/>
</div>
) : null}
<Button
type="primary"
size="small"
onClick={onUpdate}
disabled={updateDisabled}
>
Update
</Button>
</Space>
</div>
{conditionalCapture && isEnterprise ? (
<ConditionalRecordingSettings
setChanged={setChanged}
conditions={conditions}
setConditions={setConditions}
isMobile={isMobile}
/>
) : null}
</Tooltip>
</Loader>
);
}
export default observer(ProjectCaptureRate);

View file

@ -1,5 +1,4 @@
import React, { ChangeEvent, FormEvent, useEffect } from 'react';
import styles from 'Components/Client/Sites/siteForm.module.css';
import { Icon } from 'UI';
import Project from '@/mstore/types/project';
import { projectStore, useStore } from '@/mstore';
@ -13,12 +12,14 @@ interface Props {
}
function ProjectForm(props: Props) {
const [form] = Form.useForm();
const { onClose } = props;
const { projectsStore } = useStore();
const project = projectsStore.instance as Project;
const loading = projectsStore.loading;
const canDelete = projectsStore.list.length > 1;
const pathname = window.location.pathname;
const mstore = useStore();
useEffect(() => {
if (props.project && props.project.id) {
@ -33,7 +34,6 @@ function ProjectForm(props: Props) {
};
const onSubmit = (e: FormEvent) => {
e.preventDefault();
if (!projectsStore.instance) return;
if (projectsStore.instance.id && projectsStore.instance.exists()) {
projectsStore
@ -52,19 +52,19 @@ function ProjectForm(props: Props) {
}
});
} else {
projectsStore.save(projectsStore.instance!).then((response: any) => {
if (!response || !response.errors || response.errors.size === 0) {
if (onClose) {
onClose(null);
}
// searchStore.clearSearch();
// mstore.searchStoreLive.clearSearch();
// mstore.initClient();
toast.success('Project added successfully');
} else {
toast.error(response.errors[0]);
}
});
projectsStore
.save(projectsStore.instance!)
.then(() => {
toast.success('Project created successfully');
onClose?.(null);
mstore.searchStore.clearSearch();
mstore.searchStoreLive.clearSearch();
mstore.initClient();
})
.catch((error: string) => {
toast.error(error || 'An error occurred while creating the project');
});
}
};
@ -86,60 +86,65 @@ function ProjectForm(props: Props) {
};
return (
<Form onFinish={project.validate ? onSubmit : () => null}>
<div className={styles.content}>
<Form.Item>
<label>{'Name'}</label>
<Input
placeholder="Ex. OpenReplay"
name="name"
maxLength={40}
value={project.name}
onChange={handleEdit}
className={styles.input}
<Form
form={form}
layout="vertical"
requiredMark={false}
onFinish={onSubmit}
>
<Form.Item
label="Name"
name="name"
rules={[{ required: true, message: 'Please enter a name' }]}
>
<Input
placeholder="Ex. OpenReplay"
name="name"
maxLength={40}
value={project.name}
onChange={handleEdit}
/>
</Form.Item>
<Form.Item label="Project Type">
<div>
<Segmented
options={[
{
value: 'web',
label: 'Web'
},
{
value: 'ios',
label: 'Mobile'
}
]}
value={project.platform}
onChange={(value) => {
projectsStore.editInstance({ platform: value });
}}
/>
</Form.Item>
<Form.Item>
<label>Project Type</label>
<div>
<Segmented
options={[
{
value: 'web',
label: 'Web'
},
{
value: 'ios',
label: 'Mobile'
}
]}
value={project.platform}
onChange={(value) => {
projectsStore.editInstance({ platform: value });
}}
/>
</div>
</Form.Item>
<div className="mt-6 flex justify-between">
<Button
htmlType="submit"
type="primary"
className="float-left mr-2"
loading={loading}
disabled={!project.validate}
>
{project.exists() ? 'Update' : 'Add'}
</Button>
{project.exists() && (
<Button
variant="text"
onClick={handleRemove}
disabled={!canDelete}
>
<Icon name="trash" size="16" />
</Button>
)}
</div>
</Form.Item>
<div className="mt-6 flex justify-between">
<Button
htmlType="submit"
type="primary"
className="float-left mr-2"
loading={loading}
// disabled={!project.validate}
>
{project.exists() ? 'Update' : 'Add'}
</Button>
{project.exists() && (
<Button
variant="text"
onClick={handleRemove}
disabled={!canDelete}
>
<Icon name="trash" size="16" />
</Button>
)}
</div>
</Form>
);

View file

@ -1,5 +1,5 @@
import React from 'react';
import { Avatar, Input, List, Typography } from 'antd';
import { Avatar, Input, Menu, List, Typography } from 'antd';
import { useStore } from '@/mstore';
import Project from '@/mstore/types/project';
import { observer } from 'mobx-react-lite';
@ -20,34 +20,28 @@ function ProjectList() {
};
return (
<div className="flex flex-col gap-4">
<Input.Search
placeholder="Search"
onSearch={onSearch}
onClear={() => setSearch('')}
allowClear
/>
<List
dataSource={list.filter((item) => item.name.toLowerCase().includes(search.toLowerCase()))}
renderItem={(item: Project) => (
<List.Item
key={item.id}
onClick={() => onProjectClick(item)}
className={`!py-2 mb-2 rounded-lg cursor-pointer !border-b-0 ${config.project?.projectId === item.projectId ? 'bg-teal-light' : 'bg-white'}`}
>
<List.Item.Meta
className="flex !items-center px-2 overflow-hidden"
avatar={
<Avatar
className="bg-tealx-light"
icon={item.platform === 'web' ? <AppWindowMac size={18} color="teal" /> :
<Smartphone size={18} color="teal" />}
/>
}
title={<Typography.Text className="capitalize truncate text-ellipsis">{item.name}</Typography.Text>}
/>
</List.Item>
)}
<div className="h-full flex flex-col gap-4">
<div className="p-4">
<Input.Search
placeholder="Search"
onSearch={onSearch}
onClear={() => setSearch('')}
allowClear
// className="m-4"
/>
</div>
<Menu
mode="inline"
selectedKeys={[config.pid + '']}
className="h-full w-full ml-0 pl-0 !bg-white !border-r-0"
items={list.filter((item: Project) => item.name.toLowerCase().includes(search.toLowerCase())).map((project) => ({
key: project.id,
label: project.name,
onClick: () => onProjectClick(project),
icon: <Avatar className="bg-tealx-light"
icon={project.platform === 'web' ? <AppWindowMac size={18} color="teal" /> :
<Smartphone size={18} color="teal" />} />
})) as any}
/>
</div>
);

View file

@ -4,27 +4,37 @@ import { observer } from 'mobx-react-lite';
import ProjectTabTracking from 'Components/Client/Projects/ProjectTabTracking';
import CustomFields from 'Components/Client/CustomFields';
import ProjectTags from 'Components/Client/Projects/ProjectTags';
import ProjectCaptureRate from 'Components/Client/Projects/ProjectCaptureRate';
import { Empty } from 'antd';
function ProjectTabContent() {
const ProjectTabContent: React.FC = () => {
const { projectsStore } = useStore();
const { pid, tab } = projectsStore.config;
const tabContent: Record<string, React.ReactNode> = React.useMemo(() => {
const project = projectsStore.list.find((p) => p.projectId == pid);
return {
installation: <ProjectTabTracking project={project!} />,
captureRate: <div>Capture Rate Content</div>,
const project = React.useMemo(
() => projectsStore.list.find((p) => p.projectId === pid),
[pid, projectsStore.list]
);
if (!project) {
return <Empty description="Project not found" />;
}
const tabContent: Record<string, React.ReactNode> = React.useMemo(
() => ({
installation: <ProjectTabTracking project={project} />,
captureRate: <ProjectCaptureRate project={project} />,
metadata: <CustomFields />,
tags: <ProjectTags />,
groupKeys: <div>Group Keys Content</div>
};
}, [pid, projectsStore.list]);
tags: <ProjectTags />
}),
[project]
);
return (
<div>
{tabContent[tab]}
{tabContent[tab] || <Empty description="Tab not found" />}
</div>
);
}
};
export default observer(ProjectTabContent);

View file

@ -16,7 +16,7 @@ function ProjectTabs() {
{ 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: 'groupKeys', label: 'Group Keys', content: <div>Group Keys Content</div> }
// { key: 'groupKeys', label: 'Group Keys', content: <div>Group Keys Content</div> }
];
const onTabChange = (key: string) => {

View file

@ -1,11 +1,11 @@
import React from 'react';
import { Button, Card, Col, Divider, Row, Space, Typography } from 'antd';
import { App, Button, Card, Layout, Space, Typography } from 'antd';
import ProjectList from 'Components/Client/Projects/ProjectList';
import ProjectTabs from 'Components/Client/Projects/ProjectTabs';
import { useHistory } from 'react-router-dom';
import { useStore } from '@/mstore';
import { observer } from 'mobx-react-lite';
import { PlusIcon } from 'lucide-react';
import { KeyIcon, PlusIcon } from 'lucide-react';
import ProjectTabContent from 'Components/Client/Projects/ProjectTabContent';
import { useModal } from 'Components/ModalContext';
import ProjectForm from 'Components/Client/Projects/ProjectForm';
@ -45,39 +45,65 @@ function Projects() {
return (
<Card
title="Projects"
style={{ height: 'calc(100vh - 130px)' }}
classNames={{
cover: '!rounded-lg border shadow-sm',
body: '!p-0'
header: '!border-b',
body: '!p-0 !border-t'
}}
style={{ height: 'calc(100vh - 140px)' }}
extra={
<Space>
<Button onClick={createProject} icon={<PlusIcon />}>
Create Project
</Button>
</Space>
}
title="Projects"
extra={[
<Button key="1" onClick={createProject} icon={<PlusIcon />}>
Create Project
</Button>
]}
>
<Row className="h-full">
<Col span={6} className="border-r !p-4 flex flex-col">
<div className="flex-1 !overflow-y-auto">
<ProjectList />
</div>
</Col>
<Col span={18} className="!p-4 flex flex-col">
<Space className="flex justify-between">
<Typography.Title level={5} className="capitalize !m-0">{project?.name}</Typography.Title>
<Layout>
<Layout.Sider width={300} trigger={null} className="!bg-white border-r">
<ProjectList />
</Layout.Sider>
<Layout>
<Layout.Header className="flex justify-between items-center p-4 !bg-white border-b"
style={{ height: 46 }}>
<div className="flex items-center gap-4">
<Typography.Title level={5}
className="capitalize !m-0 whitespace-nowrap truncate">{project?.name}</Typography.Title>
<ProjectKeyButton />
</div>
<ProjectTabs />
</Space>
<Divider style={{ margin: '0px' }} />
<div className="flex-1 !overflow-y-auto my-4">
</Layout.Header>
<Layout.Content
style={{
padding: 24,
height: 'calc(100vh - 260px)'
}}
className="bg-white overflow-y-auto"
>
{project && <ProjectTabContent />}
</div>
</Col>
</Row>
</Layout.Content>
</Layout>
</Layout>
</Card>
);
}
export default observer(Projects);
function ProjectKeyButton() {
const { projectsStore } = useStore();
const { project } = projectsStore.config;
const { message } = App.useApp();
const copyKey = () => {
if (!project || !project.projectKey) {
void message.error('Project key not found');
return;
}
void navigator.clipboard.writeText(project?.projectKey || '');
void message.success('Project key copied to clipboard');
};
return (
<Button onClick={copyKey} icon={<KeyIcon size={14} />} size="small" />
);
}

View file

@ -1,9 +0,0 @@
import React from 'react';
function CaptureRateSettings() {
return (
<div></div>
);
}
export default CaptureRateSettings;

View file

@ -1,5 +1,5 @@
import './styles/index.css';
import './styles/global.css'
import './styles/global.css';
import React from 'react';
import { createRoot } from 'react-dom/client';
import './init';
@ -7,19 +7,19 @@ import Router from './Router';
import { StoreProvider, RootStore } from './mstore';
import { HTML5Backend } from 'react-dnd-html5-backend';
import { DndProvider } from 'react-dnd';
import { ConfigProvider, theme, ThemeConfig } from 'antd';
import { ConfigProvider, App, theme, ThemeConfig } from 'antd';
import colors from 'App/theme/colors';
import { BrowserRouter } from 'react-router-dom';
import { Notification, MountPoint } from 'UI';
import {
QueryClient,
QueryClientProvider,
} from '@tanstack/react-query'
QueryClientProvider
} from '@tanstack/react-query';
// @ts-ignore
window.getCommitHash = () => console.log(window.env.COMMIT_HASH);
const queryClient = new QueryClient()
const queryClient = new QueryClient();
const customTheme: ThemeConfig = {
// algorithm: theme.compactAlgorithm,
components: {
@ -29,7 +29,7 @@ const customTheme: ThemeConfig = {
},
Segmented: {
itemSelectedBg: '#FFFFFF',
itemSelectedColor: colors['main'],
itemSelectedColor: colors['main']
},
Menu: {
colorPrimary: colors.teal,
@ -48,7 +48,7 @@ const customTheme: ThemeConfig = {
itemMarginBlock: 0,
itemPaddingInline: 50,
iconMarginInlineEnd: 14,
collapsedWidth: 180,
collapsedWidth: 180
},
Button: {
colorPrimary: colors.teal
@ -73,21 +73,22 @@ document.addEventListener('DOMContentLoaded', () => {
// @ts-ignore
const root = createRoot(container);
// const theme = window.localStorage.getItem('theme');
root.render(
<QueryClientProvider client={queryClient}>
<ConfigProvider theme={customTheme}>
<StoreProvider store={new RootStore()}>
<DndProvider backend={HTML5Backend}>
<BrowserRouter>
<Notification />
<Router />
</BrowserRouter>
</DndProvider>
<MountPoint />
</StoreProvider>
</ConfigProvider>
<ConfigProvider theme={customTheme}>
<App>
<StoreProvider store={new RootStore()}>
<DndProvider backend={HTML5Backend}>
<BrowserRouter>
<Notification />
<Router />
</BrowserRouter>
</DndProvider>
<MountPoint />
</StoreProvider>
</App>
</ConfigProvider>
</QueryClientProvider>
);
});

View file

@ -3,6 +3,7 @@ import Project from './types/project';
import GDPR from './types/gdpr';
import { GLOBAL_HAS_NO_RECORDINGS, SITE_ID_STORAGE_KEY } from 'App/constants/storageKeys';
import { projectsService } from 'App/services';
import { toast } from '.store/react-toastify-virtual-9dd0f3eae1/package';
interface Config {
project: Project | null;
@ -143,8 +144,9 @@ export default class ProjectsStore {
this.setSiteId(newSite.id);
this.active = newSite;
});
} catch (error) {
console.error('Failed to save site:', error);
return response;
} catch (error: any) {
throw error || 'An error occurred while saving the project.';
} finally {
this.setLoading(false);
}

View file

@ -1,39 +1,47 @@
import BaseService from "./BaseService";
import BaseService from './BaseService';
export default class ProjectsService extends BaseService {
fetchGDPR = async (siteId: string) => {
const r = await this.client.get(`/${siteId}/gdpr`);
return await r.json();
}
};
saveGDPR = async (siteId: string, gdprData: any) => {
const r = await this.client.post(`/${siteId}/gdpr`, gdprData);
return await r.json();
}
};
fetchList = async () => {
const r = await this.client.get('/projects');
return await r.json();
}
};
saveProject = async (projectData: any) => {
const r = await this.client.post('/projects', projectData);
saveProject = async (projectData: any): Promise<any> => {
try {
const response = await this.client.post('/projects', projectData);
return response.json();
} catch (error: any) {
if (error.response) {
const errorData = await error.response.json();
throw errorData.errors?.[0] || 'An error occurred while saving the project.';
}
return await r.json();
}
throw 'An unexpected error occurred.';
}
};
removeProject = async (projectId: string) => {
const r = await this.client.delete(`/projects/${projectId}`)
const r = await this.client.delete(`/projects/${projectId}`);
return await r.json();
}
};
updateProject = async (projectId: string, projectData: any) => {
const r = await this.client.put(`/projects/${projectId}`, projectData);
return await r.json();
}
};
}