diff --git a/api/controllers/console/workspace/members.py b/api/controllers/console/workspace/members.py index 72330aba5f0..b9ed4bf67e2 100644 --- a/api/controllers/console/workspace/members.py +++ b/api/controllers/console/workspace/members.py @@ -1,4 +1,5 @@ from http import HTTPStatus +from typing import Annotated, Literal from urllib import parse from uuid import UUID @@ -43,7 +44,7 @@ from services.feature_service import FeatureService class MemberInvitePayload(BaseModel): - emails: list[str] = Field(default_factory=list) + emails: list[str] = Field(min_length=1) role: str language: str | None = None @@ -70,11 +71,28 @@ class OwnerTransferPayload(BaseModel): token: str -class MemberInviteResultResponse(ResponseModel): - status: str +class MemberInviteSuccessResponse(ResponseModel): + status: Literal["success"] email: str - url: str | None = None - message: str | None = None + url: str + + +class MemberInviteAlreadyMemberResponse(ResponseModel): + status: Literal["already_member"] + email: str + message: str + + +class MemberInviteFailedResponse(ResponseModel): + status: Literal["failed"] + email: str + message: str + + +MemberInviteResultResponse = Annotated[ + MemberInviteSuccessResponse | MemberInviteAlreadyMemberResponse | MemberInviteFailedResponse, + Field(discriminator="status"), +] class MemberActionResponse(ResponseModel): @@ -83,7 +101,7 @@ class MemberActionResponse(ResponseModel): class MemberInviteResponse(ResponseModel): - result: str + result: Literal["success"] invitation_results: list[MemberInviteResultResponse] tenant_id: str @@ -103,7 +121,9 @@ register_response_schema_models( AccountWithRoleListResponse, MemberActionResponse, MemberInviteResponse, - MemberInviteResultResponse, + MemberInviteSuccessResponse, + MemberInviteAlreadyMemberResponse, + MemberInviteFailedResponse, SimpleResultDataResponse, SimpleResultResponse, VerificationTokenResponse, @@ -279,7 +299,7 @@ class MemberInviteEmailApi(Resource): ) encoded_invitee_email = parse.quote(invitee_email) invitation_results.append( - MemberInviteResultResponse( + MemberInviteSuccessResponse( status="success", email=invitee_email, url=f"{console_web_url}/activate?email={encoded_invitee_email}&token={token}", @@ -287,7 +307,7 @@ class MemberInviteEmailApi(Resource): ) except AccountAlreadyInTenantError: invitation_results.append( - MemberInviteResultResponse( + MemberInviteAlreadyMemberResponse( status="already_member", email=invitee_email, message="Account already in workspace.", @@ -295,14 +315,20 @@ class MemberInviteEmailApi(Resource): ) except Exception as e: invitation_results.append( - MemberInviteResultResponse(status="failed", email=invitee_email, message=str(e)) + MemberInviteFailedResponse(status="failed", email=invitee_email, message=str(e)) ) - return MemberInviteResponse( - result="success", - invitation_results=invitation_results, - tenant_id=inviter.current_tenant.id if inviter.current_tenant else "", - ).model_dump(mode="json"), HTTPStatus.CREATED + return ( + dump_response( + MemberInviteResponse, + { + "result": "success", + "invitation_results": invitation_results, + "tenant_id": inviter.current_tenant.id if inviter.current_tenant else "", + }, + ), + HTTPStatus.CREATED, + ) @console_ns.route("/workspaces/current/members/") diff --git a/api/dev/generate_swagger_markdown_docs.py b/api/dev/generate_swagger_markdown_docs.py index 72bc56daf87..04187919bc5 100644 --- a/api/dev/generate_swagger_markdown_docs.py +++ b/api/dev/generate_swagger_markdown_docs.py @@ -109,11 +109,15 @@ def _replace_schema_table_type(markdown: str, definition_name: str, row_name: st def _has_union_schema(schema: object) -> bool: - return isinstance(schema, dict) and (isinstance(schema.get("oneOf"), list) or isinstance(schema.get("anyOf"), list)) + if not isinstance(schema, dict): + return False + if isinstance(schema.get("oneOf"), list) or isinstance(schema.get("anyOf"), list): + return True + return _has_union_schema(schema.get("items")) def _patch_union_schema_markdown(markdown: str, spec_path: Path) -> str: - """Fill Markdown table cells that `swagger-markdown` leaves blank for union schemas.""" + """Fill Markdown table cells that `swagger-markdown` leaves blank for unions, including array items.""" spec = json.loads(spec_path.read_text(encoding="utf-8")) components = spec.get("components") diff --git a/api/openapi/markdown/console-openapi.md b/api/openapi/markdown/console-openapi.md index 41d0f4abebd..64f55121dce 100644 --- a/api/openapi/markdown/console-openapi.md +++ b/api/openapi/markdown/console-openapi.md @@ -18641,11 +18641,27 @@ Enum class for large language model mode. | ---- | ---- | ----------- | -------- | | data | [ [AccessPolicyMemberBinding](#accesspolicymemberbinding) ] | | No | +#### MemberInviteAlreadyMemberResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| email | string | | Yes | +| message | string | | Yes | +| status | string | | Yes | + +#### MemberInviteFailedResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| email | string | | Yes | +| message | string | | Yes | +| status | string | | Yes | + #### MemberInvitePayload | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| emails | [ string ] | | No | +| emails | [ string ] | | Yes | | language | string | | No | | role | string | | Yes | @@ -18653,18 +18669,17 @@ Enum class for large language model mode. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| invitation_results | [ [MemberInviteResultResponse](#memberinviteresultresponse) ] | | Yes | +| invitation_results | [ [MemberInviteSuccessResponse](#memberinvitesuccessresponse)
[MemberInviteAlreadyMemberResponse](#memberinvitealreadymemberresponse)
[MemberInviteFailedResponse](#memberinvitefailedresponse) ] | | Yes | | result | string | | Yes | | tenant_id | string | | Yes | -#### MemberInviteResultResponse +#### MemberInviteSuccessResponse | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | email | string | | Yes | -| message | string | | No | | status | string | | Yes | -| url | string | | No | +| url | string | | Yes | #### MemberRoleUpdatePayload diff --git a/api/openapi/markdown/openapi-openapi.md b/api/openapi/markdown/openapi-openapi.md index c5ec6c3a7db..91a921ee31d 100644 --- a/api/openapi/markdown/openapi-openapi.md +++ b/api/openapi/markdown/openapi-openapi.md @@ -730,7 +730,7 @@ future server adds a code. Formatter tests pin emitted values to the enum. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| loc | [ ],
**Default:** | | No | +| loc | [ string
integer ] | | No | | msg | string | | Yes | | type | string | | Yes | diff --git a/api/openapi/markdown/service-openapi.md b/api/openapi/markdown/service-openapi.md index 6cd962146d8..1a96b1bcf37 100644 --- a/api/openapi/markdown/service-openapi.md +++ b/api/openapi/markdown/service-openapi.md @@ -3609,7 +3609,7 @@ Shared permission levels for resources (datasets, credentials, etc.) | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| datasource_info_list | [ ] | List of datasource objects to process. The expected item structure depends on `datasource_type`. | Yes | +| datasource_info_list | [ object
object
object
object ] | List of datasource objects to process. The expected item structure depends on `datasource_type`. | Yes | | datasource_type | string,
**Available values:** "local_file", "online_document", "online_drive", "website_crawl" | Type of the datasource. Determines which fields are expected in `datasource_info_list` items.
*Enum:* `"local_file"`, `"online_document"`, `"online_drive"`, `"website_crawl"` | Yes | | inputs | object | Key-value pairs for pipeline input variables defined in the workflow. Pass `{}` if the pipeline has no input variables. | Yes | | is_published | boolean | Whether to run the published or draft version of the pipeline. `true` runs the latest published version; `false` runs the current draft (useful for testing unpublished changes). | Yes | diff --git a/api/tests/unit_tests/commands/test_generate_swagger_markdown_docs.py b/api/tests/unit_tests/commands/test_generate_swagger_markdown_docs.py index e989d54a965..206b0fe79f3 100644 --- a/api/tests/unit_tests/commands/test_generate_swagger_markdown_docs.py +++ b/api/tests/unit_tests/commands/test_generate_swagger_markdown_docs.py @@ -237,6 +237,51 @@ def test_patch_union_schema_markdown_fills_regular_schema_union_property(tmp_pat assert "| value | string
integer
number
boolean | | No |" in patched +def test_patch_union_schema_markdown_fills_array_item_union_property(tmp_path: Path): + module = _load_generate_swagger_markdown_docs_module() + spec_path = tmp_path / "console-openapi.json" + spec_path.write_text( + json.dumps( + { + "components": { + "schemas": { + "MemberInviteResponse": { + "properties": { + "invitation_results": { + "type": "array", + "items": { + "oneOf": [ + {"$ref": "#/components/schemas/MemberInviteSuccessResponse"}, + {"$ref": "#/components/schemas/MemberInviteAlreadyMemberResponse"}, + {"$ref": "#/components/schemas/MemberInviteFailedResponse"}, + ], + }, + }, + }, + }, + }, + } + } + ), + encoding="utf-8", + ) + markdown = """#### MemberInviteResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| invitation_results | [ ] | | Yes | +""" + + patched = module._patch_union_schema_markdown(markdown, spec_path) + + assert ( + "| invitation_results | [ " + "[MemberInviteSuccessResponse](#memberinvitesuccessresponse)
" + "[MemberInviteAlreadyMemberResponse](#memberinvitealreadymemberresponse)
" + "[MemberInviteFailedResponse](#memberinvitefailedresponse) ] | | Yes |" + ) in patched + + def test_patch_union_schema_markdown_ignores_specs_without_schemas(tmp_path): module = _load_generate_swagger_markdown_docs_module() spec_path = tmp_path / "console-openapi.json" diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index 9c14671da53..1d4fa29645b 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -3331,11 +3331,6 @@ "count": 2 } }, - "web/app/components/header/account-setting/members-page/invite-modal/index.tsx": { - "jsx_a11y/no-autofocus": { - "count": 1 - } - }, "web/app/components/header/account-setting/members-page/transfer-ownership-modal/member-selector.tsx": { "jsx_a11y/click-events-have-key-events": { "count": 1 @@ -6888,7 +6883,7 @@ "count": 1 }, "typescript/no-explicit-any": { - "count": 9 + "count": 8 } }, "web/service/datasets.ts": { diff --git a/package.json b/package.json index 68ae579a854..79cfd9af925 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "private": true, "type": "module", "scripts": { - "check": "vp check && pnpm lint:eslint", + "check": "vp fmt --check && vp lint --quiet && pnpm lint:eslint", "check:fix": "pnpm lint:eslint:fix && vp check --fix", "dev": "concurrently -k -n vinext,proxy \"vp run dify-web#dev:vinext\" \"vp run dify-web#dev:proxy\"", "prepare": "vp config", diff --git a/packages/contracts/generated/api/console/workspaces/types.gen.ts b/packages/contracts/generated/api/console/workspaces/types.gen.ts index 06fccf2e9fb..d94b018f894 100644 --- a/packages/contracts/generated/api/console/workspaces/types.gen.ts +++ b/packages/contracts/generated/api/console/workspaces/types.gen.ts @@ -161,14 +161,24 @@ export type EndpointUpdatePayload = { } export type MemberInvitePayload = { - emails?: Array + emails: Array language?: string | null role: string } export type MemberInviteResponse = { - invitation_results: Array - result: string + invitation_results: Array< + | ({ + status: 'success' + } & MemberInviteSuccessResponse) + | ({ + status: 'already_member' + } & MemberInviteAlreadyMemberResponse) + | ({ + status: 'failed' + } & MemberInviteFailedResponse) + > + result: 'success' tenant_id: string } @@ -1128,11 +1138,22 @@ export type EndpointListItemResponse = { url: string } -export type MemberInviteResultResponse = { +export type MemberInviteSuccessResponse = { email: string - message?: string | null - status: string - url?: string | null + status: 'success' + url: string +} + +export type MemberInviteAlreadyMemberResponse = { + email: string + message: string + status: 'already_member' +} + +export type MemberInviteFailedResponse = { + email: string + message: string + status: 'failed' } export type ProviderResponse = { diff --git a/packages/contracts/generated/api/console/workspaces/zod.gen.ts b/packages/contracts/generated/api/console/workspaces/zod.gen.ts index 4c61e8b52b4..098e77d9f19 100644 --- a/packages/contracts/generated/api/console/workspaces/zod.gen.ts +++ b/packages/contracts/generated/api/console/workspaces/zod.gen.ts @@ -90,7 +90,7 @@ export const zEndpointUpdatePayload = z.object({ * MemberInvitePayload */ export const zMemberInvitePayload = z.object({ - emails: z.array(z.string()).optional(), + emails: z.array(z.string()).min(1), language: z.string().nullish(), role: z.string(), }) @@ -816,21 +816,56 @@ export const zAccountWithRoleListResponse = z.object({ }) /** - * MemberInviteResultResponse + * MemberInviteSuccessResponse */ -export const zMemberInviteResultResponse = z.object({ +export const zMemberInviteSuccessResponse = z.object({ email: z.string(), - message: z.string().nullish(), - status: z.string(), - url: z.string().nullish(), + status: z.literal('success'), + url: z.string(), +}) + +/** + * MemberInviteAlreadyMemberResponse + */ +export const zMemberInviteAlreadyMemberResponse = z.object({ + email: z.string(), + message: z.string(), + status: z.literal('already_member'), +}) + +/** + * MemberInviteFailedResponse + */ +export const zMemberInviteFailedResponse = z.object({ + email: z.string(), + message: z.string(), + status: z.literal('failed'), }) /** * MemberInviteResponse */ export const zMemberInviteResponse = z.object({ - invitation_results: z.array(zMemberInviteResultResponse), - result: z.string(), + invitation_results: z.array( + z.union([ + z + .object({ + status: z.literal('success'), + }) + .and(zMemberInviteSuccessResponse), + z + .object({ + status: z.literal('already_member'), + }) + .and(zMemberInviteAlreadyMemberResponse), + z + .object({ + status: z.literal('failed'), + }) + .and(zMemberInviteFailedResponse), + ]), + ), + result: z.literal('success'), tenant_id: z.string(), }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index acbc3a51f50..50f9712b6f6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -525,9 +525,6 @@ catalogs: react-i18next: specifier: 17.0.9 version: 17.0.9 - react-multi-email: - specifier: 1.0.25 - version: 1.0.25 react-papaparse: specifier: 4.4.0 version: 4.4.0 @@ -1413,9 +1410,6 @@ importers: react-i18next: specifier: 'catalog:' version: 17.0.9(@typescript/typescript6@6.0.2)(i18next@26.3.6(@typescript/typescript6@6.0.2))(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - react-multi-email: - specifier: 'catalog:' - version: 1.0.25(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react-papaparse: specifier: 'catalog:' version: 4.4.0 @@ -8544,12 +8538,6 @@ packages: react-is@17.0.2: resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} - react-multi-email@1.0.25: - resolution: {integrity: sha512-Wmv28FvIk4nWgdpHzlIPonY4iSs7bPV35+fAiWYzSBhTo+vhXfglEhjY1WnjHQINW/Pibu2xlb/q1heVuytQHQ==} - peerDependencies: - react: ^18.2.0 - react-dom: ^18.2.0 - react-papaparse@4.4.0: resolution: {integrity: sha512-xTEwHZYJ+1dh9mQDQjjwJXmWyX20DdZ52u+ddw75V+Xm5qsjXSvWmC7c8K82vRwMjKAOH2S9uFyGpHEyEztkUQ==} engines: {node: '>=8', npm: '>=5'} @@ -17465,11 +17453,6 @@ snapshots: react-is@17.0.2: {} - react-multi-email@1.0.25(react-dom@19.2.7(react@19.2.7))(react@19.2.7): - dependencies: - react: 19.2.7 - react-dom: 19.2.7(react@19.2.7) - react-papaparse@4.4.0: dependencies: '@types/papaparse': 5.5.2 @@ -19387,7 +19370,6 @@ time: react-dom@19.2.7: '2026-06-01T18:01:02.438Z' react-easy-crop@6.2.2: '2026-07-08T08:37:49.604Z' react-i18next@17.0.9: '2026-07-09T13:42:03.224Z' - react-multi-email@1.0.25: '2024-07-18T04:31:06.176Z' react-papaparse@4.4.0: '2023-10-13T10:27:07.978Z' react-pdf-highlighter@8.0.0-rc.0: '2024-09-14T16:57:58.673Z' react-server-dom-webpack@19.2.7: '2026-06-01T18:01:09.215Z' diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index c36ce6a836e..30489036a54 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -224,7 +224,6 @@ catalog: react-dom: 19.2.7 react-easy-crop: 6.2.2 react-i18next: 17.0.9 - react-multi-email: 1.0.25 react-papaparse: 4.4.0 react-pdf-highlighter: 8.0.0-rc.0 react-server-dom-webpack: 19.2.7 diff --git a/web/app/components/header/account-setting/members-page/__tests__/index.spec.tsx b/web/app/components/header/account-setting/members-page/__tests__/index.spec.tsx index 85e85812a13..b7f66eb2555 100644 --- a/web/app/components/header/account-setting/members-page/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/members-page/__tests__/index.spec.tsx @@ -1,3 +1,4 @@ +import type { ReactElement } from 'react' import type { AppContextStateMockState } from '@/__tests__/utils/mock-app-context-state' import type { Role } from '@/models/access-control' import type { ICurrentWorkspace, Member } from '@/models/common' @@ -84,31 +85,36 @@ vi.mock('../edit-workspace-modal', () => ({ ), })) -vi.mock('../invite-button', () => ({ - default: ({ onClick, disabled }: { onClick: () => void; disabled: boolean }) => ( - - ), -})) vi.mock('../invite-modal', () => ({ - default: ({ - onCancel, + InviteModal: ({ + open, + trigger, + onOpenChange, onSend, }: { - onCancel: () => void + open: boolean + trigger: ReactElement<{ disabled?: boolean }> + onOpenChange: (open: boolean) => void onSend: (results: Array<{ email: string; status: 'success'; url: string }>) => void }) => (
-
Invite Modal
- - + {open && ( +
+
Invite Modal
+ + +
+ )}
), })) @@ -321,7 +327,6 @@ describe('MembersPage', () => { await user.click(screen.getByRole('button', { name: 'Send Invite Results' })) expect(screen.getByText('Invited Modal'))!.toBeInTheDocument() - expect(mockRefetch).toHaveBeenCalled() await user.click(screen.getByRole('button', { name: 'Close Invited Modal' })) expect(screen.queryByText('Invited Modal')).not.toBeInTheDocument() @@ -723,7 +728,8 @@ describe('MembersPage', () => { expect(screen.queryByText('Member Details Modal')).not.toBeInTheDocument() }) - it('should show upgrade button when member limit is full', () => { + it('should show the upgrade action without blocking the backend-authoritative invite flow', async () => { + const user = userEvent.setup() vi.mocked(useProviderContext).mockReturnValue( createMockProviderContextValue({ enableBilling: true, @@ -739,5 +745,7 @@ describe('MembersPage', () => { renderMembersPage() expect(screen.getByText('Upgrade Button'))!.toBeInTheDocument() + await user.click(screen.getByRole('button', { name: 'Invite' })) + expect(screen.getByText('Invite Modal')).toBeInTheDocument() }) }) diff --git a/web/app/components/header/account-setting/members-page/index.tsx b/web/app/components/header/account-setting/members-page/index.tsx index ebc341c2bbb..5bfdbed579a 100644 --- a/web/app/components/header/account-setting/members-page/index.tsx +++ b/web/app/components/header/account-setting/members-page/index.tsx @@ -1,6 +1,7 @@ 'use client' +import type { MemberInviteResponse } from '@dify/contracts/api/console/workspaces/types.gen' import type { Role } from '@/models/access-control' -import type { InvitationResult, Member } from '@/models/common' +import type { Member } from '@/models/common' import { toast } from '@langgenius/dify-ui/toast' import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import { useSuspenseQuery } from '@tanstack/react-query' @@ -22,7 +23,7 @@ import { useMembers } from '@/service/use-common' import { hasPermission } from '@/utils/permission' import EditWorkspaceModal from './edit-workspace-modal' import InviteButton from './invite-button' -import InviteModal from './invite-modal' +import { InviteModal } from './invite-modal' import InvitedModal from './invited-modal' import MemberDetailsModal from './member-details-modal' import MemberRow from './member-row' @@ -40,7 +41,9 @@ const MembersPage = () => { const { data, refetch } = useMembers(language) const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) const [inviteModalVisible, setInviteModalVisible] = useState(false) - const [invitationResults, setInvitationResults] = useState([]) + const [invitationResults, setInvitationResults] = useState< + MemberInviteResponse['invitation_results'] + >([]) const [invitedModalVisible, setInvitedModalVisible] = useState(false) const accounts = data?.accounts || [] const { plan, enableBilling, isAllowTransferWorkspace } = useProviderContext() @@ -158,7 +161,16 @@ const MembersPage = () => { {isMemberFull && }
{canManageMembers && ( - setInviteModalVisible(true)} /> + } + isEmailSetup={systemFeatures.is_email_setup} + onOpenChange={setInviteModalVisible} + onSend={(invitationResults) => { + setInvitedModalVisible(true) + setInvitationResults(invitationResults) + }} + /> )}
@@ -191,17 +203,6 @@ const MembersPage = () => { - {inviteModalVisible && ( - setInviteModalVisible(false)} - onSend={(invitationResults) => { - setInvitedModalVisible(true) - setInvitationResults(invitationResults) - refetch() - }} - /> - )} {invitedModalVisible && ( void -} +type InviteButtonProps = Omit const InviteButton = (props: InviteButtonProps) => { const { t } = useTranslation() @@ -28,8 +25,8 @@ const InviteButton = (props: InviteButtonProps) => { } } return ( - ) diff --git a/web/app/components/header/account-setting/members-page/invite-modal/__tests__/email-recipients-field.spec.tsx b/web/app/components/header/account-setting/members-page/invite-modal/__tests__/email-recipients-field.spec.tsx new file mode 100644 index 00000000000..16fb2a52b03 --- /dev/null +++ b/web/app/components/header/account-setting/members-page/invite-modal/__tests__/email-recipients-field.spec.tsx @@ -0,0 +1,345 @@ +import type { EmailRecipient } from '../email-recipients' +import { createEvent, fireEvent, render, screen, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { useState } from 'react' +import { EmailRecipientsField } from '../email-recipients-field' + +function EmailRecipientsFieldWrapper({ disabled = false }: { disabled?: boolean }) { + const [recipients, setRecipients] = useState([]) + const [draft, setDraft] = useState('') + + return ( + <> + + + + ) +} + +function getRecipient(value: string) { + const recipient = screen.getAllByRole('listitem').find((item) => within(item).queryByText(value)) + + expect(recipient).toBeDefined() + return recipient! +} + +function getChip(value: string) { + return screen.getByRole('button', { name: value }) +} + +describe('EmailRecipientsField', () => { + it('describes how to add multiple email addresses', () => { + render() + + const input = screen.getByRole('textbox', { name: /members\.emailRecipients/i }) + + expect(input).toHaveAttribute('type', 'text') + expect(input).toHaveAttribute('inputmode', 'email') + expect(input).toHaveAccessibleDescription(/members\.emailRecipientsTip/i) + }) + + it('keeps an uncommitted draft when focus leaves the composer', async () => { + const user = userEvent.setup() + render() + + const input = screen.getByRole('textbox', { name: /members\.emailRecipients/i }) + await user.type(input, 'draft@example.com') + await user.tab() + + expect(input).toHaveValue('draft@example.com') + expect(screen.queryByRole('list')).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Next control' })).toHaveFocus() + }) + + it('should keep a single pasted address in the draft for further editing', async () => { + const user = userEvent.setup() + render() + + const input = screen.getByRole('textbox', { name: /members\.emailRecipients/i }) + await user.click(input) + await user.paste('Person@Example.com') + + expect(input).toHaveValue('Person@Example.com') + expect(input).toHaveFocus() + expect(screen.queryByRole('list')).not.toBeInTheDocument() + }) + + it('commits a valid draft with Enter and keeps focus in the composer', async () => { + const user = userEvent.setup() + render() + + const input = screen.getByRole('textbox', { name: /members\.emailRecipients/i }) + await user.type(input, 'person@example{Enter}') + + expect(getRecipient('person@example')).toBeInTheDocument() + expect(input).toHaveValue('') + expect(input).toHaveFocus() + }) + + it('keeps a manually entered invalid address in the input for correction', async () => { + const user = userEvent.setup() + render() + + const input = screen.getByRole('textbox', { name: /members\.emailRecipients/i }) + await user.type(input, 'not-an-email{Enter}') + + expect(input).toHaveValue('not-an-email') + expect(input).toHaveFocus() + expect(screen.queryByRole('list')).not.toBeInTheDocument() + expect(screen.getByText(/members\.emailInvalid/i)).toBeInTheDocument() + }) + + it('does not commit or submit Enter while an IME composition is active', async () => { + const user = userEvent.setup() + render() + + const input = screen.getByRole('textbox', { name: /members\.emailRecipients/i }) + await user.type(input, 'person@example.com') + const enter = createEvent.keyDown(input, { key: 'Enter', isComposing: true }) + fireEvent(input, enter) + + expect(enter).toHaveProperty('defaultPrevented', true) + expect(input).toHaveValue('person@example.com') + expect(screen.queryByRole('list')).not.toBeInTheDocument() + }) + + it('should wait for Enter before committing manually typed separators', async () => { + const user = userEvent.setup() + render() + + const input = screen.getByRole('textbox', { name: /members\.emailRecipients/i }) + await user.type(input, 'first@example.com,second@example.com;third@example.com') + + expect(screen.queryByRole('list')).not.toBeInTheDocument() + + await user.keyboard('{Enter}') + + expect(screen.getAllByRole('listitem')).toHaveLength(3) + expect(input).toHaveValue('') + expect(input).toHaveFocus() + }) + + it('should not report a valid delimited draft as invalid when focus leaves', async () => { + const user = userEvent.setup() + render() + + const input = screen.getByRole('textbox', { name: /members\.emailRecipients/i }) + await user.type(input, 'first@example.com,second@example.com') + await user.tab() + + expect(input).toHaveValue('first@example.com,second@example.com') + expect(input).not.toHaveAttribute('aria-invalid', 'true') + expect(screen.queryByText(/members\.emailInvalid/i)).not.toBeInTheDocument() + }) + + it('should not split manually typed addresses on spaces', async () => { + const user = userEvent.setup() + render() + + const input = screen.getByRole('textbox', { name: /members\.emailRecipients/i }) + await user.type(input, 'first@example.com second@example.com{Enter}') + + expect(input).toHaveValue('first@example.com second@example.com') + expect(input).toHaveFocus() + expect(screen.queryByRole('list')).not.toBeInTheDocument() + expect(screen.getByText(/members\.emailInvalid/i)).toBeInTheDocument() + }) + + it('parses batch paste delimiters and removes duplicates case-insensitively', async () => { + const user = userEvent.setup() + render() + + const input = screen.getByRole('textbox', { name: /members\.emailRecipients/i }) + await user.click(input) + await user.paste( + 'First@Example.com, second@example.com; FIRST@example.com\nthird@example.com\tfourth@example.com', + ) + + const recipientList = screen.getByRole('list', { name: /members\.emailRecipients/i }) + expect(within(recipientList).getAllByRole('listitem')).toHaveLength(4) + expect(within(recipientList).getByText('first@example.com')).toBeInTheDocument() + expect(within(recipientList).getByText('second@example.com')).toBeInTheDocument() + expect(within(recipientList).getByText('third@example.com')).toBeInTheDocument() + expect(within(recipientList).getByText('fourth@example.com')).toBeInTheDocument() + }) + + it('should combine a batch paste with the draft at the current caret', async () => { + const user = userEvent.setup() + render() + + const input = screen.getByRole('textbox', { name: /members\.emailRecipients/i }) + await user.type(input, 'first@example.com') + await user.paste(', second@example.com') + + expect(screen.getAllByRole('listitem')).toHaveLength(2) + expect(getRecipient('first@example.com')).toBeInTheDocument() + expect(getRecipient('second@example.com')).toBeInTheDocument() + expect(input).toHaveValue('') + }) + + it('should replace a selected draft when a batch is pasted', async () => { + const user = userEvent.setup() + render() + + const input = screen.getByRole('textbox', { name: /members\.emailRecipients/i }) + await user.type(input, 'stale@example.com') + await user.tripleClick(input) + await user.paste('first@example.com, second@example.com') + + expect(screen.getAllByRole('listitem')).toHaveLength(2) + expect(getRecipient('first@example.com')).toBeInTheDocument() + expect(getRecipient('second@example.com')).toBeInTheDocument() + expect(screen.queryByText('stale@example.com')).not.toBeInTheDocument() + expect(input).toHaveValue('') + }) + + it('should deduplicate a later batch against existing recipients', async () => { + const user = userEvent.setup() + render() + + const input = screen.getByRole('textbox', { name: /members\.emailRecipients/i }) + await user.click(input) + await user.paste('first@example.com,') + await user.paste('FIRST@example.com, second@example.com') + + expect(screen.getAllByRole('listitem')).toHaveLength(2) + expect(getRecipient('first@example.com')).toBeInTheDocument() + expect(getRecipient('second@example.com')).toBeInTheDocument() + }) + + it('moves an invalid pasted address back into the input for editing', async () => { + const user = userEvent.setup() + render() + + const input = screen.getByRole('textbox', { name: /members\.emailRecipients/i }) + await user.click(input) + await user.paste('valid@example.com, not-an-email') + await user.click(screen.getByRole('button', { name: /operation\.edit.*not-an-email/i })) + + expect(screen.queryByText('not-an-email')).not.toBeInTheDocument() + expect(input).toHaveValue('not-an-email') + expect(input).toHaveFocus() + expect(getRecipient('valid@example.com')).toBeInTheDocument() + }) + + it('preserves the draft and restores input focus when a recipient is removed', async () => { + const user = userEvent.setup() + render() + + const input = screen.getByRole('textbox', { name: /members\.emailRecipients/i }) + await user.type(input, 'existing@example.com{Enter}draft@example.com') + await user.click( + screen.getByRole('button', { name: /operation\.remove.*existing@example\.com/i }), + ) + + expect(screen.queryByText('existing@example.com')).not.toBeInTheDocument() + expect(input).toHaveValue('draft@example.com') + expect(input).toHaveFocus() + }) + + it('supports one keyboard path through chips without adding tab stops', async () => { + const user = userEvent.setup() + render() + + const input = screen.getByRole('textbox', { name: /members\.emailRecipients/i }) + await user.click(input) + await user.paste('first@example.com, second@example.com') + + await user.keyboard('{ArrowLeft}') + expect(getChip('second@example.com')).toHaveFocus() + + await user.keyboard('{ArrowLeft}') + expect(getChip('first@example.com')).toHaveFocus() + + await user.keyboard('{ArrowRight}{Delete}') + expect(screen.queryByText('second@example.com')).not.toBeInTheDocument() + expect(getChip('first@example.com')).toHaveFocus() + + await user.keyboard('{Backspace}') + expect(screen.queryByText('first@example.com')).not.toBeInTheDocument() + expect(input).toHaveFocus() + + await user.type(input, 'last@example.com{Enter}') + await user.tab() + expect(screen.getByRole('button', { name: 'Next control' })).toHaveFocus() + }) + + it('should return focus to the input after moving past the last chip', async () => { + const user = userEvent.setup() + render() + + const input = screen.getByRole('textbox', { name: /members\.emailRecipients/i }) + await user.click(input) + await user.paste('first@example.com, second@example.com') + await user.keyboard('{ArrowLeft}{ArrowRight}') + + expect(input).toHaveFocus() + }) + + it('should focus the next chip after deleting a middle chip', async () => { + const user = userEvent.setup() + render() + + const input = screen.getByRole('textbox', { name: /members\.emailRecipients/i }) + await user.click(input) + await user.paste('first@example.com, second@example.com, third@example.com') + await user.keyboard('{ArrowLeft}{ArrowLeft}{Delete}') + + expect(screen.queryByText('second@example.com')).not.toBeInTheDocument() + expect(getChip('third@example.com')).toHaveFocus() + }) + + it.each(['{Enter}', ' '])( + 'should return focus to the input when a valid chip is activated with %j', + async (key) => { + const user = userEvent.setup() + render() + + const input = screen.getByRole('textbox', { name: /members\.emailRecipients/i }) + await user.click(input) + await user.paste('person@example.com,') + await user.keyboard(`{ArrowLeft}${key}`) + + expect(getRecipient('person@example.com')).toBeInTheDocument() + expect(input).toHaveFocus() + }, + ) + + it('should move an invalid chip into the focused input when activated from the keyboard', async () => { + const user = userEvent.setup() + render() + + const input = screen.getByRole('textbox', { name: /members\.emailRecipients/i }) + await user.click(input) + await user.paste('valid@example.com, not-an-email') + await user.keyboard('{ArrowLeft}{Enter}') + + expect(screen.queryByText('not-an-email')).not.toBeInTheDocument() + expect(input).toHaveValue('not-an-email') + expect(input).toHaveFocus() + expect(getRecipient('valid@example.com')).toBeInTheDocument() + }) + + it('immediately removes the last chip with Backspace from an empty input', async () => { + const user = userEvent.setup() + render() + + const input = screen.getByRole('textbox', { name: /members\.emailRecipients/i }) + await user.type(input, 'person@example.com{Enter}{Backspace}') + + expect(screen.queryByText('person@example.com')).not.toBeInTheDocument() + expect(input).toHaveFocus() + }) + + it('disables every editing action while the composer is frozen', () => { + render() + + expect(screen.getByRole('textbox', { name: /members\.emailRecipients/i })).toBeDisabled() + }) +}) diff --git a/web/app/components/header/account-setting/members-page/invite-modal/__tests__/email-recipients.spec.ts b/web/app/components/header/account-setting/members-page/invite-modal/__tests__/email-recipients.spec.ts new file mode 100644 index 00000000000..62117b03157 --- /dev/null +++ b/web/app/components/header/account-setting/members-page/invite-modal/__tests__/email-recipients.spec.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest' +import { createEmailRecipient, hasEmailDelimiter, mergeEmailRecipients } from '../email-recipients' + +describe('email recipients', () => { + it('should normalize an address while preserving browser email validity semantics', () => { + expect(createEmailRecipient(' Person@Example ')).toEqual({ + value: 'person@example', + isValid: true, + }) + expect(createEmailRecipient('not-an-email')).toEqual({ + value: 'not-an-email', + isValid: false, + }) + }) + + it.each([',', ';', '\n', '\r\n', '\t'])( + 'should recognize %j as a batch paste separator', + (separator) => { + expect(hasEmailDelimiter(`first@example.com${separator}second@example.com`)).toBe(true) + }, + ) + + it.each(['first@example.com', 'first@example.com second@example.com'])( + 'should not treat %j as batch input', + (value) => { + expect(hasEmailDelimiter(value)).toBe(false) + }, + ) + + it('should ignore empty entries while preserving recipient order', () => { + expect(mergeEmailRecipients([], ' first@example.com, ,\nsecond@example.com;\t')).toEqual([ + { value: 'first@example.com', isValid: true }, + { value: 'second@example.com', isValid: true }, + ]) + }) + + it('should deduplicate recipients against both existing and pasted addresses', () => { + const existing = [createEmailRecipient('first@example.com')] + + expect( + mergeEmailRecipients(existing, 'FIRST@example.com, second@example.com, SECOND@example.com'), + ).toEqual([ + { value: 'first@example.com', isValid: true }, + { value: 'second@example.com', isValid: true }, + ]) + }) + + it('should preserve invalid pasted entries so the user can correct them', () => { + expect(mergeEmailRecipients([], 'valid@example.com, not-an-email')).toEqual([ + { value: 'valid@example.com', isValid: true }, + { value: 'not-an-email', isValid: false }, + ]) + }) + + it('should preserve the order of a realistic twenty-recipient batch', () => { + const addresses = Array.from({ length: 20 }, (_, index) => `person-${index + 1}@example.com`) + + expect(mergeEmailRecipients([], addresses.join('\n')).map(({ value }) => value)).toEqual( + addresses, + ) + }) +}) diff --git a/web/app/components/header/account-setting/members-page/invite-modal/__tests__/index.spec.tsx b/web/app/components/header/account-setting/members-page/invite-modal/__tests__/index.spec.tsx index 95d1e6669fc..3d19126d517 100644 --- a/web/app/components/header/account-setting/members-page/invite-modal/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/members-page/invite-modal/__tests__/index.spec.tsx @@ -1,84 +1,50 @@ -import type { InvitationResponse } from '@/models/common' -import { toast } from '@langgenius/dify-ui/toast' +import type { MemberInviteResponse } from '@dify/contracts/api/console/workspaces/types.gen' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { act, render, screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' +import { useState } from 'react' import { vi } from 'vitest' import { useProviderContextSelector } from '@/context/provider-context' import { useWorkspaceRoleList } from '@/service/access-control/use-workspace-roles' -import { inviteMember } from '@/service/common' -import { commonQueryKeys } from '@/service/use-common' -import InviteModal from '../index' +import { InviteModal } from '../index' -const { mockToastError } = vi.hoisted(() => ({ - mockToastError: vi.fn(), -})) +const { inviteMember } = vi.hoisted(() => ({ inviteMember: vi.fn() })) vi.mock('@/context/provider-context', () => ({ useProviderContextSelector: vi.fn(), - useProviderContext: vi.fn(() => ({ - datasetOperatorEnabled: true, - })), })) -vi.mock('@/service/common') vi.mock('@/service/access-control/use-workspace-roles') -vi.mock('@langgenius/dify-ui/toast', () => ({ - toast: { - error: mockToastError, +vi.mock('@/service/client', () => ({ + consoleQuery: { + workspaces: { + current: { + members: { + inviteEmail: { + post: { + mutationOptions: () => ({ mutationFn: inviteMember }), + }, + }, + }, + }, + }, }, })) -vi.mock('react-multi-email', () => ({ - ReactMultiEmail: ({ - emails, - onChange, - getLabel, - }: { - emails: string[] - onChange: (emails: string[]) => void - getLabel: ( - email: string, - index: number, - removeEmail: (index: number) => void, - ) => React.ReactNode - }) => ( -
- onChange(e.target.value ? e.target.value.split(',') : [])} - /> - {emails.map((email: string, index: number) => ( -
- {getLabel(email, index, (idx: number) => - onChange(emails.filter((_: string, i: number) => i !== idx)), - )} -
- ))} -
- ), -})) - describe('InviteModal', () => { - const mockOnCancel = vi.fn() - const mockOnSend = vi.fn() - const mockRefreshLicenseLimit = vi.fn() + const onOpenChange = vi.fn() + const onSend = vi.fn() + const refreshLicenseLimit = vi.fn() const createQueryClient = () => new QueryClient({ defaultOptions: { - queries: { - retry: false, - staleTime: Infinity, - }, - mutations: { - retry: false, - }, + queries: { retry: false, staleTime: Infinity }, + mutations: { retry: false }, }, }) beforeEach(() => { vi.clearAllMocks() - vi.mocked(useWorkspaceRoleList).mockReturnValue({ data: { pages: [ @@ -95,20 +61,9 @@ describe('InviteModal', () => { permission_keys: [], role_tag: '', }, - { - id: 'normal', - tenant_id: 'tenant-id', - type: 'workspace', - category: 'global_system_default', - name: 'Normal', - description: 'Can use apps', - is_builtin: true, - permission_keys: [], - role_tag: '', - }, ], pagination: { - total_count: 2, + total_count: 1, per_page: 20, current_page: 1, total_pages: 1, @@ -123,112 +78,137 @@ describe('InviteModal', () => { isFetchingNextPage: false, fetchNextPage: vi.fn(), } as unknown as ReturnType) - vi.mocked(useProviderContextSelector).mockImplementation((selector) => selector({ licenseLimit: { workspace_members: { size: 5, limit: 10 } }, - refreshLicenseLimit: mockRefreshLicenseLimit, + refreshLicenseLimit, } as unknown as Parameters[0]), ) }) - const renderModal = (isEmailSetup = true, queryClient = createQueryClient()) => ({ - queryClient, - ...render( + const renderModal = ({ + open = true, + isEmailSetup = true, + queryClient = createQueryClient(), + }: { + open?: boolean + isEmailSetup?: boolean + queryClient?: QueryClient + } = {}) => + render( - + members.invite} + isEmailSetup={isEmailSetup} + onOpenChange={onOpenChange} + onSend={onSend} + /> , - ), - }) - const fillEmails = (value: string) => { - fireEvent.change(screen.getByTestId('mock-email-input'), { target: { value } }) - } + ) + const selectAdminRole = async (user: ReturnType) => { - await user.click(screen.getByRole('button', { name: /members\.selectRole/i })) - await user.click(screen.getByRole('menuitemradio', { name: /Admin/i })) + await user.click(screen.getByRole('combobox', { name: /members\.role/i })) + await user.click(screen.getByRole('option', { name: /Admin/i })) } - it('should render invite modal content', async () => { + const addRecipients = async (user: ReturnType, value: string) => { + const input = screen.getByRole('textbox', { name: /members\.emailRecipients/i }) + await user.click(input) + await user.paste(value) + } + + it('renders a labeled form inside a controlled dialog', () => { renderModal() - expect(await screen.findByText(/members\.inviteTeamMember$/i)).toBeInTheDocument() - expect(screen.getByRole('button', { name: /members\.sendInvite/i })).toBeDisabled() + const dialog = screen.getByRole('dialog', { name: /members\.inviteTeamMember$/i }) + expect(within(dialog).getByText(/members\.inviteTeamMemberTip/i)).toBeInTheDocument() + expect(within(dialog).getByRole('form')).toBeInTheDocument() + expect( + within(dialog).getByRole('textbox', { name: /members\.emailRecipients/i }), + ).toHaveAttribute('inputmode', 'email') }) - it('should show warning when email service is not configured', async () => { - renderModal(false) - - expect(await screen.findByText(/members\.emailNotSetup$/i)).toBeInTheDocument() - }) - - it('should enable send button after entering an email and selecting a role', async () => { + it('should place initial focus in the email composer', async () => { renderModal() - fillEmails('user@example.com') - - expect(screen.getByRole('button', { name: /members\.sendInvite/i })).toBeDisabled() - - const user = userEvent.setup() - await selectAdminRole(user) - - expect(screen.getByRole('button', { name: /members\.sendInvite/i })).toBeEnabled() - }) - - it('should not close modal when invite request fails', async () => { - const user = userEvent.setup() - vi.mocked(inviteMember).mockRejectedValue(new Error('request failed')) - - renderModal() - - fillEmails('user@example.com') - await selectAdminRole(user) - await user.click(screen.getByRole('button', { name: /members\.sendInvite/i })) await waitFor(() => { - expect(inviteMember).toHaveBeenCalled() - expect(mockOnCancel).not.toHaveBeenCalled() - expect(mockOnSend).not.toHaveBeenCalled() + expect(screen.getByRole('textbox', { name: /members\.emailRecipients/i })).toHaveFocus() }) }) - it('should send invites and close modal on successful submission', async () => { + it('should focus the email field first when the untouched form is submitted with Enter', async () => { const user = userEvent.setup() - vi.mocked(inviteMember).mockResolvedValue({ - result: 'success', - invitation_results: [], - } as InvitationResponse) - renderModal() - fillEmails('user@example.com') - await selectAdminRole(user) - await user.click(screen.getByRole('button', { name: /members\.sendInvite/i })) + const input = screen.getByRole('textbox', { name: /members\.emailRecipients/i }) + await waitFor(() => expect(input).toHaveFocus()) + await user.keyboard('{Enter}') - await waitFor(() => { - expect(inviteMember).toHaveBeenCalled() - expect(mockRefreshLicenseLimit).toHaveBeenCalled() - expect(mockOnCancel).toHaveBeenCalled() - expect(mockOnSend).toHaveBeenCalled() - }) + expect(input).toHaveFocus() + expect(input).toHaveAttribute('aria-invalid', 'true') + expect(screen.getByText(/members\.emailRequired/i)).toBeInTheDocument() + expect(inviteMember).not.toHaveBeenCalled() }) - it('should submit the selected workspace role id', async () => { + it('does not render dialog content while controlled closed', () => { + renderModal({ open: false }) + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + }) + + it('shows the email service warning in the form', () => { + renderModal({ isEmailSetup: false }) + + expect(screen.getByText(/members\.emailNotSetup/i)).toBeInTheDocument() + }) + + it('submits normalized, deduplicated recipients with the selected Role id', async () => { const user = userEvent.setup() - vi.mocked(inviteMember).mockResolvedValue({ + inviteMember.mockResolvedValue({ result: 'success', invitation_results: [], - } as InvitationResponse) - + tenant_id: 'tenant-id', + } satisfies MemberInviteResponse) renderModal() - fillEmails('user@example.com') + await addRecipients(user, 'First@Example.com, second@example.com; first@example.com') await selectAdminRole(user) - await user.click(screen.getByRole('button', { name: /members\.sendInvite/i })) + await user.click(screen.getByRole('button', { name: /members\.sendInviteCount/i })) await waitFor(() => { - expect(inviteMember).toHaveBeenCalledWith({ - url: '/workspaces/current/members/invite-email', + expect(inviteMember.mock.calls[0]?.[0]).toEqual({ body: { - emails: ['user@example.com'], + emails: ['first@example.com', 'second@example.com'], + role: 'admin', + language: 'en-US', + }, + }) + }) + expect(refreshLicenseLimit).toHaveBeenCalled() + expect(onOpenChange).toHaveBeenCalledWith(false) + expect(onSend).toHaveBeenCalledWith([]) + }) + + it('submits a valid draft without requiring Enter or blur to create a chip', async () => { + const user = userEvent.setup() + inviteMember.mockResolvedValue({ + result: 'success', + invitation_results: [], + tenant_id: 'tenant-id', + } satisfies MemberInviteResponse) + renderModal() + + await selectAdminRole(user) + const input = screen.getByRole('textbox', { name: /members\.emailRecipients/i }) + await user.type(input, 'draft@example.com') + await user.click(screen.getByRole('button', { name: /members\.sendInvite/i })) + + await waitFor(() => { + expect(inviteMember).toHaveBeenCalledOnce() + expect(inviteMember.mock.calls[0]?.[0]).toEqual({ + body: { + emails: ['draft@example.com'], role: 'admin', language: 'en-US', }, @@ -236,238 +216,407 @@ describe('InviteModal', () => { }) }) - it('should invalidate members after successful submission', async () => { + it('should submit a manually typed email list without requiring Enter', async () => { const user = userEvent.setup() - const queryClient = createQueryClient() - const membersQueryKey = [...commonQueryKeys.members, 'en-US'] - queryClient.setQueryData(membersQueryKey, { accounts: [] }) - vi.mocked(inviteMember).mockResolvedValue({ + inviteMember.mockResolvedValue({ result: 'success', invitation_results: [], - } as InvitationResponse) + tenant_id: 'tenant-id', + } satisfies MemberInviteResponse) + renderModal() - renderModal(true, queryClient) - - fillEmails('user@example.com') await selectAdminRole(user) + const input = screen.getByRole('textbox', { name: /members\.emailRecipients/i }) + await user.type(input, 'first@gmail.com,second@gmail.com') await user.click(screen.getByRole('button', { name: /members\.sendInvite/i })) await waitFor(() => { - expect(queryClient.getQueryState(membersQueryKey)?.isInvalidated).toBe(true) + expect(inviteMember).toHaveBeenCalledOnce() + expect(inviteMember.mock.calls[0]?.[0]).toEqual({ + body: { + emails: ['first@gmail.com', 'second@gmail.com'], + role: 'admin', + language: 'en-US', + }, + }) }) }) - it('should keep send button disabled when license limit is exceeded', async () => { - vi.mocked(useProviderContextSelector).mockImplementation((selector) => - selector({ - licenseLimit: { workspace_members: { size: 10, limit: 10 } }, - refreshLicenseLimit: mockRefreshLicenseLimit, - } as unknown as Parameters[0]), + it('should create exactly one non-empty recipient when a single draft is submitted', async () => { + const user = userEvent.setup() + let resolveInvite!: (response: MemberInviteResponse) => void + inviteMember.mockReturnValue( + new Promise((resolve) => { + resolveInvite = resolve + }), ) - renderModal() - fillEmails('user@example.com') - - expect(screen.getByRole('button', { name: /members\.sendInvite/i })).toBeDisabled() - }) - - it('should call onCancel when close icon is clicked', async () => { - const user = userEvent.setup() - renderModal() - - await user.click(screen.getByRole('button', { name: /Close|operation.close/ })) - - expect(mockOnCancel).toHaveBeenCalled() - }) - - it('should show error notification for invalid email submission', async () => { - const user = userEvent.setup() - renderModal() - - // Use an email that passes basic validation but fails our strict regex (needs 2+ char TLD) - fillEmails('invalid@email.c') await selectAdminRole(user) + const input = screen.getByRole('textbox', { name: /members\.emailRecipients/i }) + await user.type(input, 'only@gmail.com') await user.click(screen.getByRole('button', { name: /members\.sendInvite/i })) - expect(toast.error).toHaveBeenCalledWith('common.members.emailInvalid') - expect(inviteMember).not.toHaveBeenCalled() + await waitFor(() => { + expect(screen.getAllByRole('listitem')).toHaveLength(1) + expect(screen.getByText('only@gmail.com')).toBeInTheDocument() + expect(input).toHaveValue('') + }) + + await act(async () => { + resolveInvite({ + result: 'success', + invitation_results: [], + tenant_id: 'tenant-id', + }) + }) }) - it('should remove email from list when remove icon is clicked', async () => { + it('should commit a non-empty draft before Enter submits the form', async () => { const user = userEvent.setup() - renderModal() - - fillEmails('user@example.com') - - expect(screen.getByText('user@example.com')).toBeInTheDocument() - - const removeBtn = screen.getByRole('button', { name: /operation\.remove.*user@example\.com/i }) - await user.click(removeBtn) - - expect(screen.queryByText('user@example.com')).not.toBeInTheDocument() - }) - - it('should show unlimited label when workspace member limit is zero', async () => { - vi.mocked(useProviderContextSelector).mockImplementation((selector) => - selector({ - licenseLimit: { workspace_members: { size: 5, limit: 0 } }, - refreshLicenseLimit: mockRefreshLicenseLimit, - } as unknown as Parameters[0]), - ) - - renderModal() - - expect(await screen.findByText(/license\.unlimited/i)).toBeInTheDocument() - }) - - it('should initialize usedSize to zero when workspace_members.size is null', async () => { - vi.mocked(useProviderContextSelector).mockImplementation((selector) => - selector({ - licenseLimit: { workspace_members: { size: null, limit: 10 } }, - refreshLicenseLimit: mockRefreshLicenseLimit, - } as unknown as Parameters[0]), - ) - - renderModal() - - // usedSize starts at 0 (via ?? 0 fallback), no emails added → counter shows 0 - expect(await screen.findByText('0')).toBeInTheDocument() - }) - - it('should not call onSend when invite result is not success', async () => { - const user = userEvent.setup() - vi.mocked(inviteMember).mockResolvedValue({ - result: 'error', + inviteMember.mockResolvedValue({ + result: 'success', invitation_results: [], - } as unknown as InvitationResponse) - + tenant_id: 'tenant-id', + } satisfies MemberInviteResponse) renderModal() - fillEmails('user@example.com') + await selectAdminRole(user) + const input = screen.getByRole('textbox', { name: /members\.emailRecipients/i }) + await user.type(input, 'draft@example.com{Enter}') + + expect(screen.getByText('draft@example.com')).toBeInTheDocument() + expect(input).toHaveValue('') + expect(input).toHaveFocus() + expect(inviteMember).not.toHaveBeenCalled() + + await user.keyboard('{Enter}') + + await waitFor(() => { + expect(inviteMember).toHaveBeenCalledOnce() + expect(inviteMember.mock.calls[0]?.[0]).toEqual({ + body: { + emails: ['draft@example.com'], + role: 'admin', + language: 'en-US', + }, + }) + }) + }) + + it('accepts an address allowed by the browser without requiring a dotted domain', async () => { + const user = userEvent.setup() + inviteMember.mockResolvedValue({ + result: 'success', + invitation_results: [], + tenant_id: 'tenant-id', + } satisfies MemberInviteResponse) + renderModal() + + const input = screen.getByRole('textbox', { name: /members\.emailRecipients/i }) + await user.type(input, 'person@example{Enter}') + await selectAdminRole(user) + await user.click(screen.getByRole('button', { name: /members\.sendInvite/i })) + + await waitFor(() => expect(inviteMember).toHaveBeenCalled()) + }) + + it('keeps invalid recipients visible and blocks the whole submission', async () => { + const user = userEvent.setup() + renderModal() + + await addRecipients(user, 'valid@example.com, invalid-email') + await selectAdminRole(user) + await user.click(screen.getByRole('button', { name: /members\.sendInvite/i })) + + expect(screen.getByText('invalid-email')).toBeInTheDocument() + expect(screen.getAllByText(/members\.emailInvalid/i)).not.toHaveLength(0) + expect(inviteMember).not.toHaveBeenCalled() + }) + + it('should preserve and refocus an invalid draft until the user corrects it', async () => { + const user = userEvent.setup() + inviteMember.mockResolvedValue({ + result: 'success', + invitation_results: [], + tenant_id: 'tenant-id', + } satisfies MemberInviteResponse) + renderModal() + + await selectAdminRole(user) + const input = screen.getByRole('textbox', { name: /members\.emailRecipients/i }) + await user.type(input, 'not-an-email') + await user.click(screen.getByRole('button', { name: /members\.sendInvite/i })) + + expect(input).toHaveValue('not-an-email') + expect(input).toHaveFocus() + expect(input).toHaveAttribute('aria-invalid', 'true') + expect(screen.getByText(/members\.emailInvalid/i)).toBeInTheDocument() + expect(inviteMember).not.toHaveBeenCalled() + + await user.clear(input) + await user.type(input, 'corrected@example.com') + await user.click(screen.getByRole('button', { name: /members\.sendInvite/i })) + + await waitFor(() => { + expect(inviteMember).toHaveBeenCalledOnce() + expect(inviteMember.mock.calls[0]?.[0]).toEqual({ + body: { + emails: ['corrected@example.com'], + role: 'admin', + language: 'en-US', + }, + }) + }) + }) + + it('shows the required error and focuses the email field after an empty submission', async () => { + const user = userEvent.setup() + renderModal() + + await selectAdminRole(user) + await user.click(screen.getByRole('button', { name: /members\.sendInvite/i })) + + expect(screen.getByText(/members\.emailRequired/i)).toBeInTheDocument() + expect(screen.getByRole('textbox', { name: /members\.emailRecipients/i })).toHaveFocus() + expect(inviteMember).not.toHaveBeenCalled() + }) + + it('should preserve the email draft while the user resolves a missing role', async () => { + const user = userEvent.setup() + inviteMember.mockResolvedValue({ + result: 'success', + invitation_results: [], + tenant_id: 'tenant-id', + } satisfies MemberInviteResponse) + renderModal() + + const input = screen.getByRole('textbox', { name: /members\.emailRecipients/i }) + await user.type(input, 'draft@example.com') + await user.click(screen.getByRole('button', { name: /members\.sendInvite/i })) + + const roleTrigger = screen.getByRole('combobox', { name: /members\.role/i }) + expect(roleTrigger).toHaveFocus() + expect(roleTrigger).toHaveAttribute('aria-invalid', 'true') + expect(input).toHaveValue('draft@example.com') + expect(inviteMember).not.toHaveBeenCalled() + + await user.click(roleTrigger) + await user.click(screen.getByRole('option', { name: /Admin/i })) + await user.click(screen.getByRole('button', { name: /members\.sendInvite/i })) + + await waitFor(() => { + expect(inviteMember).toHaveBeenCalledOnce() + expect(inviteMember.mock.calls[0]?.[0]).toEqual({ + body: { + emails: ['draft@example.com'], + role: 'admin', + language: 'en-US', + }, + }) + }) + }) + + it('freezes all editable controls while invitations are being sent', async () => { + const user = userEvent.setup() + let resolveInvite!: (response: MemberInviteResponse) => void + inviteMember.mockReturnValue( + new Promise((resolve) => { + resolveInvite = resolve + }), + ) + renderModal() + + await addRecipients(user, 'user@example.com, another@example.com') await selectAdminRole(user) await user.click(screen.getByRole('button', { name: /members\.sendInvite/i })) await waitFor(() => { - expect(inviteMember).toHaveBeenCalled() - expect(mockOnSend).not.toHaveBeenCalled() - expect(mockOnCancel).not.toHaveBeenCalled() + expect(screen.getByRole('textbox', { name: /members\.emailRecipients/i })).toBeDisabled() + expect(screen.getByRole('combobox', { name: /members\.role/i })).toBeDisabled() + expect( + screen.getByRole('button', { name: /operation\.remove.*user@example\.com/i }), + ).toBeDisabled() + expect(screen.getByRole('button', { name: /members\.sendInvite/i })).toHaveAttribute( + 'aria-disabled', + 'true', + ) + }) + expect(inviteMember).toHaveBeenCalledOnce() + + await act(async () => { + resolveInvite({ + result: 'success', + invitation_results: [], + tenant_id: 'tenant-id', + }) }) }) - it('should show destructive text color when used size exceeds limit', async () => { + it('warns but lets the backend decide whether recipients consume remaining seats', async () => { + const user = userEvent.setup() vi.mocked(useProviderContextSelector).mockImplementation((selector) => selector({ - licenseLimit: { workspace_members: { size: 10, limit: 10 } }, - refreshLicenseLimit: mockRefreshLicenseLimit, + licenseLimit: { workspace_members: { size: 9, limit: 10 } }, + refreshLicenseLimit, } as unknown as Parameters[0]), ) - + inviteMember.mockResolvedValue({ + result: 'success', + invitation_results: [], + tenant_id: 'tenant-id', + } satisfies MemberInviteResponse) renderModal() - fillEmails('user@example.com') - - // usedSize = 10 + 1 = 11 > limit 10 → destructive color - const counter = screen.getByText('11') - expect(counter.closest('div')).toHaveClass('text-text-destructive') - }) - - it('should not submit if already submitting', async () => { - const user = userEvent.setup() - let resolveInvite: (value: InvitationResponse) => void - const invitePromise = new Promise((resolve) => { - resolveInvite = resolve - }) - vi.mocked(inviteMember).mockReturnValue(invitePromise) - - renderModal() - - fillEmails('user@example.com') + await addRecipients(user, 'one@example.com, two@example.com') await selectAdminRole(user) - const sendBtn = screen.getByRole('button', { name: /members\.sendInvite/i }) + expect(screen.getByText(/members\.recipientCountExceedsSeats/i)).toBeInTheDocument() + expect(screen.getByRole('button', { name: /members\.sendInvite/i })).toBeEnabled() - // First click - await user.click(sendBtn) - expect(inviteMember).toHaveBeenCalledTimes(1) - - // Second click while submitting. - // userEvent will skip this click because the button is disabled. - await user.click(sendBtn) - expect(inviteMember).toHaveBeenCalledTimes(1) - - // Resolve first - resolveInvite!({ result: 'success', invitation_results: [] }) - - await waitFor(() => { - expect(mockOnCancel).toHaveBeenCalled() - }) + await user.click(screen.getByRole('button', { name: /members\.sendInvite/i })) + await waitFor(() => expect(inviteMember).toHaveBeenCalled()) }) - it('should show destructive color and disable send button when limit is exactly met with one email', async () => { - // size=10, limit=10 - adding 1 email makes usedSize=11 > limit=10 - vi.mocked(useProviderContextSelector).mockImplementation((selector) => - selector({ - licenseLimit: { workspace_members: { size: 10, limit: 10 } }, - refreshLicenseLimit: mockRefreshLicenseLimit, - } as unknown as Parameters[0]), - ) - - renderModal() - - fillEmails('user@example.com') - - // isLimitExceeded=true → button is disabled, cannot submit - const sendBtn = screen.getByRole('button', { name: /members\.sendInvite/i }) - expect(sendBtn).toBeDisabled() - expect(inviteMember).not.toHaveBeenCalled() - }) - - it('should hit isSubmitting guard inside handleSend when button is force-clicked during submission', async () => { + it('counts a manually typed recipient list before it is committed', async () => { const user = userEvent.setup() - let resolveInvite: (value: InvitationResponse) => void - const invitePromise = new Promise((resolve) => { - resolveInvite = resolve - }) - vi.mocked(inviteMember).mockReturnValue(invitePromise) - + vi.mocked(useProviderContextSelector).mockImplementation((selector) => + selector({ + licenseLimit: { workspace_members: { size: 9, limit: 10 } }, + refreshLicenseLimit, + } as unknown as Parameters[0]), + ) renderModal() - fillEmails('user@example.com') + const input = screen.getByRole('textbox', { name: /members\.emailRecipients/i }) + await user.type(input, 'one@example.com,two@example.com') + + expect( + screen.getByRole('button', { name: /members\.sendInviteCount.*"count":2/i }), + ).toBeEnabled() + expect(screen.getByText(/members\.recipientCountExceedsSeats/i)).toBeInTheDocument() + expect( + screen.queryByRole('list', { name: /members\.emailRecipients/i }), + ).not.toBeInTheDocument() + expect(input).toHaveValue('one@example.com,two@example.com') + }) + + it.each([ + ['limit_exceeded', /members\.inviteLimitExceeded/i, 'emails', 'textbox'], + ['invalid-role', /members\.invalidRole/i, 'role', 'combobox'], + ])('maps %s server validation to the owning field', async (code, message, fieldName, role) => { + const user = userEvent.setup() + inviteMember.mockRejectedValue({ + code: 'BAD_REQUEST', + data: { body: { code, message: 'Backend message' } }, + }) + renderModal() + + await addRecipients(user, 'user@example.com, another@example.com') await selectAdminRole(user) + await user.click(screen.getByRole('button', { name: /members\.sendInvite/i })) - const sendBtn = screen.getByRole('button', { name: /members\.sendInvite/i }) + expect(await screen.findByText(message)).toBeInTheDocument() + expect(document.querySelector(`[name="${fieldName}"]`)).toHaveAttribute('aria-invalid', 'true') + expect( + screen.getByRole(role, { + name: fieldName === 'emails' ? /members\.emailRecipients/i : /members\.role/i, + }), + ).toHaveFocus() + expect(onOpenChange).not.toHaveBeenCalled() + }) - // First click starts submission - await user.click(sendBtn) - expect(inviteMember).toHaveBeenCalledTimes(1) + it('should clear an email server error when the user edits and successfully retries', async () => { + const user = userEvent.setup() + inviteMember + .mockRejectedValueOnce({ + code: 'BAD_REQUEST', + data: { body: { code: 'limit_exceeded', message: 'Backend message' } }, + }) + .mockResolvedValueOnce({ + result: 'success', + invitation_results: [], + tenant_id: 'tenant-id', + } satisfies MemberInviteResponse) + renderModal() - // Force-click bypasses disabled attribute → hits isSubmitting guard in handleSend - fireEvent.click(sendBtn) - expect(inviteMember).toHaveBeenCalledTimes(1) + await addRecipients(user, 'first@example.com, second@example.com') + await selectAdminRole(user) + await user.click(screen.getByRole('button', { name: /members\.sendInvite/i })) + + expect(await screen.findByText(/members\.inviteLimitExceeded/i)).toBeInTheDocument() + + const input = screen.getByRole('textbox', { name: /members\.emailRecipients/i }) + await user.type(input, 'third@example.com') + expect(screen.queryByText(/members\.inviteLimitExceeded/i)).not.toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: /members\.sendInvite/i })) - // Cleanup - resolveInvite!({ result: 'success', invitation_results: [] }) await waitFor(() => { - expect(mockOnCancel).toHaveBeenCalled() + expect(inviteMember).toHaveBeenCalledTimes(2) + expect(inviteMember.mock.calls[1]?.[0]).toEqual({ + body: { + emails: ['first@example.com', 'second@example.com', 'third@example.com'], + role: 'admin', + language: 'en-US', + }, + }) }) }) - it('should not show error text color when isLimited is false even with many emails', async () => { - // size=0, limit=0 → isLimited=false, usedSize=emails.length - vi.mocked(useProviderContextSelector).mockImplementation((selector) => - selector({ - licenseLimit: { workspace_members: { size: 0, limit: 0 } }, - refreshLicenseLimit: mockRefreshLicenseLimit, - } as unknown as Parameters[0]), - ) - + it('keeps unknown request failures as a persistent form error', async () => { + const user = userEvent.setup() + inviteMember.mockRejectedValue(new Error('Network failed')) renderModal() - fillEmails('user@example.com') + await addRecipients(user, 'user@example.com, another@example.com') + await selectAdminRole(user) + await user.click(screen.getByRole('button', { name: /members\.sendInvite/i })) - // isLimited=false → no destructive color - const counter = screen.getByText('1') - expect(counter.closest('div')).not.toHaveClass('text-text-destructive') + expect(await screen.findByRole('alert')).toHaveTextContent(/members\.inviteFailed/i) + expect(onOpenChange).not.toHaveBeenCalled() + }) + + it('routes close actions through the controlled state owner', async () => { + const user = userEvent.setup() + renderModal() + + await user.click(screen.getByRole('button', { name: /operation\.close/i })) + + expect(onOpenChange).toHaveBeenCalledWith(false) + }) + + it('resets the form after a controlled close', async () => { + const user = userEvent.setup() + const queryClient = createQueryClient() + const ControlledInviteModal = () => { + const [open, setOpen] = useState(false) + + return ( + + members.invite} + isEmailSetup + onOpenChange={setOpen} + onSend={onSend} + /> + + ) + } + render() + + const trigger = screen.getByRole('button', { name: /members\.invite$/i }) + await user.click(trigger) + await addRecipients(user, 'person@example.com, another@example.com') + await user.click(screen.getByRole('button', { name: /operation\.close/i })) + + await waitFor(() => expect(trigger).toHaveFocus()) + + await user.click(trigger) + expect(screen.queryByText('person@example.com')).not.toBeInTheDocument() + expect(screen.getByRole('combobox', { name: /members\.role/i })).toHaveTextContent( + /members\.selectRole/i, + ) }) }) diff --git a/web/app/components/header/account-setting/members-page/invite-modal/__tests__/role-selector.spec.tsx b/web/app/components/header/account-setting/members-page/invite-modal/__tests__/role-selector.spec.tsx index a0e4b35fc74..732a6533127 100644 --- a/web/app/components/header/account-setting/members-page/invite-modal/__tests__/role-selector.spec.tsx +++ b/web/app/components/header/account-setting/members-page/invite-modal/__tests__/role-selector.spec.tsx @@ -4,7 +4,7 @@ import userEvent from '@testing-library/user-event' import { useState } from 'react' import { vi } from 'vitest' import { useWorkspaceRoleList } from '@/service/access-control/use-workspace-roles' -import RoleSelector from '../role-selector' +import { RoleSelector } from '../role-selector' vi.mock('@/service/access-control/use-workspace-roles') @@ -21,59 +21,50 @@ const createRole = (overrides: Partial): Role => ({ ...overrides, }) -const rolePages: RoleListResponse[] = [ - { - data: [ - createRole({ id: 'admin', name: 'Admin', description: 'Can manage workspace settings' }), - createRole({ id: 'editor', name: 'Editor', description: 'Can build and edit apps' }), - createRole({ id: 'normal', name: 'Normal', description: 'Can use apps' }), - ], - pagination: { - total_count: 3, - per_page: 20, - current_page: 1, - total_pages: 1, - }, - }, +const roles = [ + createRole({ id: 'admin', name: 'Admin', description: 'Can manage workspace settings' }), + createRole({ id: 'editor', name: 'Editor', description: 'Can build and edit apps' }), + createRole({ id: 'normal', name: 'Normal', description: 'Can use apps' }), ] +const createPage = (data: Role[]): RoleListResponse => ({ + data, + pagination: { + total_count: data.length, + per_page: 20, + current_page: 1, + total_pages: data.length > 0 ? 1 : 0, + }, +}) + const mockUseWorkspaceRoleList = ({ - pages = rolePages, + data = roles, isLoading = false, hasNextPage = false, - isFetchingNextPage = false, fetchNextPage = vi.fn(), }: { - pages?: RoleListResponse[] + data?: Role[] isLoading?: boolean hasNextPage?: boolean - isFetchingNextPage?: boolean fetchNextPage?: () => void } = {}) => { vi.mocked(useWorkspaceRoleList).mockReturnValue({ - data: { pages, pageParams: [1] }, + data: { pages: [createPage(data)], pageParams: [1] }, isLoading, error: null, hasNextPage, - isFetchingNextPage, + isFetchingNextPage: false, fetchNextPage, } as unknown as ReturnType) } -type WrapperProps = { - initialRole?: string -} - -const RoleSelectorWrapper = ({ initialRole = 'normal' }: WrapperProps) => { - const [role, setRole] = useState(initialRole) +const RoleSelectorWrapper = ({ initialRole = null }: { initialRole?: Role | null }) => { + const [role, setRole] = useState(initialRole) return } -const getTrigger = () => - screen.getByRole('button', { name: /members\.(invitedAsRole|selectRole)/i }) -const getRoleMenu = () => screen.getByRole('menu') -const getRoleOption = (role: string) => - within(getRoleMenu()).getByRole('menuitemradio', { name: new RegExp(role, 'i') }) +const getTrigger = () => screen.getByRole('combobox', { name: /members\.role/i }) +const getListbox = () => screen.getByRole('listbox', { name: /members\.role/i }) describe('RoleSelector', () => { beforeEach(() => { @@ -81,92 +72,65 @@ describe('RoleSelector', () => { mockUseWorkspaceRoleList() }) - it('should show current role name in trigger text', () => { - render() + it('requires an explicit selection and exposes select semantics', () => { + render() - expect(screen.getByText(/members\.invitedAsRole:\{"role":"Admin"\}/i)).toBeInTheDocument() + expect(getTrigger()).toHaveTextContent(/members\.selectRole/i) + expect(document.querySelector('input[name="role"]')).toBeRequired() }) - it('should ask users to select a role when no role is selected', () => { - render() - - expect(screen.getByText(/members\.selectRole/i)).toBeInTheDocument() - expect(screen.queryByText(/members\.invitedAsRole:\{"role":""\}/i)).not.toBeInTheDocument() - }) - - it('should toggle dropdown when trigger is clicked', async () => { + it('uses the selected Role object as its typed value', async () => { const user = userEvent.setup() render() await user.click(getTrigger()) - expect(getRoleOption('Normal')).toBeInTheDocument() + await user.click(within(getListbox()).getByRole('option', { name: /Admin/i })) - await user.click(getTrigger()) - await waitFor(() => { - expect(screen.queryByRole('menu')).not.toBeInTheDocument() - }) + expect(getTrigger()).toHaveTextContent('Admin') + expect(document.querySelector('input[name="role"]')).toHaveValue('admin') }) - it('should show checkmark state for selected role', async () => { + it('shows role descriptions in the option list', async () => { const user = userEvent.setup() - render() + render() await user.click(getTrigger()) - expect(getRoleOption('Editor')).toHaveAttribute('aria-checked', 'true') + expect(within(getListbox()).getByText('Can manage workspace settings')).toBeInTheDocument() }) - it('should show legacy descriptions for built-in roles without descriptions', async () => { + it('falls back to localized descriptions for legacy built-in roles', async () => { const user = userEvent.setup() - mockUseWorkspaceRoleList({ - pages: [ - { - data: [ - createRole({ id: 'admin', name: 'admin', description: '' }), - createRole({ id: 'editor', name: 'editor', description: '' }), - createRole({ id: 'normal', name: 'normal', description: '' }), - createRole({ id: 'dataset_operator', name: 'dataset_operator', description: '' }), - ], - pagination: { - total_count: 4, - per_page: 20, - current_page: 1, - total_pages: 1, - }, - }, + data: [ + createRole({ id: 'admin', name: 'admin', description: '' }), + createRole({ id: 'dataset_operator', name: 'dataset_operator', description: '' }), ], }) - - render() + render() await user.click(getTrigger()) - const roleMenu = getRoleMenu() - - expect(within(roleMenu).getByText(/common\.members\.adminTip/i)).toBeInTheDocument() - expect(within(roleMenu).getByText(/common\.members\.editorTip/i)).toBeInTheDocument() - expect(within(roleMenu).getByText(/common\.members\.normalTip/i)).toBeInTheDocument() - expect(within(roleMenu).getByText(/common\.members\.datasetOperatorTip/i)).toBeInTheDocument() - expect(within(roleMenu).queryByText(/permission\.role\.noDescription/i)).not.toBeInTheDocument() + expect(within(getListbox()).getByText(/common\.members\.adminTip/i)).toBeInTheDocument() + expect( + within(getListbox()).getByText(/common\.members\.datasetOperatorTip/i), + ).toBeInTheDocument() }) - it('should update selected role name after user chooses a role', async () => { + it('renders loading and empty states inside the select popup', async () => { const user = userEvent.setup() - - render() + mockUseWorkspaceRoleList({ data: [], isLoading: true }) + const { rerender } = render() await user.click(getTrigger()) - await user.click(getRoleOption('Admin')) + expect(within(getListbox()).getByText(/common\.loading/i)).toBeInTheDocument() - await waitFor(() => { - expect(screen.queryByRole('menu')).not.toBeInTheDocument() - }) - - expect(screen.getByText(/members\.invitedAsRole:\{"role":"Admin"\}/i)).toBeInTheDocument() + mockUseWorkspaceRoleList({ data: [] }) + rerender() + expect(within(getListbox()).getByText(/dynamicSelect\.noData/i)).toBeInTheDocument() }) - it('should load more roles when scrolling reaches the list anchor', async () => { + it('loads another role page when the list sentinel becomes visible', async () => { const user = userEvent.setup() const fetchNextPage = vi.fn() const callbacks: IntersectionObserverCallback[] = [] @@ -174,36 +138,27 @@ describe('RoleSelector', () => { globalThis.IntersectionObserver = class { readonly root: Element | Document | null = null - readonly rootMargin: string = '' - readonly scrollMargin: string = '' + readonly rootMargin = '' + readonly scrollMargin = '' readonly thresholds: ReadonlyArray = [] constructor(callback: IntersectionObserverCallback) { callbacks.push(callback) } - observe() { - /* noop */ - } - unobserve() { - /* noop */ - } - disconnect() { - /* noop */ - } + observe() {} + unobserve() {} + disconnect() {} takeRecords(): IntersectionObserverEntry[] { return [] } } mockUseWorkspaceRoleList({ hasNextPage: true, fetchNextPage }) - render() await user.click(getTrigger()) - await waitFor(() => { - expect(callbacks).toHaveLength(1) - }) + await waitFor(() => expect(callbacks).toHaveLength(1)) await act(async () => { callbacks[0]!( @@ -213,30 +168,6 @@ describe('RoleSelector', () => { }) expect(fetchNextPage).toHaveBeenCalledTimes(1) - globalThis.IntersectionObserver = originalIntersectionObserver }) - - it('should render an empty state when there are no roles', async () => { - const user = userEvent.setup() - mockUseWorkspaceRoleList({ - pages: [ - { - data: [], - pagination: { - total_count: 0, - per_page: 20, - current_page: 1, - total_pages: 0, - }, - }, - ], - }) - - render() - - await user.click(getTrigger()) - - expect(within(getRoleMenu()).getByText(/dynamicSelect\.noData/i)).toBeInTheDocument() - }) }) diff --git a/web/app/components/header/account-setting/members-page/invite-modal/email-recipients-field.tsx b/web/app/components/header/account-setting/members-page/invite-modal/email-recipients-field.tsx new file mode 100644 index 00000000000..7e6d0a27d00 --- /dev/null +++ b/web/app/components/header/account-setting/members-page/invite-modal/email-recipients-field.tsx @@ -0,0 +1,331 @@ +'use client' + +import type { KeyboardEvent, RefObject } from 'react' +import type { EmailRecipient } from './email-recipients' +import { cn } from '@langgenius/dify-ui/cn' +import { + Field, + FieldControl, + FieldDescription, + FieldError, + FieldLabel, +} from '@langgenius/dify-ui/field' +import { useEffect, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { hasEmailDelimiter, mergeEmailRecipients } from './email-recipients' + +type EmailRecipientsFieldProps = { + recipients: EmailRecipient[] + draft: string + onRecipientsChange: (recipients: EmailRecipient[]) => void + onDraftChange: (draft: string) => void + onChange?: () => void + error?: string + disabled?: boolean + inputRef?: RefObject +} + +function isRightToLeft(element: HTMLElement) { + return getComputedStyle(element).direction === 'rtl' || document.documentElement.dir === 'rtl' +} + +export function EmailRecipientsField({ + recipients, + draft, + onRecipientsChange, + onDraftChange, + onChange, + error, + disabled = false, + inputRef: externalInputRef, +}: EmailRecipientsFieldProps) { + const { t } = useTranslation() + const internalInputRef = useRef(null) + const chipButtonRef = useRef>([]) + const selectDraftOnRenderRef = useRef(false) + const [draftTouched, setDraftTouched] = useState(false) + const inputRef = externalInputRef ?? internalInputRef + const hasInvalidRecipient = recipients.some(({ isValid }) => !isValid) + const draftRecipients = mergeEmailRecipients([], draft) + const hasInvalidDraft = Boolean( + draftTouched && draft.trim() && draftRecipients.some(({ isValid }) => !isValid), + ) + const fieldError = + hasInvalidRecipient || hasInvalidDraft + ? t(($) => $['members.emailInvalid'], { ns: 'common' }) + : error + + const validateRecipients = (value: unknown) => { + const nextDraft = typeof value === 'string' ? value : draft + const nextRecipients = mergeEmailRecipients(recipients, nextDraft) + + if (nextRecipients.length === 0) return t(($) => $['members.emailRequired'], { ns: 'common' }) + + if (nextRecipients.some(({ isValid }) => !isValid)) + return t(($) => $['members.emailInvalid'], { ns: 'common' }) + + return null + } + + const updateRecipients = (nextRecipients: EmailRecipient[]) => { + onRecipientsChange(nextRecipients) + onChange?.() + } + + const updateDraft = (nextDraft: string) => { + onDraftChange(nextDraft) + onChange?.() + } + + const focusInput = (select = false) => { + inputRef.current?.focus() + if (select) { + selectDraftOnRenderRef.current = true + inputRef.current?.select() + } + } + + useEffect(() => { + if (!selectDraftOnRenderRef.current) return + + inputRef.current?.select() + selectDraftOnRenderRef.current = false + }, [draft, inputRef]) + + const removeRecipient = (index: number, focus: 'input' | 'neighbor') => { + const nextRecipients = recipients.filter((_, recipientIndex) => recipientIndex !== index) + const nextFocusIndex = index < nextRecipients.length ? index : index - 1 + const neighborIndex = index < recipients.length - 1 ? index + 1 : index - 1 + const neighbor = neighborIndex >= 0 ? chipButtonRef.current[neighborIndex] : null + + updateRecipients(nextRecipients) + + if (focus === 'neighbor' && nextFocusIndex >= 0) { + neighbor?.focus() + return + } + + focusInput() + } + + const editRecipient = (index: number) => { + const recipient = recipients[index] + if (!recipient) return + + updateRecipients(recipients.filter((_, recipientIndex) => recipientIndex !== index)) + updateDraft(recipient.value) + setDraftTouched(false) + focusInput(true) + } + + const commitDraft = () => { + if (!draft.trim()) return + + if (draftRecipients.some(({ isValid }) => !isValid)) { + setDraftTouched(true) + return + } + + updateRecipients(mergeEmailRecipients(recipients, draft)) + updateDraft('') + setDraftTouched(false) + } + + const handleChipKeyDown = ( + event: KeyboardEvent, + index: number, + isValid: boolean, + ) => { + if (disabled) return + + if (event.key === 'Backspace' || event.key === 'Delete') { + event.preventDefault() + removeRecipient(index, 'neighbor') + return + } + + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + if (isValid) focusInput() + else editRecipient(index) + return + } + + const rtl = isRightToLeft(event.currentTarget) + const previousKey = rtl ? 'ArrowRight' : 'ArrowLeft' + const nextKey = rtl ? 'ArrowLeft' : 'ArrowRight' + + if (event.key === previousKey && index > 0) { + event.preventDefault() + chipButtonRef.current[index - 1]?.focus() + } else if (event.key === nextKey) { + event.preventDefault() + if (index < recipients.length - 1) chipButtonRef.current[index + 1]?.focus() + else focusInput() + } + } + + return ( + +
+ {t(($) => $['members.emailRecipients'], { ns: 'common' })} + {recipients.length > 0 && ( + + {t(($) => $['members.recipientCount'], { + ns: 'common', + count: recipients.length, + })} + + )} +
+
+ {recipients.length > 0 && ( +
    $['members.emailRecipients'], { ns: 'common' })} + className="contents" + > + {recipients.map(({ value, isValid }, index) => { + const errorId = `email-recipient-${encodeURIComponent(value)}-error` + + return ( +
  • + + + {!isValid && ( + + {t(($) => $['members.emailInvalid'], { ns: 'common' })} + + )} +
  • + ) + })} +
+ )} + $['members.emailPlaceholder'], { ns: 'common' }) || '' + : '' + } + value={draft} + className={cn( + 'h-6 w-0 flex-1 rounded-none border-0 bg-transparent p-0 shadow-none hover:border-transparent hover:bg-transparent focus:border-transparent focus:bg-transparent focus:shadow-none disabled:bg-transparent disabled:hover:bg-transparent data-invalid:border-transparent data-invalid:bg-transparent', + recipients.length > 0 ? 'min-w-12' : 'min-w-40', + )} + onChange={(event) => { + updateDraft(event.target.value) + setDraftTouched(false) + }} + onBlur={() => setDraftTouched(true)} + onKeyDown={(event) => { + if (event.nativeEvent.isComposing) { + if (event.key === 'Enter') event.preventDefault() + return + } + + if (event.key === 'Enter' && draft.trim()) { + event.preventDefault() + commitDraft() + return + } + + if (event.key === 'Backspace' && !draft && recipients.length > 0) { + event.preventDefault() + updateRecipients(recipients.slice(0, -1)) + return + } + + const rtl = isRightToLeft(event.currentTarget) + const chipNavigationKey = rtl ? 'ArrowRight' : 'ArrowLeft' + if (event.key === chipNavigationKey && !draft && recipients.length > 0) { + event.preventDefault() + chipButtonRef.current[recipients.length - 1]?.focus() + } + }} + onPaste={(event) => { + const pastedText = event.clipboardData.getData('text') + if (!hasEmailDelimiter(pastedText)) return + + event.preventDefault() + const input = event.currentTarget + const selectionStart = input.selectionStart ?? input.value.length + const selectionEnd = input.selectionEnd ?? selectionStart + const nextValue = `${input.value.slice(0, selectionStart)}${pastedText}${input.value.slice(selectionEnd)}` + updateRecipients(mergeEmailRecipients(recipients, nextValue)) + updateDraft('') + setDraftTouched(false) + }} + /> +
+ {fieldError ? ( + {fieldError} + ) : ( + <> + + + {t(($) => $['members.emailRecipientsTip'], { ns: 'common' })} + + + )} +
+ ) +} diff --git a/web/app/components/header/account-setting/members-page/invite-modal/email-recipients.ts b/web/app/components/header/account-setting/members-page/invite-modal/email-recipients.ts new file mode 100644 index 00000000000..53b69a5ccbc --- /dev/null +++ b/web/app/components/header/account-setting/members-page/invite-modal/email-recipients.ts @@ -0,0 +1,41 @@ +export type EmailRecipient = { + value: string + isValid: boolean +} + +const EMAIL_DELIMITER_PATTERN = /[,;\r\n\t]+/ + +function isEmailValid(value: string) { + const input = document.createElement('input') + input.type = 'email' + input.value = value + return input.validity.valid +} + +export function createEmailRecipient(input: string): EmailRecipient { + const value = input.trim().toLowerCase() + return { value, isValid: Boolean(value) && isEmailValid(value) } +} + +export function hasEmailDelimiter(value: string) { + return EMAIL_DELIMITER_PATTERN.test(value) +} + +export function mergeEmailRecipients(recipients: EmailRecipient[], input: string) { + const nextRecipients = [...recipients] + const existingValues = new Set(recipients.map(({ value }) => value)) + + input + .split(EMAIL_DELIMITER_PATTERN) + .map(createEmailRecipient) + .filter(({ value }) => Boolean(value)) + .forEach((recipient) => { + const { value } = recipient + if (existingValues.has(value)) return + + existingValues.add(value) + nextRecipients.push(recipient) + }) + + return nextRecipients +} diff --git a/web/app/components/header/account-setting/members-page/invite-modal/index.tsx b/web/app/components/header/account-setting/members-page/invite-modal/index.tsx index 1a31e8e23d1..8ad74244769 100644 --- a/web/app/components/header/account-setting/members-page/invite-modal/index.tsx +++ b/web/app/components/header/account-setting/members-page/invite-modal/index.tsx @@ -1,184 +1,231 @@ 'use client' -import type { InvitationResult } from '@/models/common' + +import type { MemberInviteResponse } from '@dify/contracts/api/console/workspaces/types.gen' +import type { FormProps } from '@langgenius/dify-ui/form' +import type { ReactElement } from 'react' +import type { EmailRecipient } from './email-recipients' +import type { Role } from '@/models/access-control' import { Button } from '@langgenius/dify-ui/button' -import { cn } from '@langgenius/dify-ui/cn' -import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog' -import { toast } from '@langgenius/dify-ui/toast' -import { useQueryClient } from '@tanstack/react-query' -import { useBoolean } from 'ahooks' -import { useCallback, useState } from 'react' +import { + Dialog, + DialogCloseButton, + DialogContent, + DialogDescription, + DialogTitle, + DialogTrigger, +} from '@langgenius/dify-ui/dialog' +import { Form } from '@langgenius/dify-ui/form' +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import { ReactMultiEmail } from 'react-multi-email' -import { emailRegex } from '@/config' import { useLocale } from '@/context/i18n' import { useProviderContextSelector } from '@/context/provider-context' -import { inviteMember } from '@/service/common' +import { consoleQuery } from '@/service/client' import { commonQueryKeys } from '@/service/use-common' -import RoleSelector from './role-selector' -import 'react-multi-email/dist/style.css' +import { mergeEmailRecipients } from './email-recipients' +import { EmailRecipientsField } from './email-recipients-field' +import { getInviteErrorCode } from './invite-error' +import { RoleSelector } from './role-selector' -type IInviteModalProps = { +type InviteModalProps = { + open: boolean + trigger: ReactElement isEmailSetup: boolean - onCancel: () => void - onSend: (invitationResults: InvitationResult[]) => void + onOpenChange: (open: boolean) => void + onSend: (invitationResults: MemberInviteResponse['invitation_results']) => void } -const InviteModal = ({ isEmailSetup, onCancel, onSend }: IInviteModalProps) => { +type SubmitError = { + target: 'emails' | 'role' | 'form' + message: string +} | null + +type FormSubmitHandler = NonNullable + +type InviteFormProps = Omit + +function InviteForm({ isEmailSetup, onOpenChange, onSend }: InviteFormProps) { const { t } = useTranslation() - const queryClient = useQueryClient() - const licenseLimit = useProviderContextSelector((s) => s.licenseLimit) - const refreshLicenseLimit = useProviderContextSelector((s) => s.refreshLicenseLimit) - const [emails, setEmails] = useState([]) - const isLimited = licenseLimit.workspace_members.limit > 0 - const usedSize = emails.length + licenseLimit.workspace_members.size - const isLimitExceeded = isLimited && usedSize > licenseLimit.workspace_members.limit - const locale = useLocale() - const [role, setRole] = useState('') + const queryClient = useQueryClient() + const licenseLimit = useProviderContextSelector((state) => state.licenseLimit) + const refreshLicenseLimit = useProviderContextSelector((state) => state.refreshLicenseLimit) + const [recipients, setRecipients] = useState([]) + const [draft, setDraft] = useState('') + const [role, setRole] = useState(null) + const [submitError, setSubmitError] = useState(null) + const emailInputRef = useRef(null) + const roleTriggerRef = useRef(null) + const emailServerError = submitError?.target === 'emails' ? submitError.message : undefined + const roleError = submitError?.target === 'role' ? submitError.message : undefined + const currentSize = licenseLimit.workspace_members.size ?? 0 + const memberLimit = licenseLimit.workspace_members.limit + const remainingSeats = memberLimit > 0 ? Math.max(memberLimit - currentSize, 0) : null + const effectiveRecipients = mergeEmailRecipients(recipients, draft) + const validRecipientCount = effectiveRecipients.filter(({ isValid }) => isValid).length + const exceedsRemainingSeats = remainingSeats !== null && validRecipientCount > remainingSeats + const formErrors = { + ...(emailServerError ? { emails: emailServerError } : {}), + ...(roleError ? { role: roleError } : {}), + } - const [isSubmitting, { setTrue: setIsSubmitting, setFalse: setIsSubmitted }] = useBoolean(false) + const { mutateAsync, isPending } = useMutation( + consoleQuery.workspaces.current.members.inviteEmail.post.mutationOptions({ + context: { silent: true }, + }), + ) - const handleSend = useCallback(async () => { - if (isLimitExceeded || isSubmitting) return - setIsSubmitting() - if (emails.map((email: string) => emailRegex.test(email)).every(Boolean)) { - try { - const { result, invitation_results } = await inviteMember({ - url: '/workspaces/current/members/invite-email', - body: { emails, role, language: locale }, - }) + const clearSubmitError = (target: 'emails' | 'role') => { + setSubmitError((error) => (error?.target === target ? null : error)) + } - if (result === 'success') { - refreshLicenseLimit() - void queryClient.invalidateQueries({ queryKey: commonQueryKeys.members }) - onCancel() - onSend(invitation_results) - } - } catch {} - } else { - toast.error(t(($) => $['members.emailInvalid'], { ns: 'common' })) + useEffect(() => { + if (submitError?.target === 'emails') emailInputRef.current?.focus() + if (submitError?.target === 'role') roleTriggerRef.current?.focus() + }, [submitError]) + + const handleSubmit: FormSubmitHandler = async (event) => { + event.preventDefault() + if (isPending) return + + setRecipients(effectiveRecipients) + setDraft('') + + if (!role) return + + setSubmitError(null) + try { + const response = await mutateAsync({ + body: { + emails: effectiveRecipients.map(({ value }) => value), + role: role.id, + language: locale, + }, + }) + + refreshLicenseLimit() + void queryClient.invalidateQueries({ queryKey: commonQueryKeys.members }) + onOpenChange(false) + onSend(response.invitation_results) + } catch (error) { + switch (getInviteErrorCode(error)) { + case 'limit_exceeded': + setSubmitError({ + target: 'emails', + message: t(($) => $['members.inviteLimitExceeded'], { ns: 'common' }), + }) + break + case 'invalid-role': + setSubmitError({ + target: 'role', + message: t(($) => $['members.invalidRole'], { ns: 'common' }), + }) + break + default: + setSubmitError({ + target: 'form', + message: t(($) => $['members.inviteFailed'], { ns: 'common' }), + }) + } } - setIsSubmitted() - }, [ - isLimitExceeded, - emails, - role, - locale, - onCancel, - onSend, - t, - isSubmitting, - refreshLicenseLimit, - queryClient, - setIsSubmitted, - setIsSubmitting, - ]) + } return ( - { - if (!open) onCancel() - }} +
$['members.inviteTeamMember'], { ns: 'common' })} + errors={formErrors} + className="grid gap-5 pt-5" + onSubmit={handleSubmit} > - +