diff --git a/web/.gitignore b/web/.gitignore
index 1a81282c4c..9de3dc83f9 100644
--- a/web/.gitignore
+++ b/web/.gitignore
@@ -64,6 +64,3 @@ public/fallback-*.js
.vscode/settings.json
.vscode/mcp.json
-
-# CodeGraph local index
-.codegraph/
diff --git a/web/app/components/header/account-setting/members-page/__tests__/index.spec.tsx b/web/app/components/header/account-setting/members-page/__tests__/index.spec.tsx
index 6266da59b8..92d5df556a 100644
--- a/web/app/components/header/account-setting/members-page/__tests__/index.spec.tsx
+++ b/web/app/components/header/account-setting/members-page/__tests__/index.spec.tsx
@@ -9,12 +9,14 @@ import { Plan } from '@/app/components/billing/type'
import { useAppContext } from '@/context/app-context'
import { useProviderContext } from '@/context/provider-context'
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
+import { useUpdateRolesOfMember } from '@/service/access-control/use-member-roles'
import { useMembers } from '@/service/use-common'
import MembersPage from '../index'
vi.mock('@/context/app-context')
vi.mock('@/context/provider-context')
vi.mock('@/hooks/use-format-time-from-now')
+vi.mock('@/service/access-control/use-member-roles')
vi.mock('@/service/use-common')
const renderMembersPage = () => renderWithSystemFeatures(, {
@@ -94,11 +96,22 @@ vi.mock('../transfer-ownership-modal', () => ({
),
}))
vi.mock('../member-details-modal', () => ({
- default: ({ member, onClose, canAssignRoles }: { member: Member, onClose: () => void, canAssignRoles?: boolean }) => (
+ default: ({
+ member,
+ onClose,
+ canAssignRoles,
+ onAssignSubmit,
+ }: {
+ member: Member
+ onClose: () => void
+ canAssignRoles?: boolean
+ onAssignSubmit?: (roleIds: string[]) => void
+ }) => (
Member Details Modal
{member.name}
{String(canAssignRoles)}
+
),
@@ -110,6 +123,7 @@ vi.mock('@/app/components/billing/upgrade-btn', () => ({
describe('MembersPage', () => {
const mockRefetch = vi.fn()
const mockFormatTimeFromNow = vi.fn(() => 'just now')
+ const mockUpdateRolesOfMember = vi.fn()
const mockAccounts: Member[] = [
{
@@ -155,6 +169,13 @@ describe('MembersPage', () => {
data: { accounts: mockAccounts },
refetch: mockRefetch,
} as unknown as ReturnType)
+ mockUpdateRolesOfMember.mockImplementation((_payload, options) => {
+ options?.onSuccess?.()
+ return Promise.resolve()
+ })
+ vi.mocked(useUpdateRolesOfMember).mockReturnValue({
+ mutateAsync: mockUpdateRolesOfMember,
+ } as unknown as ReturnType)
vi.mocked(useProviderContext).mockReturnValue(createMockProviderContextValue({
enableBilling: false,
@@ -486,6 +507,23 @@ describe('MembersPage', () => {
expect(screen.getByTestId('details-can-assign'))!.toHaveTextContent('false')
})
+ it('should keep member details open after role assignment succeeds', async () => {
+ const user = userEvent.setup()
+
+ renderMembersPage()
+
+ await user.click(screen.getByTestId('member-row-2'))
+ await user.click(screen.getByRole('button', { name: 'Submit Member Roles' }))
+
+ expect(mockUpdateRolesOfMember).toHaveBeenCalledWith({
+ memberId: '2',
+ roleIds: ['role-next'],
+ }, expect.any(Object))
+ expect(mockRefetch).toHaveBeenCalled()
+ expect(screen.getByText('Member Details Modal')).toBeInTheDocument()
+ expect(screen.getByTestId('details-member-name')).toHaveTextContent('Admin User')
+ })
+
it('should not open member details when clicking the member menu area', async () => {
const user = userEvent.setup()
diff --git a/web/app/components/header/account-setting/members-page/assign-roles-modal/index.tsx b/web/app/components/header/account-setting/members-page/assign-roles-modal/index.tsx
index 376a38a781..34f643d619 100644
--- a/web/app/components/header/account-setting/members-page/assign-roles-modal/index.tsx
+++ b/web/app/components/header/account-setting/members-page/assign-roles-modal/index.tsx
@@ -1,6 +1,5 @@
'use client'
-import type { Member } from '@/models/common'
import { Button } from '@langgenius/dify-ui/button'
import {
Dialog,
@@ -14,7 +13,7 @@ import { useTranslation } from 'react-i18next'
import WorkspaceRoleCheckboxList from '../../workspace-role-checkbox-list'
export type AssignRolesModalProps = {
- member: Member
+ selectedRoles: string[]
onClose: () => void
onSubmit: (roleIds: string[]) => void
}
@@ -22,14 +21,12 @@ export type AssignRolesModalProps = {
type AssignRolesModalBodyProps = AssignRolesModalProps
const AssignRolesModalBody = ({
- member,
+ selectedRoles,
onClose,
onSubmit,
}: AssignRolesModalBodyProps) => {
const { t } = useTranslation()
- const [selected, setSelected] = useState(() => {
- return member.roles?.map(role => role.id) || []
- })
+ const [selected, setSelected] = useState(selectedRoles)
const handleConfirm = () => {
onSubmit(selected)
@@ -83,7 +80,7 @@ const AssignRolesModalBody = ({
}
const AssignRolesModal = ({
- member,
+ selectedRoles,
onClose,
onSubmit,
}: AssignRolesModalProps) => {
@@ -96,7 +93,7 @@ const AssignRolesModal = ({
}}
>
diff --git a/web/app/components/header/account-setting/members-page/index.tsx b/web/app/components/header/account-setting/members-page/index.tsx
index c217c5e0e5..a9b2befa96 100644
--- a/web/app/components/header/account-setting/members-page/index.tsx
+++ b/web/app/components/header/account-setting/members-page/index.tsx
@@ -62,7 +62,6 @@ const MembersPage = () => {
onSuccess: () => {
toast.success(t('actionMsg.modifiedSuccessfully', { ns: 'common' }))
refetch()
- handleCloseDetails()
},
})
}
diff --git a/web/app/components/header/account-setting/members-page/member-details-modal/index.tsx b/web/app/components/header/account-setting/members-page/member-details-modal/index.tsx
index f70e1cc23f..41a2cd695e 100644
--- a/web/app/components/header/account-setting/members-page/member-details-modal/index.tsx
+++ b/web/app/components/header/account-setting/members-page/member-details-modal/index.tsx
@@ -35,8 +35,9 @@ const MemberDetailsModal = ({
const roles = useMemo(() => rolesOfMember?.roles ?? [], [rolesOfMember?.roles])
- const builtinRoles = roles.filter(role => role.is_builtin)
- const customRoles = roles.filter(role => !role.is_builtin)
+ const builtinRoles = useMemo(() => roles.filter(role => role.is_builtin), [roles])
+ const customRoles = useMemo(() => roles.filter(role => !role.is_builtin), [roles])
+ const selectedRoles = useMemo(() => roles.map(role => role.id), [roles])
const handleClose = useCallback(() => {
setAssignOpen(false)
@@ -48,9 +49,9 @@ const MemberDetailsModal = ({
}, [onAssignSubmit])
const handleRemove = useCallback((id: string) => {
- const roleIds = roles.map(role => role.id).filter(roleId => roleId !== id)
+ const roleIds = selectedRoles.filter(roleId => roleId !== id)
onAssignSubmit?.(roleIds)
- }, [roles, onAssignSubmit])
+ }, [selectedRoles, onAssignSubmit])
return (
<>
@@ -167,7 +168,7 @@ const MemberDetailsModal = ({
{assignOpen && (
diff --git a/web/app/components/header/account-setting/members-page/member-menu.tsx b/web/app/components/header/account-setting/members-page/member-menu.tsx
index 21c0585ece..5ea55adbb2 100644
--- a/web/app/components/header/account-setting/members-page/member-menu.tsx
+++ b/web/app/components/header/account-setting/members-page/member-menu.tsx
@@ -9,7 +9,7 @@ import {
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { toast } from '@langgenius/dify-ui/toast'
-import { memo, useState } from 'react'
+import { memo, useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import ActionButton from '@/app/components/base/action-button'
import { useUpdateRolesOfMember } from '@/service/access-control/use-member-roles'
@@ -40,14 +40,16 @@ const MemberMenu = ({
const canRemove = !isOwner && !isCurrentUser
const showTransferOwnership = isOwner && canTransferOwnership
- const handleOpenAssignRoles = () => {
+ const selectedRoles = useMemo(() => member.roles?.map(role => role.id) || [], [member.roles])
+
+ const handleOpenAssignRoles = useCallback(() => {
setOpen(false)
setAssignModalOpen(true)
- }
+ }, [])
const { mutateAsync: updateRolesOfMember } = useUpdateRolesOfMember()
- const handleAssignRolesSubmit = (roleIds: string[]) => {
+ const handleAssignRolesSubmit = useCallback((roleIds: string[]) => {
updateRolesOfMember({
memberId: member.id,
roleIds,
@@ -57,9 +59,9 @@ const MemberMenu = ({
onOperate()
},
})
- }
+ }, [member.id, onOperate, t, updateRolesOfMember])
- const handleRemove = async () => {
+ const handleRemove = useCallback(async () => {
setOpen(false)
try {
await deleteMemberOrCancelInvitation({ url: `/workspaces/current/members/${member.id}` })
@@ -68,18 +70,27 @@ const MemberMenu = ({
}
catch {
}
- }
+ }, [member.id, onOperate, t])
- const handleTransferOwnership = () => {
+ const handleTransferOwnership = useCallback(() => {
setOpen(false)
onTransferOwnership?.()
- }
+ }, [onTransferOwnership])
+
+ const stopPropagationOnClick = useCallback((e: React.MouseEvent) => {
+ e.stopPropagation()
+ }, [])
+
+ const stopPropagationOnKeyDown = useCallback((e: React.KeyboardEvent) => {
+ if (e.key === 'Enter' || e.key === ' ')
+ e.stopPropagation()
+ }, [])
if (!canAssignRoles && !canRemove && !showTransferOwnership)
return null
return (
- <>
+
{assignModalOpen && (
setAssignModalOpen(false)}
onSubmit={handleAssignRolesSubmit}
/>
)}
- >
+
)
}