Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(invite-members): updated invite modal layout and copy/paste functionality #77528

Draft
wants to merge 4 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion static/app/components/modals/inviteMembersModal/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type {ModalRenderProps} from 'sentry/actionCreators/modal';
import ErrorBoundary from 'sentry/components/errorBoundary';
import LoadingError from 'sentry/components/loadingError';
import LoadingIndicator from 'sentry/components/loadingIndicator';
import InviteMembersModalNew from 'sentry/components/modals/inviteMembersModal/inviteMembersModalNew';
import InviteMembersModalView from 'sentry/components/modals/inviteMembersModal/inviteMembersModalview';
import type {InviteRow} from 'sentry/components/modals/inviteMembersModal/types';
import useInviteModal from 'sentry/components/modals/inviteMembersModal/useInviteModal';
Expand All @@ -19,6 +20,8 @@ interface InviteMembersModalProps extends ModalRenderProps {
}

function InviteMembersModal({
Header,
Body,
closeModal,
initialData,
source,
Expand Down Expand Up @@ -70,7 +73,28 @@ function InviteMembersModal({
onSendInvites={sendInvites}
>
{({sendInvites: _sendInvites, canSend, headerInfo}) => {
return (
return organization.features.includes('invite-members-new-modal') ? (
<InviteMembersModalNew
canSend={canSend}
Header={Header}
Body={Body}
complete={complete}
Footer={Footer}
headerInfo={headerInfo}
invites={invites}
inviteStatus={inviteStatus}
member={memberResult.data}
pendingInvites={pendingInvites}
reset={reset}
sendingInvites={sendingInvites}
sendInvites={sendInvites}
setEmails={setEmails}
setRole={setRole}
setTeams={setTeams}
willInvite={willInvite}
error={error}
/>
) : (
<InviteMembersModalView
addInviteRow={addInviteRow}
canSend={canSend}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import type {ComponentProps} from 'react';

import {render, screen} from 'sentry-test/reactTestingLibrary';

import {
makeClosableHeader,
ModalBody,
ModalFooter,
} from 'sentry/components/globalModal/components';
import InviteMembersModalNew from 'sentry/components/modals/inviteMembersModal/inviteMembersModalNew';

describe('InviteMembersModalNew', function () {
const modalProps: ComponentProps<typeof InviteMembersModalNew> = {
canSend: true,
complete: false,
Header: makeClosableHeader(jest.fn()),
Body: ModalBody,
Footer: ModalFooter,
headerInfo: null,
invites: [],
inviteStatus: {},
member: undefined,
pendingInvites: [],
reset: () => {},
sendingInvites: false,
sendInvites: () => {},
setEmails: () => {},
setRole: () => {},
setTeams: () => {},
willInvite: false,
};

it('renders', function () {
render(<InviteMembersModalNew {...modalProps} />);

expect(screen.getByText('Invite New Members')).toBeInTheDocument();
});

it('renders with error', function () {
const modalPropsWithError = {
...modalProps,
error: 'This is an error message',
};
render(<InviteMembersModalNew {...modalPropsWithError} />);

// Check that the Alert component renders with the provided error message
expect(screen.getByText('This is an error message')).toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import type {ReactNode} from 'react';
import {Fragment} from 'react';
import styled from '@emotion/styled';

import type {ModalRenderProps} from 'sentry/actionCreators/modal';
import Alert from 'sentry/components/alert';
import ButtonBar from 'sentry/components/buttonBar';
import InviteButton from 'sentry/components/modals/inviteMembersModal/inviteButton';
import InviteRowControl from 'sentry/components/modals/inviteMembersModal/inviteRowControlNew';
import InviteStatusMessage from 'sentry/components/modals/inviteMembersModal/inviteStatusMessage';
import type {
InviteRow,
InviteStatus,
NormalizedInvite,
} from 'sentry/components/modals/inviteMembersModal/types';
import {ORG_ROLES} from 'sentry/constants';
import {t} from 'sentry/locale';
import {space} from 'sentry/styles/space';
import type {Member} from 'sentry/types/organization';

interface Props {
Body: ModalRenderProps['Body'];
Footer: ModalRenderProps['Footer'];
Header: ModalRenderProps['Header'];
canSend: boolean;
complete: boolean;
headerInfo: ReactNode;
inviteStatus: InviteStatus;
invites: NormalizedInvite[];
member: Member | undefined;
pendingInvites: InviteRow[];
reset: () => void;
sendInvites: () => void;
sendingInvites: boolean;
setEmails: (emails: string[], index: number) => void;
setRole: (role: string, index: number) => void;
setTeams: (teams: string[], index: number) => void;
willInvite: boolean;
error?: string;
}

export default function InviteMembersModalNew({
canSend,
complete,
Header,
Body,
Footer,
headerInfo,
invites,
inviteStatus,
member,
pendingInvites,
reset,
sendingInvites,
sendInvites,
setEmails,
setRole,
setTeams,
willInvite,
error,
}: Props) {
const inviteEmails = invites.map(inv => inv.email);
const hasDuplicateEmails = inviteEmails.length !== new Set(inviteEmails).size;
const isValidInvites = invites.length > 0 && !hasDuplicateEmails;

const errorAlert = error ? (
<Alert type="error" showIcon>
{error}
</Alert>
) : null;

return (
<Fragment>
<Header closeButton>
{errorAlert}
<Heading>{t('Invite New Members')}</Heading>
</Header>
<Body>
{willInvite ? (
<Subtext>
{t('Invite unlimited new members to join your organization.')}
</Subtext>
) : (
<Alert type="warning" showIcon>
{t(
'You can’t invite users directly, but we’ll forward your request to an org owner or manager for approval.'
)}
</Alert>
)}

{headerInfo}

<Rows>
{pendingInvites.map(({emails, role, teams}, i) => (
<StyledInviteRow
key={i}
disabled={false}
emails={[...emails]}
role={role}
teams={[...teams]}
roleOptions={member?.orgRoleList ?? ORG_ROLES}
roleDisabledUnallowed={willInvite}
inviteStatus={inviteStatus}
onRemove={reset}
onChangeEmails={opts => setEmails(opts?.map(v => v.value) ?? [], i)}
onChangeRole={value => setRole(value?.value, i)}
onChangeTeams={opts => setTeams(opts ? opts.map(v => v.value) : [], i)}
/>
))}
</Rows>

<Footer>
<FooterContent>
<div>
<InviteStatusMessage
complete={complete}
hasDuplicateEmails={hasDuplicateEmails}
inviteStatus={inviteStatus}
sendingInvites={sendingInvites}
willInvite={willInvite}
/>
</div>

<ButtonBar gap={1}>
<Fragment>
<InviteButton
invites={invites}
willInvite={willInvite}
size="sm"
data-test-id="send-invites"
priority="primary"
disabled={!canSend || !isValidInvites}
onClick={sendInvites}
/>
</Fragment>
</ButtonBar>
</FooterContent>
</Footer>
</Body>
</Fragment>
);
}

const Heading = styled('h1')`
font-weight: ${p => p.theme.fontWeightNormal};
font-size: ${p => p.theme.headerFontSize};
margin-top: 0;
margin-bottom: ${space(0.75)};
`;

const Subtext = styled('p')`
color: ${p => p.theme.subText};
margin-bottom: ${space(3)};
`;

const Rows = styled('ul')`
list-style: none;
padding: 0;
margin: 0;
`;

const StyledInviteRow = styled(InviteRowControl)`
margin-bottom: ${space(1.5)};
`;

const FooterContent = styled('div')`
display: flex;
gap: ${space(1)};
align-items: center;
justify-content: space-between;
flex: 1;
`;
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import type {ComponentProps} from 'react';
import {TeamFixture} from 'sentry-fixture/team';

import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary';

import InviteRowControlNew from 'sentry/components/modals/inviteMembersModal/inviteRowControlNew';
import TeamStore from 'sentry/stores/teamStore';

describe('InviteRowControlNew', function () {
const mockOnChangeEmails = jest.fn();
const mockOnChangeRole = jest.fn();
const mockOnChangeTeams = jest.fn();

const teamData = [
{
id: '1',
slug: 'moo-deng',
name: "Moo Deng's Team",
},
{
id: '2',
slug: 'moo-waan',
name: "Moo Waan's Team",
},
];
const teams = teamData.map(data => TeamFixture(data));

const rowControlProps: ComponentProps<typeof InviteRowControlNew> = {
disabled: false,
emails: [],
inviteStatus: {},
onChangeEmails: mockOnChangeEmails,
onChangeRole: mockOnChangeRole,
onChangeTeams: mockOnChangeTeams,
onRemove: () => {},
role: '',
roleDisabledUnallowed: false,
roleOptions: [
{
id: 'member',
name: 'Member',
desc: '...',
minimumTeamRole: 'contributor',
isTeamRolesAllowed: true,
},
{
id: 'billing',
name: 'Billing',
desc: '...',
minimumTeamRole: 'contributor',
isTeamRolesAllowed: false,
},
],
teams: [],
};

beforeEach(function () {
TeamStore.loadInitialData(teams);
});

it('renders', function () {
render(<InviteRowControlNew {...rowControlProps} />);

expect(screen.getByText('Email addresses')).toBeInTheDocument();
expect(screen.getByText('Role')).toBeInTheDocument();
expect(screen.getByText('Add to team')).toBeInTheDocument();
});

it('updates email addresses when new emails are inputted', async function () {
render(<InviteRowControlNew {...rowControlProps} />);
const emailInput = screen.getByLabelText('Email Addresses');
await userEvent.type(emailInput, 'test-space@example.com ');
await userEvent.type(emailInput, 'test-comma@example.com,');
await userEvent.type(emailInput, 'test-newline@example.com{enter}');
await userEvent.type(emailInput, 'test-unfocus@example.com');
await userEvent.tab();
expect(mockOnChangeEmails).toHaveBeenCalledTimes(4);
});

it('updates role value when new role is selected', async function () {
render(<InviteRowControlNew {...rowControlProps} />);
const roleInput = screen.getByLabelText('Role');
await userEvent.click(roleInput);
await userEvent.click(screen.getByText('Member'));
expect(mockOnChangeRole).toHaveBeenCalled();
});

it('disables team selection when team roles are not allowed', function () {
render(<InviteRowControlNew {...rowControlProps} role="billing" />);
const teamInput = screen.getByLabelText('Add to Team');
expect(teamInput).toBeDisabled();
});

it('enables team selection when team roles are allowed', async function () {
render(<InviteRowControlNew {...rowControlProps} role="member" />);
const teamInput = screen.getByLabelText('Add to Team');
expect(teamInput).toBeEnabled();
await userEvent.click(teamInput);
await userEvent.click(screen.getByText('#moo-deng'));
await userEvent.click(screen.getByText('#moo-waan'));
expect(mockOnChangeTeams).toHaveBeenCalledTimes(2);
});
});
Loading
Loading