mirror of
https://github.com/langgenius/dify.git
synced 2026-07-21 18:58:35 +08:00
fix: improve members role chip accessibility (#38037)
This commit is contained in:
parent
446b3962c1
commit
17bee5fb32
@ -1,7 +1,7 @@
|
||||
import type { AppContextValue } from '@/context/app-context'
|
||||
import type { Role } from '@/models/access-control'
|
||||
import type { ICurrentWorkspace, Member } from '@/models/common'
|
||||
import { screen } from '@testing-library/react'
|
||||
import { screen, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { vi } from 'vitest'
|
||||
import { createMockProviderContextValue } from '@/__mocks__/provider-context'
|
||||
@ -24,6 +24,10 @@ const renderMembersPage = () => renderWithSystemFeatures(<MembersPage />, {
|
||||
systemFeatures: { is_email_setup: true },
|
||||
})
|
||||
|
||||
const getMemberDetailsButton = (memberId: string) => within(screen.getByTestId(`member-row-${memberId}`)).getByRole('button', {
|
||||
name: /members\.memberDetails\.openAria/i,
|
||||
})
|
||||
|
||||
const createRole = (overrides: Partial<Role>): Role => ({
|
||||
id: 'role-1',
|
||||
tenant_id: 'tenant-1',
|
||||
@ -220,8 +224,8 @@ describe('MembersPage', () => {
|
||||
|
||||
expect(screen.getByText('common.members.name', { selector: '.system-xs-medium-uppercase' }))!.toHaveClass('w-65', 'shrink-0')
|
||||
expect(screen.getByText('common.members.role', { selector: '.system-xs-medium-uppercase' }))!.toHaveClass('min-w-0', 'grow')
|
||||
expect(screen.getByTestId('member-row-1').children[0])!.toHaveClass('w-65', 'shrink-0')
|
||||
expect(screen.getByTestId('member-row-1').children[2])!.toHaveClass('min-w-0', 'grow')
|
||||
expect(getMemberDetailsButton('1').children[0])!.toHaveClass('w-65', 'shrink-0')
|
||||
expect(getMemberDetailsButton('1').children[2])!.toHaveClass('min-w-0', 'grow')
|
||||
})
|
||||
|
||||
it('should render plural roles column header when RBAC is enabled', () => {
|
||||
@ -495,12 +499,26 @@ describe('MembersPage', () => {
|
||||
expect(screen.getByText('Admin'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should expose member details as a native row button without nesting member actions', () => {
|
||||
renderMembersPage()
|
||||
|
||||
const row = screen.getByTestId('member-row-2')
|
||||
const detailsButton = getMemberDetailsButton('2')
|
||||
const memberMenu = within(row).getByTestId('member-menu')
|
||||
|
||||
expect(row).not.toHaveAttribute('role', 'button')
|
||||
expect(row).not.toHaveClass('hover:bg-state-base-hover')
|
||||
expect(detailsButton).toHaveAttribute('type', 'button')
|
||||
expect(detailsButton).toHaveClass('hover:bg-state-base-hover', 'focus-visible:bg-state-base-hover')
|
||||
expect(detailsButton).not.toContainElement(memberMenu)
|
||||
})
|
||||
|
||||
it('should open member details modal when a member row is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
renderMembersPage()
|
||||
|
||||
await user.click(screen.getByTestId('member-row-2'))
|
||||
await user.click(getMemberDetailsButton('2'))
|
||||
|
||||
expect(screen.getByText('Member Details Modal'))!.toBeInTheDocument()
|
||||
expect(screen.getByTestId('details-member-name'))!.toHaveTextContent('Admin User')
|
||||
@ -514,8 +532,8 @@ describe('MembersPage', () => {
|
||||
|
||||
renderMembersPage()
|
||||
|
||||
const row = screen.getByTestId('member-row-2')
|
||||
row.focus()
|
||||
const detailsButton = getMemberDetailsButton('2')
|
||||
detailsButton.focus()
|
||||
await user.keyboard('{Enter}')
|
||||
|
||||
expect(screen.getByText('Member Details Modal'))!.toBeInTheDocument()
|
||||
@ -526,7 +544,7 @@ describe('MembersPage', () => {
|
||||
|
||||
renderMembersPage()
|
||||
|
||||
await user.click(screen.getByTestId('member-row-1'))
|
||||
await user.click(getMemberDetailsButton('1'))
|
||||
|
||||
expect(screen.getByTestId('details-can-assign'))!.toHaveTextContent('false')
|
||||
})
|
||||
@ -543,7 +561,7 @@ describe('MembersPage', () => {
|
||||
|
||||
renderMembersPage()
|
||||
|
||||
await user.click(screen.getByTestId('member-row-2'))
|
||||
await user.click(getMemberDetailsButton('2'))
|
||||
|
||||
expect(screen.getByTestId('details-can-assign'))!.toHaveTextContent('false')
|
||||
})
|
||||
@ -553,7 +571,7 @@ describe('MembersPage', () => {
|
||||
|
||||
renderMembersPage()
|
||||
|
||||
await user.click(screen.getByTestId('member-row-2'))
|
||||
await user.click(getMemberDetailsButton('2'))
|
||||
await user.click(screen.getByRole('button', { name: 'Submit Member Roles' }))
|
||||
|
||||
expect(mockUpdateRolesOfMember).toHaveBeenCalledWith({
|
||||
@ -575,7 +593,7 @@ describe('MembersPage', () => {
|
||||
},
|
||||
})
|
||||
|
||||
await user.click(screen.getByTestId('member-row-2'))
|
||||
await user.click(getMemberDetailsButton('2'))
|
||||
await user.click(screen.getByRole('button', { name: 'Submit Member Roles' }))
|
||||
|
||||
expect(mockUpdateRolesOfMember).toHaveBeenCalledWith({
|
||||
|
||||
@ -145,11 +145,11 @@ describe('MemberDetailsModal', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByRole('button', { name: /Custom role/i })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /^Custom role$/i })).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByText('Custom role'))
|
||||
await user.click(screen.getByRole('button', { name: /^Custom role$/i }))
|
||||
|
||||
expect(screen.queryByRole('menuitem', { name: /common\.operation\.remove/i })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /common\.operation\.remove/i })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show role removal controls when role assignment is not allowed', () => {
|
||||
@ -189,9 +189,7 @@ describe('MemberDetailsModal', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Custom role/i }))
|
||||
|
||||
await user.click(screen.getByRole('menuitem', { name: /common\.operation\.remove/i }))
|
||||
await user.click(screen.getByRole('button', { name: /common\.operation\.remove.*Custom role/i }))
|
||||
|
||||
expect(handleAssignSubmit).not.toHaveBeenCalled()
|
||||
|
||||
|
||||
@ -2,17 +2,11 @@
|
||||
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@langgenius/dify-ui/dropdown-menu'
|
||||
import {
|
||||
PreviewCard,
|
||||
PreviewCardContent,
|
||||
PreviewCardTrigger,
|
||||
} from '@langgenius/dify-ui/preview-card'
|
||||
import { memo, useState } from 'react'
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@langgenius/dify-ui/popover'
|
||||
import { memo } from 'react'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
|
||||
type PermissionRoleChipProps = {
|
||||
@ -33,9 +27,8 @@ const PermissionRoleChip = ({
|
||||
className,
|
||||
}: PermissionRoleChipProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const permissions = permissionKeys
|
||||
const canOpenMenu = !isOwner && !!onRemove
|
||||
const canRemoveRole = !isOwner && !!onRemove
|
||||
const permissionLabels = permissions
|
||||
.map(key => t(key, {
|
||||
ns: 'permissionKeys',
|
||||
@ -44,42 +37,42 @@ const PermissionRoleChip = ({
|
||||
.join(', ')
|
||||
const hasPermissionLabels = permissionLabels.length > 0
|
||||
|
||||
const chipClassName = cn(
|
||||
'inline-flex h-6 max-w-full items-center rounded-full border-[0.5px] border-components-panel-border-subtle bg-background-body p-1 system-xs-regular text-text-primary shadow-xs transition-colors outline-none',
|
||||
canOpenMenu && 'cursor-pointer hover:bg-background-section-burn focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden',
|
||||
open && 'bg-background-section-burn',
|
||||
const chipRootClassName = cn(
|
||||
'inline-flex h-6 max-w-full min-w-0 items-center gap-1 rounded-full border-[0.5px] border-components-panel-border-subtle bg-background-body px-1.5 py-0.5 system-xs-medium text-text-primary shadow-xs transition-colors',
|
||||
'hover:bg-background-section-burn has-[[data-popup-open]]:bg-background-section-burn',
|
||||
'has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-state-accent-solid',
|
||||
className,
|
||||
)
|
||||
|
||||
const chipContent = (
|
||||
<span className="min-w-0 truncate px-1 leading-4">{label}</span>
|
||||
)
|
||||
const removeLabel = `${t('operation.remove', { ns: 'common' })} ${label}`
|
||||
|
||||
const chip = canOpenMenu
|
||||
? (
|
||||
const chip = (
|
||||
<span className={chipRootClassName}>
|
||||
<PopoverTrigger
|
||||
openOnHover
|
||||
delay={300}
|
||||
closeDelay={200}
|
||||
render={(
|
||||
<button
|
||||
type="button"
|
||||
className="min-w-0 truncate rounded-sm border-none bg-transparent p-0 text-start leading-4 outline-hidden"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
{canRemoveRole && (
|
||||
<button
|
||||
type="button"
|
||||
className={chipClassName}
|
||||
aria-label={t('members.memberDetails.roleActionsAria', {
|
||||
ns: 'common',
|
||||
role: label,
|
||||
defaultValue: 'Open actions for {{role}} role',
|
||||
})}
|
||||
data-testid="permission-role-chip"
|
||||
data-role-id={roleId}
|
||||
aria-label={removeLabel}
|
||||
className="flex size-3.5 shrink-0 items-center justify-center rounded-full border-none bg-transparent p-0 text-text-tertiary outline-hidden hover:bg-state-base-hover-alt hover:text-text-secondary focus-visible:bg-state-base-hover-alt focus-visible:text-text-secondary"
|
||||
onClick={() => onRemove?.(roleId)}
|
||||
>
|
||||
{chipContent}
|
||||
<span aria-hidden className="i-ri-close-line size-3" />
|
||||
</button>
|
||||
)
|
||||
: (
|
||||
<span
|
||||
className={chipClassName}
|
||||
data-testid="permission-role-chip"
|
||||
data-role-id={roleId}
|
||||
>
|
||||
{chipContent}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
|
||||
const permissionSummary = (
|
||||
<div className="flex w-58 flex-col gap-1 px-4 py-3.5">
|
||||
@ -109,53 +102,17 @@ const PermissionRoleChip = ({
|
||||
</div>
|
||||
)
|
||||
|
||||
const chipWithPermissions = (
|
||||
<PreviewCard>
|
||||
<PreviewCardTrigger render={chip} />
|
||||
<PreviewCardContent
|
||||
placement="bottom-start"
|
||||
sideOffset={8}
|
||||
popupClassName="overflow-hidden border-components-panel-border bg-components-tooltip-bg p-0 shadow-lg backdrop-blur-[5px]"
|
||||
>
|
||||
{permissionSummary}
|
||||
</PreviewCardContent>
|
||||
</PreviewCard>
|
||||
)
|
||||
|
||||
if (!canOpenMenu)
|
||||
return chipWithPermissions
|
||||
|
||||
const menuTrigger = (
|
||||
<PreviewCard>
|
||||
<DropdownMenuTrigger render={<PreviewCardTrigger render={chip} />} />
|
||||
<PreviewCardContent
|
||||
placement="bottom-start"
|
||||
sideOffset={8}
|
||||
popupClassName="overflow-hidden border-components-panel-border bg-components-tooltip-bg p-0 shadow-lg backdrop-blur-[5px]"
|
||||
>
|
||||
{permissionSummary}
|
||||
</PreviewCardContent>
|
||||
</PreviewCard>
|
||||
)
|
||||
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
{menuTrigger}
|
||||
<DropdownMenuContent
|
||||
<Popover>
|
||||
{chip}
|
||||
<PopoverContent
|
||||
placement="bottom-start"
|
||||
sideOffset={4}
|
||||
popupClassName="w-[236px] rounded-xl p-1"
|
||||
sideOffset={8}
|
||||
popupClassName="overflow-hidden border-components-panel-border bg-components-tooltip-bg p-0 shadow-lg backdrop-blur-[5px]"
|
||||
>
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
className="h-8 gap-2 rounded-lg px-2 py-1 system-sm-regular"
|
||||
onClick={() => onRemove?.(roleId)}
|
||||
>
|
||||
<span aria-hidden className="i-ri-delete-bin-line size-4 shrink-0" />
|
||||
{t('operation.remove', { ns: 'common' })}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{permissionSummary}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -10,7 +10,6 @@ import {
|
||||
AlertDialogDescription,
|
||||
AlertDialogTitle,
|
||||
} from '@langgenius/dify-ui/alert-dialog'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@ -108,26 +107,17 @@ const MemberMenu = ({
|
||||
onTransferOwnership?.()
|
||||
}, [onTransferOwnership])
|
||||
|
||||
const stopPropagationOnClick = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
}, [])
|
||||
|
||||
const stopPropagationOnKeyDown = useCallback((e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (e.key === 'Enter' || e.key === ' ')
|
||||
e.stopPropagation()
|
||||
}, [])
|
||||
|
||||
if (!canAssignRoles && !canRemove && !showTransferOwnership)
|
||||
return null
|
||||
|
||||
return (
|
||||
<div role="presentation" onClick={stopPropagationOnClick} onKeyDown={stopPropagationOnKeyDown}>
|
||||
<div role="presentation">
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenuTrigger
|
||||
render={(
|
||||
<ActionButton
|
||||
size="l"
|
||||
className={cn(open && 'bg-state-base-hover')}
|
||||
className="data-popup-open:bg-state-base-hover"
|
||||
aria-label={t('members.memberActions', { ns: 'common', defaultValue: 'Member actions' })}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
import type { KeyboardEvent } from 'react'
|
||||
import type { Member } from '@/models/common'
|
||||
import { Avatar } from '@langgenius/dify-ui/avatar'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { memo, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
|
||||
@ -41,57 +41,60 @@ const MemberRow = ({
|
||||
onOpenDetails(member)
|
||||
}, [member, onOpenDetails])
|
||||
|
||||
const handleRowKeyDown = useCallback((e: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
openDetails()
|
||||
}
|
||||
}, [openDetails])
|
||||
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
data-testid={`member-row-${member.id}`}
|
||||
aria-label={t('members.memberDetails.openAria', {
|
||||
ns: 'common',
|
||||
name: member.name,
|
||||
defaultValue: 'Open member details for {{name}}',
|
||||
})}
|
||||
className="flex cursor-pointer border-b border-divider-subtle hover:bg-state-base-hover focus-visible:bg-state-base-hover focus-visible:outline-hidden"
|
||||
onClick={openDetails}
|
||||
onKeyDown={handleRowKeyDown}
|
||||
className="relative border-b border-divider-subtle"
|
||||
>
|
||||
<div className="flex w-65 shrink-0 items-center px-3 py-2">
|
||||
<Avatar avatar={member.avatar_url} size="sm" className="mr-2" name={member.name} />
|
||||
<div className="">
|
||||
<div className="system-sm-medium text-text-secondary">
|
||||
{member.name}
|
||||
{member.status === 'pending' && (
|
||||
<span className="ml-1 system-xs-medium text-text-warning">
|
||||
{t('members.pending', { ns: 'common' })}
|
||||
</span>
|
||||
)}
|
||||
{isCurrentUser && (
|
||||
<span className="system-xs-regular text-text-tertiary">
|
||||
{t('members.you', { ns: 'common' })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="system-xs-regular text-text-tertiary">{member.email}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-30 shrink-0 items-center py-2 system-sm-regular text-text-secondary">
|
||||
{formatTimeFromNow(Number((member.last_active_at || member.created_at)) * 1000)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('members.memberDetails.openAria', {
|
||||
ns: 'common',
|
||||
name: member.name,
|
||||
defaultValue: 'Open member details for {{name}}',
|
||||
})}
|
||||
className={cn(
|
||||
'flex w-full min-w-0 cursor-pointer bg-transparent text-left hover:bg-state-base-hover focus-visible:bg-state-base-hover focus-visible:outline-hidden',
|
||||
canManage && 'pr-12',
|
||||
)}
|
||||
onClick={openDetails}
|
||||
>
|
||||
<span className="flex w-65 shrink-0 items-center px-3 py-2">
|
||||
<Avatar avatar={member.avatar_url} size="sm" className="mr-2" name={member.name} />
|
||||
<span className="min-w-0">
|
||||
<span className="block system-sm-medium text-text-secondary">
|
||||
{member.name}
|
||||
{member.status === 'pending' && (
|
||||
<span className="ml-1 system-xs-medium text-text-warning">
|
||||
{t('members.pending', { ns: 'common' })}
|
||||
</span>
|
||||
)}
|
||||
{isCurrentUser && (
|
||||
<span className="system-xs-regular text-text-tertiary">
|
||||
{t('members.you', { ns: 'common' })}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="block system-xs-regular text-text-tertiary">{member.email}</span>
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex w-30 shrink-0 items-center py-2 system-sm-regular text-text-secondary">
|
||||
{formatTimeFromNow(Number((member.last_active_at || member.created_at)) * 1000)}
|
||||
</span>
|
||||
<span
|
||||
className="flex min-w-0 grow items-center gap-2 px-3"
|
||||
role="presentation"
|
||||
>
|
||||
<RoleBadges
|
||||
className="grow"
|
||||
roleNames={roleNames}
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
<div
|
||||
className="flex min-w-0 grow items-center gap-2 px-3"
|
||||
className="absolute inset-y-0 right-0 flex items-center px-3"
|
||||
role="presentation"
|
||||
>
|
||||
<RoleBadges
|
||||
className="grow"
|
||||
roleNames={roleNames}
|
||||
/>
|
||||
{canManage && (
|
||||
<MemberMenu
|
||||
member={member}
|
||||
|
||||
@ -33,7 +33,7 @@ const RoleBadges = ({ roleNames, max = 2, className }: RoleBadgesProps) => {
|
||||
const overflow = roleNames.slice(max)
|
||||
|
||||
return (
|
||||
<div className={cn('flex min-w-0 items-center gap-1', className)}>
|
||||
<span className={cn('flex min-w-0 items-center gap-1', className)}>
|
||||
{visible.map(role => (
|
||||
<RoleBadge key={role} label={role} />
|
||||
))}
|
||||
@ -44,7 +44,7 @@ const RoleBadges = ({ roleNames, max = 2, className }: RoleBadgesProps) => {
|
||||
{`+${overflow.length}`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user