From c3bb95d71d7e31540d28ad6df8d0af2260606e94 Mon Sep 17 00:00:00 2001 From: Joel Date: Thu, 25 Dec 2025 17:26:21 +0800 Subject: [PATCH] fix: update permission in member list caused page crash (#30164) --- .../members-page/operation/index.spec.tsx | 91 +++++++++++++++ .../members-page/operation/index.tsx | 106 +++++++++--------- 2 files changed, 141 insertions(+), 56 deletions(-) create mode 100644 web/app/components/header/account-setting/members-page/operation/index.spec.tsx diff --git a/web/app/components/header/account-setting/members-page/operation/index.spec.tsx b/web/app/components/header/account-setting/members-page/operation/index.spec.tsx new file mode 100644 index 0000000000..fbe3959a0f --- /dev/null +++ b/web/app/components/header/account-setting/members-page/operation/index.spec.tsx @@ -0,0 +1,91 @@ +import type { Member } from '@/models/common' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { vi } from 'vitest' +import { ToastContext } from '@/app/components/base/toast' +import Operation from './index' + +const mockUpdateMemberRole = vi.fn() +const mockDeleteMemberOrCancelInvitation = vi.fn() + +vi.mock('@/service/common', () => ({ + deleteMemberOrCancelInvitation: () => mockDeleteMemberOrCancelInvitation(), + updateMemberRole: () => mockUpdateMemberRole(), +})) + +const mockUseProviderContext = vi.fn(() => ({ + datasetOperatorEnabled: false, +})) + +vi.mock('@/context/provider-context', () => ({ + useProviderContext: () => mockUseProviderContext(), +})) + +const defaultMember: Member = { + id: 'member-id', + name: 'Test Member', + email: 'test@example.com', + avatar: '', + avatar_url: null, + status: 'active', + role: 'editor', + last_login_at: '', + last_active_at: '', + created_at: '', +} + +const renderOperation = (propsOverride: Partial = {}, operatorRole = 'owner', onOperate?: () => void) => { + const mergedMember = { ...defaultMember, ...propsOverride } + return render( + + + , + ) +} + +describe('Operation', () => { + beforeEach(() => { + vi.clearAllMocks() + mockUseProviderContext.mockReturnValue({ datasetOperatorEnabled: false }) + }) + + it('renders the current role label', () => { + renderOperation() + + expect(screen.getByText('common.members.editor')).toBeInTheDocument() + }) + + it('shows dataset operator option when the feature flag is enabled', async () => { + mockUseProviderContext.mockReturnValue({ datasetOperatorEnabled: true }) + renderOperation() + + fireEvent.click(screen.getByText('common.members.editor')) + + expect(await screen.findByText('common.members.datasetOperator')).toBeInTheDocument() + }) + + it('calls updateMemberRole and onOperate when selecting another role', async () => { + const onOperate = vi.fn() + renderOperation({}, 'owner', onOperate) + + fireEvent.click(screen.getByText('common.members.editor')) + fireEvent.click(await screen.findByText('common.members.normal')) + + await waitFor(() => { + expect(mockUpdateMemberRole).toHaveBeenCalled() + expect(onOperate).toHaveBeenCalled() + }) + }) + + it('calls deleteMemberOrCancelInvitation when removing the member', async () => { + const onOperate = vi.fn() + renderOperation({}, 'owner', onOperate) + + fireEvent.click(screen.getByText('common.members.editor')) + fireEvent.click(await screen.findByText('common.members.removeFromTeam')) + + await waitFor(() => { + expect(mockDeleteMemberOrCancelInvitation).toHaveBeenCalled() + expect(onOperate).toHaveBeenCalled() + }) + }) +}) diff --git a/web/app/components/header/account-setting/members-page/operation/index.tsx b/web/app/components/header/account-setting/members-page/operation/index.tsx index da61746685..6effe8b058 100644 --- a/web/app/components/header/account-setting/members-page/operation/index.tsx +++ b/web/app/components/header/account-setting/members-page/operation/index.tsx @@ -1,10 +1,14 @@ 'use client' import type { Member } from '@/models/common' -import { Menu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/react' import { CheckIcon, ChevronDownIcon } from '@heroicons/react/24/outline' -import { Fragment, useMemo } from 'react' +import { memo, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { useContext } from 'use-context-selector' +import { + PortalToFollowElem, + PortalToFollowElemContent, + PortalToFollowElemTrigger, +} from '@/app/components/base/portal-to-follow-elem' import { ToastContext } from '@/app/components/base/toast' import { useProviderContext } from '@/context/provider-context' import { deleteMemberOrCancelInvitation, updateMemberRole } from '@/service/common' @@ -21,6 +25,7 @@ const Operation = ({ operatorRole, onOperate, }: IOperationProps) => { + const [open, setOpen] = useState(false) const { t } = useTranslation() const { datasetOperatorEnabled } = useProviderContext() const RoleMap = { @@ -51,6 +56,7 @@ const Operation = ({ const { notify } = useContext(ToastContext) const toHump = (name: string) => name.replace(/_(\w)/g, (all, letter) => letter.toUpperCase()) const handleDeleteMemberOrCancelInvitation = async () => { + setOpen(false) try { await deleteMemberOrCancelInvitation({ url: `/workspaces/current/members/${member.id}` }) onOperate() @@ -61,6 +67,7 @@ const Operation = ({ } } const handleUpdateMemberRole = async (role: string) => { + setOpen(false) try { await updateMemberRole({ url: `/workspaces/current/members/${member.id}/update-role`, @@ -75,63 +82,50 @@ const Operation = ({ } return ( - - { - ({ open }) => ( - <> - - {RoleMap[member.role] || RoleMap.normal} - - - - -
+ + setOpen(prev => !prev)}> +
+ {RoleMap[member.role] || RoleMap.normal} + +
+
+ +
+
+ { + roleList.map(role => ( +
handleUpdateMemberRole(role)}> { - roleList.map(role => ( - -
handleUpdateMemberRole(role)}> - { - role === member.role - ? - :
- } -
-
{t(`common.members.${toHump(role)}` as any)}
-
{t(`common.members.${toHump(role)}Tip` as any)}
-
-
- - )) + role === member.role + ? + :
} -
- -
-
-
-
-
{t('common.members.removeFromTeam')}
-
{t('common.members.removeFromTeamTip')}
-
-
+
+
{t(`common.members.${toHump(role)}` as any)}
+
{t(`common.members.${toHump(role)}Tip` as any)}
- - - - - ) - } -
+ + )) + } + +
+
+
+
+
{t('common.members.removeFromTeam')}
+
{t('common.members.removeFromTeamTip')}
+
+
+
+
+ + ) } -export default Operation +export default memo(Operation)