From 21ef9432b34b964c29384c725207ba8e343bb1da Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Thu, 18 Sep 2025 13:21:04 +0100 Subject: [PATCH] fix: don't show existing members when adding to groups and spaces (WIP) --- .../public/locales/en-US/translation.json | 5 ++- .../components/add-group-member-modal.tsx | 1 + .../group/components/multi-user-select.tsx | 13 ++++++- .../src/features/group/queries/group-query.ts | 19 ++++++++-- .../components/add-space-members-modal.tsx | 2 +- .../space/components/multi-member-select.tsx | 32 ++++++++++++---- .../src/features/space/queries/space-query.ts | 32 ++++++++++++++++ apps/client/src/lib/types.ts | 1 + apps/server/src/core/search/search.service.ts | 37 +++++++++++++++---- .../database/pagination/pagination-options.ts | 4 ++ .../src/database/repos/user/user.repo.ts | 9 +++++ 11 files changed, 133 insertions(+), 22 deletions(-) diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index 6d9e548b..ea0160e0 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -533,5 +533,8 @@ "Remove image": "Remove image", "Failed to remove image": "Failed to remove image", "Image exceeds 10MB limit.": "Image exceeds 10MB limit.", - "Image removed successfully": "Image removed successfully" + "Image removed successfully": "Image removed successfully", + "Added successfully": "Added successfully", + "Removed successfully": "Removed successfully", + "Failed to add group members": "Failed to add group members" } diff --git a/apps/client/src/features/group/components/add-group-member-modal.tsx b/apps/client/src/features/group/components/add-group-member-modal.tsx index a5abaa70..f3f7ba60 100644 --- a/apps/client/src/features/group/components/add-group-member-modal.tsx +++ b/apps/client/src/features/group/components/add-group-member-modal.tsx @@ -37,6 +37,7 @@ export default function AddGroupMemberModal() { diff --git a/apps/client/src/features/group/components/multi-user-select.tsx b/apps/client/src/features/group/components/multi-user-select.tsx index bb9272bf..2ca0c780 100644 --- a/apps/client/src/features/group/components/multi-user-select.tsx +++ b/apps/client/src/features/group/components/multi-user-select.tsx @@ -9,6 +9,7 @@ import { useTranslation } from "react-i18next"; interface MultiUserSelectProps { onChange: (value: string[]) => void; label?: string; + groupId?: string; } const renderMultiSelectOption: MultiSelectProps["renderOption"] = ({ @@ -21,7 +22,9 @@ const renderMultiSelectOption: MultiSelectProps["renderOption"] = ({ size={36} />
- {option.label} + + {option.label} + {option?.["email"]} @@ -29,14 +32,20 @@ const renderMultiSelectOption: MultiSelectProps["renderOption"] = ({ ); -export function MultiUserSelect({ onChange, label }: MultiUserSelectProps) { +export function MultiUserSelect({ + onChange, + label, + groupId, +}: MultiUserSelectProps) { const { t } = useTranslation(); const [searchValue, setSearchValue] = useState(""); const [debouncedQuery] = useDebouncedValue(searchValue, 500); const { data: users, isLoading } = useWorkspaceMembersQuery({ query: debouncedQuery, limit: 50, + ...(groupId && { groupId }), }); + const [data, setData] = useState([]); useEffect(() => { diff --git a/apps/client/src/features/group/queries/group-query.ts b/apps/client/src/features/group/queries/group-query.ts index aeb7787f..150cd6f7 100644 --- a/apps/client/src/features/group/queries/group-query.ts +++ b/apps/client/src/features/group/queries/group-query.ts @@ -22,6 +22,7 @@ import { IUser } from "@/features/user/types/user.types.ts"; import { useEffect } from "react"; import { validate as isValidUuid } from "uuid"; import { queryClient } from "@/main.tsx"; +import { useTranslation } from "react-i18next"; export function useGetGroupsQuery( params?: QueryParams, @@ -119,18 +120,24 @@ export function useGroupMembersQuery( export function useAddGroupMemberMutation() { const queryClient = useQueryClient(); + const { t } = useTranslation(); return useMutation({ mutationFn: (data) => addGroupMember(data), onSuccess: (data, variables) => { - notifications.show({ message: "Added successfully" }); + notifications.show({ message: t("Added successfully") }); queryClient.invalidateQueries({ queryKey: ["groupMembers", variables.groupId], }); + + queryClient.invalidateQueries({ + predicate: (item) => + ["workspaceMembers"].includes(item.queryKey[0] as string), + }); }, onError: () => { notifications.show({ - message: "Failed to add group members", + message: t("Failed to add group members"), color: "red", }); }, @@ -139,6 +146,7 @@ export function useAddGroupMemberMutation() { export function useRemoveGroupMemberMutation() { const queryClient = useQueryClient(); + const { t } = useTranslation(); return useMutation< void, @@ -150,10 +158,15 @@ export function useRemoveGroupMemberMutation() { >({ mutationFn: (data) => removeGroupMember(data), onSuccess: (data, variables) => { - notifications.show({ message: "Removed successfully" }); + notifications.show({ message: t("Removed successfully") }); queryClient.invalidateQueries({ queryKey: ["groupMembers", variables.groupId], }); + + queryClient.invalidateQueries({ + predicate: (item) => + ["workspaceMembers"].includes(item.queryKey[0] as string), + }); }, onError: (error) => { const errorMessage = error["response"]?.data?.message; diff --git a/apps/client/src/features/space/components/add-space-members-modal.tsx b/apps/client/src/features/space/components/add-space-members-modal.tsx index 5efd32f6..7df2b762 100644 --- a/apps/client/src/features/space/components/add-space-members-modal.tsx +++ b/apps/client/src/features/space/components/add-space-members-modal.tsx @@ -55,7 +55,7 @@ export default function AddSpaceMembersModal({ - + void; + spaceId?: string; } const renderMultiSelectOption: MultiSelectProps["renderOption"] = ({ @@ -25,23 +26,38 @@ const renderMultiSelectOption: MultiSelectProps["renderOption"] = ({ )} {option["type"] === "group" && }
- {option.label} + + {option.label} + {option["type"] === "user" && option["email"] && ( - {option["email"]} + + {option["email"]} + )}
); -export function MultiMemberSelect({ onChange }: MultiMemberSelectProps) { +export function MultiMemberSelect({ + onChange, + spaceId, +}: MultiMemberSelectProps) { const { t } = useTranslation(); const [searchValue, setSearchValue] = useState(""); const [debouncedQuery] = useDebouncedValue(searchValue, 500); - const { data: suggestion, isLoading } = useSearchSuggestionsQuery({ - query: debouncedQuery, + + console.log("vacant", spaceId); + + // Filter out empty parameters to avoid duplicate cache keys + const queryParams = { + ...(debouncedQuery && { query: debouncedQuery }), includeUsers: true, includeGroups: true, - }); + ...(spaceId && { spaceId }), + }; + + const { data: suggestion, isLoading } = + useSearchSuggestionsQuery(queryParams); const [data, setData] = useState([]); useEffect(() => { @@ -63,14 +79,14 @@ export function MultiMemberSelect({ onChange }: MultiMemberSelectProps) { // Create fresh data structure based on current search results const newData = []; - + if (userItems && userItems.length > 0) { newData.push({ group: t("Select a user"), items: userItems, }); } - + if (groupItems && groupItems.length > 0) { newData.push({ group: t("Select a group"), diff --git a/apps/client/src/features/space/queries/space-query.ts b/apps/client/src/features/space/queries/space-query.ts index b51e195e..7ea74923 100644 --- a/apps/client/src/features/space/queries/space-query.ts +++ b/apps/client/src/features/space/queries/space-query.ts @@ -190,6 +190,28 @@ export function useAddSpaceMemberMutation() { queryClient.invalidateQueries({ queryKey: ["spaceMembers", variables.spaceId], }); + + // Optimistically update search suggestions cache by filtering out added users and groups + queryClient.setQueriesData( + { queryKey: ["search-suggestion"], exact: false }, + (oldData: any) => { + if (!oldData) return oldData; + + const filteredUsers = oldData.users?.filter((user: any) => + !variables.userIds?.includes(user.id) + ) || []; + + const filteredGroups = oldData.groups?.filter((group: any) => + !variables.groupIds?.includes(group.id) + ) || []; + + return { + ...oldData, + users: filteredUsers, + groups: filteredGroups, + }; + } + ); }, onError: (error) => { const errorMessage = error["response"]?.data?.message; @@ -209,6 +231,16 @@ export function useRemoveSpaceMemberMutation() { queryClient.invalidateQueries({ queryKey: ["spaceMembers", variables.spaceId], }); + + // For remove operations, invalidate to get fresh data + // since adding the user/group back requires fetching their current data + queryClient.invalidateQueries({ + queryKey: ["search-suggestion"], + predicate: (query) => { + const queryKey = query.queryKey as any[]; + return queryKey[1]?.spaceId === variables.spaceId; + }, + }); }, onError: (error) => { const errorMessage = error["response"]?.data?.message; diff --git a/apps/client/src/lib/types.ts b/apps/client/src/lib/types.ts index fd0e3212..17a6f7e7 100644 --- a/apps/client/src/lib/types.ts +++ b/apps/client/src/lib/types.ts @@ -2,6 +2,7 @@ export interface QueryParams { query?: string; page?: number; limit?: number; + groupId?: string; } export enum UserRole { diff --git a/apps/server/src/core/search/search.service.ts b/apps/server/src/core/search/search.service.ts index 60432ce8..b22e2442 100644 --- a/apps/server/src/core/search/search.service.ts +++ b/apps/server/src/core/search/search.service.ts @@ -145,7 +145,7 @@ export class SearchService { const query = suggestion.query.toLowerCase().trim(); if (suggestion.includeUsers) { - const userQuery = this.db + let userQuery = this.db .selectFrom('users') .select(['id', 'name', 'email', 'avatarUrl']) .where('workspaceId', '=', workspaceId) @@ -159,14 +159,25 @@ export class SearchService { ), eb(sql`users.email`, 'ilike', sql`f_unaccent(${`%${query}%`})`), ]), - ) - .limit(limit); + ); + // Filter out users who are already members of the space + if (suggestion.spaceId) { + userQuery = userQuery.where('users.id', 'not in', (eb) => + eb + .selectFrom('spaceMembers') + .select('userId') + .where('spaceId', '=', suggestion.spaceId) + .where('userId', 'is not', null), + ); + } + + userQuery = userQuery.limit(limit); users = await userQuery.execute(); } if (suggestion.includeGroups) { - groups = await this.db + let groupQuery = this.db .selectFrom('groups') .select(['id', 'name', 'description']) .where((eb) => @@ -176,9 +187,21 @@ export class SearchService { sql`LOWER(f_unaccent(${`%${query}%`}))`, ), ) - .where('workspaceId', '=', workspaceId) - .limit(limit) - .execute(); + .where('workspaceId', '=', workspaceId); + + // Filter out groups that are already members of the space + if (suggestion.spaceId) { + groupQuery = groupQuery.where('groups.id', 'not in', (eb) => + eb + .selectFrom('spaceMembers') + .select('groupId') + .where('spaceId', '=', suggestion.spaceId) + .where('groupId', 'is not', null), + ); + } + + groupQuery = groupQuery.limit(limit); + groups = await groupQuery.execute(); } if (suggestion.includePages) { diff --git a/apps/server/src/database/pagination/pagination-options.ts b/apps/server/src/database/pagination/pagination-options.ts index e0481910..457e8bda 100644 --- a/apps/server/src/database/pagination/pagination-options.ts +++ b/apps/server/src/database/pagination/pagination-options.ts @@ -23,4 +23,8 @@ export class PaginationOptions { @IsOptional() @IsString() query: string; + + @IsOptional() + @IsString() + groupId?: string; } diff --git a/apps/server/src/database/repos/user/user.repo.ts b/apps/server/src/database/repos/user/user.repo.ts index c7c7b2b2..850b3c14 100644 --- a/apps/server/src/database/repos/user/user.repo.ts +++ b/apps/server/src/database/repos/user/user.repo.ts @@ -162,6 +162,15 @@ export class UserRepo { ); } + if (pagination.groupId) { + query = query.where('users.id', 'not in', (eb) => + eb + .selectFrom('groupUsers') + .select('userId') + .where('groupId', '=', pagination.groupId), + ); + } + const result = executeWithPagination(query, { page: pagination.page, perPage: pagination.limit,