Skip to content

Commit e82276b

Browse files
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.
1 parent a31b9f4 commit e82276b

7 files changed

Lines changed: 160 additions & 223 deletions

File tree

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
---
2+
---

‎packages/mosaic/src/features/create-organization/create-organization.controller.ts‎

Lines changed: 9 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,10 @@ import { useState } from 'react';
22

33
import type { UseFormResult } from '../../components/form';
44
import { useForm } from '../../components/form';
5-
import type { SelectItem } from '../../components/select';
65
import { useMessages } from '../../localization';
76
import type { MembersRoles } from '../organization-profile/organization-profile-members-panel/members-table-tab.types';
8-
import { resolveDefaultRole } from '../organization-profile/organization-profile-members-panel/organization-profile-invite-members.controller';
9-
import {
10-
canSendInvitations,
11-
emailAddressesToInvite,
12-
} from '../organization-profile/organization-profile-members-panel/organization-profile-invite-members.utils';
7+
import type { InviteMembersForm } from '../organization-profile/organization-profile-members-panel/organization-profile-invite-members.controller';
8+
import { useInviteMembersForm } from '../organization-profile/organization-profile-members-panel/organization-profile-invite-members.controller';
139
import type { CreateOrganizationModel } from './create-organization.model';
1410
import type {
1511
CreatedOrganization,
@@ -86,12 +82,6 @@ export function useCreateOrganizationController(
8682
};
8783
}
8884

89-
interface InviteValues {
90-
emailAddresses: string[];
91-
emailDraft: string;
92-
role: string | null;
93-
}
94-
9585
export interface CreateOrganizationInviteControllerOptions {
9686
organizationId: string | null;
9787
roles: MembersRoles | null;
@@ -100,70 +90,19 @@ export interface CreateOrganizationInviteControllerOptions {
10090
onSent: () => void;
10191
}
10292

103-
export interface CreateOrganizationInviteController {
104-
emailAddresses: string[];
105-
onEmailAddressesChange: (emailAddresses: string[]) => void;
106-
emailDraft: string;
107-
onEmailDraftChange: (emailDraft: string) => void;
108-
rejectedEmailAddresses: string[];
109-
roles: SelectItem[];
110-
role: string | null;
111-
onRoleChange: (role: string | null) => void;
112-
isRoleDisabled: boolean;
113-
canSubmit: boolean;
114-
isPending: boolean;
115-
error: string | undefined;
116-
onSubmit: () => void;
117-
}
118-
11993
export function useCreateOrganizationInviteController({
12094
organizationId,
12195
roles,
12296
defaultRole,
12397
onInvite,
12498
onSent,
125-
}: CreateOrganizationInviteControllerOptions): CreateOrganizationInviteController {
99+
}: CreateOrganizationInviteControllerOptions): InviteMembersForm {
126100
const m = useMessages('createOrganization');
127-
const roleNames = useMessages('roles');
128-
const [rejectedEmailAddresses, setRejectedEmailAddresses] = useState<string[]>([]);
129-
const initialRole = roles ? resolveDefaultRole(roles, defaultRole) : null;
130-
131-
const form = useForm<InviteValues, boolean>({
132-
initialValues: { emailAddresses: [], emailDraft: '', role: null },
101+
return useInviteMembersForm({
102+
roles,
103+
defaultRole,
133104
errorFallback: m.invite.error,
134-
canSubmit: values => canSendInvitations({ ...values, role: values.role ?? initialRole, rejectedEmailAddresses }),
135-
onSubmit: async values => {
136-
const selected = values.role ?? initialRole;
137-
if (!organizationId || selected === null) {
138-
return false;
139-
}
140-
await onInvite(organizationId, {
141-
emailAddresses: emailAddressesToInvite(values),
142-
role: selected,
143-
onRejected: setRejectedEmailAddresses,
144-
});
145-
return true;
146-
},
147-
onSuccess: sent => {
148-
if (sent) {
149-
onSent();
150-
}
151-
},
152-
});
153-
154-
return {
155-
emailAddresses: form.values.emailAddresses,
156-
onEmailAddressesChange: emailAddresses => form.setValue('emailAddresses', emailAddresses),
157-
emailDraft: form.values.emailDraft,
158-
onEmailDraftChange: emailDraft => form.setValue('emailDraft', emailDraft),
159-
rejectedEmailAddresses,
160-
roles: (roles?.roles ?? []).map(role => ({ value: role.key, label: roleNames[role.key] ?? role.name })),
161-
role: form.values.role ?? initialRole,
162-
onRoleChange: role => form.setValue('role', role),
163-
isRoleDisabled: !roles || roles.hasRoleSetMigration,
164-
canSubmit: form.canSubmit || form.isSubmitting,
165-
isPending: form.isSubmitting,
166-
error: form.error,
167-
onSubmit: form.submit,
168-
};
105+
send: organizationId ? input => onInvite(organizationId, input) : undefined,
106+
onSent,
107+
}).invite;
169108
}

