fix: paginate and search member/group pickers (#2768)

This commit is contained in:
Lucas Smith
2026-05-07 15:03:38 +10:00
committed by GitHub
parent bc3aa9c858
commit f66751668a
14 changed files with 522 additions and 136 deletions
@@ -34,7 +34,6 @@ import {
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { MultiSelectCombobox } from '@documenso/ui/primitives/multi-select-combobox';
import {
Select,
SelectContent,
@@ -44,6 +43,11 @@ import {
} from '@documenso/ui/primitives/select';
import { useToast } from '@documenso/ui/primitives/use-toast';
import {
type OrganisationMemberOption,
OrganisationMembersMultiSelectCombobox,
} from '~/components/general/organisation-members-multiselect-combobox';
export type OrganisationGroupCreateDialogProps = {
trigger?: React.ReactNode;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
@@ -64,6 +68,7 @@ export const OrganisationGroupCreateDialog = ({
const { toast } = useToast();
const [open, setOpen] = useState(false);
const [selectedMembers, setSelectedMembers] = useState<OrganisationMemberOption[]>([]);
const organisation = useCurrentOrganisation();
const form = useForm({
@@ -77,13 +82,6 @@ export const OrganisationGroupCreateDialog = ({
const { mutateAsync: createOrganisationGroup } = trpc.organisation.group.create.useMutation();
const { data: membersFindResult, isLoading: isLoadingMembers } =
trpc.organisation.member.find.useQuery({
organisationId: organisation.id,
});
const members = membersFindResult?.data ?? [];
const onFormSubmit = async ({
name,
organisationRole,
@@ -119,6 +117,7 @@ export const OrganisationGroupCreateDialog = ({
useEffect(() => {
form.reset();
setSelectedMembers([]);
}, [open, form]);
return (
@@ -178,7 +177,7 @@ export const OrganisationGroupCreateDialog = ({
</FormLabel>
<FormControl>
<Select {...field} onValueChange={field.onChange}>
<SelectTrigger className="text-muted-foreground w-full">
<SelectTrigger className="w-full text-muted-foreground">
<SelectValue />
</SelectTrigger>
@@ -209,16 +208,15 @@ export const OrganisationGroupCreateDialog = ({
</FormLabel>
<FormControl>
<MultiSelectCombobox
options={members.map((member) => ({
label: member.name,
value: member.id,
}))}
loading={isLoadingMembers}
selectedValues={field.value}
onChange={field.onChange}
className="bg-background w-full"
emptySelectionPlaceholder={t`Select members`}
<OrganisationMembersMultiSelectCombobox
organisationId={organisation.id}
selectedMembers={selectedMembers}
onChange={(members) => {
setSelectedMembers(members);
field.onChange(members.map((member) => member.id));
}}
className="w-full bg-background"
dataTestId="group-members-picker"
/>
</FormControl>
@@ -1,8 +1,8 @@
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro';
import { OrganisationGroupType, TeamMemberRole } from '@prisma/client';
import { TeamMemberRole } from '@prisma/client';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern';
@@ -31,7 +31,6 @@ import {
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { MultiSelectCombobox } from '@documenso/ui/primitives/multi-select-combobox';
import {
Select,
SelectContent,
@@ -41,6 +40,10 @@ import {
} from '@documenso/ui/primitives/select';
import { useToast } from '@documenso/ui/primitives/use-toast';
import {
type OrganisationGroupOption,
OrganisationGroupsMultiSelectCombobox,
} from '~/components/general/organisation-groups-multiselect-combobox';
import { useCurrentTeam } from '~/providers/team';
export type TeamGroupCreateDialogProps = Omit<DialogPrimitive.DialogProps, 'children'>;
@@ -59,6 +62,7 @@ type TAddTeamMembersFormSchema = z.infer<typeof ZAddTeamMembersFormSchema>;
export const TeamGroupCreateDialog = ({ ...props }: TeamGroupCreateDialogProps) => {
const [open, setOpen] = useState(false);
const [step, setStep] = useState<'SELECT' | 'ROLES'>('SELECT');
const [selectedGroups, setSelectedGroups] = useState<OrganisationGroupOption[]>([]);
const { t } = useLingui();
const { toast } = useToast();
@@ -74,26 +78,6 @@ export const TeamGroupCreateDialog = ({ ...props }: TeamGroupCreateDialogProps)
const { mutateAsync: createTeamGroups } = trpc.team.group.createMany.useMutation();
const organisationGroupQuery = trpc.organisation.group.find.useQuery({
organisationId: team.organisationId,
perPage: 100, // Won't really work if they somehow have more than 100 groups.
types: [OrganisationGroupType.CUSTOM],
});
const teamGroupQuery = trpc.team.group.find.useQuery({
teamId: team.id,
perPage: 100, // Won't really work if they somehow have more than 100 groups.
});
const avaliableOrganisationGroups = useMemo(() => {
const organisationGroups = organisationGroupQuery.data?.data ?? [];
const teamGroups = teamGroupQuery.data?.data ?? [];
return organisationGroups.filter(
(group) => !teamGroups.some((teamGroup) => teamGroup.organisationGroupId === group.id),
);
}, [organisationGroupQuery, teamGroupQuery]);
const onFormSubmit = async ({ groups }: TAddTeamMembersFormSchema) => {
try {
await createTeamGroups({
@@ -121,6 +105,7 @@ export const TeamGroupCreateDialog = ({ ...props }: TeamGroupCreateDialogProps)
if (!open) {
form.reset();
setStep('SELECT');
setSelectedGroups([]);
}
}, [open, form]);
@@ -178,28 +163,24 @@ export const TeamGroupCreateDialog = ({ ...props }: TeamGroupCreateDialogProps)
</FormLabel>
<FormControl>
<MultiSelectCombobox
options={avaliableOrganisationGroups.map((group) => ({
label: group.name ?? group.organisationRole,
value: group.id,
}))}
loading={organisationGroupQuery.isLoading || teamGroupQuery.isLoading}
selectedValues={field.value.map(
({ organisationGroupId }) => organisationGroupId,
)}
onChange={(value) => {
<OrganisationGroupsMultiSelectCombobox
organisationId={team.organisationId}
selectedGroups={selectedGroups}
excludeTeamId={team.id}
onChange={(groups) => {
setSelectedGroups(groups);
field.onChange(
value.map((organisationGroupId) => ({
organisationGroupId,
groups.map((group) => ({
organisationGroupId: group.id,
teamRole:
field.value.find(
(value) => value.organisationGroupId === organisationGroupId,
(value) => value.organisationGroupId === group.id,
)?.teamRole || TeamMemberRole.MEMBER,
})),
);
}}
className="bg-background w-full"
emptySelectionPlaceholder={t`Select groups`}
className="w-full bg-background"
dataTestId="team-groups-picker"
/>
</FormControl>
@@ -243,9 +224,8 @@ export const TeamGroupCreateDialog = ({ ...props }: TeamGroupCreateDialogProps)
readOnly
className="bg-background"
value={
avaliableOrganisationGroups.find(
({ id }) => id === group.organisationGroupId,
)?.name || t`Untitled Group`
selectedGroups.find(({ id }) => id === group.organisationGroupId)
?.name || t`Untitled Group`
}
/>
</div>
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro';
@@ -36,7 +36,6 @@ import {
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { MultiSelectCombobox } from '@documenso/ui/primitives/multi-select-combobox';
import {
Select,
SelectContent,
@@ -48,6 +47,10 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitive
import { useToast } from '@documenso/ui/primitives/use-toast';
import { OrganisationMemberInviteDialog } from '~/components/dialogs/organisation-member-invite-dialog';
import {
type OrganisationMemberOption,
OrganisationMembersMultiSelectCombobox,
} from '~/components/general/organisation-members-multiselect-combobox';
import { useCurrentTeam } from '~/providers/team';
export type TeamMemberCreateDialogProps = {
@@ -69,6 +72,7 @@ export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDi
const [open, setOpen] = useState(false);
const [step, setStep] = useState<'SELECT' | 'MEMBERS'>('SELECT');
const [inviteDialogOpen, setInviteDialogOpen] = useState(false);
const [selectedMembers, setSelectedMembers] = useState<OrganisationMemberOption[]>([]);
const prevInviteDialogOpenRef = useRef(false);
const { t } = useLingui();
@@ -92,25 +96,16 @@ export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDi
const { mutateAsync: createTeamMembers } = trpc.team.member.createMany.useMutation();
const organisationMemberQuery = trpc.organisation.member.find.useQuery({
// Lightweight count-only query for org members not already on this team.
// Powers the "no available members" empty state.
const availableMemberCountQuery = trpc.organisation.member.find.useQuery({
organisationId: team.organisationId,
perPage: 1,
excludeTeamId: team.id,
});
const teamMemberQuery = trpc.team.member.find.useQuery({
teamId: team.id,
});
const avaliableOrganisationMembers = useMemo(() => {
const organisationMembers = organisationMemberQuery.data?.data ?? [];
const teamMembers = teamMemberQuery.data?.data ?? [];
return organisationMembers.filter(
(member) => !teamMembers.some((teamMember) => teamMember.id === member.id),
);
}, [organisationMemberQuery, teamMemberQuery]);
const hasNoAvailableMembers =
!organisationMemberQuery.isLoading && avaliableOrganisationMembers.length === 0;
!availableMemberCountQuery.isLoading && (availableMemberCountQuery.data?.count ?? 0) === 0;
const onFormSubmit = async ({ members }: TAddTeamMembersFormSchema) => {
if (members.length === 0) {
@@ -159,6 +154,7 @@ export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDi
form.reset();
setStep('SELECT');
setInviteDialogOpen(false);
setSelectedMembers([]);
}
}, [open, form]);
@@ -296,29 +292,24 @@ export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDi
)}
</div>
) : (
<MultiSelectCombobox
options={avaliableOrganisationMembers.map((member) => ({
label: member.name,
value: member.id,
}))}
loading={organisationMemberQuery.isLoading}
selectedValues={field.value.map(
(member) => member.organisationMemberId,
)}
onChange={(value) => {
<OrganisationMembersMultiSelectCombobox
organisationId={team.organisationId}
selectedMembers={selectedMembers}
excludeTeamId={team.id}
onChange={(members) => {
setSelectedMembers(members);
field.onChange(
value.map((organisationMemberId) => ({
organisationMemberId,
members.map((member) => ({
organisationMemberId: member.id,
teamRole:
field.value.find(
(member) =>
member.organisationMemberId === organisationMemberId,
(entry) => entry.organisationMemberId === member.id,
)?.teamRole || TeamMemberRole.MEMBER,
})),
);
}}
className="w-full bg-background"
emptySelectionPlaceholder={t`Select members`}
dataTestId="team-members-picker"
/>
)}
</FormControl>
@@ -394,9 +385,8 @@ export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDi
readOnly
className="bg-background"
value={
organisationMemberQuery.data?.data.find(
({ id }) => id === member.organisationMemberId,
)?.name || ''
selectedMembers.find(({ id }) => id === member.organisationMemberId)
?.name || ''
}
/>
</div>
@@ -0,0 +1,115 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { OrganisationGroupType } from '@prisma/client';
import { trpc } from '@documenso/trpc/react';
import { MultiSelect, type Option } from '@documenso/ui/primitives/multiselect';
export type OrganisationGroupOption = {
/** Organisation group ID. */
id: string;
name: string;
};
type OrganisationGroupsMultiSelectComboboxProps = {
organisationId: string;
/**
* Currently selected groups. Must include name so chips render with
* proper labels even before the first server search returns results.
*/
selectedGroups: OrganisationGroupOption[];
onChange: (groups: OrganisationGroupOption[]) => void;
/**
* If set, organisation groups already attached to this team are filtered
* out of the search results server-side. Used by "add groups to team" flows.
*/
excludeTeamId?: number;
/**
* Restrict search to specific group types. Defaults to CUSTOM groups only,
* matching how groups are managed in the organisation settings UI.
*/
types?: OrganisationGroupType[];
/** Number of groups to fetch per search call. Defaults to the schema cap (100). */
perPage?: number;
className?: string;
dataTestId?: string;
};
const toOption = (group: OrganisationGroupOption): Option => ({
value: group.id,
label: group.name,
groupName: group.name,
});
const fromOption = (option: Option): OrganisationGroupOption => ({
id: option.value,
name: typeof option.groupName === 'string' ? option.groupName : option.label,
});
/**
* Searchable multi-select combobox for picking organisation groups,
* backed by `trpc.organisation.group.find` with server-side search.
*
* Renders selected groups as chips and supports an unbounded number of
* organisation groups (paged out via debounced server queries).
*/
export const OrganisationGroupsMultiSelectCombobox = ({
organisationId,
selectedGroups,
onChange,
excludeTeamId,
types = [OrganisationGroupType.CUSTOM],
perPage = 100,
className,
dataTestId,
}: OrganisationGroupsMultiSelectComboboxProps) => {
const { _ } = useLingui();
const utils = trpc.useUtils();
const handleSearch = async (query: string): Promise<Option[]> => {
const result = await utils.organisation.group.find.fetch({
organisationId,
query,
page: 1,
perPage,
types,
excludeTeamId,
});
return result.data.map((group) =>
toOption({
id: group.id,
name: group.name ?? '',
}),
);
};
return (
<MultiSelect
className={className}
data-testid={dataTestId}
commandProps={{ label: _(msg`Select groups`) }}
inputProps={{ 'aria-label': _(msg`Select groups`) }}
placeholder={_(msg`Search groups by name`)}
value={selectedGroups.map(toOption)}
onChange={(options) => onChange(options.map(fromOption))}
onSearch={handleSearch}
triggerSearchOnFocus
hideClearAllButton
hidePlaceholderWhenSelected
delay={300}
loadingIndicator={
<p className="py-4 text-center text-sm text-muted-foreground">
<Trans>Loading...</Trans>
</p>
}
emptyIndicator={
<p className="py-4 text-center text-sm text-muted-foreground">
<Trans>No groups found</Trans>
</p>
}
/>
);
};
@@ -0,0 +1,112 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { trpc } from '@documenso/trpc/react';
import { MultiSelect, type Option } from '@documenso/ui/primitives/multiselect';
export type OrganisationMemberOption = {
/** Organisation member ID. */
id: string;
name: string;
email: string;
};
type OrganisationMembersMultiSelectComboboxProps = {
organisationId: string;
/**
* Currently selected members. Must include name/email so chips render with
* proper labels even before the first server search returns results.
*/
selectedMembers: OrganisationMemberOption[];
onChange: (members: OrganisationMemberOption[]) => void;
/**
* If set, organisation members already on this team are filtered out of the
* search results server-side. Used by "add members to team" flows.
*/
excludeTeamId?: number;
/** Number of members to fetch per search call. Defaults to the schema cap (100). */
perPage?: number;
className?: string;
dataTestId?: string;
};
const toOption = (member: OrganisationMemberOption): Option => ({
value: member.id,
label: member.name ? `${member.name} (${member.email})` : member.email,
// Stash these so we can reconstruct OrganisationMemberOption on change.
email: member.email,
name: member.name,
});
const fromOption = (option: Option): OrganisationMemberOption => ({
id: option.value,
name: typeof option.name === 'string' ? option.name : '',
email: typeof option.email === 'string' ? option.email : '',
});
/**
* Searchable multi-select combobox for picking organisation members,
* backed by `trpc.organisation.member.find` with server-side search.
*
* Renders selected members as chips and supports an unbounded number of
* organisation members (paged out via debounced server queries).
*/
export const OrganisationMembersMultiSelectCombobox = ({
organisationId,
selectedMembers,
onChange,
excludeTeamId,
perPage = 100,
className,
dataTestId,
}: OrganisationMembersMultiSelectComboboxProps) => {
const { _ } = useLingui();
const utils = trpc.useUtils();
const handleSearch = async (query: string): Promise<Option[]> => {
const result = await utils.organisation.member.find.fetch({
organisationId,
query,
page: 1,
perPage,
excludeTeamId,
});
return result.data.map((member) =>
toOption({
id: member.id,
name: member.name,
email: member.email,
}),
);
};
return (
<MultiSelect
className={className}
data-testid={dataTestId}
commandProps={{ label: _(msg`Select members`) }}
inputProps={{ 'aria-label': _(msg`Select members`) }}
placeholder={_(msg`Search members by name or email`)}
value={selectedMembers.map(toOption)}
onChange={(options) => onChange(options.map(fromOption))}
onSearch={handleSearch}
triggerSearchOnFocus
hideClearAllButton
hidePlaceholderWhenSelected
delay={300}
loadingIndicator={
<p className="py-4 text-center text-sm text-muted-foreground">
<Trans>Loading...</Trans>
</p>
}
emptyIndicator={
<p className="py-4 text-center text-sm text-muted-foreground">
<Trans>No members found</Trans>
</p>
}
/>
);
};
@@ -1,4 +1,4 @@
import { useMemo } from 'react';
import { useMemo, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
@@ -17,7 +17,6 @@ import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams-translation
import { AppError } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react';
import type { TFindOrganisationGroupsResponse } from '@documenso/trpc/server/organisation-router/find-organisation-groups.types';
import type { TFindOrganisationMembersResponse } from '@documenso/trpc/server/organisation-router/find-organisation-members.types';
import { Button } from '@documenso/ui/primitives/button';
import { DataTable, type DataTableColumnDef } from '@documenso/ui/primitives/data-table';
import {
@@ -30,7 +29,6 @@ import {
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { MultiSelectCombobox } from '@documenso/ui/primitives/multi-select-combobox';
import {
Select,
SelectContent,
@@ -42,6 +40,10 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
import { OrganisationGroupDeleteDialog } from '~/components/dialogs/organisation-group-delete-dialog';
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
import {
type OrganisationMemberOption,
OrganisationMembersMultiSelectCombobox,
} from '~/components/general/organisation-members-multiselect-combobox';
import { SettingsHeader } from '~/components/general/settings-header';
import type { Route } from './+types/o.$orgUrl.settings.groups.$id';
@@ -53,10 +55,6 @@ export default function OrganisationGroupSettingsPage({ params }: Route.Componen
const groupId = params.id;
const { data: members, isLoading: isLoadingMembers } = trpc.organisation.member.find.useQuery({
organisationId: organisation.id,
});
const { data: groupData, isLoading: isLoadingGroup } = trpc.organisation.group.find.useQuery(
{
organisationId: organisation.id,
@@ -72,10 +70,10 @@ export default function OrganisationGroupSettingsPage({ params }: Route.Componen
const group = groupData?.data.find((g) => g.id === groupId);
if (isLoadingGroup || isLoadingMembers) {
if (isLoadingGroup) {
return (
<div className="flex items-center justify-center rounded-lg py-32">
<Loader className="text-muted-foreground h-6 w-6 animate-spin" />
<Loader className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
@@ -121,7 +119,7 @@ export default function OrganisationGroupSettingsPage({ params }: Route.Componen
/>
</SettingsHeader>
<OrganisationGroupForm group={group} organisationMembers={members?.data || []} />
<OrganisationGroupForm group={group} />
</div>
);
}
@@ -136,10 +134,9 @@ type TUpdateOrganisationGroupFormSchema = z.infer<typeof ZUpdateOrganisationGrou
type OrganisationGroupFormOptions = {
group: TFindOrganisationGroupsResponse['data'][number];
organisationMembers: TFindOrganisationMembersResponse['data'];
};
const OrganisationGroupForm = ({ group, organisationMembers }: OrganisationGroupFormOptions) => {
const OrganisationGroupForm = ({ group }: OrganisationGroupFormOptions) => {
const { toast } = useToast();
const { t } = useLingui();
@@ -147,6 +144,16 @@ const OrganisationGroupForm = ({ group, organisationMembers }: OrganisationGroup
const { mutateAsync: updateOrganisationGroup } = trpc.organisation.group.update.useMutation();
// Track full member details (name/email) keyed by id so chip labels render
// correctly even after the form has been mounted for a while.
const [selectedMembers, setSelectedMembers] = useState<OrganisationMemberOption[]>(() =>
group.members.map((member) => ({
id: member.id,
name: member.name,
email: member.email,
})),
);
const form = useForm<TUpdateOrganisationGroupFormSchema>({
resolver: zodResolver(ZUpdateOrganisationGroupFormSchema),
defaultValues: {
@@ -258,15 +265,15 @@ const OrganisationGroupForm = ({ group, organisationMembers }: OrganisationGroup
<Trans>Members</Trans>
</FormLabel>
<FormControl>
<MultiSelectCombobox
options={organisationMembers.map((member) => ({
label: member.name || member.email,
value: member.id,
}))}
selectedValues={field.value}
onChange={field.onChange}
<OrganisationMembersMultiSelectCombobox
organisationId={organisation.id}
selectedMembers={selectedMembers}
onChange={(members) => {
setSelectedMembers(members);
field.onChange(members.map((member) => member.id));
}}
className="w-full"
emptySelectionPlaceholder={t`Select members`}
dataTestId="group-members-picker"
/>
</FormControl>
<FormDescription>