diff --git a/packages/services/api/src/modules/organization/module.graphql.ts b/packages/services/api/src/modules/organization/module.graphql.ts index 208d749bacf..3bf9415d24c 100644 --- a/packages/services/api/src/modules/organization/module.graphql.ts +++ b/packages/services/api/src/modules/organization/module.graphql.ts @@ -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 @@ -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") diff --git a/packages/services/api/src/modules/organization/providers/organization-manager.ts b/packages/services/api/src/modules/organization/providers/organization-manager.ts index 6fe4226b7f0..504b67ac6bf 100644 --- a/packages/services/api/src/modules/organization/providers/organization-manager.ts +++ b/packages/services/api/src/modules/organization/providers/organization-manager.ts @@ -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', diff --git a/packages/services/api/src/modules/organization/providers/organization-members.ts b/packages/services/api/src/modules/organization/providers/organization-members.ts index abd3de1b17f..5183131d837 100644 --- a/packages/services/api/src/modules/organization/providers/organization-members.ts +++ b/packages/services/api/src/modules/organization/providers/organization-members.ts @@ -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} ${ @@ -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(query); diff --git a/packages/services/api/src/modules/organization/resolvers/Organization.ts b/packages/services/api/src/modules/organization/resolvers/Organization.ts index ed85c305022..0c1e8c36161 100644 --- a/packages/services/api/src/modules/organization/resolvers/Organization.ts +++ b/packages/services/api/src/modules/organization/resolvers/Organization.ts @@ -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 }) => { diff --git a/packages/web/app/src/components/organization/members/list.tsx b/packages/web/app/src/components/organization/members/list.tsx index fc6be5973c7..61ba9dd7bf3 100644 --- a/packages/web/app/src/components/organization/members/list.tsx +++ b/packages/web/app/src/components/organization/members/list.tsx @@ -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'; +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'; @@ -20,6 +21,7 @@ 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'; @@ -27,6 +29,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/comp 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'; @@ -91,10 +94,9 @@ const OrganizationMemberRow_MemberFragment = graphql(` } `); -function OrganizationMemberRow(props: { +const OrganizationMemberRow = React.memo(function OrganizationMemberRow(props: { organization: FragmentType; member: FragmentType; - refetchMembers(): void; }) { const organization = useFragment(OrganizationMembers_OrganizationFragment, props.organization); const member = useFragment(OrganizationMemberRow_MemberFragment, props.member); @@ -212,7 +214,7 @@ function OrganizationMemberRow(props: { ); -} +}); const MemberRole_OrganizationFragment = graphql(` fragment MemberRole_OrganizationFragment on Organization { @@ -286,8 +288,9 @@ const OrganizationMembers_OrganizationFragment = graphql(` owner { id } - members { + members(first: $first, after: $after, filters: { searchTerm: $searchTerm }) { edges { + cursor node { id user { @@ -300,6 +303,12 @@ const OrganizationMembers_OrganizationFragment = graphql(` ...OrganizationMemberRow_MemberFragment } } + pageInfo { + hasNextPage + hasPreviousPage + startCursor + endCursor + } } viewerCanManageInvitations ...MemberInvitationForm_OrganizationFragment @@ -310,111 +319,112 @@ const OrganizationMembers_OrganizationFragment = graphql(` export function OrganizationMembers(props: { organization: FragmentType; 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('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) => { + debouncedSetSearch(e.target.value); }, - [sortByKey, orderDirection], + [debouncedSetSearch], ); + // Cleanup debounced function on unmount + useEffect(() => { + return () => { + debouncedSetSearch.cancel(); + }; + }, [debouncedSetSearch]); + return ( - {organization.viewerCanManageInvitations && ( - + - )} + {organization.viewerCanManageInvitations && ( + + )} + - - - {sortedMembers.map(node => ( - + {members.map(node => ( + ))}
updateSorting('name')} - > + Member - - {sortByKey === 'name' ? ( - orderDirection === 'asc' ? ( - - ) : orderDirection === 'desc' ? ( - - ) : null - ) : null} - updateSorting('role')} - > + Assigned Role - - {sortByKey === 'role' ? ( - orderDirection === 'asc' ? ( - - ) : orderDirection === 'desc' ? ( - - ) : null - ) : null} -
+ {/* Pagination Controls */} +
+
+ Page {props.currentPage + 1} + {search && ` - search results for "${search}"`} +
+
+ + +
+
); } diff --git a/packages/web/app/src/lib/hooks/use-search-params-filters.ts b/packages/web/app/src/lib/hooks/use-search-params-filters.ts index 2bd230605a9..537fff8dc22 100644 --- a/packages/web/app/src/lib/hooks/use-search-params-filters.ts +++ b/packages/web/app/src/lib/hooks/use-search-params-filters.ts @@ -18,7 +18,11 @@ export function useSearchParamsFilter( 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, }); diff --git a/packages/web/app/src/pages/organization-members.tsx b/packages/web/app/src/pages/organization-members.tsx index feadb9ebebc..6a4c402a77d 100644 --- a/packages/web/app/src/pages/organization-members.tsx +++ b/packages/web/app/src/pages/organization-members.tsx @@ -1,4 +1,4 @@ -import { useMemo } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { useQuery } from 'urql'; import { OrganizationLayout, Page } from '@/components/layouts/organization'; import { OrganizationInvitations } from '@/components/organization/members/invitations'; @@ -11,12 +11,21 @@ import { QueryError } from '@/components/ui/query-error'; import { FragmentType, graphql, useFragment } from '@/gql'; import { useRedirect } from '@/lib/access/common'; import { cn } from '@/lib/utils'; +import { organizationMembersRoute } from '../router'; const OrganizationMembersPage_OrganizationFragment = graphql(` fragment OrganizationMembersPage_OrganizationFragment on Organization { ...OrganizationInvitations_OrganizationFragment ...OrganizationMemberRoles_OrganizationFragment ...OrganizationMembers_OrganizationFragment + members(first: $first, after: $after, filters: { searchTerm: $searchTerm }) { + pageInfo { + hasNextPage + hasPreviousPage + startCursor + endCursor + } + } viewerCanManageInvitations viewerCanManageRoles } @@ -44,6 +53,9 @@ function PageContent(props: { onPageChange(page: SubPage): void; organization: FragmentType; refetchQuery(): void; + currentPage: number; + onNextPage(): void; + onPreviousPage(): void; }) { const organization = useFragment( OrganizationMembersPage_OrganizationFragment, @@ -89,7 +101,13 @@ function PageContent(props: { {props.page === 'list' ? ( - + ) : null} {props.page === 'roles' && organization.viewerCanManageRoles ? ( @@ -106,7 +124,12 @@ function PageContent(props: { } const OrganizationMembersPageQuery = graphql(` - query OrganizationMembersPageQuery($organizationSlug: String!) { + query OrganizationMembersPageQuery( + $organizationSlug: String! + $searchTerm: String + $first: Int + $after: String + ) { organization: organizationBySlug(organizationSlug: $organizationSlug) { ...OrganizationMembersPage_OrganizationFragment viewerCanSeeMembers @@ -119,17 +142,59 @@ function OrganizationMembersPageContent(props: { page: SubPage; onPageChange(page: SubPage): void; }) { + const search = organizationMembersRoute.useSearch(); + + // Pagination state + const [cursorHistory, setCursorHistory] = useState>([null]); + const [currentPage, setCurrentPage] = useState(0); + + // Reset pagination when search changes + useEffect(() => { + setCursorHistory([null]); + setCurrentPage(0); + }, [search.search]); + + const queryVariables = useMemo( + () => ({ + organizationSlug: props.organizationSlug, + searchTerm: search.search || undefined, + first: 20, + after: cursorHistory[currentPage], + }), + [props.organizationSlug, search.search, cursorHistory, currentPage], + ); + const [query, refetch] = useQuery({ query: OrganizationMembersPageQuery, - variables: { - organizationSlug: props.organizationSlug, - }, + variables: queryVariables, }); - const currentOrganization = query.data?.organization; + const organization = useFragment( + OrganizationMembersPage_OrganizationFragment, + query.data?.organization, + ); + const pageInfo = organization?.members?.pageInfo; + + // Navigation handlers + const handleNextPage = useCallback(() => { + if (pageInfo?.hasNextPage && pageInfo.endCursor) { + setCursorHistory(prev => [...prev, pageInfo.endCursor!]); + setCurrentPage(prev => prev + 1); + } + }, [pageInfo?.hasNextPage, pageInfo?.endCursor]); + + const handlePreviousPage = useCallback(() => { + if (currentPage > 0) { + setCurrentPage(prev => prev - 1); + } + }, [currentPage]); + + const refetchQuery = useCallback(() => { + refetch({ requestPolicy: 'network-only' }); + }, [refetch]); useRedirect({ - canAccess: currentOrganization?.viewerCanSeeMembers === true, + canAccess: query.data?.organization?.viewerCanSeeMembers === true, redirectTo: router => { void router.navigate({ to: '/$organizationSlug', @@ -138,10 +203,10 @@ function OrganizationMembersPageContent(props: { }, }); }, - entity: currentOrganization, + entity: query.data?.organization, }); - if (currentOrganization?.viewerCanSeeMembers === false) { + if (query.data?.organization?.viewerCanSeeMembers === false) { return null; } @@ -155,14 +220,15 @@ function OrganizationMembersPageContent(props: { page={Page.Members} className="flex flex-col gap-y-10" > - {currentOrganization ? ( + {query.data?.organization ? ( { - refetch({ requestPolicy: 'network-only' }); - }} - organization={currentOrganization} + refetchQuery={refetchQuery} + organization={query.data.organization} + currentPage={currentPage} + onNextPage={handleNextPage} + onPreviousPage={handlePreviousPage} /> ) : null} diff --git a/packages/web/app/src/router.tsx b/packages/web/app/src/router.tsx index a0ed86fa531..0afa5c5258e 100644 --- a/packages/web/app/src/router.tsx +++ b/packages/web/app/src/router.tsx @@ -431,9 +431,10 @@ const organizationSettingsRoute = createRoute({ const OrganizationMembersRouteSearch = z.object({ page: z.enum(['list', 'roles', 'invitations']).catch('list').default('list'), + search: z.string().optional(), }); -const organizationMembersRoute = createRoute({ +export const organizationMembersRoute = createRoute({ getParentRoute: () => organizationRoute, path: 'view/members', validateSearch(search) { @@ -445,7 +446,7 @@ const organizationMembersRoute = createRoute({ const navigate = useNavigate({ from: organizationMembersRoute.fullPath }); const onPageChange = useCallback( (newPage: z.infer['page']) => { - void navigate({ search: { page: newPage } }); + void navigate({ search: { page: newPage, search: '' } }); }, [navigate], );