mirror of
https://github.com/docmost/docmost.git
synced 2026-05-07 06:23:06 +08:00
fix: don't show existing members when adding to groups and spaces (WIP)
This commit is contained in:
@@ -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(() => {
|
||||||
@@ -63,14 +79,14 @@ export function MultiMemberSelect({ onChange }: MultiMemberSelectProps) {
|
|||||||
|
|
||||||
// Create fresh data structure based on current search results
|
// Create fresh data structure based on current search results
|
||||||
const newData = [];
|
const newData = [];
|
||||||
|
|
||||||
if (userItems && userItems.length > 0) {
|
if (userItems && userItems.length > 0) {
|
||||||
newData.push({
|
newData.push({
|
||||||
group: t("Select a user"),
|
group: t("Select a user"),
|
||||||
items: userItems,
|
items: userItems,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (groupItems && groupItems.length > 0) {
|
if (groupItems && groupItems.length > 0) {
|
||||||
newData.push({
|
newData.push({
|
||||||
group: t("Select a group"),
|
group: t("Select a group"),
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user