‎packages/mosaic/src/features/create-organization/create-organization.view.tsx‎

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@ import { useErrorText, useMessages } from '../../localization';
1515
import type { FileRejectionReason } from '../../primitives/file-upload';
1616
import { FileUpload } from '../../primitives/file-upload';
1717
import { Form } from '../../primitives/form';
18+
import type { InviteMembersForm } from '../organization-profile/organization-profile-members-panel/organization-profile-invite-members.controller';
1819
import { InviteMembersFields } from '../organization-profile/organization-profile-members-panel/organization-profile-invite-members.fields';
19-
import type { CreateOrganizationInviteController } from './create-organization.controller';
2020
import { styles } from './create-organization.styles';
2121
import type { CreateOrganizationStep, CreateOrganizationValues } from './create-organization.types';
2222

@@ -34,7 +34,7 @@ export interface CreateOrganizationStepsProps {
3434
form: UseFormResult<CreateOrganizationValues>;
3535
slugEnabled: boolean;
3636
onNameChange: (name: string) => void;
37-
invite: CreateOrganizationInviteController;
37+
invite: InviteMembersForm;
3838
onFinish: () => void;
3939
onCancel?: () => void;
4040
}
@@ -261,7 +261,6 @@ function LogoField({
261261

262262
function InviteStep({ invite, onFinish }: CreateOrganizationStepsProps) {
263263
const m = useMessages('createOrganization');
264-
const formId = useId();
265264
const emailInput = useFlowAutoFocus();
266265

267266
return (
@@ -278,17 +277,14 @@ function InviteStep({ invite, onFinish }: CreateOrganizationStepsProps) {
278277
<Card.Content
279278
render={
280279
<Form
281-
id={formId}
282-
onSubmit={event => {
283-
event.preventDefault();
284-
invite.onSubmit();
285-
}}
280+
id={invite.formId}
281+
onSubmit={invite.onSubmit}
286282
/>
287283
}
288284
>
289285
<InviteMembersFields
290286
emailInputRef={emailInput}
291-
{...invite}
287+
invite={invite}
292288
/>
293289
</Card.Content>
294290
<Card.Footer>
@@ -301,7 +297,7 @@ function InviteStep({ invite, onFinish }: CreateOrganizationStepsProps) {
301297
{m.invite.skip}
302298
</Button>
303299
<SubmitButton
304-
form={formId}
300+
form={invite.formId}
305301
isPending={invite.isPending}
306302
disabled={!invite.canSubmit}
307303
focusableWhenDisabled
Original file line numberDiff line numberDiff line change
@@ -1,64 +1,55 @@
11
import { render, screen } from '@testing-library/react';
22
import userEvent from '@testing-library/user-event';
3-
import { useState } from 'react';
43
import { describe, expect, it, vi } from 'vitest';
54

65
import { MosaicProvider } from '../../../../mosaic-provider';
7-
import type { OrganizationProfileInviteMembersDialogProps } from '../organization-profile-invite-members.dialog';
6+
import { useInviteMembersForm } from '../organization-profile-invite-members.controller';
87
import { OrganizationProfileInviteMembersDialog } from '../organization-profile-invite-members.dialog';
9-
import { canSendInvitations } from '../organization-profile-invite-members.utils';
8+
import type { InviteMembersInput } from '../organization-profile-invite-members.model';
109

11-
type HarnessProps = Partial<Omit<OrganizationProfileInviteMembersDialogProps, 'emailAddresses'>> & {
12-
initialEmailAddresses?: string[];
13-
};
14-
15-
function Harness({ initialEmailAddresses = [], ...overrides }: HarnessProps) {
16-
const [emailAddresses, setEmailAddresses] = useState(initialEmailAddresses);
17-
const [emailDraft, setEmailDraft] = useState('');
18-
const [role, setRole] = useState<string | null>('member');
10+
function Harness({ send }: { send: (input: InviteMembersInput) => Promise<void> }) {
11+
const { invite } = useInviteMembersForm({
12+
roles: {
13+
roles: [
14+
{ key: 'org:member', name: 'Member' },
15+
{ key: 'org:admin', name: 'Admin' },
16+
],
17+
hasRoleSetMigration: false,
18+
},
19+
defaultRole: 'org:member',
20+
errorFallback: 'Something went wrong.',
21+
send,
22+
onSent: vi.fn(),
23+
});
1924
return (
2025
<OrganizationProfileInviteMembersDialog
2126
open
2227
onOpenChange={vi.fn()}
23-
emailAddresses={emailAddresses}
24-
onEmailAddressesChange={setEmailAddresses}
25-
emailDraft={emailDraft}
26-
onEmailDraftChange={setEmailDraft}
27-
roles={[
28-
{ value: 'member', label: 'Member', description: 'Non-privileged permissions.' },
29-
{ value: 'admin', label: 'Admin', description: 'Elevated permissions.' },
30-
]}
31-
role={role}
32-
onRoleChange={setRole}
33-
canSubmit={canSendInvitations({ emailAddresses, emailDraft, rejectedEmailAddresses: [], role })}
34-
isPending={false}
35-
error={null}
36-
onSubmit={vi.fn()}
37-
{...overrides}
28+
invite={invite}
3829
/>
3930
);
4031
}
4132

42-
function renderDialog(props: HarnessProps = {}) {
43-
return render(
44-
<MosaicProvider>
45-
<Harness {...props} />
46-
</MosaicProvider>,
47-
);
48-
}
49-
5033
describe('OrganizationProfileInviteMembersDialog', () => {
5134
it('blocks submitting until malformed emails are removed', async () => {
5235
const user = userEvent.setup();
53-
const onSubmit = vi.fn();
54-
renderDialog({ onSubmit, initialEmailAddresses: ['preston@clerk.dev', 'nate'] });
36+
const send = vi.fn(() => Promise.resolve());
37+
render(
38+
<MosaicProvider>
39+
<Harness send={send} />
40+
</MosaicProvider>,
41+
);
5542

43+
await user.type(screen.getByRole('textbox', { name: 'Email' }), 'preston@clerk.dev,nate,');
5644
expect(screen.getByText('Remove invalid email addresses to continue')).toBeInTheDocument();
5745
await user.click(screen.getByRole('button', { name: 'Send invites' }));
58-
expect(onSubmit).not.toHaveBeenCalled();
46+
expect(send).not.toHaveBeenCalled();
5947

6048
await user.click(screen.getByRole('button', { name: 'Remove nate' }));
6149
await user.click(screen.getByRole('button', { name: 'Send invites' }));
62-
expect(onSubmit).toHaveBeenCalledOnce();
50+
expect(send).toHaveBeenCalledOnce();
51+
expect(send).toHaveBeenCalledWith(
52+
expect.objectContaining({ emailAddresses: ['preston@clerk.dev'], role: 'org:member' }),
53+
);
6354
});
6455
});

0 commit comments

Comments
 (0)