fix: don't show existing members when adding to groups and spaces (WIP)

This commit is contained in:
Philipinho
2025-09-18 13:21:04 +01:00
parent 9ac180f719
commit 21ef9432b3
11 changed files with 133 additions and 22 deletions
@@ -533,5 +533,8 @@
"Remove image": "Remove image", "Remove image": "Remove image",
"Failed to remove image": "Failed to remove image", "Failed to remove image": "Failed to remove image",
"Image exceeds 10MB limit.": "Image exceeds 10MB limit.", "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"
} }
@@ -37,6 +37,7 @@ export default function AddGroupMemberModal() {
<MultiUserSelect <MultiUserSelect
label={t("Add group members")} label={t("Add group members")}
onChange={handleMultiSelectChange} onChange={handleMultiSelectChange}
groupId={groupId}
/> />
<Group justify="flex-end" mt="md"> <Group justify="flex-end" mt="md">
@@ -9,6 +9,7 @@ import { useTranslation } from "react-i18next";
interface MultiUserSelectProps { interface MultiUserSelectProps {
onChange: (value: string[]) => void; onChange: (value: string[]) => void;
label?: string; label?: string;
groupId?: string;
} }
const renderMultiSelectOption: MultiSelectProps["renderOption"] = ({ const renderMultiSelectOption: MultiSelectProps["renderOption"] = ({
@@ -21,7 +22,9 @@ const renderMultiSelectOption: MultiSelectProps["renderOption"] = ({
size={36} size={36}
/> />
<div> <div>
<Text size="sm" lineClamp={1}>{option.label}</Text> <Text size="sm" lineClamp={1}>
{option.label}
</Text>
<Text size="xs" opacity={0.5}> <Text size="xs" opacity={0.5}>
{option?.["email"]} {option?.["email"]}
</Text> </Text>
@@ -29,14 +32,20 @@ const renderMultiSelectOption: MultiSelectProps["renderOption"] = ({
</Group> </Group>
); );
export function MultiUserSelect({ onChange, label }: MultiUserSelectProps) { export function MultiUserSelect({
onChange,
label,
groupId,
}: MultiUserSelectProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [searchValue, setSearchValue] = useState(""); const [searchValue, setSearchValue] = useState("");
const [debouncedQuery] = useDebouncedValue(searchValue, 500); const [debouncedQuery] = useDebouncedValue(searchValue, 500);
const { data: users, isLoading } = useWorkspaceMembersQuery({ const { data: users, isLoading } = useWorkspaceMembersQuery({
query: debouncedQuery, query: debouncedQuery,
limit: 50, limit: 50,
...(groupId && { groupId }),
}); });
const [data, setData] = useState([]); const [data, setData] = useState([]);
useEffect(() => { useEffect(() => {
@@ -22,6 +22,7 @@ import { IUser } from "@/features/user/types/user.types.ts";
import { useEffect } from "react"; import { useEffect } from "react";
import { validate as isValidUuid } from "uuid"; import { validate as isValidUuid } from "uuid";
import { queryClient } from "@/main.tsx"; import { queryClient } from "@/main.tsx";
import { useTranslation } from "react-i18next";
export function useGetGroupsQuery( export function useGetGroupsQuery(
params?: QueryParams, params?: QueryParams,
@@ -119,18 +120,24 @@ export function useGroupMembersQuery(
export function useAddGroupMemberMutation() { export function useAddGroupMemberMutation() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<void, Error, { groupId: string; userIds: string[] }>({ return useMutation<void, Error, { groupId: string; userIds: string[] }>({
mutationFn: (data) => addGroupMember(data), mutationFn: (data) => addGroupMember(data),
onSuccess: (data, variables) => { onSuccess: (data, variables) => {
notifications.show({ message: "Added successfully" }); notifications.show({ message: t("Added successfully") });
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: ["groupMembers", variables.groupId], queryKey: ["groupMembers", variables.groupId],
}); });
queryClient.invalidateQueries({
predicate: (item) =>
["workspaceMembers"].includes(item.queryKey[0] as string),
});
}, },
onError: () => { onError: () => {
notifications.show({ notifications.show({
message: "Failed to add group members", message: t("Failed to add group members"),
color: "red", color: "red",
}); });
}, },
@@ -139,6 +146,7 @@ export function useAddGroupMemberMutation() {
export function useRemoveGroupMemberMutation() { export function useRemoveGroupMemberMutation() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation< return useMutation<
void, void,
@@ -150,10 +158,15 @@ export function useRemoveGroupMemberMutation() {
>({ >({
mutationFn: (data) => removeGroupMember(data), mutationFn: (data) => removeGroupMember(data),
onSuccess: (data, variables) => { onSuccess: (data, variables) => {
notifications.show({ message: "Removed successfully" }); notifications.show({ message: t("Removed successfully") });
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: ["groupMembers", variables.groupId], queryKey: ["groupMembers", variables.groupId],
}); });
queryClient.invalidateQueries({
predicate: (item) =>
["workspaceMembers"].includes(item.queryKey[0] as string),
});
}, },
onError: (error) => { onError: (error) => {
const errorMessage = error["response"]?.data?.message; const errorMessage = error["response"]?.data?.message;
@@ -55,7 +55,7 @@ export default function AddSpaceMembersModal({
<Divider size="xs" mb="xs" /> <Divider size="xs" mb="xs" />
<Stack> <Stack>
<MultiMemberSelect onChange={handleMultiSelectChange} /> <MultiMemberSelect onChange={handleMultiSelectChange} spaceId={spaceId} />
<SpaceMemberRole <SpaceMemberRole
onSelect={handleRoleSelection} onSelect={handleRoleSelection}
defaultRole={role} defaultRole={role}
@@ -10,6 +10,7 @@ import { useTranslation } from "react-i18next";
interface MultiMemberSelectProps { interface MultiMemberSelectProps {
onChange: (value: string[]) => void; onChange: (value: string[]) => void;
spaceId?: string;
} }
const renderMultiSelectOption: MultiSelectProps["renderOption"] = ({ const renderMultiSelectOption: MultiSelectProps["renderOption"] = ({
@@ -25,23 +26,38 @@ const renderMultiSelectOption: MultiSelectProps["renderOption"] = ({
)} )}
{option["type"] === "group" && <IconGroupCircle />} {option["type"] === "group" && <IconGroupCircle />}
<div> <div>
<Text size="sm" lineClamp={1}>{option.label}</Text> <Text size="sm" lineClamp={1}>
{option.label}
</Text>
{option["type"] === "user" && option["email"] && ( {option["type"] === "user" && option["email"] && (
<Text size="xs" c="dimmed" lineClamp={1}>{option["email"]}</Text> <Text size="xs" c="dimmed" lineClamp={1}>
{option["email"]}
</Text>
)} )}
</div> </div>
</Group> </Group>
); );
export function MultiMemberSelect({ onChange }: MultiMemberSelectProps) { export function MultiMemberSelect({
onChange,
spaceId,
}: MultiMemberSelectProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [searchValue, setSearchValue] = useState(""); const [searchValue, setSearchValue] = useState("");
const [debouncedQuery] = useDebouncedValue(searchValue, 500); 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, includeUsers: true,
includeGroups: true, includeGroups: true,
}); ...(spaceId && { spaceId }),
};
const { data: suggestion, isLoading } =
useSearchSuggestionsQuery(queryParams);
const [data, setData] = useState([]); const [data, setData] = useState([]);
useEffect(() => { useEffect(() => {
@@ -190,6 +190,28 @@ export function useAddSpaceMemberMutation() {
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: ["spaceMembers", variables.spaceId], 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) => { onError: (error) => {
const errorMessage = error["response"]?.data?.message; const errorMessage = error["response"]?.data?.message;
@@ -209,6 +231,16 @@ export function useRemoveSpaceMemberMutation() {
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: ["spaceMembers", variables.spaceId], 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) => { onError: (error) => {
const errorMessage = error["response"]?.data?.message; const errorMessage = error["response"]?.data?.message;
+1
View File
@@ -2,6 +2,7 @@ export interface QueryParams {
query?: string; query?: string;
page?: number; page?: number;
limit?: number; limit?: number;
groupId?: string;
} }
export enum UserRole { export enum UserRole {
+30 -7
View File
@@ -145,7 +145,7 @@ export class SearchService {
const query = suggestion.query.toLowerCase().trim(); const query = suggestion.query.toLowerCase().trim();
if (suggestion.includeUsers) { if (suggestion.includeUsers) {
const userQuery = this.db let userQuery = this.db
.selectFrom('users') .selectFrom('users')
.select(['id', 'name', 'email', 'avatarUrl']) .select(['id', 'name', 'email', 'avatarUrl'])
.where('workspaceId', '=', workspaceId) .where('workspaceId', '=', workspaceId)
@@ -159,14 +159,25 @@ export class SearchService {
), ),
eb(sql`users.email`, 'ilike', sql`f_unaccent(${`%${query}%`})`), 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(); users = await userQuery.execute();
} }
if (suggestion.includeGroups) { if (suggestion.includeGroups) {
groups = await this.db let groupQuery = this.db
.selectFrom('groups') .selectFrom('groups')
.select(['id', 'name', 'description']) .select(['id', 'name', 'description'])
.where((eb) => .where((eb) =>
@@ -176,9 +187,21 @@ export class SearchService {
sql`LOWER(f_unaccent(${`%${query}%`}))`, sql`LOWER(f_unaccent(${`%${query}%`}))`,
), ),
) )
.where('workspaceId', '=', workspaceId) .where('workspaceId', '=', workspaceId);
.limit(limit)
.execute(); // 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) { if (suggestion.includePages) {
@@ -23,4 +23,8 @@ export class PaginationOptions {
@IsOptional() @IsOptional()
@IsString() @IsString()
query: string; query: string;
@IsOptional()
@IsString()
groupId?: string;
} }
@@ -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, { const result = executeWithPagination(query, {
page: pagination.page, page: pagination.page,
perPage: pagination.limit, perPage: pagination.limit,