From 5825c220f3c17a96f50a4683f08acf31e49be682 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Mon, 17 Nov 2025 19:38:00 -0800 Subject: [PATCH 01/31] fix pagination and add filter to Organization.members --- .../modules/organization/module.graphql.ts | 15 +++++++++++++-- .../providers/organization-manager.ts | 2 +- .../providers/organization-members.ts | 19 +++++++++++++++---- .../organization/resolvers/Organization.ts | 1 + 4 files changed, 30 insertions(+), 7 deletions(-) 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..34eb22de6c5 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 to_tsvector("u"."display_name" || ' ' || "u"."email") @@ to_tsquery('simple', ${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 }) => { From 546b5507db06de1fb3dece3ab214eb3fb63732a9 Mon Sep 17 00:00:00 2001 From: Jonathan Brennan Date: Thu, 20 Nov 2025 20:58:24 -0600 Subject: [PATCH 02/31] update route --- packages/web/app/src/router.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/web/app/src/router.tsx b/packages/web/app/src/router.tsx index a0ed86fa531..c78b0150463 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().default(''), }); -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], ); From abbd0e4d2361f017fa55324e4c4883d8ce8b7bd1 Mon Sep 17 00:00:00 2001 From: Jonathan Brennan Date: Thu, 20 Nov 2025 21:04:59 -0600 Subject: [PATCH 03/31] remove empty default search param string --- packages/web/app/src/router.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/web/app/src/router.tsx b/packages/web/app/src/router.tsx index c78b0150463..0afa5c5258e 100644 --- a/packages/web/app/src/router.tsx +++ b/packages/web/app/src/router.tsx @@ -431,7 +431,7 @@ const organizationSettingsRoute = createRoute({ const OrganizationMembersRouteSearch = z.object({ page: z.enum(['list', 'roles', 'invitations']).catch('list').default('list'), - search: z.string().optional().default(''), + search: z.string().optional(), }); export const organizationMembersRoute = createRoute({ From e1a55461ca72fd82d9c43d75ea8372b16f8f9b9b Mon Sep 17 00:00:00 2001 From: Jonathan Brennan Date: Thu, 20 Nov 2025 21:06:35 -0600 Subject: [PATCH 04/31] update query --- packages/web/app/src/pages/organization-members.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/web/app/src/pages/organization-members.tsx b/packages/web/app/src/pages/organization-members.tsx index feadb9ebebc..c471cca9116 100644 --- a/packages/web/app/src/pages/organization-members.tsx +++ b/packages/web/app/src/pages/organization-members.tsx @@ -11,6 +11,7 @@ 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 { @@ -106,7 +107,7 @@ function PageContent(props: { } const OrganizationMembersPageQuery = graphql(` - query OrganizationMembersPageQuery($organizationSlug: String!) { + query OrganizationMembersPageQuery($organizationSlug: String!, $searchTerm: String) { organization: organizationBySlug(organizationSlug: $organizationSlug) { ...OrganizationMembersPage_OrganizationFragment viewerCanSeeMembers @@ -119,10 +120,13 @@ function OrganizationMembersPageContent(props: { page: SubPage; onPageChange(page: SubPage): void; }) { + const search = organizationMembersRoute.useSearch(); + const [query, refetch] = useQuery({ query: OrganizationMembersPageQuery, variables: { organizationSlug: props.organizationSlug, + searchTerm: search.search || undefined, }, }); From 64578a0b558bd8fd4381e2d5c9fdca7a7bd07742 Mon Sep 17 00:00:00 2001 From: Jonathan Brennan Date: Thu, 20 Nov 2025 21:08:03 -0600 Subject: [PATCH 05/31] strip sorting from UI, remove unused refetch prop, init basic input field for testing --- .../components/organization/members/list.tsx | 102 ++++++------------ 1 file changed, 32 insertions(+), 70 deletions(-) diff --git a/packages/web/app/src/components/organization/members/list.tsx b/packages/web/app/src/components/organization/members/list.tsx index fc6be5973c7..0533de4e1e1 100644 --- a/packages/web/app/src/components/organization/members/list.tsx +++ b/packages/web/app/src/components/organization/members/list.tsx @@ -1,5 +1,5 @@ -import { useCallback, useMemo, useState } from 'react'; -import { MoreHorizontalIcon, MoveDownIcon, MoveUpIcon } from 'lucide-react'; +import { ChangeEvent, useCallback, useState } from 'react'; +import { 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 +20,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 +28,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 { useRouter } from '@tanstack/react-router'; import { MemberInvitationButton } from './invitations'; import { MemberRolePicker } from './member-role-picker'; @@ -94,7 +96,6 @@ const OrganizationMemberRow_MemberFragment = graphql(` function OrganizationMemberRow(props: { organization: FragmentType; member: FragmentType; - refetchMembers(): void; }) { const organization = useFragment(OrganizationMembers_OrganizationFragment, props.organization); const member = useFragment(OrganizationMemberRow_MemberFragment, props.member); @@ -286,7 +287,7 @@ const OrganizationMembers_OrganizationFragment = graphql(` owner { id } - members { + members(filters: { searchTerm: $searchTerm }) { edges { node { id @@ -313,53 +314,41 @@ export function OrganizationMembers(props: { }) { 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); - } - if (sortByKey === 'role') { - return (a.role?.name ?? 'Select role').localeCompare(b.role?.name ?? 'Select role') ?? 0; - } + const router = useRouter(); - return 0; - }); - - return orderDirection === 'asc' ? sorted : sorted.reverse(); - }, [members, orderDirection, sortByKey]); - - const updateSorting = useCallback( - (newSortBy: 'name' | 'role') => { - if (newSortBy === sortByKey) { - setOrderDirection( - orderDirection === 'asc' ? 'desc' : orderDirection === 'desc' ? null : 'asc', - ); - } else { - setSortByKey(newSortBy); - setOrderDirection('asc'); - } + const onChange = useCallback( + (e: ChangeEvent) => { + void router.navigate({ + search: { + ...router.latestLocation.search, + search: e.target.value === '' ? undefined : e.target.value, + }, + // don't write to history + replace: true, + }); }, - [sortByKey, orderDirection], + [router], ); + const initialValue = + 'search' in router.latestLocation.search && + typeof router.latestLocation.search.search === 'string' + ? router.latestLocation.search.search + : ''; + return ( + {organization.viewerCanManageInvitations && ( updateSorting('name')} > Member - - {sortByKey === 'name' ? ( - orderDirection === 'asc' ? ( - - ) : orderDirection === 'desc' ? ( - - ) : null - ) : null} - - updateSorting('role')} - > + Assigned Role - - {sortByKey === 'role' ? ( - orderDirection === 'asc' ? ( - - ) : orderDirection === 'desc' ? ( - - ) : null - ) : null} - - {sortedMembers.map(node => ( - + {members.map(node => ( + ))} From 8e820f29fcdaa4e81cb05ef39449ade3ea662654 Mon Sep 17 00:00:00 2001 From: Jonathan Brennan Date: Fri, 21 Nov 2025 18:00:29 -0600 Subject: [PATCH 06/31] init filter ui --- .../components/organization/members/list.tsx | 50 ++++++------------- 1 file changed, 16 insertions(+), 34 deletions(-) diff --git a/packages/web/app/src/components/organization/members/list.tsx b/packages/web/app/src/components/organization/members/list.tsx index 0533de4e1e1..ccf12b9513a 100644 --- a/packages/web/app/src/components/organization/members/list.tsx +++ b/packages/web/app/src/components/organization/members/list.tsx @@ -1,4 +1,4 @@ -import { ChangeEvent, useCallback, useState } from 'react'; +import { useState } from 'react'; import { MoreHorizontalIcon } from 'lucide-react'; import type { IconType } from 'react-icons'; import { FaGithub, FaGoogle, FaOpenid, FaUserLock } from 'react-icons/fa'; @@ -28,7 +28,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 { useRouter } from '@tanstack/react-router'; +import { useSearchParamsFilter } from '@/lib/hooks/use-search-params-filters'; import { MemberInvitationButton } from './invitations'; import { MemberRolePicker } from './member-role-picker'; @@ -315,27 +315,7 @@ export function OrganizationMembers(props: { const organization = useFragment(OrganizationMembers_OrganizationFragment, props.organization); const members = organization.members?.edges?.map(edge => edge.node); - const router = useRouter(); - - const onChange = useCallback( - (e: ChangeEvent) => { - void router.navigate({ - search: { - ...router.latestLocation.search, - search: e.target.value === '' ? undefined : e.target.value, - }, - // don't write to history - replace: true, - }); - }, - [router], - ); - - const initialValue = - 'search' in router.latestLocation.search && - typeof router.latestLocation.search.search === 'string' - ? router.latestLocation.search.search - : ''; + const [search, setSearch] = useSearchParamsFilter('search', ''); return ( @@ -343,18 +323,20 @@ export function OrganizationMembers(props: { subPageTitle="List of organization members" description="Manage the members of your organization and their permissions." > - - {organization.viewerCanManageInvitations && ( - + setSearch(e.target.value)} + defaultValue={search} /> - )} + {organization.viewerCanManageInvitations && ( + + )} + From 5a125472af0a4e72ea513bf0feb6282523037f56 Mon Sep 17 00:00:00 2001 From: Jonathan Brennan Date: Fri, 21 Nov 2025 18:01:01 -0600 Subject: [PATCH 07/31] update hook to clear single value param if length === 0 --- packages/web/app/src/lib/hooks/use-search-params-filters.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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, }); From a2e903726765df6aeec53f37fd49b36a84dac727 Mon Sep 17 00:00:00 2001 From: Jonathan Brennan Date: Fri, 21 Nov 2025 19:41:19 -0600 Subject: [PATCH 08/31] initial pagination work --- .../components/organization/members/list.tsx | 68 ++++++++++++-- .../app/src/pages/organization-members.tsx | 94 +++++++++++++++---- 2 files changed, 140 insertions(+), 22 deletions(-) diff --git a/packages/web/app/src/components/organization/members/list.tsx b/packages/web/app/src/components/organization/members/list.tsx index ccf12b9513a..fc90e0c417f 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 { useState } from 'react'; -import { MoreHorizontalIcon } 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'; @@ -93,7 +94,7 @@ const OrganizationMemberRow_MemberFragment = graphql(` } `); -function OrganizationMemberRow(props: { +const OrganizationMemberRow = React.memo(function OrganizationMemberRow(props: { organization: FragmentType; member: FragmentType; }) { @@ -213,7 +214,7 @@ function OrganizationMemberRow(props: { ); -} +}); const MemberRole_OrganizationFragment = graphql(` fragment MemberRole_OrganizationFragment on Organization { @@ -287,8 +288,9 @@ const OrganizationMembers_OrganizationFragment = graphql(` owner { id } - members(filters: { searchTerm: $searchTerm }) { + members(first: $first, after: $after, filters: { searchTerm: $searchTerm }) { edges { + cursor node { id user { @@ -301,6 +303,12 @@ const OrganizationMembers_OrganizationFragment = graphql(` ...OrganizationMemberRow_MemberFragment } } + pageInfo { + hasNextPage + hasPreviousPage + startCursor + endCursor + } } viewerCanManageInvitations ...MemberInvitationForm_OrganizationFragment @@ -311,12 +319,36 @@ 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 pageInfo = organization.members?.pageInfo; const [search, setSearch] = useSearchParamsFilter('search', ''); + // Debounced search to prevent excessive queries + const debouncedSetSearch = useMemo( + () => debounce((value: string) => setSearch(value), 300), + [setSearch], + ); + + const handleSearchChange = useCallback( + (e: React.ChangeEvent) => { + debouncedSetSearch(e.target.value); + }, + [debouncedSetSearch], + ); + + // Cleanup debounced function on unmount + useEffect(() => { + return () => { + debouncedSetSearch.cancel(); + }; + }, [debouncedSetSearch]); + return ( setSearch(e.target.value)} + onChange={handleSearchChange} defaultValue={search} /> {organization.viewerCanManageInvitations && ( @@ -359,6 +391,30 @@ export function OrganizationMembers(props: { ))}
+ {/* Pagination Controls */} +
+
Page {props.currentPage + 1}
+
+ + +
+
); } diff --git a/packages/web/app/src/pages/organization-members.tsx b/packages/web/app/src/pages/organization-members.tsx index c471cca9116..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'; @@ -18,6 +18,14 @@ const OrganizationMembersPage_OrganizationFragment = graphql(` ...OrganizationInvitations_OrganizationFragment ...OrganizationMemberRoles_OrganizationFragment ...OrganizationMembers_OrganizationFragment + members(first: $first, after: $after, filters: { searchTerm: $searchTerm }) { + pageInfo { + hasNextPage + hasPreviousPage + startCursor + endCursor + } + } viewerCanManageInvitations viewerCanManageRoles } @@ -45,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, @@ -90,7 +101,13 @@ function PageContent(props: { {props.page === 'list' ? ( - + ) : null} {props.page === 'roles' && organization.viewerCanManageRoles ? ( @@ -107,7 +124,12 @@ function PageContent(props: { } const OrganizationMembersPageQuery = graphql(` - query OrganizationMembersPageQuery($organizationSlug: String!, $searchTerm: String) { + query OrganizationMembersPageQuery( + $organizationSlug: String! + $searchTerm: String + $first: Int + $after: String + ) { organization: organizationBySlug(organizationSlug: $organizationSlug) { ...OrganizationMembersPage_OrganizationFragment viewerCanSeeMembers @@ -122,18 +144,57 @@ function OrganizationMembersPageContent(props: { }) { const search = organizationMembersRoute.useSearch(); - const [query, refetch] = useQuery({ - query: OrganizationMembersPageQuery, - variables: { + // 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: 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', @@ -142,10 +203,10 @@ function OrganizationMembersPageContent(props: { }, }); }, - entity: currentOrganization, + entity: query.data?.organization, }); - if (currentOrganization?.viewerCanSeeMembers === false) { + if (query.data?.organization?.viewerCanSeeMembers === false) { return null; } @@ -159,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} From dea8fc1e52d516e1ff0c470d1062a930d1d0e5e1 Mon Sep 17 00:00:00 2001 From: Jonathan Brennan Date: Fri, 21 Nov 2025 19:46:18 -0600 Subject: [PATCH 09/31] fix wonky edge case when clicking prev page button --- .../src/components/organization/members/list.tsx | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/web/app/src/components/organization/members/list.tsx b/packages/web/app/src/components/organization/members/list.tsx index fc90e0c417f..f717e18623f 100644 --- a/packages/web/app/src/components/organization/members/list.tsx +++ b/packages/web/app/src/components/organization/members/list.tsx @@ -398,7 +398,12 @@ export function OrganizationMembers(props: { {isOpen && ( Date: Tue, 25 Nov 2025 09:19:27 -0600 Subject: [PATCH 24/31] small refactor to remove confusing double selection of members --- .../components/organization/members/list.tsx | 15 +++------- .../app/src/pages/organization-members.tsx | 28 ++++--------------- 2 files changed, 10 insertions(+), 33 deletions(-) diff --git a/packages/web/app/src/components/organization/members/list.tsx b/packages/web/app/src/components/organization/members/list.tsx index d6db33847c9..dd047920dff 100644 --- a/packages/web/app/src/components/organization/members/list.tsx +++ b/packages/web/app/src/components/organization/members/list.tsx @@ -85,7 +85,6 @@ const OrganizationMemberRow_MemberFragment = graphql(` } role { id - name } isOwner viewerCanRemove @@ -235,7 +234,6 @@ const MemberRole_MemberFragment = graphql(` projects { project { id - slug } } } @@ -291,23 +289,16 @@ const OrganizationMembers_OrganizationFragment = graphql(` } members(first: $first, after: $after, filters: { searchTerm: $searchTerm }) { edges { - cursor node { id - user { - displayName - } role { id - name } ...OrganizationMemberRow_MemberFragment } } pageInfo { hasNextPage - hasPreviousPage - startCursor endCursor } } @@ -321,7 +312,7 @@ export function OrganizationMembers(props: { organization: FragmentType; refetchMembers(): void; currentPage: number; - onNextPage(): void; + onNextPage(endCursor: string): void; onPreviousPage(): void; }) { const organization = useFragment(OrganizationMembers_OrganizationFragment, props.organization); @@ -416,7 +407,9 @@ export function OrganizationMembers(props: { variant="outline" size="sm" onClick={() => { - props.onNextPage(); + if (pageInfo?.endCursor) { + props.onNextPage(pageInfo.endCursor); + } setTimeout(() => { window.scrollTo({ top: 0, behavior: 'smooth' }); }, 0); diff --git a/packages/web/app/src/pages/organization-members.tsx b/packages/web/app/src/pages/organization-members.tsx index 6b38cb6fee0..d40ea524eb3 100644 --- a/packages/web/app/src/pages/organization-members.tsx +++ b/packages/web/app/src/pages/organization-members.tsx @@ -18,14 +18,7 @@ const OrganizationMembersPage_OrganizationFragment = graphql(` ...OrganizationInvitations_OrganizationFragment ...OrganizationMemberRoles_OrganizationFragment ...OrganizationMembers_OrganizationFragment - members(first: $first, after: $after, filters: { searchTerm: $searchTerm }) { - pageInfo { - hasNextPage - hasPreviousPage - startCursor - endCursor - } - } + viewerCanManageInvitations viewerCanManageRoles } @@ -54,7 +47,7 @@ function PageContent(props: { organization: FragmentType; refetchQuery(): void; currentPage: number; - onNextPage(): void; + onNextPage(endCursor: string): void; onPreviousPage(): void; }) { const organization = useFragment( @@ -166,19 +159,10 @@ function OrganizationMembersPageContent(props: { variables: queryVariables, }); - 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 handleNextPage = useCallback((endCursor: string) => { + setCursorHistory(prev => [...prev, endCursor]); + setCurrentPage(prev => prev + 1); + }, []); const handlePreviousPage = useCallback(() => { if (currentPage > 0) { From 34a0dc906a7f1371f179772c245cf0b383a773d9 Mon Sep 17 00:00:00 2001 From: Jonathan Brennan Date: Tue, 25 Nov 2025 12:02:47 -0600 Subject: [PATCH 25/31] relocate all pagination code into the actual view file --- .../components/organization/members/list.tsx | 61 +++++++++++++----- .../app/src/pages/organization-members.tsx | 63 ++++++------------- 2 files changed, 66 insertions(+), 58 deletions(-) diff --git a/packages/web/app/src/components/organization/members/list.tsx b/packages/web/app/src/components/organization/members/list.tsx index dd047920dff..828a9e57d94 100644 --- a/packages/web/app/src/components/organization/members/list.tsx +++ b/packages/web/app/src/components/organization/members/list.tsx @@ -1,8 +1,8 @@ -import { memo, useState } from 'react'; +import { memo, useEffect, useState } from 'react'; 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'; +import { useMutation, type UseQueryExecute } from 'urql'; import { useDebouncedCallback } from 'use-debounce'; import { AlertDialog, @@ -29,6 +29,7 @@ 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 { organizationMembersRoute } from '../../../router'; import { MemberInvitationButton } from './invitations'; import { MemberRolePicker } from './member-role-picker'; @@ -310,21 +311,51 @@ const OrganizationMembers_OrganizationFragment = graphql(` export function OrganizationMembers(props: { organization: FragmentType; - refetchMembers(): void; - currentPage: number; - onNextPage(endCursor: string): void; - onPreviousPage(): void; + refetchMembers: UseQueryExecute; + /** + * The setter for the reactive "after" variable required by urql + */ + setAfter: (after: string | null) => void; }) { + // Pagination state + const [cursorHistory, setCursorHistory] = useState>([null]); + const [currentPage, setCurrentPage] = useState(0); + + const search = organizationMembersRoute.useSearch(); + const organization = useFragment(OrganizationMembers_OrganizationFragment, props.organization); const members = organization.members?.edges?.map(edge => edge.node); const pageInfo = organization.members?.pageInfo; - const [search, setSearch] = useSearchParamsFilter('search', ''); + // Reset pagination when search changes + useEffect(() => { + setCursorHistory([null]); + setCurrentPage(0); + props.setAfter(null); + }, [search.search]); + + useEffect(() => { + // Update the cursor in parent, which will trigger query refetch + props.setAfter(cursorHistory[currentPage]); + }, [currentPage]); + + const [searchValue, setSearchValue] = useSearchParamsFilter('search', ''); const handleSearchChange = useDebouncedCallback((e: React.ChangeEvent) => { - setSearch(e.target.value); + setSearchValue(e.target.value); }, 300); + const handleNextPage = (endCursor: string) => { + setCursorHistory(prev => [...prev, endCursor]); + setCurrentPage(prev => prev + 1); + }; + + const handlePreviousPage = () => { + if (currentPage > 0) { + setCurrentPage(prev => prev - 1); + } + }; + return ( {organization.viewerCanManageInvitations && ( No members found

- {`No results for "${search}". Try adjusting your search term.`} + {`No results for "${searchValue}". Try adjusting your search term.`}

@@ -385,20 +416,20 @@ export function OrganizationMembers(props: { {/* Pagination Controls */}
- Page {props.currentPage + 1} - {search && members.length > 0 && ` - search results for "${search}"`} + Page {currentPage + 1} + {searchValue && members.length > 0 && ` - showing results for "${searchValue}"`}
- {!query.fetching && !query.stale ? ( + + {!props.hasCollectedOperations ? ( +
+ +
+ ) : !query.fetching && !query.stale ? ( <> {latestValidSchemaVersion?.unusedSchema && latestSchemaVersion ? ( <> @@ -426,21 +436,10 @@ function ExplorerUnusedSchemaPageContent(props: { return null; } - if (!hasCollectedOperations) { - return ( -
- -
- ); - } - return ( Date: Tue, 25 Nov 2025 14:40:36 -0600 Subject: [PATCH 27/31] Revert "relocate the check for hasCollectedOperations to inside the necessary view" This reverts commit e1d5da1d1736c7db18c7902d58a064707e3888df. --- .../app/src/pages/target-explorer-unused.tsx | 25 ++++++++++--------- 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/packages/web/app/src/pages/target-explorer-unused.tsx b/packages/web/app/src/pages/target-explorer-unused.tsx index c1568afbec8..5fb5298610b 100644 --- a/packages/web/app/src/pages/target-explorer-unused.tsx +++ b/packages/web/app/src/pages/target-explorer-unused.tsx @@ -255,7 +255,6 @@ const UnusedSchemaExplorer_UnusedSchemaQuery = graphql(` function UnusedSchemaExplorer(props: { dataRetentionInDays: number; - hasCollectedOperations: boolean; organizationSlug: string; projectSlug: string; targetSlug: string; @@ -319,16 +318,7 @@ function UnusedSchemaExplorer(props: { /> - - {!props.hasCollectedOperations ? ( -
- -
- ) : !query.fetching && !query.stale ? ( + {!query.fetching && !query.stale ? ( <> {latestValidSchemaVersion?.unusedSchema && latestSchemaVersion ? ( <> @@ -436,10 +426,21 @@ function ExplorerUnusedSchemaPageContent(props: { return null; } + if (!hasCollectedOperations) { + return ( +
+ +
+ ); + } + return ( Date: Wed, 26 Nov 2025 14:31:06 -0600 Subject: [PATCH 28/31] lock file --- pnpm-lock.yaml | 37 ------------------------------------- 1 file changed, 37 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4442c983071..3e519ee1faa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1714,21 +1714,6 @@ importers: specifier: 3.25.76 version: 3.25.76 - packages/services/workflows: - dependencies: - '@graphql-hive/logger': - specifier: 1.0.9 - version: 1.0.9 - '@openworkflow/backend-postgres': - specifier: 0.3.0 - version: 0.3.0(openworkflow@0.3.0) - openworkflow: - specifier: 0.3.0 - version: 0.3.0 - zod: - specifier: 3.25.76 - version: 3.25.76 - packages/web/app: devDependencies: '@date-fns/utc': @@ -7265,11 +7250,6 @@ packages: peerDependencies: '@opentelemetry/api': ^1.1.0 - '@openworkflow/backend-postgres@0.3.0': - resolution: {integrity: sha512-h7uE/+xrQpGpXeI0IaAy1Q+FN2SILIYX166R5kk47TleEYhRBF1JZ8jmZkmkqUapODxFp+BUZmTVmg3SctIIFg==} - peerDependencies: - openworkflow: ^0.3.0 - '@pagefind/darwin-arm64@1.3.0': resolution: {integrity: sha512-365BEGl6ChOsauRjyVpBjXybflXAOvoMROw3TucAROHIcdBvXk9/2AmEvGFU0r75+vdQI4LJdJdpH4Y6Yqaj4A==} cpu: [arm64] @@ -15735,10 +15715,6 @@ packages: resolution: {integrity: sha512-vz9iS7MJ5+Bp1URw8Khvdyw1H/hGvzHWlKQ7eRrQojSCDL1/SrWfrY9QebLw97n2deyRtzHRC3MkQfVNUCo91Q==} engines: {node: '>=0.10'} - openworkflow@0.3.0: - resolution: {integrity: sha512-eP3W7bvmcdllRZp3Xawh0iB2VKR4eyUML5D2yi87f2GDyFcrKMHCddM1tVxUgjaXBYa6zpTeJasbcSgrVTRsAQ==} - engines: {node: '>=20'} - optionator@0.9.3: resolution: {integrity: sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg==} engines: {node: '>= 0.8.0'} @@ -16277,10 +16253,6 @@ packages: postgres-range@1.1.3: resolution: {integrity: sha512-VdlZoocy5lCP0c/t66xAfclglEapXPCIVhqqJRncYpvbCgImF0w67aPKfbqUMr72tO2k5q0TdTZwCLjPTI6C9g==} - postgres@3.4.7: - resolution: {integrity: sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==} - engines: {node: '>=12'} - prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -27398,11 +27370,6 @@ snapshots: '@opentelemetry/api': 1.9.0 '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0) - '@openworkflow/backend-postgres@0.3.0(openworkflow@0.3.0)': - dependencies: - openworkflow: 0.3.0 - postgres: 3.4.7 - '@pagefind/darwin-arm64@1.3.0': optional: true @@ -38175,8 +38142,6 @@ snapshots: opentracing@0.14.7: {} - openworkflow@0.3.0: {} - optionator@0.9.3: dependencies: '@aashutoshrathi/word-wrap': 1.2.6 @@ -38751,8 +38716,6 @@ snapshots: postgres-range@1.1.3: {} - postgres@3.4.7: {} - prelude-ls@1.2.1: {} prettier-plugin-pkg@0.18.0(prettier@3.4.2): From 688f9b653ad7fa9c307f0dd555c6885f54ae132c Mon Sep 17 00:00:00 2001 From: Jonathan Brennan Date: Wed, 26 Nov 2025 16:45:16 -0600 Subject: [PATCH 29/31] ensure that useSearchParamsFilters handles simple strings --- .../app/src/lib/hooks/use-search-params-filters.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) 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 d6278b98a09..f20344d4bb3 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 @@ -11,7 +11,18 @@ export function useSearchParamsFilter( const rawSearchValue = ((name as string) in searchParams && (searchParams[name] as string)) || null; - const searchValue = (deserializeSearchValue(rawSearchValue) ?? defaultState) as TValue; + + /** + * If our extracted search params (rawSearchValue) is an array, we deserialize. + * Otherwise, it's just a simple string. + */ + const searchValue = ( + rawSearchValue + ? Array.isArray(defaultState) + ? deserializeSearchValue(rawSearchValue) + : rawSearchValue + : defaultState + ) as TValue; const setSearchValue = (value: TValue) => { void router.navigate({ From 0148d24b8c1896588f6c1bd744dda53d93475dd6 Mon Sep 17 00:00:00 2001 From: Jonathan Brennan Date: Wed, 26 Nov 2025 17:10:52 -0600 Subject: [PATCH 30/31] lock file --- pnpm-lock.yaml | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3e519ee1faa..40c5d9fe4c2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -19370,8 +19370,8 @@ snapshots: dependencies: '@aws-crypto/sha256-browser': 3.0.0 '@aws-crypto/sha256-js': 3.0.0 - '@aws-sdk/client-sso-oidc': 3.596.0 - '@aws-sdk/client-sts': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0) + '@aws-sdk/client-sso-oidc': 3.596.0(@aws-sdk/client-sts@3.596.0) + '@aws-sdk/client-sts': 3.596.0 '@aws-sdk/core': 3.592.0 '@aws-sdk/credential-provider-node': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0)(@aws-sdk/client-sts@3.596.0) '@aws-sdk/middleware-host-header': 3.577.0 @@ -19523,11 +19523,11 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/client-sso-oidc@3.596.0': + '@aws-sdk/client-sso-oidc@3.596.0(@aws-sdk/client-sts@3.596.0)': dependencies: '@aws-crypto/sha256-browser': 3.0.0 '@aws-crypto/sha256-js': 3.0.0 - '@aws-sdk/client-sts': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0) + '@aws-sdk/client-sts': 3.596.0 '@aws-sdk/core': 3.592.0 '@aws-sdk/credential-provider-node': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0)(@aws-sdk/client-sts@3.596.0) '@aws-sdk/middleware-host-header': 3.577.0 @@ -19566,6 +19566,7 @@ snapshots: '@smithy/util-utf8': 3.0.0 tslib: 2.8.1 transitivePeerDependencies: + - '@aws-sdk/client-sts' - aws-crt '@aws-sdk/client-sso-oidc@3.723.0(@aws-sdk/client-sts@3.723.0)': @@ -19785,11 +19786,11 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/client-sts@3.596.0(@aws-sdk/client-sso-oidc@3.596.0)': + '@aws-sdk/client-sts@3.596.0': dependencies: '@aws-crypto/sha256-browser': 3.0.0 '@aws-crypto/sha256-js': 3.0.0 - '@aws-sdk/client-sso-oidc': 3.596.0 + '@aws-sdk/client-sso-oidc': 3.596.0(@aws-sdk/client-sts@3.596.0) '@aws-sdk/core': 3.592.0 '@aws-sdk/credential-provider-node': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0)(@aws-sdk/client-sts@3.596.0) '@aws-sdk/middleware-host-header': 3.577.0 @@ -19828,7 +19829,6 @@ snapshots: '@smithy/util-utf8': 3.0.0 tslib: 2.8.1 transitivePeerDependencies: - - '@aws-sdk/client-sso-oidc' - aws-crt '@aws-sdk/client-sts@3.723.0': @@ -20060,7 +20060,7 @@ snapshots: '@aws-sdk/credential-provider-ini@3.596.0(@aws-sdk/client-sso-oidc@3.596.0)(@aws-sdk/client-sts@3.596.0)': dependencies: - '@aws-sdk/client-sts': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0) + '@aws-sdk/client-sts': 3.596.0 '@aws-sdk/credential-provider-env': 3.587.0 '@aws-sdk/credential-provider-http': 3.596.0 '@aws-sdk/credential-provider-process': 3.587.0 @@ -20307,7 +20307,7 @@ snapshots: '@aws-sdk/credential-provider-web-identity@3.587.0(@aws-sdk/client-sts@3.596.0)': dependencies: - '@aws-sdk/client-sts': 3.596.0(@aws-sdk/client-sso-oidc@3.596.0) + '@aws-sdk/client-sts': 3.596.0 '@aws-sdk/types': 3.577.0 '@smithy/property-provider': 3.1.11 '@smithy/types': 3.7.2 @@ -20696,7 +20696,7 @@ snapshots: '@aws-sdk/token-providers@3.587.0(@aws-sdk/client-sso-oidc@3.596.0)': dependencies: - '@aws-sdk/client-sso-oidc': 3.596.0 + '@aws-sdk/client-sso-oidc': 3.596.0(@aws-sdk/client-sts@3.596.0) '@aws-sdk/types': 3.577.0 '@smithy/property-provider': 3.1.11 '@smithy/shared-ini-file-loader': 3.1.12 From 8a9afb0a9a748f83741951742aa68ebf37c48a18 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Fri, 28 Nov 2025 12:37:54 -0800 Subject: [PATCH 31/31] Remove trigram index; fix pagination index --- .../actions/2025.11.25T00-00-00.members-search.ts | 12 ++++++++++-- .../organization/providers/organization-members.ts | 4 ++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/packages/migrations/src/actions/2025.11.25T00-00-00.members-search.ts b/packages/migrations/src/actions/2025.11.25T00-00-00.members-search.ts index 4528ebc8526..dfb73d3b031 100644 --- a/packages/migrations/src/actions/2025.11.25T00-00-00.members-search.ts +++ b/packages/migrations/src/actions/2025.11.25T00-00-00.members-search.ts @@ -3,7 +3,15 @@ import { type MigrationExecutor } from '../pg-migrator'; export default { name: '2025.11.25T00-00-00.members-search.ts', run: ({ sql }) => sql` - CREATE EXTENSION IF NOT EXISTS pg_trgm; - CREATE INDEX CONCURRENTLY IF NOT EXISTS "users_search_by_email_and_display_name" on users using gin(LOWER(email|| ' ' || display_name) gin_trgm_ops); + -- The order was wrong. This was sorting by org_id, user_id, then created_at... + DROP INDEX IF EXISTS "organization_member_pagination_idx"; + + -- Replace "organization_member_pagination_idx" with a new index in the correct order + CREATE INDEX CONCURRENTLY IF NOT EXISTS "organization_member_pagination" + ON "organization_member" ( + "organization_id" DESC + , "created_at" DESC + , "user_id" DESC + ); `, } satisfies MigrationExecutor; 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 533c1f31577..e2b9be9c106 100644 --- a/packages/services/api/src/modules/organization/providers/organization-members.ts +++ b/packages/services/api/src/modules/organization/providers/organization-members.ts @@ -157,7 +157,7 @@ export class OrganizationMembers { const first = args.first ? Math.min(args.first, 50) : 50; const cursor = args.after ? decodeCreatedAtAndUUIDIdBasedCursor(args.after) : null; - const searchTerm = args.searchTerm?.trim().toLowerCase() ?? ''; + const searchTerm = args.searchTerm?.trim() ?? ''; const searching = searchTerm.length > 0; const query = sql` @@ -188,7 +188,7 @@ export class OrganizationMembers { ` : sql`` } - ${searching ? sql`AND (LOWER("u"."display_name" || ' ' || "u"."email") LIKE ${'%' + searchTerm + '%'})` : sql``} + ${searching ? sql`AND "u"."display_name" || ' ' || "u"."email" ILIKE ${'%' + searchTerm + '%'}` : sql``} ORDER BY "om"."organization_id" DESC , "om"."created_at" DESC