mirror of
https://github.com/documenso/documenso.git
synced 2026-07-24 08:54:20 +10:00
fix: paginate and search member/group pickers (#2768)
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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),
|
||||
})),
|
||||
});
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user