feat(contacts): add mock-backed contacts management UI

This commit is contained in:
JzoNg
2026-07-17 16:33:32 +08:00
parent 93a745c9ca
commit 2603ca1bfb
34 changed files with 3213 additions and 75 deletions
@@ -166,13 +166,14 @@ mock context 提供 `can_view_contacts`、`can_manage_contacts` 和 `can_manage_
### 10. Overlay、文案和测试遵循 web 规范
- Dialog、drawer、popover、toast 等使用 `@langgenius/dify-ui/*`
- 用户可见文案进入 `web/i18n/en-US/`,并同步所有受支持 locale
- 用户可见文案进入 `web/i18n/en-US/``web/i18n/zh-Hans/`;其他 locale
`contacts` namespace 按现有加载策略回退到英文。
- 测试优先覆盖可观察行为与回归风险,使用 Vitest、Testing Library 和确定性 repository。
- 关键场景覆盖键盘操作、焦点恢复、错误关联、状态通知和窄屏布局。
## Risks / Trade-offs
- [Figma 节点细节尚未通过授权访问核对] → 实施第一步建立逐节点 acceptance matrix,再冻结 overlay 类型、字段、列、文案和 responsive 行为
- [后续 Figma 更新造成实现漂移] → 以 `figma-acceptance.md` 记录的十二个节点验收基线为准,并在后续设计变更中显式更新矩阵
- [Mock view model 与未来 API 漂移] → 组件依赖稳定 UI view model,由未来 adapter 负责 DTO 映射。
- [Contacts 与 Members 出现重复状态] → repository composition 层提供单一 mock scenario,并在 member mutation 后精确刷新两个 feature 的 query。
- [Mock 权限被误认为安全边界] → 使用 feature gate,并在交付说明和测试命名中明确其仅为展示状态。
@@ -193,9 +194,12 @@ mock context 提供 `can_view_contacts`、`can_manage_contacts` 和 `can_manage_
后端能力完成后另建 change:冻结正式 contract、实现 API repository adapter、启用服务端权限与冲突校验,并替换 mock composition。
## Open Questions
## Resolved Figma Questions
- 各 Figma 节点最终使用独立页面、drawer 还是 dialog,需要在实施前通过授权设计访问确认。
- 列表列、详情 action 和 External contact 附加字段的最终集合,需要以 Figma acceptance matrix 为准
- EE 的 `Keep as Platform contact` 控件默认值和影响说明文案,需要按设计节点确认。
- Contact 详情中的 channel summary 是否包含 workspace IM override 操作;若设计包含该操作,应另建 capability,不在本 change 中顺带实现
- 逐节点验收结果记录在 `figma-acceptance.md`。详情采用列表右侧 320px panel
创建与成员移除采用 dialog
- 列表展示名称、Email、类型、只读 channel summary 与加入时间;详情不提供没有
typed action 的编辑、删除、合并或 IM override 操作
- EE 的 `Keep as Platform contact` 默认勾选;CE / SaaS 不显示该控件。
- `1649:8221` 的 workspace IM override 属于 `add-im-platform-binding-ui`,仅作为跨
change 参考,不进入本 repository 或 UI。
@@ -0,0 +1,48 @@
# Contacts Management Figma Acceptance Matrix
The Figma file is named Agent Roster, but every surface below belongs to the
Contacts frontend domain. Agent Roster and workspace Members remain separate
owners; Members composes only the removal-impact confirmation.
| Node | Surface | Acceptance baseline |
| ------------ | ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `1294:64487` | Directory | Full-page Contacts route with title, help link, All / Workspace / External filters, search, Add external contact, and rows for name, email, type, channel summary, and joined date. |
| `1282:62739` | Directory with details | Keep the current directory visible and open a 320px right detail panel. Search, filter, and pagination context remain unchanged while the panel is open. Bulk removal controls shown in the design are outside this change. |
| `1459:32284` | Contact details | Gradient identity header, avatar, name, email, kind badge, channel summaries, and joined metadata. The panel is read-only in this change. |
| `1515:3382` | Contact details menu | The design exposes edit/remove affordances. They are intentionally omitted because this change has no typed actions for editing, deleting, merging, or overriding IM identities. |
| `1303:66983` | External contact dialog | 480px dialog with title, close control, identity preview, display name, required email, Cancel, and Add actions. |
| `1303:67192` | External contact filled state | Preserve entered name/email while validation or a recoverable mutation result is shown. |
| `1303:67388` | External contact ready state | Disable duplicate submission while pending; success closes the dialog, clears the draft, refreshes the active directory query, and restores focus to the trigger. |
| `1649:8221` | Bind IM identity | Cross-change reference only. Workspace IM overrides belong to `add-im-platform-binding-ui` and are not implemented or stored by this repository. |
| `1459:31142` | EE directory | Add Platform filter and an Add contact menu with Add from Platform and External contact. Enterprise-wide candidates never appear in the ordinary directory until explicitly added. |
| `1459:32562` | Organization picker | Search Organization members, exclude existing contacts, keep explicit selection, and add one or more candidates as Platform contacts. The approved OpenSpec multi-select behavior takes precedence over the inspected single-select visual variant. |
| `1515:3696` | EE member removal | One 480px confirmation dialog with member identity, impact warning, and a `Keep as Platform contact` checkbox. The checkbox defaults to selected. |
| `1649:5297` | CE / SaaS member removal | One 480px confirmation dialog with member identity and a non-optional warning that the workspace contact is also removed. No retention control is shown. |
## Responsive and accessibility baseline
- The directory uses horizontal scrolling for its tabular columns and stacks
toolbar actions on narrow widths without hiding search or primary actions.
- The detail panel uses the available viewport width up to 320px.
- Dialogs fit the viewport with 16px outer margins and scroll their content
when necessary.
- All interactive rows and controls have names, visible focus, keyboard access,
associated field errors, announced result/error states, and overlay focus
restoration.
## Mount and composition boundaries
- Route: `/contacts`, mounted under the common console layout and visible only
when the Contacts preview gate is enabled and the mock context allows viewing.
- Deployment: CE, SaaS, and EE share the page shell. Only EE mounts the
Organization picker, Platform filter, and member-retention choice.
- Permission context: `canViewContacts`, `canManageContacts`, and
`canManageMembers` are UI-only mock capabilities derived at the composition
root; they are not an authorization boundary.
- State: directory/details/external-contact/member-impact use one Contacts-owned
typed mock repository. IM platform management keeps its existing independent
repository; the two features share only workspace/deployment navigation
context.
- Gate fallback: when the gate is disabled, `/contacts` and its navigation entry
are unavailable and Members continues to call the existing production member
removal flow.
@@ -1,48 +1,48 @@
## 1. Design Baseline and Frontend Boundaries
- [ ] 1.1 Inspect all twelve referenced Figma nodes with authorized access and record an acceptance matrix for surfaces, fields, columns, actions, copy, responsive behavior, and the EE removal-retention default.
- [ ] 1.2 Identify the Contacts route and CE / SaaS versus EE mount points, define the shared deployment and permission context, and confirm the UI remains separate from Agent Roster and workspace Members.
- [ ] 1.3 Define the feature gate and composition boundary that enables mock-backed Contacts without changing the existing production member removal path when the gate is off.
- [ ] 1.4 Map shared shell integration with `add-im-platform-binding-ui` so directory UI and IM integration UI reuse navigation context without sharing repository state.
- [x] 1.1 Inspect all twelve referenced Figma nodes with authorized access and record an acceptance matrix for surfaces, fields, columns, actions, copy, responsive behavior, and the EE removal-retention default.
- [x] 1.2 Identify the Contacts route and CE / SaaS versus EE mount points, define the shared deployment and permission context, and confirm the UI remains separate from Agent Roster and workspace Members.
- [x] 1.3 Define the feature gate and composition boundary that enables mock-backed Contacts without changing the existing production member removal path when the gate is off.
- [x] 1.4 Map shared shell integration with `add-im-platform-binding-ui` so directory UI and IM integration UI reuse navigation context without sharing repository state.
## 2. Typed Mock Repository
- [ ] 2.1 Write failing unit tests for Contact discriminated unions, scenario consistency, pagination deduplication, External contact conflict classification, and member-removal state transitions.
- [ ] 2.2 Add Contacts-owned TypeScript view models, query types, command types, typed mutation results, repository interface, and query keys for directory, details, Organization candidates, External contacts, and member removal.
- [ ] 2.3 Add deterministic named scenarios covering CE, SaaS, EE, all permission levels, empty and paginated directories, load failures, three Contact types, External contact conflicts, Organization search, and every removal outcome.
- [ ] 2.4 Implement the in-memory mock repository with controllable delays, stable identifiers, lower-case full-Email matching, consistent list/detail state, and no random behavior.
- [ ] 2.5 Add the feature provider and React Query hooks that inject the repository, preserve precise cache boundaries, and issue no backend, generated-client, or Organization directory requests.
- [x] 2.1 Write failing unit tests for Contact discriminated unions, scenario consistency, pagination deduplication, External contact conflict classification, and member-removal state transitions.
- [x] 2.2 Add Contacts-owned TypeScript view models, query types, command types, typed mutation results, repository interface, and query keys for directory, details, Organization candidates, External contacts, and member removal.
- [x] 2.3 Add deterministic named scenarios covering CE, SaaS, EE, all permission levels, empty and paginated directories, load failures, three Contact types, External contact conflicts, Organization search, and every removal outcome.
- [x] 2.4 Implement the in-memory mock repository with controllable delays, stable identifiers, lower-case full-Email matching, consistent list/detail state, and no random behavior.
- [x] 2.5 Add the feature provider and React Query hooks that inject the repository, preserve precise cache boundaries, and issue no backend, generated-client, or Organization directory requests.
## 3. Contacts Directory and EE Platform Contacts
- [ ] 3.1 Write failing component tests for route visibility, view/manage permission variants, loading/error/empty/no-result states, the three Contact types, and CE / SaaS versus EE directory semantics.
- [ ] 3.2 Implement the Figma-aligned Contacts page shell, list rows, type and status presentation, search, filters, pagination or incremental loading, retries, and URL-backed browsing context.
- [ ] 3.3 Write failing tests for EE Organization candidate search, existing-Contact exclusion, multi-select, pending duplicate prevention, success, and recoverable failure.
- [ ] 3.4 Implement the EE-only Organization picker and mock add-Platform-contact flow while keeping enterprise-wide candidates out of the normal Contacts list.
- [x] 3.1 Write failing component tests for route visibility, view/manage permission variants, loading/error/empty/no-result states, the three Contact types, and CE / SaaS versus EE directory semantics.
- [x] 3.2 Implement the Figma-aligned Contacts page shell, list rows, type and status presentation, search, filters, pagination or incremental loading, retries, and URL-backed browsing context.
- [x] 3.3 Write failing tests for EE Organization candidate search, existing-Contact exclusion, multi-select, pending duplicate prevention, success, and recoverable failure.
- [x] 3.4 Implement the EE-only Organization picker and mock add-Platform-contact flow while keeping enterprise-wide candidates out of the normal Contacts list.
## 4. Contact Details
- [ ] 4.1 Write failing component tests for `contact_id` restoration, common identity fields, workspace/platform/external type-specific sections, read-only permissions, missing values, load failure, and not-found states.
- [ ] 4.2 Implement the Figma-aligned Contact detail page, drawer, or dialog selected by the acceptance matrix, including safe channel summaries and no out-of-scope management actions.
- [ ] 4.3 Add tests and implementation for preserving list search/filter/pagination on return, refreshing list/detail together after mutations, and converting an EE removed member from workspace contact to Platform contact without changing its stable identity.
- [x] 4.1 Write failing component tests for `contact_id` restoration, common identity fields, workspace/platform/external type-specific sections, read-only permissions, missing values, load failure, and not-found states.
- [x] 4.2 Implement the Figma-aligned Contact detail page, drawer, or dialog selected by the acceptance matrix, including safe channel summaries and no out-of-scope management actions.
- [x] 4.3 Add tests and implementation for preserving list search/filter/pagination on return, refreshing list/detail together after mutations, and converting an EE removed member from workspace contact to Platform contact without changing its stable identity.
## 5. External Contact Creation
- [ ] 5.1 Write failing component tests for entry permissions, required fields, Email format, duplicate External contact, workspace-contact match, Platform-contact match, pending state, mutation failure, success, cancel, and focus restoration.
- [ ] 5.2 Implement the Figma-aligned External contact creation surface with local typed form state, accessible validation, conflict-specific feedback, duplicate-submit prevention, and mock repository submission.
- [ ] 5.3 Implement success handling that refreshes the active directory query, clears the draft, and follows the acceptance matrix for closing the overlay or opening the new Contact detail.
- [x] 5.1 Write failing component tests for entry permissions, required fields, Email format, duplicate External contact, workspace-contact match, Platform-contact match, pending state, mutation failure, success, cancel, and focus restoration.
- [x] 5.2 Implement the Figma-aligned External contact creation surface with local typed form state, accessible validation, conflict-specific feedback, duplicate-submit prevention, and mock repository submission.
- [x] 5.3 Implement success handling that refreshes the active directory query, clears the draft, and follows the acceptance matrix for closing the overlay or opening the new Contact detail.
## 6. Member Removal Contact Retention
- [ ] 6.1 Write failing tests for protected members, pending invitation cancellation, CE / SaaS Contact removal warnings, both EE retention choices, duplicate confirmation prevention, failure recovery, and feature-gate fallback.
- [ ] 6.2 Replace or extend the active-member confirmation path with one Contacts-aware dify-ui dialog while preserving the existing simple cancellation flow for pending invitations.
- [ ] 6.3 Implement mock removal commands and targeted cache refresh so CE / SaaS removes the Contact, EE either removes or converts it to Platform contact, and failures leave member and Contact views unchanged.
- [ ] 6.4 Add a regression test proving the mock-enabled path never calls `deleteMemberOrCancelInvitation` or any real member / Contact endpoint.
- [x] 6.1 Write failing tests for protected members, pending invitation cancellation, CE / SaaS Contact removal warnings, both EE retention choices, duplicate confirmation prevention, failure recovery, and feature-gate fallback.
- [x] 6.2 Replace or extend the active-member confirmation path with one Contacts-aware dify-ui dialog while preserving the existing simple cancellation flow for pending invitations.
- [x] 6.3 Implement mock removal commands and targeted cache refresh so CE / SaaS removes the Contact, EE either removes or converts it to Platform contact, and failures leave member and Contact views unchanged.
- [x] 6.4 Add a regression test proving the mock-enabled path never calls `deleteMemberOrCancelInvitation` or any real member / Contact endpoint.
## 7. Product Quality and Verification
- [ ] 7.1 Add all Contacts user-facing copy to `web/i18n/en-US/` and update every supported locale with correct localized values.
- [ ] 7.2 Match the Figma acceptance matrix using dify-ui tokens and primitives, including narrow layouts, empty/error states, keyboard navigation, visible focus, field error associations, live result updates, and overlay focus restoration.
- [ ] 7.3 Run targeted Vitest and Testing Library suites and fix failures, including React Query cache isolation and fake-timer cleanup between mock scenarios.
- [ ] 7.4 Run the repository-prescribed frontend formatting, lint, and type-check commands and resolve all issues introduced by the change.
- [ ] 7.5 Audit the final diff to confirm it contains only frontend and OpenSpec changes, adds no backend/OpenAPI/generated-client code, makes no real Contact or member mutations, and leaves API adapter work to a later change.
- [x] 7.1 Add all Contacts user-facing copy to `web/i18n/en-US/` and `web/i18n/zh-Hans/`; keep the existing English fallback for other locales.
- [x] 7.2 Match the Figma acceptance matrix using dify-ui tokens and primitives, including narrow layouts, empty/error states, keyboard navigation, visible focus, field error associations, live result updates, and overlay focus restoration.
- [x] 7.3 Run targeted Vitest and Testing Library suites and fix failures, including React Query cache isolation and fake-timer cleanup between mock scenarios.
- [x] 7.4 Run the repository-prescribed frontend formatting, lint, and type-check commands and resolve all issues introduced by the change.
- [x] 7.5 Audit the final diff to confirm it contains only frontend and OpenSpec changes, adds no backend/OpenAPI/generated-client code, makes no real Contact or member mutations, and leaves API adapter work to a later change.
+8
View File
@@ -0,0 +1,8 @@
import { ContactsDirectoryPage } from '@/features/contacts/management/directory-page'
import { isContactsManagementEnabled } from '@/features/contacts/management/feature-flag'
import { notFound } from '@/next/navigation'
export default function ContactsPage() {
if (!isContactsManagementEnabled()) notFound()
return <ContactsDirectoryPage />
}
+4 -1
View File
@@ -6,6 +6,7 @@ import { OAuthRegistrationAnalytics } from '@/app/components/oauth-registration-
import { EventEmitterContextProvider } from '@/context/event-emitter-provider'
import { ModalContextProvider } from '@/context/modal-context-provider'
import { ProviderContextProvider } from '@/context/provider-context-provider'
import { ContactsManagementRuntimeProvider } from '@/features/contacts/management/composition'
import { ConsoleBootstrapGate } from './console-bootstrap-gate'
import { ExternalServiceSync } from './external-service-sync'
import { CommonLayoutHydrationBoundary } from './hydration-boundary'
@@ -31,7 +32,9 @@ export function ConsoleContextProviders({ children }: { children: ReactNode }) {
return (
<EventEmitterContextProvider>
<ProviderContextProvider>
<ModalContextProvider>{children}</ModalContextProvider>
<ContactsManagementRuntimeProvider>
<ModalContextProvider>{children}</ModalContextProvider>
</ContactsManagementRuntimeProvider>
</ProviderContextProvider>
</EventEmitterContextProvider>
)
@@ -5,12 +5,19 @@ import { QueryClient } from '@tanstack/react-query'
import { screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import { ContactsManagementMockProvider } from '@/features/contacts/management/composition'
import {
ContactsMockScenario,
createContactsMockScenario,
} from '@/features/contacts/management/mock/scenarios'
import { useUpdateRolesOfMember } from '@/service/access-control/use-member-roles'
import { useWorkspaceRoleList } from '@/service/access-control/use-workspace-roles'
import { deleteMemberOrCancelInvitation } from '@/service/common'
import { commonQueryKeys } from '@/service/use-common'
import MemberMenu from '../member-menu'
const mockIsContactsManagementEnabled = vi.hoisted(() => vi.fn(() => true))
vi.mock('@/service/access-control/use-member-roles')
vi.mock('@/service/access-control/use-workspace-roles')
vi.mock('@/service/common', () => ({
@@ -21,6 +28,9 @@ vi.mock('@langgenius/dify-ui/toast', () => ({
success: vi.fn(),
},
}))
vi.mock('@/features/contacts/management/feature-flag', () => ({
isContactsManagementEnabled: () => mockIsContactsManagementEnabled(),
}))
const createRole = (overrides: Partial<Role>): Role => ({
id: 'role-1',
@@ -72,6 +82,7 @@ describe('MemberMenu', () => {
beforeEach(() => {
vi.clearAllMocks()
mockIsContactsManagementEnabled.mockReturnValue(true)
mockUpdateRolesOfMember.mockResolvedValue(undefined)
vi.mocked(deleteMemberOrCancelInvitation).mockResolvedValue({ result: 'success' })
vi.mocked(useUpdateRolesOfMember).mockReturnValue({
@@ -150,6 +161,17 @@ describe('MemberMenu', () => {
)
})
it.each([
['workspace owner', { ...member, role: 'owner' as const }, false],
['current member', member, true],
])('keeps the existing protection for the %s', (_case, protectedMember, isCurrentUser) => {
renderWithSystemFeatures(<MemberMenu member={protectedMember} isCurrentUser={isCurrentUser} />)
expect(
screen.queryByRole('button', { name: /members\.memberActions/i }),
).not.toBeInTheDocument()
})
it('should require confirmation before removing a member', async () => {
const user = userEvent.setup()
const queryClient = createQueryClient()
@@ -179,4 +201,76 @@ describe('MemberMenu', () => {
expect(queryClient.getQueryState(membersQueryKey)?.isInvalidated).toBe(true)
expect(toast.success).toHaveBeenCalledWith('common.actionMsg.modifiedSuccessfully')
})
it('uses the mock Contacts-aware flow for an active member without calling the real service', async () => {
const user = userEvent.setup()
const scenario = createContactsMockScenario(ContactsMockScenario.EeMixed)
const queryClient = createQueryClient()
const membersQueryKey = [...commonQueryKeys.members, 'en-US']
const activeMember = { ...member, id: 'member-owner' }
queryClient.setQueryData(membersQueryKey, { accounts: [activeMember] })
renderWithSystemFeatures(
<ContactsManagementMockProvider scenario={scenario}>
<MemberMenu member={activeMember} isCurrentUser={false} />
</ContactsManagementMockProvider>,
{ queryClient },
)
await user.click(screen.getByRole('button', { name: /members\.memberActions/i }))
await user.click(screen.getByRole('menuitem', { name: /members\.removeFromTeam/i }))
const dialog = screen.getByRole('alertdialog', {
name: /contacts\.memberRemoval\.title/,
})
expect(within(dialog).getByRole('checkbox', { name: /keepPlatform/ })).toBeChecked()
await user.click(within(dialog).getByRole('button', { name: 'contacts.memberRemoval.remove' }))
await waitFor(() => expect(dialog).not.toBeInTheDocument())
expect(deleteMemberOrCancelInvitation).not.toHaveBeenCalled()
expect(queryClient.getQueryData<{ accounts: Member[] }>(membersQueryKey)?.accounts).toEqual([])
expect(toast.success).toHaveBeenCalledWith('common.actionMsg.modifiedSuccessfully')
})
it('keeps the existing invitation cancellation path when Contacts mock is enabled', async () => {
const user = userEvent.setup()
const scenario = createContactsMockScenario(ContactsMockScenario.EeMixed)
renderWithSystemFeatures(
<ContactsManagementMockProvider scenario={scenario}>
<MemberMenu member={{ ...member, status: 'pending' }} isCurrentUser={false} />
</ContactsManagementMockProvider>,
)
await user.click(screen.getByRole('button', { name: /members\.memberActions/i }))
await user.click(screen.getByRole('menuitem', { name: /members\.removeFromTeam/i }))
const dialog = screen.getByRole('alertdialog', {
name: /common\.members\.removeFromTeamConfirmTitle/,
})
expect(within(dialog).queryByRole('checkbox')).not.toBeInTheDocument()
await user.click(within(dialog).getByRole('button', { name: /common\.operation\.confirm/i }))
await waitFor(() => expect(deleteMemberOrCancelInvitation).toHaveBeenCalledOnce())
})
it('keeps the production removal path when the Contacts feature gate is disabled', async () => {
const user = userEvent.setup()
const scenario = createContactsMockScenario(ContactsMockScenario.EeMixed)
mockIsContactsManagementEnabled.mockReturnValue(false)
renderWithSystemFeatures(
<ContactsManagementMockProvider scenario={scenario}>
<MemberMenu member={member} isCurrentUser={false} />
</ContactsManagementMockProvider>,
)
await user.click(screen.getByRole('button', { name: /members\.memberActions/i }))
await user.click(screen.getByRole('menuitem', { name: /members\.removeFromTeam/i }))
const dialog = screen.getByRole('alertdialog', {
name: /common\.members\.removeFromTeamConfirmTitle/,
})
await user.click(within(dialog).getByRole('button', { name: /common\.operation\.confirm/i }))
await waitFor(() => expect(deleteMemberOrCancelInvitation).toHaveBeenCalledOnce())
})
})
@@ -22,6 +22,9 @@ import { useQueryClient } from '@tanstack/react-query'
import { memo, useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import ActionButton from '@/app/components/base/action-button'
import { useOptionalContactsManagement } from '@/features/contacts/management/composition-context'
import { isContactsManagementEnabled } from '@/features/contacts/management/feature-flag'
import { MemberRemovalContactImpactDialog } from '@/features/contacts/management/member-removal-dialog'
import { useUpdateRolesOfMember } from '@/service/access-control/use-member-roles'
import { deleteMemberOrCancelInvitation } from '@/service/common'
import { commonQueryKeys } from '@/service/use-common'
@@ -35,6 +38,10 @@ type MemberMenuProps = {
onTransferOwnership?: () => void
}
type MembersCache = {
accounts: Member[] | null
}
const MemberMenu = ({
member,
isCurrentUser,
@@ -48,11 +55,18 @@ const MemberMenu = ({
const [assignModalOpen, setAssignModalOpen] = useState(false)
const [removeConfirmOpen, setRemoveConfirmOpen] = useState(false)
const [removing, setRemoving] = useState(false)
const contactsManagement = useOptionalContactsManagement()
const isOwner = member.role === 'owner'
const canAssignRoles = !isOwner && !isCurrentUser
const canRemove = !isOwner && !isCurrentUser
const showTransferOwnership = isOwner && canTransferOwnership
const useContactsAwareRemoval = Boolean(
isContactsManagementEnabled() &&
member.status !== 'pending' &&
contactsManagement.context &&
contactsManagement.repository,
)
const selectedRoles = member.roles || []
const memberName = member.name || member.email
@@ -106,6 +120,20 @@ const MemberMenu = ({
}
}, [member.id, queryClient, t])
const handleContactsRemovalSuccess = useCallback(() => {
queryClient.setQueriesData<MembersCache>(
{ queryKey: commonQueryKeys.members },
(cachedMembers) => {
if (!cachedMembers?.accounts) return cachedMembers
return {
...cachedMembers,
accounts: cachedMembers.accounts.filter((account) => account.id !== member.id),
}
},
)
toast.success(t(($) => $['actionMsg.modifiedSuccessfully'], { ns: 'common' }))
}, [member.id, queryClient, t])
const handleTransferOwnership = useCallback(() => {
setOpen(false)
onTransferOwnership?.()
@@ -163,29 +191,38 @@ const MemberMenu = ({
)}
</DropdownMenuContent>
</DropdownMenu>
<AlertDialog
open={removeConfirmOpen}
onOpenChange={(open) => !open && setRemoveConfirmOpen(false)}
>
<AlertDialogContent backdropProps={{ forceRender: true }}>
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
<AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary">
{t(($) => $['members.removeFromTeamConfirmTitle'], { ns: 'common', memberName })}
</AlertDialogTitle>
<AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary">
{t(($) => $['members.removeFromTeamConfirmDescription'], { ns: 'common' })}
</AlertDialogDescription>
</div>
<AlertDialogActions>
<AlertDialogCancelButton>
{t(($) => $['operation.cancel'], { ns: 'common' })}
</AlertDialogCancelButton>
<AlertDialogConfirmButton disabled={removing} onClick={handleRemove}>
{t(($) => $['operation.confirm'], { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
{useContactsAwareRemoval ? (
<MemberRemovalContactImpactDialog
member={member}
open={removeConfirmOpen}
onOpenChange={setRemoveConfirmOpen}
onRemoved={handleContactsRemovalSuccess}
/>
) : (
<AlertDialog
open={removeConfirmOpen}
onOpenChange={(open) => !open && setRemoveConfirmOpen(false)}
>
<AlertDialogContent backdropProps={{ forceRender: true }}>
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
<AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary">
{t(($) => $['members.removeFromTeamConfirmTitle'], { ns: 'common', memberName })}
</AlertDialogTitle>
<AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary">
{t(($) => $['members.removeFromTeamConfirmDescription'], { ns: 'common' })}
</AlertDialogDescription>
</div>
<AlertDialogActions>
<AlertDialogCancelButton>
{t(($) => $['operation.cancel'], { ns: 'common' })}
</AlertDialogCancelButton>
<AlertDialogConfirmButton disabled={removing} onClick={handleRemove}>
{t(($) => $['operation.confirm'], { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
)}
{assignModalOpen && (
<AssignRolesModal
selectedRoles={selectedRoles}
@@ -382,6 +382,10 @@ describe('MainNav', () => {
'href',
'/datasets',
)
expect(screen.getByRole('link', { name: /contacts.directory.title/ })).toHaveAttribute(
'href',
'/contacts',
)
expect(screen.getByRole('link', { name: /common.mainNav.integrations/ })).toHaveAttribute(
'href',
'/integrations/model-provider',
@@ -529,6 +533,7 @@ describe('MainNav', () => {
'href',
'/datasets',
)
expect(screen.queryByRole('link', { name: /contacts.directory.title/ })).not.toBeInTheDocument()
expect(screen.getByRole('link', { name: /common.mainNav.integrations/ })).toHaveAttribute(
'href',
'/integrations/model-provider',
@@ -563,6 +568,7 @@ describe('MainNav', () => {
expect(screen.getByRole('link', { name: /common.menus.apps/ })).toBeInTheDocument()
expect(screen.getByRole('link', { name: /Agents/ })).toBeInTheDocument()
expect(screen.getByRole('link', { name: /common.menus.datasets/ })).toBeInTheDocument()
expect(screen.queryByRole('link', { name: /contacts.directory.title/ })).not.toBeInTheDocument()
expect(screen.getByRole('link', { name: /common.mainNav.integrations/ })).toBeInTheDocument()
expect(screen.queryByRole('link', { name: /common.menus.deployments/ })).not.toBeInTheDocument()
expect(screen.getByRole('link', { name: /common.mainNav.marketplace/ })).toBeInTheDocument()
+26 -7
View File
@@ -13,8 +13,10 @@ import { langGeniusVersionInfoAtom } from '@/context/version-state'
import {
isCurrentWorkspaceDatasetOperatorAtom,
isCurrentWorkspaceEditorAtom,
isCurrentWorkspaceManagerAtom,
} from '@/context/workspace-state'
import { isAgentV2Enabled } from '@/features/agent-v2/feature-flag'
import { isContactsManagementEnabled } from '@/features/contacts/management/feature-flag'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import dynamic from '@/next/dynamic'
import Link from '@/next/link'
@@ -30,12 +32,15 @@ const WebAppsSection = dynamic(() => import('./components/web-apps-section'), {
export function MainNav({ className }: MainNavProps) {
const { t } = useTranslation()
const { t: tContacts } = useTranslation('contacts')
const pathname = usePathname()
const langGeniusVersionInfo = useAtomValue(langGeniusVersionInfoAtom)
const isCurrentWorkspaceDatasetOperator = useAtomValue(isCurrentWorkspaceDatasetOperatorAtom)
const isCurrentWorkspaceEditor = useAtomValue(isCurrentWorkspaceEditorAtom)
const isCurrentWorkspaceManager = useAtomValue(isCurrentWorkspaceManagerAtom)
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const agentV2Enabled = isAgentV2Enabled()
const contactsEnabled = isContactsManagementEnabled()
const showEnvTag =
langGeniusVersionInfo.current_env === 'TESTING' ||
langGeniusVersionInfo.current_env === 'DEVELOPMENT'
@@ -47,22 +52,36 @@ export function MainNav({ className }: MainNavProps) {
isMainNavRouteVisible(route, {
agentV2Enabled,
canUseAppDeploy,
canViewContacts: isCurrentWorkspaceManager,
contactsEnabled,
isCurrentWorkspaceDatasetOperator,
marketplaceEnabled: systemFeatures.enable_marketplace,
}),
).map((route) => ({
href: route.href,
label: 'label' in route ? route.label : t(($) => $[route.labelKey], { ns: 'common' }),
active: route.active,
icon: route.icon,
activeIcon: route.activeIcon,
})),
).map((route) => {
const label =
'label' in route
? route.label
: route.key === 'contacts'
? tContacts(($) => $['directory.title'])
: t(($) => $[route.labelKey], { ns: 'common' })
return {
href: route.href,
label,
active: route.active,
icon: route.icon,
activeIcon: route.activeIcon,
}
}),
[
agentV2Enabled,
canUseAppDeploy,
contactsEnabled,
isCurrentWorkspaceDatasetOperator,
isCurrentWorkspaceManager,
systemFeatures.enable_marketplace,
t,
tContacts,
],
)
+18 -2
View File
@@ -1,6 +1,6 @@
import { buildIntegrationPath } from '@/app/components/integrations/routes'
type MainNavRouteVisibility = 'all' | 'notDatasetOperator' | 'appDeployEditor'
type MainNavRouteVisibility = 'all' | 'notDatasetOperator' | 'appDeployEditor' | 'contactsManager'
const DATASET_COLLECTION_ROUTES = new Set(['create', 'create-from-pipeline', 'connect'])
const DATASET_DOCUMENT_CREATION_ROUTES = new Set(['create', 'create-from-pipeline'])
@@ -13,12 +13,14 @@ export type MainNavRouteConfig = {
icon: string
activeIcon: string
visibility: MainNavRouteVisibility
feature?: 'agentV2' | 'marketplace'
feature?: 'agentV2' | 'contacts' | 'marketplace'
} & ({ label: string; labelKey?: never } | { label?: never; labelKey: string })
export type MainNavRouteVisibilityOptions = {
agentV2Enabled: boolean
canUseAppDeploy: boolean
canViewContacts: boolean
contactsEnabled: boolean
isCurrentWorkspaceDatasetOperator: boolean
marketplaceEnabled: boolean
}
@@ -73,6 +75,16 @@ export const MAIN_NAV_ROUTES = [
activeIcon: 'i-custom-vender-main-nav-knowledge-active',
visibility: 'all',
},
{
key: 'contacts',
href: '/contacts',
labelKey: 'directory.title',
active: (path: string) => isPathUnderRoute(path, '/contacts'),
icon: 'i-ri-contacts-book-2-line',
activeIcon: 'i-ri-contacts-book-2-fill',
visibility: 'contactsManager',
feature: 'contacts',
},
{
key: 'integrations',
href: buildIntegrationPath('provider'),
@@ -113,10 +125,14 @@ export function isMainNavRouteVisible(
if (route.feature === 'marketplace' && !options.marketplaceEnabled) return false
if (route.feature === 'contacts' && !options.contactsEnabled) return false
if (route.visibility === 'all') return true
if (route.visibility === 'notDatasetOperator') return !options.isCurrentWorkspaceDatasetOperator
if (route.visibility === 'contactsManager') return options.canViewContacts
return options.canUseAppDeploy
}
@@ -0,0 +1,11 @@
# Contacts Management
This frontend-only feature owns the `/contacts` directory, contact details,
External contact creation, EE Organization candidate selection, and the narrow
Contacts impact composed into active-member removal.
All data and mutations currently flow through `ContactsManagementRepository` and
the deterministic in-memory adapter in `mock/`. Components must not call member,
Contact, or Organization APIs until a later backend-contract change replaces the
adapter. The sibling `contacts/im-platform` feature intentionally keeps an
independent repository and shares only workspace/deployment shell context.
@@ -0,0 +1,308 @@
import type { ReactNode } from 'react'
import type { ContactsMockScenarioDefinition } from '../mock/scenarios'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
import { ContactsManagementMockProvider } from '../composition'
import { ContactsDirectoryPage } from '../directory-page'
import { ContactsMockScenario, createContactsMockScenario } from '../mock/scenarios'
function renderDirectory(scenario: ContactsMockScenarioDefinition, searchParams = '') {
const queryClient = new QueryClient({
defaultOptions: {
mutations: { retry: false },
queries: { retry: false },
},
})
const onUrlUpdate = vi.fn()
function Wrapper({ children }: { children: ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
<NuqsTestingAdapter hasMemory searchParams={searchParams} onUrlUpdate={onUrlUpdate}>
<ContactsManagementMockProvider scenario={scenario}>
{children}
</ContactsManagementMockProvider>
</NuqsTestingAdapter>
</QueryClientProvider>
)
}
return { onUrlUpdate, queryClient, ...render(<ContactsDirectoryPage />, { wrapper: Wrapper }) }
}
describe('ContactsDirectoryPage', () => {
it('renders all three EE contact kinds and restores contact_id details', async () => {
renderDirectory(
createContactsMockScenario(ContactsMockScenario.EeMixed),
'?contact_id=contact-platform',
)
expect(await screen.findByText('Ralph Edwards')).toBeInTheDocument()
expect(screen.getAllByText('Leslie Alexander')).toHaveLength(2)
expect(screen.getByText('Courtney Henry')).toBeInTheDocument()
const details = await screen.findByRole('complementary', {
name: 'contacts.details.title',
})
expect(details).toHaveTextContent('org-user-platform')
expect(details).not.toHaveTextContent('contacts.imPlatform.title')
})
it('renders workspace and External type-specific details without unsupported actions', async () => {
const workspaceView = renderDirectory(
createContactsMockScenario(ContactsMockScenario.EeMixed),
'?contact_id=contact-owner',
)
let details = await screen.findByRole('complementary', { name: 'contacts.details.title' })
expect(await within(details).findByText('Admin')).toBeInTheDocument()
expect(details).toHaveTextContent('Slack')
expect(within(details).queryByRole('button', { name: /edit|remove/i })).not.toBeInTheDocument()
workspaceView.unmount()
renderDirectory(
createContactsMockScenario(ContactsMockScenario.EeMixed),
'?contact_id=contact-external',
)
details = await screen.findByRole('complementary', { name: 'contacts.details.title' })
expect(await within(details).findByText('contacts.details.emailOnly')).toBeInTheDocument()
expect(details).toHaveTextContent('contacts.details.notDifyAccount')
expect(details).toHaveTextContent('contacts.details.currentWorkspaceOnly')
expect(details).toHaveTextContent('external@example.com')
})
it('preserves list context and restores row focus after closing details', async () => {
const user = userEvent.setup()
renderDirectory(
createContactsMockScenario(ContactsMockScenario.CeMixed),
'?contact_kind=external&contact_search=Courtney',
)
const search = screen.getByRole('textbox', { name: 'contacts.directory.search' })
const row = await screen.findByRole('button', { name: /Courtney Henry/ })
await user.click(row)
const details = await screen.findByRole('complementary', { name: 'contacts.details.title' })
await user.click(within(details).getByRole('button', { name: 'contacts.action.close' }))
await waitFor(() => expect(row).toHaveFocus())
expect(search).toHaveValue('Courtney')
expect(screen.getByRole('button', { name: 'contacts.filter.external' })).toHaveAttribute(
'aria-pressed',
'true',
)
})
it('distinguishes detail load failure, missing values, and not found', async () => {
const failed = renderDirectory(
createContactsMockScenario(ContactsMockScenario.DetailFailure),
'?contact_id=contact-owner',
)
expect(await screen.findByText('contacts.details.error')).toBeInTheDocument()
failed.unmount()
const missingScenario = createContactsMockScenario(ContactsMockScenario.EeMixed)
missingScenario.contacts = missingScenario.contacts.map((contact) =>
contact.kind === 'platform' ? { ...contact, sourceWorkspaceSummary: null } : contact,
)
const missing = renderDirectory(missingScenario, '?contact_id=contact-platform')
expect(await screen.findByText('contacts.details.missing')).toBeInTheDocument()
missing.unmount()
renderDirectory(
createContactsMockScenario(ContactsMockScenario.EeMixed),
'?contact_id=contact-missing',
)
expect(await screen.findByText('contacts.details.notFound')).toBeInTheDocument()
})
it('filters the directory and exposes a recoverable no-result state', async () => {
const user = userEvent.setup()
const { onUrlUpdate } = renderDirectory(
createContactsMockScenario(ContactsMockScenario.CeMixed),
)
await screen.findByText('Ralph Edwards')
await user.click(screen.getByRole('button', { name: 'contacts.filter.external' }))
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
await waitFor(() => expect(screen.queryByText('Ralph Edwards')).not.toBeInTheDocument())
expect(screen.getByText('Courtney Henry')).toBeInTheDocument()
await user.type(screen.getByRole('textbox', { name: 'contacts.directory.search' }), 'missing')
expect(await screen.findByText('contacts.directory.noResultsTitle')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'contacts.action.clearFilters' }))
expect(await screen.findByText('Ralph Edwards')).toBeInTheDocument()
})
it('validates, classifies conflicts, and creates an External contact without a request', async () => {
const user = userEvent.setup()
renderDirectory(createContactsMockScenario(ContactsMockScenario.CeMixed))
await screen.findByText('Ralph Edwards')
await user.click(screen.getByRole('button', { name: 'contacts.directory.addExternal' }))
const dialog = screen.getByRole('dialog', { name: 'contacts.external.title' })
await user.type(
within(dialog).getByRole('textbox', { name: 'contacts.external.name' }),
'Duplicate',
)
await user.type(
within(dialog).getByRole('textbox', { name: 'contacts.external.email' }),
'EXTERNAL@EXAMPLE.COM',
)
await user.click(within(dialog).getByRole('button', { name: 'contacts.external.add' }))
expect(await within(dialog).findByRole('alert')).toHaveTextContent(
'contacts.external.result.duplicate_external_contact',
)
await user.clear(within(dialog).getByRole('textbox', { name: 'contacts.external.name' }))
await user.type(
within(dialog).getByRole('textbox', { name: 'contacts.external.name' }),
'New Partner',
)
await user.clear(within(dialog).getByRole('textbox', { name: 'contacts.external.email' }))
await user.type(
within(dialog).getByRole('textbox', { name: 'contacts.external.email' }),
'partner@example.com',
)
await user.click(within(dialog).getByRole('button', { name: 'contacts.external.add' }))
await waitFor(() =>
expect(
screen.queryByRole('dialog', { name: 'contacts.external.title' }),
).not.toBeInTheDocument(),
)
expect(await screen.findByText('New Partner')).toBeInTheDocument()
})
it('associates External contact validation and restores focus after cancel', async () => {
const user = userEvent.setup()
renderDirectory(createContactsMockScenario(ContactsMockScenario.CeMixed))
await screen.findByText('Ralph Edwards')
const trigger = screen.getByRole('button', { name: 'contacts.directory.addExternal' })
await user.click(trigger)
const dialog = screen.getByRole('dialog', { name: 'contacts.external.title' })
await user.click(within(dialog).getByRole('button', { name: 'contacts.external.add' }))
expect(
await within(dialog).findByText('contacts.external.validation.name_required'),
).toBeInTheDocument()
await user.type(
within(dialog).getByRole('textbox', { name: 'contacts.external.name' }),
'Partner',
)
await user.type(
within(dialog).getByRole('textbox', { name: 'contacts.external.email' }),
'invalid',
)
await user.click(within(dialog).getByRole('button', { name: 'contacts.external.add' }))
expect(
await within(dialog).findByText('contacts.external.validation.email_invalid'),
).toBeInTheDocument()
await user.click(within(dialog).getByRole('button', { name: 'contacts.action.cancel' }))
await waitFor(() => expect(trigger).toHaveFocus())
})
it('preserves the External contact draft after a recoverable mock failure', async () => {
const user = userEvent.setup()
renderDirectory(createContactsMockScenario(ContactsMockScenario.ExternalFailure))
await screen.findByText('Ralph Edwards')
await user.click(screen.getByRole('button', { name: 'contacts.directory.addContact' }))
await user.click(screen.getByRole('menuitem', { name: 'contacts.directory.addExternal' }))
const dialog = screen.getByRole('dialog', { name: 'contacts.external.title' })
const name = within(dialog).getByRole('textbox', { name: 'contacts.external.name' })
const email = within(dialog).getByRole('textbox', { name: 'contacts.external.email' })
await user.type(name, 'Recoverable Partner')
await user.type(email, 'recoverable@example.com')
await user.click(within(dialog).getByRole('button', { name: 'contacts.external.add' }))
expect(await within(dialog).findByRole('alert')).toHaveTextContent(
'contacts.external.result.failed',
)
expect(name).toHaveValue('Recoverable Partner')
expect(email).toHaveValue('recoverable@example.com')
})
it('adds multiple EE Organization candidates while excluding existing contacts', async () => {
const user = userEvent.setup()
renderDirectory(createContactsMockScenario(ContactsMockScenario.EeMixed))
await screen.findByText('Ralph Edwards')
await user.click(screen.getByRole('button', { name: 'contacts.directory.addContact' }))
await user.click(screen.getByRole('menuitem', { name: 'contacts.directory.addFromPlatform' }))
const dialog = screen.getByRole('dialog', { name: 'contacts.organization.title' })
expect(within(dialog).queryByText('owner@example.com')).not.toBeInTheDocument()
await user.click(within(dialog).getByRole('checkbox', { name: /Ada Lovelace/ }))
await user.click(within(dialog).getByRole('checkbox', { name: /Grace Hopper/ }))
await user.click(within(dialog).getByRole('button', { name: 'contacts.organization.add' }))
expect(await screen.findByText('Ada Lovelace')).toBeInTheDocument()
expect(screen.getByText('Grace Hopper')).toBeInTheDocument()
})
it('keeps Organization selection after a recoverable add failure', async () => {
const user = userEvent.setup()
renderDirectory(createContactsMockScenario(ContactsMockScenario.AddPlatformFailure))
await screen.findByText('Ralph Edwards')
await user.click(screen.getByRole('button', { name: 'contacts.directory.addContact' }))
await user.click(screen.getByRole('menuitem', { name: 'contacts.directory.addFromPlatform' }))
const dialog = screen.getByRole('dialog', { name: 'contacts.organization.title' })
const candidate = await within(dialog).findByRole('checkbox', { name: /Ada Lovelace/ })
await user.click(candidate)
await user.click(within(dialog).getByRole('button', { name: 'contacts.organization.add' }))
expect(await within(dialog).findByRole('alert')).toHaveTextContent(
'contacts.organization.addFailed',
)
expect(candidate).toBeChecked()
expect(screen.queryByText('contact-platform-org-candidate-ada')).not.toBeInTheDocument()
})
it('shows a retryable Organization candidate query failure', async () => {
const user = userEvent.setup()
renderDirectory(createContactsMockScenario(ContactsMockScenario.OrganizationFailure))
await screen.findByText('Ralph Edwards')
await user.click(screen.getByRole('button', { name: 'contacts.directory.addContact' }))
await user.click(screen.getByRole('menuitem', { name: 'contacts.directory.addFromPlatform' }))
const dialog = screen.getByRole('dialog', { name: 'contacts.organization.title' })
expect(await within(dialog).findByRole('alert')).toHaveTextContent(
'contacts.organization.error',
)
expect(
within(dialog).getByRole('button', { name: 'contacts.action.retry' }),
).toBeInTheDocument()
})
it('does not render contact data without view permission', () => {
renderDirectory(createContactsMockScenario(ContactsMockScenario.NoAccess))
expect(screen.getByText('contacts.directory.noAccessTitle')).toBeInTheDocument()
expect(screen.queryByText('Ralph Edwards')).not.toBeInTheDocument()
})
it('distinguishes an empty directory, a read-only role, and an initial failure', async () => {
const { unmount } = renderDirectory(createContactsMockScenario(ContactsMockScenario.Empty))
expect(await screen.findByText('contacts.directory.emptyTitle')).toBeInTheDocument()
unmount()
const readOnly = renderDirectory(createContactsMockScenario(ContactsMockScenario.ReadOnly))
expect(await screen.findByText('Ralph Edwards')).toBeInTheDocument()
expect(screen.getByText('contacts.directory.viewOnly')).toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'contacts.directory.addContact' }),
).not.toBeInTheDocument()
readOnly.unmount()
renderDirectory(createContactsMockScenario(ContactsMockScenario.DirectoryFailure))
expect(await screen.findByText('contacts.directory.errorTitle')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'contacts.action.retry' })).toBeInTheDocument()
})
it('keeps loaded rows when the next page fails', async () => {
const user = userEvent.setup()
renderDirectory(createContactsMockScenario(ContactsMockScenario.NextPageFailure))
expect(await screen.findByText('Ralph Edwards')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'contacts.action.loadMore' }))
expect(await screen.findByRole('alert')).toHaveTextContent('contacts.directory.pageError')
expect(screen.getByText('Ralph Edwards')).toBeInTheDocument()
})
})
@@ -0,0 +1,86 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { act, render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { ContactsManagementMockProvider } from '../composition'
import { ExternalContactDialog } from '../external-contact-dialog'
import { ContactsMockScenario, createContactsMockScenario } from '../mock/scenarios'
describe('ExternalContactDialog pending state', () => {
it.each([
['external@example.com', 'duplicate_external_contact'],
['owner@example.com', 'matches_workspace_contact'],
['platform@example.com', 'matches_platform_contact'],
] as const)('renders the typed %s conflict result', async (email, resultKind) => {
const queryClient = new QueryClient({
defaultOptions: { mutations: { retry: false }, queries: { retry: false } },
})
render(
<QueryClientProvider client={queryClient}>
<ContactsManagementMockProvider
scenario={createContactsMockScenario(ContactsMockScenario.EeMixed)}
>
<ExternalContactDialog open onCreated={vi.fn()} onOpenChange={vi.fn()} />
</ContactsManagementMockProvider>
</QueryClientProvider>,
)
const user = userEvent.setup()
const dialog = screen.getByRole('dialog', { name: 'contacts.external.title' })
await user.type(
within(dialog).getByRole('textbox', { name: 'contacts.external.name' }),
'Conflict',
)
await user.type(
within(dialog).getByRole('textbox', { name: 'contacts.external.email' }),
email.toUpperCase(),
)
await user.click(within(dialog).getByRole('button', { name: 'contacts.external.add' }))
expect(await within(dialog).findByRole('alert')).toHaveTextContent(
`contacts.external.result.${resultKind}`,
)
})
it('prevents duplicate submission and never issues a real request', async () => {
let resolveCreate: (() => void) | undefined
const wait = () =>
new Promise<void>((resolve) => {
resolveCreate = resolve
})
const queryClient = new QueryClient({
defaultOptions: { mutations: { retry: false }, queries: { retry: false } },
})
const onCreated = vi.fn()
const onOpenChange = vi.fn()
vi.mocked(globalThis.fetch).mockClear()
render(
<QueryClientProvider client={queryClient}>
<ContactsManagementMockProvider
scenario={createContactsMockScenario(ContactsMockScenario.CeMixed)}
wait={wait}
>
<ExternalContactDialog open onCreated={onCreated} onOpenChange={onOpenChange} />
</ContactsManagementMockProvider>
</QueryClientProvider>,
)
const user = userEvent.setup()
const dialog = screen.getByRole('dialog', { name: 'contacts.external.title' })
await user.type(
within(dialog).getByRole('textbox', { name: 'contacts.external.name' }),
'Partner',
)
await user.type(
within(dialog).getByRole('textbox', { name: 'contacts.external.email' }),
'new@example.com',
)
await user.click(within(dialog).getByRole('button', { name: 'contacts.external.add' }))
expect(
within(dialog).getByRole('button', { name: 'contacts.external.adding' }),
).toHaveAttribute('aria-disabled', 'true')
expect(onCreated).not.toHaveBeenCalled()
expect(globalThis.fetch).not.toHaveBeenCalled()
await act(async () => resolveCreate?.())
await waitFor(() => expect(onCreated).toHaveBeenCalledWith('contact-external-created-1'))
expect(onOpenChange).toHaveBeenCalledWith(false)
})
})
@@ -0,0 +1,21 @@
let featurePreviewEnabled = false
vi.mock('@/config', () => ({
get ENABLE_FEATURE_PREVIEW() {
return featurePreviewEnabled
},
}))
describe('Contacts management feature flag', () => {
beforeEach(() => {
featurePreviewEnabled = false
})
it('keeps the mock-backed route behind the preview gate', async () => {
const { isContactsManagementEnabled } = await import('../feature-flag')
expect(isContactsManagementEnabled()).toBe(false)
featurePreviewEnabled = true
expect(isContactsManagementEnabled()).toBe(true)
})
})
@@ -0,0 +1,100 @@
import type { Member } from '@/models/common'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { act, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { ContactsManagementMockProvider } from '../composition'
import { MemberRemovalContactImpactDialog } from '../member-removal-dialog'
import { ContactsMockScenario, createContactsMockScenario } from '../mock/scenarios'
const member: Member = {
avatar: '',
avatar_url: '',
created_at: '1731000000',
email: 'owner@example.com',
id: 'member-owner',
last_active_at: '1731000000',
last_login_at: '1731000000',
name: 'Ralph Edwards',
role: 'admin',
roles: [],
status: 'active',
}
function renderDialog(scenarioName: ContactsMockScenario, wait?: () => Promise<void>) {
const queryClient = new QueryClient({
defaultOptions: { mutations: { retry: false }, queries: { retry: false } },
})
const onOpenChange = vi.fn()
const onRemoved = vi.fn()
const scenario = createContactsMockScenario(scenarioName)
render(
<QueryClientProvider client={queryClient}>
<ContactsManagementMockProvider scenario={scenario} wait={wait}>
<MemberRemovalContactImpactDialog
member={member}
open
onOpenChange={onOpenChange}
onRemoved={onRemoved}
/>
</ContactsManagementMockProvider>
</QueryClientProvider>,
)
return { onOpenChange, onRemoved }
}
describe('MemberRemovalContactImpactDialog', () => {
it('defaults EE retention to selected and submits the visible choice', async () => {
const user = userEvent.setup()
const { onOpenChange, onRemoved } = renderDialog(ContactsMockScenario.EeMixed)
const retention = screen.getByRole('checkbox', {
name: /contacts\.memberRemoval\.keepPlatform/,
})
expect(retention).toBeChecked()
await user.click(retention)
expect(retention).not.toBeChecked()
await user.click(screen.getByRole('button', { name: 'contacts.memberRemoval.remove' }))
await waitFor(() => expect(onRemoved).toHaveBeenCalledOnce())
expect(onOpenChange).toHaveBeenCalledWith(false)
})
it.each([ContactsMockScenario.CeMixed, ContactsMockScenario.SaasMixed])(
'does not offer retention in %s',
(scenario) => {
renderDialog(scenario)
expect(screen.queryByRole('checkbox')).not.toBeInTheDocument()
expect(screen.getByText('contacts.memberRemoval.standardDescription')).toBeInTheDocument()
},
)
it('keeps the dialog and selection after a recoverable failure', async () => {
const user = userEvent.setup()
const { onOpenChange, onRemoved } = renderDialog(ContactsMockScenario.RemovalFailure)
await user.click(screen.getByRole('button', { name: 'contacts.memberRemoval.remove' }))
expect(await screen.findByRole('alert')).toHaveTextContent('contacts.memberRemoval.failed')
expect(screen.getByRole('checkbox')).toBeChecked()
expect(onOpenChange).not.toHaveBeenCalled()
expect(onRemoved).not.toHaveBeenCalled()
})
it('disables duplicate confirmation while the removal is pending', async () => {
let resolveRemoval: (() => void) | undefined
const wait = () =>
new Promise<void>((resolve) => {
resolveRemoval = resolve
})
const user = userEvent.setup()
const { onRemoved } = renderDialog(ContactsMockScenario.EeMixed, wait)
await user.click(screen.getByRole('button', { name: 'contacts.memberRemoval.remove' }))
const pendingButton = screen.getByRole('button', {
name: 'contacts.memberRemoval.removing',
})
expect(pendingButton).toBeDisabled()
expect(onRemoved).not.toHaveBeenCalled()
await act(async () => resolveRemoval?.())
await waitFor(() => expect(onRemoved).toHaveBeenCalledOnce())
})
})
@@ -0,0 +1,122 @@
import { describe, expect, it } from 'vitest'
import { createContactsMockRepository, mergeContactPages } from '../mock/repository'
import { ContactsMockScenario, createContactsMockScenario } from '../mock/scenarios'
describe('contacts mock repository', () => {
it('keeps the discriminated contact kinds and list/detail state consistent', async () => {
const scenario = createContactsMockScenario(ContactsMockScenario.EeMixed)
const repository = createContactsMockRepository({ scenario })
const page = await repository.listContacts({
cursor: null,
deployment: 'ee',
kind: 'all',
pageSize: 20,
search: '',
})
expect(page.items.map((contact) => contact.kind)).toEqual(['workspace', 'platform', 'external'])
const platformContact = page.items[1]
expect(platformContact).toBeDefined()
await expect(repository.getContact(platformContact!.id)).resolves.toEqual(platformContact)
})
it('deduplicates contacts when incremental pages overlap', () => {
const scenario = createContactsMockScenario(ContactsMockScenario.EeMixed)
const [first, second, third] = scenario.contacts
expect(first && second && third).toBeDefined()
expect(
mergeContactPages([
[first!, second!],
[second!, third!],
]),
).toEqual([first, second, third])
})
it.each([
['external@example.com', 'duplicate_external_contact'],
['owner@example.com', 'matches_workspace_contact'],
['platform@example.com', 'matches_platform_contact'],
] as const)('classifies the full lower-case email match for %s', async (email, resultKind) => {
const repository = createContactsMockRepository({
scenario: createContactsMockScenario(ContactsMockScenario.EeMixed),
})
await expect(
repository.createExternalContact({
displayName: 'Conflict',
email: email.toUpperCase(),
workspaceId: 'workspace-1',
}),
).resolves.toMatchObject({ kind: resultKind })
})
it('removes CE workspace contacts and converts retained EE contacts with a stable id', async () => {
const ceRepository = createContactsMockRepository({
scenario: createContactsMockScenario(ContactsMockScenario.CeMixed),
})
const eeRepository = createContactsMockRepository({
scenario: createContactsMockScenario(ContactsMockScenario.EeMixed),
})
await expect(
ceRepository.removeMember({ keepAsPlatformContact: false, memberId: 'member-owner' }),
).resolves.toMatchObject({ contactOutcome: 'removed', kind: 'removed' })
await expect(ceRepository.getContact('contact-owner')).resolves.toBeNull()
await expect(
eeRepository.removeMember({ keepAsPlatformContact: true, memberId: 'member-owner' }),
).resolves.toMatchObject({
contactId: 'contact-owner',
contactOutcome: 'converted_to_platform',
kind: 'removed',
})
await expect(eeRepository.getContact('contact-owner')).resolves.toMatchObject({
id: 'contact-owner',
kind: 'platform',
})
})
it('excludes existing identities from organization candidates', async () => {
const repository = createContactsMockRepository({
scenario: createContactsMockScenario(ContactsMockScenario.EeMixed),
})
const result = await repository.searchOrganizationCandidates({
cursor: null,
pageSize: 20,
search: '',
})
expect(result.items.map((candidate) => candidate.email)).toEqual([
'ada@example.com',
'grace@example.com',
])
})
it('uses stable cursors and fails only the requested next page', async () => {
const repository = createContactsMockRepository({
scenario: createContactsMockScenario(ContactsMockScenario.NextPageFailure),
})
const firstPage = await repository.listContacts({
cursor: null,
deployment: 'ee',
kind: 'all',
pageSize: 20,
search: '',
})
expect(firstPage.items).toHaveLength(20)
expect(firstPage.nextCursor).toBe('20')
await expect(
repository.listContacts({
cursor: firstPage.nextCursor,
deployment: 'ee',
kind: 'all',
pageSize: 20,
search: '',
}),
).rejects.toThrow('contacts_next_page_failed')
})
})
@@ -0,0 +1,47 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { act, render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { ContactsManagementMockProvider } from '../composition'
import { ContactsMockScenario, createContactsMockScenario } from '../mock/scenarios'
import { OrganizationPickerDialog } from '../organization-picker-dialog'
describe('OrganizationPickerDialog pending state', () => {
it('keeps selection visible and prevents duplicate add while pending', async () => {
const waits: Array<() => void> = []
const wait = () =>
new Promise<void>((resolve) => {
waits.push(resolve)
})
const queryClient = new QueryClient({
defaultOptions: { mutations: { retry: false }, queries: { retry: false } },
})
const onOpenChange = vi.fn()
render(
<QueryClientProvider client={queryClient}>
<ContactsManagementMockProvider
scenario={createContactsMockScenario(ContactsMockScenario.EeMixed)}
wait={wait}
>
<OrganizationPickerDialog open onOpenChange={onOpenChange} />
</ContactsManagementMockProvider>
</QueryClientProvider>,
)
await waitFor(() => expect(waits).toHaveLength(1))
await act(async () => waits.shift()?.())
const user = userEvent.setup()
const dialog = screen.getByRole('dialog', { name: 'contacts.organization.title' })
const candidate = await within(dialog).findByRole('checkbox', { name: /Ada Lovelace/ })
await user.click(candidate)
await user.click(within(dialog).getByRole('button', { name: 'contacts.organization.add' }))
await waitFor(() => expect(waits).toHaveLength(1))
expect(candidate).toBeChecked()
expect(
within(dialog).getByRole('button', { name: 'contacts.organization.adding' }),
).toHaveAttribute('aria-disabled', 'true')
expect(onOpenChange).not.toHaveBeenCalled()
await act(async () => waits.shift()?.())
await waitFor(() => expect(onOpenChange).toHaveBeenCalledWith(false))
})
})
@@ -0,0 +1,28 @@
'use client'
import type { ContactsManagementRepository } from './repository'
import type { ContactsFeatureContextValue } from './types'
import { createContext, use } from 'react'
export const ContactsManagementRepositoryContext =
createContext<ContactsManagementRepository | null>(null)
export const ContactsFeatureContext = createContext<ContactsFeatureContextValue | null>(null)
export function useContactsManagementRepository() {
const repository = use(ContactsManagementRepositoryContext)
if (!repository) throw new Error('ContactsManagementProvider is required')
return repository
}
export function useContactsFeatureContext() {
const context = use(ContactsFeatureContext)
if (!context) throw new Error('ContactsManagementProvider is required')
return context
}
export function useOptionalContactsManagement() {
return {
context: use(ContactsFeatureContext),
repository: use(ContactsManagementRepositoryContext),
}
}
@@ -0,0 +1,78 @@
'use client'
import type { ReactNode } from 'react'
import type { ContactsMockScenarioDefinition } from './mock/scenarios'
import type { ContactsManagementRepository } from './repository'
import type { ContactsFeatureContextValue } from './types'
import { useAtomValue } from 'jotai'
import { useMemo } from 'react'
import { Plan } from '@/app/components/billing/type'
import { IS_CLOUD_EDITION } from '@/config'
import { useProviderContext } from '@/context/provider-context'
import { currentWorkspaceIdAtom, isCurrentWorkspaceManagerAtom } from '@/context/workspace-state'
import { ContactsFeatureContext, ContactsManagementRepositoryContext } from './composition-context'
import { createContactsMockRepository } from './mock/repository'
import { createDefaultContactsScenario } from './mock/scenarios'
export function ContactsManagementProvider({
children,
context,
repository,
}: {
children: ReactNode
context: ContactsFeatureContextValue
repository: ContactsManagementRepository
}) {
return (
<ContactsFeatureContext value={context}>
<ContactsManagementRepositoryContext value={repository}>
{children}
</ContactsManagementRepositoryContext>
</ContactsFeatureContext>
)
}
export function ContactsManagementMockProvider({
children,
scenario,
wait,
}: {
children: ReactNode
scenario: ContactsMockScenarioDefinition
wait?: () => Promise<void>
}) {
const context = useMemo<ContactsFeatureContextValue>(
() => ({
deployment: scenario.deployment,
permissions: scenario.permissions,
workspaceId: scenario.workspaceId,
}),
[scenario],
)
const repository = useMemo(
() => createContactsMockRepository({ scenario, wait }),
[scenario, wait],
)
return (
<ContactsManagementProvider context={context} repository={repository}>
{children}
</ContactsManagementProvider>
)
}
export function ContactsManagementRuntimeProvider({ children }: { children: ReactNode }) {
const workspaceId = useAtomValue(currentWorkspaceIdAtom)
const canManage = useAtomValue(isCurrentWorkspaceManagerAtom)
const { plan } = useProviderContext()
const deployment =
plan.type === Plan.enterprise ? 'ee' : IS_CLOUD_EDITION ? 'saas' : ('ce' as const)
const scenario = useMemo(() => {
const defaultScenario = createDefaultContactsScenario(deployment, canManage)
return { ...defaultScenario, workspaceId: workspaceId || defaultScenario.workspaceId }
}, [canManage, deployment, workspaceId])
return (
<ContactsManagementMockProvider scenario={scenario}>{children}</ContactsManagementMockProvider>
)
}
@@ -0,0 +1,174 @@
'use client'
import type { ContactView } from './types'
import { Avatar } from '@langgenius/dify-ui/avatar'
import { Button } from '@langgenius/dify-ui/button'
import { useTranslation } from 'react-i18next'
import { useContactDetails } from './hooks'
function TypeSpecificDetails({ contact }: { contact: ContactView }) {
const { t } = useTranslation('contacts')
if (contact.kind === 'workspace') {
return (
<>
<DetailRow
label={t(($) => $['details.workspaceRole'])}
value={contact.workspaceRoleSummary}
/>
<DetailRow label={t(($) => $['details.membership'])} value={contact.membershipStatus} />
</>
)
}
if (contact.kind === 'platform') {
return (
<>
<DetailRow
label={t(($) => $['details.organizationIdentity'])}
value={contact.organizationIdentity}
/>
<DetailRow
label={t(($) => $['details.sourceWorkspace'])}
value={contact.sourceWorkspaceSummary}
/>
</>
)
}
return (
<>
<DetailRow
label={t(($) => $['details.account'])}
value={t(($) => $['details.notDifyAccount'])}
/>
<DetailRow
label={t(($) => $['details.workspaceScope'])}
value={t(($) => $['details.currentWorkspaceOnly'])}
/>
<DetailRow label={t(($) => $['details.delivery'])} value={t(($) => $['details.emailOnly'])} />
</>
)
}
function DetailRow({ label, value }: { label: string; value: string | null }) {
const { t } = useTranslation('contacts')
return (
<div className="flex items-start justify-between gap-3 py-2">
<dt className="system-xs-regular text-text-tertiary">{label}</dt>
<dd className="min-w-0 text-right system-xs-medium wrap-break-word text-text-secondary">
{value || t(($) => $['details.missing'])}
</dd>
</div>
)
}
export function ContactDetailsPanel({
contactId,
onClose,
}: {
contactId: string
onClose: () => void
}) {
const { t } = useTranslation('contacts')
const contactQuery = useContactDetails(contactId)
const contact = contactQuery.data
return (
<aside
aria-label={t(($) => $['details.title'])}
className="absolute inset-y-0 right-0 z-20 flex w-80 max-w-full flex-col border-l border-divider-subtle bg-components-panel-bg shadow-xl"
>
<div className="flex h-12 shrink-0 items-center justify-end px-4">
<Button
variant="ghost"
aria-label={t(($) => $['action.close'])}
className="px-2"
onClick={onClose}
>
<span aria-hidden className="i-ri-close-line size-4" />
</Button>
</div>
{contactQuery.isPending && (
<div role="status" aria-label={t(($) => $['details.loading'])} className="space-y-4 px-5">
<div className="mx-auto size-16 animate-pulse rounded-full bg-background-default-subtle" />
<div className="mx-auto h-4 w-36 animate-pulse rounded bg-background-default-subtle" />
<div className="h-40 animate-pulse rounded-xl bg-background-default-subtle" />
</div>
)}
{contactQuery.isError && (
<div
role="alert"
className="flex flex-1 flex-col items-center justify-center gap-3 px-5 text-center"
>
<p className="system-sm-regular text-text-secondary">{t(($) => $['details.error'])}</p>
<Button size="small" onClick={() => contactQuery.refetch()}>
{t(($) => $['action.retry'])}
</Button>
</div>
)}
{!contactQuery.isPending && !contactQuery.isError && !contact && (
<div
role="status"
className="flex flex-1 items-center justify-center px-5 text-center system-sm-regular text-text-tertiary"
>
{t(($) => $['details.notFound'])}
</div>
)}
{contact && (
<div className="min-h-0 flex-1 overflow-y-auto">
<div className="bg-gradient-to-b from-primary-50 to-components-panel-bg px-5 pt-4 pb-6 text-center">
<Avatar
avatar={contact.avatarUrl}
className="mx-auto ring-4 ring-components-panel-bg"
name={contact.displayName}
size="3xl"
/>
<h2 className="mt-3 title-xl-semi-bold text-text-primary">{contact.displayName}</h2>
<p className="mt-1 system-sm-regular text-text-tertiary">{contact.email}</p>
<span className="mt-2 inline-flex rounded-md bg-background-default-subtle px-2 py-1 system-xs-medium text-text-secondary">
{t(($) => $[`type.${contact.kind}`])}
</span>
</div>
<div className="px-5 py-4">
<h3 className="system-sm-semibold text-text-secondary">
{t(($) => $['details.profile'])}
</h3>
<dl className="mt-2 divide-y divide-divider-subtle">
<TypeSpecificDetails contact={contact} />
</dl>
<h3 className="mt-6 system-sm-semibold text-text-secondary">
{t(($) => $['details.channels'])}
</h3>
<div className="mt-2 space-y-2">
<div className="flex items-center gap-2 rounded-lg border border-divider-subtle p-3">
<span aria-hidden className="i-ri-mail-line size-4 text-text-tertiary" />
<span className="min-w-0 truncate system-sm-regular text-text-secondary">
{contact.channels.email}
</span>
</div>
{contact.channels.imIdentities.map((identity) => (
<div
key={`${identity.provider}-${identity.identity}`}
className="flex items-center gap-2 rounded-lg border border-divider-subtle p-3"
>
<span aria-hidden className="i-ri-chat-3-line size-4 text-text-tertiary" />
<span className="system-xs-medium text-text-secondary">{identity.provider}</span>
<span className="min-w-0 truncate system-sm-regular text-text-tertiary">
{identity.identity}
</span>
</div>
))}
</div>
<dl className="mt-6 border-t border-divider-subtle pt-2">
<DetailRow
label={t(($) => $['details.joined'])}
value={new Date(contact.joinedAt).toLocaleDateString()}
/>
</dl>
</div>
</div>
)}
</aside>
)
}
@@ -0,0 +1,401 @@
'use client'
import type { ReactNode } from 'react'
import type { ContactKindFilter, ContactView } from './types'
import { Avatar } from '@langgenius/dify-ui/avatar'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { Input } from '@langgenius/dify-ui/input'
import {
parseAsInteger,
parseAsString,
parseAsStringLiteral,
useQueryState,
useQueryStates,
} from 'nuqs'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContactsFeatureContext } from './composition-context'
import { ContactDetailsPanel } from './contact-details-panel'
import { ExternalContactDialog } from './external-contact-dialog'
import { useContactsDirectory } from './hooks'
import { OrganizationPickerDialog } from './organization-picker-dialog'
const contactKindFilters = ['all', 'workspace', 'platform', 'external'] as const
const searchParser = parseAsString.withDefault('')
const kindParser = parseAsStringLiteral(contactKindFilters).withDefault('all')
const contactIdParser = parseAsString
const loadedPagesParser = parseAsInteger.withDefault(1)
function ContactTypeBadge({ kind }: { kind: ContactView['kind'] }) {
const { t } = useTranslation('contacts')
return (
<span
className={cn(
'inline-flex rounded-md px-2 py-1 system-xs-medium',
kind === 'workspace' && 'bg-state-accent-hover text-text-accent',
kind === 'platform' && 'bg-state-success-hover text-text-success',
kind === 'external' && 'bg-background-default-subtle text-text-tertiary',
)}
>
{t(($) => $[`type.${kind}`])}
</span>
)
}
function ContactRow({
contact,
onOpen,
registerTrigger,
}: {
contact: ContactView
onOpen: () => void
registerTrigger: (element: HTMLButtonElement | null) => void
}) {
const { t } = useTranslation('contacts')
return (
<tr className="border-b border-divider-subtle hover:bg-state-base-hover">
<td className="p-0">
<button
ref={registerTrigger}
type="button"
aria-label={t(($) => $['directory.openDetails'], { name: contact.displayName })}
className="flex w-full min-w-64 items-center gap-3 px-4 py-3 text-left focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden focus-visible:ring-inset"
onClick={onOpen}
>
<Avatar avatar={contact.avatarUrl} name={contact.displayName} size="md" />
<span className="min-w-0">
<span className="block truncate system-sm-medium text-text-secondary">
{contact.displayName}
</span>
<span className="block truncate system-xs-regular text-text-tertiary">
{contact.email}
</span>
</span>
</button>
</td>
<td className="px-4 py-3">
<ContactTypeBadge kind={contact.kind} />
</td>
<td className="px-4 py-3">
<span className="flex items-center gap-2 text-text-tertiary">
<span
aria-label={t(($) => $['directory.channel.email'])}
className="i-ri-mail-line size-4"
/>
{contact.channels.imIdentities.map((identity) => (
<span
key={`${identity.provider}-${identity.identity}`}
className="system-xs-medium text-text-secondary"
>
{identity.provider}
</span>
))}
</span>
</td>
<td className="px-4 py-3 system-xs-regular whitespace-nowrap text-text-tertiary">
{new Date(contact.joinedAt).toLocaleDateString()}
</td>
</tr>
)
}
function DirectoryState({
action,
description,
icon,
title,
}: {
action?: ReactNode
description: string
icon: string
title: string
}) {
return (
<div className="flex min-h-80 flex-col items-center justify-center px-6 text-center">
<span aria-hidden className={cn(icon, 'size-8 text-text-quaternary')} />
<h2 className="mt-3 system-md-semibold text-text-secondary">{title}</h2>
<p className="mt-1 max-w-md system-sm-regular text-text-tertiary">{description}</p>
{action && <div className="mt-4">{action}</div>}
</div>
)
}
export function ContactsDirectoryPage() {
const { t } = useTranslation('contacts')
const context = useContactsFeatureContext()
const [browsing, setBrowsing] = useQueryStates({
contact_kind: kindParser,
contact_pages: loadedPagesParser,
contact_search: searchParser,
})
const search = browsing.contact_search
const kind = browsing.contact_kind
const loadedPages = browsing.contact_pages
const [contactId, setContactId] = useQueryState('contact_id', contactIdParser)
const [externalDialogOpen, setExternalDialogOpen] = useState(false)
const [organizationDialogOpen, setOrganizationDialogOpen] = useState(false)
const rowTriggersRef = useRef(new Map<string, HTMLButtonElement>())
const selectedContactIdRef = useRef<string | null>(null)
const directoryQuery = useContactsDirectory({ kind, pageSize: 20, search })
const fetchNextPage = directoryQuery.fetchNextPage
const filters = contactKindFilters.filter(
(filter) => filter !== 'platform' || context.deployment === 'ee',
)
const hasFilters = Boolean(search) || kind !== 'all'
const currentPageCount = directoryQuery.data?.pages.length ?? 0
useEffect(() => {
if (
currentPageCount > 0 &&
currentPageCount < loadedPages &&
directoryQuery.hasNextPage &&
!directoryQuery.isFetchingNextPage &&
!directoryQuery.isFetchNextPageError
) {
void fetchNextPage()
}
}, [
currentPageCount,
directoryQuery.hasNextPage,
directoryQuery.isFetchNextPageError,
directoryQuery.isFetchingNextPage,
fetchNextPage,
loadedPages,
])
function updateSearch(value: string) {
void setBrowsing({ contact_pages: null, contact_search: value || null })
}
function updateKind(value: ContactKindFilter) {
void setBrowsing({
contact_kind: value === 'all' ? null : value,
contact_pages: null,
})
}
function openDetails(id: string) {
selectedContactIdRef.current = id
void setContactId(id)
}
function closeDetails() {
const trigger = selectedContactIdRef.current
? rowTriggersRef.current.get(selectedContactIdRef.current)
: null
void setContactId(null).then(() => trigger?.focus())
}
function clearFilters() {
void setBrowsing({ contact_kind: null, contact_pages: null, contact_search: null })
}
async function loadMore() {
const nextResult = await fetchNextPage()
if (!nextResult.isFetchNextPageError)
void setBrowsing({ contact_pages: nextResult.data?.pages.length ?? loadedPages })
}
if (!context.permissions.canViewContacts) {
return (
<DirectoryState
description={t(($) => $['directory.noAccessDescription'])}
icon="i-ri-lock-2-line"
title={t(($) => $['directory.noAccessTitle'])}
/>
)
}
return (
<main className="relative flex h-full min-w-0 flex-1 flex-col overflow-hidden bg-background-body">
<header className="shrink-0 px-4 pt-5 pb-3 sm:px-8">
<div className="flex items-center gap-2">
<h1 className="title-xl-semi-bold text-text-primary">{t(($) => $['directory.title'])}</h1>
<a
href="#contacts-help"
className="system-xs-medium text-text-accent hover:underline focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
>
{t(($) => $['directory.learnMore'])}
</a>
</div>
<div className="mt-4 flex min-w-0 flex-col gap-3 lg:flex-row lg:items-center">
<div
role="group"
aria-label={t(($) => $['directory.filters'])}
className="flex max-w-full gap-1 overflow-x-auto rounded-lg bg-background-default-subtle p-1"
>
{filters.map((filter) => (
<button
key={filter}
type="button"
aria-pressed={kind === filter}
className={cn(
'h-7 rounded-md px-3 system-xs-medium whitespace-nowrap text-text-tertiary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden',
kind === filter && 'bg-components-panel-bg text-text-secondary shadow-xs',
)}
onClick={() => updateKind(filter)}
>
{t(($) => $[`filter.${filter}`])}
</button>
))}
</div>
<div className="relative min-w-0 flex-1 lg:max-w-60">
<span
aria-hidden
className="absolute top-1/2 left-3 i-ri-search-line size-4 -translate-y-1/2 text-text-tertiary"
/>
<Input
aria-label={t(($) => $['directory.search'])}
className="w-full pl-9"
placeholder={t(($) => $['directory.search'])}
value={search}
onChange={(event) => updateSearch(event.target.value)}
/>
</div>
{context.permissions.canManageContacts && (
<div className="flex shrink-0 lg:ml-auto">
{context.deployment === 'ee' ? (
<DropdownMenu>
<DropdownMenuTrigger render={<Button variant="primary" />}>
<span aria-hidden className="mr-1 i-ri-add-line size-4" />
{t(($) => $['directory.addContact'])}
<span aria-hidden className="ml-1 i-ri-arrow-down-s-line size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent popupClassName="min-w-48">
<DropdownMenuItem onClick={() => setOrganizationDialogOpen(true)}>
{t(($) => $['directory.addFromPlatform'])}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setExternalDialogOpen(true)}>
{t(($) => $['directory.addExternal'])}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<Button variant="primary" onClick={() => setExternalDialogOpen(true)}>
<span aria-hidden className="mr-1 i-ri-add-line size-4" />
{t(($) => $['directory.addExternal'])}
</Button>
)}
</div>
)}
</div>
{!context.permissions.canManageContacts && (
<p className="mt-3 system-xs-regular text-text-tertiary">
{t(($) => $['directory.viewOnly'])}
</p>
)}
</header>
<div className="min-h-0 flex-1 overflow-auto px-4 pb-6 sm:px-8">
{directoryQuery.isPending && (
<div
role="status"
aria-label={t(($) => $['directory.loading'])}
className="space-y-2 rounded-xl border border-divider-subtle p-3"
>
{[0, 1, 2, 3, 4].map((key) => (
<div
key={key}
className="h-12 animate-pulse rounded-lg bg-background-default-subtle"
/>
))}
</div>
)}
{directoryQuery.isError && !directoryQuery.contacts.length && (
<DirectoryState
action={
<Button onClick={() => directoryQuery.refetch()}>
{t(($) => $['action.retry'])}
</Button>
}
description={t(($) => $['directory.errorDescription'])}
icon="i-ri-error-warning-line"
title={t(($) => $['directory.errorTitle'])}
/>
)}
{!directoryQuery.isPending &&
!directoryQuery.isError &&
!directoryQuery.contacts.length && (
<DirectoryState
action={
hasFilters ? (
<Button onClick={clearFilters}>{t(($) => $['action.clearFilters'])}</Button>
) : undefined
}
description={t(
($) =>
$[hasFilters ? 'directory.noResultsDescription' : 'directory.emptyDescription'],
)}
icon={hasFilters ? 'i-ri-search-line' : 'i-ri-contacts-book-2-line'}
title={t(($) => $[hasFilters ? 'directory.noResultsTitle' : 'directory.emptyTitle'])}
/>
)}
{directoryQuery.contacts.length > 0 && (
<div className="overflow-hidden rounded-xl border border-divider-subtle bg-components-panel-bg">
<table className="w-full min-w-180 border-collapse">
<thead className="bg-background-default-subtle text-left system-xs-medium text-text-tertiary">
<tr>
<th scope="col" className="px-4 py-2">
{t(($) => $['directory.column.name'])}
</th>
<th scope="col" className="px-4 py-2">
{t(($) => $['directory.column.type'])}
</th>
<th scope="col" className="px-4 py-2">
{t(($) => $['directory.column.channels'])}
</th>
<th scope="col" className="px-4 py-2">
{t(($) => $['directory.column.joined'])}
</th>
</tr>
</thead>
<tbody>
{directoryQuery.contacts.map((contact) => (
<ContactRow
key={contact.id}
contact={contact}
registerTrigger={(element) => {
if (element) rowTriggersRef.current.set(contact.id, element)
else rowTriggersRef.current.delete(contact.id)
}}
onOpen={() => openDetails(contact.id)}
/>
))}
</tbody>
</table>
{directoryQuery.hasNextPage && (
<div className="flex flex-col items-center border-t border-divider-subtle p-3">
{directoryQuery.isFetchNextPageError && (
<p role="alert" className="mb-2 system-xs-regular text-text-destructive">
{t(($) => $['directory.pageError'])}
</p>
)}
<Button loading={directoryQuery.isFetchingNextPage} onClick={loadMore}>
{t(($) => $['action.loadMore'])}
</Button>
</div>
)}
</div>
)}
</div>
{contactId && <ContactDetailsPanel contactId={contactId} onClose={closeDetails} />}
<ExternalContactDialog
open={externalDialogOpen}
onOpenChange={setExternalDialogOpen}
onCreated={() => {}}
/>
{context.deployment === 'ee' && (
<OrganizationPickerDialog
open={organizationDialogOpen}
onOpenChange={setOrganizationDialogOpen}
/>
)}
</main>
)
}
@@ -0,0 +1,233 @@
'use client'
import { Avatar } from '@langgenius/dify-ui/avatar'
import { Button } from '@langgenius/dify-ui/button'
import {
Dialog,
DialogCloseButton,
DialogContent,
DialogDescription,
DialogTitle,
} from '@langgenius/dify-ui/dialog'
import { Field, FieldControl, FieldError, FieldLabel } from '@langgenius/dify-ui/field'
import { Form } from '@langgenius/dify-ui/form'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContactsFeatureContext } from './composition-context'
import { useCreateExternalContact } from './hooks'
type ExternalContactDraft = {
displayName: string
email: string
}
type ExternalContactDialogProps = {
onCreated: (contactId: string) => void
onOpenChange: (open: boolean) => void
open: boolean
}
const emptyDraft: ExternalContactDraft = { displayName: '', email: '' }
function isValidEmail(value: string) {
const parts = value.split('@')
if (parts.length !== 2) return false
const [local, domain] = parts
return Boolean(
local &&
domain &&
!value.includes(' ') &&
domain.includes('.') &&
!domain.startsWith('.') &&
!domain.endsWith('.'),
)
}
export function ExternalContactDialog({
onCreated,
onOpenChange,
open,
}: ExternalContactDialogProps) {
const { t } = useTranslation('contacts')
const context = useContactsFeatureContext()
const createExternalContact = useCreateExternalContact()
const [draft, setDraft] = useState<ExternalContactDraft>(emptyDraft)
const [fieldError, setFieldError] = useState<
'name_required' | 'email_required' | 'email_invalid' | null
>(null)
const [resultError, setResultError] = useState<string | null>(null)
const resetMutation = createExternalContact.reset
function resetDialog() {
setDraft(emptyDraft)
setFieldError(null)
setResultError(null)
resetMutation()
}
function closeDialog() {
if (createExternalContact.isPending) return
onOpenChange(false)
resetDialog()
}
function updateDraft(field: keyof ExternalContactDraft, value: string) {
setDraft((current) => ({ ...current, [field]: value }))
setFieldError(null)
setResultError(null)
}
async function handleSubmit() {
if (!draft.displayName.trim()) {
setFieldError('name_required')
return
}
if (!draft.email.trim()) {
setFieldError('email_required')
return
}
if (!isValidEmail(draft.email.trim())) {
setFieldError('email_invalid')
return
}
const result = await createExternalContact.mutateAsync({
displayName: draft.displayName.trim(),
email: draft.email.trim(),
workspaceId: context.workspaceId,
})
if (result.kind === 'created') {
onOpenChange(false)
onCreated(result.contactId)
resetDialog()
return
}
setResultError(result.kind)
}
const fieldErrorMessage = fieldError ? t(($) => $[`external.validation.${fieldError}`]) : null
const resultErrorMessage = (() => {
switch (resultError) {
case 'duplicate_external_contact':
return t(($) => $['external.result.duplicate_external_contact'])
case 'matches_workspace_contact':
return t(($) => $['external.result.matches_workspace_contact'])
case 'matches_platform_contact':
return t(($) => $['external.result.matches_platform_contact'])
case 'failed':
return t(($) => $['external.result.failed'])
default:
return null
}
})()
return (
<Dialog
open={open}
onOpenChange={(nextOpen) => !nextOpen && closeDialog()}
disablePointerDismissal={createExternalContact.isPending}
>
<DialogContent className="w-120 max-w-[calc(100vw-2rem)] p-0!">
<DialogCloseButton
aria-label={t(($) => $['action.close'])}
disabled={createExternalContact.isPending}
/>
<div className="px-6 pt-6 pb-4">
<DialogTitle className="title-2xl-semi-bold text-text-primary">
{t(($) => $['external.title'])}
</DialogTitle>
<DialogDescription className="mt-1 system-sm-regular text-text-tertiary">
{t(($) => $['external.description'])}
</DialogDescription>
</div>
<Form<ExternalContactDraft> onFormSubmit={handleSubmit} className="px-6 pb-6">
<div className="mb-5 flex justify-center rounded-xl bg-gradient-to-br from-background-default-subtle to-background-section-burn py-5">
<Avatar
avatar={null}
className="ring-4 ring-components-panel-bg"
name={draft.displayName || t(($) => $['external.avatarFallback'])}
size="3xl"
/>
</div>
<div className="space-y-4">
<Field name="displayName" invalid={fieldError === 'name_required'}>
<FieldLabel>{t(($) => $['external.name'])}</FieldLabel>
<FieldControl
aria-describedby={
fieldError === 'name_required' ? 'external-name-error' : undefined
}
autoComplete="name"
disabled={createExternalContact.isPending}
required
value={draft.displayName}
onValueChange={(value) => updateDraft('displayName', value)}
/>
{fieldError === 'name_required' && (
<p
id="external-name-error"
role="alert"
className="body-xs-regular text-text-destructive"
>
{fieldErrorMessage}
</p>
)}
<FieldError match="valueMissing">
{t(($) => $['external.validation.name_required'])}
</FieldError>
</Field>
<Field
name="email"
invalid={fieldError === 'email_required' || fieldError === 'email_invalid'}
>
<FieldLabel>{t(($) => $['external.email'])}</FieldLabel>
<FieldControl
aria-describedby={
fieldError?.startsWith('email') ? 'external-email-error' : undefined
}
autoComplete="email"
disabled={createExternalContact.isPending}
required
type="email"
value={draft.email}
onValueChange={(value) => updateDraft('email', value)}
/>
{fieldError?.startsWith('email') && (
<p
id="external-email-error"
role="alert"
className="body-xs-regular text-text-destructive"
>
{fieldErrorMessage}
</p>
)}
<FieldError match="valueMissing">
{t(($) => $['external.validation.email_required'])}
</FieldError>
<FieldError match="typeMismatch">
{t(($) => $['external.validation.email_invalid'])}
</FieldError>
</Field>
</div>
{resultErrorMessage && (
<div
role="alert"
className="mt-4 rounded-lg bg-state-destructive-hover p-3 system-sm-regular text-text-destructive"
>
{resultErrorMessage}
</div>
)}
<div className="mt-6 flex justify-end gap-2">
<Button disabled={createExternalContact.isPending} onClick={closeDialog}>
{t(($) => $['action.cancel'])}
</Button>
<Button type="submit" variant="primary" loading={createExternalContact.isPending}>
{createExternalContact.isPending
? t(($) => $['external.adding'])
: t(($) => $['external.add'])}
</Button>
</div>
</Form>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,3 @@
import { ENABLE_FEATURE_PREVIEW } from '@/config'
export const isContactsManagementEnabled = () => ENABLE_FEATURE_PREVIEW
+165
View File
@@ -0,0 +1,165 @@
'use client'
import type {
ContactsListQuery,
CreateExternalContactCommand,
OrganizationCandidateQuery,
RemoveMemberCommand,
} from './types'
import {
infiniteQueryOptions,
mutationOptions,
queryOptions,
useInfiniteQuery,
useMutation,
useQuery,
useQueryClient,
} from '@tanstack/react-query'
import { useContactsFeatureContext, useContactsManagementRepository } from './composition-context'
import { mergeContactPages } from './mock/repository'
import { contactsManagementQueryKeys } from './query-keys'
export function useContactsDirectory(query: Omit<ContactsListQuery, 'cursor' | 'deployment'>) {
const context = useContactsFeatureContext()
const repository = useContactsManagementRepository()
const result = useInfiniteQuery(
infiniteQueryOptions({
initialPageParam: null as string | null,
queryFn: ({ pageParam }: { pageParam: string | null }) =>
repository.listContacts({ ...query, cursor: pageParam, deployment: context.deployment }),
queryKey: [...contactsManagementQueryKeys.directory(context, query), repository] as const,
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
}),
)
return {
contacts: mergeContactPages(result.data?.pages.map((page) => page.items) ?? []),
data: result.data,
error: result.error,
fetchNextPage: result.fetchNextPage,
hasNextPage: result.hasNextPage,
isError: result.isError,
isFetchNextPageError: result.isFetchNextPageError,
isFetchingNextPage: result.isFetchingNextPage,
isPending: result.isPending,
refetch: result.refetch,
}
}
export function useContactDetails(contactId: string | null) {
const context = useContactsFeatureContext()
const repository = useContactsManagementRepository()
return useQuery(
queryOptions({
enabled: Boolean(contactId),
queryFn: () => repository.getContact(contactId as string),
queryKey: [
...contactsManagementQueryKeys.detail(context.workspaceId, contactId ?? 'none'),
repository,
] as const,
}),
)
}
export function useOrganizationCandidates(
query: Omit<OrganizationCandidateQuery, 'cursor'>,
enabled: boolean,
) {
const context = useContactsFeatureContext()
const repository = useContactsManagementRepository()
const result = useInfiniteQuery(
infiniteQueryOptions({
enabled: enabled && context.deployment === 'ee',
initialPageParam: null as string | null,
queryFn: ({ pageParam }: { pageParam: string | null }) =>
repository.searchOrganizationCandidates({ ...query, cursor: pageParam }),
queryKey: [
...contactsManagementQueryKeys.organizationCandidates(context.workspaceId, query),
repository,
] as const,
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
}),
)
return {
candidates: result.data?.pages.flatMap((page) => page.items) ?? [],
error: result.error,
fetchNextPage: result.fetchNextPage,
hasNextPage: result.hasNextPage,
isError: result.isError,
isFetchingNextPage: result.isFetchingNextPage,
isPending: result.isPending,
refetch: result.refetch,
}
}
export function useCreateExternalContact() {
const context = useContactsFeatureContext()
const repository = useContactsManagementRepository()
const queryClient = useQueryClient()
return useMutation(
mutationOptions({
mutationFn: (command: CreateExternalContactCommand) =>
repository.createExternalContact(command),
onSuccess: (result) => {
if (result.kind !== 'created') return
void queryClient.invalidateQueries({
queryKey: contactsManagementQueryKeys.all(context.workspaceId),
})
},
}),
)
}
export function useAddPlatformContacts() {
const context = useContactsFeatureContext()
const repository = useContactsManagementRepository()
const queryClient = useQueryClient()
return useMutation(
mutationOptions({
mutationFn: (candidateIds: string[]) => repository.addPlatformContacts({ candidateIds }),
onSuccess: (result) => {
if (result.kind !== 'added') return
void queryClient.invalidateQueries({
queryKey: contactsManagementQueryKeys.all(context.workspaceId),
})
},
}),
)
}
export function useMemberRemovalImpact(memberId: string, enabled: boolean) {
const context = useContactsFeatureContext()
const repository = useContactsManagementRepository()
return useQuery(
queryOptions({
enabled,
queryFn: () => repository.getMemberRemovalImpact(memberId),
queryKey: [
...contactsManagementQueryKeys.all(context.workspaceId),
'member-removal-impact',
memberId,
repository,
] as const,
}),
)
}
export function useRemoveContactMember() {
const context = useContactsFeatureContext()
const repository = useContactsManagementRepository()
const queryClient = useQueryClient()
return useMutation(
mutationOptions({
mutationFn: (command: RemoveMemberCommand) => repository.removeMember(command),
onSuccess: (result) => {
if (result.kind !== 'removed') return
void queryClient.invalidateQueries({
queryKey: contactsManagementQueryKeys.all(context.workspaceId),
})
},
}),
)
}
@@ -0,0 +1,144 @@
'use client'
import type { Member } from '@/models/common'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogTitle,
} from '@langgenius/dify-ui/alert-dialog'
import { Avatar } from '@langgenius/dify-ui/avatar'
import { Checkbox } from '@langgenius/dify-ui/checkbox'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContactsFeatureContext } from './composition-context'
import { useRemoveContactMember } from './hooks'
export function MemberRemovalContactImpactDialog({
member,
onOpenChange,
onRemoved,
open,
}: {
member: Member
onOpenChange: (open: boolean) => void
onRemoved: () => void
open: boolean
}) {
const { t } = useTranslation('contacts')
const context = useContactsFeatureContext()
const removal = useRemoveContactMember()
const [keepAsPlatformContact, setKeepAsPlatformContact] = useState(true)
const [failed, setFailed] = useState(false)
const resetMutation = removal.reset
const isEnterprise = context.deployment === 'ee'
function resetDialog() {
setKeepAsPlatformContact(true)
setFailed(false)
resetMutation()
}
function handleOpenChange(nextOpen: boolean) {
if (!nextOpen && removal.isPending) return
onOpenChange(nextOpen)
if (!nextOpen) resetDialog()
}
async function handleRemove() {
if (removal.isPending) return
setFailed(false)
const result = await removal.mutateAsync({
keepAsPlatformContact: isEnterprise && keepAsPlatformContact,
memberId: member.id,
})
if (result.kind === 'failed') {
setFailed(true)
return
}
onOpenChange(false)
onRemoved()
resetDialog()
}
return (
<AlertDialog open={open} onOpenChange={handleOpenChange}>
<AlertDialogContent backdropProps={{ forceRender: true }}>
<div className="flex flex-col gap-4 px-6 pt-6 pb-4">
<div>
<AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary">
{t(($) => $['memberRemoval.title'], { memberName: member.name || member.email })}
</AlertDialogTitle>
<AlertDialogDescription className="mt-2 w-full system-sm-regular wrap-break-word text-text-tertiary">
{t(
($) =>
$[
isEnterprise
? 'memberRemoval.eeDescription'
: 'memberRemoval.standardDescription'
],
)}
</AlertDialogDescription>
</div>
<div className="flex items-center gap-3 rounded-xl border border-divider-subtle bg-background-default-subtle p-3">
<Avatar
avatar={member.avatar_url || null}
name={member.name || member.email}
size="lg"
/>
<span className="min-w-0">
<span className="block truncate system-sm-medium text-text-secondary">
{member.name || member.email}
</span>
<span className="block truncate system-xs-regular text-text-tertiary">
{member.email}
</span>
</span>
</div>
{isEnterprise && (
<div className="flex items-start gap-2 rounded-lg p-1 focus-within:ring-2 focus-within:ring-state-accent-solid">
<Checkbox
aria-labelledby="member-removal-keep-platform-label"
checked={keepAsPlatformContact}
disabled={removal.isPending}
onCheckedChange={setKeepAsPlatformContact}
/>
<span>
<span
id="member-removal-keep-platform-label"
className="block system-sm-medium text-text-secondary"
>
{t(($) => $['memberRemoval.keepPlatform'])}
</span>
<span className="block system-xs-regular text-text-tertiary">
{t(($) => $['memberRemoval.keepPlatformDescription'])}
</span>
</span>
</div>
)}
{failed && (
<p
role="alert"
className="rounded-lg bg-state-destructive-hover p-3 system-sm-regular text-text-destructive"
>
{t(($) => $['memberRemoval.failed'])}
</p>
)}
</div>
<AlertDialogActions>
<AlertDialogCancelButton disabled={removal.isPending}>
{t(($) => $['action.cancel'])}
</AlertDialogCancelButton>
<AlertDialogConfirmButton disabled={removal.isPending} onClick={handleRemove}>
{removal.isPending
? t(($) => $['memberRemoval.removing'])
: t(($) => $['memberRemoval.remove'])}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
)
}
@@ -0,0 +1,205 @@
import type { ContactsManagementRepository } from '../repository'
import type { ContactPage, ContactView, PlatformContactView } from '../types'
import type { ContactsMockScenarioDefinition } from './scenarios'
type CreateContactsMockRepositoryOptions = {
scenario: ContactsMockScenarioDefinition
wait?: () => Promise<void>
}
const normalizeEmail = (email: string) => email.trim().toLocaleLowerCase()
const matchesSearch = (search: string, ...values: string[]) => {
const normalizedSearch = search.trim().toLocaleLowerCase()
return (
!normalizedSearch ||
values.some((value) => value.toLocaleLowerCase().includes(normalizedSearch))
)
}
function paginate<T>(items: T[], cursor: string | null, pageSize: number): ContactPage<T> {
const offset = cursor ? Number.parseInt(cursor, 10) : 0
const safeOffset = Number.isFinite(offset) ? offset : 0
const pageItems = items.slice(safeOffset, safeOffset + pageSize)
const nextOffset = safeOffset + pageItems.length
return {
items: structuredClone(pageItems),
nextCursor: nextOffset < items.length ? String(nextOffset) : null,
}
}
export function mergeContactPages(pages: ContactView[][]): ContactView[] {
const contacts = new Map<string, ContactView>()
for (const page of pages) {
for (const contact of page) contacts.set(contact.id, contact)
}
return [...contacts.values()]
}
export function createContactsMockRepository({
scenario,
wait = async () => {},
}: CreateContactsMockRepositoryOptions): ContactsManagementRepository {
let contacts = structuredClone(scenario.contacts)
const organizationCandidates = structuredClone(scenario.organizationCandidates)
let createdExternalCount = 0
const getExistingContactIds = () => new Set(contacts.map((contact) => contact.id))
const getExistingEmails = () => new Set(contacts.map((contact) => normalizeEmail(contact.email)))
return {
async listContacts(query) {
await wait()
if (scenario.failures.directory) throw new Error('contacts_directory_failed')
if (query.cursor && scenario.failures.nextPage) throw new Error('contacts_next_page_failed')
const filtered = contacts.filter((contact) => {
const matchesKind = query.kind === 'all' || contact.kind === query.kind
return matchesKind && matchesSearch(query.search, contact.displayName, contact.email)
})
return paginate(filtered, query.cursor, query.pageSize)
},
async getContact(contactId) {
await wait()
if (scenario.failures.detail) throw new Error('contact_detail_failed')
return structuredClone(contacts.find((contact) => contact.id === contactId) ?? null)
},
async createExternalContact(command) {
await wait()
if (scenario.failures.createExternal) return { kind: 'failed' }
const email = normalizeEmail(command.email)
const contact = contacts.find((item) => normalizeEmail(item.email) === email)
if (contact?.kind === 'external') {
return { contactId: contact.id, kind: 'duplicate_external_contact' }
}
if (contact?.kind === 'workspace') {
return { contactId: contact.id, kind: 'matches_workspace_contact' }
}
if (contact?.kind === 'platform') {
return { contactId: contact.id, kind: 'matches_platform_contact' }
}
const organizationIdentity = organizationCandidates.find(
(candidate) => normalizeEmail(candidate.email) === email,
)
if (organizationIdentity) {
return { contactId: organizationIdentity.id, kind: 'matches_platform_contact' }
}
createdExternalCount += 1
const contactId = `contact-external-created-${createdExternalCount}`
contacts = [
...contacts,
{
avatarUrl: null,
channels: { email, imIdentities: [] },
displayName: command.displayName.trim(),
email,
emailOnly: true,
id: contactId,
joinedAt: '2026-07-17T00:00:00.000Z',
kind: 'external',
workspaceId: command.workspaceId,
},
]
return { contactId, kind: 'created' }
},
async searchOrganizationCandidates(query) {
await wait()
if (scenario.failures.organization) throw new Error('organization_candidates_failed')
const existingContactIds = getExistingContactIds()
const existingEmails = getExistingEmails()
const filtered = organizationCandidates.filter((candidate) => {
const isExisting =
existingContactIds.has(candidate.id) ||
existingEmails.has(normalizeEmail(candidate.email))
return !isExisting && matchesSearch(query.search, candidate.displayName, candidate.email)
})
return paginate(filtered, query.cursor, query.pageSize)
},
async addPlatformContacts(command) {
await wait()
if (scenario.failures.addPlatform) return { kind: 'failed' }
const selectedCandidates = organizationCandidates.filter((candidate) =>
command.candidateIds.includes(candidate.id),
)
const existingEmails = getExistingEmails()
const newContacts: PlatformContactView[] = selectedCandidates
.filter((candidate) => !existingEmails.has(normalizeEmail(candidate.email)))
.map((candidate) => ({
avatarUrl: candidate.avatarUrl,
channels: { email: candidate.email, imIdentities: [] },
displayName: candidate.displayName,
email: candidate.email,
id: `contact-platform-${candidate.id}`,
joinedAt: '2026-07-17T00:00:00.000Z',
kind: 'platform',
organizationIdentity: candidate.organizationIdentity,
sourceWorkspaceSummary: candidate.sourceWorkspaceSummary,
}))
contacts = [...contacts, ...newContacts]
return { contactIds: newContacts.map((contact) => contact.id), kind: 'added' }
},
async getMemberRemovalImpact(memberId) {
await wait()
const contact = contacts.find(
(item) => item.kind === 'workspace' && item.memberId === memberId,
)
return {
contactId: contact?.id ?? null,
deployment: scenario.deployment,
memberId,
}
},
async removeMember(command) {
await wait()
if (scenario.failures.removal) return { kind: 'failed' }
const contact = contacts.find(
(item) => item.kind === 'workspace' && item.memberId === command.memberId,
)
if (!contact) {
return { contactId: null, contactOutcome: 'not_found', kind: 'removed' }
}
if (scenario.deployment === 'ee' && command.keepAsPlatformContact) {
contacts = contacts.map((item) => {
if (item.id !== contact.id) return item
return {
avatarUrl: item.avatarUrl,
channels: item.channels,
displayName: item.displayName,
email: item.email,
id: item.id,
joinedAt: item.joinedAt,
kind: 'platform',
organizationIdentity: `retained-${command.memberId}`,
sourceWorkspaceSummary: 'Former workspace member',
}
})
return {
contactId: contact.id,
contactOutcome: 'converted_to_platform',
kind: 'removed',
}
}
contacts = contacts.filter((item) => item.id !== contact.id)
return { contactId: contact.id, contactOutcome: 'removed', kind: 'removed' }
},
}
}
@@ -0,0 +1,226 @@
import type {
ContactsDeployment,
ContactsPermissions,
ContactView,
ExternalContactView,
OrganizationCandidate,
PlatformContactView,
WorkspaceContactView,
} from '../types'
export const ContactsMockScenario = {
AddPlatformFailure: 'add-platform-failure',
CeMixed: 'ce-mixed',
DetailFailure: 'detail-failure',
DirectoryFailure: 'directory-failure',
EeMixed: 'ee-mixed',
Empty: 'empty',
ExternalFailure: 'external-failure',
NextPageFailure: 'next-page-failure',
NoAccess: 'no-access',
OrganizationFailure: 'organization-failure',
Paginated: 'paginated',
ReadOnly: 'read-only',
RemovalFailure: 'removal-failure',
SaasMixed: 'saas-mixed',
} as const
export type ContactsMockScenario = (typeof ContactsMockScenario)[keyof typeof ContactsMockScenario]
export type ContactsMockFailurePlan = {
addPlatform?: boolean
createExternal?: boolean
detail?: boolean
directory?: boolean
nextPage?: boolean
organization?: boolean
removal?: boolean
}
export type ContactsMockScenarioDefinition = {
contacts: ContactView[]
deployment: ContactsDeployment
failures: ContactsMockFailurePlan
organizationCandidates: OrganizationCandidate[]
permissions: ContactsPermissions
workspaceId: string
}
const workspaceContact: WorkspaceContactView = {
avatarUrl: null,
channels: {
email: 'owner@example.com',
imIdentities: [{ identity: 'owner', provider: 'Slack' }],
},
displayName: 'Ralph Edwards',
email: 'owner@example.com',
id: 'contact-owner',
joinedAt: '2026-01-12T08:00:00.000Z',
kind: 'workspace',
memberId: 'member-owner',
membershipStatus: 'active',
workspaceRoleSummary: 'Admin',
}
const platformContact: PlatformContactView = {
avatarUrl: null,
channels: {
email: 'platform@example.com',
imIdentities: [{ identity: 'platform-contact', provider: 'Feishu' }],
},
displayName: 'Leslie Alexander',
email: 'platform@example.com',
id: 'contact-platform',
joinedAt: '2026-02-04T08:00:00.000Z',
kind: 'platform',
organizationIdentity: 'org-user-platform',
sourceWorkspaceSummary: 'Mobile Dev',
}
const externalContact: ExternalContactView = {
avatarUrl: null,
channels: { email: 'external@example.com', imIdentities: [] },
displayName: 'Courtney Henry',
email: 'external@example.com',
emailOnly: true,
id: 'contact-external',
joinedAt: '2026-03-20T08:00:00.000Z',
kind: 'external',
workspaceId: 'workspace-1',
}
const organizationCandidates: OrganizationCandidate[] = [
{
avatarUrl: null,
displayName: 'Ada Lovelace',
email: 'ada@example.com',
id: 'org-candidate-ada',
organizationIdentity: 'org-user-ada',
sourceWorkspaceSummary: 'Dev Team',
},
{
avatarUrl: null,
displayName: 'Grace Hopper',
email: 'grace@example.com',
id: 'org-candidate-grace',
organizationIdentity: 'org-user-grace',
sourceWorkspaceSummary: 'Platform Team',
},
{
avatarUrl: null,
displayName: 'Ralph Edwards',
email: 'owner@example.com',
id: 'org-candidate-owner',
organizationIdentity: 'org-user-owner',
sourceWorkspaceSummary: 'Current workspace',
},
]
const managerPermissions: ContactsPermissions = {
canManageContacts: true,
canManageMembers: true,
canViewContacts: true,
}
const clone = <T>(value: T): T => structuredClone(value)
function mixedContacts(deployment: ContactsDeployment): ContactView[] {
if (deployment === 'ee') return clone([workspaceContact, platformContact, externalContact])
return clone([workspaceContact, externalContact])
}
function paginatedContacts(): ContactView[] {
const contacts = mixedContacts('ee')
for (let index = 1; index <= 20; index += 1) {
contacts.push({
avatarUrl: null,
channels: { email: `partner-${index}@example.com`, imIdentities: [] },
displayName: `Partner ${index}`,
email: `partner-${index}@example.com`,
emailOnly: true,
id: `contact-partner-${index}`,
joinedAt: '2026-04-01T08:00:00.000Z',
kind: 'external',
workspaceId: 'workspace-1',
})
}
return contacts
}
export function createContactsMockScenario(
scenario: ContactsMockScenario,
): ContactsMockScenarioDefinition {
const base: ContactsMockScenarioDefinition = {
contacts: mixedContacts('ee'),
deployment: 'ee',
failures: {},
organizationCandidates: clone(organizationCandidates),
permissions: clone(managerPermissions),
workspaceId: 'workspace-1',
}
switch (scenario) {
case ContactsMockScenario.CeMixed:
return { ...base, contacts: mixedContacts('ce'), deployment: 'ce' }
case ContactsMockScenario.SaasMixed:
return { ...base, contacts: mixedContacts('saas'), deployment: 'saas' }
case ContactsMockScenario.Empty:
return { ...base, contacts: [] }
case ContactsMockScenario.DirectoryFailure:
return { ...base, failures: { directory: true } }
case ContactsMockScenario.DetailFailure:
return { ...base, failures: { detail: true } }
case ContactsMockScenario.NextPageFailure:
return { ...base, contacts: paginatedContacts(), failures: { nextPage: true } }
case ContactsMockScenario.Paginated:
return { ...base, contacts: paginatedContacts() }
case ContactsMockScenario.ExternalFailure:
return { ...base, failures: { createExternal: true } }
case ContactsMockScenario.OrganizationFailure:
return { ...base, failures: { organization: true } }
case ContactsMockScenario.AddPlatformFailure:
return { ...base, failures: { addPlatform: true } }
case ContactsMockScenario.RemovalFailure:
return { ...base, failures: { removal: true } }
case ContactsMockScenario.ReadOnly:
return {
...base,
permissions: {
canManageContacts: false,
canManageMembers: false,
canViewContacts: true,
},
}
case ContactsMockScenario.NoAccess:
return {
...base,
permissions: {
canManageContacts: false,
canManageMembers: false,
canViewContacts: false,
},
}
case ContactsMockScenario.EeMixed:
return base
}
}
export function createDefaultContactsScenario(deployment: ContactsDeployment, canManage: boolean) {
const scenario = createContactsMockScenario(
deployment === 'ee'
? ContactsMockScenario.EeMixed
: deployment === 'saas'
? ContactsMockScenario.SaasMixed
: ContactsMockScenario.CeMixed,
)
return {
...scenario,
permissions: {
canManageContacts: canManage,
canManageMembers: canManage,
canViewContacts: canManage,
},
}
}
@@ -0,0 +1,195 @@
'use client'
import { Avatar } from '@langgenius/dify-ui/avatar'
import { Button } from '@langgenius/dify-ui/button'
import { Checkbox } from '@langgenius/dify-ui/checkbox'
import {
Dialog,
DialogCloseButton,
DialogContent,
DialogDescription,
DialogTitle,
} from '@langgenius/dify-ui/dialog'
import { Input } from '@langgenius/dify-ui/input'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useAddPlatformContacts, useOrganizationCandidates } from './hooks'
export function OrganizationPickerDialog({
onOpenChange,
open,
}: {
onOpenChange: (open: boolean) => void
open: boolean
}) {
const { t } = useTranslation('contacts')
const [search, setSearch] = useState('')
const [selectedIds, setSelectedIds] = useState<string[]>([])
const [mutationError, setMutationError] = useState(false)
const candidatesQuery = useOrganizationCandidates({ pageSize: 20, search }, open)
const addPlatformContacts = useAddPlatformContacts()
const resetMutation = addPlatformContacts.reset
function resetDialog() {
setSearch('')
setSelectedIds([])
setMutationError(false)
resetMutation()
}
function closeDialog() {
if (addPlatformContacts.isPending) return
onOpenChange(false)
resetDialog()
}
function toggleCandidate(candidateId: string, checked: boolean) {
setMutationError(false)
setSelectedIds((current) =>
checked
? [...new Set([...current, candidateId])]
: current.filter((id) => id !== candidateId),
)
}
async function handleAdd() {
if (!selectedIds.length || addPlatformContacts.isPending) return
const result = await addPlatformContacts.mutateAsync(selectedIds)
if (result.kind === 'added') {
onOpenChange(false)
resetDialog()
return
}
setMutationError(true)
}
return (
<Dialog
open={open}
onOpenChange={(nextOpen) => !nextOpen && closeDialog()}
disablePointerDismissal={addPlatformContacts.isPending}
>
<DialogContent className="flex max-h-[calc(100dvh-2rem)] w-120 flex-col overflow-hidden! p-0!">
<DialogCloseButton
aria-label={t(($) => $['action.close'])}
disabled={addPlatformContacts.isPending}
/>
<div className="shrink-0 px-6 pt-6 pb-4">
<DialogTitle className="title-2xl-semi-bold text-text-primary">
{t(($) => $['organization.title'])}
</DialogTitle>
<DialogDescription className="mt-1 system-sm-regular text-text-tertiary">
{t(($) => $['organization.description'])}
</DialogDescription>
<div className="relative mt-4">
<span
aria-hidden
className="absolute top-1/2 left-3 i-ri-search-line size-4 -translate-y-1/2 text-text-tertiary"
/>
<Input
aria-label={t(($) => $['organization.search'])}
className="pl-9"
disabled={addPlatformContacts.isPending}
placeholder={t(($) => $['organization.search'])}
value={search}
onChange={(event) => setSearch(event.target.value)}
/>
</div>
</div>
<div className="min-h-48 flex-1 overflow-y-auto border-y border-divider-subtle px-3 py-2">
{candidatesQuery.isPending && (
<div
role="status"
className="space-y-2 p-3"
aria-label={t(($) => $['organization.loading'])}
>
{[0, 1, 2].map((key) => (
<div
key={key}
className="h-12 animate-pulse rounded-lg bg-background-default-subtle"
/>
))}
</div>
)}
{candidatesQuery.isError && (
<div
role="alert"
className="flex min-h-40 flex-col items-center justify-center gap-3 text-center"
>
<p className="system-sm-regular text-text-secondary">
{t(($) => $['organization.error'])}
</p>
<Button size="small" onClick={() => candidatesQuery.refetch()}>
{t(($) => $['action.retry'])}
</Button>
</div>
)}
{!candidatesQuery.isPending &&
!candidatesQuery.isError &&
!candidatesQuery.candidates.length && (
<div className="flex min-h-40 items-center justify-center system-sm-regular text-text-tertiary">
{t(($) => $['organization.empty'])}
</div>
)}
{candidatesQuery.candidates.map((candidate) => {
const selected = selectedIds.includes(candidate.id)
return (
<label
key={candidate.id}
className="flex cursor-pointer items-center gap-3 rounded-lg px-3 py-2 focus-within:ring-2 focus-within:ring-state-accent-solid hover:bg-state-base-hover"
>
<Checkbox
aria-label={t(($) => $['organization.selectCandidate'], {
name: candidate.displayName,
})}
checked={selected}
disabled={addPlatformContacts.isPending}
onCheckedChange={(checked) => toggleCandidate(candidate.id, checked)}
/>
<Avatar avatar={candidate.avatarUrl} name={candidate.displayName} size="md" />
<span className="min-w-0 flex-1">
<span className="block truncate system-sm-medium text-text-secondary">
{candidate.displayName}
</span>
<span className="block truncate system-xs-regular text-text-tertiary">
{candidate.email}
</span>
</span>
<span className="system-xs-regular text-text-tertiary">
{candidate.sourceWorkspaceSummary}
</span>
</label>
)
})}
</div>
<div className="shrink-0 px-6 py-4">
{mutationError && (
<p role="alert" className="mb-3 system-sm-regular text-text-destructive">
{t(($) => $['organization.addFailed'])}
</p>
)}
<div className="flex items-center justify-between gap-3">
<span aria-live="polite" className="system-xs-regular text-text-tertiary">
{t(($) => $['organization.selected'], { count: selectedIds.length })}
</span>
<div className="flex gap-2">
<Button disabled={addPlatformContacts.isPending} onClick={closeDialog}>
{t(($) => $['action.cancel'])}
</Button>
<Button
variant="primary"
disabled={!selectedIds.length}
loading={addPlatformContacts.isPending}
onClick={handleAdd}
>
{addPlatformContacts.isPending
? t(($) => $['organization.adding'])
: t(($) => $['organization.add'])}
</Button>
</div>
</div>
</div>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,25 @@
import type {
ContactsFeatureContextValue,
ContactsListQuery,
OrganizationCandidateQuery,
} from './types'
export const contactsManagementQueryKeys = {
all: (workspaceId: string) => ['contacts-management', workspaceId] as const,
detail: (workspaceId: string, contactId: string) =>
[...contactsManagementQueryKeys.all(workspaceId), 'detail', contactId] as const,
directory: (
context: Pick<ContactsFeatureContextValue, 'deployment' | 'workspaceId'>,
query: Omit<ContactsListQuery, 'cursor' | 'deployment'>,
) =>
[
...contactsManagementQueryKeys.all(context.workspaceId),
'directory',
context.deployment,
query,
] as const,
organizationCandidates: (
workspaceId: string,
query: Omit<OrganizationCandidateQuery, 'cursor'>,
) => [...contactsManagementQueryKeys.all(workspaceId), 'organization-candidates', query] as const,
}
@@ -0,0 +1,28 @@
import type {
AddPlatformContactsCommand,
AddPlatformContactsResult,
ContactPage,
ContactsListQuery,
ContactView,
CreateExternalContactCommand,
CreateExternalContactResult,
MemberRemovalImpact,
OrganizationCandidate,
OrganizationCandidateQuery,
RemoveMemberCommand,
RemoveMemberResult,
} from './types'
export type ContactsManagementRepository = {
addPlatformContacts: (command: AddPlatformContactsCommand) => Promise<AddPlatformContactsResult>
createExternalContact: (
command: CreateExternalContactCommand,
) => Promise<CreateExternalContactResult>
getContact: (contactId: string) => Promise<ContactView | null>
getMemberRemovalImpact: (memberId: string) => Promise<MemberRemovalImpact>
listContacts: (query: ContactsListQuery) => Promise<ContactPage<ContactView>>
removeMember: (command: RemoveMemberCommand) => Promise<RemoveMemberResult>
searchOrganizationCandidates: (
query: OrganizationCandidateQuery,
) => Promise<ContactPage<OrganizationCandidate>>
}
+120
View File
@@ -0,0 +1,120 @@
export type ContactsDeployment = 'ce' | 'saas' | 'ee'
export type ContactsPermissions = {
canManageContacts: boolean
canManageMembers: boolean
canViewContacts: boolean
}
export type ContactsFeatureContextValue = {
deployment: ContactsDeployment
permissions: ContactsPermissions
workspaceId: string
}
export type ContactKind = 'workspace' | 'platform' | 'external'
export type ContactKindFilter = 'all' | ContactKind
export type ContactChannelSummary = {
email: string
imIdentities: Array<{
identity: string
provider: string
}>
}
type ContactBase = {
avatarUrl: string | null
channels: ContactChannelSummary
displayName: string
email: string
id: string
joinedAt: string
}
export type WorkspaceContactView = ContactBase & {
kind: 'workspace'
memberId: string
membershipStatus: 'active' | 'pending'
workspaceRoleSummary: string
}
export type PlatformContactView = ContactBase & {
kind: 'platform'
organizationIdentity: string
sourceWorkspaceSummary: string | null
}
export type ExternalContactView = ContactBase & {
emailOnly: true
kind: 'external'
workspaceId: string
}
export type ContactView = WorkspaceContactView | PlatformContactView | ExternalContactView
export type ContactsListQuery = {
cursor: string | null
deployment: ContactsDeployment
kind: ContactKindFilter
pageSize: number
search: string
}
export type ContactPage<T> = {
items: T[]
nextCursor: string | null
}
export type OrganizationCandidate = {
avatarUrl: string | null
displayName: string
email: string
id: string
organizationIdentity: string
sourceWorkspaceSummary: string
}
export type OrganizationCandidateQuery = {
cursor: string | null
pageSize: number
search: string
}
export type CreateExternalContactCommand = {
displayName: string
email: string
workspaceId: string
}
export type CreateExternalContactResult =
| { contactId: string; kind: 'created' }
| { contactId: string; kind: 'duplicate_external_contact' }
| { contactId: string; kind: 'matches_workspace_contact' }
| { contactId: string; kind: 'matches_platform_contact' }
| { kind: 'failed' }
export type AddPlatformContactsCommand = {
candidateIds: string[]
}
export type AddPlatformContactsResult = { contactIds: string[]; kind: 'added' } | { kind: 'failed' }
export type MemberRemovalImpact = {
contactId: string | null
deployment: ContactsDeployment
memberId: string
}
export type RemoveMemberCommand = {
keepAsPlatformContact: boolean
memberId: string
}
export type RemoveMemberResult =
| {
contactId: string | null
contactOutcome: 'removed' | 'converted_to_platform' | 'not_found'
kind: 'removed'
}
| { kind: 'failed' }
+88 -1
View File
@@ -1,4 +1,69 @@
{
"action.cancel": "Cancel",
"action.clearFilters": "Clear filters",
"action.close": "Close",
"action.loadMore": "Load more",
"action.retry": "Retry",
"details.account": "Account",
"details.channels": "Channels",
"details.currentWorkspaceOnly": "Current workspace only",
"details.delivery": "Delivery",
"details.emailOnly": "Email only",
"details.error": "Contact details couldn't be loaded.",
"details.joined": "Joined",
"details.loading": "Loading contact details",
"details.membership": "Membership",
"details.missing": "—",
"details.notDifyAccount": "Not a Dify Account",
"details.notFound": "This contact is no longer in the workspace directory.",
"details.organizationIdentity": "Organization identity",
"details.profile": "Contact details",
"details.sourceWorkspace": "Source workspace",
"details.title": "Contact details",
"details.workspaceRole": "Workspace role",
"details.workspaceScope": "Workspace",
"directory.addContact": "Add contact",
"directory.addExternal": "Add external contact",
"directory.addFromPlatform": "Add from Platform",
"directory.channel.email": "Email",
"directory.column.channels": "Channels",
"directory.column.joined": "Joined",
"directory.column.name": "Name",
"directory.column.type": "Type",
"directory.emptyDescription": "Add an external contact, or invite a member to make them available for contact selection.",
"directory.emptyTitle": "No contacts yet",
"directory.errorDescription": "Try again to read the current workspace directory.",
"directory.errorTitle": "Contacts couldn't be loaded",
"directory.filters": "Contact type filters",
"directory.learnMore": "Learn more about Contacts",
"directory.loading": "Loading contacts",
"directory.noAccessDescription": "Only workspace owners and admins can browse the full Contacts directory.",
"directory.noAccessTitle": "You don't have access to Contacts",
"directory.noResultsDescription": "Try another search or clear the current type filter.",
"directory.noResultsTitle": "No matching contacts",
"directory.openDetails": "Open contact details for {{name}}",
"directory.pageError": "More contacts couldn't be loaded. Retry this page.",
"directory.search": "Search name or email",
"directory.title": "Contacts",
"directory.viewOnly": "You can browse Contacts, but only workspace owners and admins can add contacts.",
"external.add": "Add",
"external.adding": "Adding",
"external.avatarFallback": "External contact",
"external.description": "Create a workspace-only contact that can be reached by email.",
"external.email": "Email",
"external.name": "Name",
"external.result.duplicate_external_contact": "An external contact with this email already exists.",
"external.result.failed": "The contact couldn't be added. Keep these details and try again.",
"external.result.matches_platform_contact": "This email belongs to an Organization identity. Add it as a Platform contact instead.",
"external.result.matches_workspace_contact": "This person is already a workspace contact.",
"external.title": "Add External Contact",
"external.validation.email_invalid": "Enter a valid email address.",
"external.validation.email_required": "Email is required.",
"external.validation.name_required": "Name is required.",
"filter.all": "All",
"filter.external": "External",
"filter.platform": "Platform",
"filter.workspace": "Workspace",
"imPlatform.action.authorize": "Authorize",
"imPlatform.action.authorizing": "Authorizing",
"imPlatform.action.configure": "Configure",
@@ -112,5 +177,27 @@
"imPlatform.sync.status.success": "Success",
"imPlatform.sync.title": "Directory sync",
"imPlatform.sync.unsupported": "This provider doesn't support directory sync.",
"imPlatform.title": "IM Platforms"
"imPlatform.title": "IM Platforms",
"memberRemoval.eeDescription": "Removing this member ends their workspace access. Choose whether to keep the same contact as a Platform contact.",
"memberRemoval.failed": "The member couldn't be removed. Nothing has changed; try again.",
"memberRemoval.keepPlatform": "Keep as Platform contact",
"memberRemoval.keepPlatformDescription": "Keep this identity available in Contacts after workspace access is removed.",
"memberRemoval.remove": "Remove",
"memberRemoval.removing": "Removing",
"memberRemoval.standardDescription": "Removing this member also removes their workspace contact from the current Contacts directory.",
"memberRemoval.title": "Remove {{memberName}} from the workspace?",
"organization.add": "Add contacts",
"organization.addFailed": "The selected contacts couldn't be added. Your selection has been kept.",
"organization.adding": "Adding",
"organization.description": "Choose Organization members who are not already in this workspace Contacts directory.",
"organization.empty": "No available Organization members match this search.",
"organization.error": "Organization members couldn't be loaded.",
"organization.loading": "Loading Organization members",
"organization.search": "Search Organization members",
"organization.selectCandidate": "Select {{name}}",
"organization.selected": "{{count}} selected",
"organization.title": "Add from Platform",
"type.external": "External",
"type.platform": "Platform",
"type.workspace": "Workspace"
}
+88 -1
View File
@@ -1,4 +1,69 @@
{
"action.cancel": "取消",
"action.clearFilters": "清除筛选",
"action.close": "关闭",
"action.loadMore": "加载更多",
"action.retry": "重试",
"details.account": "账号",
"details.channels": "联系方式",
"details.currentWorkspaceOnly": "仅属于当前工作区",
"details.delivery": "触达方式",
"details.emailOnly": "仅 Email",
"details.error": "无法加载联系人详情。",
"details.joined": "加入时间",
"details.loading": "正在加载联系人详情",
"details.membership": "成员状态",
"details.missing": "—",
"details.notDifyAccount": "不属于 Dify Account",
"details.notFound": "该联系人已不在当前工作区通讯录中。",
"details.organizationIdentity": "企业身份",
"details.profile": "联系人详情",
"details.sourceWorkspace": "来源工作区",
"details.title": "联系人详情",
"details.workspaceRole": "工作区角色",
"details.workspaceScope": "工作区",
"directory.addContact": "添加联系人",
"directory.addExternal": "添加外部联系人",
"directory.addFromPlatform": "从企业平台添加",
"directory.channel.email": "Email",
"directory.column.channels": "联系方式",
"directory.column.joined": "加入时间",
"directory.column.name": "名称",
"directory.column.type": "类型",
"directory.emptyDescription": "添加外部联系人,或邀请成员加入以便在联系人选择中使用。",
"directory.emptyTitle": "暂无联系人",
"directory.errorDescription": "请重试以读取当前工作区通讯录。",
"directory.errorTitle": "无法加载联系人",
"directory.filters": "联系人类型筛选",
"directory.learnMore": "了解 Contacts",
"directory.loading": "正在加载联系人",
"directory.noAccessDescription": "仅工作区所有者和管理员可以浏览完整联系人目录。",
"directory.noAccessTitle": "你没有 Contacts 访问权限",
"directory.noResultsDescription": "请尝试其他关键词或清除当前类型筛选。",
"directory.noResultsTitle": "没有匹配的联系人",
"directory.openDetails": "打开 {{name}} 的联系人详情",
"directory.pageError": "无法加载更多联系人,请重试当前页。",
"directory.search": "搜索名称或 Email",
"directory.title": "Contacts",
"directory.viewOnly": "你可以浏览 Contacts,但仅工作区所有者和管理员可以添加联系人。",
"external.add": "添加",
"external.adding": "正在添加",
"external.avatarFallback": "外部联系人",
"external.description": "创建仅属于当前工作区、通过 Email 触达的联系人。",
"external.email": "Email",
"external.name": "名称",
"external.result.duplicate_external_contact": "已存在使用此 Email 的外部联系人。",
"external.result.failed": "无法添加联系人,已保留填写内容,请重试。",
"external.result.matches_platform_contact": "此 Email 属于企业身份,请改为添加 Platform contact。",
"external.result.matches_workspace_contact": "该用户已经是工作区联系人。",
"external.title": "添加外部联系人",
"external.validation.email_invalid": "请输入有效的 Email 地址。",
"external.validation.email_required": "Email 为必填项。",
"external.validation.name_required": "名称为必填项。",
"filter.all": "全部",
"filter.external": "外部",
"filter.platform": "企业平台",
"filter.workspace": "工作区",
"imPlatform.action.authorize": "授权",
"imPlatform.action.authorizing": "正在授权",
"imPlatform.action.configure": "配置",
@@ -112,5 +177,27 @@
"imPlatform.sync.status.success": "成功",
"imPlatform.sync.title": "通讯录同步",
"imPlatform.sync.unsupported": "此 IM 平台不支持通讯录同步。",
"imPlatform.title": "IM 平台"
"imPlatform.title": "IM 平台",
"memberRemoval.eeDescription": "移除后该成员将失去工作区访问权限。请选择是否将同一联系人保留为 Platform contact。",
"memberRemoval.failed": "无法移除成员,当前数据未发生变化,请重试。",
"memberRemoval.keepPlatform": "保留为 Platform contact",
"memberRemoval.keepPlatformDescription": "移除工作区访问权限后,继续在 Contacts 中保留该身份。",
"memberRemoval.remove": "移除",
"memberRemoval.removing": "正在移除",
"memberRemoval.standardDescription": "移除此成员也会从当前 Contacts 目录中移除对应的工作区联系人。",
"memberRemoval.title": "从工作区移除 {{memberName}}",
"organization.add": "添加联系人",
"organization.addFailed": "无法添加所选联系人,当前选择已保留。",
"organization.adding": "正在添加",
"organization.description": "选择尚未加入当前工作区 Contacts 的企业成员。",
"organization.empty": "没有符合搜索条件且可添加的企业成员。",
"organization.error": "无法加载企业成员。",
"organization.loading": "正在加载企业成员",
"organization.search": "搜索企业成员",
"organization.selectCandidate": "选择 {{name}}",
"organization.selected": "已选择 {{count}} 人",
"organization.title": "从企业平台添加",
"type.external": "外部",
"type.platform": "企业平台",
"type.workspace": "工作区"
}
+14 -5
View File
@@ -7,6 +7,9 @@ const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const targetLanguage = 'en-US'
const localizedFileLocales = {
contacts: new Set(['en-US', 'zh-Hans']),
}
const languages = data.languages
.filter((language) => language.supported)
@@ -229,12 +232,16 @@ async function main() {
)
: allLanguagesKeys
const keysCount = languagesKeys.map((keys) => keys.length)
const targetKeysCount = targetKeys.length
const getRequiredTargetKeys = (language) =>
targetKeys.filter((key) => {
const fileName = key.split('.')[0]
const localizedLocales = localizedFileLocales[fileName]
return !localizedLocales || localizedLocales.has(language)
})
const comparison = languagesToProcess.reduce((result, language, index) => {
const languageKeysCount = keysCount[index]
const difference = targetKeysCount - languageKeysCount
const languageKeysCount = languagesKeys[index].length
const difference = getRequiredTargetKeys(language).length - languageKeysCount
result[language] = difference
return result
}, {})
@@ -245,7 +252,9 @@ async function main() {
for (let index = 0; index < languagesToProcess.length; index++) {
const language = languagesToProcess[index]
const languageKeys = languagesKeys[index]
const missingKeys = targetKeys.filter((key) => !languageKeys.includes(key))
const missingKeys = getRequiredTargetKeys(language).filter(
(key) => !languageKeys.includes(key),
)
const extraKeys = languageKeys.filter((key) => !targetKeys.includes(key))
console.log(`Missing keys in ${language}:`, missingKeys)