diff --git a/static/app/components/modals/inviteMembersModal/index.tsx b/static/app/components/modals/inviteMembersModal/index.tsx index a4f4753780e98a..ff42f80f193f92 100644 --- a/static/app/components/modals/inviteMembersModal/index.tsx +++ b/static/app/components/modals/inviteMembersModal/index.tsx @@ -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'; @@ -19,6 +20,8 @@ interface InviteMembersModalProps extends ModalRenderProps { } function InviteMembersModal({ + Header, + Body, closeModal, initialData, source, @@ -70,7 +73,28 @@ function InviteMembersModal({ onSendInvites={sendInvites} > {({sendInvites: _sendInvites, canSend, headerInfo}) => { - return ( + return organization.features.includes('invite-members-new-modal') ? ( + + ) : ( = { + 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(); + + expect(screen.getByText('Invite New Members')).toBeInTheDocument(); + }); + + it('renders with error', function () { + const modalPropsWithError = { + ...modalProps, + error: 'This is an error message', + }; + render(); + + // Check that the Alert component renders with the provided error message + expect(screen.getByText('This is an error message')).toBeInTheDocument(); + }); +}); diff --git a/static/app/components/modals/inviteMembersModal/inviteMembersModalNew.tsx b/static/app/components/modals/inviteMembersModal/inviteMembersModalNew.tsx new file mode 100644 index 00000000000000..693fe34a0b3648 --- /dev/null +++ b/static/app/components/modals/inviteMembersModal/inviteMembersModalNew.tsx @@ -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 ? ( + + {error} + + ) : null; + + return ( + +
+ {errorAlert} + {t('Invite New Members')} +
+ + {willInvite ? ( + + {t('Invite unlimited new members to join your organization.')} + + ) : ( + + {t( + 'You can’t invite users directly, but we’ll forward your request to an org owner or manager for approval.' + )} + + )} + + {headerInfo} + + + {pendingInvites.map(({emails, role, teams}, i) => ( + setEmails(opts?.map(v => v.value) ?? [], i)} + onChangeRole={value => setRole(value?.value, i)} + onChangeTeams={opts => setTeams(opts ? opts.map(v => v.value) : [], i)} + /> + ))} + + +
+ +
+ +
+ + + + + + +
+
+ +
+ ); +} + +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; +`; diff --git a/static/app/components/modals/inviteMembersModal/inviteRowControlNew.spec.tsx b/static/app/components/modals/inviteMembersModal/inviteRowControlNew.spec.tsx new file mode 100644 index 00000000000000..e4c1a391251224 --- /dev/null +++ b/static/app/components/modals/inviteMembersModal/inviteRowControlNew.spec.tsx @@ -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 = { + 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(); + + 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(); + 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(); + 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(); + const teamInput = screen.getByLabelText('Add to Team'); + expect(teamInput).toBeDisabled(); + }); + + it('enables team selection when team roles are allowed', async function () { + render(); + 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); + }); +}); diff --git a/static/app/components/modals/inviteMembersModal/inviteRowControlNew.tsx b/static/app/components/modals/inviteMembersModal/inviteRowControlNew.tsx new file mode 100644 index 00000000000000..5cf3a3de42c829 --- /dev/null +++ b/static/app/components/modals/inviteMembersModal/inviteRowControlNew.tsx @@ -0,0 +1,236 @@ +import {useCallback, useState} from 'react'; +import type {MultiValueProps} from 'react-select'; +import type {Theme} from '@emotion/react'; +import {useTheme} from '@emotion/react'; +import styled from '@emotion/styled'; + +import type {StylesConfig} from 'sentry/components/forms/controls/selectControl'; +import SelectControl from 'sentry/components/forms/controls/selectControl'; +import RoleSelectControl from 'sentry/components/roleSelectControl'; +import TeamSelector from 'sentry/components/teamSelector'; +import {t} from 'sentry/locale'; +import {space} from 'sentry/styles/space'; +import type {SelectValue} from 'sentry/types/core'; +import type {OrgRole} from 'sentry/types/organization'; + +import renderEmailValue from './renderEmailValue'; +import type {InviteStatus} from './types'; + +type SelectOption = SelectValue; + +type Props = { + disabled: boolean; + emails: string[]; + inviteStatus: InviteStatus; + onChangeEmails: (emails: SelectOption[]) => void; + onChangeRole: (role: SelectOption) => void; + onChangeTeams: (teams: SelectOption[]) => void; + onRemove: () => void; + role: string; + roleDisabledUnallowed: boolean; + roleOptions: OrgRole[]; + teams: string[]; +}; + +function ValueComponent( + props: MultiValueProps, + inviteStatus: Props['inviteStatus'] +) { + return renderEmailValue(inviteStatus[props.data.value], props); +} + +function mapToOptions(values: string[]): SelectOption[] { + return values.map(value => ({value, label: value})); +} + +function InviteRowControl({ + disabled, + emails, + role, + teams, + roleOptions, + roleDisabledUnallowed, + inviteStatus, + onRemove, + onChangeEmails, + onChangeRole, + onChangeTeams, +}: Props) { + const [inputValue, setInputValue] = useState(''); + + const theme = useTheme(); + + const isTeamRolesAllowedForRole = useCallback<(roleId: string) => boolean>( + roleId => { + const roleOptionsMap = roleOptions.reduce( + (rolesMap, roleOption) => ({...rolesMap, [roleOption.id]: roleOption}), + {} + ); + return roleOptionsMap[roleId]?.isTeamRolesAllowed ?? true; + }, + [roleOptions] + ); + const isTeamRolesAllowed = isTeamRolesAllowedForRole(role); + + const handleKeyDown = (event: React.KeyboardEvent) => { + switch (event.key) { + case 'Enter': + case ',': + case ' ': + handleInput(inputValue); + setInputValue(''); + event.preventDefault(); + break; + default: + // do nothing. + } + }; + + const handleInput = input => { + const newEmails = input.trim() ? input.trim().split(/[\s,]+/) : []; + if (newEmails.length > 0) { + onChangeEmails([ + ...mapToOptions(emails), + ...newEmails.map(email => ({label: email, value: email})), + ]); + } + }; + + return ( + +
+ Email addresses + + ValueComponent(props, inviteStatus), + DropdownIndicator: () => null, + }} + options={mapToOptions(emails)} + onBlur={(e: React.ChangeEvent) => { + handleInput(e.target.value); + }} + styles={getStyles(theme, inviteStatus)} + onInputChange={setInputValue} + onKeyDown={handleKeyDown} + onChange={onChangeEmails} + multiple + creatable + clearable + onClear={onRemove} + menuIsOpen={false} + /> + +
+ +
+ Role + { + onChangeRole(roleOption); + if (!isTeamRolesAllowedForRole(roleOption.value)) { + onChangeTeams([]); + } + }} + /> +
+
+ Add to team + +
+
+
+ ); +} + +/** + * The email select control has custom selected item states as items + * show their delivery status after the form is submitted. + */ +function getStyles(theme: Theme, inviteStatus: Props['inviteStatus']): StylesConfig { + return { + multiValue: (provided, {data}: MultiValueProps) => { + const status = inviteStatus[data.value]; + return { + ...provided, + ...(status?.error + ? { + color: theme.red400, + border: `1px solid ${theme.red300}`, + backgroundColor: theme.red100, + } + : {}), + }; + }, + multiValueLabel: (provided, {data}: MultiValueProps) => { + const status = inviteStatus[data.value]; + return { + ...provided, + pointerEvents: 'all', + ...(status?.error ? {color: theme.red400} : {}), + }; + }, + multiValueRemove: (provided, {data}: MultiValueProps) => { + const status = inviteStatus[data.value]; + return { + ...provided, + ...(status?.error + ? { + borderLeft: `1px solid ${theme.red300}`, + ':hover': {backgroundColor: theme.red100, color: theme.red400}, + } + : {}), + }; + }, + }; +} + +const Heading = styled('div')` + margin-bottom: ${space(1)}; + font-weight: ${p => p.theme.fontWeightBold}; + text-transform: uppercase; + font-size: ${p => p.theme.fontSizeSmall}; +`; + +const RowWrapper = styled('div')` + display: flex; + flex-direction: column; + gap: ${space(1.5)}; +`; + +const EmailWrapper = styled('div')` + &:focus-within { + display: grid; + } +`; + +const RoleTeamWrapper = styled('div')` + display: grid; + gap: ${space(1.5)}; + grid-template-columns: 1fr 1fr; + align-items: start; +`; + +export default InviteRowControl;