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
@@ -312,10 +312,12 @@ test('[ORGANISATIONS]: manage groups and members', async ({ page }) => {
await page.getByRole('textbox', { name: 'Group Name *' }).fill('CUSTOM_GROUP');
await page.getByRole('combobox').filter({ hasText: 'Organisation Member' }).click();
await page.getByRole('option', { name: 'Organisation Admin' }).click();
await page.getByRole('combobox').filter({ hasText: 'Select members' }).click();
await page.getByTestId('group-members-picker').click();
await page.getByRole('option', { name: 'Member1' }).click();
await page.getByRole('option', { name: 'Member2' }).click();
await page.getByRole('option', { name: 'Member3' }).click();
// Close the multiselect dropdown so it doesn't overlap the submit button.
await page.getByRole('heading', { name: 'Create group' }).click();
await page.getByTestId('dialog-create-organisation-button').click();
await expect(page.getByText('Group has been created.').first()).toBeVisible();
@@ -338,8 +340,12 @@ test('[ORGANISATIONS]: manage groups and members', async ({ page }) => {
await page.getByRole('textbox', { name: 'Group Name *' }).fill('CUSTOM_GROUP_A');
await page.getByRole('combobox').filter({ hasText: 'Organisation Admin' }).click();
await page.getByRole('option', { name: 'Organisation Member' }).click();
await page.getByRole('combobox').filter({ hasText: 'Member1, Member2, Member3' }).click();
await page.getByRole('option', { name: 'Member3' }).click();
// Remove Member3 by clicking the X on its chip in the multiselect.
await page
.getByTestId('group-members-picker')
.locator('div', { hasText: /^Member3/ })
.getByRole('button', { name: 'Remove' })
.click();
await page.getByRole('button', { name: 'Update' }).click();
await expect(page.getByText('Group has been updated successfully').first()).toBeVisible();
@@ -348,10 +354,12 @@ test('[ORGANISATIONS]: manage groups and members', async ({ page }) => {
// Create a custom member group with the 3 admins to check that they still get the ADMIN roles.
await page.getByRole('button', { name: 'Create group' }).click();
await page.getByRole('textbox', { name: 'Group Name *' }).fill('CUSTOM_GROUP_ADMINS');
await page.getByRole('combobox').filter({ hasText: 'Select members' }).click();
await page.getByTestId('group-members-picker').click();
await page.getByRole('option', { name: 'Admin1' }).click();
await page.getByRole('option', { name: 'Admin2' }).click();
await page.getByRole('option', { name: 'Admin3' }).click();
// Close the multiselect dropdown so it doesn't overlap the submit button.
await page.getByRole('heading', { name: 'Create group' }).click();
await page.getByTestId('dialog-create-organisation-button').click();
await expect(page.getByText('Group has been created.').first()).toBeVisible();
@@ -374,17 +382,21 @@ test('[ORGANISATIONS]: manage groups and members', async ({ page }) => {
await page.getByRole('textbox', { name: 'Group Name *' }).fill('CUSTOM_GROUP_B');
await page.getByRole('combobox').filter({ hasText: 'Organisation Member' }).click();
await page.getByRole('option', { name: 'Organisation Admin' }).click();
await page.getByRole('combobox').filter({ hasText: 'Select members' }).click();
await page.getByTestId('group-members-picker').click();
await page.getByRole('option', { name: 'Member4' }).click();
await page.getByRole('option', { name: 'Member5' }).click();
// Close the multiselect dropdown so it doesn't overlap the submit button.
await page.getByRole('heading', { name: 'Create group' }).click();
await page.getByTestId('dialog-create-organisation-button').click();
await expect(page.getByText('Group has been created.').first()).toBeVisible();
// Assign CUSTOM_GROUP_A to TeamA
await page.goto(`/t/${teamA}/settings/groups`);
await page.getByRole('button', { name: 'Add groups' }).click();
await page.getByRole('combobox').click();
await page.getByTestId('team-groups-picker').click();
await page.getByRole('option', { name: 'CUSTOM_GROUP_A', exact: true }).click();
// Close the multiselect dropdown so it doesn't overlap the Next button.
await page.getByRole('heading', { name: 'Add groups' }).click();
await page.getByRole('button', { name: 'Next' }).click();
await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'Manager' }).click();
@@ -394,8 +406,10 @@ test('[ORGANISATIONS]: manage groups and members', async ({ page }) => {
// Assign CUSTOM_GROUP_B to TeamA
await page.goto(`/t/${teamA}/settings/groups`);
await page.getByRole('button', { name: 'Add groups' }).click();
await page.getByRole('combobox').click();
await page.getByTestId('team-groups-picker').click();
await page.getByRole('option', { name: 'CUSTOM_GROUP_B', exact: true }).click();
// Close the multiselect dropdown so it doesn't overlap the Next button.
await page.getByRole('heading', { name: 'Add groups' }).click();
await page.getByRole('button', { name: 'Next' }).click();
await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'Manager' }).click();
+69
View File
@@ -0,0 +1,69 @@
import { TeamMemberRole } from '@prisma/client';
import { createTeamMembers } from '@documenso/trpc/server/team-router/create-team-members';
import { prisma } from '..';
import { seedTeam } from './teams';
/**
* One-off seed script: creates a team with a large number of members.
*
* Run via:
* npm run with:env -- tsx packages/prisma/seed/large-team-seed.ts
*
* Produces:
* - 1 owner
* - ORG_MEMBER_COUNT organisation members
* - TEAM_MEMBER_COUNT of those org members are also added to the team's role group
*/
const ORG_MEMBER_COUNT = 200;
const TEAM_MEMBER_COUNT = 50;
const seedLargeTeam = async () => {
console.log(`[SEEDING]: Creating team with ${ORG_MEMBER_COUNT} organisation members...`);
const { owner, team, organisation } = await seedTeam({
createTeamMembers: ORG_MEMBER_COUNT,
});
// Exclude the owner — they're already a team member by default.
const nonOwnerOrgMembers = organisation.members.filter((member) => member.userId !== owner.id);
const membersToAttachToTeam = nonOwnerOrgMembers.slice(0, TEAM_MEMBER_COUNT);
console.log(
`[SEEDING]: Attaching ${membersToAttachToTeam.length} org members to the team's role group...`,
);
await createTeamMembers({
userId: owner.id,
teamId: team.id,
membersToCreate: membersToAttachToTeam.map((member) => ({
organisationMemberId: member.id,
teamRole: TeamMemberRole.MEMBER,
})),
});
console.log(`[SEEDING]: Done.`);
console.log(` Owner email: ${owner.email}`);
console.log(` Owner password: password`);
console.log(` Organisation: ${organisation.url} (id ${organisation.id})`);
console.log(` Team URL: ${team.url} (id ${team.id})`);
console.log(` Org members: ${ORG_MEMBER_COUNT}`);
console.log(` Team-group members: ${membersToAttachToTeam.length}`);
};
const main = async () => {
try {
await seedLargeTeam();
} catch (err) {
console.error('[SEEDING]: Failed to seed large team.');
console.error(err);
process.exitCode = 1;
} finally {
await prisma.$disconnect();
}
};
void main();
@@ -17,8 +17,16 @@ export const findOrganisationGroupsRoute = authenticatedProcedure
.input(ZFindOrganisationGroupsRequestSchema)
.output(ZFindOrganisationGroupsResponseSchema)
.query(async ({ input, ctx }) => {
const { organisationId, types, query, page, perPage, organisationGroupId, organisationRoles } =
input;
const {
organisationId,
types,
query,
page,
perPage,
organisationGroupId,
organisationRoles,
excludeTeamId,
} = input;
const { user } = ctx;
ctx.logger.info({
@@ -36,6 +44,7 @@ export const findOrganisationGroupsRoute = authenticatedProcedure
query,
page,
perPage,
excludeTeamId,
});
});
@@ -48,6 +57,7 @@ type FindOrganisationGroupsOptions = {
query?: string;
page?: number;
perPage?: number;
excludeTeamId?: number;
};
export const findOrganisationGroups = async ({
@@ -59,6 +69,7 @@ export const findOrganisationGroups = async ({
query,
page = 1,
perPage = 10,
excludeTeamId,
}: FindOrganisationGroupsOptions) => {
const organisation = await prisma.organisation.findFirst({
where: buildOrganisationWhereQuery({ organisationId, userId }),
@@ -92,6 +103,14 @@ export const findOrganisationGroups = async ({
};
}
// Exclude organisation groups that already have a team-group entry pointing
// at the given team — i.e. they're already attached.
if (excludeTeamId !== undefined) {
whereClause.teamGroups = {
none: { teamId: excludeTeamId },
};
}
const [data, count] = await Promise.all([
prisma.organisationGroup.findMany({
where: whereClause,
@@ -19,6 +19,12 @@ export const ZFindOrganisationGroupsRequestSchema = ZFindSearchParamsSchema.exte
organisationGroupId: z.string().optional(),
organisationRoles: z.nativeEnum(OrganisationMemberRole).array().optional(),
types: z.nativeEnum(OrganisationGroupType).array().optional(),
/**
* Exclude organisation groups that are already attached to the given team.
* Useful for "add groups to team" pickers so that groups already on the
* team don't appear in the dropdown.
*/
excludeTeamId: z.number().optional(),
});
export const ZFindOrganisationGroupsResponseSchema = ZFindResultResponse.extend({
@@ -28,6 +28,7 @@ export const findOrganisationMembersRoute = authenticatedProcedure
query: input.query,
page: input.page,
perPage: input.perPage,
excludeTeamId: input.excludeTeamId,
});
return {
@@ -55,6 +56,7 @@ type FindOrganisationMembersOptions = {
query?: string;
page?: number;
perPage?: number;
excludeTeamId?: number;
};
export const findOrganisationMembers = async ({
@@ -63,6 +65,7 @@ export const findOrganisationMembers = async ({
query,
page = 1,
perPage = 10,
excludeTeamId,
}: FindOrganisationMembersOptions) => {
const organisation = await prisma.organisation.findFirst({
where: buildOrganisationWhereQuery({ organisationId, userId }),
@@ -95,6 +98,21 @@ export const findOrganisationMembers = async ({
};
}
// Exclude organisation members who are already part of the given team —
// i.e. they belong to an organisation group that has a team-group entry
// pointing at the team.
if (excludeTeamId !== undefined) {
whereClause.organisationGroupMembers = {
none: {
group: {
teamGroups: {
some: { teamId: excludeTeamId },
},
},
},
};
}
const [data, count] = await Promise.all([
prisma.organisationMember.findMany({
where: whereClause,
@@ -17,6 +17,12 @@ import { OrganisationMemberSchema } from '@documenso/prisma/generated/zod/modelS
export const ZFindOrganisationMembersRequestSchema = ZFindSearchParamsSchema.extend({
organisationId: z.string(),
/**
* Exclude organisation members who are already members of the given team.
* Useful for "add members to team" pickers so that members already on the
* team don't appear in the dropdown.
*/
excludeTeamId: z.number().optional(),
});
export const ZFindOrganisationMembersResponseSchema = ZFindResultResponse.extend({
@@ -65,6 +65,8 @@ export const createTeamGroupsRoute = authenticatedProcedure
},
});
// Hard validation — these failures indicate programming or authorisation
// errors and should reject the whole request.
const isValid = groups.every((group) => {
const organisationGroup = team.organisation.groups.find(
({ id }) => id === group.organisationGroupId,
@@ -84,11 +86,6 @@ export const createTeamGroupsRoute = authenticatedProcedure
return false;
}
// Check that the group is not already added to the team.
if (organisationGroup.teamGroups.some((teamGroup) => teamGroup.teamId === teamId)) {
return false;
}
// Check that the user has permission to add the group to the team.
if (!isTeamRoleWithinUserHierarchy(currentUserTeamRole, group.teamRole)) {
return false;
@@ -103,8 +100,23 @@ export const createTeamGroupsRoute = authenticatedProcedure
});
}
// Silently drop groups already attached to the team. Makes the create
// idempotent for the common race where a group was added between the
// picker fetch and the submit.
const filteredGroups = groups.filter((group) => {
const organisationGroup = team.organisation.groups.find(
({ id }) => id === group.organisationGroupId,
);
return !organisationGroup?.teamGroups.some((teamGroup) => teamGroup.teamId === teamId);
});
if (filteredGroups.length === 0) {
return;
}
await prisma.teamGroup.createMany({
data: groups.map((group) => ({
data: filteredGroups.map((group) => ({
id: generateDatabaseId('team_group'),
teamId,
organisationGroupId: group.organisationGroupId,
@@ -146,15 +146,55 @@ export const createTeamMembers = async ({
});
}
const teamRoleGroupId = (role: TeamMemberRole) =>
match(role)
.with(TeamMemberRole.MEMBER, () => teamMemberGroup.organisationGroupId)
.with(TeamMemberRole.MANAGER, () => teamManagerGroup.organisationGroupId)
.with(TeamMemberRole.ADMIN, () => teamAdminGroup.organisationGroupId)
.exhaustive();
// Silently drop additions that would duplicate an existing membership in
// the same internal-team role group (the only case that would hit the
// (organisationMemberId, groupId) unique constraint). Members who are
// implicitly part of the team via an INTERNAL_ORGANISATION group are NOT
// dropped, so this still allows assigning an explicit team role on top
// of the inherited org-level membership.
const existingTeamGroupMemberships = await prisma.organisationGroupMember.findMany({
where: {
organisationMemberId: {
in: membersToCreate.map((member) => member.organisationMemberId),
},
groupId: {
in: [
teamMemberGroup.organisationGroupId,
teamManagerGroup.organisationGroupId,
teamAdminGroup.organisationGroupId,
],
},
},
select: { organisationMemberId: true, groupId: true },
});
const existingPairs = new Set(
existingTeamGroupMemberships.map(
({ organisationMemberId, groupId }) => `${organisationMemberId}:${groupId}`,
),
);
const filteredMembersToCreate = membersToCreate.filter(
(member) =>
!existingPairs.has(`${member.organisationMemberId}:${teamRoleGroupId(member.teamRole)}`),
);
if (filteredMembersToCreate.length === 0) {
return;
}
await prisma.organisationGroupMember.createMany({
data: membersToCreate.map((member) => ({
data: filteredMembersToCreate.map((member) => ({
id: generateDatabaseId('group_member'),
organisationMemberId: member.organisationMemberId,
groupId: match(member.teamRole)
.with(TeamMemberRole.MEMBER, () => teamMemberGroup.organisationGroupId)
.with(TeamMemberRole.MANAGER, () => teamManagerGroup.organisationGroupId)
.with(TeamMemberRole.ADMIN, () => teamAdminGroup.organisationGroupId)
.exhaustive(),
groupId: teamRoleGroupId(member.teamRole),
})),
});
};