mirror of
https://git.mirrors.martin98.com/https://github.com/SigNoz/signoz
synced 2025-08-12 03:29:02 +08:00
feat: added invite team member from onboarding flow (#5410)
* feat: added invite team member from onboarding flow * feat: removed commented code and added text to strings-translations * feat: added en-gb strings * feat: added more text to strings * feat: removed commented code and app.ts changes * feat: added test case for onboarding and invite flow * feat: added invite team member logEvents * feat: resovled comments * feat: cdoe refactor and test case changes
This commit is contained in:
parent
79eef5bb91
commit
e6eaaa660a
@ -9,6 +9,7 @@ const config: Config.InitialOptions = {
|
|||||||
modulePathIgnorePatterns: ['dist'],
|
modulePathIgnorePatterns: ['dist'],
|
||||||
moduleNameMapper: {
|
moduleNameMapper: {
|
||||||
'\\.(css|less|scss)$': '<rootDir>/__mocks__/cssMock.ts',
|
'\\.(css|less|scss)$': '<rootDir>/__mocks__/cssMock.ts',
|
||||||
|
'\\.md$': '<rootDir>/__mocks__/cssMock.ts',
|
||||||
},
|
},
|
||||||
globals: {
|
globals: {
|
||||||
extensionsToTreatAsEsm: ['.ts'],
|
extensionsToTreatAsEsm: ['.ts'],
|
||||||
|
8
frontend/public/locales/en-GB/onboarding.json
Normal file
8
frontend/public/locales/en-GB/onboarding.json
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"invite_user": "Invite your teammates",
|
||||||
|
"invite": "Invite",
|
||||||
|
"skip": "Skip",
|
||||||
|
"invite_user_helper_text": "Not the right person to get started? No worries! Invite someone who can.",
|
||||||
|
"select_use_case": "Select a use-case to get started",
|
||||||
|
"get_started": "Get Started"
|
||||||
|
}
|
8
frontend/public/locales/en/onboarding.json
Normal file
8
frontend/public/locales/en/onboarding.json
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"invite_user": "Invite your teammates",
|
||||||
|
"invite": "Invite",
|
||||||
|
"skip": "Skip",
|
||||||
|
"invite_user_helper_text": "Not the right person to get started? No worries! Invite someone who can.",
|
||||||
|
"select_use_case": "Select a use-case to get started",
|
||||||
|
"get_started": "Get Started"
|
||||||
|
}
|
@ -1,16 +1,6 @@
|
|||||||
.container {
|
.container {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
// max-width: 1440px;
|
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
|
|
||||||
&.darkMode {
|
|
||||||
}
|
|
||||||
|
|
||||||
&.lightMode {
|
|
||||||
.onboardingHeader {
|
|
||||||
color: #1d1d1d;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.moduleSelectContainer {
|
.moduleSelectContainer {
|
||||||
@ -61,6 +51,8 @@
|
|||||||
width: 300px;
|
width: 300px;
|
||||||
transition: 0.3s;
|
transition: 0.3s;
|
||||||
|
|
||||||
|
background-color: #000;
|
||||||
|
|
||||||
.ant-card-body {
|
.ant-card-body {
|
||||||
padding: 0px;
|
padding: 0px;
|
||||||
}
|
}
|
||||||
@ -80,6 +72,9 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|
||||||
|
border-bottom: 1px solid #303030;
|
||||||
|
background-color: var(--bg-ink-400);
|
||||||
}
|
}
|
||||||
|
|
||||||
.moduleStyles.selected {
|
.moduleStyles.selected {
|
||||||
@ -157,3 +152,107 @@
|
|||||||
padding: 12px;
|
padding: 12px;
|
||||||
margin: 24px 0;
|
margin: 24px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.invite-member-wrapper {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
margin: 32px 0;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
|
||||||
|
.invite-member {
|
||||||
|
display: flex;
|
||||||
|
width: 480px;
|
||||||
|
height: 64px;
|
||||||
|
padding: 16px;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid var(--bg-slate-500);
|
||||||
|
background: var(--bg-ink-400);
|
||||||
|
|
||||||
|
.ant-typography {
|
||||||
|
color: var(--bg-vanilla-400);
|
||||||
|
font-family: Inter;
|
||||||
|
font-size: 14px;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
> button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skip-to-console {
|
||||||
|
color: var(--bg-vanilla-400);
|
||||||
|
font-family: Inter;
|
||||||
|
font-size: 14px;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 20px;
|
||||||
|
position: absolute;
|
||||||
|
top: 40px;
|
||||||
|
right: 40px;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--bg-vanilla-200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightMode {
|
||||||
|
.invite-member-wrapper {
|
||||||
|
.invite-member {
|
||||||
|
border: 1px solid var(--bg-vanilla-200);
|
||||||
|
background: var(--bg-vanilla-100);
|
||||||
|
|
||||||
|
.ant-typography {
|
||||||
|
color: var(--bg-slate-200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.skip-to-console {
|
||||||
|
color: var(--bg-slate-200);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--bg-slate-200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightMode {
|
||||||
|
.container {
|
||||||
|
.onboardingHeader {
|
||||||
|
color: var(--bg-slate-200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.moduleStyles {
|
||||||
|
background-color: var(--bg-vanilla-100);
|
||||||
|
}
|
||||||
|
|
||||||
|
.moduleTitleStyle {
|
||||||
|
border-bottom: 1px solid var(--bg-vanilla-300);
|
||||||
|
background-color: var(--bg-vanilla-100);
|
||||||
|
}
|
||||||
|
|
||||||
|
.moduleDesc {
|
||||||
|
background-color: var(--bg-vanilla-100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -3,15 +3,19 @@
|
|||||||
import './Onboarding.styles.scss';
|
import './Onboarding.styles.scss';
|
||||||
|
|
||||||
import { ArrowRightOutlined } from '@ant-design/icons';
|
import { ArrowRightOutlined } from '@ant-design/icons';
|
||||||
import { Button, Card, Typography } from 'antd';
|
import { Button, Card, Form, Typography } from 'antd';
|
||||||
|
import logEvent from 'api/common/logEvent';
|
||||||
import getIngestionData from 'api/settings/getIngestionData';
|
import getIngestionData from 'api/settings/getIngestionData';
|
||||||
import cx from 'classnames';
|
import cx from 'classnames';
|
||||||
import ROUTES from 'constants/routes';
|
import ROUTES from 'constants/routes';
|
||||||
import FullScreenHeader from 'container/FullScreenHeader/FullScreenHeader';
|
import FullScreenHeader from 'container/FullScreenHeader/FullScreenHeader';
|
||||||
|
import InviteUserModal from 'container/OrganizationSettings/InviteUserModal/InviteUserModal';
|
||||||
|
import { InviteMemberFormValues } from 'container/OrganizationSettings/PendingInvitesContainer';
|
||||||
import useAnalytics from 'hooks/analytics/useAnalytics';
|
import useAnalytics from 'hooks/analytics/useAnalytics';
|
||||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
|
||||||
import history from 'lib/history';
|
import history from 'lib/history';
|
||||||
import { useEffect, useState } from 'react';
|
import { UserPlus } from 'lucide-react';
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useQuery } from 'react-query';
|
import { useQuery } from 'react-query';
|
||||||
import { useEffectOnce } from 'react-use';
|
import { useEffectOnce } from 'react-use';
|
||||||
|
|
||||||
@ -100,9 +104,9 @@ export default function Onboarding(): JSX.Element {
|
|||||||
const [selectedModuleSteps, setSelectedModuleSteps] = useState(APM_STEPS);
|
const [selectedModuleSteps, setSelectedModuleSteps] = useState(APM_STEPS);
|
||||||
const [activeStep, setActiveStep] = useState(1);
|
const [activeStep, setActiveStep] = useState(1);
|
||||||
const [current, setCurrent] = useState(0);
|
const [current, setCurrent] = useState(0);
|
||||||
const isDarkMode = useIsDarkMode();
|
|
||||||
const { trackEvent } = useAnalytics();
|
const { trackEvent } = useAnalytics();
|
||||||
const { location } = history;
|
const { location } = history;
|
||||||
|
const { t } = useTranslation(['onboarding']);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
selectedDataSource,
|
selectedDataSource,
|
||||||
@ -279,13 +283,38 @@ export default function Onboarding(): JSX.Element {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const [form] = Form.useForm<InviteMemberFormValues>();
|
||||||
|
const [
|
||||||
|
isInviteTeamMemberModalOpen,
|
||||||
|
setIsInviteTeamMemberModalOpen,
|
||||||
|
] = useState<boolean>(false);
|
||||||
|
|
||||||
|
const toggleModal = useCallback(
|
||||||
|
(value: boolean): void => {
|
||||||
|
setIsInviteTeamMemberModalOpen(value);
|
||||||
|
if (!value) {
|
||||||
|
form.resetFields();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[form],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cx('container', isDarkMode ? 'darkMode' : 'lightMode')}>
|
<div className="container">
|
||||||
{activeStep === 1 && (
|
{activeStep === 1 && (
|
||||||
<>
|
<div className="onboarding-page">
|
||||||
|
<div
|
||||||
|
onClick={(): void => {
|
||||||
|
logEvent('Onboarding V2: Skip Button Clicked', {});
|
||||||
|
history.push('/');
|
||||||
|
}}
|
||||||
|
className="skip-to-console"
|
||||||
|
>
|
||||||
|
{t('skip')}
|
||||||
|
</div>
|
||||||
<FullScreenHeader />
|
<FullScreenHeader />
|
||||||
<div className="onboardingHeader">
|
<div className="onboardingHeader">
|
||||||
<h1> Select a use-case to get started</h1>
|
<h1>{t('select_use_case')}</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className="modulesContainer">
|
<div className="modulesContainer">
|
||||||
<div className="moduleContainerRowStyles">
|
<div className="moduleContainerRowStyles">
|
||||||
@ -298,26 +327,13 @@ export default function Onboarding(): JSX.Element {
|
|||||||
'moduleStyles',
|
'moduleStyles',
|
||||||
selectedModule.id === selectedUseCase.id ? 'selected' : '',
|
selectedModule.id === selectedUseCase.id ? 'selected' : '',
|
||||||
)}
|
)}
|
||||||
style={{
|
|
||||||
backgroundColor: isDarkMode ? '#000' : '#FFF',
|
|
||||||
}}
|
|
||||||
key={selectedUseCase.id}
|
key={selectedUseCase.id}
|
||||||
onClick={(): void => handleModuleSelect(selectedUseCase)}
|
onClick={(): void => handleModuleSelect(selectedUseCase)}
|
||||||
>
|
>
|
||||||
<Typography.Title
|
<Typography.Title className="moduleTitleStyle" level={4}>
|
||||||
className="moduleTitleStyle"
|
|
||||||
level={4}
|
|
||||||
style={{
|
|
||||||
borderBottom: isDarkMode ? '1px solid #303030' : '1px solid #ddd',
|
|
||||||
backgroundColor: isDarkMode ? '#141414' : '#FFF',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{selectedUseCase.title}
|
{selectedUseCase.title}
|
||||||
</Typography.Title>
|
</Typography.Title>
|
||||||
<Typography.Paragraph
|
<Typography.Paragraph className="moduleDesc">
|
||||||
className="moduleDesc"
|
|
||||||
style={{ backgroundColor: isDarkMode ? '#000' : '#FFF' }}
|
|
||||||
>
|
|
||||||
{selectedUseCase.desc}
|
{selectedUseCase.desc}
|
||||||
</Typography.Paragraph>
|
</Typography.Paragraph>
|
||||||
</Card>
|
</Card>
|
||||||
@ -327,10 +343,31 @@ export default function Onboarding(): JSX.Element {
|
|||||||
</div>
|
</div>
|
||||||
<div className="continue-to-next-step">
|
<div className="continue-to-next-step">
|
||||||
<Button type="primary" icon={<ArrowRightOutlined />} onClick={handleNext}>
|
<Button type="primary" icon={<ArrowRightOutlined />} onClick={handleNext}>
|
||||||
Get Started
|
{t('get_started')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
<div className="invite-member-wrapper">
|
||||||
|
<Typography.Text className="helper-text">
|
||||||
|
{t('invite_user_helper_text')}
|
||||||
|
</Typography.Text>
|
||||||
|
<div className="invite-member">
|
||||||
|
<Typography.Text>{t('invite_user')}</Typography.Text>
|
||||||
|
<Button
|
||||||
|
onClick={(): void => {
|
||||||
|
logEvent('Onboarding V2: Invite Member', {
|
||||||
|
module: selectedModule?.id,
|
||||||
|
page: 'homepage',
|
||||||
|
});
|
||||||
|
setIsInviteTeamMemberModalOpen(true);
|
||||||
|
}}
|
||||||
|
icon={<UserPlus size={16} />}
|
||||||
|
type="primary"
|
||||||
|
>
|
||||||
|
{t('invite')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeStep > 1 && (
|
{activeStep > 1 && (
|
||||||
@ -345,9 +382,15 @@ export default function Onboarding(): JSX.Element {
|
|||||||
}}
|
}}
|
||||||
selectedModule={selectedModule}
|
selectedModule={selectedModule}
|
||||||
selectedModuleSteps={selectedModuleSteps}
|
selectedModuleSteps={selectedModuleSteps}
|
||||||
|
setIsInviteTeamMemberModalOpen={setIsInviteTeamMemberModalOpen}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<InviteUserModal
|
||||||
|
form={form}
|
||||||
|
isInviteTeamMemberModalOpen={isInviteTeamMemberModalOpen}
|
||||||
|
toggleModal={toggleModal}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,127 @@
|
|||||||
|
/* eslint-disable sonarjs/no-identical-functions */
|
||||||
|
import { queryByAttribute, waitFor } from '@testing-library/react';
|
||||||
|
import { fireEvent, render, screen, within } from 'tests/test-utils';
|
||||||
|
|
||||||
|
import OnboardingContainer from '..';
|
||||||
|
import { OnboardingContextProvider } from '../context/OnboardingContext';
|
||||||
|
|
||||||
|
jest.mock('react-markdown', () => jest.fn());
|
||||||
|
jest.mock('rehype-raw', () => jest.fn());
|
||||||
|
|
||||||
|
const successNotification = jest.fn();
|
||||||
|
jest.mock('hooks/useNotifications', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
useNotifications: jest.fn(() => ({
|
||||||
|
notifications: {
|
||||||
|
success: successNotification,
|
||||||
|
error: jest.fn(),
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
window.analytics = {
|
||||||
|
track: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('Onboarding invite team member flow', () => {
|
||||||
|
it('initial render and get started page', async () => {
|
||||||
|
const { findByText } = render(
|
||||||
|
<OnboardingContextProvider>
|
||||||
|
<OnboardingContainer />
|
||||||
|
</OnboardingContextProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(findByText('SigNoz')).resolves.toBeInTheDocument();
|
||||||
|
|
||||||
|
// Check all the option present
|
||||||
|
const monitoringTexts = [
|
||||||
|
{
|
||||||
|
title: 'Application Monitoring',
|
||||||
|
description:
|
||||||
|
'Monitor application metrics like p99 latency, error rates, external API calls, and db calls.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Logs Management',
|
||||||
|
description:
|
||||||
|
'Easily filter and query logs, build dashboards and alerts based on attributes in logs',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Infrastructure Monitoring',
|
||||||
|
description:
|
||||||
|
'Monitor Kubernetes infrastructure metrics, hostmetrics, or metrics of any third-party integration',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'AWS Monitoring',
|
||||||
|
description:
|
||||||
|
'Monitor your traces, logs and metrics for AWS services like EC2, ECS, EKS etc.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Azure Monitoring',
|
||||||
|
description:
|
||||||
|
'Monitor your traces, logs and metrics for Azure services like AKS, Container Apps, App Service etc.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
monitoringTexts.forEach(async ({ title, description }) => {
|
||||||
|
await expect(findByText(title)).resolves.toBeInTheDocument();
|
||||||
|
await expect(findByText(description)).resolves.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Invite team member button
|
||||||
|
await expect(findByText('invite')).resolves.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('invite team member', async () => {
|
||||||
|
const { findByText } = render(
|
||||||
|
<OnboardingContextProvider>
|
||||||
|
<OnboardingContainer />
|
||||||
|
</OnboardingContextProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Invite team member button
|
||||||
|
const inviteBtn = await findByText('invite');
|
||||||
|
expect(inviteBtn).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(inviteBtn);
|
||||||
|
const inviteModal = await screen.findByTestId('invite-team-members-modal');
|
||||||
|
expect(inviteModal).toBeInTheDocument();
|
||||||
|
|
||||||
|
const inviteModalTitle = await within(inviteModal).findAllByText(
|
||||||
|
/invite_team_members/i,
|
||||||
|
);
|
||||||
|
expect(inviteModalTitle[0]).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Verify that the invite modal contains an input field for entering the email address
|
||||||
|
const emailInput = within(inviteModal).getByText('email_address');
|
||||||
|
expect(emailInput).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Verify that the invite modal contains a dropdown for selecting the role
|
||||||
|
const role = within(inviteModal).getByText('role');
|
||||||
|
expect(role).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Verify that the invite modal contains a button for sending the invitation
|
||||||
|
const sendButton = within(inviteModal).getByTestId(
|
||||||
|
'invite-team-members-button',
|
||||||
|
);
|
||||||
|
expect(sendButton).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Verify that the invite modal sends the invitation
|
||||||
|
fireEvent.input(queryByAttribute('id', inviteModal, 'members_0_email')!, {
|
||||||
|
target: { value: 'test@example.com' },
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
queryByAttribute('value', inviteModal, 'test@example.com'),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
|
||||||
|
const roleDropdown = within(inviteModal).getByTestId('role-select');
|
||||||
|
expect(roleDropdown).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(sendButton);
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(successNotification).toHaveBeenCalledWith({
|
||||||
|
message: 'Invite sent successfully',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
@ -39,6 +39,9 @@
|
|||||||
.steps-container {
|
.steps-container {
|
||||||
width: 20%;
|
width: 20%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
.steps-container-header {
|
.steps-container-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -69,6 +72,30 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.invite-user-btn {
|
||||||
|
display: flex;
|
||||||
|
width: 170px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 6px;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
border-radius: 2px;
|
||||||
|
margin-bottom: 31px;
|
||||||
|
border: 1px solid var(--bg-slate-400);
|
||||||
|
background: var(--bg-ink-300);
|
||||||
|
box-shadow: none;
|
||||||
|
|
||||||
|
.ant-typography {
|
||||||
|
color: var(--bg-vanilla-400);
|
||||||
|
font-family: Inter;
|
||||||
|
font-size: 12px;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 10px;
|
||||||
|
letter-spacing: 0.12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.selected-step-content {
|
.selected-step-content {
|
||||||
@ -196,5 +223,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.invite-user-btn {
|
||||||
|
border: 1px solid var(--bg-vanilla-300);
|
||||||
|
background: var(--bg-vanilla-100);
|
||||||
|
|
||||||
|
.ant-typography {
|
||||||
|
color: var(--bg-slate-200);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -18,8 +18,8 @@ import { hasFrameworks } from 'container/OnboardingContainer/utils/dataSourceUti
|
|||||||
import useAnalytics from 'hooks/analytics/useAnalytics';
|
import useAnalytics from 'hooks/analytics/useAnalytics';
|
||||||
import history from 'lib/history';
|
import history from 'lib/history';
|
||||||
import { isEmpty, isNull } from 'lodash-es';
|
import { isEmpty, isNull } from 'lodash-es';
|
||||||
import { HelpCircle } from 'lucide-react';
|
import { HelpCircle, UserPlus } from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { SetStateAction, useState } from 'react';
|
||||||
|
|
||||||
import { useOnboardingContext } from '../../context/OnboardingContext';
|
import { useOnboardingContext } from '../../context/OnboardingContext';
|
||||||
import {
|
import {
|
||||||
@ -33,6 +33,7 @@ interface ModuleStepsContainerProps {
|
|||||||
onReselectModule: any;
|
onReselectModule: any;
|
||||||
selectedModule: ModuleProps;
|
selectedModule: ModuleProps;
|
||||||
selectedModuleSteps: SelectedModuleStepProps[];
|
selectedModuleSteps: SelectedModuleStepProps[];
|
||||||
|
setIsInviteTeamMemberModalOpen: (value: SetStateAction<boolean>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MetaDataProps {
|
interface MetaDataProps {
|
||||||
@ -63,6 +64,7 @@ export default function ModuleStepsContainer({
|
|||||||
onReselectModule,
|
onReselectModule,
|
||||||
selectedModule,
|
selectedModule,
|
||||||
selectedModuleSteps,
|
selectedModuleSteps,
|
||||||
|
setIsInviteTeamMemberModalOpen,
|
||||||
}: ModuleStepsContainerProps): JSX.Element {
|
}: ModuleStepsContainerProps): JSX.Element {
|
||||||
const {
|
const {
|
||||||
activeStep,
|
activeStep,
|
||||||
@ -409,32 +411,47 @@ Thanks
|
|||||||
return (
|
return (
|
||||||
<div className="onboarding-module-steps">
|
<div className="onboarding-module-steps">
|
||||||
<div className="steps-container">
|
<div className="steps-container">
|
||||||
<div className="steps-container-header">
|
<div>
|
||||||
<div className="brand-logo" onClick={handleLogoClick}>
|
<div className="steps-container-header">
|
||||||
<img src="/Logos/signoz-brand-logo.svg" alt="SigNoz" />
|
<div className="brand-logo" onClick={handleLogoClick}>
|
||||||
|
<img src="/Logos/signoz-brand-logo.svg" alt="SigNoz" />
|
||||||
|
|
||||||
<div className="brand-logo-name">SigNoz</div>
|
<div className="brand-logo-name">SigNoz</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Space style={{ marginBottom: '24px' }}>
|
||||||
|
<Button
|
||||||
|
style={{ display: 'flex', alignItems: 'center' }}
|
||||||
|
type="default"
|
||||||
|
icon={<LeftCircleOutlined />}
|
||||||
|
onClick={onReselectModule}
|
||||||
|
>
|
||||||
|
{selectedModule.title}
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
<Steps
|
||||||
|
direction="vertical"
|
||||||
|
size="small"
|
||||||
|
status="finish"
|
||||||
|
current={current}
|
||||||
|
items={selectedModuleSteps}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<Button
|
||||||
<Space style={{ marginBottom: '24px' }}>
|
onClick={(): void => {
|
||||||
<Button
|
logEvent('Onboarding V2: Invite Member', {
|
||||||
style={{ display: 'flex', alignItems: 'center' }}
|
module: selectedModule?.id,
|
||||||
type="default"
|
page: 'sidebar',
|
||||||
icon={<LeftCircleOutlined />}
|
});
|
||||||
onClick={onReselectModule}
|
setIsInviteTeamMemberModalOpen(true);
|
||||||
>
|
}}
|
||||||
{selectedModule.title}
|
icon={<UserPlus size={16} />}
|
||||||
</Button>
|
className="invite-user-btn"
|
||||||
</Space>
|
>
|
||||||
|
Invite teammates
|
||||||
<Steps
|
</Button>
|
||||||
direction="vertical"
|
|
||||||
size="small"
|
|
||||||
status="finish"
|
|
||||||
current={current}
|
|
||||||
items={selectedModuleSteps}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="selected-step-content">
|
<div className="selected-step-content">
|
||||||
|
@ -50,7 +50,7 @@ function InviteTeamMembers({ form, onFinish }: Props): JSX.Element {
|
|||||||
<Input placeholder={t('name_placeholder')} />
|
<Input placeholder={t('name_placeholder')} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name={[name, 'role']} initialValue="VIEWER">
|
<Form.Item name={[name, 'role']} initialValue="VIEWER">
|
||||||
<SelectDrawer>
|
<SelectDrawer data-testid="role-select">
|
||||||
<Select.Option value="ADMIN">ADMIN</Select.Option>
|
<Select.Option value="ADMIN">ADMIN</Select.Option>
|
||||||
<Select.Option value="VIEWER">VIEWER</Select.Option>
|
<Select.Option value="VIEWER">VIEWER</Select.Option>
|
||||||
<Select.Option value="EDITOR">EDITOR</Select.Option>
|
<Select.Option value="EDITOR">EDITOR</Select.Option>
|
||||||
|
@ -0,0 +1,182 @@
|
|||||||
|
import { Button, Form, Modal } from 'antd';
|
||||||
|
import { FormInstance } from 'antd/lib';
|
||||||
|
import getPendingInvites from 'api/user/getPendingInvites';
|
||||||
|
import sendInvite from 'api/user/sendInvite';
|
||||||
|
import ROUTES from 'constants/routes';
|
||||||
|
import { useNotifications } from 'hooks/useNotifications';
|
||||||
|
import {
|
||||||
|
Dispatch,
|
||||||
|
SetStateAction,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useQuery } from 'react-query';
|
||||||
|
import { useSelector } from 'react-redux';
|
||||||
|
import { AppState } from 'store/reducers';
|
||||||
|
import { PayloadProps } from 'types/api/user/getPendingInvites';
|
||||||
|
import AppReducer from 'types/reducer/app';
|
||||||
|
import { ROLES } from 'types/roles';
|
||||||
|
|
||||||
|
import InviteTeamMembers from '../InviteTeamMembers';
|
||||||
|
import { InviteMemberFormValues } from '../PendingInvitesContainer';
|
||||||
|
|
||||||
|
export interface InviteUserModalProps {
|
||||||
|
isInviteTeamMemberModalOpen: boolean;
|
||||||
|
toggleModal: (value: boolean) => void;
|
||||||
|
form: FormInstance<InviteMemberFormValues>;
|
||||||
|
setDataSource?: Dispatch<SetStateAction<DataProps[]>>;
|
||||||
|
shouldCallApi?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DataProps {
|
||||||
|
key: number;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
accessLevel: ROLES;
|
||||||
|
inviteLink: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function InviteUserModal(props: InviteUserModalProps): JSX.Element {
|
||||||
|
const {
|
||||||
|
isInviteTeamMemberModalOpen,
|
||||||
|
toggleModal,
|
||||||
|
form,
|
||||||
|
setDataSource,
|
||||||
|
shouldCallApi = false,
|
||||||
|
} = props;
|
||||||
|
const { notifications } = useNotifications();
|
||||||
|
const { t } = useTranslation(['organizationsettings', 'common']);
|
||||||
|
const { user } = useSelector<AppState, AppReducer>((state) => state.app);
|
||||||
|
const [isInvitingMembers, setIsInvitingMembers] = useState<boolean>(false);
|
||||||
|
const [modalForm] = Form.useForm<InviteMemberFormValues>(form);
|
||||||
|
|
||||||
|
const getPendingInvitesResponse = useQuery({
|
||||||
|
queryFn: getPendingInvites,
|
||||||
|
queryKey: ['getPendingInvites', user?.accessJwt],
|
||||||
|
enabled: shouldCallApi,
|
||||||
|
});
|
||||||
|
|
||||||
|
const getParsedInviteData = useCallback(
|
||||||
|
(payload: PayloadProps = []) =>
|
||||||
|
payload?.map((data) => ({
|
||||||
|
key: data.createdAt,
|
||||||
|
name: data?.name,
|
||||||
|
email: data.email,
|
||||||
|
accessLevel: data.role,
|
||||||
|
inviteLink: `${window.location.origin}${ROUTES.SIGN_UP}?token=${data.token}`,
|
||||||
|
})),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
getPendingInvitesResponse.status === 'success' &&
|
||||||
|
getPendingInvitesResponse?.data?.payload
|
||||||
|
) {
|
||||||
|
const data = getParsedInviteData(
|
||||||
|
getPendingInvitesResponse?.data?.payload || [],
|
||||||
|
);
|
||||||
|
setDataSource?.(data);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
getParsedInviteData,
|
||||||
|
getPendingInvitesResponse?.data?.payload,
|
||||||
|
getPendingInvitesResponse.status,
|
||||||
|
setDataSource,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const onInviteClickHandler = useCallback(
|
||||||
|
async (values: InviteMemberFormValues): Promise<void> => {
|
||||||
|
try {
|
||||||
|
setIsInvitingMembers?.(true);
|
||||||
|
values?.members?.forEach(
|
||||||
|
async (member): Promise<void> => {
|
||||||
|
const { error, statusCode } = await sendInvite({
|
||||||
|
email: member.email,
|
||||||
|
name: member?.name,
|
||||||
|
role: member.role,
|
||||||
|
frontendBaseUrl: window.location.origin,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (statusCode !== 200) {
|
||||||
|
notifications.error({
|
||||||
|
message:
|
||||||
|
error ||
|
||||||
|
t('something_went_wrong', {
|
||||||
|
ns: 'common',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} else if (statusCode === 200) {
|
||||||
|
notifications.success({
|
||||||
|
message: 'Invite sent successfully',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
setTimeout(async () => {
|
||||||
|
const { data, status } = await getPendingInvitesResponse.refetch();
|
||||||
|
if (status === 'success' && data.payload) {
|
||||||
|
setDataSource?.(getParsedInviteData(data?.payload || []));
|
||||||
|
}
|
||||||
|
setIsInvitingMembers?.(false);
|
||||||
|
toggleModal(false);
|
||||||
|
}, 2000);
|
||||||
|
} catch (error) {
|
||||||
|
notifications.error({
|
||||||
|
message: t('something_went_wrong', {
|
||||||
|
ns: 'common',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
getParsedInviteData,
|
||||||
|
getPendingInvitesResponse,
|
||||||
|
notifications,
|
||||||
|
setDataSource,
|
||||||
|
setIsInvitingMembers,
|
||||||
|
t,
|
||||||
|
toggleModal,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title={t('invite_team_members')}
|
||||||
|
open={isInviteTeamMemberModalOpen}
|
||||||
|
onCancel={(): void => toggleModal(false)}
|
||||||
|
centered
|
||||||
|
data-testid="invite-team-members-modal"
|
||||||
|
destroyOnClose
|
||||||
|
footer={[
|
||||||
|
<Button key="back" onClick={(): void => toggleModal(false)} type="default">
|
||||||
|
{t('cancel', {
|
||||||
|
ns: 'common',
|
||||||
|
})}
|
||||||
|
</Button>,
|
||||||
|
<Button
|
||||||
|
key={t('invite_team_members').toString()}
|
||||||
|
onClick={modalForm.submit}
|
||||||
|
data-testid="invite-team-members-button"
|
||||||
|
type="primary"
|
||||||
|
disabled={isInvitingMembers}
|
||||||
|
loading={isInvitingMembers}
|
||||||
|
>
|
||||||
|
{t('invite_team_members')}
|
||||||
|
</Button>,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<InviteTeamMembers form={modalForm} onFinish={onInviteClickHandler} />
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
InviteUserModal.defaultProps = {
|
||||||
|
setDataSource: (): void => {},
|
||||||
|
shouldCallApi: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default InviteUserModal;
|
@ -1,9 +1,8 @@
|
|||||||
import { PlusOutlined } from '@ant-design/icons';
|
import { PlusOutlined } from '@ant-design/icons';
|
||||||
import { Button, Form, Modal, Space, Typography } from 'antd';
|
import { Button, Form, Space, Typography } from 'antd';
|
||||||
import { ColumnsType } from 'antd/lib/table';
|
import { ColumnsType } from 'antd/lib/table';
|
||||||
import deleteInvite from 'api/user/deleteInvite';
|
import deleteInvite from 'api/user/deleteInvite';
|
||||||
import getPendingInvites from 'api/user/getPendingInvites';
|
import getPendingInvites from 'api/user/getPendingInvites';
|
||||||
import sendInvite from 'api/user/sendInvite';
|
|
||||||
import { ResizeTable } from 'components/ResizeTable';
|
import { ResizeTable } from 'components/ResizeTable';
|
||||||
import { INVITE_MEMBERS_HASH } from 'constants/app';
|
import { INVITE_MEMBERS_HASH } from 'constants/app';
|
||||||
import ROUTES from 'constants/routes';
|
import ROUTES from 'constants/routes';
|
||||||
@ -19,7 +18,7 @@ import { PayloadProps } from 'types/api/user/getPendingInvites';
|
|||||||
import AppReducer from 'types/reducer/app';
|
import AppReducer from 'types/reducer/app';
|
||||||
import { ROLES } from 'types/roles';
|
import { ROLES } from 'types/roles';
|
||||||
|
|
||||||
import InviteTeamMembers from '../InviteTeamMembers';
|
import InviteUserModal from '../InviteUserModal/InviteUserModal';
|
||||||
import { TitleWrapper } from './styles';
|
import { TitleWrapper } from './styles';
|
||||||
|
|
||||||
function PendingInvitesContainer(): JSX.Element {
|
function PendingInvitesContainer(): JSX.Element {
|
||||||
@ -28,7 +27,6 @@ function PendingInvitesContainer(): JSX.Element {
|
|||||||
setIsInviteTeamMemberModalOpen,
|
setIsInviteTeamMemberModalOpen,
|
||||||
] = useState<boolean>(false);
|
] = useState<boolean>(false);
|
||||||
const [form] = Form.useForm<InviteMemberFormValues>();
|
const [form] = Form.useForm<InviteMemberFormValues>();
|
||||||
const [isInvitingMembers, setIsInvitingMembers] = useState<boolean>(false);
|
|
||||||
const { t } = useTranslation(['organizationsettings', 'common']);
|
const { t } = useTranslation(['organizationsettings', 'common']);
|
||||||
const [state, setText] = useCopyToClipboard();
|
const [state, setText] = useCopyToClipboard();
|
||||||
const { notifications } = useNotifications();
|
const { notifications } = useNotifications();
|
||||||
@ -191,83 +189,15 @@ function PendingInvitesContainer(): JSX.Element {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const onInviteClickHandler = useCallback(
|
|
||||||
async (values: InviteMemberFormValues): Promise<void> => {
|
|
||||||
try {
|
|
||||||
setIsInvitingMembers(true);
|
|
||||||
values.members.forEach(
|
|
||||||
async (member): Promise<void> => {
|
|
||||||
const { error, statusCode } = await sendInvite({
|
|
||||||
email: member.email,
|
|
||||||
name: member.name,
|
|
||||||
role: member.role,
|
|
||||||
frontendBaseUrl: window.location.origin,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (statusCode !== 200) {
|
|
||||||
notifications.error({
|
|
||||||
message:
|
|
||||||
error ||
|
|
||||||
t('something_went_wrong', {
|
|
||||||
ns: 'common',
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
setTimeout(async () => {
|
|
||||||
const { data, status } = await getPendingInvitesResponse.refetch();
|
|
||||||
if (status === 'success' && data.payload) {
|
|
||||||
setDataSource(getParsedInviteData(data?.payload || []));
|
|
||||||
}
|
|
||||||
setIsInvitingMembers(false);
|
|
||||||
toggleModal(false);
|
|
||||||
}, 2000);
|
|
||||||
} catch (error) {
|
|
||||||
notifications.error({
|
|
||||||
message: t('something_went_wrong', {
|
|
||||||
ns: 'common',
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[
|
|
||||||
getParsedInviteData,
|
|
||||||
getPendingInvitesResponse,
|
|
||||||
notifications,
|
|
||||||
t,
|
|
||||||
toggleModal,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Modal
|
<InviteUserModal
|
||||||
title={t('invite_team_members')}
|
form={form}
|
||||||
open={isInviteTeamMemberModalOpen}
|
isInviteTeamMemberModalOpen={isInviteTeamMemberModalOpen}
|
||||||
onCancel={(): void => toggleModal(false)}
|
setDataSource={setDataSource}
|
||||||
centered
|
toggleModal={toggleModal}
|
||||||
destroyOnClose
|
shouldCallApi
|
||||||
footer={[
|
/>
|
||||||
<Button key="back" onClick={(): void => toggleModal(false)} type="default">
|
|
||||||
{t('cancel', {
|
|
||||||
ns: 'common',
|
|
||||||
})}
|
|
||||||
</Button>,
|
|
||||||
<Button
|
|
||||||
key={t('invite_team_members').toString()}
|
|
||||||
onClick={form.submit}
|
|
||||||
type="primary"
|
|
||||||
disabled={isInvitingMembers}
|
|
||||||
loading={isInvitingMembers}
|
|
||||||
>
|
|
||||||
{t('invite_team_members')}
|
|
||||||
</Button>,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<InviteTeamMembers form={form} onFinish={onInviteClickHandler} />
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
<Space direction="vertical" size="middle">
|
<Space direction="vertical" size="middle">
|
||||||
<TitleWrapper>
|
<TitleWrapper>
|
||||||
|
25
frontend/src/mocks-server/__mockdata__/invite_user.ts
Normal file
25
frontend/src/mocks-server/__mockdata__/invite_user.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
export const inviteUser = {
|
||||||
|
status: 'success',
|
||||||
|
data: {
|
||||||
|
statusCode: 200,
|
||||||
|
error: null,
|
||||||
|
payload: [
|
||||||
|
{
|
||||||
|
email: 'jane@doe.com',
|
||||||
|
name: 'Jane',
|
||||||
|
token: 'testtoken',
|
||||||
|
createdAt: 1715741587,
|
||||||
|
role: 'VIEWER',
|
||||||
|
organization: 'test',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
email: 'test+in@singoz.io',
|
||||||
|
name: '',
|
||||||
|
token: 'testtoken1',
|
||||||
|
createdAt: 1720095913,
|
||||||
|
role: 'VIEWER',
|
||||||
|
organization: 'test',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
@ -1,6 +1,7 @@
|
|||||||
import { rest } from 'msw';
|
import { rest } from 'msw';
|
||||||
|
|
||||||
import { billingSuccessResponse } from './__mockdata__/billing';
|
import { billingSuccessResponse } from './__mockdata__/billing';
|
||||||
|
import { inviteUser } from './__mockdata__/invite_user';
|
||||||
import { licensesSuccessResponse } from './__mockdata__/licenses';
|
import { licensesSuccessResponse } from './__mockdata__/licenses';
|
||||||
import { membersResponse } from './__mockdata__/members';
|
import { membersResponse } from './__mockdata__/members';
|
||||||
import { queryRangeSuccessResponse } from './__mockdata__/query_range';
|
import { queryRangeSuccessResponse } from './__mockdata__/query_range';
|
||||||
@ -89,4 +90,11 @@ export const handlers = [
|
|||||||
rest.get('http://localhost/api/v1/billing', (req, res, ctx) =>
|
rest.get('http://localhost/api/v1/billing', (req, res, ctx) =>
|
||||||
res(ctx.status(200), ctx.json(billingSuccessResponse)),
|
res(ctx.status(200), ctx.json(billingSuccessResponse)),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
rest.get('http://localhost/api/v1/invite', (_, res, ctx) =>
|
||||||
|
res(ctx.status(200), ctx.json(inviteUser)),
|
||||||
|
),
|
||||||
|
rest.post('http://localhost/api/v1/invite', (_, res, ctx) =>
|
||||||
|
res(ctx.status(200), ctx.json(inviteUser)),
|
||||||
|
),
|
||||||
];
|
];
|
||||||
|
Loading…
x
Reference in New Issue
Block a user