Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions packages/services/api/src/modules/organization/module.graphql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -868,6 +868,14 @@ export default gql`
appDeployments: [String!]
}

input MembersFilter {
"""
Part of a user's email or username that is used to filter the list of
members.
"""
searchTerm: String
}

type Organization {
"""
Unique UUID of the organization
Expand All @@ -881,8 +889,11 @@ export default gql`
name: String! @deprecated(reason: "Use the 'slug' field instead.")
owner: Member! @tag(name: "public")
me: Member!
members(first: Int @tag(name: "public"), after: String @tag(name: "public")): MemberConnection!
@tag(name: "public")
members(
first: Int @tag(name: "public")
after: String @tag(name: "public")
filters: MembersFilter
): MemberConnection! @tag(name: "public")
invitations(
first: Int @tag(name: "public")
after: String @tag(name: "public")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1221,7 +1221,7 @@ export class OrganizationManager {

async getPaginatedOrganizationMembersForOrganization(
organization: Organization,
args: { first: number | null; after: string | null },
args: { first: number | null; after: string | null; searchTerm?: string | null },
) {
await this.session.assertPerformAction({
action: 'member:describe',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -148,21 +148,31 @@ export class OrganizationMembers {

async getPaginatedOrganizationMembersForOrganization(
organization: Organization,
args: { first: number | null; after: string | null },
args: { first: number | null; after: string | null; searchTerm?: string | null },
) {
this.logger.debug(
'Find paginated organization members for organization. (organizationId=%s)',
organization.id,
);

const first = args.first;
const first = args.first ? Math.min(args.first, 50) : 50;
const cursor = args.after ? decodeCreatedAtAndUUIDIdBasedCursor(args.after) : null;
const searchTerm = args.searchTerm ?? '';
const searching = searchTerm.length > 0;

const query = sql`
SELECT
${organizationMemberFields(sql`"om"`)}
FROM
"organization_member" AS "om"
${
searching
? sql`
JOIN "users" as "u"
ON "om"."user_id" = "u"."id"
`
: sql``
}
WHERE
"om"."organization_id" = ${organization.id}
${
Expand All @@ -178,11 +188,12 @@ export class OrganizationMembers {
`
: sql``
}
${searching ? sql`AND ("u"."display_name" ILIKE ${'%' + searchTerm + '%'} OR "u"."email" ILIKE ${'%' + searchTerm + '%'})` : sql``}
ORDER BY
"om"."organization_id" DESC
, "om"."created_at" DESC
, "om"."user_id" DESC
, "om"."user_id" DESC
${first ? sql`LIMIT ${first + 1}` : sql``}
LIMIT ${first + 1}
`;

const result = await this.pool.any<unknown>(query);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ export const Organization: Pick<
.getPaginatedOrganizationMembersForOrganization(organization, {
first: args.first ?? null,
after: args.after ?? null,
searchTerm: args.filters?.searchTerm,
});
},
invitations: async (organization, args, { injector }) => {
Expand Down
170 changes: 90 additions & 80 deletions packages/web/app/src/components/organization/members/list.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useCallback, useMemo, useState } from 'react';
import { MoreHorizontalIcon, MoveDownIcon, MoveUpIcon } from 'lucide-react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import debounce from 'lodash.debounce';
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have a debounce helper you can use:
import { useDebouncedCallback } from 'use-debounce';

import { ChevronLeftIcon, ChevronRightIcon, MoreHorizontalIcon } from 'lucide-react';
import type { IconType } from 'react-icons';
import { FaGithub, FaGoogle, FaOpenid, FaUserLock } from 'react-icons/fa';
import { useMutation } from 'urql';
Expand All @@ -20,13 +21,15 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Input } from '@/components/ui/input';
import { Link } from '@/components/ui/link';
import { SubPageLayout, SubPageLayoutHeader } from '@/components/ui/page-content-layout';
import * as Sheet from '@/components/ui/sheet';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { useToast } from '@/components/ui/use-toast';
import { FragmentType, graphql, useFragment } from '@/gql';
import * as GraphQLSchema from '@/gql/graphql';
import { useSearchParamsFilter } from '@/lib/hooks/use-search-params-filters';
import { MemberInvitationButton } from './invitations';
import { MemberRolePicker } from './member-role-picker';

Expand Down Expand Up @@ -91,10 +94,9 @@ const OrganizationMemberRow_MemberFragment = graphql(`
}
`);

function OrganizationMemberRow(props: {
const OrganizationMemberRow = React.memo(function OrganizationMemberRow(props: {
organization: FragmentType<typeof OrganizationMembers_OrganizationFragment>;
member: FragmentType<typeof OrganizationMemberRow_MemberFragment>;
refetchMembers(): void;
}) {
const organization = useFragment(OrganizationMembers_OrganizationFragment, props.organization);
const member = useFragment(OrganizationMemberRow_MemberFragment, props.member);
Expand Down Expand Up @@ -212,7 +214,7 @@ function OrganizationMemberRow(props: {
</tr>
</>
);
}
});

const MemberRole_OrganizationFragment = graphql(`
fragment MemberRole_OrganizationFragment on Organization {
Expand Down Expand Up @@ -286,8 +288,9 @@ const OrganizationMembers_OrganizationFragment = graphql(`
owner {
id
}
members {
members(first: $first, after: $after, filters: { searchTerm: $searchTerm }) {
edges {
cursor
node {
id
user {
Expand All @@ -300,6 +303,12 @@ const OrganizationMembers_OrganizationFragment = graphql(`
...OrganizationMemberRow_MemberFragment
}
}
pageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
}
}
viewerCanManageInvitations
...MemberInvitationForm_OrganizationFragment
Expand All @@ -310,111 +319,112 @@ const OrganizationMembers_OrganizationFragment = graphql(`
export function OrganizationMembers(props: {
organization: FragmentType<typeof OrganizationMembers_OrganizationFragment>;
refetchMembers(): void;
currentPage: number;
onNextPage(): void;
onPreviousPage(): void;
}) {
const organization = useFragment(OrganizationMembers_OrganizationFragment, props.organization);
const members = organization.members?.edges?.map(edge => edge.node);
const [orderDirection, setOrderDirection] = useState<'asc' | 'desc' | null>(null);
const [sortByKey, setSortByKey] = useState<'name' | 'role'>('name');

const sortedMembers = useMemo(() => {
if (!members) {
return [];
}

if (!orderDirection) {
return members ?? [];
}

const sorted = [...members].sort((a, b) => {
if (sortByKey === 'name') {
return a.user.displayName.localeCompare(b.user.displayName);
}
const pageInfo = organization.members?.pageInfo;

if (sortByKey === 'role') {
return (a.role?.name ?? 'Select role').localeCompare(b.role?.name ?? 'Select role') ?? 0;
}

return 0;
});
const [search, setSearch] = useSearchParamsFilter<string>('search', '');

return orderDirection === 'asc' ? sorted : sorted.reverse();
}, [members, orderDirection, sortByKey]);
// Debounced search to prevent excessive queries
const debouncedSetSearch = useMemo(
() => debounce((value: string) => setSearch(value), 300),
[setSearch],
);

const updateSorting = useCallback(
(newSortBy: 'name' | 'role') => {
if (newSortBy === sortByKey) {
setOrderDirection(
orderDirection === 'asc' ? 'desc' : orderDirection === 'desc' ? null : 'asc',
);
} else {
setSortByKey(newSortBy);
setOrderDirection('asc');
}
const handleSearchChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
debouncedSetSearch(e.target.value);
},
[sortByKey, orderDirection],
[debouncedSetSearch],
);

// Cleanup debounced function on unmount
useEffect(() => {
return () => {
debouncedSetSearch.cancel();
};
}, [debouncedSetSearch]);

return (
<SubPageLayout>
<SubPageLayoutHeader
subPageTitle="List of organization members"
description="Manage the members of your organization and their permissions."
>
{organization.viewerCanManageInvitations && (
<MemberInvitationButton
refetchInvitations={props.refetchMembers}
organization={organization}
<div className="flex flex-row gap-4">
<Input
className="w-[220px] grow cursor-text"
placeholder="Search by username or email"
onChange={handleSearchChange}
defaultValue={search}
/>
)}
{organization.viewerCanManageInvitations && (
<MemberInvitationButton
refetchInvitations={props.refetchMembers}
organization={organization}
/>
)}
</div>
</SubPageLayoutHeader>
<table className="w-full table-auto divide-y-[1px] divide-gray-500/20">
<thead>
<tr>
<th
colSpan={2}
className="relative cursor-pointer select-none py-3 text-left text-sm font-semibold"
onClick={() => updateSorting('name')}
>
<th colSpan={2} className="relative select-none py-3 text-left text-sm font-semibold">
Member
<span className="inline-block">
{sortByKey === 'name' ? (
orderDirection === 'asc' ? (
<MoveUpIcon className="relative top-[3px] size-4" />
) : orderDirection === 'desc' ? (
<MoveDownIcon className="relative top-[3px] size-4" />
) : null
) : null}
</span>
</th>
<th
className="relative w-[300px] cursor-pointer select-none py-3 text-center align-middle text-sm font-semibold"
onClick={() => updateSorting('role')}
>
<th className="relative w-[300px] select-none py-3 text-center align-middle text-sm font-semibold">
Assigned Role
<span className="inline-block">
{sortByKey === 'role' ? (
orderDirection === 'asc' ? (
<MoveUpIcon className="relative top-[3px] size-4" />
) : orderDirection === 'desc' ? (
<MoveDownIcon className="relative top-[3px] size-4" />
) : null
) : null}
</span>
</th>
<th className="w-12 py-3 text-right text-sm font-semibold" />
</tr>
</thead>
<tbody className="divide-y-[1px] divide-gray-500/20">
{sortedMembers.map(node => (
<OrganizationMemberRow
key={node.id}
refetchMembers={props.refetchMembers}
organization={props.organization}
member={node}
/>
{members.map(node => (
<OrganizationMemberRow key={node.id} organization={props.organization} member={node} />
))}
</tbody>
</table>
{/* Pagination Controls */}
<div className="mt-4 flex items-center justify-between">
<div className="text-sm text-gray-500">
Page {props.currentPage + 1}
{search && ` - search results for "${search}"`}
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => {
props.onPreviousPage();
setTimeout(() => {
window.scrollTo({ top: 0, behavior: 'smooth' });
Copy link
Collaborator

@jdolle jdolle Nov 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should this scroll to the top of the list instead of all the way back to 0?
I'm not sure which is better UX given our design.

}, 0);
}}
disabled={props.currentPage === 0}
>
<ChevronLeftIcon className="mr-1 size-4" />
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
props.onNextPage();
setTimeout(() => {
window.scrollTo({ top: 0, behavior: 'smooth' });
}, 0);
}}
disabled={!pageInfo?.hasNextPage}
>
Next
<ChevronRightIcon className="ml-1 size-4" />
</Button>
</div>
</div>
</SubPageLayout>
);
}
6 changes: 5 additions & 1 deletion packages/web/app/src/lib/hooks/use-search-params-filters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,11 @@ export function useSearchParamsFilter<TValue extends SearchParamsFilter>(
search: {
...searchParams,
[name]:
Array.isArray(value) && value.length === 0 ? undefined : serializeSearchValue(value),
value.length === 0
? undefined
: Array.isArray(value) && value.length === 0
? undefined
: serializeSearchValue(value),
},
replace: true,
});
Expand Down
Loading
Loading