change(ui): projects settings (#2924)
* change(ui): projects revamtp (wip) * change(ui): projects revamtp (wip) * change(ui): projects revamp - project form * change(ui): projects revamp - capture rate tab * change(ui): projects revamp - gdpr * change(ui): projects revamp - reset state * change(ui): projects revamp - progress avatar of samplerate, scroll etc., * change(ui): projects revamp - sync projects in list * change(ui): projects revamp - project menu improvements
This commit is contained in:
parent
adf27d4cb7
commit
80462e4534
23 changed files with 1005 additions and 331 deletions
|
|
@ -8,6 +8,7 @@ import Integrations from './Integrations';
|
|||
import UserView from './Users/UsersView';
|
||||
import AuditView from './Audit/AuditView';
|
||||
import Sites from './Sites';
|
||||
import Projects from './Projects';
|
||||
import CustomFields from './CustomFields';
|
||||
import Webhooks from './Webhooks';
|
||||
import Notifications from './Notifications';
|
||||
|
|
@ -31,7 +32,7 @@ export default class Client extends React.PureComponent {
|
|||
<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={Sites} />
|
||||
<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} />
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import React, { useRef, useState } from 'react';
|
||||
import { Form, Input, confirm } from 'UI';
|
||||
import { Form, Input } from 'UI';
|
||||
import styles from './customFieldForm.module.css';
|
||||
import { useStore } from 'App/mstore';
|
||||
import { useModal } from 'Components/Modal';
|
||||
import { toast } from 'react-toastify';
|
||||
import { Button } from 'antd';
|
||||
import { Button, Modal } from 'antd';
|
||||
import { Trash } from 'UI/Icons';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
|
||||
|
|
@ -23,16 +23,14 @@ const CustomFieldForm: React.FC<CustomFieldFormProps> = ({ siteId }) => {
|
|||
const exists = field?.exists();
|
||||
|
||||
const onDelete = async () => {
|
||||
if (
|
||||
await confirm({
|
||||
header: 'Metadata',
|
||||
confirmation: `Are you sure you want to remove?`
|
||||
})
|
||||
) {
|
||||
store.remove(siteId, field?.index!).then(() => {
|
||||
Modal.confirm({
|
||||
title: 'Metadata',
|
||||
content: `Are you sure you want to remove?`,
|
||||
onOk: async () => {
|
||||
await store.remove(siteId, field?.index!);
|
||||
hideModal();
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const onSave = (field: any) => {
|
||||
|
|
@ -48,7 +46,7 @@ const CustomFieldForm: React.FC<CustomFieldFormProps> = ({ siteId }) => {
|
|||
toast.error('An error occurred while saving metadata.');
|
||||
}).finally(() => {
|
||||
setLoading(false);
|
||||
})
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -1,137 +0,0 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import cn from 'classnames';
|
||||
import { connect } from 'react-redux';
|
||||
import withPageTitle from 'HOCs/withPageTitle';
|
||||
import { Button, Loader, NoContent, Icon, Tooltip, Divider } from 'UI';
|
||||
import { init, fetchList, save, remove } from 'Duck/customField';
|
||||
import SiteDropdown from 'Shared/SiteDropdown';
|
||||
import styles from './customFields.module.css';
|
||||
import CustomFieldForm from './CustomFieldForm';
|
||||
import ListItem from './ListItem';
|
||||
import { confirm } from 'UI';
|
||||
import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG';
|
||||
import { useModal } from 'App/components/Modal';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
function CustomFields(props) {
|
||||
const [currentSite, setCurrentSite] = React.useState(props.sites.get(0));
|
||||
const [deletingItem, setDeletingItem] = React.useState(null);
|
||||
const { showModal, hideModal } = useModal();
|
||||
|
||||
useEffect(() => {
|
||||
const activeSite = props.sites.get(0);
|
||||
if (!activeSite) return;
|
||||
|
||||
props.fetchList(activeSite.id);
|
||||
}, []);
|
||||
|
||||
const save = (field) => {
|
||||
props.save(currentSite.id, field).then((response) => {
|
||||
if (!response || !response.errors || response.errors.size === 0) {
|
||||
hideModal();
|
||||
toast.success('Metadata added successfully!');
|
||||
} else {
|
||||
toast.error(response.errors[0]);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const init = (field) => {
|
||||
props.init(field);
|
||||
showModal(<CustomFieldForm onClose={hideModal} onSave={save} onDelete={() => removeMetadata(field)} />);
|
||||
};
|
||||
|
||||
const onChangeSelect = ({ value }) => {
|
||||
const site = props.sites.find((s) => s.id === value.value);
|
||||
setCurrentSite(site);
|
||||
props.fetchList(site.id);
|
||||
};
|
||||
|
||||
const removeMetadata = async (field) => {
|
||||
if (
|
||||
await confirm({
|
||||
header: 'Metadata',
|
||||
confirmation: `Are you sure you want to remove?`
|
||||
})
|
||||
) {
|
||||
setDeletingItem(field.index);
|
||||
props
|
||||
.remove(currentSite.id, field.index)
|
||||
.then(() => {
|
||||
hideModal();
|
||||
})
|
||||
.finally(() => {
|
||||
setDeletingItem(null);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const { fields, loading } = props;
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-sm border p-5 ">
|
||||
<div className={cn(styles.tabHeader)}>
|
||||
<h3 className={cn(styles.tabTitle, 'text-2xl')}>{'Metadata'}</h3>
|
||||
<div style={{ marginRight: '15px' }}>
|
||||
<SiteDropdown value={currentSite && currentSite.id} onChange={onChangeSelect} />
|
||||
</div>
|
||||
<div className="ml-auto">
|
||||
<Tooltip title="You've reached the limit of 10 metadata." disabled={fields.size < 10}>
|
||||
<Button disabled={fields.size >= 10} variant="primary" onClick={() => init()}>Add Metadata</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-base text-disabled-text flex px-5 items-center my-3">
|
||||
<Icon name="info-circle-fill" className="mr-2" size={16} />
|
||||
See additonal user information in sessions.
|
||||
<a href="https://docs.openreplay.com/installation/metadata" className="link ml-1" target="_blank">Learn more</a>
|
||||
</div>
|
||||
|
||||
<Loader loading={loading}>
|
||||
<NoContent
|
||||
title={
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<AnimatedSVG name={ICONS.NO_METADATA} size={60} />
|
||||
{/* <div className="mt-4" /> */}
|
||||
<div className="text-center my-4">None added yet</div>
|
||||
</div>
|
||||
}
|
||||
size="small"
|
||||
show={fields.size === 0}
|
||||
>
|
||||
<div className={styles.list}>
|
||||
{fields
|
||||
.filter((i) => i.index)
|
||||
.map((field) => (
|
||||
<>
|
||||
<ListItem
|
||||
disabled={deletingItem && deletingItem === field.index}
|
||||
key={field._key}
|
||||
field={field}
|
||||
onEdit={init}
|
||||
// onDelete={ () => removeMetadata(field) }
|
||||
/>
|
||||
<Divider className="m-0" />
|
||||
</>
|
||||
))}
|
||||
</div>
|
||||
</NoContent>
|
||||
</Loader>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default connect(
|
||||
(state) => ({
|
||||
fields: state.getIn(['customFields', 'list']).sortBy((i) => i.index),
|
||||
field: state.getIn(['customFields', 'instance']),
|
||||
loading: state.getIn(['customFields', 'fetchRequest', 'loading']),
|
||||
sites: state.getIn(['site', 'list']),
|
||||
errors: state.getIn(['customFields', 'saveRequest', 'errors'])
|
||||
}),
|
||||
{
|
||||
init,
|
||||
fetchList,
|
||||
save,
|
||||
remove
|
||||
}
|
||||
)(withPageTitle('Metadata - OpenReplay Preferences')(CustomFields));
|
||||
|
|
@ -1,107 +1,86 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import cn from 'classnames';
|
||||
import withPageTitle from 'HOCs/withPageTitle';
|
||||
import { Button, Loader, NoContent, Icon, Tooltip, Divider } from 'UI';
|
||||
import SiteDropdown from 'Shared/SiteDropdown';
|
||||
import styles from './customFields.module.css';
|
||||
import CustomFieldForm from './CustomFieldForm';
|
||||
import ListItem from './ListItem';
|
||||
import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG';
|
||||
import { useModal } from 'App/components/Modal';
|
||||
import { useStore } from 'App/mstore';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { List, Space, Typography, Button, Tooltip } from 'antd';
|
||||
import { PencilIcon, PlusIcon, Tags } from 'lucide-react';
|
||||
import usePageTitle from '@/hooks/usePageTitle';
|
||||
import { Empty } from '.store/antd-virtual-7db13b4af6/package';
|
||||
|
||||
const CustomFields = () => {
|
||||
usePageTitle('Metadata - OpenReplay Preferences');
|
||||
const { customFieldStore: store, projectsStore } = useStore();
|
||||
const sites = projectsStore.list;
|
||||
const [currentSite, setCurrentSite] = useState(sites[0]);
|
||||
const [deletingItem, setDeletingItem] = useState<number | null>(null);
|
||||
const currentSite = projectsStore.config.project;
|
||||
const { showModal, hideModal } = useModal();
|
||||
const fields = store.list;
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const activeSite = sites[0];
|
||||
if (!activeSite) return;
|
||||
|
||||
setCurrentSite(activeSite);
|
||||
|
||||
setLoading(true);
|
||||
store.fetchList(activeSite.id).finally(() => {
|
||||
store.fetchList(currentSite?.id).finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}, [sites]);
|
||||
}, [currentSite]);
|
||||
|
||||
const handleInit = (field?: any) => {
|
||||
store.init(field);
|
||||
showModal(<CustomFieldForm siteId={currentSite.id} />, {
|
||||
showModal(<CustomFieldForm siteId={currentSite?.projectId + ''} />, {
|
||||
title: field ? 'Edit Metadata' : 'Add Metadata', right: true
|
||||
});
|
||||
};
|
||||
|
||||
const onChangeSelect = ({ value }: { value: { value: number } }) => {
|
||||
const site = sites.find((s: any) => s.id === value.value);
|
||||
setCurrentSite(site);
|
||||
|
||||
setLoading(true);
|
||||
store.fetchList(site.id).finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
};
|
||||
const remaining = 10 - fields.length;
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-sm border p-5">
|
||||
<div className={cn(styles.tabHeader)}>
|
||||
<h3 className={cn(styles.tabTitle, 'text-2xl')}>{'Metadata'}</h3>
|
||||
<div style={{ marginRight: '15px' }}>
|
||||
<SiteDropdown value={currentSite && currentSite.id} onChange={onChangeSelect} />
|
||||
</div>
|
||||
<div className="ml-auto">
|
||||
<Tooltip title="You've reached the limit of 10 metadata." disabled={fields.length < 10}>
|
||||
<Button disabled={fields.length >= 10} variant="primary" onClick={() => handleInit()}>
|
||||
Add Metadata
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-base text-disabled-text flex px-5 items-center my-3">
|
||||
<Icon name="info-circle-fill" className="mr-2" size={16} />
|
||||
See additional user information in sessions.
|
||||
<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. Learn More.
|
||||
<a href="https://docs.openreplay.com/installation/metadata" className="link ml-1" target="_blank">
|
||||
Learn more
|
||||
</a>
|
||||
</div>
|
||||
</Typography.Text>
|
||||
|
||||
<Loader loading={loading}>
|
||||
<NoContent
|
||||
title={
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<AnimatedSVG name={ICONS.NO_METADATA} size={60} />
|
||||
<div className="text-center my-4">None added yet</div>
|
||||
</div>
|
||||
}
|
||||
size="small"
|
||||
show={fields.length === 0}
|
||||
<Space>
|
||||
<Tooltip
|
||||
title={remaining > 0 ? '' : 'You\'ve reached the limit of 10 metadata.'}
|
||||
>
|
||||
<div className={styles.list}>
|
||||
{fields
|
||||
.filter((i: any) => i.index)
|
||||
.map((field: any) => (
|
||||
<>
|
||||
<ListItem
|
||||
disabled={deletingItem !== null && deletingItem === field.index}
|
||||
key={field._key}
|
||||
field={field}
|
||||
onEdit={handleInit}
|
||||
/>
|
||||
<Divider className="m-0" />
|
||||
</>
|
||||
))}
|
||||
</div>
|
||||
</NoContent>
|
||||
</Loader>
|
||||
<Button icon={<PlusIcon size={18} />} type="primary"
|
||||
disabled={remaining === 0}
|
||||
onClick={() => handleInit()}>
|
||||
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`}
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
|
||||
<List
|
||||
locale={{
|
||||
emptyText: <Empty description="None added yet" image={<AnimatedSVG name={ICONS.NO_METADATA} size={60} />} />
|
||||
}}
|
||||
loading={loading}
|
||||
dataSource={fields}
|
||||
renderItem={(field: any) => (
|
||||
<List.Item
|
||||
onClick={() => handleInit(field)}
|
||||
className="cursor-pointer group hover:bg-active-blue !px-4"
|
||||
actions={[
|
||||
<Button className="opacity-0 group-hover:!opacity-100" icon={<PencilIcon size={14} />} />
|
||||
]}
|
||||
>
|
||||
<List.Item.Meta
|
||||
title={field.key}
|
||||
avatar={<Tags size={20} />}
|
||||
/>
|
||||
</List.Item>
|
||||
)} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default withPageTitle('Metadata - OpenReplay Preferences')(observer(CustomFields));
|
||||
export default observer(CustomFields);
|
||||
|
|
|
|||
143
frontend/app/components/Client/Projects/ProjectCaptureRate.tsx
Normal file
143
frontend/app/components/Client/Projects/ProjectCaptureRate.tsx
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
import React, { useEffect, useMemo, 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) {
|
||||
setChanged(false);
|
||||
void fetchCaptureConditions(projectId);
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
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);
|
||||
153
frontend/app/components/Client/Projects/ProjectForm.tsx
Normal file
153
frontend/app/components/Client/Projects/ProjectForm.tsx
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
import React, { ChangeEvent, FormEvent, useEffect } from 'react';
|
||||
import { Icon } from 'UI';
|
||||
import Project from '@/mstore/types/project';
|
||||
import { projectStore, useStore } from '@/mstore';
|
||||
import { Modal, Segmented, Form, Input, Button } from 'antd';
|
||||
import { toast } from 'react-toastify';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
|
||||
interface Props {
|
||||
project?: Project;
|
||||
onClose?: (arg: any) => void;
|
||||
}
|
||||
|
||||
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) {
|
||||
projectsStore.initProject(props.project);
|
||||
} else {
|
||||
projectsStore.initProject({});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleEdit = ({ target: { name, value } }: ChangeEvent<HTMLInputElement>) => {
|
||||
projectsStore.editInstance({ [name]: value });
|
||||
};
|
||||
|
||||
const onSubmit = (e: FormEvent) => {
|
||||
if (!projectsStore.instance) return;
|
||||
if (projectsStore.instance.id && projectsStore.instance.exists()) {
|
||||
projectsStore
|
||||
.updateProject(projectsStore.instance.id, project)
|
||||
.then((response: any) => {
|
||||
if (!response || !response.errors || response.errors.size === 0) {
|
||||
if (onClose) {
|
||||
onClose(null);
|
||||
}
|
||||
if (!pathname.includes('onboarding')) {
|
||||
void projectsStore.fetchList();
|
||||
}
|
||||
toast.success('Project updated successfully');
|
||||
} else {
|
||||
toast.error(response.errors[0]);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
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.',
|
||||
onOk: () => {
|
||||
projectsStore.removeProject(project.id!).then(() => {
|
||||
if (onClose) {
|
||||
onClose(null);
|
||||
}
|
||||
if (project.id === projectsStore.active?.id) {
|
||||
projectsStore.setSiteId(projectStore.list[0].id!);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<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 });
|
||||
}}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(ProjectForm);
|
||||
92
frontend/app/components/Client/Projects/ProjectList.tsx
Normal file
92
frontend/app/components/Client/Projects/ProjectList.tsx
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import React from 'react';
|
||||
import { Avatar, Input, Menu, MenuProps, Progress } from 'antd';
|
||||
import { useStore } from '@/mstore';
|
||||
import Project from '@/mstore/types/project';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { AppWindowMac, Smartphone } from 'lucide-react';
|
||||
|
||||
type MenuItem = Required<MenuProps>['items'][number];
|
||||
|
||||
const ProjectList: React.FC = () => {
|
||||
const { projectsStore } = useStore();
|
||||
const [search, setSearch] = React.useState('');
|
||||
|
||||
const filteredProjects = projectsStore.list.filter((project: Project) =>
|
||||
project.name.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
const handleSearch = (value: string) => setSearch(value);
|
||||
|
||||
const onClick: MenuProps['onClick'] = (e) => {
|
||||
const pid = parseInt(e.key as string);
|
||||
projectsStore.setConfigProject(pid);
|
||||
};
|
||||
|
||||
const menuItems: MenuItem[] = filteredProjects.map((project) => ({
|
||||
key: project.id + '',
|
||||
label: project.name,
|
||||
icon: (
|
||||
<ProjectIconWithProgress
|
||||
platform={project.platform}
|
||||
progress={project.sampleRate}
|
||||
/>
|
||||
)
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col gap-4">
|
||||
<div className="px-4 mt-4">
|
||||
<Input.Search
|
||||
placeholder="Search projects"
|
||||
onSearch={handleSearch}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
allowClear
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="overflow-y-auto"
|
||||
style={{ height: 'calc(100vh - 250px)' }}
|
||||
>
|
||||
<Menu
|
||||
mode="inline"
|
||||
onClick={onClick}
|
||||
selectedKeys={[String(projectsStore.config.pid)]}
|
||||
className="w-full !bg-white !border-0"
|
||||
inlineIndent={11}
|
||||
items={menuItems}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default observer(ProjectList);
|
||||
|
||||
const ProjectIconWithProgress: React.FC<{
|
||||
platform: string;
|
||||
progress: number;
|
||||
}> = ({ platform, progress }) => (
|
||||
<div className="relative flex items-center justify-center mr-2 leading-none">
|
||||
<Progress
|
||||
type="circle"
|
||||
percent={progress}
|
||||
size={28}
|
||||
format={() => ''}
|
||||
strokeWidth={4}
|
||||
strokeColor="#23959a"
|
||||
/>
|
||||
<div className="absolute">
|
||||
<Avatar
|
||||
className="bg-tealx-light"
|
||||
size={26}
|
||||
icon={
|
||||
platform === 'web' ? (
|
||||
<AppWindowMac size={16} color="teal" />
|
||||
) : (
|
||||
<Smartphone size={16} color="teal" />
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
import React from 'react';
|
||||
import { useStore } from '@/mstore';
|
||||
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';
|
||||
|
||||
const ProjectTabContent: React.FC = () => {
|
||||
const { projectsStore } = useStore();
|
||||
const { pid, tab } = projectsStore.config;
|
||||
|
||||
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 />
|
||||
}),
|
||||
[project]
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{tabContent[tab] || <Empty description="Tab not found" />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default observer(ProjectTabContent);
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
import React from 'react';
|
||||
import Project from '@/mstore/types/project';
|
||||
import InstallMobileDocs from 'Shared/TrackingCodeModal/InstallIosDocs';
|
||||
import { Tabs } from 'UI';
|
||||
import ProjectCodeSnippet from 'Shared/TrackingCodeModal/ProjectCodeSnippet/ProjectCodeSnippet';
|
||||
import InstallDocs from 'Shared/TrackingCodeModal/InstallDocs';
|
||||
import usePageTitle from '@/hooks/usePageTitle';
|
||||
|
||||
const PROJECT = 'Using Script';
|
||||
const DOCUMENTATION = 'Using NPM';
|
||||
const TABS = [
|
||||
{ key: DOCUMENTATION, text: DOCUMENTATION },
|
||||
{ key: PROJECT, text: PROJECT }
|
||||
];
|
||||
|
||||
interface Props {
|
||||
project: Project;
|
||||
}
|
||||
|
||||
function ProjectTabTracking(props: Props) {
|
||||
usePageTitle('Installation - OpenReplay Preferences');
|
||||
const { project } = props;
|
||||
const [activeTab, setActiveTab] = React.useState(PROJECT);
|
||||
const ingestPoint = `https://${window.location.hostname}/ingest`;
|
||||
|
||||
const renderActiveTab = () => {
|
||||
switch (activeTab) {
|
||||
case PROJECT:
|
||||
return <ProjectCodeSnippet site={project} />;
|
||||
case DOCUMENTATION:
|
||||
return <InstallDocs site={project} />;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{project.platform === 'ios' ? (
|
||||
<InstallMobileDocs site={project} ingestPoint={ingestPoint} />
|
||||
) : (
|
||||
<div>
|
||||
<Tabs
|
||||
tabs={TABS}
|
||||
active={activeTab}
|
||||
onClick={(tab: string) => setActiveTab(tab)}
|
||||
/>
|
||||
<div className="p-5">{renderActiveTab()}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ProjectTabTracking;
|
||||
42
frontend/app/components/Client/Projects/ProjectTabs.tsx
Normal file
42
frontend/app/components/Client/Projects/ProjectTabs.tsx
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import React from 'react';
|
||||
import { Tabs, TabsProps } from 'antd';
|
||||
import { useStore } from '@/mstore';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
|
||||
const customTabBar: TabsProps['renderTabBar'] = (props, DefaultTabBar) => (
|
||||
<DefaultTabBar {...props} className="!mb-0" />
|
||||
);
|
||||
|
||||
function ProjectTabs() {
|
||||
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: 'groupKeys', label: 'Group Keys', content: <div>Group Keys Content</div> }
|
||||
];
|
||||
|
||||
const onTabChange = (key: string) => {
|
||||
projectsStore.setConfigTab(key);
|
||||
};
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
type="line"
|
||||
defaultActiveKey={tabItems[0].key}
|
||||
activeKey={activeTab}
|
||||
style={{ borderBottom: 'none' }}
|
||||
onChange={onTabChange}
|
||||
renderTabBar={customTabBar}
|
||||
items={tabItems.map((tab) => ({
|
||||
key: tab.key,
|
||||
label: tab.label
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(ProjectTabs);
|
||||
69
frontend/app/components/Client/Projects/ProjectTags.tsx
Normal file
69
frontend/app/components/Client/Projects/ProjectTags.tsx
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import { useStore } from '@/mstore';
|
||||
import { List, Button, Typography, Space, Empty } from 'antd';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { PencilIcon, ScanSearch } from 'lucide-react';
|
||||
import { useModal } from 'Components/ModalContext';
|
||||
import TagForm from 'Components/Client/Projects/TagForm';
|
||||
import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG';
|
||||
|
||||
function ProjectTags() {
|
||||
const { tagWatchStore, projectsStore } = useStore();
|
||||
const list = tagWatchStore.tags;
|
||||
const { openModal } = useModal();
|
||||
const { pid } = projectsStore.config;
|
||||
|
||||
useEffect(() => {
|
||||
void tagWatchStore.getTags(pid);
|
||||
}, [pid]);
|
||||
|
||||
const handleInit = (tag?: any) => {
|
||||
openModal(<TagForm tag={tag} projectId={pid!} />, {
|
||||
title: tag ? 'Edit Tag' : 'Add Tag'
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<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.
|
||||
</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>
|
||||
</li>
|
||||
<li><Typography.Text>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} />} />
|
||||
}}
|
||||
loading={tagWatchStore.isLoading}
|
||||
dataSource={list}
|
||||
renderItem={(item) => (
|
||||
<List.Item
|
||||
className="cursor-pointer group hover:bg-active-blue !px-4"
|
||||
actions={[
|
||||
<Button className="opacity-0 group-hover:!opacity-100" icon={<PencilIcon size={14} />} />
|
||||
]}
|
||||
onClick={() => handleInit(item)}
|
||||
>
|
||||
<List.Item.Meta
|
||||
title={item.name}
|
||||
avatar={<ScanSearch size={20} />}
|
||||
/>
|
||||
</List.Item>
|
||||
)}
|
||||
// pagination={{
|
||||
// pageSize: 5,
|
||||
// showSizeChanger: false,
|
||||
// size: 'small'
|
||||
// }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(ProjectTags);
|
||||
112
frontend/app/components/Client/Projects/Projects.tsx
Normal file
112
frontend/app/components/Client/Projects/Projects.tsx
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
import React from 'react';
|
||||
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';
|
||||
import { useStore } from '@/mstore';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
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';
|
||||
import Project from '@/mstore/types/project';
|
||||
|
||||
function Projects() {
|
||||
const { projectsStore } = useStore();
|
||||
const history = useHistory();
|
||||
const { project, pid, tab } = projectsStore.config;
|
||||
const { openModal, closeModal } = useModal();
|
||||
|
||||
React.useEffect(() => {
|
||||
const params = new URLSearchParams(history.location.search);
|
||||
const pid = params.get('pid');
|
||||
const tab = params.get('tab');
|
||||
projectsStore.setConfigProject(pid ? parseInt(pid) : undefined);
|
||||
projectsStore.setConfigTab(tab);
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
const params = new URLSearchParams(history.location.search);
|
||||
if (projectsStore.config.pid) {
|
||||
params.set('pid', projectsStore.config.pid + '');
|
||||
}
|
||||
|
||||
if (projectsStore.config.tab) {
|
||||
params.set('tab', projectsStore.config.tab);
|
||||
}
|
||||
history.push({ search: params.toString() });
|
||||
}, [pid, tab]);
|
||||
|
||||
const createProject = () => {
|
||||
openModal(<ProjectForm onClose={closeModal} project={new Project()} />, {
|
||||
title: 'New Project'
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
style={{ height: 'calc(100vh - 130px)' }}
|
||||
classNames={{
|
||||
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} icon={<PlusIcon size={18} />}>
|
||||
Create Project
|
||||
</Button>
|
||||
]}
|
||||
>
|
||||
<Layout>
|
||||
<Layout.Sider width={260} 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 project={project} />
|
||||
</div>
|
||||
<ProjectTabs />
|
||||
</Layout.Header>
|
||||
<Layout.Content
|
||||
style={{
|
||||
padding: 24,
|
||||
height: 'calc(100vh - 260px)'
|
||||
}}
|
||||
className="bg-white overflow-y-auto"
|
||||
>
|
||||
{project && <ProjectTabContent />}
|
||||
</Layout.Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(Projects);
|
||||
|
||||
function ProjectKeyButton({ project }: { project: Project | null }) {
|
||||
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 (
|
||||
<Tooltip title="Copy Project Key">
|
||||
<Button onClick={copyKey} icon={<KeyIcon size={14} />} size="small" />
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
80
frontend/app/components/Client/Projects/TagForm.tsx
Normal file
80
frontend/app/components/Client/Projects/TagForm.tsx
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import React from 'react';
|
||||
import { Button, Form, Input, Space, Modal } from 'antd';
|
||||
import { Trash } from 'UI/Icons';
|
||||
import { useStore } from '@/mstore';
|
||||
import { useModal } from 'Components/ModalContext';
|
||||
|
||||
interface Props {
|
||||
tag: any;
|
||||
projectId: number;
|
||||
}
|
||||
|
||||
function TagForm(props: Props) {
|
||||
const { tag, projectId } = props;
|
||||
const { tagWatchStore } = useStore();
|
||||
const [name, setName] = React.useState(tag.name);
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const { closeModal } = useModal();
|
||||
|
||||
const write = ({ target: { value, name } }: any) => {
|
||||
setName(value);
|
||||
};
|
||||
|
||||
const onDelete = async () => {
|
||||
Modal.confirm({
|
||||
title: 'Tag',
|
||||
content: `Are you sure you want to remove?`,
|
||||
onOk: async () => {
|
||||
await tagWatchStore.deleteTag(tag.tagId, projectId);
|
||||
closeModal();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const onSave = async () => {
|
||||
setLoading(true);
|
||||
tagWatchStore.updateTagName(tag.tagId, name, projectId)
|
||||
.then(() => {
|
||||
closeModal();
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Form layout="vertical">
|
||||
<Form.Item label="Name:">
|
||||
<Input
|
||||
autoFocus
|
||||
name="name"
|
||||
value={name}
|
||||
onChange={write}
|
||||
placeholder="Name"
|
||||
maxLength={50}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<div className="flex justify-between">
|
||||
<Space>
|
||||
<Button
|
||||
onClick={onSave}
|
||||
disabled={name.length === 0 || name === tag.name || loading}
|
||||
loading={loading}
|
||||
type="primary"
|
||||
className="float-left mr-2"
|
||||
>
|
||||
Update
|
||||
</Button>
|
||||
<Button onClick={closeModal}>
|
||||
Cancel
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
<Button type="text" icon={<Trash />} onClick={onDelete}></Button>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
export default TagForm;
|
||||
1
frontend/app/components/Client/Projects/index.ts
Normal file
1
frontend/app/components/Client/Projects/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default } from './Projects';
|
||||
|
|
@ -20,14 +20,14 @@ inputModeOptions.forEach((o, i) => inputModeOptionsMap[o.value] = i)
|
|||
const ProjectCodeSnippet = props => {
|
||||
const { projectsStore } = useStore();
|
||||
const site = props.site;
|
||||
const gdpr = projectsStore.instance.gdpr;
|
||||
const gdpr = site.gdpr;
|
||||
const saveGdpr = projectsStore.saveGDPR;
|
||||
const editGdpr = projectsStore.editGDPR;
|
||||
const [changed, setChanged] = useState(false)
|
||||
|
||||
const saveGDPR = () => {
|
||||
setChanged(true)
|
||||
saveGdpr(site.id);
|
||||
void saveGdpr(site.id);
|
||||
}
|
||||
|
||||
const onChangeSelect = ({ name, value }) => {
|
||||
|
|
@ -39,7 +39,7 @@ const ProjectCodeSnippet = props => {
|
|||
editGdpr({ [ name ]: checked });
|
||||
saveGDPR()
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -46,13 +46,13 @@ const customTheme: ThemeConfig = {
|
|||
itemSelectedColor: colors['teal'],
|
||||
|
||||
itemMarginBlock: 0,
|
||||
itemPaddingInline: 50,
|
||||
iconMarginInlineEnd: 14,
|
||||
collapsedWidth: 180,
|
||||
// itemPaddingInline: 50,
|
||||
// iconMarginInlineEnd: 14,
|
||||
collapsedWidth: 180
|
||||
},
|
||||
Button: {
|
||||
colorPrimary: colors.teal
|
||||
}
|
||||
},
|
||||
},
|
||||
token: {
|
||||
colorPrimary: colors.teal,
|
||||
|
|
@ -64,7 +64,8 @@ const customTheme: ThemeConfig = {
|
|||
|
||||
borderRadius: 4,
|
||||
fontSize: 14,
|
||||
fontFamily: '\'Roboto\', \'ArialMT\', \'Arial\''
|
||||
fontFamily: '\'Roboto\', \'ArialMT\', \'Arial\'',
|
||||
fontWeightStrong: 400,
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -73,21 +74,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>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -9,11 +9,7 @@ import * as routes from 'App/routes';
|
|||
import {
|
||||
CLIENT_DEFAULT_TAB,
|
||||
CLIENT_TABS,
|
||||
bookmarks,
|
||||
client,
|
||||
fflags,
|
||||
notes,
|
||||
sessions,
|
||||
withSiteId
|
||||
} from 'App/routes';
|
||||
import { MODULES } from 'Components/Client/Modules';
|
||||
|
|
@ -33,13 +29,6 @@ import { useStore } from 'App/mstore';
|
|||
|
||||
const { Text } = Typography;
|
||||
|
||||
const TabToUrlMap = {
|
||||
all: sessions() as '/sessions',
|
||||
bookmark: bookmarks() as '/bookmarks',
|
||||
notes: notes() as '/notes',
|
||||
flags: fflags() as '/feature-flags'
|
||||
};
|
||||
|
||||
interface Props extends RouteComponentProps {
|
||||
siteId?: string;
|
||||
isCollapsed?: boolean;
|
||||
|
|
@ -135,16 +124,6 @@ function SideMenu(props: Props) {
|
|||
});
|
||||
}, [isAdmin, isEnterprise, isPreferencesActive, modules, spotOnly, siteId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const currentLocation = location.pathname;
|
||||
// const tab = Object.keys(TabToUrlMap).find((tab: keyof typeof TabToUrlMap) =>
|
||||
// currentLocation.includes(TabToUrlMap[tab])
|
||||
// );
|
||||
// if (tab && tab !== searchStore.activeTab && siteId) {
|
||||
// searchStore.setActiveTab({ type: tab });
|
||||
// }
|
||||
}, [location.pathname]);
|
||||
|
||||
const menuRoutes: any = {
|
||||
[MENU.EXIT]: () =>
|
||||
props.history.push(withSiteId(routes.sessions(), siteId)),
|
||||
|
|
@ -164,7 +143,6 @@ function SideMenu(props: Props) {
|
|||
[PREFERENCES_MENU.SESSION_LISTING]: () =>
|
||||
client(CLIENT_TABS.SESSIONS_LISTING),
|
||||
[PREFERENCES_MENU.INTEGRATIONS]: () => client(CLIENT_TABS.INTEGRATIONS),
|
||||
[PREFERENCES_MENU.METADATA]: () => client(CLIENT_TABS.CUSTOM_FIELDS),
|
||||
[PREFERENCES_MENU.WEBHOOKS]: () => client(CLIENT_TABS.WEBHOOKS),
|
||||
[PREFERENCES_MENU.PROJECTS]: () => client(CLIENT_TABS.SITES),
|
||||
[PREFERENCES_MENU.ROLES_ACCESS]: () => client(CLIENT_TABS.MANAGE_ROLES),
|
||||
|
|
|
|||
|
|
@ -24,7 +24,6 @@ export const enum PREFERENCES_MENU {
|
|||
ACCOUNT = 'account',
|
||||
SESSION_LISTING = 'session-listing',
|
||||
INTEGRATIONS = 'integrations',
|
||||
METADATA = 'metadata',
|
||||
WEBHOOKS = 'webhooks',
|
||||
MODULES = 'modules',
|
||||
PROJECTS = 'projects',
|
||||
|
|
@ -131,7 +130,6 @@ export const preferences: Category[] = [
|
|||
{ label: 'Account', key: PREFERENCES_MENU.ACCOUNT, icon: 'person' },
|
||||
{ label: 'Sessions Listing', key: PREFERENCES_MENU.SESSION_LISTING, icon: 'card-list' },
|
||||
{ label: 'Integrations', key: PREFERENCES_MENU.INTEGRATIONS, icon: 'plug' },
|
||||
{ label: 'Metadata', key: PREFERENCES_MENU.METADATA, icon: 'tags' },
|
||||
{ label: 'Webhooks', key: PREFERENCES_MENU.WEBHOOKS, icon: 'link-45deg' },
|
||||
{ label: 'Modules', key: PREFERENCES_MENU.MODULES, icon: 'puzzle' },
|
||||
{ label: 'Projects', key: PREFERENCES_MENU.PROJECTS, icon: 'folder2' },
|
||||
|
|
@ -159,4 +157,4 @@ export const spotOnlyCats = [
|
|||
MENU.PREFERENCES,
|
||||
MENU.SUPPORT,
|
||||
MENU.SPOTS,
|
||||
]
|
||||
]
|
||||
|
|
|
|||
|
|
@ -2,7 +2,14 @@ import { makeAutoObservable, runInAction } from 'mobx';
|
|||
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 { projectsService } from 'App/services';
|
||||
import { toast } from '.store/react-toastify-virtual-9dd0f3eae1/package';
|
||||
|
||||
interface Config {
|
||||
project: Project | null;
|
||||
pid: number | undefined;
|
||||
tab: string;
|
||||
}
|
||||
|
||||
export default class ProjectsStore {
|
||||
list: Project[] = [];
|
||||
|
|
@ -11,6 +18,11 @@ export default class ProjectsStore {
|
|||
active: Project | null = null;
|
||||
sitesLoading = true;
|
||||
loading = false;
|
||||
config: Config = {
|
||||
project: null,
|
||||
pid: undefined,
|
||||
tab: 'installation'
|
||||
};
|
||||
|
||||
constructor() {
|
||||
const storedSiteId = localStorage.getItem(SITE_ID_STORAGE_KEY);
|
||||
|
|
@ -22,45 +34,52 @@ export default class ProjectsStore {
|
|||
return this.active ? ['ios', 'android'].includes(this.active.platform) : false;
|
||||
}
|
||||
|
||||
syncProjectInList = (project: Partial<Project>) => {
|
||||
const index = this.list.findIndex(site => site.id === project.id);
|
||||
if (index !== -1) {
|
||||
this.list[index] = this.list[index].edit(project);
|
||||
}
|
||||
}
|
||||
|
||||
getSiteId = () => {
|
||||
return {
|
||||
siteId: this.siteId,
|
||||
active: this.active,
|
||||
active: this.active
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
initProject = (project: Partial<Project>) => {
|
||||
this.instance = new Project(project);
|
||||
}
|
||||
};
|
||||
|
||||
setSitesLoading = (loading: boolean) => {
|
||||
this.sitesLoading = loading;
|
||||
}
|
||||
};
|
||||
|
||||
setLoading = (loading: boolean) => {
|
||||
this.loading = loading;
|
||||
}
|
||||
};
|
||||
|
||||
setSiteId = (siteId: string) => {
|
||||
localStorage.setItem(SITE_ID_STORAGE_KEY, siteId.toString());
|
||||
this.siteId = siteId;
|
||||
this.active = this.list.find((site) => site.id! === siteId) ?? null;
|
||||
}
|
||||
};
|
||||
|
||||
editGDPR = (gdprData: Partial<GDPR>) => {
|
||||
if (this.instance) {
|
||||
this.instance.gdpr.edit(gdprData);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
editInstance = (instance: Partial<Project>) => {
|
||||
if (!this.instance) return;
|
||||
this.instance = this.instance.edit(instance);
|
||||
}
|
||||
};
|
||||
|
||||
fetchGDPR = async (siteId: string) => {
|
||||
try {
|
||||
const response = await projectsService.fetchGDPR(siteId)
|
||||
const response = await projectsService.fetchGDPR(siteId);
|
||||
runInAction(() => {
|
||||
if (this.instance) {
|
||||
Object.assign(this.instance.gdpr, response.data);
|
||||
|
|
@ -69,7 +88,7 @@ export default class ProjectsStore {
|
|||
} catch (error) {
|
||||
console.error('Failed to fetch GDPR:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
saveGDPR = async (siteId: string) => {
|
||||
if (!this.instance) return;
|
||||
|
|
@ -77,10 +96,19 @@ export default class ProjectsStore {
|
|||
const gdprData = this.instance.gdpr.toData();
|
||||
const response = await projectsService.saveGDPR(siteId, gdprData);
|
||||
this.editGDPR(response.data);
|
||||
|
||||
try {
|
||||
this.syncProjectInList({
|
||||
id: siteId,
|
||||
gdpr: response.data
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to sync project in list:', error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to save GDPR:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
fetchList = async (siteIdFromPath?: string) => {
|
||||
this.setSitesLoading(true);
|
||||
|
|
@ -115,7 +143,7 @@ export default class ProjectsStore {
|
|||
} finally {
|
||||
this.setSitesLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
save = async (projectData: Partial<Project>) => {
|
||||
this.setLoading(true);
|
||||
|
|
@ -132,12 +160,13 @@ 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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
updateProjectRecordingStatus = (siteId: string, status: boolean) => {
|
||||
const site = this.list.find(site => site.id === siteId);
|
||||
|
|
@ -150,7 +179,7 @@ export default class ProjectsStore {
|
|||
localStorage.removeItem(GLOBAL_HAS_NO_RECORDINGS);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
removeProject = async (projectId: string) => {
|
||||
this.setLoading(true);
|
||||
|
|
@ -161,13 +190,13 @@ export default class ProjectsStore {
|
|||
if (this.siteId === projectId) {
|
||||
this.setSiteId(this.list[0].id!);
|
||||
}
|
||||
})
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Failed to remove project:', e);
|
||||
} finally {
|
||||
this.setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
updateProject = async (projectId: string, projectData: Partial<Project>) => {
|
||||
this.setLoading(true);
|
||||
|
|
@ -183,7 +212,24 @@ export default class ProjectsStore {
|
|||
} catch (error) {
|
||||
console.error('Failed to update site:', error);
|
||||
} finally {
|
||||
this.setLoading(false)
|
||||
this.setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
setConfigProject = (pid?: number) => {
|
||||
if (!pid) {
|
||||
const firstProject = this.list[0];
|
||||
this.config.pid = firstProject?.projectId ?? undefined;
|
||||
this.config.project = firstProject ?? null;
|
||||
return;
|
||||
}
|
||||
|
||||
const project = this.list.find(site => site.projectId === pid);
|
||||
this.config.pid = project?.projectId ?? undefined;
|
||||
this.config.project = project ?? null;
|
||||
};
|
||||
|
||||
setConfigTab = (tab: string | null) => {
|
||||
this.config.tab = tab ?? 'installation';
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import Webhook, { IWebhook } from 'Types/webhook';
|
|||
import { webhookService } from 'App/services';
|
||||
import { GettingStarted } from './types/gettingStarted';
|
||||
import { MENU_COLLAPSED } from 'App/constants/storageKeys';
|
||||
import { projectStore } from '@/mstore/index';
|
||||
|
||||
interface CaptureConditions {
|
||||
rate: number;
|
||||
|
|
@ -102,6 +103,15 @@ export default class SettingsStore {
|
|||
conditionalCapture: data.conditionalCapture,
|
||||
captureConditions: data.conditions,
|
||||
});
|
||||
|
||||
try {
|
||||
projectStore.syncProjectInList({
|
||||
id: projectId + '',
|
||||
sampleRate: data.rate,
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('Failed to update project in list:', e);
|
||||
}
|
||||
toast.success('Settings updated successfully');
|
||||
})
|
||||
.catch((err) => {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { makeAutoObservable } from 'mobx';
|
||||
import { tagWatchService } from 'App/services';
|
||||
import { CreateTag, Tag } from 'App/services/TagWatchService';
|
||||
import { projectStore } from '@/mstore';
|
||||
|
||||
export default class TagWatchStore {
|
||||
tags: Tag[] = [];
|
||||
|
|
@ -18,13 +19,14 @@ export default class TagWatchStore {
|
|||
this.isLoading = loading;
|
||||
};
|
||||
|
||||
getTags = async () => {
|
||||
getTags = async (projectId?: number) => {
|
||||
if (this.isLoading) {
|
||||
return;
|
||||
}
|
||||
this.setLoading(true);
|
||||
try {
|
||||
const tags: Tag[] = await tagWatchService.getTags();
|
||||
const pid = projectId || projectStore.active?.projectId;
|
||||
const tags: Tag[] = await tagWatchService.getTags(pid!);
|
||||
this.setTags(tags);
|
||||
return tags;
|
||||
} catch (e) {
|
||||
|
|
@ -34,28 +36,31 @@ export default class TagWatchStore {
|
|||
}
|
||||
};
|
||||
|
||||
createTag = async (data: CreateTag) => {
|
||||
createTag = async (data: CreateTag, projectId?: number) => {
|
||||
try {
|
||||
const tagId: number = await tagWatchService.createTag(data);
|
||||
const pid = projectId || projectStore.active?.projectId;
|
||||
const tagId: number = await tagWatchService.createTag(pid!, data);
|
||||
return tagId;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
deleteTag = async (id: number) => {
|
||||
deleteTag = async (id: number, projectId?: number) => {
|
||||
try {
|
||||
await tagWatchService.deleteTag(id);
|
||||
const pid = projectId || projectStore.active?.projectId;
|
||||
await tagWatchService.deleteTag(pid!, id);
|
||||
this.setTags(this.tags.filter((t) => t.tagId !== id));
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
updateTagName = async (id: number, name: string) => {
|
||||
updateTagName = async (id: number, name: string, projectId?: number) => {
|
||||
try {
|
||||
await tagWatchService.updateTagName(id, name);
|
||||
const updatedTag = this.tags.find((t) => t.tagId === id)
|
||||
const pid = projectId || projectStore.active?.projectId;
|
||||
await tagWatchService.updateTagName(pid!, id, name);
|
||||
const updatedTag = this.tags.find((t) => t.tagId === id);
|
||||
if (updatedTag) {
|
||||
this.setTags(this.tags.map((t) => t.tagId === id ? { ...updatedTag, name } : t));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,27 +12,27 @@ export interface Tag extends CreateTag {
|
|||
}
|
||||
|
||||
export default class TagWatchService extends BaseService {
|
||||
createTag(data: CreateTag) {
|
||||
return this.client.post('/tags', data)
|
||||
.then(r => r.json())
|
||||
.then((response: { data: any; }) => response.data || {})
|
||||
async createTag(projectId: number, data: CreateTag) {
|
||||
const r = await this.client.post(`/${projectId}/tags`, data);
|
||||
const response = await r.json();
|
||||
return response.data || {};
|
||||
}
|
||||
|
||||
getTags() {
|
||||
return this.client.get('/tags')
|
||||
.then(r => r.json())
|
||||
.then((response: { data: any; }) => response.data || {})
|
||||
async getTags(projectId: number) {
|
||||
const r = await this.client.get(`/${projectId}/tags`);
|
||||
const response = await r.json();
|
||||
return response.data || {};
|
||||
}
|
||||
|
||||
deleteTag(id: number) {
|
||||
return this.client.delete(`/tags/${id}`)
|
||||
.then(r => r.json())
|
||||
.then((response: { data: any; }) => response.data || {})
|
||||
async deleteTag(projectId: number, id: number) {
|
||||
const r = await this.client.delete(`/${projectId}/tags/${id}`);
|
||||
const response = await r.json();
|
||||
return response.data || {};
|
||||
}
|
||||
|
||||
updateTagName(id: number, name: string) {
|
||||
return this.client.put(`/tags/${id}`, { name })
|
||||
.then(r => r.json())
|
||||
.then((response: { data: any; }) => response.data || {})
|
||||
async updateTagName(projectId: number, id: number, name: string) {
|
||||
const r = await this.client.put(`/${projectId}/tags/${id}`, { name });
|
||||
const response = await r.json();
|
||||
return response.data || {};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue