InviteField.tsx18.8 KBView on GitHub import { useEffect, useMemo, useRef, useState } from 'react';
import { Check, UserPlus, X } from 'lucide-react';
import { PillMenu } from '@/modules/sharing/components/PillMenu';
import { PersonAvatar } from '@/components/ui/person-avatar';
import { parseEmail } from '@/modules/sharing/types';
import { cn } from '@/lib/utils';
/**
* Somebody the field must show as ALREADY HERE rather than as addable.
*
* Deliberately not `AudienceEntry`: an agent's people come from `agentSharing.list`, a
* different table with a different shape and a status a file's audience does not have.
* The field needs four facts about them and no more, so it asks for four — which is
* what lets one field serve both panels instead of one panel growing a second field.
*/
/**
* Somebody the field may offer. The three fields it reads, and not the row type of any
* one route — the org directory reaches it from `organisation.listMembers` on a
* document and from `crm.getOrgMembers` on a deal, which are different shapes.
*/
export interface DirectoryPerson {
id: string;
name: string | null;
email: string | null;
image?: string | null;
}
export interface PresentPerson {
userId: string | null;
name: string | null;
email: string | null;
image?: string | null;
/** What they can already do, beside their name — the answer to "is Ravi on this?". */
roleLabel: string;
}
/** Somebody picked and not yet committed. An address that is not a Cedar user has no id. */
interface StagedPerson {
id: string | null;
name: string | null;
email: string | null;
image?: string | null;
}
/**
* Somebody staged from OUTSIDE the field — an approved access request, today.
*
* The point of routing an approval through here rather than through a grant mutation is
* that the approver ends up looking at the ordinary invite row: the person, the role,
* and a `Share` they have to press. So the approval becomes a share they can read and
* adjust — change the rung, add somebody else, or take the chip out and grant nothing —
* instead of a yes that has already been written by the time they see it.
*
* `key` identifies the staging ACT, not the person. The field re-stages when it changes
* and never otherwise, so a chip the approver deliberately removed does not come back
* on the next render, and pressing Approve again after removing it does.
*/
export interface StagedInvite<TRole extends string> {
key=[redacted];
person: StagedPerson;
role: TRole;
}
/**
* The invite row — a field, the role the invite carries, and the button that sends it.
*
* ── WHY THERE IS A BUTTON AT ALL, AND WHY IT STAGES ─────────────────────────
*
* Clicking a name used to grant immediately, which made the button unnecessary — and
* made the panel unreadable on the case it is most often opened in. An agent nobody
* else has shows ONE row (yours) and a text box, and a text box is not an invitation:
* there is nothing on the surface that says "this is the thing you came here to do".
* A primary button says it, at rest, before anybody types.
*
* A button is only honest if it does the act, so picking now STAGES: chips in the
* field, `Share` commits them. That also buys the thing immediate-grant could not do —
* adding four people is four picks and one send, rather than four separate writes and
* four rows animating in one at a time.
*
* ── WHERE THE ROLE SITS, AND WHY IT IS INSIDE THE FIELD ─────────────────────
*
* At the field's trailing edge, INSIDE its border, with the button outside — Notion's
* arrangement, and the reason is what it excludes. Standing between the field and the
* button it was a third box on the one line the eye travels straight through, and it
* read as a step ("pick a name, then pick a role, then press Share") rather than as a
* property of the thing being composed. Inside the border it is part of the sentence
* the field is writing: *these people, at this level*.
*
* It still defaults to the weakest role the caller offers, which is the only default
* that is not a decision made on the reader's behalf — and it is the caller who says
* what the rungs ARE, because "Can comment" means nothing on an agent and an agent's
* "Admin" means nothing on a deal.
*
* ── WHY THE RESULTS ARE IN TWO NAMED GROUPS ─────────────────────────────────
*
* `Already shared with` / `Not shared with`. The first version simply EXCLUDED anyone
* already on the panel, so typing "Ravi" when Ravi is already an editor returned
* nothing — the same answer the field gives for a name that does not exist. The
* reader's question, "is Ravi on this?", was exactly the question the field refused to
* answer. Showing him is half the fix; saying WHICH GROUP he is in is the other half,
* because a mixed list where some rows are pickable and some are not is one the reader
* has to test by clicking.
*
* ── WHY IT IS AN INPUT AND NOT AN `OptionPicker` ────────────────────────────
*
* `OptionPicker` answers "which one(s) of these?" over a closed list. This field also
* accepts a value that is NOT in the list — an address outside the organisation — and
* marks a whole group unpickable. Both are outside the picker's contract, and bending
* it to fit would change a component eleven other surfaces depend on.
*
* Design: apps/server/docs/sharing.md §3.2 B2-B4.
*/
export function InviteField<TRole extends string>({
directory,
people,
onAdd,
/**
* Absent where there is nothing to invite an outsider TO. Both `files.grantAccess`
* and `agentSharing.share` take an address, so both pass it; a surface that does not
* simply shows no guest row rather than a control that would refuse.
*/
onInviteGuest,
/** The rungs this surface actually has, weakest first. */
roles,
/** What an invite carries until somebody changes it. */
defaultRole,
/**
* Fires while the field is searching or holds a pick. The panel below is REPLACED by
* the results — the alternative is the people you already share with rendered twice
* on one surface, once in the results and once in the list underneath them.
*/
onActiveChange,
/** Somebody the surface above has staged for the reader — see `StagedInvite`. */
staging,
}: {
directory: readonly DirectoryPerson[];
people: readonly PresentPerson[];
roles: ReadonlyArray<{ value: TRole; label: string }>;
defaultRole: TRole;
onAdd: (member: { id: string; name: string | null; email: string | null }, role: TRole) => void;
onInviteGuest?: (email: string, role: TRole) => void;
onActiveChange?: (active: boolean) => void;
staging?: StagedInvite<TRole> | null;
}) {
const [query, setQuery] = useState('');
const [staged, setStaged] = useState<StagedPerson[]>([]);
const [role, setRole] = useState<TRole>(defaultRole);
/**
* Focus opens the list, the way typing does.
*
* A field that shows nothing until a character is typed makes the reader guess
* whether it searches people or takes a free-form address — and on the common case,
* where you want somebody who is already in the directory, the whole answer was one
* keystroke away and invisible. Blur closes it only when focus leaves the FIELD AND
* its results; a click on a result is a blur, and closing on that would make the
* list impossible to use.
*/
const [focused, setFocused] = useState(false);
const input = useRef<HTMLInputElement>(null);
const needle = query.trim().toLowerCase();
const active = focused || needle.length > 0 || staged.length > 0;
useEffect(() => onActiveChange?.(active), [active, onActiveChange]);
/**
* Take what the surface above staged, and stop.
*
* Keyed on the whole `staging` object, which the caller replaces per ACT: re-running
* on every render would put a removed chip straight back, and running on the person's
* identity would refuse to re-stage somebody the approver changed their mind about
* twice. Focus moves to the field because the chip is otherwise a change three
* elements away from where the reader clicked.
*/
useEffect(() => {
if (!staging) return;
const key=[redacted] ?? staging.person.email ?? '';
setStaged((current) =>
current.some((person) => (person.id ?? person.email ?? '') === key)
? current
: [...current, staging.person],
);
setRole(staging.role);
input.current?.focus();
}, [staging]);
/** Everybody already on the panel, by user id and by address. */
const present = useMemo(() => {
const byId = new Set<string>();
const byEmail = new Set<string>();
for (const person of people) {
if (person.userId) byId.add(person.userId);
if (person.email) byEmail.add(person.email.toLowerCase());
}
return { byId, byEmail };
}, [people]);
const stagedIds = useMemo(
() => new Set(staged.map((person) => person.id ?? person.email ?? '')),
[staged],
);
const matches = (name: string | null, email: string | null) =>
!needle ||
(name ?? '').toLowerCase().includes(needle) ||
(email ?? '').toLowerCase().includes(needle);
const already = useMemo(
() => people.filter((person) => matches(person.name, person.email)),
// eslint-disable-next-line react-hooks/exhaustive-deps -- `matches` closes over `needle`
[people, needle],
);
const addable = useMemo(
() => directory.filter((m) => !present.byId.has(m.id) && matches(m.name, m.email)),
// eslint-disable-next-line react-hooks/exhaustive-deps -- `matches` closes over `needle`
[directory, present, needle],
);
const typedEmail = onInviteGuest ? parseEmail(query) : null;
const guest =
typedEmail &&
!present.byEmail.has(typedEmail) &&
!stagedIds.has(typedEmail) &&
!directory.some((m) => (m.email ?? '').toLowerCase() === typedEmail)
? typedEmail
: null;
const nothing = needle.length > 0 && already.length === 0 && addable.length === 0 && !guest;
const toggle = (person: StagedPerson) => {
const key=[redacted] ?? person.email ?? '';
setQuery('');
setStaged((current) =>
stagedIds.has(key)
? current.filter((entry) => (entry.id ?? entry.email ?? '') !== key)
: [...current, person],
);
input.current?.focus();
};
const submit = () => {
for (const person of staged) {
if (person.id) onAdd({ id: person.id, name: person.name, email: person.email }, role);
else if (person.email) onInviteGuest?.(person.email, role);
}
setStaged([]);
setQuery('');
setFocused(false);
// The ROLE too. It is seeded from `defaultRole` and then overwritten by whatever
// the surface staged — approving a request that asked for Full access pins the
// field there — so leaving it behind meant the NEXT person invited from the same
// open panel silently inherited a role nobody picked for them.
setRole(defaultRole);
};
return (
<div
className="flex flex-col px-1.5 pb-1 pt-1.5"
onFocus={() => setFocused(true)}
onBlur={(event) => {
if (!event.currentTarget.contains(event.relatedTarget)) setFocused(false);
}}
>
<div className="flex items-start gap-1.5">
{/* One box: the chips, the caret and the role. The role is INSIDE the border and
the send button is outside it, because the role is part of what is being
composed and the button is what happens to it. */}
<div
onClick={() => input.current?.focus()}
className={cn(
'bg-sunken border-surface-border flex min-h-8 min-w-0 flex-1 cursor-text',
'focus-within:ring-ring/50 items-center gap-1 rounded-lg border py-1 pl-3 pr-1',
'focus-within:ring-2',
)}
>
{/* Only the chips and the caret wrap; the role stays pinned to the trailing
edge however many people are staged. */}
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-1">
{staged.map((person) => (
<span
key=[redacted] ?? person.email}
// `bg-raised`, not `bg-control`: a chip is a raised token sitting ON the
// sunken field, and at one step of lightness it disappeared into it.
className="bg-raised border-surface-border flex max-w-full items-center gap-1 rounded-md border py-0.5 pl-1.5 pr-1 text-xs"
>
<span className="truncate">{person.name ?? person.email}</span>
<button
type="button"
aria-label={`Remove ${person.name ?? person.email}`}
onClick={(event) => {
event.stopPropagation();
toggle(person);
}}
className="hover:bg-hover cursor-pointer rounded"
>
<X className="size-3" aria-hidden />
</button>
</span>
))}
<input
ref={input}
value={query}
onChange={(event) => setQuery(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Escape') setFocused(false);
if (event.key === 'Enter' && staged.length > 0 && !needle) submit();
if (event.key === 'Backspace' && !query && staged.length > 0) {
setStaged((current) => current.slice(0, -1));
}
}}
// Short, because the row also carries the role and the button. The full
// sentence stays as the ACCESSIBLE name, where it costs no width.
placeholder={staged.length > 0 ? '' : 'Email or name'}
aria-label="Add people by name or email"
className="placeholder:text-muted-foreground min-w-20 flex-1 bg-transparent text-sm outline-none"
/>
</div>
<PillMenu
value={role}
label={roles.find((option) => option.value === role)?.label ?? String(role)}
ariaLabel="Invite as"
variant="plain"
className="text-muted-foreground h-6 shrink-0 text-xs"
options={roles.map((option) => ({ value: option.value, label: option.label }))}
onPick={setRole}
/>
</div>
<button
type="button"
disabled={staged.length === 0}
onClick={submit}
className={cn(
'bg-action text-action-foreground hover:bg-action-hover h-8 shrink-0 cursor-pointer',
'rounded-lg px-3 text-xs font-medium transition-colors',
'disabled:pointer-events-none disabled:opacity-40',
)}
>
Share
</button>
</div>
{active ? (
<div className="pt-1.5">
{already.length > 0 ? (
<>
<p className="text-muted-foreground px-2 pb-0.5 text-xs">Already shared with</p>
{already.map((person) => (
// Not a button. Picking it would write a grant that changes nothing, and
// a control that does nothing is worse than the fact it was hiding.
<div
key=[redacted] ?? person.email}
className="flex items-center gap-2 rounded-lg px-2 py-1.5"
>
<PersonAvatar
userId={person.userId ?? person.email ?? ''}
name={person.name}
email={person.email}
image={person.image}
/>
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-sm">{person.name ?? person.email}</span>
{person.name && person.email ? (
<span className="text-muted-foreground truncate text-xs">{person.email}</span>
) : null}
</div>
<span className="text-muted-foreground shrink-0 pr-1.5 text-xs">
{person.roleLabel}
</span>
</div>
))}
</>
) : null}
{addable.length > 0 ? (
<>
<p className="text-muted-foreground px-2 pb-0.5 pt-1 text-xs">Not shared with</p>
{addable.map((member) => {
const picked = stagedIds.has(member.id);
return (
<button
key=[redacted]
type="button"
onClick={() =>
toggle({
id: member.id,
name: member.name,
email: member.email,
image: member.image,
})
}
className="hover:bg-hover flex w-full cursor-pointer items-center gap-2 rounded-lg px-2 py-1.5 text-left"
>
<PersonAvatar
userId={member.id}
name={member.name}
email={member.email}
image={member.image}
/>
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-sm">{member.name ?? member.email}</span>
{member.name && member.email ? (
<span className="text-muted-foreground truncate text-xs">
{member.email}
</span>
) : null}
</div>
{/* The tick's lane is always reserved, so picking never shifts the
list sideways (crystallized.md §3.6). */}
<Check
aria-hidden
className={cn('size-3.5 shrink-0', picked ? '' : 'opacity-0')}
/>
</button>
);
})}
</>
) : null}
{guest ? (
<button
type="button"
onClick={() => toggle({ id: null, name: null, email: guest })}
className="hover:bg-hover flex w-full cursor-pointer items-center gap-2 rounded-lg px-2 py-1.5 text-left"
>
<span
aria-hidden
className="bg-control text-muted-foreground flex size-5 shrink-0 items-center justify-center rounded-full"
>
<UserPlus className="size-3" />
</span>
<span className="truncate text-sm">Invite {guest} as a guest</span>
</button>
) : null}
{nothing ? (
<p className="text-muted-foreground px-2 py-1.5 text-xs">
{onInviteGuest
? 'Nobody by that name. An email address invites a guest.'
: 'Nobody by that name.'}
</p>
) : null}
</div>
) : null}
</div>
);
}