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>