mirror of
https://github.com/langgenius/dify.git
synced 2026-07-21 10:38:32 +08:00
fix(web): restore permission selector semantics (#39269)
Signed-off-by: yyh <yuanyouhuilyz@gmail.com>
This commit is contained in:
parent
38ea0de907
commit
ba24745c07
@ -1889,22 +1889,6 @@
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"web/app/components/base/permission-selector/member-item.tsx": {
|
||||
"jsx_a11y/click-events-have-key-events": {
|
||||
"count": 1
|
||||
},
|
||||
"jsx_a11y/no-static-element-interactions": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/base/permission-selector/permission-item.tsx": {
|
||||
"jsx_a11y/click-events-have-key-events": {
|
||||
"count": 1
|
||||
},
|
||||
"jsx_a11y/no-static-element-interactions": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/base/prompt-editor/index.stories.tsx": {
|
||||
"jsx_a11y/label-has-associated-control": {
|
||||
"count": 4
|
||||
@ -2956,22 +2940,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/datasets/settings/permission-selector/member-item.tsx": {
|
||||
"jsx_a11y/click-events-have-key-events": {
|
||||
"count": 1
|
||||
},
|
||||
"jsx_a11y/no-static-element-interactions": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/datasets/settings/permission-selector/permission-item.tsx": {
|
||||
"jsx_a11y/click-events-have-key-events": {
|
||||
"count": 1
|
||||
},
|
||||
"jsx_a11y/no-static-element-interactions": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/develop/code.tsx": {
|
||||
"typescript/no-explicit-any": {
|
||||
"count": 6
|
||||
|
||||
@ -1,295 +0,0 @@
|
||||
import type { Member } from '@/models/common'
|
||||
import { Avatar } from '@langgenius/dify-ui/avatar'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
|
||||
import { RiArrowDownSLine, RiGroup2Line, RiLock2Line } from '@remixicon/react'
|
||||
import { useDebounceFn } from 'ahooks'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
// oxlint-disable-next-line no-restricted-imports -- This legacy selector still relies on showLeftIcon/showClearIcon props from the old input.
|
||||
import Input from '@/app/components/base/input'
|
||||
import { userProfileAtom } from '@/context/account-state'
|
||||
import { PermissionLevel } from '@/models/permission'
|
||||
import MemberItem from './member-item'
|
||||
import Item from './permission-item'
|
||||
|
||||
type PermissionSelectorProps = {
|
||||
disabled?: boolean
|
||||
permission?: PermissionLevel
|
||||
value: string[]
|
||||
memberList: Member[]
|
||||
onChange: (permission?: PermissionLevel) => void
|
||||
onMemberSelect: (v: string[]) => void
|
||||
/** i18n namespace for label strings (defaults to datasetSettings for backward compat) */
|
||||
i18nNamespace?: 'datasetSettings'
|
||||
/**
|
||||
* Hide the "Partial members" option. Useful for surfaces (e.g. plugin
|
||||
* credential creation) where partial-member access is delegated to RBAC
|
||||
* and the picker should only expose only_me / all_team_members.
|
||||
*/
|
||||
hidePartialMembers?: boolean
|
||||
}
|
||||
|
||||
const PermissionSelector = ({
|
||||
disabled,
|
||||
permission,
|
||||
value,
|
||||
memberList,
|
||||
onChange,
|
||||
onMemberSelect,
|
||||
i18nNamespace = 'datasetSettings',
|
||||
hidePartialMembers = false,
|
||||
}: PermissionSelectorProps) => {
|
||||
const { t } = useTranslation()
|
||||
const userProfile = useAtomValue(userProfileAtom)
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const [keywords, setKeywords] = useState('')
|
||||
const [searchKeywords, setSearchKeywords] = useState('')
|
||||
const { run: handleSearch } = useDebounceFn(
|
||||
() => {
|
||||
setSearchKeywords(keywords)
|
||||
},
|
||||
{ wait: 500 },
|
||||
)
|
||||
const handleKeywordsChange = (value: string) => {
|
||||
setKeywords(value)
|
||||
handleSearch()
|
||||
}
|
||||
const selectMember = useCallback(
|
||||
(member: Member) => {
|
||||
if (value.includes(member.id)) onMemberSelect(value.filter((v) => v !== member.id))
|
||||
else onMemberSelect([...value, member.id])
|
||||
},
|
||||
[value, onMemberSelect],
|
||||
)
|
||||
|
||||
const selectedMembers = useMemo(() => {
|
||||
return [
|
||||
userProfile,
|
||||
...memberList
|
||||
.filter((member) => member.id !== userProfile.id)
|
||||
.filter((member) => value.includes(member.id)),
|
||||
]
|
||||
}, [userProfile, value, memberList])
|
||||
|
||||
const showMe = useMemo(() => {
|
||||
return (
|
||||
(userProfile.name ?? '').includes(searchKeywords) ||
|
||||
(userProfile.email ?? '').includes(searchKeywords)
|
||||
)
|
||||
}, [searchKeywords, userProfile])
|
||||
|
||||
const filteredMemberList = useMemo(() => {
|
||||
return memberList.filter(
|
||||
(member) =>
|
||||
(member.name.includes(searchKeywords) || member.email.includes(searchKeywords)) &&
|
||||
member.id !== userProfile.id &&
|
||||
['owner', 'admin', 'editor', 'dataset_operator'].includes(member.role),
|
||||
)
|
||||
}, [memberList, searchKeywords, userProfile])
|
||||
|
||||
const onSelectOnlyMe = useCallback(() => {
|
||||
onChange(PermissionLevel.onlyMe)
|
||||
setOpen(false)
|
||||
}, [onChange])
|
||||
|
||||
const onSelectAllMembers = useCallback(() => {
|
||||
onChange(PermissionLevel.allTeamMembers)
|
||||
setOpen(false)
|
||||
}, [onChange])
|
||||
|
||||
const onSelectPartialMembers = useCallback(() => {
|
||||
onChange(PermissionLevel.partialMembers)
|
||||
onMemberSelect([userProfile.id])
|
||||
}, [onChange, onMemberSelect, userProfile])
|
||||
|
||||
const isOnlyMe = permission === PermissionLevel.onlyMe
|
||||
const isAllTeamMembers = permission === PermissionLevel.allTeamMembers
|
||||
const isPartialMembers = permission === PermissionLevel.partialMembers
|
||||
const selectedMemberNames = selectedMembers.map((member) => member.name).join(', ')
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<div className="relative">
|
||||
<PopoverTrigger
|
||||
disabled={disabled}
|
||||
render={
|
||||
<div
|
||||
className={cn(
|
||||
'flex cursor-pointer items-center gap-x-0.5 rounded-lg bg-components-input-bg-normal px-2 py-1 hover:bg-state-base-hover-alt',
|
||||
open && 'bg-state-base-hover-alt',
|
||||
disabled &&
|
||||
'cursor-not-allowed! bg-components-input-bg-disabled! hover:bg-components-input-bg-disabled!',
|
||||
)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{isOnlyMe && (
|
||||
<>
|
||||
<div className="flex size-6 shrink-0 items-center justify-center">
|
||||
<Avatar avatar={userProfile.avatar_url} name={userProfile.name} size="xs" />
|
||||
</div>
|
||||
<div className="grow p-1 system-sm-regular text-components-input-text-filled">
|
||||
{t(($) => $['form.permissionsOnlyMe'], { ns: i18nNamespace })}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{isAllTeamMembers && (
|
||||
<>
|
||||
<div className="flex size-6 shrink-0 items-center justify-center">
|
||||
<RiGroup2Line className="size-4 text-text-secondary" />
|
||||
</div>
|
||||
<div className="grow p-1 system-sm-regular text-components-input-text-filled">
|
||||
{t(($) => $['form.permissionsAllMember'], { ns: i18nNamespace })}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{isPartialMembers && (
|
||||
<>
|
||||
<div className="relative flex size-6 shrink-0 items-center justify-center">
|
||||
{selectedMembers.length === 1 && (
|
||||
<Avatar
|
||||
avatar={selectedMembers[0]!.avatar_url}
|
||||
name={selectedMembers[0]!.name}
|
||||
size="xs"
|
||||
/>
|
||||
)}
|
||||
{selectedMembers.length >= 2 && (
|
||||
<>
|
||||
<Avatar
|
||||
avatar={selectedMembers[0]!.avatar_url}
|
||||
name={selectedMembers[0]!.name}
|
||||
className="absolute top-0 left-0 z-0"
|
||||
size="xxs"
|
||||
/>
|
||||
<Avatar
|
||||
avatar={selectedMembers[1]!.avatar_url}
|
||||
name={selectedMembers[1]!.name}
|
||||
className="absolute right-0 bottom-0 z-10"
|
||||
size="xxs"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
title={selectedMemberNames}
|
||||
className="grow truncate p-1 system-sm-regular text-components-input-text-filled"
|
||||
>
|
||||
{selectedMemberNames}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<RiArrowDownSLine
|
||||
className={cn(
|
||||
'h-4 w-4 shrink-0 text-text-quaternary group-hover:text-text-secondary',
|
||||
open && 'text-text-secondary',
|
||||
disabled && 'text-components-input-text-placeholder!',
|
||||
)}
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent placement="bottom-start" sideOffset={4} popupClassName="w-[480px] p-0">
|
||||
<div className="p-1">
|
||||
{/* Only me */}
|
||||
<Item
|
||||
leftIcon={
|
||||
<Avatar
|
||||
avatar={userProfile.avatar_url}
|
||||
name={userProfile.name}
|
||||
className="shrink-0"
|
||||
size="sm"
|
||||
/>
|
||||
}
|
||||
text={t(($) => $['form.permissionsOnlyMe'], { ns: i18nNamespace })}
|
||||
onClick={onSelectOnlyMe}
|
||||
isSelected={isOnlyMe}
|
||||
/>
|
||||
{/* All team members */}
|
||||
<Item
|
||||
leftIcon={
|
||||
<div className="flex size-6 shrink-0 items-center justify-center">
|
||||
<RiGroup2Line className="size-4 text-text-secondary" />
|
||||
</div>
|
||||
}
|
||||
text={t(($) => $['form.permissionsAllMember'], { ns: i18nNamespace })}
|
||||
onClick={onSelectAllMembers}
|
||||
isSelected={isAllTeamMembers}
|
||||
/>
|
||||
{/* Partial members */}
|
||||
{!hidePartialMembers && (
|
||||
<Item
|
||||
leftIcon={
|
||||
<div className="flex size-6 shrink-0 items-center justify-center">
|
||||
<RiLock2Line className="size-4 text-text-secondary" />
|
||||
</div>
|
||||
}
|
||||
text={t(($) => $['form.permissionsInvitedMembers'], { ns: i18nNamespace })}
|
||||
onClick={onSelectPartialMembers}
|
||||
isSelected={isPartialMembers}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{!hidePartialMembers && isPartialMembers && (
|
||||
<div className="max-h-[360px] overflow-y-auto border-t border-divider-regular pr-1 pb-1 pl-1">
|
||||
<div className="sticky top-0 left-0 z-10 bg-components-panel-on-panel-item-bg p-2 pb-1">
|
||||
<Input
|
||||
showLeftIcon
|
||||
showClearIcon
|
||||
value={keywords}
|
||||
onChange={(e) => handleKeywordsChange(e.target.value)}
|
||||
onClear={() => handleKeywordsChange('')}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col p-1">
|
||||
{showMe && (
|
||||
<MemberItem
|
||||
leftIcon={
|
||||
<Avatar
|
||||
avatar={userProfile.avatar_url}
|
||||
name={userProfile.name}
|
||||
className="shrink-0"
|
||||
size="sm"
|
||||
/>
|
||||
}
|
||||
name={userProfile.name}
|
||||
email={userProfile.email}
|
||||
isSelected
|
||||
isMe
|
||||
i18nNamespace={i18nNamespace}
|
||||
/>
|
||||
)}
|
||||
{filteredMemberList.map((member) => (
|
||||
<MemberItem
|
||||
key={member.id}
|
||||
leftIcon={
|
||||
<Avatar
|
||||
avatar={member.avatar_url}
|
||||
name={member.name}
|
||||
className="shrink-0"
|
||||
size="sm"
|
||||
/>
|
||||
}
|
||||
name={member.name}
|
||||
email={member.email}
|
||||
isSelected={value.includes(member.id)}
|
||||
onClick={selectMember.bind(null, member)}
|
||||
i18nNamespace={i18nNamespace}
|
||||
/>
|
||||
))}
|
||||
{!showMe && filteredMemberList.length === 0 && (
|
||||
<div className="flex items-center justify-center px-1 py-6 text-center system-xs-regular whitespace-pre-wrap text-text-tertiary">
|
||||
{t(($) => $['form.onSearchResults'], { ns: i18nNamespace })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</div>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
export default PermissionSelector
|
||||
@ -1,51 +0,0 @@
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { RiCheckLine } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
type MemberItemProps = {
|
||||
leftIcon: React.ReactNode
|
||||
name: string
|
||||
email: string
|
||||
isSelected: boolean
|
||||
isMe?: boolean
|
||||
onClick?: () => void
|
||||
i18nNamespace?: 'datasetSettings'
|
||||
}
|
||||
|
||||
const MemberItem = ({
|
||||
leftIcon,
|
||||
name,
|
||||
email,
|
||||
isSelected,
|
||||
isMe = false,
|
||||
onClick,
|
||||
i18nNamespace = 'datasetSettings',
|
||||
}: MemberItemProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex cursor-pointer items-center gap-2 rounded-lg py-1 pr-[10px] pl-2 hover:bg-state-base-hover"
|
||||
onClick={onClick}
|
||||
>
|
||||
{leftIcon}
|
||||
<div className="grow">
|
||||
<div className="truncate system-sm-medium text-text-secondary">
|
||||
{name}
|
||||
{isMe && (
|
||||
<span className="system-xs-regular text-text-tertiary">
|
||||
{t(($) => $['form.me'], { ns: i18nNamespace })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="truncate system-xs-regular text-text-tertiary">{email}</div>
|
||||
</div>
|
||||
{isSelected && (
|
||||
<RiCheckLine className={cn('size-4 shrink-0 text-text-accent', isMe && 'opacity-30')} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(MemberItem)
|
||||
@ -1,24 +0,0 @@
|
||||
import { RiCheckLine } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
|
||||
type PermissionItemProps = {
|
||||
leftIcon: React.ReactNode
|
||||
text: string
|
||||
onClick: () => void
|
||||
isSelected: boolean
|
||||
}
|
||||
|
||||
const PermissionItem = ({ leftIcon, text, onClick, isSelected }: PermissionItemProps) => {
|
||||
return (
|
||||
<div
|
||||
className="flex cursor-pointer items-center gap-x-1 rounded-lg px-2 py-1 hover:bg-state-base-hover"
|
||||
onClick={onClick}
|
||||
>
|
||||
{leftIcon}
|
||||
<div className="grow px-1 system-md-regular text-text-secondary">{text}</div>
|
||||
{isSelected && <RiCheckLine className="size-4 text-text-accent" />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(PermissionItem)
|
||||
@ -1,574 +1,228 @@
|
||||
import type { ComponentProps, ReactNode } from 'react'
|
||||
import type { Member } from '@/models/common'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import { render, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { Provider } from 'jotai'
|
||||
import { userProfileQueryOptions } from '@/features/account-profile/client'
|
||||
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
|
||||
import { defaultSystemFeatures } from '@/features/system-features/config'
|
||||
import { DatasetPermission } from '@/models/datasets'
|
||||
import { renderWithConsoleQuery } from '@/test/console/query-data'
|
||||
import { createQueryAtomTestStore } from '@/test/query-atom'
|
||||
import PermissionSelector from '../index'
|
||||
|
||||
const mockConsoleState = vi.hoisted(() => ({
|
||||
userProfile: {
|
||||
id: 'user-1',
|
||||
name: 'Current User',
|
||||
email: 'current@example.com',
|
||||
const currentUser = {
|
||||
id: 'user-1',
|
||||
name: 'Current User',
|
||||
email: 'current@example.com',
|
||||
avatar: '',
|
||||
avatar_url: null,
|
||||
is_password_set: true,
|
||||
timezone: 'UTC',
|
||||
}
|
||||
|
||||
const memberList: Member[] = [
|
||||
{
|
||||
...currentUser,
|
||||
avatar_url: '',
|
||||
role: 'owner',
|
||||
roles: [],
|
||||
last_login_at: '',
|
||||
created_at: '',
|
||||
status: 'active',
|
||||
},
|
||||
}))
|
||||
{
|
||||
id: 'user-2',
|
||||
name: 'John Doe',
|
||||
email: 'john@example.com',
|
||||
avatar: '',
|
||||
avatar_url: '',
|
||||
role: 'admin',
|
||||
roles: [],
|
||||
last_login_at: '',
|
||||
created_at: '',
|
||||
status: 'active',
|
||||
},
|
||||
{
|
||||
id: 'user-3',
|
||||
name: 'Jane Smith',
|
||||
email: 'jane@example.com',
|
||||
avatar: '',
|
||||
avatar_url: '',
|
||||
role: 'normal',
|
||||
roles: [],
|
||||
last_login_at: '',
|
||||
created_at: '',
|
||||
status: 'active',
|
||||
},
|
||||
]
|
||||
|
||||
let mockIsRbacEnabled = false
|
||||
const defaultProps: ComponentProps<typeof PermissionSelector> = {
|
||||
permission: DatasetPermission.onlyMe,
|
||||
value: ['user-1'],
|
||||
memberList,
|
||||
onChange: vi.fn(),
|
||||
onMemberSelect: vi.fn(),
|
||||
}
|
||||
|
||||
vi.mock('@/context/account-state', async () => {
|
||||
const { createAccountStateModuleMock } = await import('@/test/console/state-fixture')
|
||||
const renderSelector = (
|
||||
props: Partial<ComponentProps<typeof PermissionSelector>> = {},
|
||||
options: { rbacEnabled?: boolean } = {},
|
||||
) => {
|
||||
const { queryClient, store } = createQueryAtomTestStore()
|
||||
queryClient.setQueryData(userProfileQueryOptions().queryKey, {
|
||||
profile: currentUser,
|
||||
meta: { currentVersion: null, currentEnv: null },
|
||||
})
|
||||
queryClient.setQueryData(systemFeaturesQueryOptions().queryKey, {
|
||||
...defaultSystemFeatures,
|
||||
rbac_enabled: options.rbacEnabled ?? false,
|
||||
})
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<Provider store={store}>{children}</Provider>
|
||||
)
|
||||
|
||||
return createAccountStateModuleMock(() => mockConsoleState)
|
||||
})
|
||||
vi.mock('@/context/workspace-state', async () => {
|
||||
const { createWorkspaceStateModuleMock } = await import('@/test/console/state-fixture')
|
||||
|
||||
return createWorkspaceStateModuleMock(() => mockConsoleState)
|
||||
})
|
||||
vi.mock('@/context/permission-state', async () => {
|
||||
const { createPermissionStateModuleMock } = await import('@/test/console/state-fixture')
|
||||
|
||||
return createPermissionStateModuleMock(() => mockConsoleState)
|
||||
})
|
||||
vi.mock('@/context/system-features-state', async () => {
|
||||
const { createSystemFeaturesStateModuleMock } = await import('@/test/console/state-fixture')
|
||||
|
||||
return createSystemFeaturesStateModuleMock(() => ({
|
||||
...(() => mockConsoleState)(),
|
||||
datasetRbacEnabled: (() => ({
|
||||
isRbacEnabled: mockIsRbacEnabled,
|
||||
}))().isRbacEnabled,
|
||||
}))
|
||||
})
|
||||
return render(<PermissionSelector {...defaultProps} {...props} />, { wrapper })
|
||||
}
|
||||
|
||||
describe('PermissionSelector', () => {
|
||||
const mockMemberList: Member[] = [
|
||||
{
|
||||
id: 'user-1',
|
||||
name: 'Current User',
|
||||
email: 'current@example.com',
|
||||
avatar: '',
|
||||
avatar_url: '',
|
||||
role: 'owner',
|
||||
roles: [],
|
||||
last_login_at: '',
|
||||
created_at: '',
|
||||
status: 'active',
|
||||
}!,
|
||||
{
|
||||
id: 'user-2',
|
||||
name: 'John Doe',
|
||||
email: 'john@example.com',
|
||||
avatar: '',
|
||||
avatar_url: '',
|
||||
role: 'admin',
|
||||
roles: [],
|
||||
last_login_at: '',
|
||||
created_at: '',
|
||||
status: 'active',
|
||||
}!,
|
||||
{
|
||||
id: 'user-3',
|
||||
name: 'Jane Smith',
|
||||
email: 'jane@example.com',
|
||||
avatar: '',
|
||||
avatar_url: '',
|
||||
role: 'editor',
|
||||
roles: [],
|
||||
last_login_at: '',
|
||||
created_at: '',
|
||||
status: 'active',
|
||||
}!,
|
||||
{
|
||||
id: 'user-4',
|
||||
name: 'Dataset Operator',
|
||||
email: 'operator@example.com',
|
||||
avatar: '',
|
||||
avatar_url: '',
|
||||
role: 'dataset_operator',
|
||||
roles: [],
|
||||
last_login_at: '',
|
||||
created_at: '',
|
||||
status: 'active',
|
||||
}!,
|
||||
]
|
||||
|
||||
const defaultProps = {
|
||||
permission: DatasetPermission.onlyMe,
|
||||
value: ['user-1'!],
|
||||
memberList: mockMemberList,
|
||||
onChange: vi.fn(),
|
||||
onMemberSelect: vi.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockIsRbacEnabled = false
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render Only Me option when permission is onlyMe', () => {
|
||||
renderWithConsoleQuery(
|
||||
<PermissionSelector {...defaultProps} permission={DatasetPermission.onlyMe} />,
|
||||
)
|
||||
expect(screen.getByText(/form\.permissionsOnlyMe/))!.toBeInTheDocument()
|
||||
it('opens from its native button with the keyboard', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderSelector()
|
||||
|
||||
const trigger = screen.getByRole('button', { name: /permissionsOnlyMe/ })
|
||||
expect(trigger).toHaveAttribute('type', 'button')
|
||||
|
||||
await user.tab()
|
||||
expect(trigger).toHaveFocus()
|
||||
await user.keyboard('{Enter}')
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: /form.permissions/ })
|
||||
expect(within(dialog).getByRole('radiogroup', { name: /form.permissions/ })).toBeInTheDocument()
|
||||
expect(within(dialog).getByRole('radio', { name: /permissionsOnlyMe/ })).toBeChecked()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['the disabled prop', { disabled: true }, false],
|
||||
['dataset RBAC', {}, true],
|
||||
])('uses a disabled native trigger for %s', async (_, props, rbacEnabled) => {
|
||||
const user = userEvent.setup()
|
||||
renderSelector(props, { rbacEnabled })
|
||||
|
||||
const trigger = rbacEnabled
|
||||
? screen.getByRole('button', { name: /permissionsAccessConfig/ })
|
||||
: screen.getByRole('button', { name: /permissionsOnlyMe/ })
|
||||
expect(trigger).toBeDisabled()
|
||||
|
||||
await user.click(trigger)
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.each([
|
||||
[DatasetPermission.onlyMe, /permissionsOnlyMe/],
|
||||
[DatasetPermission.allTeamMembers, /permissionsAllMember/],
|
||||
])('selects %s and closes the popover', async (permission, optionName) => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
renderSelector({
|
||||
permission:
|
||||
permission === DatasetPermission.onlyMe
|
||||
? DatasetPermission.allTeamMembers
|
||||
: DatasetPermission.onlyMe,
|
||||
onChange,
|
||||
})
|
||||
|
||||
it('should render All Team Members option when permission is allTeamMembers', () => {
|
||||
renderWithConsoleQuery(
|
||||
<PermissionSelector {...defaultProps} permission={DatasetPermission.allTeamMembers} />,
|
||||
)
|
||||
expect(screen.getByText(/form\.permissionsAllMember/))!.toBeInTheDocument()
|
||||
await user.click(
|
||||
screen.getByRole('button', {
|
||||
name:
|
||||
permission === DatasetPermission.onlyMe ? /permissionsAllMember/ : /permissionsOnlyMe/,
|
||||
}),
|
||||
)
|
||||
const popover = screen.getByRole('dialog', { name: /form.permissions/ })
|
||||
await user.click(within(popover).getByRole('radio', { name: optionName }))
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(permission)
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('resets partial access to the current user and keeps the popover open', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
const onMemberSelect = vi.fn()
|
||||
renderSelector({ onChange, onMemberSelect })
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /permissionsOnlyMe/ }))
|
||||
const popover = screen.getByRole('dialog', { name: /form.permissions/ })
|
||||
await user.click(within(popover).getByRole('radio', { name: /permissionsInvitedMembers/ }))
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(DatasetPermission.partialMembers)
|
||||
expect(onMemberSelect).toHaveBeenCalledWith(['user-1'])
|
||||
expect(screen.getByRole('dialog', { name: /form.permissions/ })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('uses radio keyboard navigation without closing the popover', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
renderSelector({ onChange })
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /permissionsOnlyMe/ }))
|
||||
const dialog = screen.getByRole('dialog', { name: /form.permissions/ })
|
||||
const onlyMe = within(dialog).getByRole('radio', { name: /permissionsOnlyMe/ })
|
||||
onlyMe.focus()
|
||||
await user.keyboard('{ArrowDown}')
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(DatasetPermission.allTeamMembers)
|
||||
expect(dialog).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.each([
|
||||
[['user-1'], ['user-1', 'user-2']],
|
||||
[['user-1', 'user-2'], ['user-1']],
|
||||
])('toggles a member using a native button', async (value, expectedValue) => {
|
||||
const user = userEvent.setup()
|
||||
const onMemberSelect = vi.fn()
|
||||
renderSelector({
|
||||
permission: DatasetPermission.partialMembers,
|
||||
value,
|
||||
onMemberSelect,
|
||||
})
|
||||
|
||||
it('should render selected member names when permission is partialMembers', () => {
|
||||
renderWithConsoleQuery(
|
||||
<PermissionSelector
|
||||
{...defaultProps}
|
||||
permission={DatasetPermission.partialMembers}
|
||||
value={['user-1', 'user-2']}
|
||||
/>,
|
||||
)
|
||||
// Should show member names
|
||||
// Should show member names
|
||||
expect(screen.getByTitle(/Current User/))!.toBeInTheDocument()
|
||||
await user.click(screen.getByRole('button', { name: /Current User/ }))
|
||||
await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: /John Doe/ }))
|
||||
|
||||
expect(onMemberSelect).toHaveBeenCalledWith(expectedValue)
|
||||
})
|
||||
|
||||
it('filters members after the search debounce and clears the query', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderSelector({ permission: DatasetPermission.partialMembers })
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Current User/ }))
|
||||
const search = screen.getByRole('textbox', { name: /operation.search/ })
|
||||
await user.type(search, 'Jane')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /Jane Smith/ })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /John Doe/ })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /operation.clear/ }))
|
||||
expect(search).toHaveValue('')
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /John Doe/ })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Dropdown Toggle', () => {
|
||||
it('should open dropdown when clicked', async () => {
|
||||
renderWithConsoleQuery(<PermissionSelector {...defaultProps} />)
|
||||
it('shows the empty state when no member matches', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderSelector({ permission: DatasetPermission.partialMembers })
|
||||
|
||||
const trigger = screen.getByText(/form\.permissionsOnlyMe/)
|
||||
fireEvent.click(trigger)
|
||||
await user.click(screen.getByRole('button', { name: /Current User/ }))
|
||||
await user.type(screen.getByRole('textbox', { name: /operation.search/ }), 'Nobody')
|
||||
|
||||
await waitFor(() => {
|
||||
// Should show all permission options in dropdown
|
||||
expect(screen.getAllByText(/form\.permissionsOnlyMe/).length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
})
|
||||
|
||||
it('should not open dropdown when disabled', () => {
|
||||
renderWithConsoleQuery(<PermissionSelector {...defaultProps} disabled={true} />)
|
||||
|
||||
const trigger = screen.getByText(/form\.permissionsOnlyMe/)
|
||||
fireEvent.click(trigger)
|
||||
|
||||
// Dropdown should not open - only the trigger text should be visible
|
||||
expect(screen.getAllByText(/form\.permissionsOnlyMe/).length).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Permission Selection', () => {
|
||||
it('should call onChange with onlyMe when Only Me is selected', async () => {
|
||||
const handleChange = vi.fn()
|
||||
renderWithConsoleQuery(
|
||||
<PermissionSelector
|
||||
{...defaultProps}
|
||||
onChange={handleChange}
|
||||
permission={DatasetPermission.allTeamMembers}
|
||||
/>,
|
||||
)
|
||||
|
||||
const trigger = screen.getByText(/form\.permissionsAllMember/)
|
||||
fireEvent.click(trigger)
|
||||
|
||||
await waitFor(() => {
|
||||
const onlyMeOptions = screen.getAllByText(/form\.permissionsOnlyMe/)
|
||||
fireEvent.click(onlyMeOptions[0]!)
|
||||
})
|
||||
|
||||
expect(handleChange).toHaveBeenCalledWith(DatasetPermission.onlyMe)
|
||||
})
|
||||
|
||||
it('should call onChange with allTeamMembers when All Team Members is selected', async () => {
|
||||
const handleChange = vi.fn()
|
||||
renderWithConsoleQuery(<PermissionSelector {...defaultProps} onChange={handleChange} />)
|
||||
|
||||
const trigger = screen.getByText(/form\.permissionsOnlyMe/)
|
||||
fireEvent.click(trigger)
|
||||
|
||||
await waitFor(() => {
|
||||
const allMemberOptions = screen.getAllByText(/form\.permissionsAllMember/)
|
||||
fireEvent.click(allMemberOptions[0]!)
|
||||
})
|
||||
|
||||
expect(handleChange).toHaveBeenCalledWith(DatasetPermission.allTeamMembers)
|
||||
})
|
||||
|
||||
it('should call onChange with partialMembers when Invited Members is selected', async () => {
|
||||
const handleChange = vi.fn()
|
||||
const handleMemberSelect = vi.fn()
|
||||
renderWithConsoleQuery(
|
||||
<PermissionSelector
|
||||
{...defaultProps}
|
||||
onChange={handleChange}
|
||||
onMemberSelect={handleMemberSelect}
|
||||
/>,
|
||||
)
|
||||
|
||||
const trigger = screen.getByText(/form\.permissionsOnlyMe/)
|
||||
fireEvent.click(trigger)
|
||||
|
||||
await waitFor(() => {
|
||||
const invitedOptions = screen.getAllByText(/form\.permissionsInvitedMembers/)
|
||||
fireEvent.click(invitedOptions[0]!)
|
||||
})
|
||||
|
||||
expect(handleChange).toHaveBeenCalledWith(DatasetPermission.partialMembers)
|
||||
expect(handleMemberSelect).toHaveBeenCalledWith(['user-1'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Member Selection', () => {
|
||||
it('should show member list when partialMembers is selected', async () => {
|
||||
renderWithConsoleQuery(
|
||||
<PermissionSelector {...defaultProps} permission={DatasetPermission.partialMembers} />,
|
||||
)
|
||||
|
||||
const trigger = screen.getByTitle(/Current User/)
|
||||
fireEvent.click(trigger)
|
||||
|
||||
await waitFor(() => {
|
||||
// Should show member list
|
||||
// Should show member list
|
||||
expect(screen.getByText('John Doe'))!.toBeInTheDocument()
|
||||
expect(screen.getByText('Jane Smith'))!.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onMemberSelect when a member is clicked', async () => {
|
||||
const handleMemberSelect = vi.fn()
|
||||
renderWithConsoleQuery(
|
||||
<PermissionSelector
|
||||
{...defaultProps}
|
||||
permission={DatasetPermission.partialMembers}
|
||||
onMemberSelect={handleMemberSelect}
|
||||
/>,
|
||||
)
|
||||
|
||||
const trigger = screen.getByTitle(/Current User/)
|
||||
fireEvent.click(trigger)
|
||||
|
||||
await waitFor(() => {
|
||||
const johnDoe = screen.getByText('John Doe')
|
||||
fireEvent.click(johnDoe)
|
||||
})
|
||||
|
||||
expect(handleMemberSelect).toHaveBeenCalledWith(['user-1', 'user-2'])
|
||||
})
|
||||
|
||||
it('should deselect member when clicked again', async () => {
|
||||
const handleMemberSelect = vi.fn()
|
||||
renderWithConsoleQuery(
|
||||
<PermissionSelector
|
||||
{...defaultProps}
|
||||
permission={DatasetPermission.partialMembers}
|
||||
value={['user-1', 'user-2']}
|
||||
onMemberSelect={handleMemberSelect}
|
||||
/>,
|
||||
)
|
||||
|
||||
const trigger = screen.getByTitle(/Current User/)
|
||||
fireEvent.click(trigger)
|
||||
|
||||
await waitFor(() => {
|
||||
const johnDoe = screen.getByText('John Doe')
|
||||
fireEvent.click(johnDoe)
|
||||
})
|
||||
|
||||
expect(handleMemberSelect).toHaveBeenCalledWith(['user-1'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Search Functionality', () => {
|
||||
it('should allow typing in search input', async () => {
|
||||
renderWithConsoleQuery(
|
||||
<PermissionSelector {...defaultProps} permission={DatasetPermission.partialMembers} />,
|
||||
)
|
||||
|
||||
const trigger = screen.getByTitle(/Current User/)
|
||||
fireEvent.click(trigger)
|
||||
|
||||
// Wait for dropdown to open
|
||||
const searchInput = await screen.findByRole('textbox')
|
||||
|
||||
// Type in search
|
||||
fireEvent.change(searchInput, { target: { value: 'John' } })
|
||||
expect(searchInput)!.toHaveValue('John')
|
||||
})
|
||||
|
||||
it('should render search input in partial members mode', async () => {
|
||||
renderWithConsoleQuery(
|
||||
<PermissionSelector {...defaultProps} permission={DatasetPermission.partialMembers} />,
|
||||
)
|
||||
|
||||
const trigger = screen.getByTitle(/Current User/)
|
||||
fireEvent.click(trigger)
|
||||
|
||||
// Wait for dropdown to open and search input to be available
|
||||
const searchInput = await screen.findByRole('textbox')
|
||||
expect(searchInput)!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should filter members after debounce completes', async () => {
|
||||
renderWithConsoleQuery(
|
||||
<PermissionSelector {...defaultProps} permission={DatasetPermission.partialMembers} />,
|
||||
)
|
||||
|
||||
const trigger = screen.getByTitle(/Current User/)
|
||||
fireEvent.click(trigger)
|
||||
|
||||
// Wait for dropdown to open
|
||||
const searchInput = await screen.findByRole('textbox')
|
||||
|
||||
// Type in search
|
||||
fireEvent.change(searchInput, { target: { value: 'John' } })
|
||||
|
||||
// Wait for debounce (500ms) + buffer
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(screen.getByText('John Doe'))!.toBeInTheDocument()
|
||||
},
|
||||
{ timeout: 1000 },
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle clear search functionality', async () => {
|
||||
renderWithConsoleQuery(
|
||||
<PermissionSelector {...defaultProps} permission={DatasetPermission.partialMembers} />,
|
||||
)
|
||||
|
||||
const trigger = screen.getByTitle(/Current User/)
|
||||
fireEvent.click(trigger)
|
||||
|
||||
// Wait for dropdown to open
|
||||
const searchInput = await screen.findByRole('textbox')
|
||||
|
||||
// Type in search
|
||||
fireEvent.change(searchInput, { target: { value: 'test' } })
|
||||
expect(searchInput)!.toHaveValue('test')
|
||||
|
||||
const clearButton = screen.getByRole('button', { name: 'common.operation.clear' })
|
||||
fireEvent.click(clearButton)
|
||||
|
||||
// After clicking clear, input should be empty
|
||||
await waitFor(() => {
|
||||
expect(searchInput)!.toHaveValue('')
|
||||
})
|
||||
})
|
||||
|
||||
it('should filter members by email', async () => {
|
||||
renderWithConsoleQuery(
|
||||
<PermissionSelector {...defaultProps} permission={DatasetPermission.partialMembers} />,
|
||||
)
|
||||
|
||||
const trigger = screen.getByTitle(/Current User/)
|
||||
fireEvent.click(trigger)
|
||||
|
||||
// Wait for dropdown to open
|
||||
const searchInput = await screen.findByRole('textbox')
|
||||
|
||||
// Search by email
|
||||
fireEvent.change(searchInput, { target: { value: 'john@example' } })
|
||||
|
||||
// Wait for debounce
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(screen.getByText('John Doe'))!.toBeInTheDocument()
|
||||
},
|
||||
{ timeout: 1000 },
|
||||
)
|
||||
})
|
||||
|
||||
it('should show no results message when search matches nothing', async () => {
|
||||
renderWithConsoleQuery(
|
||||
<PermissionSelector {...defaultProps} permission={DatasetPermission.partialMembers} />,
|
||||
)
|
||||
|
||||
const trigger = screen.getByTitle(/Current User/)
|
||||
fireEvent.click(trigger)
|
||||
|
||||
// Wait for dropdown to open
|
||||
const searchInput = await screen.findByRole('textbox')
|
||||
|
||||
// Search for non-existent member
|
||||
fireEvent.change(searchInput, { target: { value: 'nonexistent12345' } })
|
||||
|
||||
// Wait for debounce and no results message
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(screen.getByText(/form\.onSearchResults/))!.toBeInTheDocument()
|
||||
},
|
||||
{ timeout: 1000 },
|
||||
)
|
||||
})
|
||||
|
||||
it('should show current user when search matches user name', async () => {
|
||||
renderWithConsoleQuery(
|
||||
<PermissionSelector {...defaultProps} permission={DatasetPermission.partialMembers} />,
|
||||
)
|
||||
|
||||
const trigger = screen.getByTitle(/Current User/)
|
||||
fireEvent.click(trigger)
|
||||
|
||||
// Wait for dropdown to open
|
||||
const searchInput = await screen.findByRole('textbox')
|
||||
|
||||
// Search for current user by name - partial match
|
||||
fireEvent.change(searchInput, { target: { value: 'Current' } })
|
||||
|
||||
// Current user (showMe) should remain visible based on name match
|
||||
// The component uses useMemo to check if userProfile.name.includes(searchKeywords)
|
||||
// Current user (showMe) should remain visible based on name match
|
||||
// The component uses useMemo to check if userProfile.name.includes(searchKeywords)
|
||||
expect(searchInput)!.toHaveValue('Current')
|
||||
// Current User label appears multiple times (trigger + member list)
|
||||
expect(screen.getAllByText('Current User').length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('should show current user when search matches user email', async () => {
|
||||
renderWithConsoleQuery(
|
||||
<PermissionSelector {...defaultProps} permission={DatasetPermission.partialMembers} />,
|
||||
)
|
||||
|
||||
const trigger = screen.getByTitle(/Current User/)
|
||||
fireEvent.click(trigger)
|
||||
|
||||
// Wait for dropdown to open
|
||||
const searchInput = await screen.findByRole('textbox')
|
||||
|
||||
// Search for current user by email
|
||||
fireEvent.change(searchInput, { target: { value: 'current@' } })
|
||||
|
||||
// The component checks userProfile.email.includes(searchKeywords)
|
||||
// The component checks userProfile.email.includes(searchKeywords)
|
||||
expect(searchInput)!.toHaveValue('current@')
|
||||
// Current User should remain visible based on email match
|
||||
expect(screen.getAllByText('Current User').length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Disabled State', () => {
|
||||
it('should apply disabled styles when disabled', () => {
|
||||
const { container } = renderWithConsoleQuery(
|
||||
<PermissionSelector {...defaultProps} disabled={true} />,
|
||||
)
|
||||
// When disabled, the component has cursor-not-allowed! class (escaped in Tailwind)
|
||||
const triggerElement = container.querySelector('[class*="cursor-not-allowed"]')
|
||||
expect(triggerElement)!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show access config hint and remain closed when RBAC is enabled', () => {
|
||||
mockIsRbacEnabled = true
|
||||
|
||||
renderWithConsoleQuery(<PermissionSelector {...defaultProps} />, {
|
||||
systemFeatures: {
|
||||
rbac_enabled: true,
|
||||
},
|
||||
})
|
||||
|
||||
const trigger = screen.getByText(/form\.permissionsAccessConfig/)
|
||||
fireEvent.click(trigger)
|
||||
|
||||
expect(screen.getByText(/form\.permissionsAccessConfig/))!.toBeInTheDocument()
|
||||
expect(screen.queryByText(/form\.permissionsOnlyMe/))!.not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Display Variations', () => {
|
||||
it('should display single avatar when only one member selected', () => {
|
||||
renderWithConsoleQuery(
|
||||
<PermissionSelector
|
||||
{...defaultProps}
|
||||
permission={DatasetPermission.partialMembers}
|
||||
value={['user-1']}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Should display single avatar
|
||||
// Should display single avatar
|
||||
expect(screen.getByTitle(/Current User/))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display two avatars when two or more members selected', () => {
|
||||
renderWithConsoleQuery(
|
||||
<PermissionSelector
|
||||
{...defaultProps}
|
||||
permission={DatasetPermission.partialMembers}
|
||||
value={['user-1', 'user-2']}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Should display member names
|
||||
// Should display member names
|
||||
expect(screen.getByTitle(/Current User, John Doe/))!.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty member list', () => {
|
||||
renderWithConsoleQuery(<PermissionSelector {...defaultProps} memberList={[]} />)
|
||||
|
||||
expect(screen.getByText(/form\.permissionsOnlyMe/))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle member list with only current user', () => {
|
||||
renderWithConsoleQuery(
|
||||
<PermissionSelector {...defaultProps} memberList={[mockMemberList[0]!]} />,
|
||||
)
|
||||
|
||||
expect(screen.getByText(/form\.permissionsOnlyMe/))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should only show members with allowed roles', () => {
|
||||
// The component filters members by role in useMemo
|
||||
// Allowed roles are: owner, admin, editor, dataset_operator
|
||||
// This is tested indirectly through the memberList filtering
|
||||
const memberListWithNormalUser: Member[] = [
|
||||
...mockMemberList,
|
||||
{
|
||||
id: 'user-5',
|
||||
name: 'Normal User',
|
||||
email: 'normal@example.com',
|
||||
avatar: '',
|
||||
avatar_url: '',
|
||||
role: 'normal',
|
||||
roles: [],
|
||||
last_login_at: '',
|
||||
created_at: '',
|
||||
status: 'active',
|
||||
},
|
||||
]
|
||||
|
||||
renderWithConsoleQuery(
|
||||
<PermissionSelector
|
||||
{...defaultProps}
|
||||
memberList={memberListWithNormalUser}
|
||||
permission={DatasetPermission.partialMembers}
|
||||
/>,
|
||||
)
|
||||
|
||||
// The component renders - the filtering logic is internal
|
||||
// The component renders - the filtering logic is internal
|
||||
expect(screen.getByTitle(/Current User/))!.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should update when permission prop changes', () => {
|
||||
const { rerender } = renderWithConsoleQuery(
|
||||
<PermissionSelector {...defaultProps} permission={DatasetPermission.onlyMe} />,
|
||||
)
|
||||
|
||||
expect(screen.getByText(/form\.permissionsOnlyMe/))!.toBeInTheDocument()
|
||||
|
||||
rerender(
|
||||
<PermissionSelector {...defaultProps} permission={DatasetPermission.allTeamMembers} />,
|
||||
)
|
||||
|
||||
expect(screen.getByText(/form\.permissionsAllMember/))!.toBeInTheDocument()
|
||||
})
|
||||
expect(await screen.findByText(/form.onSearchResults/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -2,24 +2,25 @@ import type { Member } from '@/models/common'
|
||||
import { Avatar } from '@langgenius/dify-ui/avatar'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Input } from '@langgenius/dify-ui/input'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
|
||||
import { Popover, PopoverContent, PopoverTitle, PopoverTrigger } from '@langgenius/dify-ui/popover'
|
||||
import { RadioGroup } from '@langgenius/dify-ui/radio'
|
||||
import { useDebounceFn } from 'ahooks'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { userProfileAtom } from '@/context/account-state'
|
||||
import { datasetRbacEnabledAtom } from '@/context/system-features-state'
|
||||
import { DatasetPermission } from '@/models/datasets'
|
||||
import MemberItem from './member-item'
|
||||
import Item from './permission-item'
|
||||
import PermissionItem from './permission-item'
|
||||
|
||||
type RoleSelectorProps = {
|
||||
type PermissionSelectorProps = {
|
||||
disabled?: boolean
|
||||
permission?: DatasetPermission
|
||||
value: string[]
|
||||
memberList: Member[]
|
||||
onChange: (permission?: DatasetPermission) => void
|
||||
onMemberSelect: (v: string[]) => void
|
||||
onMemberSelect: (value: string[]) => void
|
||||
}
|
||||
|
||||
const PermissionSelector = ({
|
||||
@ -29,283 +30,272 @@ const PermissionSelector = ({
|
||||
memberList,
|
||||
onChange,
|
||||
onMemberSelect,
|
||||
}: RoleSelectorProps) => {
|
||||
}: PermissionSelectorProps) => {
|
||||
const { t } = useTranslation()
|
||||
const userProfile = useAtomValue(userProfileAtom)
|
||||
const isRbacEnabled = useAtomValue(datasetRbacEnabledAtom)
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const [keywords, setKeywords] = useState('')
|
||||
const [searchKeywords, setSearchKeywords] = useState('')
|
||||
const { run: handleSearch } = useDebounceFn(
|
||||
() => {
|
||||
setSearchKeywords(keywords)
|
||||
(nextKeywords: string) => {
|
||||
setSearchKeywords(nextKeywords)
|
||||
},
|
||||
{ wait: 500 },
|
||||
)
|
||||
const handleKeywordsChange = (value: string) => {
|
||||
setKeywords(value)
|
||||
handleSearch()
|
||||
const handleKeywordsChange = (nextKeywords: string) => {
|
||||
setKeywords(nextKeywords)
|
||||
handleSearch(nextKeywords)
|
||||
}
|
||||
const selectMember = (member: Member) => {
|
||||
if (value.includes(member.id)) onMemberSelect(value.filter((id) => id !== member.id))
|
||||
else onMemberSelect([...value, member.id])
|
||||
}
|
||||
const selectMember = useCallback(
|
||||
(member: Member) => {
|
||||
if (value.includes(member.id)) onMemberSelect(value.filter((v) => v !== member.id))
|
||||
else onMemberSelect([...value, member.id])
|
||||
},
|
||||
[value, onMemberSelect],
|
||||
)
|
||||
|
||||
const selectedMembers = useMemo(() => {
|
||||
return [
|
||||
const selectedMembers = useMemo(
|
||||
() => [
|
||||
userProfile,
|
||||
...memberList
|
||||
.filter((member) => member.id !== userProfile.id)
|
||||
.filter((member) => value.includes(member.id)),
|
||||
]
|
||||
}, [userProfile, value, memberList])
|
||||
|
||||
const showMe = useMemo(() => {
|
||||
return userProfile.name.includes(searchKeywords) || userProfile.email.includes(searchKeywords)
|
||||
}, [searchKeywords, userProfile])
|
||||
|
||||
const filteredMemberList = useMemo(() => {
|
||||
return memberList.filter(
|
||||
(member) =>
|
||||
(member.name.includes(searchKeywords) || member.email.includes(searchKeywords)) &&
|
||||
member.id !== userProfile.id,
|
||||
)
|
||||
}, [memberList, searchKeywords, userProfile])
|
||||
|
||||
const onSelectOnlyMe = useCallback(() => {
|
||||
onChange(DatasetPermission.onlyMe)
|
||||
setOpen(false)
|
||||
}, [onChange])
|
||||
|
||||
const onSelectAllMembers = useCallback(() => {
|
||||
onChange(DatasetPermission.allTeamMembers)
|
||||
setOpen(false)
|
||||
}, [onChange])
|
||||
|
||||
const onSelectPartialMembers = useCallback(() => {
|
||||
onChange(DatasetPermission.partialMembers)
|
||||
onMemberSelect([userProfile.id])
|
||||
}, [onChange, onMemberSelect, userProfile])
|
||||
...memberList.filter((member) => member.id !== userProfile.id && value.includes(member.id)),
|
||||
],
|
||||
[memberList, userProfile, value],
|
||||
)
|
||||
const filteredMemberList = useMemo(
|
||||
() =>
|
||||
memberList.filter(
|
||||
(member) =>
|
||||
member.id !== userProfile.id &&
|
||||
(member.name.includes(searchKeywords) || member.email.includes(searchKeywords)),
|
||||
),
|
||||
[memberList, searchKeywords, userProfile.id],
|
||||
)
|
||||
|
||||
const isOnlyMe = permission === DatasetPermission.onlyMe
|
||||
const isAllTeamMembers = permission === DatasetPermission.allTeamMembers
|
||||
const isPartialMembers = permission === DatasetPermission.partialMembers
|
||||
const showMe =
|
||||
userProfile.name.includes(searchKeywords) || userProfile.email.includes(searchKeywords)
|
||||
const selectedMemberNames = selectedMembers.map((member) => member.name).join(', ')
|
||||
const isDisabledByRBAC = isRbacEnabled
|
||||
const isDisabled = disabled || isDisabledByRBAC
|
||||
const isDisabledByRbac = isRbacEnabled
|
||||
const isDisabled = disabled || isDisabledByRbac
|
||||
const permissionLabel = t(($) => $['form.permissions'], { ns: 'datasetSettings' })
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (isDisabled) return
|
||||
setOpen(nextOpen)
|
||||
}}
|
||||
>
|
||||
<div className="relative">
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<div
|
||||
className={cn(
|
||||
'group flex cursor-pointer items-center gap-x-0.5 rounded-lg bg-components-input-bg-normal px-2 py-1 hover:bg-state-base-hover-alt data-popup-open:bg-state-base-hover-alt',
|
||||
isDisabled &&
|
||||
'cursor-not-allowed! bg-components-input-bg-disabled! hover:bg-components-input-bg-disabled!',
|
||||
)}
|
||||
>
|
||||
{isDisabledByRBAC && (
|
||||
<>
|
||||
<div className="flex size-6 shrink-0 items-center justify-center">
|
||||
<span className="i-ri-lock-2-line size-4 text-text-tertiary" />
|
||||
</div>
|
||||
<div className="grow p-1 system-sm-regular text-components-input-text-placeholder">
|
||||
{t(($) => $['form.permissionsAccessConfig'], { ns: 'datasetSettings' })}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{!isDisabledByRBAC && isOnlyMe && (
|
||||
<>
|
||||
<div className="flex size-6 shrink-0 items-center justify-center">
|
||||
<Avatar avatar={userProfile.avatar_url} name={userProfile.name} size="xs" />
|
||||
</div>
|
||||
<div className="grow p-1 system-sm-regular text-components-input-text-filled">
|
||||
{t(($) => $['form.permissionsOnlyMe'], { ns: 'datasetSettings' })}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{!isDisabledByRBAC && isAllTeamMembers && (
|
||||
<>
|
||||
<div className="flex size-6 shrink-0 items-center justify-center">
|
||||
<span className="i-ri-group-2-line size-4 text-text-secondary" />
|
||||
</div>
|
||||
<div className="grow p-1 system-sm-regular text-components-input-text-filled">
|
||||
{t(($) => $['form.permissionsAllMember'], { ns: 'datasetSettings' })}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{!isDisabledByRBAC && isPartialMembers && (
|
||||
<>
|
||||
<div className="relative flex size-6 shrink-0 items-center justify-center">
|
||||
{selectedMembers.length === 1 && (
|
||||
<Avatar
|
||||
avatar={selectedMembers[0]!.avatar_url}
|
||||
name={selectedMembers[0]!.name}
|
||||
size="xs"
|
||||
/>
|
||||
)}
|
||||
{selectedMembers.length >= 2 && (
|
||||
<>
|
||||
<Avatar
|
||||
avatar={selectedMembers[0]!.avatar_url}
|
||||
name={selectedMembers[0]!.name}
|
||||
className="absolute top-0 left-0 z-0"
|
||||
size="xxs"
|
||||
/>
|
||||
<Avatar
|
||||
avatar={selectedMembers[1]!.avatar_url}
|
||||
name={selectedMembers[1]!.name}
|
||||
className="absolute right-0 bottom-0 z-10"
|
||||
size="xxs"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
title={selectedMemberNames}
|
||||
className="grow truncate p-1 system-sm-regular text-components-input-text-filled"
|
||||
>
|
||||
{selectedMemberNames}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
'i-ri-arrow-down-s-line',
|
||||
'size-4 shrink-0 text-text-quaternary group-hover:text-text-secondary group-data-popup-open:text-text-secondary',
|
||||
isDisabled && 'text-components-input-text-placeholder!',
|
||||
)}
|
||||
/>
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
disabled={isDisabled}
|
||||
className={cn(
|
||||
'group/permission-trigger flex w-full cursor-pointer touch-manipulation items-center gap-x-0.5 rounded-lg bg-components-input-bg-normal px-2 py-1 text-left outline-hidden hover:bg-state-base-hover-alt focus-visible:ring-2 focus-visible:ring-state-accent-solid data-popup-open:bg-state-base-hover-alt',
|
||||
'data-disabled:cursor-not-allowed! data-disabled:bg-components-input-bg-disabled! data-disabled:hover:bg-components-input-bg-disabled!',
|
||||
)}
|
||||
>
|
||||
{isDisabledByRbac && (
|
||||
<>
|
||||
<div className="flex size-6 shrink-0 items-center justify-center">
|
||||
<span aria-hidden="true" className="i-ri-lock-2-line size-4 text-text-tertiary" />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<PopoverContent
|
||||
placement="bottom-start"
|
||||
sideOffset={4}
|
||||
popupClassName="border-none bg-transparent shadow-none"
|
||||
>
|
||||
<div className="relative w-[480px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg shadow-shadow-shadow-5">
|
||||
<div className="p-1">
|
||||
{/* Only me */}
|
||||
<Item
|
||||
leftIcon={
|
||||
<div className="grow p-1 system-sm-regular text-components-input-text-placeholder">
|
||||
{t(($) => $['form.permissionsAccessConfig'], { ns: 'datasetSettings' })}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{!isDisabledByRbac && isOnlyMe && (
|
||||
<>
|
||||
<div className="flex size-6 shrink-0 items-center justify-center">
|
||||
<Avatar avatar={userProfile.avatar_url} name={userProfile.name} size="xs" />
|
||||
</div>
|
||||
<div className="grow p-1 system-sm-regular text-components-input-text-filled">
|
||||
{t(($) => $['form.permissionsOnlyMe'], { ns: 'datasetSettings' })}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{!isDisabledByRbac && isAllTeamMembers && (
|
||||
<>
|
||||
<div className="flex size-6 shrink-0 items-center justify-center">
|
||||
<span aria-hidden="true" className="i-ri-group-2-line size-4 text-text-secondary" />
|
||||
</div>
|
||||
<div className="grow p-1 system-sm-regular text-components-input-text-filled">
|
||||
{t(($) => $['form.permissionsAllMember'], { ns: 'datasetSettings' })}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{!isDisabledByRbac && isPartialMembers && (
|
||||
<>
|
||||
<div className="relative flex size-6 shrink-0 items-center justify-center">
|
||||
{selectedMembers.length === 1 && (
|
||||
<Avatar
|
||||
avatar={selectedMembers[0]!.avatar_url}
|
||||
name={selectedMembers[0]!.name}
|
||||
size="xs"
|
||||
/>
|
||||
)}
|
||||
{selectedMembers.length >= 2 && (
|
||||
<>
|
||||
<Avatar
|
||||
avatar={userProfile.avatar_url}
|
||||
name={userProfile.name}
|
||||
className="shrink-0"
|
||||
size="sm"
|
||||
avatar={selectedMembers[0]!.avatar_url}
|
||||
name={selectedMembers[0]!.name}
|
||||
className="absolute top-0 left-0 z-0"
|
||||
size="xxs"
|
||||
/>
|
||||
}
|
||||
text={t(($) => $['form.permissionsOnlyMe'], { ns: 'datasetSettings' })}
|
||||
onClick={onSelectOnlyMe}
|
||||
isSelected={isOnlyMe}
|
||||
/>
|
||||
{/* All team members */}
|
||||
<Item
|
||||
leftIcon={
|
||||
<div className="flex size-6 shrink-0 items-center justify-center">
|
||||
<span className="i-ri-group-2-line size-4 text-text-secondary" />
|
||||
</div>
|
||||
}
|
||||
text={t(($) => $['form.permissionsAllMember'], { ns: 'datasetSettings' })}
|
||||
onClick={onSelectAllMembers}
|
||||
isSelected={isAllTeamMembers}
|
||||
/>
|
||||
{/* Partial members */}
|
||||
<Item
|
||||
leftIcon={
|
||||
<div className="flex size-6 shrink-0 items-center justify-center">
|
||||
<span className="i-ri-lock-2-line size-4 text-text-secondary" />
|
||||
</div>
|
||||
}
|
||||
text={t(($) => $['form.permissionsInvitedMembers'], { ns: 'datasetSettings' })}
|
||||
onClick={onSelectPartialMembers}
|
||||
isSelected={isPartialMembers}
|
||||
/>
|
||||
<Avatar
|
||||
avatar={selectedMembers[1]!.avatar_url}
|
||||
name={selectedMembers[1]!.name}
|
||||
className="absolute right-0 bottom-0 z-10"
|
||||
size="xxs"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{isPartialMembers && (
|
||||
<div className="max-h-[360px] overflow-y-auto border-t border-divider-regular pr-1 pb-1 pl-1">
|
||||
<div className="sticky top-0 left-0 z-10 bg-components-panel-on-panel-item-bg p-2 pb-1">
|
||||
<div className="relative w-full">
|
||||
<span className="absolute top-1/2 left-2 i-ri-search-line size-4 -translate-y-1/2 text-components-input-text-placeholder" />
|
||||
<Input
|
||||
className={cn('w-full pl-[26px]', keywords && 'pr-[26px]')}
|
||||
value={keywords}
|
||||
placeholder={t(($) => $['operation.search'], { ns: 'common' }) || ''}
|
||||
onChange={(e) => handleKeywordsChange(e.target.value)}
|
||||
/>
|
||||
{!!keywords && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t(($) => $['operation.clear'], { ns: 'common' })}
|
||||
className="group absolute top-1/2 right-2 -translate-y-1/2 cursor-pointer border-none bg-transparent p-px"
|
||||
onClick={() => handleKeywordsChange('')}
|
||||
>
|
||||
<span
|
||||
className="i-ri-close-circle-fill size-3.5 cursor-pointer text-text-quaternary group-hover:text-text-tertiary"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
title={selectedMemberNames}
|
||||
className="min-w-0 grow truncate p-1 system-sm-regular text-components-input-text-filled"
|
||||
>
|
||||
{selectedMemberNames}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'i-ri-arrow-down-s-line size-4 shrink-0 text-text-quaternary',
|
||||
'group-hover/permission-trigger:text-text-secondary group-data-popup-open/permission-trigger:text-text-secondary',
|
||||
'group-data-disabled/permission-trigger:text-components-input-text-placeholder!',
|
||||
)}
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
placement="bottom-start"
|
||||
sideOffset={4}
|
||||
popupClassName="border-none bg-transparent shadow-none"
|
||||
>
|
||||
<PopoverTitle className="sr-only">{permissionLabel}</PopoverTitle>
|
||||
<div className="relative w-[480px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg shadow-shadow-shadow-5">
|
||||
<RadioGroup<DatasetPermission>
|
||||
value={permission}
|
||||
onValueChange={(nextPermission) => {
|
||||
onChange(nextPermission)
|
||||
if (nextPermission === DatasetPermission.partialMembers)
|
||||
onMemberSelect([userProfile.id])
|
||||
}}
|
||||
aria-label={permissionLabel}
|
||||
className="flex-col items-stretch gap-0 p-1"
|
||||
>
|
||||
<PermissionItem
|
||||
value={DatasetPermission.onlyMe}
|
||||
closeOnSelect
|
||||
leftIcon={
|
||||
<Avatar
|
||||
avatar={userProfile.avatar_url}
|
||||
name={userProfile.name}
|
||||
className="shrink-0"
|
||||
size="sm"
|
||||
/>
|
||||
}
|
||||
text={t(($) => $['form.permissionsOnlyMe'], { ns: 'datasetSettings' })}
|
||||
isSelected={isOnlyMe}
|
||||
/>
|
||||
<PermissionItem
|
||||
value={DatasetPermission.allTeamMembers}
|
||||
closeOnSelect
|
||||
leftIcon={
|
||||
<div className="flex size-6 shrink-0 items-center justify-center">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="i-ri-group-2-line size-4 text-text-secondary"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col p-1">
|
||||
{showMe && (
|
||||
<MemberItem
|
||||
leftIcon={
|
||||
<Avatar
|
||||
avatar={userProfile.avatar_url}
|
||||
name={userProfile.name}
|
||||
className="shrink-0"
|
||||
size="sm"
|
||||
/>
|
||||
}
|
||||
name={userProfile.name}
|
||||
email={userProfile.email}
|
||||
isSelected
|
||||
isMe
|
||||
/>
|
||||
)}
|
||||
{filteredMemberList.map((member) => (
|
||||
<MemberItem
|
||||
key={member.id}
|
||||
leftIcon={
|
||||
<Avatar
|
||||
avatar={member.avatar_url}
|
||||
name={member.name}
|
||||
className="shrink-0"
|
||||
size="sm"
|
||||
/>
|
||||
}
|
||||
name={member.name}
|
||||
email={member.email}
|
||||
isSelected={value.includes(member.id)}
|
||||
onClick={selectMember.bind(null, member)}
|
||||
/>
|
||||
))}
|
||||
{!showMe && filteredMemberList.length === 0 && (
|
||||
<div className="flex items-center justify-center px-1 py-6 text-center system-xs-regular whitespace-pre-wrap text-text-tertiary">
|
||||
{t(($) => $['form.onSearchResults'], { ns: 'datasetSettings' })}
|
||||
</div>
|
||||
}
|
||||
text={t(($) => $['form.permissionsAllMember'], { ns: 'datasetSettings' })}
|
||||
isSelected={isAllTeamMembers}
|
||||
/>
|
||||
<PermissionItem
|
||||
value={DatasetPermission.partialMembers}
|
||||
leftIcon={
|
||||
<div className="flex size-6 shrink-0 items-center justify-center">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="i-ri-lock-2-line size-4 text-text-secondary"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
text={t(($) => $['form.permissionsInvitedMembers'], { ns: 'datasetSettings' })}
|
||||
isSelected={isPartialMembers}
|
||||
/>
|
||||
</RadioGroup>
|
||||
{isPartialMembers && (
|
||||
<div className="max-h-[360px] overflow-y-auto border-t border-divider-regular pr-1 pb-1 pl-1">
|
||||
<div className="sticky top-0 left-0 z-10 bg-components-panel-on-panel-item-bg p-2 pb-1">
|
||||
<div className="relative w-full">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute top-1/2 left-2 i-ri-search-line size-4 -translate-y-1/2 text-components-input-text-placeholder"
|
||||
/>
|
||||
<Input
|
||||
aria-label={t(($) => $['operation.search'], { ns: 'common' })}
|
||||
name="member-search"
|
||||
autoComplete="off"
|
||||
className={cn('w-full pl-[26px]', keywords && 'pr-[26px]')}
|
||||
value={keywords}
|
||||
placeholder={t(($) => $['operation.search'], { ns: 'common' }) || ''}
|
||||
onChange={(event) => handleKeywordsChange(event.target.value)}
|
||||
/>
|
||||
{!!keywords && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t(($) => $['operation.clear'], { ns: 'common' })}
|
||||
className="group absolute top-1/2 right-2 -translate-y-1/2 cursor-pointer touch-manipulation border-none bg-transparent p-px outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid"
|
||||
onClick={() => handleKeywordsChange('')}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="i-ri-close-circle-fill size-3.5 text-text-quaternary group-hover:text-text-tertiary"
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</div>
|
||||
<div className="flex flex-col p-1">
|
||||
{showMe && (
|
||||
<MemberItem
|
||||
leftIcon={
|
||||
<Avatar
|
||||
avatar={userProfile.avatar_url}
|
||||
name={userProfile.name}
|
||||
className="shrink-0"
|
||||
size="sm"
|
||||
/>
|
||||
}
|
||||
name={userProfile.name}
|
||||
email={userProfile.email}
|
||||
isSelected
|
||||
isMe
|
||||
/>
|
||||
)}
|
||||
{filteredMemberList.map((member) => (
|
||||
<MemberItem
|
||||
key={member.id}
|
||||
leftIcon={
|
||||
<Avatar
|
||||
avatar={member.avatar_url}
|
||||
name={member.name}
|
||||
className="shrink-0"
|
||||
size="sm"
|
||||
/>
|
||||
}
|
||||
name={member.name}
|
||||
email={member.email}
|
||||
isSelected={value.includes(member.id)}
|
||||
onClick={() => selectMember(member)}
|
||||
/>
|
||||
))}
|
||||
{!showMe && filteredMemberList.length === 0 && (
|
||||
<div className="flex items-center justify-center px-1 py-6 text-center system-xs-regular whitespace-pre-wrap text-text-tertiary">
|
||||
{t(($) => $['form.onSearchResults'], { ns: 'datasetSettings' })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,10 +1,9 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { RiCheckLine } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
type MemberItemProps = {
|
||||
leftIcon: React.ReactNode
|
||||
leftIcon: ReactNode
|
||||
name: string
|
||||
email: string
|
||||
isSelected: boolean
|
||||
@ -22,13 +21,10 @@ const MemberItem = ({
|
||||
}: MemberItemProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex cursor-pointer items-center gap-2 rounded-lg py-1 pr-[10px] pl-2 hover:bg-state-base-hover"
|
||||
onClick={onClick}
|
||||
>
|
||||
const content = (
|
||||
<>
|
||||
{leftIcon}
|
||||
<div className="grow">
|
||||
<div className="min-w-0 grow">
|
||||
<div className="truncate system-sm-medium text-text-secondary">
|
||||
{name}
|
||||
{isMe && (
|
||||
@ -40,10 +36,28 @@ const MemberItem = ({
|
||||
<div className="truncate system-xs-regular text-text-tertiary">{email}</div>
|
||||
</div>
|
||||
{isSelected && (
|
||||
<RiCheckLine className={cn('size-4 shrink-0 text-text-accent', isMe && 'opacity-30')} />
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn('i-ri-check-line size-4 shrink-0 text-text-accent', isMe && 'opacity-30')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
if (isMe) {
|
||||
return <div className="flex items-center gap-2 rounded-lg py-1 pr-[10px] pl-2">{content}</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full cursor-pointer touch-manipulation items-center gap-2 rounded-lg border-none bg-transparent py-1 pr-[10px] pl-2 text-left outline-hidden hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid"
|
||||
aria-pressed={isSelected}
|
||||
onClick={onClick}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(MemberItem)
|
||||
export default MemberItem
|
||||
|
||||
@ -1,24 +1,49 @@
|
||||
import { RiCheckLine } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { DatasetPermission } from '@/models/datasets'
|
||||
import { PopoverClose } from '@langgenius/dify-ui/popover'
|
||||
import { RadioItem } from '@langgenius/dify-ui/radio'
|
||||
|
||||
type PermissionItemProps = {
|
||||
leftIcon: React.ReactNode
|
||||
value: DatasetPermission
|
||||
leftIcon: ReactNode
|
||||
text: string
|
||||
onClick: () => void
|
||||
isSelected: boolean
|
||||
closeOnSelect?: boolean
|
||||
}
|
||||
|
||||
const PermissionItem = ({ leftIcon, text, onClick, isSelected }: PermissionItemProps) => {
|
||||
return (
|
||||
<div
|
||||
className="flex cursor-pointer items-center gap-x-1 rounded-lg px-2 py-1 hover:bg-state-base-hover"
|
||||
onClick={onClick}
|
||||
>
|
||||
const className =
|
||||
'flex w-full touch-manipulation cursor-pointer items-center gap-x-1 rounded-lg border-none bg-transparent px-2 py-1 text-left outline-hidden hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid'
|
||||
|
||||
const PermissionItem = ({
|
||||
value,
|
||||
leftIcon,
|
||||
text,
|
||||
isSelected,
|
||||
closeOnSelect = false,
|
||||
}: PermissionItemProps) => {
|
||||
const content = (
|
||||
<>
|
||||
{leftIcon}
|
||||
<div className="grow px-1 system-md-regular text-text-secondary">{text}</div>
|
||||
{isSelected && <RiCheckLine className="size-4 text-text-accent" />}
|
||||
</div>
|
||||
{isSelected && (
|
||||
<span aria-hidden="true" className="i-ri-check-line size-4 text-text-accent" />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
if (closeOnSelect) {
|
||||
return (
|
||||
<PopoverClose render={<RadioItem<DatasetPermission> value={value} />} className={className}>
|
||||
{content}
|
||||
</PopoverClose>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<RadioItem<DatasetPermission> value={value} className={className}>
|
||||
{content}
|
||||
</RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(PermissionItem)
|
||||
export default PermissionItem
|
||||
|
||||
@ -2,7 +2,7 @@ import type { ApiKeyModalProps } from '../api-key-modal'
|
||||
import type { FormSchema } from '@/app/components/base/form/types'
|
||||
import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import { fireEvent, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import * as React from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
@ -97,10 +97,6 @@ vi.mock('@/app/components/base/form/types', () => ({
|
||||
FormTypeEnum: { textInput: 'text-input' },
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-common', () => ({
|
||||
useMembers: () => ({ data: { accounts: [] } }),
|
||||
}))
|
||||
|
||||
const basePayload = {
|
||||
category: AuthCategory.tool,
|
||||
provider: 'test-provider',
|
||||
@ -298,11 +294,50 @@ describe('ApiKeyModal', () => {
|
||||
expect.objectContaining({
|
||||
type: 'api-key',
|
||||
name: 'My Key',
|
||||
visibility: 'all_team_members',
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('selects credential visibility through the native trigger', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<ApiKeyModal pluginPayload={basePayload} />)
|
||||
|
||||
const trigger = screen.getByRole('button', { name: /permissionsAllMember/ })
|
||||
expect(trigger).toHaveAttribute('type', 'button')
|
||||
await user.click(trigger)
|
||||
const permissionDialog = screen.getByRole('dialog', { name: /auth.whoCanUse/ })
|
||||
const permissionGroup = within(permissionDialog).getByRole('radiogroup', {
|
||||
name: /auth.whoCanUse/,
|
||||
})
|
||||
const allMembers = within(permissionGroup).getByRole('radio', {
|
||||
name: /permissionsAllMember/,
|
||||
})
|
||||
const onlyMe = within(permissionGroup).getByRole('radio', { name: /permissionsOnlyMe/ })
|
||||
expect(allMembers).toBeChecked()
|
||||
|
||||
allMembers.focus()
|
||||
await user.keyboard('{ArrowUp}')
|
||||
expect(onlyMe).toBeChecked()
|
||||
expect(permissionDialog).toBeInTheDocument()
|
||||
|
||||
await user.click(onlyMe)
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockAddPluginCredential).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ visibility: 'only_me' }),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('disables the credential visibility trigger with the modal', () => {
|
||||
render(<ApiKeyModal pluginPayload={basePayload} disabled />)
|
||||
|
||||
expect(screen.getByRole('button', { name: /permissionsAllMember/ })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should use empty credential name when authorization name is blank in add mode', async () => {
|
||||
mockFormValues = { isCheckValidated: true, values: { api_key: 'sk-123' } }
|
||||
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import type { PluginPayload } from '../types'
|
||||
import type { CredentialPermission } from './permission-selector'
|
||||
import type { FormRefObject, FormSchema } from '@/app/components/base/form/types'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
|
||||
@ -9,9 +10,7 @@ import { EncryptedBottom } from '@/app/components/base/encrypted-bottom'
|
||||
import AuthForm from '@/app/components/base/form/form-scenarios/auth'
|
||||
import { FormTypeEnum } from '@/app/components/base/form/types'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import PermissionSelector from '@/app/components/base/permission-selector'
|
||||
import { PermissionLevel } from '@/models/permission'
|
||||
import { useMembers } from '@/service/use-common'
|
||||
import { ReadmeEntrance } from '../../readme-panel/entrance'
|
||||
import {
|
||||
useAddPluginCredentialHook,
|
||||
@ -19,6 +18,7 @@ import {
|
||||
useUpdatePluginCredentialHook,
|
||||
} from '../hooks/use-credential'
|
||||
import { CredentialTypeEnum } from '../types'
|
||||
import PermissionSelector from './permission-selector'
|
||||
|
||||
export type ApiKeyModalProps = {
|
||||
pluginPayload: PluginPayload
|
||||
@ -53,15 +53,11 @@ const ApiKeyModal = ({
|
||||
pluginPayload,
|
||||
CredentialTypeEnum.API_KEY,
|
||||
)
|
||||
const [permission, setPermission] = useState<PermissionLevel | undefined>(
|
||||
(editValues?.__visibility__ as PermissionLevel) ?? PermissionLevel.allTeamMembers,
|
||||
const [permission, setPermission] = useState<CredentialPermission>(
|
||||
editValues?.__visibility__ === PermissionLevel.onlyMe
|
||||
? PermissionLevel.onlyMe
|
||||
: PermissionLevel.allTeamMembers,
|
||||
)
|
||||
const [selectedMemberIDs, setSelectedMemberIDs] = useState<string[]>(
|
||||
(editValues?.__partial_member_list__ as string[]) ?? [],
|
||||
)
|
||||
// Only need member list when creating (the permission selector is hidden on edit).
|
||||
const { data: membersData } = useMembers()
|
||||
const memberList = membersData?.accounts ?? []
|
||||
const mergedData = useMemo(() => {
|
||||
if (formSchemasFromProps?.length) return formSchemasFromProps
|
||||
|
||||
@ -115,17 +111,11 @@ const ApiKeyModal = ({
|
||||
name: __name__ || '',
|
||||
})
|
||||
} else {
|
||||
const permissionPayload = {
|
||||
visibility: permission,
|
||||
...(permission === PermissionLevel.partialMembers
|
||||
? { partial_member_list: selectedMemberIDs.map((id) => ({ user_id: id })) }
|
||||
: {}),
|
||||
}
|
||||
await addPluginCredential({
|
||||
credentials: restValues,
|
||||
type: CredentialTypeEnum.API_KEY,
|
||||
name: __name__ || '',
|
||||
...permissionPayload,
|
||||
visibility: permission,
|
||||
})
|
||||
}
|
||||
toast.success(t(($) => $['api.actionSuccess'], { ns: 'common' }))
|
||||
@ -146,7 +136,6 @@ const ApiKeyModal = ({
|
||||
editValues,
|
||||
handleSetDoingAction,
|
||||
permission,
|
||||
selectedMemberIDs,
|
||||
])
|
||||
|
||||
const isDisabled = disabled || isLoading || doingAction
|
||||
@ -199,11 +188,7 @@ const ApiKeyModal = ({
|
||||
<PermissionSelector
|
||||
disabled={disabled}
|
||||
permission={permission}
|
||||
value={selectedMemberIDs}
|
||||
memberList={memberList}
|
||||
onChange={(v) => setPermission(v)}
|
||||
onMemberSelect={setSelectedMemberIDs}
|
||||
hidePartialMembers
|
||||
onChange={setPermission}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -0,0 +1,115 @@
|
||||
import { Avatar } from '@langgenius/dify-ui/avatar'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
Popover,
|
||||
PopoverClose,
|
||||
PopoverContent,
|
||||
PopoverTitle,
|
||||
PopoverTrigger,
|
||||
} from '@langgenius/dify-ui/popover'
|
||||
import { RadioGroup, RadioItem } from '@langgenius/dify-ui/radio'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { userProfileAtom } from '@/context/account-state'
|
||||
import { PermissionLevel } from '@/models/permission'
|
||||
|
||||
export type CredentialPermission =
|
||||
| typeof PermissionLevel.onlyMe
|
||||
| typeof PermissionLevel.allTeamMembers
|
||||
|
||||
type PermissionSelectorProps = {
|
||||
disabled?: boolean
|
||||
permission: CredentialPermission
|
||||
onChange: (permission: CredentialPermission) => void
|
||||
}
|
||||
|
||||
const optionClassName =
|
||||
'flex w-full touch-manipulation cursor-pointer items-center gap-x-1 rounded-lg border-none bg-transparent px-2 py-1 text-left outline-hidden hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid'
|
||||
|
||||
const PermissionSelector = ({ disabled, permission, onChange }: PermissionSelectorProps) => {
|
||||
const { t } = useTranslation()
|
||||
const userProfile = useAtomValue(userProfileAtom)
|
||||
const isOnlyMe = permission === PermissionLevel.onlyMe
|
||||
const isAllTeamMembers = permission === PermissionLevel.allTeamMembers
|
||||
const permissionLabel = t(($) => $['auth.whoCanUse'], { ns: 'plugin' })
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'group/permission-trigger flex w-full cursor-pointer touch-manipulation items-center gap-x-0.5 rounded-lg bg-components-input-bg-normal px-2 py-1 text-left outline-hidden hover:bg-state-base-hover-alt focus-visible:ring-2 focus-visible:ring-state-accent-solid data-popup-open:bg-state-base-hover-alt',
|
||||
'data-disabled:cursor-not-allowed! data-disabled:bg-components-input-bg-disabled! data-disabled:hover:bg-components-input-bg-disabled!',
|
||||
)}
|
||||
>
|
||||
{isOnlyMe && (
|
||||
<>
|
||||
<div className="flex size-6 shrink-0 items-center justify-center">
|
||||
<Avatar avatar={userProfile.avatar_url} name={userProfile.name} size="xs" />
|
||||
</div>
|
||||
<div className="grow p-1 system-sm-regular text-components-input-text-filled">
|
||||
{t(($) => $['form.permissionsOnlyMe'], { ns: 'datasetSettings' })}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{isAllTeamMembers && (
|
||||
<>
|
||||
<div className="flex size-6 shrink-0 items-center justify-center">
|
||||
<span aria-hidden="true" className="i-ri-group-2-line size-4 text-text-secondary" />
|
||||
</div>
|
||||
<div className="grow p-1 system-sm-regular text-components-input-text-filled">
|
||||
{t(($) => $['form.permissionsAllMember'], { ns: 'datasetSettings' })}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="i-ri-arrow-down-s-line size-4 shrink-0 text-text-quaternary group-data-disabled/permission-trigger:text-components-input-text-placeholder! group-data-popup-open/permission-trigger:text-text-secondary"
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent placement="bottom-start" sideOffset={4} popupClassName="w-[480px] p-0">
|
||||
<PopoverTitle className="sr-only">{permissionLabel}</PopoverTitle>
|
||||
<RadioGroup<CredentialPermission>
|
||||
value={permission}
|
||||
onValueChange={onChange}
|
||||
aria-label={permissionLabel}
|
||||
className="flex-col items-stretch gap-0 p-1"
|
||||
>
|
||||
<PopoverClose
|
||||
render={<RadioItem<CredentialPermission> value={PermissionLevel.onlyMe} />}
|
||||
className={optionClassName}
|
||||
>
|
||||
<Avatar
|
||||
avatar={userProfile.avatar_url}
|
||||
name={userProfile.name}
|
||||
className="shrink-0"
|
||||
size="sm"
|
||||
/>
|
||||
<div className="grow px-1 system-md-regular text-text-secondary">
|
||||
{t(($) => $['form.permissionsOnlyMe'], { ns: 'datasetSettings' })}
|
||||
</div>
|
||||
{isOnlyMe && (
|
||||
<span aria-hidden="true" className="i-ri-check-line size-4 text-text-accent" />
|
||||
)}
|
||||
</PopoverClose>
|
||||
<PopoverClose
|
||||
render={<RadioItem<CredentialPermission> value={PermissionLevel.allTeamMembers} />}
|
||||
className={optionClassName}
|
||||
>
|
||||
<div className="flex size-6 shrink-0 items-center justify-center">
|
||||
<span aria-hidden="true" className="i-ri-group-2-line size-4 text-text-secondary" />
|
||||
</div>
|
||||
<div className="grow px-1 system-md-regular text-text-secondary">
|
||||
{t(($) => $['form.permissionsAllMember'], { ns: 'datasetSettings' })}
|
||||
</div>
|
||||
{isAllTeamMembers && (
|
||||
<span aria-hidden="true" className="i-ri-check-line size-4 text-text-accent" />
|
||||
)}
|
||||
</PopoverClose>
|
||||
</RadioGroup>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
export default PermissionSelector
|
||||
Loading…
Reference in New Issue
Block a user