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",
"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"
}
@@ -37,6 +37,7 @@ export default function AddGroupMemberModal() {
<MultiUserSelect
label={t("Add group members")}
onChange={handleMultiSelectChange}
groupId={groupId}
/>
<Group justify="flex-end" mt="md">
@@ -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}
/>
<div>
<Text size="sm" lineClamp={1}>{option.label}</Text>
<Text size="sm" lineClamp={1}>
{option.label}
</Text>
<Text size="xs" opacity={0.5}>
{option?.["email"]}
</Text>
@@ -29,14 +32,20 @@ const renderMultiSelectOption: MultiSelectProps["renderOption"] = ({
</Group>
);
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(() => {
@@ -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<void, Error, { groupId: string; userIds: string[] }>({
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;
@@ -55,7 +55,7 @@ export default function AddSpaceMembersModal({
<Divider size="xs" mb="xs" />
<Stack>
<MultiMemberSelect onChange={handleMultiSelectChange} />
<MultiMemberSelect onChange={handleMultiSelectChange} spaceId={spaceId} />
<SpaceMemberRole
onSelect={handleRoleSelection}
defaultRole={role}
@@ -10,6 +10,7 @@ import { useTranslation } from "react-i18next";
interface MultiMemberSelectProps {
onChange: (value: string[]) => void;
spaceId?: string;
}
const renderMultiSelectOption: MultiSelectProps["renderOption"] = ({
@@ -25,23 +26,38 @@ const renderMultiSelectOption: MultiSelectProps["renderOption"] = ({
)}
{option["type"] === "group" && <IconGroupCircle />}
<div>
<Text size="sm" lineClamp={1}>{option.label}</Text>
<Text size="sm" lineClamp={1}>
{option.label}
</Text>
{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>
</Group>
);
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"),
@@ -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;
+1
View File
@@ -2,6 +2,7 @@ export interface QueryParams {
query?: string;
page?: number;
limit?: number;
groupId?: string;
}
export enum UserRole {
+30 -7
View File
@@ -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) {
@@ -23,4 +23,8 @@ export class PaginationOptions {
@IsOptional()
@IsString()
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, {
page: pagination.page,
perPage: pagination.limit,