change(ui): project settings updates (#1381)

* change(ui): prefs project settings

* change(api): projects settings redesign

* change(api): projects settings redesign

* change(ui): projects pagination
This commit is contained in:
Shekar Siri 2023-06-28 13:19:02 +02:00 committed by GitHub
parent 86d47b595d
commit d3a411f852
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 429 additions and 257 deletions

View file

@ -10,6 +10,7 @@ import Header from 'Components/Header/Header';
import { fetchList as fetchSiteList } from 'Duck/site';
import { withStore } from 'App/mstore';
import APIClient from './api_client';
import * as routes from './routes';
import { OB_DEFAULT_TAB, isRoute } from 'App/routes';

View file

@ -15,7 +15,6 @@ const siteIdRequiredPaths = [
'/assignments',
'/integration/sources',
'/issue_types',
'/sample_rate',
'/saved_search',
'/rehydrations',
'/sourcemaps',

View file

@ -1,10 +1,10 @@
import React, { useState, useEffect } from 'react';
import { connect } from 'react-redux';
import React, { useState, useEffect, ChangeEvent, FormEvent } from 'react';
import { connect, ConnectedProps } from 'react-redux';
import { Form, Input, Button, Icon } from 'UI';
import { save, edit, update, fetchList, remove } from 'Duck/site';
import { pushNewSite } from 'Duck/user';
import { setSiteId } from 'Duck/site';
import { withRouter } from 'react-router-dom';
import { withRouter, RouteComponentProps } from 'react-router-dom';
import styles from './siteForm.module.css';
import { confirm } from 'UI';
import { clearSearch } from 'Duck/search';
@ -12,6 +12,16 @@ import { clearSearch as clearSearchLive } from 'Duck/liveSearch';
import { withStore } from 'App/mstore';
import { toast } from 'react-toastify';
type OwnProps = {
onClose: (arg: any) => void;
mstore: any;
canDelete: boolean;
};
type PropsFromRedux = ConnectedProps<typeof connector>;
type Props = PropsFromRedux & RouteComponentProps & OwnProps;
const NewSiteForm = ({
site,
loading,
@ -29,7 +39,7 @@ const NewSiteForm = ({
mstore,
activeSiteId,
canDelete,
}) => {
}: Props) => {
const [existsError, setExistsError] = useState(false);
useEffect(() => {
@ -38,11 +48,11 @@ const NewSiteForm = ({
}
}, []);
const onSubmit = (e) => {
const onSubmit = (e: FormEvent) => {
e.preventDefault();
if (site.exists()) {
update(site, site.id).then((response) => {
update(site, site.id).then((response: any) => {
if (!response || !response.errors || response.errors.size === 0) {
onClose(null);
if (!pathname.includes('onboarding')) {
@ -54,7 +64,7 @@ const NewSiteForm = ({
}
});
} else {
save(site).then((response) => {
save(site).then((response: any) => {
if (!response || !response.errors || response.errors.size === 0) {
onClose(null);
clearSearch();
@ -78,13 +88,13 @@ const NewSiteForm = ({
remove(site.id).then(() => {
onClose(null);
if (site.id === activeSiteId) {
setSiteId(null)
setSiteId(null);
}
});
}
};
const handleEdit = ({ target: { name, value } }) => {
const handleEdit = ({ target: { name, value } }: ChangeEvent<HTMLInputElement>) => {
setExistsError(false);
edit({ [name]: value });
};
@ -128,7 +138,7 @@ const NewSiteForm = ({
);
};
const mapStateToProps = (state) => ({
const mapStateToProps = (state: any) => ({
activeSiteId: state.getIn(['site', 'active', 'id']),
site: state.getIn(['site', 'instance']),
siteList: state.getIn(['site', 'list']),
@ -136,7 +146,7 @@ const mapStateToProps = (state) => ({
canDelete: state.getIn(['site', 'list']).size > 1,
});
export default connect(mapStateToProps, {
const connector = connect(mapStateToProps, {
save,
remove,
edit,
@ -146,4 +156,6 @@ export default connect(mapStateToProps, {
setSiteId,
clearSearch,
clearSearchLive,
})(withRouter(withStore(NewSiteForm)));
});
export default connector(withRouter(withStore(NewSiteForm)));

View file

@ -1,140 +0,0 @@
import React from 'react';
import { connect } from 'react-redux';
import withPageTitle from 'HOCs/withPageTitle';
import { Loader, Button, TextLink, NoContent } from 'UI';
import { init, remove, fetchGDPR, setSiteId } from 'Duck/site';
import stl from './sites.module.css';
import NewSiteForm from './NewSiteForm';
import { confirm, PageTitle } from 'UI';
import SiteSearch from './SiteSearch';
import AddProjectButton from './AddProjectButton';
import InstallButton from './InstallButton';
import ProjectKey from './ProjectKey';
import { useModal } from 'App/components/Modal';
import { getInitials } from 'App/utils';
import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG';
import cn from 'classnames'
const NEW_SITE_FORM = 'NEW_SITE_FORM';
@connect(
(state) => ({
site: state.getIn(['site', 'instance']),
sites: state.getIn(['site', 'list']),
loading: state.getIn(['site', 'loading']),
user: state.getIn(['user', 'account']),
account: state.getIn(['user', 'account']),
}),
{
init,
remove,
fetchGDPR,
setSiteId,
}
)
@withPageTitle('Projects - OpenReplay Preferences')
class Sites extends React.PureComponent {
state = {
searchQuery: '',
};
edit = (site) => {
this.props.init(site);
this.setState({ modalContent: NEW_SITE_FORM });
};
remove = async (site) => {
if (
await confirm({
header: 'Projects',
confirmation: `Are you sure you want to delete this Project? We won't be able to record anymore sessions.`,
})
) {
this.props.remove(site.id)
this.props.setSiteId(null);
}
};
render() {
const { loading, sites, user } = this.props;
const isAdmin = user.admin || user.superAdmin;
const filteredSites = sites.filter((site) => site.name.toLowerCase().includes(this.state.searchQuery.toLowerCase()));
return (
<Loader loading={loading}>
<div className={stl.wrapper}>
<div className={cn(stl.tabHeader, 'px-5 pt-5')}>
<PageTitle title={<div className="mr-4">Projects</div>} actionButton={<TextLink icon="book" href="https://docs.openreplay.com/installation" label="Installation Docs" />} />
<div className="flex ml-auto items-center">
<AddProjectButton isAdmin={isAdmin} />
<div className="mx-2" />
<SiteSearch onChange={(value) => this.setState({ searchQuery: value })} />
</div>
</div>
<div className={stl.list}>
<NoContent
title={
<div className="flex flex-col items-center justify-center">
<AnimatedSVG name={ICONS.NO_PROJECTS} size={170} />
<div className="text-center my-4">No matching results.</div>
</div>
}
size="small"
show={!loading && filteredSites.size === 0}
>
<div className="grid grid-cols-12 gap-2 w-full items-center px-5 py-3 font-medium">
<div className="col-span-4">Project Name</div>
<div className="col-span-4">Key</div>
<div className="col-span-4"></div>
</div>
{filteredSites.map((_site) => (
<div
key={_site.key}
className="grid grid-cols-12 gap-2 w-full group hover:bg-active-blue items-center border-t px-5 py-3"
>
<div className="col-span-4">
<div className="flex items-center">
<div className="relative flex items-center justify-center w-10 h-10">
<div
className="absolute left-0 right-0 top-0 bottom-0 mx-auto w-10 h-10 rounded-full opacity-30 bg-tealx"
/>
<div className="text-lg uppercase color-tealx">
{getInitials(_site.name)}
</div>
</div>
<span className="ml-2">{_site.host}</span>
</div>
</div>
<div className="col-span-4">
<ProjectKey value={_site.projectKey} tooltip="Project key copied to clipboard" />
</div>
<div className="col-span-4 justify-self-end flex items-center">
<div className="mr-4">
<InstallButton site={_site} />
</div>
<div className="invisible group-hover:visible">
<EditButton isAdmin={isAdmin} onClick={() => this.props.init(_site)} />
</div>
</div>
</div>
))}
</NoContent>
</div>
</div>
</Loader>
);
}
}
export default Sites;
function EditButton({ isAdmin, onClick }) {
const { showModal, hideModal } = useModal();
const _onClick = () => {
onClick();
showModal(<NewSiteForm onClose={hideModal} />);
};
return <Button icon="edit" variant="text-primary" disabled={!isAdmin} onClick={_onClick} />;
}

View file

@ -0,0 +1,220 @@
import React, { useState } from 'react';
import { connect, ConnectedProps } from 'react-redux';
import { Drawer } from 'antd';
import cn from 'classnames';
import {
Loader,
Button,
TextLink,
NoContent,
Pagination,
PageTitle
} from 'UI';
import {
init,
remove,
fetchGDPR,
setSiteId
} from 'Duck/site';
import withPageTitle from 'HOCs/withPageTitle';
import stl from './sites.module.css';
import NewSiteForm from './NewSiteForm';
import SiteSearch from './SiteSearch';
import AddProjectButton from './AddProjectButton';
import InstallButton from './InstallButton';
import ProjectKey from './ProjectKey';
import { getInitials, sliceListPerPage } from 'App/utils';
import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG';
import { useModal } from 'App/components/Modal';
import CaptureRate from 'Shared/SessionSettings/components/CaptureRate';
type Project = {
id: number;
name: string;
host: string;
projectKey: string;
sampleRate: number;
};
type PropsFromRedux = ConnectedProps<typeof connector>;
const Sites = ({
loading,
sites,
user,
init
}: PropsFromRedux) => {
const [searchQuery, setSearchQuery] = useState('');
const [showCaptureRate, setShowCaptureRate] = useState(true);
const [activeProject, setActiveProject] = useState<Project | null>(null);
const [page, setPage] = useState(1);
const pageSize = 5;
const isAdmin = user.admin || user.superAdmin;
const filteredSites = sites.filter((site: { name: string }) =>
site.name.toLowerCase().includes(searchQuery.toLowerCase())
);
const { showModal, hideModal } = useModal();
const EditButton = ({
isAdmin,
onClick
}: {
isAdmin: boolean;
onClick: () => void;
}) => {
const _onClick = () => {
onClick();
showModal(<NewSiteForm onClose={hideModal} />, { right: true });
};
return (
<Button
icon='edit'
variant='text-primary'
disabled={!isAdmin}
onClick={_onClick}
/>
);
};
const captureRateClickHandler = (project: Project) => {
setActiveProject(project);
setShowCaptureRate(true);
};
const updatePage = (page: number) => {
setPage(page);
};
const ProjectItem = ({ project }: { project: Project }) => (
<div
key={project.id}
className='grid grid-cols-12 gap-2 w-full group hover:bg-active-blue items-center border-t px-5 py-3'
>
<div className='col-span-4'>
<div className='flex items-center'>
<div className='relative flex items-center justify-center w-10 h-10'>
<div
className='absolute left-0 right-0 top-0 bottom-0 mx-auto w-10 h-10 rounded-full opacity-30 bg-tealx' />
<div className='text-lg uppercase color-tealx'>
{getInitials(project.name)}
</div>
</div>
<span className='ml-2'>{project.host}</span>
</div>
</div>
<div className='col-span-3'>
<ProjectKey
value={project.projectKey}
tooltip='Project key copied to clipboard'
/>
</div>
<div className='col-span-2'>
<span
className='link'
onClick={() => captureRateClickHandler(project)}
>
{project.sampleRate}%
</span>
</div>
<div className='col-span-3 justify-self-end flex items-center'>
<div className='mr-4'>
<InstallButton site={project} />
</div>
<div className='invisible group-hover:visible'>
<EditButton isAdmin={isAdmin} onClick={() => init(project)} />
</div>
</div>
</div>
);
return (
<Loader loading={loading}>
<div className={stl.wrapper}>
<div className={cn(stl.tabHeader, 'px-5 pt-5')}>
<PageTitle
title={<div className='mr-4'>Projects</div>}
actionButton={
<TextLink
icon='book'
href='https://docs.openreplay.com/installation'
label='Installation Docs'
/>
}
/>
<div className='flex ml-auto items-center'>
<AddProjectButton isAdmin={isAdmin} />
<div className='mx-2' />
<SiteSearch onChange={(value) => setSearchQuery(value)} />
</div>
</div>
<div className={stl.list}>
<NoContent
title={
<div className='flex flex-col items-center justify-center'>
<AnimatedSVG name={ICONS.NO_PROJECTS} size={170} />
<div className='text-center text-gray-600 my-4'>
No matching results.
</div>
</div>
}
size='small'
show={!loading && filteredSites.size === 0}
>
<div className='grid grid-cols-12 gap-2 w-full items-center px-5 py-3 font-medium'>
<div className='col-span-4'>Project Name</div>
<div className='col-span-3'>Key</div>
<div className='col-span-2'>Capture Rate</div>
<div className='col-span-3'></div>
</div>
{sliceListPerPage(filteredSites, page - 1, pageSize).map(
(project: Project) => (
<ProjectItem project={project} />
)
)}
<div className='w-full flex items-center justify-center py-10'>
<Pagination
page={page}
totalPages={Math.ceil(filteredSites.size / pageSize)}
onPageChange={(page) => updatePage(page)}
limit={pageSize}
/>
</div>
</NoContent>
</div>
</div>
<Drawer
open={showCaptureRate && !!activeProject}
onClose={() => setShowCaptureRate(!showCaptureRate)}
title='Capture Rate'
closable={false}
destroyOnClose
>
{activeProject && <CaptureRate projectId={activeProject.id} />}
</Drawer>
</Loader>
);
};
const mapStateToProps = (state: any) => ({
site: state.getIn(['site', 'instance']),
sites: state.getIn(['site', 'list']),
loading: state.getIn(['site', 'loading']),
user: state.getIn(['user', 'account']),
account: state.getIn(['user', 'account'])
});
const connector = connect(mapStateToProps, {
init,
remove,
fetchGDPR,
setSiteId
});
export default connector(withPageTitle('Projects - OpenReplay Preferences')(Sites));

View file

@ -0,0 +1,68 @@
import React, { useState } from 'react';
import { Drawer, DrawerProps } from 'antd';
interface ExtendedDrawerProps extends DrawerProps {
visible: boolean;
onClose: any;
}
const DrawerComponent: React.FC<ExtendedDrawerProps> = ({
visible,
onClose,
title,
placement,
children
}) => {
return (
<Drawer
visible={visible}
onClose={onClose}
destroyOnClose
width={400}
maskClosable={false}
title={title}
placement={placement}
>
{children}
</Drawer>
);
};
const useDrawer = () => {
const [visible, setVisible] = useState(false);
const [content, setContent] = useState<React.ReactNode>(null);
const [drawerProps, setDrawerProps] = useState<DrawerProps>({
title: '',
children: null,
placement: 'right'
});
const showDrawer = (component: React.ReactNode, props: DrawerProps) => {
console.log('here');
setContent(component);
setDrawerProps(props);
setVisible(true);
};
const DrawerWrapper: React.FC = () => {
return (
<DrawerComponent
visible={visible}
onClose={() => setVisible(false)}
{...drawerProps}
>
{content}
</DrawerComponent>
);
};
return {
showDrawer,
// hideDrawer,
DrawerWrapper
};
};
export default useDrawer;

View file

@ -3,31 +3,35 @@ import ListingVisibility from './components/ListingVisibility';
import DefaultPlaying from './components/DefaultPlaying';
import DefaultTimezone from './components/DefaultTimezone';
import CaptureRate from './components/CaptureRate';
import { connect } from 'react-redux';
function SessionSettings() {
return (
<div className="bg-white box-shadow h-screen overflow-y-auto">
<div className="px-6 pt-6">
<h1 className="text-2xl">Sessions Settings</h1>
</div>
function SessionSettings({ projectId }: { projectId: number }) {
return (
<div className='bg-white box-shadow h-screen overflow-y-auto'>
<div className='px-6 pt-6'>
<h1 className='text-2xl'>Sessions Settings</h1>
</div>
<div className="p-6 border-b py-8">
<ListingVisibility />
</div>
<div className='p-6 border-b py-8'>
<ListingVisibility />
</div>
<div className="p-6 border-b py-8">
<DefaultPlaying />
</div>
<div className='p-6 border-b py-8'>
<DefaultPlaying />
</div>
<div className="p-6 border-b py-8">
<DefaultTimezone />
</div>
<div className='p-6 border-b py-8'>
<DefaultTimezone />
</div>
<div className="p-6 py-8">
<CaptureRate />
</div>
</div>
);
<div className='p-6 py-8'>
<h3 className='text-lg'>Capture Rate</h3>
<CaptureRate projectId={projectId} />
</div>
</div>
);
}
export default SessionSettings
export default connect((state: any) => ({
projectId: state.getIn(['site', 'siteId'])
}))(SessionSettings);

View file

@ -5,89 +5,96 @@ import { observer } from 'mobx-react-lite';
import { connect } from 'react-redux';
import cn from 'classnames';
function CaptureRate({ isAdmin = false }) {
const { settingsStore } = useStore();
const [changed, setChanged] = useState(false);
const [sessionSettings] = useState(settingsStore.sessionSettings);
const [loading] = useState(settingsStore.loadingCaptureRate);
type Props = {
isAdmin: boolean;
projectId: number;
}
const captureRate = sessionSettings.captureRate;
const setCaptureRate = sessionSettings.changeCaptureRate;
const captureAll = sessionSettings.captureAll;
const setCaptureAll = sessionSettings.changeCaptureAll;
function CaptureRate(props: Props) {
const { isAdmin, projectId } = props;
const { settingsStore } = useStore();
const [changed, setChanged] = useState(false);
const [sessionSettings] = useState(settingsStore.sessionSettings);
const loading = settingsStore.loadingCaptureRate;
useEffect(() => {
settingsStore.fetchCaptureRate();
}, []);
const captureRate = sessionSettings.captureRate;
const setCaptureRate = sessionSettings.changeCaptureRate;
const captureAll = sessionSettings.captureAll;
const setCaptureAll = sessionSettings.changeCaptureAll;
const changeCaptureRate = (input: string) => {
setChanged(true);
setCaptureRate(input);
};
useEffect(() => {
settingsStore.fetchCaptureRate(projectId);
}, [projectId]);
const toggleRate = () => {
const newValue = !captureAll;
setChanged(true);
if (newValue === true) {
const updateObj = {
rate: '100',
captureAll: true,
};
settingsStore.saveCaptureRate(updateObj);
} else {
setCaptureAll(newValue);
}
};
const changeCaptureRate = (input: string) => {
setChanged(true);
setCaptureRate(input);
};
return (
<Loader loading={loading}>
<h3 className="text-lg">Capture Rate</h3>
<div className="my-1">The percentage of session you want to capture</div>
<Tooltip title="You don't have permission to change." disabled={isAdmin} delay={0}>
<div className={cn('mt-2 mb-4 mr-1 flex items-center', { disabled: !isAdmin })}>
<Toggler checked={captureAll} name="test" onChange={toggleRate} />
<span className="ml-2" style={{ color: captureAll ? '#000000' : '#999' }}>
const toggleRate = () => {
const newValue = !captureAll;
setChanged(true);
if (newValue) {
const updateObj = {
rate: '100',
captureAll: true
};
settingsStore.saveCaptureRate(projectId, updateObj);
} else {
setCaptureAll(newValue);
}
};
return (
<Loader loading={loading}>
{/*<h3 className='text-lg'>Capture Rate</h3>*/}
<div className='my-1'>The percentage of session you want to capture</div>
<Tooltip title="You don't have permission to change." disabled={isAdmin} delay={0}>
<div className={cn('mt-2 mb-4 mr-1 flex items-center', { disabled: !isAdmin })}>
<Toggler checked={captureAll} name='test' onChange={toggleRate} />
<span className='ml-2' style={{ color: captureAll ? '#000000' : '#999' }}>
100%
</span>
</div>
</Tooltip>
{!captureAll && (
<div className="flex items-center">
<Tooltip title="You don't have permission to change." disabled={isAdmin} delay={0}>
<div className={cn("relative", { 'disabled' : !isAdmin })}>
<Input
type="number"
onChange={(e: React.ChangeEvent<HTMLInputElement>) => changeCaptureRate(e.target.value)}
value={captureRate.toString()}
style={{ height: '38px', width: '100px' }}
disabled={captureAll}
min={0}
max={100}
/>
<Icon className="absolute right-0 mr-6 top-0 bottom-0 m-auto" name="percent" color="gray-medium" size="18" />
</div>
</Tooltip>
<span className="mx-3">of the sessions</span>
<Button
disabled={!changed}
variant="outline"
onClick={() =>
settingsStore
.saveCaptureRate({
rate: captureRate,
captureAll,
})
.finally(() => setChanged(false))
}
>
Update
</Button>
</div>
)}
</Loader>
);
</div>
</Tooltip>
{!captureAll && (
<div className='flex items-center'>
<Tooltip title="You don't have permission to change." disabled={isAdmin} delay={0}>
<div className={cn('relative', { 'disabled': !isAdmin })}>
<Input
type='number'
onChange={(e: React.ChangeEvent<HTMLInputElement>) => changeCaptureRate(e.target.value)}
value={captureRate.toString()}
style={{ height: '38px', width: '100px' }}
disabled={captureAll}
min={0}
max={100}
/>
<Icon className='absolute right-0 mr-6 top-0 bottom-0 m-auto' name='percent' color='gray-medium'
size='18' />
</div>
</Tooltip>
<span className='mx-3'>of the sessions</span>
<Button
disabled={!changed}
variant='outline'
onClick={() =>
settingsStore
.saveCaptureRate(projectId, {
rate: captureRate,
captureAll
})
.finally(() => setChanged(false))
}
>
Update
</Button>
</div>
)}
</Loader>
);
}
export default connect((state: any) => ({
isAdmin: state.getIn(['user', 'account', 'admin']) || state.getIn(['user', 'account', 'superAdmin']),
isAdmin: state.getIn(['user', 'account', 'admin']) || state.getIn(['user', 'account', 'superAdmin'])
}))(observer(CaptureRate));

View file

@ -22,9 +22,9 @@ export default class SettingsStore {
});
}
saveCaptureRate(data: any) {
saveCaptureRate(projectId: number, data: any) {
return sessionService
.saveCaptureRate(data)
.saveCaptureRate(projectId, data)
.then((data) => data.json())
.then(({ data }) => {
this.sessionSettings.merge({
@ -38,10 +38,10 @@ export default class SettingsStore {
});
}
fetchCaptureRate(): Promise<any> {
fetchCaptureRate(projectId: number): Promise<any> {
this.loadingCaptureRate = true;
return sessionService
.fetchCaptureRate()
.fetchCaptureRate(projectId)
.then((data) => {
this.sessionSettings.merge({
captureRate: data.rate,

View file

@ -14,13 +14,13 @@ export default class SettingsService {
this.client = client || new APIClient();
}
saveCaptureRate(data: any) {
return this.client.post('/sample_rate', data);
saveCaptureRate(projectId: number, data: any) {
return this.client.post(`/${projectId}/sample_rate`, data);
}
fetchCaptureRate() {
fetchCaptureRate(projectId: number) {
return this.client
.get('/sample_rate')
.get(`/${projectId}/sample_rate`)
.then((response) => response.json())
.then((response) => response.data || 0);
}

View file

@ -23,6 +23,7 @@ export default Record({
projectKey: undefined,
trackerVersion: undefined,
saveRequestPayloads: false,
sampleRate: 0,
}, {
idKey: 'id',
methods: {