diff --git a/.changeset/mosaic-share-invite-members-form.md b/.changeset/mosaic-share-invite-members-form.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-share-invite-members-form.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/mosaic/src/features/create-organization/create-organization.controller.ts b/packages/mosaic/src/features/create-organization/create-organization.controller.ts index 4580d91f16e..34ee01d90ca 100644 --- a/packages/mosaic/src/features/create-organization/create-organization.controller.ts +++ b/packages/mosaic/src/features/create-organization/create-organization.controller.ts @@ -2,14 +2,10 @@ import { useState } from 'react'; import type { UseFormResult } from '../../components/form'; import { useForm } from '../../components/form'; -import type { SelectItem } from '../../components/select'; import { useMessages } from '../../localization'; import type { MembersRoles } from '../organization-profile/organization-profile-members-panel/members-table-tab.types'; -import { resolveDefaultRole } from '../organization-profile/organization-profile-members-panel/organization-profile-invite-members.controller'; -import { - canSendInvitations, - emailAddressesToInvite, -} from '../organization-profile/organization-profile-members-panel/organization-profile-invite-members.utils'; +import type { InviteMembersForm } from '../organization-profile/organization-profile-members-panel/organization-profile-invite-members.controller'; +import { useInviteMembersForm } from '../organization-profile/organization-profile-members-panel/organization-profile-invite-members.controller'; import type { CreateOrganizationModel } from './create-organization.model'; import type { CreatedOrganization, @@ -86,12 +82,6 @@ export function useCreateOrganizationController( }; } -interface InviteValues { - emailAddresses: string[]; - emailDraft: string; - role: string | null; -} - export interface CreateOrganizationInviteControllerOptions { organizationId: string | null; roles: MembersRoles | null; @@ -100,70 +90,19 @@ export interface CreateOrganizationInviteControllerOptions { onSent: () => void; } -export interface CreateOrganizationInviteController { - emailAddresses: string[]; - onEmailAddressesChange: (emailAddresses: string[]) => void; - emailDraft: string; - onEmailDraftChange: (emailDraft: string) => void; - rejectedEmailAddresses: string[]; - roles: SelectItem[]; - role: string | null; - onRoleChange: (role: string | null) => void; - isRoleDisabled: boolean; - canSubmit: boolean; - isPending: boolean; - error: string | undefined; - onSubmit: () => void; -} - export function useCreateOrganizationInviteController({ organizationId, roles, defaultRole, onInvite, onSent, -}: CreateOrganizationInviteControllerOptions): CreateOrganizationInviteController { +}: CreateOrganizationInviteControllerOptions): InviteMembersForm { const m = useMessages('createOrganization'); - const roleNames = useMessages('roles'); - const [rejectedEmailAddresses, setRejectedEmailAddresses] = useState([]); - const initialRole = roles ? resolveDefaultRole(roles, defaultRole) : null; - - const form = useForm({ - initialValues: { emailAddresses: [], emailDraft: '', role: null }, + return useInviteMembersForm({ + roles, + defaultRole, errorFallback: m.invite.error, - canSubmit: values => canSendInvitations({ ...values, role: values.role ?? initialRole, rejectedEmailAddresses }), - onSubmit: async values => { - const selected = values.role ?? initialRole; - if (!organizationId || selected === null) { - return false; - } - await onInvite(organizationId, { - emailAddresses: emailAddressesToInvite(values), - role: selected, - onRejected: setRejectedEmailAddresses, - }); - return true; - }, - onSuccess: sent => { - if (sent) { - onSent(); - } - }, - }); - - return { - emailAddresses: form.values.emailAddresses, - onEmailAddressesChange: emailAddresses => form.setValue('emailAddresses', emailAddresses), - emailDraft: form.values.emailDraft, - onEmailDraftChange: emailDraft => form.setValue('emailDraft', emailDraft), - rejectedEmailAddresses, - roles: (roles?.roles ?? []).map(role => ({ value: role.key, label: roleNames[role.key] ?? role.name })), - role: form.values.role ?? initialRole, - onRoleChange: role => form.setValue('role', role), - isRoleDisabled: !roles || roles.hasRoleSetMigration, - canSubmit: form.canSubmit || form.isSubmitting, - isPending: form.isSubmitting, - error: form.error, - onSubmit: form.submit, - }; + send: organizationId ? input => onInvite(organizationId, input) : undefined, + onSent, + }).invite; } diff --git a/packages/mosaic/src/features/create-organization/create-organization.view.tsx b/packages/mosaic/src/features/create-organization/create-organization.view.tsx index 071e2ebb2f4..bfc9d1e879c 100644 --- a/packages/mosaic/src/features/create-organization/create-organization.view.tsx +++ b/packages/mosaic/src/features/create-organization/create-organization.view.tsx @@ -1,5 +1,5 @@ import { useMergeRefs } from '@floating-ui/react'; -import { useId, useState } from 'react'; +import { useState } from 'react'; import { AvatarUpload } from '../../blocks/avatar-upload'; import { Button, SubmitButton } from '../../components/button'; @@ -13,8 +13,8 @@ import { useErrorText, useMessages } from '../../localization'; import { FileUpload } from '../../primitives/file-upload'; import { Form } from '../../primitives/form'; import { AVATAR_REJECTION_ERRORS } from '../../utils/avatar-upload'; +import type { InviteMembersForm } from '../organization-profile/organization-profile-members-panel/organization-profile-invite-members.controller'; import { InviteMembersFields } from '../organization-profile/organization-profile-members-panel/organization-profile-invite-members.fields'; -import type { CreateOrganizationInviteController } from './create-organization.controller'; import type { CreateOrganizationStep, CreateOrganizationValues } from './create-organization.types'; export interface CreateOrganizationStepsProps { @@ -22,7 +22,7 @@ export interface CreateOrganizationStepsProps { form: UseFormResult; slugEnabled: boolean; onNameChange: (name: string) => void; - invite: CreateOrganizationInviteController; + invite: InviteMembersForm; onFinish: () => void; onCancel?: () => void; } @@ -208,7 +208,6 @@ function LogoField({ function InviteStep({ invite, onFinish }: CreateOrganizationStepsProps) { const m = useMessages('createOrganization'); - const formId = useId(); const emailInput = useFlowAutoFocus(); return ( @@ -225,17 +224,14 @@ function InviteStep({ invite, onFinish }: CreateOrganizationStepsProps) { { - event.preventDefault(); - invite.onSubmit(); - }} + id={invite.formId} + onSubmit={invite.onSubmit} /> } > @@ -248,7 +244,7 @@ function InviteStep({ invite, onFinish }: CreateOrganizationStepsProps) { {m.invite.skip} > & { - initialEmailAddresses?: string[]; -}; - -function Harness({ initialEmailAddresses = [], ...overrides }: HarnessProps) { - const [emailAddresses, setEmailAddresses] = useState(initialEmailAddresses); - const [emailDraft, setEmailDraft] = useState(''); - const [role, setRole] = useState('member'); +function Harness({ send }: { send: (input: InviteMembersInput) => Promise }) { + const { invite } = useInviteMembersForm({ + roles: { + roles: [ + { key: 'org:member', name: 'Member' }, + { key: 'org:admin', name: 'Admin' }, + ], + hasRoleSetMigration: false, + }, + defaultRole: 'org:member', + errorFallback: 'Something went wrong.', + send, + onSent: vi.fn(), + }); return ( ); } -function renderDialog(props: HarnessProps = {}) { - return render( - - - , - ); -} - describe('OrganizationProfileInviteMembersDialog', () => { it('blocks submitting until malformed emails are removed', async () => { const user = userEvent.setup(); - const onSubmit = vi.fn(); - renderDialog({ onSubmit, initialEmailAddresses: ['preston@clerk.dev', 'nate'] }); + const send = vi.fn(() => Promise.resolve()); + render( + + + , + ); + await user.type(screen.getByRole('textbox', { name: 'Email' }), 'preston@clerk.dev,nate,'); expect(screen.getByText('Remove invalid email addresses to continue')).toBeInTheDocument(); await user.click(screen.getByRole('button', { name: 'Send invites' })); - expect(onSubmit).not.toHaveBeenCalled(); + expect(send).not.toHaveBeenCalled(); await user.click(screen.getByRole('button', { name: 'Remove nate' })); await user.click(screen.getByRole('button', { name: 'Send invites' })); - expect(onSubmit).toHaveBeenCalledOnce(); + expect(send).toHaveBeenCalledOnce(); + expect(send).toHaveBeenCalledWith( + expect.objectContaining({ emailAddresses: ['preston@clerk.dev'], role: 'org:member' }), + ); }); }); diff --git a/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/organization-profile-invite-members.controller.ts b/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/organization-profile-invite-members.controller.ts index c72985b4761..f38f117062b 100644 --- a/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/organization-profile-invite-members.controller.ts +++ b/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/organization-profile-invite-members.controller.ts @@ -2,6 +2,7 @@ import type { MouseEvent } from 'react'; import { useRef, useState } from 'react'; import { useForm } from '../../../components/form'; +import type { SelectItem } from '../../../components/select'; import { useToastManager } from '../../../components/toast'; import { plural, useLocale, useMessages } from '../../../localization'; import type { MembersRoles } from './members-table-tab.types'; @@ -15,38 +16,105 @@ interface InviteMembersValues { role: string | null; } -export interface InviteMembersControllerOptions { +const EMPTY_INVITE: InviteMembersValues = { emailAddresses: [], emailDraft: '', role: null }; + +export interface InviteMembersForm { + formId: string; + emailAddresses: string[]; + onEmailAddressesChange: (emailAddresses: string[]) => void; + emailDraft: string; + onEmailDraftChange: (emailDraft: string) => void; + rejectedEmailAddresses: string[]; + roles: SelectItem[]; + role: string | null; + onRoleChange: (role: string | null) => void; + isRoleDisabled: boolean; + canSubmit: boolean; + isPending: boolean; + error: string | undefined; + onSubmit: (event: { preventDefault: () => void }) => void; +} + +export interface InviteMembersFormOptions { roles: MembersRoles | null; defaultRole: string | null; - invite: ((input: InviteMembersInput) => Promise) | undefined; + errorFallback: string; + send: ((input: InviteMembersInput) => Promise) | undefined; + onSent: (emailAddresses: string[]) => void; } -export function useInviteMembersController({ roles, defaultRole, invite }: InviteMembersControllerOptions) { - const m = useMessages('organizationProfile').members.invite; +export function useInviteMembersForm({ roles, defaultRole, errorFallback, send, onSent }: InviteMembersFormOptions) { const roleNames = useMessages('roles'); - const locale = useLocale(); - const toast = useToastManager(); - const trigger = useRef(null); - const [open, setOpen] = useState(false); const [rejectedEmailAddresses, setRejectedEmailAddresses] = useState([]); const initialRole = roles ? resolveDefaultRole(roles, defaultRole) : null; const form = useForm({ - initialValues: { emailAddresses: [], emailDraft: '', role: initialRole }, - errorFallback: m.error, - canSubmit: values => canSendInvitations({ ...values, rejectedEmailAddresses }), + initialValues: EMPTY_INVITE, + errorFallback, + canSubmit: values => canSendInvitations({ ...values, role: values.role ?? initialRole, rejectedEmailAddresses }), onSubmit: async ({ role, ...draft }) => { - if (!invite || !roles || role === null) { + const selected = role ?? initialRole; + if (!send || !roles || selected === null) { return null; } const emailAddresses = emailAddressesToInvite(draft); - await invite({ emailAddresses, role, roles, onRejected: setRejectedEmailAddresses }); + await send({ emailAddresses, role: selected, roles, onRejected: setRejectedEmailAddresses }); return emailAddresses; }, onSuccess: invited => { - if (!invited) { - return; + if (invited) { + onSent(invited); } + }, + }); + + const invite: InviteMembersForm = { + formId: form.id, + emailAddresses: form.values.emailAddresses, + onEmailAddressesChange: emailAddresses => form.setValue('emailAddresses', emailAddresses), + emailDraft: form.values.emailDraft, + onEmailDraftChange: emailDraft => form.setValue('emailDraft', emailDraft), + rejectedEmailAddresses, + roles: (roles?.roles ?? []).map(role => ({ value: role.key, label: roleNames[role.key] ?? role.name })), + role: form.values.role ?? initialRole, + onRoleChange: role => form.setValue('role', role), + isRoleDisabled: !roles || roles.hasRoleSetMigration, + canSubmit: form.canSubmit || form.isSubmitting, + isPending: form.isSubmitting, + error: form.error, + onSubmit: form.handleSubmit, + }; + + return { + invite, + get isSubmitting() { + return form.isSubmitting; + }, + reset: () => { + form.reset(); + setRejectedEmailAddresses([]); + }, + }; +} + +export interface InviteMembersControllerOptions { + roles: MembersRoles | null; + defaultRole: string | null; + invite: ((input: InviteMembersInput) => Promise) | undefined; +} + +export function useInviteMembersController({ roles, defaultRole, invite }: InviteMembersControllerOptions) { + const m = useMessages('organizationProfile').members.invite; + const locale = useLocale(); + const toast = useToastManager(); + const trigger = useRef(null); + const [open, setOpen] = useState(false); + const inviteForm = useInviteMembersForm({ + roles, + defaultRole, + errorFallback: m.error, + send: invite, + onSent: invited => { setOpen(false); toast.add({ type: 'success', label: plural(m.sent, invited.length, locale) }); }, @@ -57,11 +125,10 @@ export function useInviteMembersController({ roles, defaultRole, invite }: Invit } const onOpenChange = (next: boolean) => { - if (form.isSubmitting) { + if (inviteForm.isSubmitting) { return; } - form.reset({ emailAddresses: [], emailDraft: '', role: initialRole }); - setRejectedEmailAddresses([]); + inviteForm.reset(); setOpen(next); }; @@ -78,19 +145,7 @@ export function useInviteMembersController({ roles, defaultRole, invite }: Invit open, onOpenChange, finalFocus: trigger, - emailAddresses: form.values.emailAddresses, - onEmailAddressesChange: emailAddresses => form.setValue('emailAddresses', emailAddresses), - emailDraft: form.values.emailDraft, - onEmailDraftChange: emailDraft => form.setValue('emailDraft', emailDraft), - rejectedEmailAddresses, - roles: roles.roles.map(role => ({ value: role.key, label: roleNames[role.key] ?? role.name })), - role: form.values.role, - onRoleChange: role => form.setValue('role', role), - isRoleDisabled: roles.hasRoleSetMigration, - canSubmit: form.canSubmit || form.isSubmitting, - isPending: form.isSubmitting, - error: form.error ?? null, - onSubmit: form.submit, + invite: inviteForm.invite, }; return { onInvite, isInviteDisabled: false, inviteDialog }; diff --git a/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/organization-profile-invite-members.dialog.tsx b/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/organization-profile-invite-members.dialog.tsx index 93f049888b3..df2b5144541 100644 --- a/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/organization-profile-invite-members.dialog.tsx +++ b/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/organization-profile-invite-members.dialog.tsx @@ -1,60 +1,35 @@ -import { useId, useRef } from 'react'; +import { useRef } from 'react'; import { Button, SubmitButton } from '../../../components/button'; import { Card } from '../../../components/card'; import type { DialogFocusTarget } from '../../../components/dialog'; import { Dialog } from '../../../components/dialog'; -import type { SelectItem } from '../../../components/select'; import { useMessages } from '../../../localization'; import { Form } from '../../../primitives/form'; +import type { InviteMembersForm } from './organization-profile-invite-members.controller'; import { InviteMembersFields } from './organization-profile-invite-members.fields'; export interface OrganizationProfileInviteMembersDialogProps { open: boolean; onOpenChange: (open: boolean) => void; finalFocus?: DialogFocusTarget; - emailAddresses: string[]; - onEmailAddressesChange: (emailAddresses: string[]) => void; - emailDraft: string; - onEmailDraftChange: (emailDraft: string) => void; - rejectedEmailAddresses?: string[]; - roles: SelectItem[]; - role: string | null; - onRoleChange: (role: string | null) => void; - isRoleDisabled?: boolean; - canSubmit: boolean; - isPending: boolean; - error: string | null; - onSubmit: () => void | Promise; + invite: InviteMembersForm; } export function OrganizationProfileInviteMembersDialog({ open, onOpenChange, finalFocus, - emailAddresses, - onEmailAddressesChange, - emailDraft, - onEmailDraftChange, - rejectedEmailAddresses = [], - roles, - role, - onRoleChange, - isRoleDisabled = false, - canSubmit, - isPending, - error, - onSubmit, + invite, }: OrganizationProfileInviteMembersDialogProps) { const m = useMessages('organizationProfile').members.invite; - const formId = useId(); const emailInput = useRef(null); return ( { - if (!isPending) { + if (!invite.isPending) { onOpenChange(next); } }} @@ -75,36 +50,24 @@ export function OrganizationProfileInviteMembersDialog({ role='alert' color='negative' > - {error} + {invite.error} { - event.preventDefault(); - void onSubmit(); - }} + id={invite.formId} + onSubmit={invite.onSubmit} /> } > {m.submit} diff --git a/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/organization-profile-invite-members.fields.tsx b/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/organization-profile-invite-members.fields.tsx index e20a36a359c..8f765a103a6 100644 --- a/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/organization-profile-invite-members.fields.tsx +++ b/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/organization-profile-invite-members.fields.tsx @@ -2,41 +2,32 @@ import * as stylex from '@stylexjs/stylex'; import type { Ref } from 'react'; import { Field } from '../../../components/field'; -import type { SelectItem } from '../../../components/select'; import { Select } from '../../../components/select'; import { TagInput } from '../../../components/tag-input'; import { fill, useMessages } from '../../../localization'; +import type { InviteMembersForm } from './organization-profile-invite-members.controller'; import { styles } from './organization-profile-invite-members.styles'; import { hasInvalidEmailAddresses, isValidEmail } from './organization-profile-invite-members.utils'; export interface InviteMembersFieldsProps { emailInputRef?: Ref; - emailAddresses: string[]; - onEmailAddressesChange: (emailAddresses: string[]) => void; - emailDraft: string; - onEmailDraftChange: (emailDraft: string) => void; - rejectedEmailAddresses?: string[]; - roles: SelectItem[]; - role: string | null; - onRoleChange: (role: string | null) => void; - isRoleDisabled?: boolean; - isPending: boolean; + invite: InviteMembersForm; } -export function InviteMembersFields({ - emailInputRef, - emailAddresses, - onEmailAddressesChange, - emailDraft, - onEmailDraftChange, - rejectedEmailAddresses = [], - roles, - role, - onRoleChange, - isRoleDisabled = false, - isPending, -}: InviteMembersFieldsProps) { +export function InviteMembersFields({ emailInputRef, invite }: InviteMembersFieldsProps) { const m = useMessages('organizationProfile').members.invite; + const { + emailAddresses, + onEmailAddressesChange, + emailDraft, + onEmailDraftChange, + rejectedEmailAddresses, + roles, + role, + onRoleChange, + isRoleDisabled, + isPending, + } = invite; const hasInvalid = hasInvalidEmailAddresses(emailAddresses, rejectedEmailAddresses); return (