Skip to content
2 changes: 2 additions & 0 deletions .changeset/mosaic-invite-typed-address.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
18 changes: 17 additions & 1 deletion packages/mosaic/src/components/tag-input/tag-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,17 @@ import { styles } from './tag-input.styles';

type TagInputRootProps = Pick<
PrimitiveTagInputProps,
'value' | 'defaultValue' | 'onValueChange' | 'validate' | 'delimiters' | 'name' | 'form' | 'disabled'
| 'value'
| 'defaultValue'
| 'onValueChange'
| 'inputValue'
| 'defaultInputValue'
| 'onInputValueChange'
| 'validate'
| 'delimiters'
| 'name'
| 'form'
| 'disabled'
>;

export interface TagInputProps
Expand Down Expand Up @@ -92,6 +102,9 @@ export const TagInput = React.forwardRef<HTMLInputElement, TagInputProps>(functi
value,
defaultValue,
onValueChange,
inputValue,
defaultInputValue,
onInputValueChange,
validate,
delimiters,
name,
Expand Down Expand Up @@ -130,6 +143,9 @@ export const TagInput = React.forwardRef<HTMLInputElement, TagInputProps>(functi
value={value}
defaultValue={defaultValue}
onValueChange={onValueChange}
inputValue={inputValue}
defaultInputValue={defaultInputValue}
onInputValueChange={onInputValueChange}
validate={validate}
delimiters={delimiters}
name={name}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,24 @@ describe('CreateOrganization', () => {
await waitFor(() => expect(navigate).toHaveBeenCalledWith('/dashboard'));
});

it('sends a valid address still in the input', async () => {
const { fapi } = await renderCreateOrganization();
const user = userEvent.setup();

await create(user, 'Acme');
const role = await screen.findByRole('combobox', { name: /Role/ });
await waitFor(() => expect(role).toBeEnabled());
await user.click(role);
await user.click(await screen.findByRole('option', { name: 'Member' }));
await user.type(screen.getByRole('textbox', { name: 'Email' }), 'grace@example.com');
await user.click(screen.getByRole('button', { name: 'Send invitations' }));

expect(await screen.findByRole('heading', { name: 'Invitations sent' })).toBeVisible();
expect(fapi.organizationInvitations.map(item => [item.email_address, item.role])).toEqual([
['grace@example.com', 'org:member'],
]);
});

it('starts on the instance default role', async () => {
await renderCreateOrganization(
signedIn({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ 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 { CreateOrganizationModel } from './create-organization.model';
import type {
CreatedOrganization,
Expand Down Expand Up @@ -84,6 +88,7 @@ export function useCreateOrganizationController(

interface InviteValues {
emailAddresses: string[];
emailDraft: string;
role: string | null;
}

Expand All @@ -98,11 +103,14 @@ export interface CreateOrganizationInviteControllerOptions {
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;
Expand All @@ -121,14 +129,19 @@ export function useCreateOrganizationInviteController({
const initialRole = roles ? resolveDefaultRole(roles, defaultRole) : null;

const form = useForm<InviteValues, boolean>({
initialValues: { emailAddresses: [], role: null },
initialValues: { emailAddresses: [], emailDraft: '', role: null },
errorFallback: m.invite.error,
onSubmit: async ({ emailAddresses, role }) => {
const selected = role ?? initialRole;
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, role: selected, onRejected: setRejectedEmailAddresses });
await onInvite(organizationId, {
emailAddresses: emailAddressesToInvite(values),
role: selected,
onRejected: setRejectedEmailAddresses,
});
return true;
},
onSuccess: sent => {
Expand All @@ -141,11 +154,14 @@ export function useCreateOrganizationInviteController({
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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,7 @@ 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 {
canSendInvitations,
InviteMembersFields,
} from '../organization-profile/organization-profile-members-panel/organization-profile-invite-members.fields';
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';

Expand Down Expand Up @@ -212,7 +209,6 @@ function LogoField({
function InviteStep({ invite, onFinish }: CreateOrganizationStepsProps) {
const m = useMessages('createOrganization');
const formId = useId();
const canSubmit = canSendInvitations(invite);
const emailInput = useFlowAutoFocus();

return (
Expand All @@ -232,9 +228,7 @@ function InviteStep({ invite, onFinish }: CreateOrganizationStepsProps) {
id={formId}
onSubmit={event => {
event.preventDefault();
if (canSubmit && !invite.isPending) {
invite.onSubmit();
}
invite.onSubmit();
}}
/>
}
Expand All @@ -256,7 +250,7 @@ function InviteStep({ invite, onFinish }: CreateOrganizationStepsProps) {
<SubmitButton
form={formId}
isPending={invite.isPending}
disabled={!canSubmit}
disabled={!invite.canSubmit}
focusableWhenDisabled
>
{m.invite.submit}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -510,6 +510,77 @@ describe('inviting members', () => {
expect(dialog.getByRole('button', { name: 'Send invites' })).not.toHaveAttribute('aria-disabled', 'true');
});

it('sends a valid address still in the input with the selected role', async () => {
const fapi = serveInvite({ defaultRole: 'org:member' });
await renderWithClerk(<OrganizationProfileMembersPanel />);
const { user, dialog } = await openInviteDialog();
await user.click(dialog.getByRole('combobox', { name: /Role/ }));
await user.click(await screen.findByRole('option', { name: 'Admin' }));
await user.type(dialog.getByRole('textbox', { name: 'Email' }), 'grace@example.com');
const send = dialog.getByRole('button', { name: 'Send invites' });
expect(send).not.toHaveAttribute('aria-disabled', 'true');

const bulk = holdRequests('post', '/v1/organizations/:organizationId/invitations/bulk');
await user.click(send);
await waitFor(() => expect(bulk.requests).toHaveLength(1));
const body = new URLSearchParams(await bulk.requests[0]?.text());
expect(body.getAll('email_address')).toEqual(['grace@example.com']);
expect(body.get('role')).toBe('org:admin');
bulk.release();
await waitFor(() => expect(screen.getByRole('heading', { name: 'Invitation sent' })).toBeVisible());
expect(fapi.organizationInvitations.filter(item => item.status === 'pending')).toHaveLength(2);
});

it.each([
['on its own', []],
['beside a valid address', ['linus@example.com']],
])('keeps Send unavailable for an invalid address still in the input %s', async (_, tags) => {
serveInvite({ defaultRole: 'org:member' });
await renderWithClerk(<OrganizationProfileMembersPanel />);
const { user, dialog } = await openInviteDialog();
if (tags.length > 0) {
await addEmails(dialog, ...tags);
}
const bulk = holdRequests('post', '/v1/organizations/:organizationId/invitations/bulk');
await user.type(dialog.getByRole('textbox', { name: 'Email' }), 'grace@example');
const send = dialog.getByRole('button', { name: 'Send invites' });
expect(send).toHaveAttribute('aria-disabled', 'true');
await user.click(send);
expect(bulk.requests).toHaveLength(0);
bulk.release();
});

it.each([
['a new address', 'grace@example.com', ['linus@example.com', 'grace@example.com']],
['an address already added', 'linus@example.com', ['linus@example.com']],
])('sends added addresses and %s still in the input once each', async (_, draft, expected) => {
serveInvite({ defaultRole: 'org:member' });
await renderWithClerk(<OrganizationProfileMembersPanel />);
const { user, dialog } = await openInviteDialog();
await addEmails(dialog, 'linus@example.com');
await user.type(dialog.getByRole('textbox', { name: 'Email' }), draft);
const bulk = holdRequests('post', '/v1/organizations/:organizationId/invitations/bulk');
await user.click(dialog.getByRole('button', { name: 'Send invites' }));
await waitFor(() => expect(bulk.requests).toHaveLength(1));
const body = new URLSearchParams(await bulk.requests[0]?.text());
expect(body.getAll('email_address')).toEqual(expected);
bulk.release();
});

it('does not keep a typed address available after closing and reopening', async () => {
serveInvite({ defaultRole: 'org:member' });
await renderWithClerk(<OrganizationProfileMembersPanel />);
const { user, dialog } = await openInviteDialog();
await user.type(dialog.getByRole('textbox', { name: 'Email' }), 'grace@example.com');
await user.keyboard('{Escape}');
await waitFor(() => expect(screen.queryByRole('dialog', { name: 'Invite members' })).toBeNull());

await user.click(screen.getByRole('button', { name: 'Invite' }));
const reopened = within(await screen.findByRole('dialog', { name: 'Invite members' }));
expect(reopened.getByRole('textbox', { name: 'Email' })).toHaveValue('');
expect(reopened.getByRole('button', { name: 'Send invites' })).toHaveAttribute('aria-disabled', 'true');
});

it('ignores a default role that is not in the role list', async () => {
serveInvite({ defaultRole: 'org:missing' });
await renderWithClerk(<OrganizationProfileMembersPanel />);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,26 +6,31 @@ import { describe, expect, it, vi } from 'vitest';
import { MosaicProvider } from '../../../../mosaic-provider';
import type { OrganizationProfileInviteMembersDialogProps } from '../organization-profile-invite-members.dialog';
import { OrganizationProfileInviteMembersDialog } from '../organization-profile-invite-members.dialog';
import { canSendInvitations } from '../organization-profile-invite-members.utils';

type HarnessProps = Partial<Omit<OrganizationProfileInviteMembersDialogProps, 'emailAddresses'>> & {
initialEmailAddresses?: string[];
};

function Harness({ initialEmailAddresses = [], ...overrides }: HarnessProps) {
const [emailAddresses, setEmailAddresses] = useState(initialEmailAddresses);
const [emailDraft, setEmailDraft] = useState('');
const [role, setRole] = useState<string | null>('member');
return (
<OrganizationProfileInviteMembersDialog
open
onOpenChange={vi.fn()}
emailAddresses={emailAddresses}
onEmailAddressesChange={setEmailAddresses}
emailDraft={emailDraft}
onEmailDraftChange={setEmailDraft}
roles={[
{ value: 'member', label: 'Member', description: 'Non-privileged permissions.' },
{ value: 'admin', label: 'Admin', description: 'Elevated permissions.' },
]}
role={role}
onRoleChange={setRole}
canSubmit={canSendInvitations({ emailAddresses, emailDraft, rejectedEmailAddresses: [], role })}
isPending={false}
error={null}
onSubmit={vi.fn()}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@ import { plural, useLocale, useMessages } from '../../../localization';
import type { MembersRoles } from './members-table-tab.types';
import type { OrganizationProfileInviteMembersDialogProps } from './organization-profile-invite-members.dialog';
import type { InviteMembersInput } from './organization-profile-invite-members.model';
import { canSendInvitations, emailAddressesToInvite } from './organization-profile-invite-members.utils';

interface InviteMembersValues {
emailAddresses: string[];
emailDraft: string;
role: string | null;
}

Expand All @@ -29,22 +31,24 @@ export function useInviteMembersController({ roles, defaultRole, invite }: Invit
const [rejectedEmailAddresses, setRejectedEmailAddresses] = useState<string[]>([]);
const initialRole = roles ? resolveDefaultRole(roles, defaultRole) : null;

const form = useForm<InviteMembersValues, boolean>({
initialValues: { emailAddresses: [], role: initialRole },
const form = useForm<InviteMembersValues, string[] | null>({
initialValues: { emailAddresses: [], emailDraft: '', role: initialRole },
errorFallback: m.error,
onSubmit: async ({ emailAddresses, role }) => {
canSubmit: values => canSendInvitations({ ...values, rejectedEmailAddresses }),
onSubmit: async ({ role, ...draft }) => {
if (!invite || !roles || role === null) {
return false;
return null;
}
const emailAddresses = emailAddressesToInvite(draft);
await invite({ emailAddresses, role, roles, onRejected: setRejectedEmailAddresses });
return true;
return emailAddresses;
},
onSuccess: (invited, { emailAddresses }) => {
onSuccess: invited => {
if (!invited) {
return;
}
setOpen(false);
toast.add({ type: 'success', label: plural(m.sent, emailAddresses.length, locale) });
toast.add({ type: 'success', label: plural(m.sent, invited.length, locale) });
},
});

Expand All @@ -56,7 +60,7 @@ export function useInviteMembersController({ roles, defaultRole, invite }: Invit
if (form.isSubmitting) {
return;
}
form.reset({ emailAddresses: [], role: initialRole });
form.reset({ emailAddresses: [], emailDraft: '', role: initialRole });
setRejectedEmailAddresses([]);
setOpen(next);
};
Expand All @@ -76,11 +80,14 @@ export function useInviteMembersController({ roles, defaultRole, invite }: Invit
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,
Expand Down
Loading
Loading