change(ui): projects revamtp (wip)
This commit is contained in:
parent
22d04436c0
commit
869b5c00c8
16 changed files with 543 additions and 266 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,96 @@
|
|||
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 { NoContent } from 'UI';
|
||||
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';
|
||||
|
||||
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" />
|
||||
</>
|
||||
))}
|
||||
<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>
|
||||
|
||||
|
||||
<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>
|
||||
</NoContent>
|
||||
</Loader>
|
||||
}
|
||||
size="small"
|
||||
show={fields.length === 0}
|
||||
>
|
||||
<List
|
||||
loading={loading}
|
||||
dataSource={fields}
|
||||
renderItem={(field: any) => (
|
||||
<List.Item
|
||||
// disabled={deletingItem !== null && deletingItem === field.index}
|
||||
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>
|
||||
)} />
|
||||
</NoContent>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default withPageTitle('Metadata - OpenReplay Preferences')(observer(CustomFields));
|
||||
export default observer(CustomFields);
|
||||
|
|
|
|||
53
frontend/app/components/Client/Projects/ProjectList.tsx
Normal file
53
frontend/app/components/Client/Projects/ProjectList.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import React from 'react';
|
||||
import { Avatar, Input, List, Typography } from 'antd';
|
||||
import { useStore } from '@/mstore';
|
||||
import Project from '@/mstore/types/project';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { AppWindowMac, Smartphone } from 'lucide-react';
|
||||
|
||||
function ProjectList() {
|
||||
const { projectsStore } = useStore();
|
||||
const list = projectsStore.list;
|
||||
const [search, setSearch] = React.useState('');
|
||||
const config = projectsStore.config;
|
||||
console.log('config', config.pid);
|
||||
|
||||
const onSearch = (value: string) => {
|
||||
setSearch(value);
|
||||
};
|
||||
|
||||
const onProjectClick = (project: Project) => {
|
||||
projectsStore.setConfigProject(project.projectId);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Input.Search placeholder="Search" onSearch={onSearch} />
|
||||
<div className="my-3" />
|
||||
<List
|
||||
dataSource={list.filter((item) => item.name.toLowerCase().includes(search.toLowerCase()))}
|
||||
renderItem={(item: Project) => (
|
||||
<List.Item
|
||||
key={item.id}
|
||||
onClick={() => onProjectClick(item)}
|
||||
className={`!py-2 mb-2 rounded-lg cursor-pointer !border-b-0 ${config.pid == item.projectId ? 'bg-teal-light' : 'bg-white'}`}
|
||||
>
|
||||
<List.Item.Meta
|
||||
className="flex !items-center px-2 overflow-hidden"
|
||||
avatar={
|
||||
<Avatar
|
||||
className="bg-tealx-light"
|
||||
icon={item.platform === 'web' ? <AppWindowMac size={18} color="teal" /> :
|
||||
<Smartphone size={18} color="teal" />}
|
||||
/>
|
||||
}
|
||||
title={<Typography.Text className="capitalize truncate text-ellipsis">{item.name}</Typography.Text>}
|
||||
/>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(ProjectList);
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
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';
|
||||
|
||||
function ProjectTabContent() {
|
||||
const { projectsStore } = useStore();
|
||||
const { pid, tab } = projectsStore.config;
|
||||
|
||||
const tabContent: Record<string, React.ReactNode> = React.useMemo(() => {
|
||||
const project = projectsStore.list.find((p) => p.projectId == pid);
|
||||
return {
|
||||
installation: <ProjectTabTracking project={project!} />,
|
||||
captureRate: <div>Capture Rate Content</div>,
|
||||
metadata: <CustomFields />,
|
||||
tags: <ProjectTags />,
|
||||
groupKeys: <div>Group Keys Content</div>
|
||||
};
|
||||
}, [pid, projectsStore.list]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{tabContent[tab]}
|
||||
</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;
|
||||
38
frontend/app/components/Client/Projects/ProjectTabs.tsx
Normal file
38
frontend/app/components/Client/Projects/ProjectTabs.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import React from 'react';
|
||||
import { Tabs } from 'antd';
|
||||
import { useStore } from '@/mstore';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
|
||||
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="installation"
|
||||
activeKey={activeTab}
|
||||
style={{ borderBottom: 'none' }}
|
||||
onChange={onTabChange}
|
||||
items={tabItems.map((tab) => ({
|
||||
key: tab.key,
|
||||
label: tab.label
|
||||
// children: tab.content,
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(ProjectTabs);
|
||||
65
frontend/app/components/Client/Projects/ProjectTags.tsx
Normal file
65
frontend/app/components/Client/Projects/ProjectTags.tsx
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import { useStore } from '@/mstore';
|
||||
import { List, Button, Typography, Space } 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';
|
||||
|
||||
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
|
||||
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);
|
||||
70
frontend/app/components/Client/Projects/Projects.tsx
Normal file
70
frontend/app/components/Client/Projects/Projects.tsx
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import React from 'react';
|
||||
import { Button, Card, Col, Divider, Row, Space, Typography } from 'antd';
|
||||
import ProjectList from 'Components/Client/Projects/ProjectList';
|
||||
import ProjectTabs from 'Components/Client/Projects/ProjectTabs';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { useStore } from '@/mstore';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { PlusIcon } from 'lucide-react';
|
||||
import ProjectTabContent from 'Components/Client/Projects/ProjectTabContent';
|
||||
|
||||
function Projects() {
|
||||
const { projectsStore } = useStore();
|
||||
const history = useHistory();
|
||||
const { project, pid, tab } = projectsStore.config;
|
||||
|
||||
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]);
|
||||
|
||||
return (
|
||||
<Card
|
||||
title="Projects"
|
||||
classNames={{
|
||||
cover: '!rounded-lg border shadow-sm',
|
||||
body: '!p-0'
|
||||
}}
|
||||
style={{ height: 'calc(100vh - 140px)' }}
|
||||
extra={
|
||||
<Space>
|
||||
<Button type="primary" onClick={() => projectsStore.setConfigProject(undefined)} icon={<PlusIcon />}>Create
|
||||
Project</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Row className="items-stretch">
|
||||
<Col span={6} className="border-r !p-4">
|
||||
<ProjectList />
|
||||
</Col>
|
||||
<Col span={18} className="!p-4 !overflow-hidden">
|
||||
<Space className="flex justify-between">
|
||||
<Typography.Title level={5} className="capitalize !m-0">{project?.name}</Typography.Title>
|
||||
<ProjectTabs />
|
||||
</Space>
|
||||
<Divider />
|
||||
<div className="!overflow-y-auto">
|
||||
{project && <ProjectTabContent />}
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(Projects);
|
||||
73
frontend/app/components/Client/Projects/TagForm.tsx
Normal file
73
frontend/app/components/Client/Projects/TagForm.tsx
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
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 = () => {
|
||||
void tagWatchStore.updateTagName(tag.tagId, name, projectId);
|
||||
};
|
||||
|
||||
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}
|
||||
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';
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import React from 'react';
|
||||
|
||||
function CaptureRateSettings() {
|
||||
return (
|
||||
<div></div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CaptureRateSettings;
|
||||
|
|
@ -2,7 +2,13 @@ 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';
|
||||
|
||||
interface Config {
|
||||
project: Project | null;
|
||||
pid: number | undefined;
|
||||
tab: string;
|
||||
}
|
||||
|
||||
export default class ProjectsStore {
|
||||
list: Project[] = [];
|
||||
|
|
@ -11,6 +17,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);
|
||||
|
|
@ -25,42 +36,42 @@ export default class ProjectsStore {
|
|||
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 +80,7 @@ export default class ProjectsStore {
|
|||
} catch (error) {
|
||||
console.error('Failed to fetch GDPR:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
saveGDPR = async (siteId: string) => {
|
||||
if (!this.instance) return;
|
||||
|
|
@ -80,7 +91,7 @@ export default class ProjectsStore {
|
|||
} catch (error) {
|
||||
console.error('Failed to save GDPR:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
fetchList = async (siteIdFromPath?: string) => {
|
||||
this.setSitesLoading(true);
|
||||
|
|
@ -115,7 +126,7 @@ export default class ProjectsStore {
|
|||
} finally {
|
||||
this.setSitesLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
save = async (projectData: Partial<Project>) => {
|
||||
this.setLoading(true);
|
||||
|
|
@ -137,7 +148,7 @@ export default class ProjectsStore {
|
|||
} finally {
|
||||
this.setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
updateProjectRecordingStatus = (siteId: string, status: boolean) => {
|
||||
const site = this.list.find(site => site.id === siteId);
|
||||
|
|
@ -150,7 +161,7 @@ export default class ProjectsStore {
|
|||
localStorage.removeItem(GLOBAL_HAS_NO_RECORDINGS);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
removeProject = async (projectId: string) => {
|
||||
this.setLoading(true);
|
||||
|
|
@ -161,13 +172,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 +194,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';
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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