-
Notifications
You must be signed in to change notification settings - Fork 122
Console 1382 member pagination and search #7299
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 7 commits
5825c22
475e7fe
68af504
546b550
abbd0e4
e1a5546
64578a0
8e820f2
5a12547
a2e9037
dea8fc1
a2d73a4
69f6939
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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,13 +20,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 { 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<typeof OrganizationMembers_OrganizationFragment>; | ||
| member: FragmentType<typeof OrganizationMemberRow_MemberFragment>; | ||
| 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<HTMLInputElement>) => { | ||
| 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 ( | ||
| <SubPageLayout> | ||
| <SubPageLayoutHeader | ||
| subPageTitle="List of organization members" | ||
| description="Manage the members of your organization and their permissions." | ||
| > | ||
| <Input | ||
| className="w-[200px] grow cursor-text" | ||
| placeholder="Filter by field name" | ||
| onChange={onChange} | ||
| defaultValue={initialValue} | ||
| /> | ||
| {organization.viewerCanManageInvitations && ( | ||
| <MemberInvitationButton | ||
| refetchInvitations={props.refetchMembers} | ||
|
|
@@ -373,45 +362,18 @@ export function OrganizationMembers(props: { | |
| <th | ||
| colSpan={2} | ||
| className="relative cursor-pointer select-none py-3 text-left text-sm font-semibold" | ||
| onClick={() => updateSorting('name')} | ||
| > | ||
| 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] cursor-pointer 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> | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -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<typeof OrganizationMembersRouteSearch>['page']) => { | ||||||
| void navigate({ search: { page: newPage } }); | ||||||
| void navigate({ search: { page: newPage, search: '' } }); | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. For consistency, it's better to use
Suggested change
|
||||||
| }, | ||||||
| [navigate], | ||||||
| ); | ||||||
|
|
||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The current full-text search implementation has a few issues:
to_tsquerywill fail with a syntax error ifsearchTermcontains spaces (e.g., "John Doe").searchTermcontains tsquery operators like&,|,!,(,), it can lead to syntax errors.to_tsvectorfunction is called without specifying a configuration, so it uses the database's default (e.g., 'english'), whileto_tsqueryexplicitly uses 'simple'. This can lead to search misses.I suggest using
plainto_tsquery, which is safer as it doesn't interpret operators and handles multiple words by AND-ing them. This also requires specifying the 'simple' configuration into_tsvectorfor consistency. This change fixes the bug at the cost of removing prefix matching, which is a reasonable trade-off for correctness and stability.For performance, you should also add a GIN index on the expression used in the
WHEREclause: