mirror of
https://github.com/langgenius/dify.git
synced 2026-07-19 16:44:00 -04:00
feat(contacts): add mock-backed contacts management UI
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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 />
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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>>
|
||||
}
|
||||
@@ -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' }
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": "工作区"
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user