From f35ff84a944b03c60fb741520fbb725c9a74f8a1 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Tue, 3 Nov 2026 08:15:36 -0700 Subject: [PATCH] refactor(mosaic): share one invite form between the invite dialog and create organization useInviteMembersForm owns the invite values, the default role, rejected addresses, submit availability, and the submit. The invite dialog and the create organization invite step take it as a single invite prop instead of repeating each field, and use its form id and submit handler directly. --- .../mosaic-share-invite-members-form.md | 2 + .../create-organization.controller.ts | 79 ++---------- .../create-organization.view.tsx | 18 ++- ...ion-profile-invite-members.dialog.test.tsx | 67 +++++----- ...ation-profile-invite-members.controller.ts | 117 +++++++++++++----- ...nization-profile-invite-members.dialog.tsx | 63 ++-------- ...nization-profile-invite-members.fields.tsx | 39 +++--- 7 files changed, 161 insertions(+), 224 deletions(-) create mode 100644 .changeset/mosaic-share-invite-members-form.md 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 (