9fd7e611f3
* implement * fix: apply CodeRabbit auto-fixes Fixed 2 file(s) based on 1 unresolved review comment. Co-authored-by: CodeRabbit <[email protected]> * nits --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Co-authored-by: CodeRabbit <[email protected]>
180 lines
5.1 KiB
TypeScript
180 lines
5.1 KiB
TypeScript
import { useState } from 'react';
|
|
import { X } from 'lucide-react';
|
|
import { toast } from 'sonner';
|
|
import { z } from 'zod';
|
|
import { Button } from '@/components/ui/button';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from '@/components/ui/dialog';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Muted } from '@/components/ui/typography';
|
|
import { useInviteMembers } from '@/hooks/use-member-management';
|
|
|
|
const emailSchema = z.string().email();
|
|
|
|
function EmailChip({
|
|
email,
|
|
onRemove,
|
|
}: {
|
|
email: string;
|
|
onRemove: () => void;
|
|
}) {
|
|
return (
|
|
<span className="bg-secondary text-secondary-foreground inline-flex items-center gap-1 rounded-md py-0.5 pl-2 pr-1 text-xs">
|
|
{email}
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
onClick={onRemove}
|
|
aria-label={`Remove ${email}`}
|
|
className="text-muted-foreground hover:text-foreground size-5"
|
|
>
|
|
<X className="size-3" />
|
|
</Button>
|
|
</span>
|
|
);
|
|
}
|
|
|
|
export function AddMembersDialog({
|
|
networkId,
|
|
open,
|
|
onOpenChange,
|
|
}: {
|
|
networkId: string;
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
}) {
|
|
const [emails, setEmails] = useState<string[]>([]);
|
|
const [input, setInput] = useState('');
|
|
const [error, setError] = useState<string | null>(null);
|
|
const inviteMembers = useInviteMembers(networkId);
|
|
|
|
const reset = () => {
|
|
setEmails([]);
|
|
setInput('');
|
|
setError(null);
|
|
};
|
|
|
|
const handleOpenChange = (next: boolean) => {
|
|
if (!next) reset();
|
|
onOpenChange(next);
|
|
};
|
|
|
|
// Commits the current input as a chip. Returns the next list of emails so
|
|
// callers (like submit) can act on the freshly-committed value.
|
|
const commit = (raw: string): string[] | null => {
|
|
const trimmed = raw.trim().replace(/,$/, '').trim();
|
|
if (!trimmed) return emails;
|
|
if (!emailSchema.safeParse(trimmed).success) {
|
|
setError(`"${trimmed}" doesn't look like a valid email.`);
|
|
return null;
|
|
}
|
|
if (emails.includes(trimmed)) {
|
|
setInput('');
|
|
return emails;
|
|
}
|
|
const next = [...emails, trimmed];
|
|
setEmails(next);
|
|
setInput('');
|
|
setError(null);
|
|
return next;
|
|
};
|
|
|
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
if (e.key === 'Enter' || e.key === ',') {
|
|
e.preventDefault();
|
|
commit(input);
|
|
} else if (e.key === 'Backspace' && input === '' && emails.length > 0) {
|
|
setEmails(emails.slice(0, -1));
|
|
}
|
|
};
|
|
|
|
const handleSubmit = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
const next = commit(input);
|
|
if (next === null) return; // invalid pending input
|
|
if (next.length === 0) return;
|
|
|
|
inviteMembers.mutate(next, {
|
|
onSuccess: () => {
|
|
toast.success(
|
|
next.length === 1
|
|
? `Invited ${next[0]}`
|
|
: `Invited ${next.length} people`,
|
|
);
|
|
handleOpenChange(false);
|
|
},
|
|
});
|
|
};
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Add members</DialogTitle>
|
|
<DialogDescription>
|
|
Enter email addresses to add people to this network.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<form onSubmit={handleSubmit}>
|
|
<div className="border-input focus-within:border-ring focus-within:ring-ring/50 flex flex-wrap items-center gap-1.5 rounded-md border px-2 py-1.5 transition-colors focus-within:ring-[3px]">
|
|
{emails.map((email) => (
|
|
<EmailChip
|
|
key={email}
|
|
email={email}
|
|
onRemove={() => setEmails(emails.filter((x) => x !== email))}
|
|
/>
|
|
))}
|
|
<Input
|
|
type="email"
|
|
value={input}
|
|
onChange={(e) => {
|
|
setInput(e.target.value);
|
|
if (error) setError(null);
|
|
}}
|
|
onKeyDown={handleKeyDown}
|
|
onBlur={() => commit(input)}
|
|
placeholder={
|
|
emails.length === 0 ? '[email protected]' : 'Add another…'
|
|
}
|
|
className="h-7 min-w-[8rem] flex-1 border-0 px-1 shadow-none focus-visible:ring-0"
|
|
autoFocus
|
|
/>
|
|
</div>
|
|
{error ? (
|
|
<p className="text-destructive mt-1.5 text-xs">{error}</p>
|
|
) : (
|
|
<Muted className="mt-1.5 text-xs">
|
|
Press Enter or comma to add each email.
|
|
</Muted>
|
|
)}
|
|
<DialogFooter className="mt-4">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
onClick={() => handleOpenChange(false)}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
type="submit"
|
|
disabled={
|
|
inviteMembers.isPending ||
|
|
(emails.length === 0 && input.trim() === '')
|
|
}
|
|
>
|
|
{inviteMembers.isPending ? 'Adding…' : 'Add members'}
|
|
</Button>
|
|
</DialogFooter>
|
|
</form>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|