From 80462e4534bdf59a4d376d5b6531d6253aedd4fd Mon Sep 17 00:00:00 2001 From: Shekar Siri Date: Wed, 8 Jan 2025 11:50:22 +0100 Subject: [PATCH] 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 --- frontend/app/components/Client/Client.tsx | 3 +- .../Client/CustomFields/CustomFieldForm.tsx | 22 ++- .../Client/CustomFields/CustomFields.js | 137 ---------------- .../Client/CustomFields/CustomFields.tsx | 123 ++++++-------- .../Client/Projects/ProjectCaptureRate.tsx | 143 ++++++++++++++++ .../Client/Projects/ProjectForm.tsx | 153 ++++++++++++++++++ .../Client/Projects/ProjectList.tsx | 92 +++++++++++ .../Client/Projects/ProjectTabContent.tsx | 40 +++++ .../Client/Projects/ProjectTabTracking.tsx | 54 +++++++ .../Client/Projects/ProjectTabs.tsx | 42 +++++ .../Client/Projects/ProjectTags.tsx | 69 ++++++++ .../components/Client/Projects/Projects.tsx | 112 +++++++++++++ .../components/Client/Projects/TagForm.tsx | 80 +++++++++ .../app/components/Client/Projects/index.ts | 1 + .../ProjectCodeSnippet/ProjectCodeSnippet.js | 6 +- frontend/app/initialize.tsx | 48 +++--- frontend/app/layout/SideMenu.tsx | 22 --- frontend/app/layout/data.ts | 4 +- frontend/app/mstore/projectsStore.ts | 88 +++++++--- frontend/app/mstore/settingsStore.ts | 10 ++ frontend/app/mstore/tagWatchStore.ts | 23 +-- frontend/app/services/ProjectsService.ts | 30 ++-- frontend/app/services/TagWatchService.ts | 34 ++-- 23 files changed, 1005 insertions(+), 331 deletions(-) delete mode 100644 frontend/app/components/Client/CustomFields/CustomFields.js create mode 100644 frontend/app/components/Client/Projects/ProjectCaptureRate.tsx create mode 100644 frontend/app/components/Client/Projects/ProjectForm.tsx create mode 100644 frontend/app/components/Client/Projects/ProjectList.tsx create mode 100644 frontend/app/components/Client/Projects/ProjectTabContent.tsx create mode 100644 frontend/app/components/Client/Projects/ProjectTabTracking.tsx create mode 100644 frontend/app/components/Client/Projects/ProjectTabs.tsx create mode 100644 frontend/app/components/Client/Projects/ProjectTags.tsx create mode 100644 frontend/app/components/Client/Projects/Projects.tsx create mode 100644 frontend/app/components/Client/Projects/TagForm.tsx create mode 100644 frontend/app/components/Client/Projects/index.ts diff --git a/frontend/app/components/Client/Client.tsx b/frontend/app/components/Client/Client.tsx index be2bfd3cf..06991988d 100644 --- a/frontend/app/components/Client/Client.tsx +++ b/frontend/app/components/Client/Client.tsx @@ -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 { - + diff --git a/frontend/app/components/Client/CustomFields/CustomFieldForm.tsx b/frontend/app/components/Client/CustomFields/CustomFieldForm.tsx index 2b95055fa..c732b038b 100644 --- a/frontend/app/components/Client/CustomFields/CustomFieldForm.tsx +++ b/frontend/app/components/Client/CustomFields/CustomFieldForm.tsx @@ -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 = ({ 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 = ({ siteId }) => { toast.error('An error occurred while saving metadata.'); }).finally(() => { setLoading(false); - }) + }); }; return ( diff --git a/frontend/app/components/Client/CustomFields/CustomFields.js b/frontend/app/components/Client/CustomFields/CustomFields.js deleted file mode 100644 index 1b800095a..000000000 --- a/frontend/app/components/Client/CustomFields/CustomFields.js +++ /dev/null @@ -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( 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 ( -
-
-

{'Metadata'}

-
- -
-
- - - -
-
-
- - See additonal user information in sessions. - Learn more -
- - - - - {/*
*/} -
None added yet
-
- } - size="small" - show={fields.size === 0} - > -
- {fields - .filter((i) => i.index) - .map((field) => ( - <> - removeMetadata(field) } - /> - - - ))} -
-
-
-
- ); -} - -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)); diff --git a/frontend/app/components/Client/CustomFields/CustomFields.tsx b/frontend/app/components/Client/CustomFields/CustomFields.tsx index 408c78961..98844e9a5 100644 --- a/frontend/app/components/Client/CustomFields/CustomFields.tsx +++ b/frontend/app/components/Client/CustomFields/CustomFields.tsx @@ -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(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(, { + showModal(, { 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 ( -
-
-

{'Metadata'}

-
- -
-
- - - -
-
-
- - See additional user information in sessions. +
+ + Attach key-value pairs to session replays for enhanced filtering, searching, and identifying relevant user + sessions. Learn More. Learn more -
+ - - - -
None added yet
-
- } - size="small" - show={fields.length === 0} + + 0 ? '' : 'You\'ve reached the limit of 10 metadata.'} > -
- {fields - .filter((i: any) => i.index) - .map((field: any) => ( - <> - - - - ))} -
- - + +
+ {/*{remaining === 0 && }*/} + + {remaining === 0 ? 'You have reached the limit of 10 metadata.' : `${remaining}/10 Remaining for this project`} + +
+ + } /> + }} + loading={loading} + dataSource={fields} + renderItem={(field: any) => ( + handleInit(field)} + className="cursor-pointer group hover:bg-active-blue !px-4" + actions={[ +
); }; -export default withPageTitle('Metadata - OpenReplay Preferences')(observer(CustomFields)); +export default observer(CustomFields); diff --git a/frontend/app/components/Client/Projects/ProjectCaptureRate.tsx b/frontend/app/components/Client/Projects/ProjectCaptureRate.tsx new file mode 100644 index 000000000..0f0d76938 --- /dev/null +++ b/frontend/app/components/Client/Projects/ProjectCaptureRate.tsx @@ -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([]); + 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 ( + + +
+ + Define percentage of sessions you want to capture + + + + + + + + + {!conditionalCapture ? ( +
+ ) => { + 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} + /> + +
+ ) : null} + + +
+
+ {conditionalCapture && isEnterprise ? ( + + ) : null} +
+
+ ); +} + +export default observer(ProjectCaptureRate); diff --git a/frontend/app/components/Client/Projects/ProjectForm.tsx b/frontend/app/components/Client/Projects/ProjectForm.tsx new file mode 100644 index 000000000..2c940728d --- /dev/null +++ b/frontend/app/components/Client/Projects/ProjectForm.tsx @@ -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) => { + 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 ( +
+ + + + + +
+ { + projectsStore.editInstance({ platform: value }); + }} + /> +
+
+
+ + {project.exists() && ( + + )} +
+
+ ); +} + +export default observer(ProjectForm); diff --git a/frontend/app/components/Client/Projects/ProjectList.tsx b/frontend/app/components/Client/Projects/ProjectList.tsx new file mode 100644 index 000000000..75d059107 --- /dev/null +++ b/frontend/app/components/Client/Projects/ProjectList.tsx @@ -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['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: ( + + ) + })); + + return ( +
+
+ setSearch(e.target.value)} + allowClear + /> +
+
+ +
+
+ ); +}; + +export default observer(ProjectList); + +const ProjectIconWithProgress: React.FC<{ + platform: string; + progress: number; +}> = ({ platform, progress }) => ( +
+ ''} + strokeWidth={4} + strokeColor="#23959a" + /> +
+ + ) : ( + + ) + } + /> +
+
+); diff --git a/frontend/app/components/Client/Projects/ProjectTabContent.tsx b/frontend/app/components/Client/Projects/ProjectTabContent.tsx new file mode 100644 index 000000000..ead60f76f --- /dev/null +++ b/frontend/app/components/Client/Projects/ProjectTabContent.tsx @@ -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 ; + } + + const tabContent: Record = React.useMemo( + () => ({ + installation: , + captureRate: , + metadata: , + tags: + }), + [project] + ); + + return ( +
+ {tabContent[tab] || } +
+ ); +}; + +export default observer(ProjectTabContent); diff --git a/frontend/app/components/Client/Projects/ProjectTabTracking.tsx b/frontend/app/components/Client/Projects/ProjectTabTracking.tsx new file mode 100644 index 000000000..c8aad161d --- /dev/null +++ b/frontend/app/components/Client/Projects/ProjectTabTracking.tsx @@ -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 ; + case DOCUMENTATION: + return ; + } + return null; + }; + + return ( +
+ {project.platform === 'ios' ? ( + + ) : ( +
+ setActiveTab(tab)} + /> +
{renderActiveTab()}
+
+ )} +
+ ); +} + +export default ProjectTabTracking; diff --git a/frontend/app/components/Client/Projects/ProjectTabs.tsx b/frontend/app/components/Client/Projects/ProjectTabs.tsx new file mode 100644 index 000000000..802b581d4 --- /dev/null +++ b/frontend/app/components/Client/Projects/ProjectTabs.tsx @@ -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) => ( + +); + +function ProjectTabs() { + const { projectsStore } = useStore(); + const activeTab = projectsStore.config.tab; + + const tabItems = [ + { key: 'installation', label: 'Installation', content:
Installation Content
}, + { key: 'captureRate', label: 'Capture Rate', content:
Capture Rate Content
}, + { key: 'metadata', label: 'Metadata', content:
Metadata Content
}, + { key: 'tags', label: 'Tags', content:
Tags Content
}, + // { key: 'groupKeys', label: 'Group Keys', content:
Group Keys Content
} + ]; + + const onTabChange = (key: string) => { + projectsStore.setConfigTab(key); + }; + + return ( + ({ + key: tab.key, + label: tab.label + }))} + /> + ); +} + +export default observer(ProjectTabs); diff --git a/frontend/app/components/Client/Projects/ProjectTags.tsx b/frontend/app/components/Client/Projects/ProjectTags.tsx new file mode 100644 index 000000000..4d59a3ceb --- /dev/null +++ b/frontend/app/components/Client/Projects/ProjectTags.tsx @@ -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(, { + title: tag ? 'Edit Tag' : 'Add Tag' + }); + }; + + return ( +
+ + + Manage Tag Elements here. Rename tags for easy identification or delete those you no longer need. + +
    +
  • To create new tags, navigate to the Tags tab while playing a session. +
  • +
  • Use tags in OmniSearch to quickly find relevant sessions. +
  • +
+
+ } /> + }} + loading={tagWatchStore.isLoading} + dataSource={list} + renderItem={(item) => ( + } /> + ]} + onClick={() => handleInit(item)} + > + } + /> + + )} + // pagination={{ + // pageSize: 5, + // showSizeChanger: false, + // size: 'small' + // }} + /> +
+ ); +} + +export default observer(ProjectTags); diff --git a/frontend/app/components/Client/Projects/Projects.tsx b/frontend/app/components/Client/Projects/Projects.tsx new file mode 100644 index 000000000..c091af680 --- /dev/null +++ b/frontend/app/components/Client/Projects/Projects.tsx @@ -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(, { + title: 'New Project' + }); + }; + + return ( + Projects} + extra={[ + + ]} + > + + + + + + + +
+ + {project?.name} + + +
+ +
+ + {project && } + +
+
+
+ ); +} + +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 ( + + + + + + + + + ); +} + +export default TagForm; diff --git a/frontend/app/components/Client/Projects/index.ts b/frontend/app/components/Client/Projects/index.ts new file mode 100644 index 000000000..3b68dc8cf --- /dev/null +++ b/frontend/app/components/Client/Projects/index.ts @@ -0,0 +1 @@ +export { default } from './Projects'; diff --git a/frontend/app/components/shared/TrackingCodeModal/ProjectCodeSnippet/ProjectCodeSnippet.js b/frontend/app/components/shared/TrackingCodeModal/ProjectCodeSnippet/ProjectCodeSnippet.js index 925526bcf..0c8a56d02 100644 --- a/frontend/app/components/shared/TrackingCodeModal/ProjectCodeSnippet/ProjectCodeSnippet.js +++ b/frontend/app/components/shared/TrackingCodeModal/ProjectCodeSnippet/ProjectCodeSnippet.js @@ -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 (
diff --git a/frontend/app/initialize.tsx b/frontend/app/initialize.tsx index f56dc05c0..e4c5fdbd4 100644 --- a/frontend/app/initialize.tsx +++ b/frontend/app/initialize.tsx @@ -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( - - - - - - - - - - - + + + + + + + + + + + + + ); }); diff --git a/frontend/app/layout/SideMenu.tsx b/frontend/app/layout/SideMenu.tsx index afe132432..901da31f1 100644 --- a/frontend/app/layout/SideMenu.tsx +++ b/frontend/app/layout/SideMenu.tsx @@ -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), diff --git a/frontend/app/layout/data.ts b/frontend/app/layout/data.ts index c17c51bc9..7a1c92302 100644 --- a/frontend/app/layout/data.ts +++ b/frontend/app/layout/data.ts @@ -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, -] \ No newline at end of file +] diff --git a/frontend/app/mstore/projectsStore.ts b/frontend/app/mstore/projectsStore.ts index c30e1f862..272ccb1a3 100644 --- a/frontend/app/mstore/projectsStore.ts +++ b/frontend/app/mstore/projectsStore.ts @@ -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) => { + 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) => { 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) => { if (this.instance) { this.instance.gdpr.edit(gdprData); } - } + }; editInstance = (instance: Partial) => { 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) => { 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) => { 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'; + }; } diff --git a/frontend/app/mstore/settingsStore.ts b/frontend/app/mstore/settingsStore.ts index fe51173fa..2c84ac202 100644 --- a/frontend/app/mstore/settingsStore.ts +++ b/frontend/app/mstore/settingsStore.ts @@ -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) => { diff --git a/frontend/app/mstore/tagWatchStore.ts b/frontend/app/mstore/tagWatchStore.ts index 433b27451..2cc39b81c 100644 --- a/frontend/app/mstore/tagWatchStore.ts +++ b/frontend/app/mstore/tagWatchStore.ts @@ -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)); } diff --git a/frontend/app/services/ProjectsService.ts b/frontend/app/services/ProjectsService.ts index dec448903..393bcb835 100644 --- a/frontend/app/services/ProjectsService.ts +++ b/frontend/app/services/ProjectsService.ts @@ -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 => { + 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(); - } + }; } diff --git a/frontend/app/services/TagWatchService.ts b/frontend/app/services/TagWatchService.ts index f8c9ff461..ab12d3e0c 100644 --- a/frontend/app/services/TagWatchService.ts +++ b/frontend/app/services/TagWatchService.ts @@ -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 || {}; } -} \ No newline at end of file +}