From b3e67e0f80b85abe28ba09522297d4781cb657d7 Mon Sep 17 00:00:00 2001 From: Mia Hsu Date: Fri, 13 Sep 2024 10:39:33 -0700 Subject: [PATCH] new modal layout --- .../modals/inviteMembersModal/index.tsx | 26 +- .../inviteMembersModalNew.tsx | 172 +++++++++++++ .../inviteRowControlNew.tsx | 228 ++++++++++++++++++ 3 files changed, 425 insertions(+), 1 deletion(-) create mode 100644 static/app/components/modals/inviteMembersModal/inviteMembersModalNew.tsx create mode 100644 static/app/components/modals/inviteMembersModal/inviteRowControlNew.tsx 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') ? ( + + ) : ( 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.tsx b/static/app/components/modals/inviteMembersModal/inviteRowControlNew.tsx new file mode 100644 index 00000000000000..990e4fe5a9e5be --- /dev/null +++ b/static/app/components/modals/inviteMembersModal/inviteRowControlNew.tsx @@ -0,0 +1,228 @@ +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 RoleTeamWrapper = styled('div')` + display: grid; + gap: ${space(1.5)}; + grid-template-columns: 1fr 1fr; + align-items: start; +`; + +export default InviteRowControl;