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:
Shekar Siri 2025-01-08 11:50:22 +01:00 committed by GitHub
parent adf27d4cb7
commit 80462e4534
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 1005 additions and 331 deletions

View file

@ -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} />

View file

@ -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 (

View file

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

View file

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

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

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

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

View file

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

View file

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

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

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

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

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

View file

@ -0,0 +1 @@
export { default } from './Projects';

View file

@ -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">

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,
@ -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>
);
});

View file

@ -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),

View file

@ -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,
]
]

View file

@ -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';
};
}

View file

@ -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) => {

View file

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

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

View file

@ -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 || {};
}
}
}