diff --git a/.github/workflows/anti-slop.yml b/.github/workflows/anti-slop.yml deleted file mode 100644 index b0f0a36bc9..0000000000 --- a/.github/workflows/anti-slop.yml +++ /dev/null @@ -1,19 +0,0 @@ -name: Anti-Slop PR Check - -on: - pull_request_target: - types: [opened, edited, synchronize] - -permissions: - pull-requests: write - contents: read - -jobs: - anti-slop: - runs-on: ubuntu-latest - steps: - - uses: peakoss/anti-slop@85daca1880e9e1af197fc06ea03349daf08f4202 # v0.2.1 - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - close-pr: false - failure-add-pr-labels: "needs-revision" diff --git a/.github/workflows/api-tests.yml b/.github/workflows/api-tests.yml index fd910531db..717413937f 100644 --- a/.github/workflows/api-tests.yml +++ b/.github/workflows/api-tests.yml @@ -35,7 +35,7 @@ jobs: persist-credentials: false - name: Setup UV and Python - uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0 + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: enable-cache: true python-version: ${{ matrix.python-version }} @@ -84,7 +84,7 @@ jobs: persist-credentials: false - name: Setup UV and Python - uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0 + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: enable-cache: true python-version: ${{ matrix.python-version }} @@ -105,7 +105,7 @@ jobs: run: sh .github/workflows/expose_service_ports.sh - name: Set up Sandbox - uses: hoverkraft-tech/compose-action@4894d2492015c1774ee5a13a95b1072093087ec3 # v2.5.0 + uses: hoverkraft-tech/compose-action@d2bee4f07e8ca410d6b196d00f90c12e7d48c33a # v2.6.0 with: compose-file: | docker/docker-compose.middleware.yaml @@ -156,7 +156,7 @@ jobs: persist-credentials: false - name: Setup UV and Python - uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0 + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: enable-cache: true python-version: "3.12" diff --git a/.github/workflows/autofix.yml b/.github/workflows/autofix.yml index 3946834e09..35683b112f 100644 --- a/.github/workflows/autofix.yml +++ b/.github/workflows/autofix.yml @@ -25,7 +25,7 @@ jobs: - name: Check Docker Compose inputs if: github.event_name != 'merge_group' id: docker-compose-changes - uses: tj-actions/changed-files@22103cc46bda19c2b464ffe86db46df6922fd323 # v47.0.5 + uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6 with: files: | docker/generate_docker_compose @@ -35,7 +35,7 @@ jobs: - name: Check web inputs if: github.event_name != 'merge_group' id: web-changes - uses: tj-actions/changed-files@22103cc46bda19c2b464ffe86db46df6922fd323 # v47.0.5 + uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6 with: files: | web/** @@ -48,7 +48,7 @@ jobs: - name: Check api inputs if: github.event_name != 'merge_group' id: api-changes - uses: tj-actions/changed-files@22103cc46bda19c2b464ffe86db46df6922fd323 # v47.0.5 + uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6 with: files: | api/** @@ -58,7 +58,7 @@ jobs: python-version: "3.11" - if: github.event_name != 'merge_group' - uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0 + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 - name: Generate Docker Compose if: github.event_name != 'merge_group' && steps.docker-compose-changes.outputs.any_changed == 'true' @@ -123,4 +123,4 @@ jobs: vp exec eslint --concurrency=2 --prune-suppressions --quiet || true - if: github.event_name != 'merge_group' - uses: autofix-ci/action@7a166d7532b277f34e16238930461bf77f9d7ed8 # v1.3.3 + uses: autofix-ci/action@c5b2d67aa2274e7b5a18224e8171550871fc7e4a # v1.3.4 diff --git a/.github/workflows/db-migration-test.yml b/.github/workflows/db-migration-test.yml index 5991abe3ba..17b867dd6d 100644 --- a/.github/workflows/db-migration-test.yml +++ b/.github/workflows/db-migration-test.yml @@ -19,7 +19,7 @@ jobs: persist-credentials: false - name: Setup UV and Python - uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0 + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: enable-cache: true python-version: "3.12" @@ -40,7 +40,7 @@ jobs: cp middleware.env.example middleware.env - name: Set up Middlewares - uses: hoverkraft-tech/compose-action@4894d2492015c1774ee5a13a95b1072093087ec3 # v2.5.0 + uses: hoverkraft-tech/compose-action@d2bee4f07e8ca410d6b196d00f90c12e7d48c33a # v2.6.0 with: compose-file: | docker/docker-compose.middleware.yaml @@ -69,7 +69,7 @@ jobs: persist-credentials: false - name: Setup UV and Python - uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0 + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: enable-cache: true python-version: "3.12" @@ -94,7 +94,7 @@ jobs: sed -i 's/DB_USERNAME=postgres/DB_USERNAME=mysql/' middleware.env - name: Set up Middlewares - uses: hoverkraft-tech/compose-action@4894d2492015c1774ee5a13a95b1072093087ec3 # v2.5.0 + uses: hoverkraft-tech/compose-action@d2bee4f07e8ca410d6b196d00f90c12e7d48c33a # v2.6.0 with: compose-file: | docker/docker-compose.middleware.yaml diff --git a/.github/workflows/pyrefly-diff.yml b/.github/workflows/pyrefly-diff.yml index ac3732579c..eb15cd6f75 100644 --- a/.github/workflows/pyrefly-diff.yml +++ b/.github/workflows/pyrefly-diff.yml @@ -22,7 +22,7 @@ jobs: fetch-depth: 0 - name: Setup Python & UV - uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0 + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: enable-cache: true diff --git a/.github/workflows/pyrefly-type-coverage-comment.yml b/.github/workflows/pyrefly-type-coverage-comment.yml index 974da99aad..3c6c96a664 100644 --- a/.github/workflows/pyrefly-type-coverage-comment.yml +++ b/.github/workflows/pyrefly-type-coverage-comment.yml @@ -24,7 +24,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Python & UV - uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0 + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: enable-cache: true diff --git a/.github/workflows/pyrefly-type-coverage.yml b/.github/workflows/pyrefly-type-coverage.yml index c795c32e31..0599c94eef 100644 --- a/.github/workflows/pyrefly-type-coverage.yml +++ b/.github/workflows/pyrefly-type-coverage.yml @@ -22,7 +22,7 @@ jobs: fetch-depth: 0 - name: Setup Python & UV - uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0 + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: enable-cache: true diff --git a/.github/workflows/style.yml b/.github/workflows/style.yml index 29f5b090f8..d8c7ebbad3 100644 --- a/.github/workflows/style.yml +++ b/.github/workflows/style.yml @@ -25,7 +25,7 @@ jobs: - name: Check changed files id: changed-files - uses: tj-actions/changed-files@22103cc46bda19c2b464ffe86db46df6922fd323 # v47.0.5 + uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6 with: files: | api/** @@ -33,7 +33,7 @@ jobs: - name: Setup UV and Python if: steps.changed-files.outputs.any_changed == 'true' - uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0 + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: enable-cache: false python-version: "3.12" @@ -73,7 +73,7 @@ jobs: - name: Check changed files id: changed-files - uses: tj-actions/changed-files@22103cc46bda19c2b464ffe86db46df6922fd323 # v47.0.5 + uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6 with: files: | web/** @@ -95,7 +95,7 @@ jobs: - name: Restore ESLint cache if: steps.changed-files.outputs.any_changed == 'true' id: eslint-cache-restore - uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: .eslintcache key: ${{ runner.os }}-eslint-${{ hashFiles('pnpm-lock.yaml', 'eslint.config.mjs', 'web/eslint.config.mjs', 'web/eslint.constants.mjs', 'web/plugins/eslint/**') }}-${{ github.sha }} @@ -124,7 +124,7 @@ jobs: - name: Save ESLint cache if: steps.changed-files.outputs.any_changed == 'true' && success() && steps.eslint-cache-restore.outputs.cache-hit != 'true' - uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: .eslintcache key: ${{ steps.eslint-cache-restore.outputs.cache-primary-key }} @@ -142,7 +142,7 @@ jobs: - name: Check changed files id: changed-files - uses: tj-actions/changed-files@22103cc46bda19c2b464ffe86db46df6922fd323 # v47.0.5 + uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6 with: files: | **.sh diff --git a/.github/workflows/tool-test-sdks.yaml b/.github/workflows/tool-test-sdks.yaml index 467f31fccf..bf33207a14 100644 --- a/.github/workflows/tool-test-sdks.yaml +++ b/.github/workflows/tool-test-sdks.yaml @@ -30,7 +30,7 @@ jobs: persist-credentials: false - name: Use Node.js - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: 22 cache: '' diff --git a/.github/workflows/translate-i18n-claude.yml b/.github/workflows/translate-i18n-claude.yml index 541200293d..eecbbb1a56 100644 --- a/.github/workflows/translate-i18n-claude.yml +++ b/.github/workflows/translate-i18n-claude.yml @@ -158,7 +158,7 @@ jobs: - name: Run Claude Code for Translation Sync if: steps.context.outputs.CHANGED_FILES != '' - uses: anthropics/claude-code-action@b47fd721da662d48c5680e154ad16a73ed74d2e0 # v1.0.93 + uses: anthropics/claude-code-action@38ec876110f9fbf8b950c79f534430740c3ac009 # v1.0.101 with: anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} github_token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/vdb-tests-full.yml b/.github/workflows/vdb-tests-full.yml index f0def8fe7a..b79e8927d7 100644 --- a/.github/workflows/vdb-tests-full.yml +++ b/.github/workflows/vdb-tests-full.yml @@ -36,7 +36,7 @@ jobs: remove_tool_cache: true - name: Setup UV and Python - uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0 + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: enable-cache: true python-version: ${{ matrix.python-version }} @@ -65,7 +65,7 @@ jobs: # tiflash - name: Set up Full Vector Store Matrix - uses: hoverkraft-tech/compose-action@4894d2492015c1774ee5a13a95b1072093087ec3 # v2.5.0 + uses: hoverkraft-tech/compose-action@d2bee4f07e8ca410d6b196d00f90c12e7d48c33a # v2.6.0 with: compose-file: | docker/docker-compose.yaml diff --git a/.github/workflows/vdb-tests.yml b/.github/workflows/vdb-tests.yml index f3966f15b9..bd13d662c3 100644 --- a/.github/workflows/vdb-tests.yml +++ b/.github/workflows/vdb-tests.yml @@ -33,7 +33,7 @@ jobs: remove_tool_cache: true - name: Setup UV and Python - uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0 + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: enable-cache: true python-version: ${{ matrix.python-version }} @@ -62,7 +62,7 @@ jobs: # tiflash - name: Set up Vector Stores for Smoke Coverage - uses: hoverkraft-tech/compose-action@4894d2492015c1774ee5a13a95b1072093087ec3 # v2.5.0 + uses: hoverkraft-tech/compose-action@d2bee4f07e8ca410d6b196d00f90c12e7d48c33a # v2.6.0 with: compose-file: | docker/docker-compose.yaml diff --git a/.github/workflows/web-e2e.yml b/.github/workflows/web-e2e.yml index 10dc31bde8..6bd4d4f406 100644 --- a/.github/workflows/web-e2e.yml +++ b/.github/workflows/web-e2e.yml @@ -28,7 +28,7 @@ jobs: uses: ./.github/actions/setup-web - name: Setup UV and Python - uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0 + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: enable-cache: true python-version: "3.12" diff --git a/api/controllers/console/workspace/account.py b/api/controllers/console/workspace/account.py index 44404005b2..c01286cc59 100644 --- a/api/controllers/console/workspace/account.py +++ b/api/controllers/console/workspace/account.py @@ -595,13 +595,25 @@ class ChangeEmailSendEmailApi(Resource): account = None user_email = None email_for_sending = args.email.lower() - if args.phase is not None and args.phase == "new_email": + # Default to the initial phase; any legacy/unexpected client input is + # coerced back to `old_email` so we never trust the caller to declare + # later phases without a verified predecessor token. + send_phase = AccountService.CHANGE_EMAIL_PHASE_OLD + if args.phase is not None and args.phase == AccountService.CHANGE_EMAIL_PHASE_NEW: + send_phase = AccountService.CHANGE_EMAIL_PHASE_NEW if args.token is None: raise InvalidTokenError() reset_data = AccountService.get_change_email_data(args.token) if reset_data is None: raise InvalidTokenError() + + # The token used to request a new-email code must come from the + # old-email verification step. This prevents the bypass described + # in GHSA-4q3w-q5mc-45rq where the phase-1 token was reused here. + token_phase = reset_data.get(AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY) + if token_phase != AccountService.CHANGE_EMAIL_PHASE_OLD_VERIFIED: + raise InvalidTokenError() user_email = reset_data.get("email", "") if user_email.lower() != current_user.email.lower(): @@ -620,7 +632,7 @@ class ChangeEmailSendEmailApi(Resource): email=email_for_sending, old_email=user_email, language=language, - phase=args.phase, + phase=send_phase, ) return {"result": "success", "data": token} @@ -655,12 +667,31 @@ class ChangeEmailCheckApi(Resource): AccountService.add_change_email_error_rate_limit(user_email) raise EmailCodeError() + # Only advance tokens that were minted by the matching send-code step; + # refuse tokens that have already progressed or lack a phase marker so + # the chain `old_email -> old_email_verified -> new_email -> new_email_verified` + # is strictly enforced. + phase_transitions = { + AccountService.CHANGE_EMAIL_PHASE_OLD: AccountService.CHANGE_EMAIL_PHASE_OLD_VERIFIED, + AccountService.CHANGE_EMAIL_PHASE_NEW: AccountService.CHANGE_EMAIL_PHASE_NEW_VERIFIED, + } + token_phase = token_data.get(AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY) + if not isinstance(token_phase, str): + raise InvalidTokenError() + refreshed_phase = phase_transitions.get(token_phase) + if refreshed_phase is None: + raise InvalidTokenError() + # Verified, revoke the first token AccountService.revoke_change_email_token(args.token) - # Refresh token data by generating a new token + # Refresh token data by generating a new token that carries the + # upgraded phase so later steps can check it. _, new_token = AccountService.generate_change_email_token( - user_email, code=args.code, old_email=token_data.get("old_email"), additional_data={} + user_email, + code=args.code, + old_email=token_data.get("old_email"), + additional_data={AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY: refreshed_phase}, ) AccountService.reset_change_email_error_rate_limit(user_email) @@ -690,13 +721,29 @@ class ChangeEmailResetApi(Resource): if not reset_data: raise InvalidTokenError() - AccountService.revoke_change_email_token(args.token) + # Only tokens that completed both verification phases may be used to + # change the email. This closes GHSA-4q3w-q5mc-45rq where a token from + # the initial send-code step could be replayed directly here. + token_phase = reset_data.get(AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY) + if token_phase != AccountService.CHANGE_EMAIL_PHASE_NEW_VERIFIED: + raise InvalidTokenError() + + # Bind the new email to the token that was mailed and verified, so a + # verified token cannot be reused with a different `new_email` value. + token_email = reset_data.get("email") + normalized_token_email = token_email.lower() if isinstance(token_email, str) else token_email + if normalized_token_email != normalized_new_email: + raise InvalidTokenError() old_email = reset_data.get("old_email", "") current_user, _ = current_account_with_tenant() if current_user.email.lower() != old_email.lower(): raise AccountNotFound() + # Revoke only after all checks pass so failed attempts don't burn a + # legitimately verified token. + AccountService.revoke_change_email_token(args.token) + updated_account = AccountService.update_account_email(current_user, email=normalized_new_email) AccountService.send_change_email_completed_notify_email( diff --git a/api/pyproject.toml b/api/pyproject.toml index 159b09d844..b13c744f0a 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -6,7 +6,7 @@ requires-python = "~=3.12.0" dependencies = [ # Legacy: mature and widely deployed "bleach>=6.3.0", - "boto3>=1.42.88", + "boto3>=1.42.91", "celery>=5.6.3", "croniter>=6.2.2", "flask-cors>=6.0.2", @@ -30,7 +30,7 @@ dependencies = [ "flask-migrate>=4.1.0,<5.0.0", "flask-orjson>=2.0.0,<3.0.0", "flask-restx>=1.3.2,<2.0.0", - "google-cloud-aiplatform>=1.147.0,<2.0.0", + "google-cloud-aiplatform>=1.148.1,<2.0.0", "httpx[socks]>=0.28.1,<1.0.0", "opentelemetry-distro>=0.62b0,<1.0.0", "opentelemetry-instrumentation-celery>=0.62b0,<1.0.0", @@ -46,7 +46,7 @@ dependencies = [ "fastopenapi[flask]~=0.7.0", "graphon~=0.2.2", "httpx-sse~=0.4.0", - "json-repair~=0.59.2", + "json-repair~=0.59.4", ] # Before adding new dependency, consider place it in # alphabet order (a-z) and suitable group. @@ -174,7 +174,7 @@ dev = [ "pytest-timeout>=2.4.0", "pytest-xdist>=3.8.0", "pyrefly>=0.61.1", - "xinference-client>=2.4.0", + "xinference-client>=2.5.0", ] ############################################################ @@ -183,13 +183,13 @@ dev = [ ############################################################ storage = [ "azure-storage-blob>=12.28.0", - "bce-python-sdk>=0.9.69", + "bce-python-sdk>=0.9.70", "cos-python-sdk-v5>=1.9.41", "esdk-obs-python>=3.22.2", "google-cloud-storage>=3.10.1", "opendal>=0.46.0", "oss2>=2.19.1", - "supabase>=2.18.1", + "supabase>=2.28.3", "tos>=2.9.0", ] @@ -266,7 +266,7 @@ vdb-vastbase = ["dify-vdb-vastbase"] vdb-vikingdb = ["dify-vdb-vikingdb"] vdb-weaviate = ["dify-vdb-weaviate"] # Optional client used by some tests / integrations (not a vector backend plugin) -vdb-xinference = ["xinference-client>=2.4.0"] +vdb-xinference = ["xinference-client>=2.5.0"] trace-all = [ "dify-trace-aliyun", diff --git a/api/services/account_service.py b/api/services/account_service.py index ccc4a7c1fa..b6554a3de7 100644 --- a/api/services/account_service.py +++ b/api/services/account_service.py @@ -112,6 +112,14 @@ REFRESH_TOKEN_EXPIRY = timedelta(days=dify_config.REFRESH_TOKEN_EXPIRE_DAYS) class AccountService: + # Phase-bound token metadata for the change-email flow. Tokens carry the + # current phase so that downstream endpoints can enforce proper progression + CHANGE_EMAIL_TOKEN_PHASE_KEY = "email_change_phase" + CHANGE_EMAIL_PHASE_OLD = "old_email" + CHANGE_EMAIL_PHASE_OLD_VERIFIED = "old_email_verified" + CHANGE_EMAIL_PHASE_NEW = "new_email" + CHANGE_EMAIL_PHASE_NEW_VERIFIED = "new_email_verified" + reset_password_rate_limiter = RateLimiter(prefix="reset_password_rate_limit", max_attempts=1, time_window=60 * 1) email_register_rate_limiter = RateLimiter(prefix="email_register_rate_limit", max_attempts=1, time_window=60 * 1) email_code_login_rate_limiter = RateLimiter( @@ -576,13 +584,20 @@ class AccountService: raise ValueError("Email must be provided.") if not phase: raise ValueError("phase must be provided.") + if phase not in (cls.CHANGE_EMAIL_PHASE_OLD, cls.CHANGE_EMAIL_PHASE_NEW): + raise ValueError("phase must be one of old_email or new_email.") if cls.change_email_rate_limiter.is_rate_limited(account_email): from controllers.console.auth.error import EmailChangeRateLimitExceededError raise EmailChangeRateLimitExceededError(int(cls.change_email_rate_limiter.time_window / 60)) - code, token = cls.generate_change_email_token(account_email, account, old_email=old_email) + code, token = cls.generate_change_email_token( + account_email, + account, + old_email=old_email, + additional_data={cls.CHANGE_EMAIL_TOKEN_PHASE_KEY: phase}, + ) send_change_mail_task.delay( language=language, diff --git a/api/tests/unit_tests/controllers/console/test_workspace_account.py b/api/tests/unit_tests/controllers/console/test_workspace_account.py index c513be950b..26ff264f18 100644 --- a/api/tests/unit_tests/controllers/console/test_workspace_account.py +++ b/api/tests/unit_tests/controllers/console/test_workspace_account.py @@ -68,7 +68,10 @@ class TestChangeEmailSend: mock_features.return_value = SimpleNamespace(enable_change_email=True) mock_account = _build_account("current@example.com", "acc1") mock_current_account.return_value = (mock_account, None) - mock_get_change_data.return_value = {"email": "current@example.com"} + mock_get_change_data.return_value = { + "email": "current@example.com", + AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY: AccountService.CHANGE_EMAIL_PHASE_OLD_VERIFIED, + } mock_send_email.return_value = "token-abc" with app.test_request_context( @@ -85,12 +88,55 @@ class TestChangeEmailSend: email="new@example.com", old_email="current@example.com", language="en-US", - phase="new_email", + phase=AccountService.CHANGE_EMAIL_PHASE_NEW, ) mock_extract_ip.assert_called_once() mock_is_ip_limit.assert_called_once_with("127.0.0.1") mock_csrf.assert_called_once() + @patch("controllers.console.wraps.db") + @patch("controllers.console.workspace.account.current_account_with_tenant") + @patch("controllers.console.workspace.account.AccountService.get_change_email_data") + @patch("controllers.console.workspace.account.AccountService.send_change_email_email") + @patch("controllers.console.workspace.account.AccountService.is_email_send_ip_limit", return_value=False) + @patch("controllers.console.workspace.account.extract_remote_ip", return_value="127.0.0.1") + @patch("libs.login.check_csrf_token", return_value=None) + @patch("controllers.console.wraps.FeatureService.get_system_features") + def test_should_reject_new_email_phase_when_token_phase_is_not_old_verified( + self, + mock_features, + mock_csrf, + mock_extract_ip, + mock_is_ip_limit, + mock_send_email, + mock_get_change_data, + mock_current_account, + mock_db, + app, + ): + """GHSA-4q3w-q5mc-45rq: a phase-1 token must not unlock the new-email send step.""" + from controllers.console.auth.error import InvalidTokenError + + _mock_wraps_db(mock_db) + mock_features.return_value = SimpleNamespace(enable_change_email=True) + mock_account = _build_account("current@example.com", "acc1") + mock_current_account.return_value = (mock_account, None) + mock_get_change_data.return_value = { + "email": "current@example.com", + AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY: AccountService.CHANGE_EMAIL_PHASE_OLD, + } + + with app.test_request_context( + "/account/change-email", + method="POST", + json={"email": "New@Example.com", "language": "en-US", "phase": "new_email", "token": "token-123"}, + ): + _set_logged_in_user(_build_account("tester@example.com", "tester")) + with pytest.raises(InvalidTokenError): + ChangeEmailSendEmailApi().post() + + mock_send_email.assert_not_called() + class TestChangeEmailValidity: @patch("controllers.console.wraps.db") @@ -122,7 +168,12 @@ class TestChangeEmailValidity: mock_account = _build_account("user@example.com", "acc2") mock_current_account.return_value = (mock_account, None) mock_is_rate_limit.return_value = False - mock_get_data.return_value = {"email": "user@example.com", "code": "1234", "old_email": "old@example.com"} + mock_get_data.return_value = { + "email": "user@example.com", + "code": "1234", + "old_email": "old@example.com", + AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY: AccountService.CHANGE_EMAIL_PHASE_OLD, + } mock_generate_token.return_value = (None, "new-token") with app.test_request_context( @@ -138,11 +189,169 @@ class TestChangeEmailValidity: mock_add_rate.assert_not_called() mock_revoke_token.assert_called_once_with("token-123") mock_generate_token.assert_called_once_with( - "user@example.com", code="1234", old_email="old@example.com", additional_data={} + "user@example.com", + code="1234", + old_email="old@example.com", + additional_data={ + AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY: AccountService.CHANGE_EMAIL_PHASE_OLD_VERIFIED, + }, ) mock_reset_rate.assert_called_once_with("user@example.com") mock_csrf.assert_called_once() + @patch("controllers.console.wraps.db") + @patch("controllers.console.workspace.account.current_account_with_tenant") + @patch("controllers.console.workspace.account.AccountService.reset_change_email_error_rate_limit") + @patch("controllers.console.workspace.account.AccountService.generate_change_email_token") + @patch("controllers.console.workspace.account.AccountService.revoke_change_email_token") + @patch("controllers.console.workspace.account.AccountService.add_change_email_error_rate_limit") + @patch("controllers.console.workspace.account.AccountService.get_change_email_data") + @patch("controllers.console.workspace.account.AccountService.is_change_email_error_rate_limit") + @patch("libs.login.check_csrf_token", return_value=None) + @patch("controllers.console.wraps.FeatureService.get_system_features") + def test_should_upgrade_new_phase_token_to_new_verified( + self, + mock_features, + mock_csrf, + mock_is_rate_limit, + mock_get_data, + mock_add_rate, + mock_revoke_token, + mock_generate_token, + mock_reset_rate, + mock_current_account, + mock_db, + app, + ): + _mock_wraps_db(mock_db) + mock_features.return_value = SimpleNamespace(enable_change_email=True) + mock_current_account.return_value = (_build_account("old@example.com", "acc"), None) + mock_is_rate_limit.return_value = False + mock_get_data.return_value = { + "email": "new@example.com", + "code": "1234", + "old_email": "old@example.com", + AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY: AccountService.CHANGE_EMAIL_PHASE_NEW, + } + mock_generate_token.return_value = (None, "new-verified-token") + + with app.test_request_context( + "/account/change-email/validity", + method="POST", + json={"email": "new@example.com", "code": "1234", "token": "token-123"}, + ): + _set_logged_in_user(_build_account("tester@example.com", "tester")) + response = ChangeEmailCheckApi().post() + + assert response == {"is_valid": True, "email": "new@example.com", "token": "new-verified-token"} + mock_generate_token.assert_called_once_with( + "new@example.com", + code="1234", + old_email="old@example.com", + additional_data={ + AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY: AccountService.CHANGE_EMAIL_PHASE_NEW_VERIFIED, + }, + ) + + @patch("controllers.console.wraps.db") + @patch("controllers.console.workspace.account.current_account_with_tenant") + @patch("controllers.console.workspace.account.AccountService.reset_change_email_error_rate_limit") + @patch("controllers.console.workspace.account.AccountService.generate_change_email_token") + @patch("controllers.console.workspace.account.AccountService.revoke_change_email_token") + @patch("controllers.console.workspace.account.AccountService.add_change_email_error_rate_limit") + @patch("controllers.console.workspace.account.AccountService.get_change_email_data") + @patch("controllers.console.workspace.account.AccountService.is_change_email_error_rate_limit") + @patch("libs.login.check_csrf_token", return_value=None) + @patch("controllers.console.wraps.FeatureService.get_system_features") + def test_should_reject_validity_when_token_phase_is_unknown( + self, + mock_features, + mock_csrf, + mock_is_rate_limit, + mock_get_data, + mock_add_rate, + mock_revoke_token, + mock_generate_token, + mock_reset_rate, + mock_current_account, + mock_db, + app, + ): + """A token whose phase marker is a string but not a known transition must be rejected.""" + from controllers.console.auth.error import InvalidTokenError + + _mock_wraps_db(mock_db) + mock_features.return_value = SimpleNamespace(enable_change_email=True) + mock_current_account.return_value = (_build_account("old@example.com", "acc"), None) + mock_is_rate_limit.return_value = False + mock_get_data.return_value = { + "email": "user@example.com", + "code": "1234", + "old_email": "old@example.com", + AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY: "something_else", + } + + with app.test_request_context( + "/account/change-email/validity", + method="POST", + json={"email": "user@example.com", "code": "1234", "token": "token-123"}, + ): + _set_logged_in_user(_build_account("tester@example.com", "tester")) + with pytest.raises(InvalidTokenError): + ChangeEmailCheckApi().post() + + mock_revoke_token.assert_not_called() + mock_generate_token.assert_not_called() + + @patch("controllers.console.wraps.db") + @patch("controllers.console.workspace.account.current_account_with_tenant") + @patch("controllers.console.workspace.account.AccountService.reset_change_email_error_rate_limit") + @patch("controllers.console.workspace.account.AccountService.generate_change_email_token") + @patch("controllers.console.workspace.account.AccountService.revoke_change_email_token") + @patch("controllers.console.workspace.account.AccountService.add_change_email_error_rate_limit") + @patch("controllers.console.workspace.account.AccountService.get_change_email_data") + @patch("controllers.console.workspace.account.AccountService.is_change_email_error_rate_limit") + @patch("libs.login.check_csrf_token", return_value=None) + @patch("controllers.console.wraps.FeatureService.get_system_features") + def test_should_reject_validity_when_token_has_no_phase( + self, + mock_features, + mock_csrf, + mock_is_rate_limit, + mock_get_data, + mock_add_rate, + mock_revoke_token, + mock_generate_token, + mock_reset_rate, + mock_current_account, + mock_db, + app, + ): + """A token minted without a phase marker (e.g. a hand-crafted token) must not validate.""" + from controllers.console.auth.error import InvalidTokenError + + _mock_wraps_db(mock_db) + mock_features.return_value = SimpleNamespace(enable_change_email=True) + mock_current_account.return_value = (_build_account("old@example.com", "acc"), None) + mock_is_rate_limit.return_value = False + mock_get_data.return_value = { + "email": "user@example.com", + "code": "1234", + "old_email": "old@example.com", + } + + with app.test_request_context( + "/account/change-email/validity", + method="POST", + json={"email": "user@example.com", "code": "1234", "token": "token-123"}, + ): + _set_logged_in_user(_build_account("tester@example.com", "tester")) + with pytest.raises(InvalidTokenError): + ChangeEmailCheckApi().post() + + mock_revoke_token.assert_not_called() + mock_generate_token.assert_not_called() + class TestChangeEmailReset: @patch("controllers.console.wraps.db") @@ -175,7 +384,11 @@ class TestChangeEmailReset: mock_current_account.return_value = (current_user, None) mock_is_freeze.return_value = False mock_check_unique.return_value = True - mock_get_data.return_value = {"old_email": "OLD@example.com"} + mock_get_data.return_value = { + "email": "new@example.com", + "old_email": "OLD@example.com", + AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY: AccountService.CHANGE_EMAIL_PHASE_NEW_VERIFIED, + } mock_account_after_update = _build_account("new@example.com", "acc3-updated") mock_update_account.return_value = mock_account_after_update @@ -194,6 +407,155 @@ class TestChangeEmailReset: mock_send_notify.assert_called_once_with(email="new@example.com") mock_csrf.assert_called_once() + @patch("controllers.console.wraps.db") + @patch("controllers.console.workspace.account.current_account_with_tenant") + @patch("controllers.console.workspace.account.AccountService.send_change_email_completed_notify_email") + @patch("controllers.console.workspace.account.AccountService.update_account_email") + @patch("controllers.console.workspace.account.AccountService.revoke_change_email_token") + @patch("controllers.console.workspace.account.AccountService.get_change_email_data") + @patch("controllers.console.workspace.account.AccountService.check_email_unique") + @patch("controllers.console.workspace.account.AccountService.is_account_in_freeze") + @patch("libs.login.check_csrf_token", return_value=None) + @patch("controllers.console.wraps.FeatureService.get_system_features") + def test_should_reject_reset_when_token_phase_is_not_new_verified( + self, + mock_features, + mock_csrf, + mock_is_freeze, + mock_check_unique, + mock_get_data, + mock_revoke_token, + mock_update_account, + mock_send_notify, + mock_current_account, + mock_db, + app, + ): + """GHSA-4q3w-q5mc-45rq PoC: phase-1 token must not be usable against /reset.""" + from controllers.console.auth.error import InvalidTokenError + + _mock_wraps_db(mock_db) + mock_features.return_value = SimpleNamespace(enable_change_email=True) + current_user = _build_account("old@example.com", "acc3") + mock_current_account.return_value = (current_user, None) + mock_is_freeze.return_value = False + mock_check_unique.return_value = True + # Simulate a token straight out of step #1 (phase=old_email) — exactly + # the replay used in the advisory PoC. + mock_get_data.return_value = { + "email": "old@example.com", + "old_email": "old@example.com", + AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY: AccountService.CHANGE_EMAIL_PHASE_OLD, + } + + with app.test_request_context( + "/account/change-email/reset", + method="POST", + json={"new_email": "attacker@example.com", "token": "token-from-step1"}, + ): + _set_logged_in_user(_build_account("tester@example.com", "tester")) + with pytest.raises(InvalidTokenError): + ChangeEmailResetApi().post() + + mock_revoke_token.assert_not_called() + mock_update_account.assert_not_called() + mock_send_notify.assert_not_called() + + @patch("controllers.console.wraps.db") + @patch("controllers.console.workspace.account.current_account_with_tenant") + @patch("controllers.console.workspace.account.AccountService.send_change_email_completed_notify_email") + @patch("controllers.console.workspace.account.AccountService.update_account_email") + @patch("controllers.console.workspace.account.AccountService.revoke_change_email_token") + @patch("controllers.console.workspace.account.AccountService.get_change_email_data") + @patch("controllers.console.workspace.account.AccountService.check_email_unique") + @patch("controllers.console.workspace.account.AccountService.is_account_in_freeze") + @patch("libs.login.check_csrf_token", return_value=None) + @patch("controllers.console.wraps.FeatureService.get_system_features") + def test_should_reject_reset_when_token_email_differs_from_payload_new_email( + self, + mock_features, + mock_csrf, + mock_is_freeze, + mock_check_unique, + mock_get_data, + mock_revoke_token, + mock_update_account, + mock_send_notify, + mock_current_account, + mock_db, + app, + ): + """A verified token for address A must not be replayed to change to address B.""" + from controllers.console.auth.error import InvalidTokenError + + _mock_wraps_db(mock_db) + mock_features.return_value = SimpleNamespace(enable_change_email=True) + current_user = _build_account("old@example.com", "acc3") + mock_current_account.return_value = (current_user, None) + mock_is_freeze.return_value = False + mock_check_unique.return_value = True + mock_get_data.return_value = { + "email": "verified@example.com", + "old_email": "old@example.com", + AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY: AccountService.CHANGE_EMAIL_PHASE_NEW_VERIFIED, + } + + with app.test_request_context( + "/account/change-email/reset", + method="POST", + json={"new_email": "attacker@example.com", "token": "token-verified"}, + ): + _set_logged_in_user(_build_account("tester@example.com", "tester")) + with pytest.raises(InvalidTokenError): + ChangeEmailResetApi().post() + + mock_revoke_token.assert_not_called() + mock_update_account.assert_not_called() + mock_send_notify.assert_not_called() + + +class TestAccountServiceSendChangeEmailEmail: + """Service-level coverage for the phase-bound changes in `send_change_email_email`.""" + + def test_should_raise_value_error_for_invalid_phase(self): + with pytest.raises(ValueError, match="phase must be one of"): + AccountService.send_change_email_email( + email="user@example.com", + old_email="user@example.com", + phase="old_email_verified", + ) + + @patch("services.account_service.send_change_mail_task") + @patch("services.account_service.AccountService.change_email_rate_limiter") + @patch("services.account_service.AccountService.generate_change_email_token") + def test_should_stamp_phase_into_generated_token( + self, + mock_generate_token, + mock_rate_limiter, + mock_mail_task, + ): + mock_rate_limiter.is_rate_limited.return_value = False + mock_generate_token.return_value = ("123456", "the-token") + + returned = AccountService.send_change_email_email( + email="user@example.com", + old_email="user@example.com", + language="en-US", + phase=AccountService.CHANGE_EMAIL_PHASE_NEW, + ) + + assert returned == "the-token" + mock_generate_token.assert_called_once_with( + "user@example.com", + None, + old_email="user@example.com", + additional_data={ + AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY: AccountService.CHANGE_EMAIL_PHASE_NEW, + }, + ) + mock_mail_task.delay.assert_called_once() + mock_rate_limiter.increment_rate_limit.assert_called_once_with("user@example.com") + class TestAccountDeletionFeedback: @patch("controllers.console.wraps.db") diff --git a/api/uv.lock b/api/uv.lock index 226cb96e4c..40d196a160 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -481,7 +481,7 @@ wheels = [ [[package]] name = "bce-python-sdk" -version = "0.9.69" +version = "0.9.70" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "crc32c" }, @@ -489,9 +489,9 @@ dependencies = [ { name = "pycryptodome" }, { name = "six" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/07/9c/8fdaf7f9259002b5aa9101bb88252f6d05f65c4535bbca567457da84d765/bce_python_sdk-0.9.69.tar.gz", hash = "sha256:2aaa9f4fc118b3efb720a66d7a541789b7d838a1ddacb9f3c6faa6b75e1c7d23", size = 300008, upload-time = "2026-04-10T08:13:29.769Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f7/a9/7c21a9073eb9ad7e8cacf6f8a0e47c0d01ad7bf8fd8e0dc42164b117d60b/bce_python_sdk-0.9.70.tar.gz", hash = "sha256:3b37fd7448278dd33f745a6a23198a2cc2490fded9cb8d59b72500784853df4e", size = 299967, upload-time = "2026-04-14T12:02:42.034Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ca/3b/41c2985d1b3b3bd5cdf103b4156b08320268ee7a0617f2a40c34fdd377e9/bce_python_sdk-0.9.69-py3-none-any.whl", hash = "sha256:50fb94833b5f4931255296396081b85143101bd9a7a894efbf20d1f759779de5", size = 415659, upload-time = "2026-04-10T08:13:27.958Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2d/70fc866ff98d1f6bd75b0a4235694129b3c519b014254d7bcfc02ffe1bee/bce_python_sdk-0.9.70-py3-none-any.whl", hash = "sha256:fd1f31113e4a8dca314f040662b7caf07ec11cf896c5da232627a9a2c9d2e3a1", size = 415660, upload-time = "2026-04-14T12:02:40.034Z" }, ] [[package]] @@ -604,16 +604,16 @@ wheels = [ [[package]] name = "boto3" -version = "1.42.88" +version = "1.42.91" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "botocore" }, { name = "jmespath" }, { name = "s3transfer" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/da/bb/7d4435cca6fccf235dd40c891c731bcb9078e815917b57ebadd1e0ffabaf/boto3-1.42.88.tar.gz", hash = "sha256:2d22c70de5726918676a06f1a03acfb4d5d9ea92fc759354800b67b22aaeef19", size = 113238, upload-time = "2026-04-10T19:41:06.912Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a7/c0/98b8cec7ca22dde776df48c58940ae1abc425593959b7226e270760d726f/boto3-1.42.91.tar.gz", hash = "sha256:03d70532b17f7f84df37ca7e8c21553280454dea53ae12b15d1cfef9b16fcb8a", size = 113181, upload-time = "2026-04-17T19:31:06.251Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0a/2b/8bfddb39a19f5fbc16a869f1a394771e6223f07160dbc0ff6b38e05ea0ae/boto3-1.42.88-py3-none-any.whl", hash = "sha256:2d0f52c971503377e4370d2a83edee6f077ddb8e684366ff38df4f13581d9cfc", size = 140557, upload-time = "2026-04-10T19:41:05.309Z" }, + { url = "https://files.pythonhosted.org/packages/02/29/faba6521257c34085cc9b439ef98235b581772580f417fa3629728007270/boto3-1.42.91-py3-none-any.whl", hash = "sha256:04e72071cde022951ce7f81bd9933c90095ab8923e8ced61c8dacfe9edac0f5c", size = 140553, upload-time = "2026-04-17T19:31:02.57Z" }, ] [[package]] @@ -636,16 +636,16 @@ bedrock-runtime = [ [[package]] name = "botocore" -version = "1.42.88" +version = "1.42.91" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jmespath" }, { name = "python-dateutil" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/93/50/87966238f7aa3f7e5f87081185d5a407a95ede8b551e11bbe134ca3306dc/botocore-1.42.88.tar.gz", hash = "sha256:cbb59ee464662039b0c2c95a520cdf85b1e8ce00b72375ab9cd9f842cc001301", size = 15195331, upload-time = "2026-04-10T19:40:57.012Z" } +sdist = { url = "https://files.pythonhosted.org/packages/21/bc/a4b7c46471c2e789ad8c4c7acfd7f302fdb481d93ff870f441249b924ae6/botocore-1.42.91.tar.gz", hash = "sha256:d252e27bc454afdbf5ed3dc617aa423f2c855c081e98b7963093399483ecc698", size = 15213010, upload-time = "2026-04-17T19:30:50.793Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/46/ad14e41245adb8b0c83663ba13e822b68a0df08999dd250e75b0750fdf6c/botocore-1.42.88-py3-none-any.whl", hash = "sha256:032375b213305b6b81eedb269eaeefdf96f674620799bbf96117dca86052cc1a", size = 14876640, upload-time = "2026-04-10T19:40:53.663Z" }, + { url = "https://files.pythonhosted.org/packages/b1/fc/24cc0a47c824f13933e210e9ad034b4fba22f7185b8d904c0fbf5a3b2be8/botocore-1.42.91-py3-none-any.whl", hash = "sha256:7a28c3cc6bfab5724ad18899d52402b776a0de7d87fa20c3c5270bcaaf199ce8", size = 14897344, upload-time = "2026-04-17T19:30:44.245Z" }, ] [[package]] @@ -710,11 +710,11 @@ wheels = [ [[package]] name = "cachetools" -version = "7.0.5" +version = "6.2.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/af/dd/57fe3fdb6e65b25a5987fd2cdc7e22db0aef508b91634d2e57d22928d41b/cachetools-7.0.5.tar.gz", hash = "sha256:0cd042c24377200c1dcd225f8b7b12b0ca53cc2c961b43757e774ebe190fd990", size = 37367, upload-time = "2026-03-09T20:51:29.451Z" } +sdist = { url = "https://files.pythonhosted.org/packages/39/91/d9ae9a66b01102a18cd16db0cf4cd54187ffe10f0865cc80071a4104fbb3/cachetools-6.2.6.tar.gz", hash = "sha256:16c33e1f276b9a9c0b49ab5782d901e3ad3de0dd6da9bf9bcd29ac5672f2f9e6", size = 32363, upload-time = "2026-01-27T20:32:59.956Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/06/f3/39cf3367b8107baa44f861dc802cbf16263c945b62d8265d36034fc07bea/cachetools-7.0.5-py3-none-any.whl", hash = "sha256:46bc8ebefbe485407621d0a4264b23c080cedd913921bad7ac3ed2f26c183114", size = 13918, upload-time = "2026-03-09T20:51:27.33Z" }, + { url = "https://files.pythonhosted.org/packages/90/45/f458fa2c388e79dd9d8b9b0c99f1d31b568f27388f2fdba7bb66bbc0c6ed/cachetools-6.2.6-py3-none-any.whl", hash = "sha256:8c9717235b3c651603fff0076db52d6acbfd1b338b8ed50256092f7ce9c85bda", size = 11668, upload-time = "2026-01-27T20:32:58.527Z" }, ] [[package]] @@ -1577,7 +1577,7 @@ requires-dist = [ { name = "aliyun-log-python-sdk", specifier = ">=0.9.44,<1.0.0" }, { name = "azure-identity", specifier = ">=1.25.3,<2.0.0" }, { name = "bleach", specifier = ">=6.3.0" }, - { name = "boto3", specifier = ">=1.42.88" }, + { name = "boto3", specifier = ">=1.42.91" }, { name = "celery", specifier = ">=5.6.3" }, { name = "croniter", specifier = ">=6.2.2" }, { name = "fastopenapi", extras = ["flask"], specifier = "~=0.7.0" }, @@ -1591,12 +1591,12 @@ requires-dist = [ { name = "gevent-websocket", specifier = ">=0.10.1" }, { name = "gmpy2", specifier = ">=2.3.0" }, { name = "google-api-python-client", specifier = ">=2.194.0" }, - { name = "google-cloud-aiplatform", specifier = ">=1.147.0,<2.0.0" }, + { name = "google-cloud-aiplatform", specifier = ">=1.148.1,<2.0.0" }, { name = "graphon", specifier = "~=0.2.2" }, { name = "gunicorn", specifier = ">=25.3.0" }, { name = "httpx", extras = ["socks"], specifier = ">=0.28.1,<1.0.0" }, { name = "httpx-sse", specifier = "~=0.4.0" }, - { name = "json-repair", specifier = "~=0.59.2" }, + { name = "json-repair", specifier = "~=0.59.4" }, { name = "opentelemetry-distro", specifier = ">=0.62b0,<1.0.0" }, { name = "opentelemetry-instrumentation-celery", specifier = ">=0.62b0,<1.0.0" }, { name = "opentelemetry-instrumentation-flask", specifier = ">=0.62b0,<1.0.0" }, @@ -1677,17 +1677,17 @@ dev = [ { name = "types-tensorflow", specifier = ">=2.18.0.20260408" }, { name = "types-tqdm", specifier = ">=4.67.3.20260408" }, { name = "types-ujson", specifier = ">=5.10.0" }, - { name = "xinference-client", specifier = ">=2.4.0" }, + { name = "xinference-client", specifier = ">=2.5.0" }, ] storage = [ { name = "azure-storage-blob", specifier = ">=12.28.0" }, - { name = "bce-python-sdk", specifier = ">=0.9.69" }, + { name = "bce-python-sdk", specifier = ">=0.9.70" }, { name = "cos-python-sdk-v5", specifier = ">=1.9.41" }, { name = "esdk-obs-python", specifier = ">=3.22.2" }, { name = "google-cloud-storage", specifier = ">=3.10.1" }, { name = "opendal", specifier = ">=0.46.0" }, { name = "oss2", specifier = ">=2.19.1" }, - { name = "supabase", specifier = ">=2.18.1" }, + { name = "supabase", specifier = ">=2.28.3" }, { name = "tos", specifier = ">=2.9.0" }, ] tools = [ @@ -1774,7 +1774,7 @@ vdb-upstash = [{ name = "dify-vdb-upstash", editable = "providers/vdb/vdb-upstas vdb-vastbase = [{ name = "dify-vdb-vastbase", editable = "providers/vdb/vdb-vastbase" }] vdb-vikingdb = [{ name = "dify-vdb-vikingdb", editable = "providers/vdb/vdb-vikingdb" }] vdb-weaviate = [{ name = "dify-vdb-weaviate", editable = "providers/vdb/vdb-weaviate" }] -vdb-xinference = [{ name = "xinference-client", specifier = ">=2.4.0" }] +vdb-xinference = [{ name = "xinference-client", specifier = ">=2.5.0" }] [[package]] name = "dify-trace-aliyun" @@ -2764,7 +2764,7 @@ wheels = [ [[package]] name = "google-cloud-aiplatform" -version = "1.147.0" +version = "1.148.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "docstring-parser" }, @@ -2780,9 +2780,9 @@ dependencies = [ { name = "pydantic" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/23/93/9bfcaaf1ceab12999a881ccf69ebd9b30f467ec5623989c66894e81fc139/google_cloud_aiplatform-1.147.0.tar.gz", hash = "sha256:b2e1b669ba37f02426e03eb13187eebf4cbfeaa0a3bfed37b5578abb375ab689", size = 10235245, upload-time = "2026-04-09T17:14:49.179Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/f3/b2a9417014c93858a2e3266134f931eefd972c2d410b25d7b8782fc6f143/google_cloud_aiplatform-1.148.1.tar.gz", hash = "sha256:75d605fba34e68714bd08e1e482755d0a6e3ae972805f809d088e686c30879e7", size = 10278758, upload-time = "2026-04-17T23:45:26.738Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d3/d2/1c1c582f6bbed9bbc0daa5acf3a5d98751ca8bc48584548d28569b8ce1a7/google_cloud_aiplatform-1.147.0-py2.py3-none-any.whl", hash = "sha256:29f7ae020718d3c45094f0475464e06a97f81b1572bea150ae6a1b22c5f45997", size = 8408951, upload-time = "2026-04-09T17:14:45.482Z" }, + { url = "https://files.pythonhosted.org/packages/56/5b/e3515d7bbba602c2b0f6a0da5431785e897252443682e4735d0e6873dc8f/google_cloud_aiplatform-1.148.1-py2.py3-none-any.whl", hash = "sha256:035101e2d8e65c6a706cc3930b2452de7ddcbde50dd130320fcea0d8b03b0c5a", size = 8434481, upload-time = "2026-04-17T23:45:22.919Z" }, ] [[package]] @@ -3492,11 +3492,11 @@ wheels = [ [[package]] name = "json-repair" -version = "0.59.2" +version = "0.59.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ed/cb/a49f1661737a78098ce33668350590c981a4163055bc9a01e0cc688d896a/json_repair-0.59.2.tar.gz", hash = "sha256:1d8abb2fa94c4035a66ef9892ea3785dace8dcf09c583e6de781cfd31b278b3d", size = 48341, upload-time = "2026-04-11T15:55:41.145Z" } +sdist = { url = "https://files.pythonhosted.org/packages/32/41/4ae9c6e711647a41b4e0c04bce815113ce9c0286eff6dc6fb86979b2fb9f/json_repair-0.59.4.tar.gz", hash = "sha256:559ca1828f6f566530663cd96d64bee29f8282b9d2ff0e661e05fa87b4171ab3", size = 47624, upload-time = "2026-04-15T06:48:40.776Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e1/03/7afcecb4242d93b684708b47fb014abdc1922a01b38c0e30f1117ae74a83/json_repair-0.59.2-py3-none-any.whl", hash = "sha256:6ca6238519c24f671bcb05d1f38a0d6a452bb4ca5af82137595c5c2f1a0fb785", size = 46918, upload-time = "2026-04-11T15:55:39.817Z" }, + { url = "https://files.pythonhosted.org/packages/74/c4/ec3068436d2275731539b7a43fbc947f502bc3fe149856a5d00368c7b087/json_repair-0.59.4-py3-none-any.whl", hash = "sha256:46052e646bc0b0c39db672ebbf732f774f3c1a5bde81a54f0b0e19d3af4f45cd", size = 46697, upload-time = "2026-04-15T06:48:39.61Z" }, ] [[package]] @@ -4808,16 +4808,17 @@ wheels = [ [[package]] name = "postgrest" -version = "1.1.1" +version = "2.28.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "deprecation" }, { name = "httpx", extra = ["http2"] }, { name = "pydantic" }, + { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6e/3e/1b50568e1f5db0bdced4a82c7887e37326585faef7ca43ead86849cb4861/postgrest-1.1.1.tar.gz", hash = "sha256:f3bb3e8c4602775c75c844a31f565f5f3dd584df4d36d683f0b67d01a86be322", size = 15431, upload-time = "2025-06-23T19:21:34.742Z" } +sdist = { url = "https://files.pythonhosted.org/packages/40/60/9378ddd6e21b6005b34aeb42dc7a9ed9985c673c97c9b6a1858f9c52ebbd/postgrest-2.28.3.tar.gz", hash = "sha256:56336e9304950a78315ec7d6c8eb307cdb964d0878a7bec6111392ddb6c16a45", size = 13758, upload-time = "2026-03-20T14:38:06.542Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a4/71/188a50ea64c17f73ff4df5196ec1553a8f1723421eb2d1069c73bab47d78/postgrest-1.1.1-py3-none-any.whl", hash = "sha256:98a6035ee1d14288484bfe36235942c5fb2d26af6d8120dfe3efbe007859251a", size = 22366, upload-time = "2025-06-23T19:21:33.637Z" }, + { url = "https://files.pythonhosted.org/packages/7f/5e/6eeb1d53d010d80e800204c1eee6b3d5419a6a2b985c364f56f36cf48cca/postgrest-2.28.3-py3-none-any.whl", hash = "sha256:5a44d6c6d509abdbe0f928c86f0dc31ef26bda36e0357129836ec54dfb50b083", size = 21865, upload-time = "2026-03-20T14:38:05.55Z" }, ] [[package]] @@ -5158,6 +5159,35 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] +[[package]] +name = "pyiceberg" +version = "0.11.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cachetools" }, + { name = "click" }, + { name = "fsspec" }, + { name = "mmh3" }, + { name = "pydantic" }, + { name = "pyparsing" }, + { name = "pyroaring" }, + { name = "requests" }, + { name = "rich" }, + { name = "strictyaml" }, + { name = "tenacity" }, + { name = "zstandard" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ce/f0/7616676603fdbd05ab97816337a9b31be08a5f9e1ffd636260812b217e0f/pyiceberg-0.11.1.tar.gz", hash = "sha256:366fe0d5a74e3cf1d4e7cbf3c49e308da60e7835ea268667be9185388f05d7a5", size = 1076075, upload-time = "2026-03-03T00:10:27.61Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/84/a140466b7e0841207e6b77042e03d4ab3a4f9d47e00f0bbbcc5420792bbb/pyiceberg-0.11.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cd423b8ee2f75fc9db09158875abe5e2c952a26ae5e521c3265ab2f9d3511ddf", size = 532981, upload-time = "2026-03-03T00:10:08.906Z" }, + { url = "https://files.pythonhosted.org/packages/17/10/6bedd784010f707680ffd0606d4d11394cf915f4f9f54ae16e8007e00ad4/pyiceberg-0.11.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e273242cdca56029af694d7ce18075d47a74d034326d663ff6dd2655a6f44825", size = 533188, upload-time = "2026-03-03T00:10:10.086Z" }, + { url = "https://files.pythonhosted.org/packages/f1/a3/79db617c3cffc963efa8a332707079d3f22fd58067b31a208d358dd89b39/pyiceberg-0.11.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b347d3cc8510f8fbe191956fcda7da372ebb3302789acefca08e352345959003", size = 729546, upload-time = "2026-03-03T00:10:11.413Z" }, + { url = "https://files.pythonhosted.org/packages/06/64/acc11d230c33817bced80d9d947bb49e7bb3a429d76d906523e3df86faf8/pyiceberg-0.11.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bba3a35b4648694783aeae5b77c235a57191c8b1b375c8602b03ae56a6cf4fe7", size = 730263, upload-time = "2026-03-03T00:10:13.283Z" }, + { url = "https://files.pythonhosted.org/packages/8d/1a/fb067d5150c7309fbf5dd126c648a6afed6259e7bc924ba3c65d0f87a333/pyiceberg-0.11.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0f958cbca18d05846e3081dfff8575e73d45595441d659847479656dc76f91d", size = 724064, upload-time = "2026-03-03T00:10:14.55Z" }, + { url = "https://files.pythonhosted.org/packages/c1/71/103fdba5b144d55f3bb07347893737cc1d8fd71308108a77b7817c92c544/pyiceberg-0.11.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8c62636a1e9d8a1fc74ffb70383939b9cd93f2c9ee8e12015a50dd75c98a989e", size = 727239, upload-time = "2026-03-03T00:10:16.204Z" }, + { url = "https://files.pythonhosted.org/packages/18/c3/4db64429304c58c039f8e842cd37a9a1c472f596c2868ed2a5d2907b17ed/pyiceberg-0.11.1-cp312-cp312-win_amd64.whl", hash = "sha256:1d6b6f0c1e7dd8357f1ba56524bfc870d04ad3c00979db291784a7145497ad3b", size = 531309, upload-time = "2026-03-03T00:10:17.561Z" }, +] + [[package]] name = "pyjwt" version = "2.12.1" @@ -5342,6 +5372,26 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/87/32/38ac5af84d96167412024abf5e2f49f15b777987a1942e7a442e8e5fef82/pyrefly-0.61.1-py3-none-win_arm64.whl", hash = "sha256:cef5631e2ab09702274ec2eaaafee28a114891cf85f2d31568b329727e3ff735", size = 12302467, upload-time = "2026-04-17T18:47:31.409Z" }, ] +[[package]] +name = "pyroaring" +version = "1.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fd/71/134bcaf93d8734051ecbc164dabe695645849249e0fb24209b2dd88e8147/pyroaring-1.0.4.tar.gz", hash = "sha256:99d4217bdfeedc91b82efcec940175a9f9a9137c6476faf7ce5d9c9dd889c8e6", size = 189155, upload-time = "2026-03-19T13:57:27.932Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/a7/9f4977405d3a3fa02cf575951f03a9cda4c01efbe27a19230addee06acc2/pyroaring-1.0.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:924ee997ff1a0f2a184e39e153e9f77e0b928fe908d0aef63d03204c3ed90586", size = 322060, upload-time = "2026-03-19T13:56:04.94Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e9/32fd7125aea82a3d1d29b755edbc7bb531907638c68a5bcc767d20c2be4a/pyroaring-1.0.4-cp312-cp312-macosx_14_0_universal2.whl", hash = "sha256:b7a527279cc378e893a543a2271d71321b57d21733915a5a14e587532e29265a", size = 685716, upload-time = "2026-03-19T13:56:06.431Z" }, + { url = "https://files.pythonhosted.org/packages/3d/b3/6b78bd9d743c053fb3b0485a6b1f487e6a658123f06013bee55610b02120/pyroaring-1.0.4-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:d73979e1a3a6de2b7039dd9a545afa23f3b33f8b6f90a825390a34253d097f96", size = 363373, upload-time = "2026-03-19T13:56:07.707Z" }, + { url = "https://files.pythonhosted.org/packages/e7/ff/188c16cdd75d841e50d2779ff7b5d1c5c915a6f23006ef3ab3680f48faca/pyroaring-1.0.4-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dab3d8577b143c64c1c1659b4d1c69d7fec5baa0d0c0181cdddf6b84f43af00a", size = 1914865, upload-time = "2026-03-19T13:56:09.22Z" }, + { url = "https://files.pythonhosted.org/packages/c8/12/ae7b4fa3682190597cbfb252be570358c5bc55f46f6b05db3fde66dfacc1/pyroaring-1.0.4-cp312-cp312-manylinux_2_24_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:dcc7d0133f46163b5390dd151e3305bd47289f7710c6e5444f38453d55b15d1c", size = 1742423, upload-time = "2026-03-19T13:56:10.422Z" }, + { url = "https://files.pythonhosted.org/packages/f7/25/966ea0a9d857ac3f2af1eaebdbfd56e627507b890f8ff7752c32e8e57b57/pyroaring-1.0.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a8b8448c2f7af3b40f17dde23b6739f3b19cf8b24db6f817c549c870d50aae3", size = 2130698, upload-time = "2026-03-19T13:56:12.039Z" }, + { url = "https://files.pythonhosted.org/packages/7a/31/0d0320925cf8bc1fcb53db182359bd673bf6b434f31c6cc69e4f5312c55f/pyroaring-1.0.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:13ccc488cfe6a227945586090397f299fd40c1a0dc1ed25e8e58a4d068c1ed46", size = 2822746, upload-time = "2026-03-19T13:56:13.274Z" }, + { url = "https://files.pythonhosted.org/packages/b9/98/a5f6d619098e307baf71b14a4df955914e8092f459d19daf80fbbd651fa1/pyroaring-1.0.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:dd89ebb7496325fb1b3dbe290dad35bc4da8722b5e3afd2a71b1d6e8ad981725", size = 2657370, upload-time = "2026-03-19T13:56:14.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/13/4d8beb31e4f648326b9b39c8f056fc1cb41422ef4e2be17cb15432c7fd40/pyroaring-1.0.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:06b15bc8e0c272c3bee0c8adc29130178cd792ad99af64d7d7a1eb3069a1e0b8", size = 3088618, upload-time = "2026-03-19T13:56:16.625Z" }, + { url = "https://files.pythonhosted.org/packages/ad/16/95e223db5e60a6def8abcc2a8ebc4c71df23148a602310973c9a6964e3c5/pyroaring-1.0.4-cp312-cp312-win32.whl", hash = "sha256:4dca094f1d0e18901fa3f6b8866fa14a0f9640b22f41f5fc278c20e15e70efee", size = 202455, upload-time = "2026-03-19T13:56:18.124Z" }, + { url = "https://files.pythonhosted.org/packages/b9/2c/5e41a91822c3bbc735382939d354e2c10bae453b18fe5a133f7fdbb33ce9/pyroaring-1.0.4-cp312-cp312-win_amd64.whl", hash = "sha256:63ab0dcb24933fc9d4ca8c9fa0440f7e177183975990f756a42cddad22fd66db", size = 257479, upload-time = "2026-03-19T13:56:19.25Z" }, + { url = "https://files.pythonhosted.org/packages/bd/d7/7bc58e807e7d6739f4f5c45964a35927e1ff7f591e3943372097d29a00a7/pyroaring-1.0.4-cp312-cp312-win_arm64.whl", hash = "sha256:226079645dd4098d3619ae5fc19bf9abfd2187a74aba94a8768443e637d406fa", size = 215516, upload-time = "2026-03-19T13:56:20.286Z" }, +] + [[package]] name = "pytest" version = "9.0.3" @@ -5671,16 +5721,16 @@ wheels = [ [[package]] name = "realtime" -version = "2.7.0" +version = "2.28.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, { name = "typing-extensions" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d3/ca/e408fbdb6b344bf529c7e8bf020372d21114fe538392c72089462edd26e5/realtime-2.7.0.tar.gz", hash = "sha256:6b9434eeba8d756c8faf94fc0a32081d09f250d14d82b90341170602adbb019f", size = 18860, upload-time = "2025-07-28T18:54:22.949Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/3d/ef6ed9221f98766f3a503e6e3ac68fa7ca25c117b383f1efc448294232ac/realtime-2.28.3.tar.gz", hash = "sha256:5cc83a6217874426799d8bf74e96d904ac6fa77c39fa8982fa99287947eb2cbf", size = 18723, upload-time = "2026-03-20T14:38:08.424Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/07/a5c7aef12f9a3497f5ad77157a37915645861e8b23b89b2ad4b0f11b48ad/realtime-2.7.0-py3-none-any.whl", hash = "sha256:d55a278803529a69d61c7174f16563a9cfa5bacc1664f656959694481903d99c", size = 22409, upload-time = "2025-07-28T18:54:21.383Z" }, + { url = "https://files.pythonhosted.org/packages/5d/d5/659405f9d4c9b022b7ac02bd52986ccc081f211db081051440f46bf4f358/realtime-2.28.3-py3-none-any.whl", hash = "sha256:efe484d6d39024c7e00ef70f70be600142e9407e5d802de8c96e86e014ce3b36", size = 22378, upload-time = "2026-03-20T14:38:07.144Z" }, ] [[package]] @@ -6162,16 +6212,18 @@ wheels = [ [[package]] name = "storage3" -version = "0.12.1" +version = "2.28.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "deprecation" }, { name = "httpx", extra = ["http2"] }, - { name = "python-dateutil" }, + { name = "pydantic" }, + { name = "pyiceberg" }, + { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b9/e2/280fe75f65e7a3ca680b7843acfc572a63aa41230e3d3c54c66568809c85/storage3-0.12.1.tar.gz", hash = "sha256:32ea8f5eb2f7185c2114a4f6ae66d577722e32503f0a30b56e7ed5c7f13e6b48", size = 10198, upload-time = "2025-08-05T18:09:11.989Z" } +sdist = { url = "https://files.pythonhosted.org/packages/eb/b5/18df59ba92951d74774eb0265072bf236ead5e3cbc4b802d8bf1cf3581a0/storage3-2.28.3.tar.gz", hash = "sha256:2b3f843cbd44c4a3b483ec076a12c27de88c0ad5358a43067ed44ef08292353f", size = 20109, upload-time = "2026-03-20T14:38:11.467Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7f/3b/c5f8709fc5349928e591fee47592eeff78d29a7d75b097f96a4e01de028d/storage3-0.12.1-py3-none-any.whl", hash = "sha256:9da77fd4f406b019fdcba201e9916aefbf615ef87f551253ce427d8136459a34", size = 18420, upload-time = "2025-08-05T18:09:10.365Z" }, + { url = "https://files.pythonhosted.org/packages/ad/a5/2dbe216954e026a8c2e2dc7dfa5fd7b1a1ae0824d10972e62462f4f15aca/storage3-2.28.3-py3-none-any.whl", hash = "sha256:bac35c5087619174448fdef6a337db4e3dfebf3de69f685bd706de93ddcdad69", size = 28239, upload-time = "2026-03-20T14:38:10.423Z" }, ] [[package]] @@ -6183,9 +6235,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/69/297302c5f5f59c862faa31e6cb9a4cd74721cd1e052b38e464c5b402df8b/StrEnum-0.4.15-py3-none-any.whl", hash = "sha256:a30cda4af7cc6b5bf52c8055bc4bf4b2b6b14a93b574626da33df53cf7740659", size = 8851, upload-time = "2023-06-29T22:02:56.947Z" }, ] +[[package]] +name = "strictyaml" +version = "1.7.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/08/efd28d49162ce89c2ad61a88bd80e11fb77bc9f6c145402589112d38f8af/strictyaml-1.7.3.tar.gz", hash = "sha256:22f854a5fcab42b5ddba8030a0e4be51ca89af0267961c8d6cfa86395586c407", size = 115206, upload-time = "2023-03-10T12:50:27.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/7c/a81ef5ef10978dd073a854e0fa93b5d8021d0594b639cc8f6453c3c78a1d/strictyaml-1.7.3-py3-none-any.whl", hash = "sha256:fb5c8a4edb43bebb765959e420f9b3978d7f1af88c80606c03fb420888f5d1c7", size = 123917, upload-time = "2023-03-10T12:50:17.242Z" }, +] + [[package]] name = "supabase" -version = "2.18.1" +version = "2.28.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, @@ -6194,37 +6258,39 @@ dependencies = [ { name = "storage3" }, { name = "supabase-auth" }, { name = "supabase-functions" }, + { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/99/d2/3b135af55dd5788bd47875bb81f99c870054b990c030e51fd641a61b10b5/supabase-2.18.1.tar.gz", hash = "sha256:205787b1fbb43d6bc997c06fe3a56137336d885a1b56ec10f0012f2a2905285d", size = 11549, upload-time = "2025-08-12T19:02:27.852Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/98/2f1c95a2269ce995a34f275760b1c2ee71ee7a75649238ca0470afdfc2ef/supabase-2.28.3.tar.gz", hash = "sha256:1200961e46cdec17c7c280a1e09a159544643eada2759591ea69835303a2e1a4", size = 9687, upload-time = "2026-03-20T14:38:13.272Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a8/33/0e0062fea22cfe01d466dee83f56b3ed40c89bdcbca671bafeba3fe86b92/supabase-2.18.1-py3-none-any.whl", hash = "sha256:4fdd7b7247178a847f97ecd34f018dcb4775e487c8ff46b1208a01c933691fe9", size = 18683, upload-time = "2025-08-12T19:02:26.68Z" }, + { url = "https://files.pythonhosted.org/packages/de/96/1b48eb664153401c22087bbf77f6a428965e830cc8e0d0c6d68324a28342/supabase-2.28.3-py3-none-any.whl", hash = "sha256:52a7ce4a1d2d55fa6d657bf4760672935058143a5bedc64165851be25ce01dbd", size = 16634, upload-time = "2026-03-20T14:38:12.319Z" }, ] [[package]] name = "supabase-auth" -version = "2.12.3" +version = "2.28.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx", extra = ["http2"] }, { name = "pydantic" }, - { name = "pyjwt" }, + { name = "pyjwt", extra = ["crypto"] }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e9/e9/3d6f696a604752803b9e389b04d454f4b26a29b5d155b257fea4af8dc543/supabase_auth-2.12.3.tar.gz", hash = "sha256:8d3b67543f3b27f5adbfe46b66990424c8504c6b08c1141ec572a9802761edc2", size = 38430, upload-time = "2025-07-04T06:49:22.906Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/6f/1bf81293374ba71183b321bf5dfd7151c3db0c2e24715f35783bc1c56385/supabase_auth-2.28.3.tar.gz", hash = "sha256:41c049da82f9d7fc2f111808e57e984015f128d033f58caa67fd76f428472807", size = 39160, upload-time = "2026-03-20T14:38:15.128Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/96/a6/4102d5fa08a8521d9432b4d10bb58fedbd1f92b211d1b45d5394f5cb9021/supabase_auth-2.12.3-py3-none-any.whl", hash = "sha256:15c7580e1313d30ffddeb3221cb3cdb87c2a80fd220bf85d67db19cd1668435b", size = 44417, upload-time = "2025-07-04T06:49:21.351Z" }, + { url = "https://files.pythonhosted.org/packages/c3/d3/e012315aa895b434fa77bc475e2dfeb87119e67918ecca4d88a25f96814d/supabase_auth-2.28.3-py3-none-any.whl", hash = "sha256:e47c5caec7bbf3c258964d027fbbe99f3cc4a956d3a635f898c962b4d22832dd", size = 48378, upload-time = "2026-03-20T14:38:14.169Z" }, ] [[package]] name = "supabase-functions" -version = "0.10.1" +version = "2.28.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx", extra = ["http2"] }, { name = "strenum" }, + { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6c/e4/6df7cd4366396553449e9907c745862ebf010305835b2bac99933dd7db9d/supabase_functions-0.10.1.tar.gz", hash = "sha256:4779d33a1cc3d4aea567f586b16d8efdb7cddcd6b40ce367c5fb24288af3a4f1", size = 5025, upload-time = "2025-06-23T18:26:12.239Z" } +sdist = { url = "https://files.pythonhosted.org/packages/19/ea/59bf327960e5384fcc9e69afbdf97260a2cf2684a25c0731968a8a393b9c/supabase_functions-2.28.3.tar.gz", hash = "sha256:5a6255d60a263d44251c5ca250fcdde2408a8483a8bf31f4ac80255de8f3fcae", size = 4679, upload-time = "2026-03-20T14:38:16.742Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bc/06/060118a1e602c9bda8e4bf950bd1c8b5e1542349f2940ec57541266fabe1/supabase_functions-0.10.1-py3-none-any.whl", hash = "sha256:1db85e20210b465075aacee4e171332424f7305f9903c5918096be1423d6fcc5", size = 8275, upload-time = "2025-06-23T18:26:10.387Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ca/1e720f1347a88519e3d52b6d801cd031c3a7a5df66640c5dc6e81d925057/supabase_functions-2.28.3-py3-none-any.whl", hash = "sha256:eb30578866103fed9322c54e95dd68c2f1a4b6b177e129d9369edd364637904e", size = 8801, upload-time = "2026-03-20T14:38:15.883Z" }, ] [[package]] @@ -7413,7 +7479,7 @@ wheels = [ [[package]] name = "xinference-client" -version = "2.4.0" +version = "2.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohttp" }, @@ -7421,9 +7487,9 @@ dependencies = [ { name = "requests" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0e/f2/7640528fd4f816df19afe91d52332a658ad2d2bacb13471b0a27dbd0cf46/xinference_client-2.4.0.tar.gz", hash = "sha256:59de6d58f89126c8ff05136818e0756108e534858255d7c4c0673b804fd2d01d", size = 58386, upload-time = "2026-03-29T05:10:58.533Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/8a/4d7c72510f3c462195c2e7aa63559cafcf20f7d1901132d533b7498bab1c/xinference_client-2.5.0.tar.gz", hash = "sha256:0680324e2f438b8b208ca80e8a7e1c22e9152fce54f8c024c75e2ce57bfa5639", size = 58430, upload-time = "2026-04-13T07:21:40.145Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/73/cf/9d27e0095cc28691c73ff186b33556790c7b87f046ca2ecd517c80272592/xinference_client-2.4.0-py3-none-any.whl", hash = "sha256:2f9478b00fe15643f281fe4c0643e74479c8b7837d377000ff120702cda81efc", size = 40012, upload-time = "2026-03-29T05:10:57.279Z" }, + { url = "https://files.pythonhosted.org/packages/5b/dd/4fd501b8092c01f0775142850e3b601d743edf733077b756defe4a01cc37/xinference_client-2.5.0-py3-none-any.whl", hash = "sha256:bb90f069a2c30ac6ea7453ab37a0fadd34c28b655afa51fe20c18e67a361c269", size = 40006, upload-time = "2026-04-13T07:21:38.851Z" }, ] [[package]] diff --git a/docker/.env.example b/docker/.env.example index 8176155698..ec7d572057 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -273,7 +273,7 @@ SQLALCHEMY_POOL_TIMEOUT=30 # Default is 100 # # Reference: https://www.postgresql.org/docs/current/runtime-config-connection.html#GUC-MAX-CONNECTIONS -POSTGRES_MAX_CONNECTIONS=100 +POSTGRES_MAX_CONNECTIONS=200 # Sets the amount of shared memory used for postgres's shared buffers. # Default is 128MB diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index a10fdf77c6..aaf099453a 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -70,7 +70,7 @@ x-shared-env: &shared-api-worker-env SQLALCHEMY_POOL_PRE_PING: ${SQLALCHEMY_POOL_PRE_PING:-false} SQLALCHEMY_POOL_USE_LIFO: ${SQLALCHEMY_POOL_USE_LIFO:-false} SQLALCHEMY_POOL_TIMEOUT: ${SQLALCHEMY_POOL_TIMEOUT:-30} - POSTGRES_MAX_CONNECTIONS: ${POSTGRES_MAX_CONNECTIONS:-100} + POSTGRES_MAX_CONNECTIONS: ${POSTGRES_MAX_CONNECTIONS:-200} POSTGRES_SHARED_BUFFERS: ${POSTGRES_SHARED_BUFFERS:-128MB} POSTGRES_WORK_MEM: ${POSTGRES_WORK_MEM:-4MB} POSTGRES_MAINTENANCE_WORK_MEM: ${POSTGRES_MAINTENANCE_WORK_MEM:-64MB} diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 798ae1ec28..96af36d27a 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -129,11 +129,6 @@ "count": 3 } }, - "web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/config-button.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/config-popup.tsx": { "no-restricted-imports": { "count": 1 @@ -1086,21 +1081,11 @@ "count": 1 } }, - "web/app/components/base/date-and-time-picker/date-picker/index.tsx": { - "react/set-state-in-effect": { - "count": 4 - } - }, "web/app/components/base/date-and-time-picker/hooks.ts": { "react/no-unnecessary-use-prefix": { "count": 2 } }, - "web/app/components/base/date-and-time-picker/time-picker/index.tsx": { - "react/set-state-in-effect": { - "count": 2 - } - }, "web/app/components/base/date-and-time-picker/types.ts": { "erasable-syntax-only/enums": { "count": 2 @@ -1195,11 +1180,6 @@ "count": 5 } }, - "web/app/components/base/features/new-feature-panel/file-upload/setting-modal.tsx": { - "ts/no-explicit-any": { - "count": 1 - } - }, "web/app/components/base/features/new-feature-panel/moderation/form-generation.tsx": { "no-restricted-imports": { "count": 1 @@ -1223,11 +1203,6 @@ "count": 2 } }, - "web/app/components/base/features/new-feature-panel/text-to-speech/voice-settings.tsx": { - "ts/no-explicit-any": { - "count": 1 - } - }, "web/app/components/base/features/types.ts": { "erasable-syntax-only/enums": { "count": 2 @@ -1878,11 +1853,6 @@ "count": 1 } }, - "web/app/components/base/portal-to-follow-elem/index.tsx": { - "ts/no-explicit-any": { - "count": 1 - } - }, "web/app/components/base/prompt-editor/index.stories.tsx": { "no-console": { "count": 1 @@ -1906,11 +1876,6 @@ "count": 1 } }, - "web/app/components/base/prompt-editor/plugins/context-block/component.tsx": { - "ts/no-explicit-any": { - "count": 1 - } - }, "web/app/components/base/prompt-editor/plugins/context-block/index.tsx": { "no-barrel-files/no-barrel-files": { "count": 3 @@ -1940,11 +1905,6 @@ "count": 2 } }, - "web/app/components/base/prompt-editor/plugins/history-block/component.tsx": { - "ts/no-explicit-any": { - "count": 1 - } - }, "web/app/components/base/prompt-editor/plugins/history-block/index.tsx": { "no-barrel-files/no-barrel-files": { "count": 3 @@ -2268,16 +2228,6 @@ "count": 1 } }, - "web/app/components/datasets/common/document-picker/index.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "web/app/components/datasets/common/document-picker/preview-document-picker.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/datasets/common/image-previewer/index.tsx": { "no-irregular-whitespace": { "count": 1 @@ -2894,14 +2844,6 @@ "count": 1 } }, - "web/app/components/datasets/settings/permission-selector/index.tsx": { - "no-restricted-imports": { - "count": 1 - }, - "react/no-missing-key": { - "count": 1 - } - }, "web/app/components/datasets/settings/summary-index-setting.tsx": { "no-restricted-imports": { "count": 1 @@ -3069,21 +3011,11 @@ "count": 1 } }, - "web/app/components/header/account-setting/api-based-extension-page/selector.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/header/account-setting/data-source-page-new/card.tsx": { "ts/no-explicit-any": { "count": 2 } }, - "web/app/components/header/account-setting/data-source-page-new/configure.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/header/account-setting/data-source-page-new/hooks/index.ts": { "no-barrel-files/no-barrel-files": { "count": 2 @@ -3167,19 +3099,6 @@ "count": 4 } }, - "web/app/components/header/account-setting/model-provider-page/model-auth/add-custom-model.tsx": { - "no-restricted-imports": { - "count": 2 - } - }, - "web/app/components/header/account-setting/model-provider-page/model-auth/authorized/index.tsx": { - "no-restricted-imports": { - "count": 2 - }, - "ts/no-explicit-any": { - "count": 2 - } - }, "web/app/components/header/account-setting/model-provider-page/model-auth/config-provider.tsx": { "no-restricted-imports": { "count": 1 @@ -3411,11 +3330,6 @@ "count": 1 } }, - "web/app/components/plugins/marketplace/search-box/tags-filter.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/plugins/plugin-auth/authorize/add-oauth-button.tsx": { "ts/no-explicit-any": { "count": 2 @@ -3447,14 +3361,6 @@ "count": 1 } }, - "web/app/components/plugins/plugin-auth/authorized/index.tsx": { - "no-restricted-imports": { - "count": 2 - }, - "ts/no-explicit-any": { - "count": 2 - } - }, "web/app/components/plugins/plugin-auth/authorized/item.tsx": { "no-restricted-imports": { "count": 1 @@ -3510,16 +3416,6 @@ "count": 8 } }, - "web/app/components/plugins/plugin-detail-panel/app-selector/app-picker.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "web/app/components/plugins/plugin-detail-panel/app-selector/index.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/plugins/plugin-detail-panel/datasource-action-list.tsx": { "ts/no-explicit-any": { "count": 1 @@ -3713,11 +3609,6 @@ "count": 2 } }, - "web/app/components/plugins/plugin-detail-panel/tool-selector/index.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/plugins/plugin-detail-panel/trigger/event-detail-drawer.tsx": { "ts/no-explicit-any": { "count": 5 @@ -3756,16 +3647,6 @@ "count": 2 } }, - "web/app/components/plugins/plugin-page/filter-management/category-filter.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "web/app/components/plugins/plugin-page/filter-management/tag-filter.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/plugins/plugin-page/index.tsx": { "no-restricted-imports": { "count": 1 @@ -3918,11 +3799,6 @@ "count": 1 } }, - "web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/index.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/rag-pipeline/components/rag-pipeline-header/run-mode.tsx": { "ts/no-explicit-any": { "count": 1 @@ -4248,11 +4124,6 @@ "count": 1 } }, - "web/app/components/workflow/block-selector/main.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/workflow/block-selector/market-place-plugin/action.tsx": { "react/set-state-in-effect": { "count": 1 @@ -4378,19 +4249,6 @@ "count": 1 } }, - "web/app/components/workflow/header/view-history.tsx": { - "no-restricted-imports": { - "count": 2 - } - }, - "web/app/components/workflow/header/view-workflow-history.tsx": { - "no-restricted-imports": { - "count": 1 - }, - "ts/no-explicit-any": { - "count": 1 - } - }, "web/app/components/workflow/hooks-store/index.ts": { "no-barrel-files/no-barrel-files": { "count": 2 @@ -5053,11 +4911,6 @@ "count": 5 } }, - "web/app/components/workflow/nodes/human-input/components/button-style-dropdown.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/workflow/nodes/human-input/components/delivery-method/email-configure-modal.tsx": { "no-restricted-imports": { "count": 1 @@ -5306,16 +5159,6 @@ "count": 1 } }, - "web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/metadata-trigger.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "web/app/components/workflow/nodes/knowledge-retrieval/components/retrieval-config.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/workflow/nodes/knowledge-retrieval/default.ts": { "ts/no-explicit-any": { "count": 1 @@ -5414,17 +5257,6 @@ "count": 2 } }, - "web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/index.tsx": { - "erasable-syntax-only/enums": { - "count": 1 - }, - "no-restricted-imports": { - "count": 1 - }, - "react/set-state-in-effect": { - "count": 2 - } - }, "web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/prompt-editor.tsx": { "no-restricted-imports": { "count": 1 @@ -5871,24 +5703,11 @@ "count": 1 } }, - "web/app/components/workflow/note-node/note-editor/toolbar/color-picker.tsx": { - "no-restricted-imports": { - "count": 1 - }, - "react-refresh/only-export-components": { - "count": 1 - } - }, "web/app/components/workflow/note-node/note-editor/toolbar/command.tsx": { "no-restricted-imports": { "count": 1 } }, - "web/app/components/workflow/note-node/note-editor/toolbar/font-size-selector.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/workflow/note-node/note-editor/utils.ts": { "regexp/no-useless-quantifier": { "count": 1 @@ -6012,11 +5831,6 @@ "count": 1 } }, - "web/app/components/workflow/panel/version-history-panel/filter/index.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/workflow/panel/version-history-panel/restore-confirm-modal.tsx": { "no-restricted-imports": { "count": 1 diff --git a/web/__mocks__/__tests__/base-ui-popover.spec.tsx b/web/__mocks__/__tests__/base-ui-popover.spec.tsx new file mode 100644 index 0000000000..3b5b741ca0 --- /dev/null +++ b/web/__mocks__/__tests__/base-ui-popover.spec.tsx @@ -0,0 +1,127 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import * as React from 'react' +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '../base-ui-popover' + +type PopoverHarnessProps = { + useRenderElement?: boolean + preventDefaultOnTrigger?: boolean +} + +const PopoverHarness = ({ + useRenderElement = false, + preventDefaultOnTrigger = false, +}: PopoverHarnessProps) => { + const [open, setOpen] = React.useState(false) + + return ( +
+
outside
+ + { + if (preventDefaultOnTrigger) + event.preventDefault() + }} + > + toggle + + ) + : undefined} + > + fallback trigger + + } + popupProps={{ 'data-popup': 'true' } as unknown as React.HTMLAttributes} + > +
popover body
+
+
+
{open ? 'open' : 'closed'}
+
+ ) +} + +describe('base-ui-popover mock', () => { + it('should toggle popover content from the fallback trigger and expose content props', () => { + render() + + expect(screen.getByTestId('open-state')).toHaveTextContent('closed') + expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument() + + fireEvent.click(screen.getByTestId('popover-trigger')) + + expect(screen.getByTestId('open-state')).toHaveTextContent('open') + expect(screen.getByTestId('popover-content')).toHaveAttribute('data-placement', 'bottom-start') + expect(screen.getByTestId('popover-content')).toHaveAttribute('data-side-offset', '4') + expect(screen.getByTestId('popover-content')).toHaveAttribute('data-align-offset', '8') + expect(screen.getByTestId('popover-content')).toHaveAttribute('data-positioner', 'true') + expect(screen.getByTestId('popover-content')).toHaveAttribute('data-popup', 'true') + expect(screen.getByTestId('popover-content')).toHaveClass('custom-content') + }) + + it('should keep the popover open on inside clicks and close it on outside clicks or escape', () => { + render() + + fireEvent.click(screen.getByTestId('custom-trigger')) + expect(screen.getByTestId('open-state')).toHaveTextContent('open') + + fireEvent.mouseDown(screen.getByTestId('popover-content')) + expect(screen.getByTestId('open-state')).toHaveTextContent('open') + + fireEvent.keyDown(document, { key: 'Escape' }) + expect(screen.getByTestId('open-state')).toHaveTextContent('closed') + + fireEvent.click(screen.getByTestId('custom-trigger')) + expect(screen.getByTestId('open-state')).toHaveTextContent('open') + + fireEvent.mouseDown(screen.getByTestId('outside-area')) + expect(screen.getByTestId('open-state')).toHaveTextContent('closed') + }) + + it('should preserve rendered trigger props and respect preventDefault', () => { + render() + + fireEvent.click(screen.getByTestId('custom-trigger')) + + expect(screen.getByTestId('custom-trigger')).toHaveAttribute('data-popover-trigger', 'true') + expect(screen.getByTestId('open-state')).toHaveTextContent('closed') + expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument() + }) + + it('should keep the popover closed when the fallback trigger click is prevented', () => { + const handleClick = (event: React.MouseEvent) => { + event.preventDefault() + } + + render( +
+ + + fallback trigger + + +
popover body
+
+
+
, + ) + + fireEvent.click(screen.getByTestId('popover-trigger')) + + expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument() + }) +}) diff --git a/web/__mocks__/base-ui-popover.tsx b/web/__mocks__/base-ui-popover.tsx new file mode 100644 index 0000000000..8818f60f4e --- /dev/null +++ b/web/__mocks__/base-ui-popover.tsx @@ -0,0 +1,154 @@ +import type { ReactNode } from 'react' +import * as React from 'react' + +const PopoverContext = React.createContext({ + open: false, + onOpenChange: (_open: boolean) => {}, +}) + +type PopoverProps = { + children?: ReactNode + open?: boolean + onOpenChange?: (open: boolean) => void +} + +type PopoverTriggerProps = React.HTMLAttributes & { + children?: ReactNode + nativeButton?: boolean + render?: React.ReactElement +} + +type PopoverContentProps = React.HTMLAttributes & { + children?: ReactNode + placement?: string + sideOffset?: number + alignOffset?: number + positionerProps?: React.HTMLAttributes + popupProps?: React.HTMLAttributes +} + +export const Popover = ({ + children, + open = false, + onOpenChange, +}: PopoverProps) => { + React.useEffect(() => { + if (!open) + return + + const handleMouseDown = (event: MouseEvent) => { + const target = event.target as Element | null + if (target?.closest?.('[data-popover-trigger="true"], [data-popover-content="true"]')) + return + + onOpenChange?.(false) + } + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') + onOpenChange?.(false) + } + + document.addEventListener('mousedown', handleMouseDown) + document.addEventListener('keydown', handleKeyDown) + + return () => { + document.removeEventListener('mousedown', handleMouseDown) + document.removeEventListener('keydown', handleKeyDown) + } + }, [open, onOpenChange]) + + return ( + {}), + }} + > +
+ {children} +
+
+ ) +} + +export const PopoverTrigger = ({ + children, + render, + nativeButton: _nativeButton, + onClick, + ...props +}: PopoverTriggerProps) => { + const { open, onOpenChange } = React.useContext(PopoverContext) + const node = render ?? children + + if (React.isValidElement(node)) { + const triggerElement = node as React.ReactElement> + const childProps = (triggerElement.props ?? {}) as React.HTMLAttributes & { 'data-testid'?: string } + + return React.cloneElement(triggerElement, { + ...props, + ...childProps, + 'data-testid': childProps['data-testid'] ?? 'popover-trigger', + 'data-popover-trigger': 'true', + 'onClick': (event: React.MouseEvent) => { + childProps.onClick?.(event) + onClick?.(event) + if (event.defaultPrevented) + return + onOpenChange(!open) + }, + }) + } + + return ( +
{ + onClick?.(event) + if (event.defaultPrevented) + return + onOpenChange(!open) + }} + {...props} + > + {node} +
+ ) +} + +export const PopoverContent = ({ + children, + className, + placement, + sideOffset, + alignOffset, + positionerProps, + popupProps, + ...props +}: PopoverContentProps) => { + const { open } = React.useContext(PopoverContext) + + if (!open) + return null + + return ( +
+ {children} +
+ ) +} + +export const PopoverClose = ({ children }: { children?: ReactNode }) => <>{children} +export const PopoverTitle = ({ children }: { children?: ReactNode }) => <>{children} +export const PopoverDescription = ({ children }: { children?: ReactNode }) => <>{children} diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/config-button.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/config-button.tsx index 29de1a1eae..6d7c178728 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/config-button.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/config-button.tsx @@ -3,13 +3,13 @@ import type { FC } from 'react' import type { PopupProps } from './config-popup' import { cn } from '@langgenius/dify-ui/cn' -import * as React from 'react' -import { useCallback, useRef, useState } from 'react' import { - PortalToFollowElem, - PortalToFollowElemContent, - PortalToFollowElemTrigger, -} from '@/app/components/base/portal-to-follow-elem' + Popover, + PopoverContent, + PopoverTrigger, +} from '@langgenius/dify-ui/popover' +import * as React from 'react' +import { useState } from 'react' import ConfigPopup from './config-popup' type Props = { @@ -25,36 +25,31 @@ const ConfigBtn: FC = ({ children, ...popupProps }) => { - const [open, doSetOpen] = useState(false) - const openRef = useRef(open) - const setOpen = useCallback((v: boolean) => { - doSetOpen(v) - openRef.current = v - }, [doSetOpen]) - - const handleTrigger = useCallback(() => { - setOpen(!openRef.current) - }, [setOpen]) + const [open, setOpen] = useState(false) if (popupProps.readOnly && !hasConfigured) return null return ( - - -
- {children} -
-
- + + {children} + + )} + /> + - -
+ + ) } export default React.memo(ConfigBtn) diff --git a/web/app/components/app/configuration/config-var/config-modal/__tests__/index-logic.spec.tsx b/web/app/components/app/configuration/config-var/config-modal/__tests__/index-logic.spec.tsx index 77f2b54533..e6cb56f490 100644 --- a/web/app/components/app/configuration/config-var/config-modal/__tests__/index-logic.spec.tsx +++ b/web/app/components/app/configuration/config-var/config-modal/__tests__/index-logic.spec.tsx @@ -43,7 +43,7 @@ vi.mock('../form-fields', () => ({ > invalid-name-change - + diff --git a/web/app/components/base/chat/chat-with-history/inputs-form/view-form-dropdown.tsx b/web/app/components/base/chat/chat-with-history/inputs-form/view-form-dropdown.tsx index 057f3d03df..3b82db72db 100644 --- a/web/app/components/base/chat/chat-with-history/inputs-form/view-form-dropdown.tsx +++ b/web/app/components/base/chat/chat-with-history/inputs-form/view-form-dropdown.tsx @@ -1,3 +1,4 @@ +import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import { RiChatSettingsLine, } from '@remixicon/react' @@ -6,30 +7,29 @@ import { useTranslation } from 'react-i18next' import ActionButton, { ActionButtonState } from '@/app/components/base/action-button' import InputsFormContent from '@/app/components/base/chat/chat-with-history/inputs-form/content' import { Message3Fill } from '@/app/components/base/icons/src/public/other' -import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem' const ViewFormDropdown = () => { const { t } = useTranslation() const [open, setOpen] = useState(false) return ( - - setOpen(v => !v)} + + + + )} + /> + - - - - -
@@ -39,8 +39,8 @@ const ViewFormDropdown = () => {
-
-
+ + ) } diff --git a/web/app/components/base/chat/chat/citation/__tests__/popup.spec.tsx b/web/app/components/base/chat/chat/citation/__tests__/popup.spec.tsx index 3a2cec9820..1b8518e869 100644 --- a/web/app/components/base/chat/chat/citation/__tests__/popup.spec.tsx +++ b/web/app/components/base/chat/chat/citation/__tests__/popup.spec.tsx @@ -6,6 +6,8 @@ import { useDocumentDownload } from '@/service/knowledge/use-document' import { downloadUrl } from '@/utils/download' import Popup from '../popup' +vi.mock('@langgenius/dify-ui/popover', async () => await import('@/__mocks__/base-ui-popover')) + vi.mock('@/service/knowledge/use-document', () => ({ useDocumentDownload: vi.fn(), })) diff --git a/web/app/components/base/chat/chat/citation/popup.tsx b/web/app/components/base/chat/chat/citation/popup.tsx index 51a73bc4b6..9ea4a6b742 100644 --- a/web/app/components/base/chat/chat/citation/popup.tsx +++ b/web/app/components/base/chat/chat/citation/popup.tsx @@ -1,13 +1,9 @@ import type { FC, MouseEvent } from 'react' import type { Resources } from './index' +import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import { Fragment, useState } from 'react' import { useTranslation } from 'react-i18next' import FileIcon from '@/app/components/base/file-icon' -import { - PortalToFollowElem, - PortalToFollowElemContent, - PortalToFollowElemTrigger, -} from '@/app/components/base/portal-to-follow-elem' import Link from '@/next/link' import { useDocumentDownload } from '@/service/knowledge/use-document' import { downloadUrl } from '@/utils/download' @@ -47,22 +43,25 @@ const Popup: FC = ({ } return ( - - setOpen(v => !v)}> -
- -
{data.documentName}
-
-
- + + +
{data.documentName}
+ + )} + /> +
@@ -156,8 +155,8 @@ const Popup: FC = ({
-
-
+ + ) } diff --git a/web/app/components/base/chat/embedded-chatbot/inputs-form/view-form-dropdown.tsx b/web/app/components/base/chat/embedded-chatbot/inputs-form/view-form-dropdown.tsx index 68cddb97b0..f7fc80819e 100644 --- a/web/app/components/base/chat/embedded-chatbot/inputs-form/view-form-dropdown.tsx +++ b/web/app/components/base/chat/embedded-chatbot/inputs-form/view-form-dropdown.tsx @@ -1,10 +1,10 @@ import { cn } from '@langgenius/dify-ui/cn' +import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import * as React from 'react' import { useState } from 'react' import { useTranslation } from 'react-i18next' import ActionButton, { ActionButtonState } from '@/app/components/base/action-button' import InputsFormContent from '@/app/components/base/chat/embedded-chatbot/inputs-form/content' -import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem' type Props = { iconColor?: string @@ -17,25 +17,27 @@ const ViewFormDropdown = ({ const [open, setOpen] = useState(false) return ( - - setOpen(v => !v)}> - -
- - - + +
+ + )} + /> +
-
- + + ) } diff --git a/web/app/components/base/date-and-time-picker/date-picker/__tests__/index.spec.tsx b/web/app/components/base/date-and-time-picker/date-picker/__tests__/index.spec.tsx index ea4f1bb928..d37647f358 100644 --- a/web/app/components/base/date-and-time-picker/date-picker/__tests__/index.spec.tsx +++ b/web/app/components/base/date-and-time-picker/date-picker/__tests__/index.spec.tsx @@ -3,6 +3,20 @@ import { act, fireEvent, render, screen, within } from '@testing-library/react' import dayjs from '../../utils/dayjs' import DatePicker from '../index' +vi.mock('@langgenius/dify-ui/popover', async () => await import('@/__mocks__/base-ui-popover')) +vi.mock('@langgenius/dify-ui/button', () => ({ + Button: ({ children, onClick, disabled, className }: { + children?: React.ReactNode + onClick?: () => void + disabled?: boolean + className?: string + }) => ( + + ), +})) + // Mock scrollIntoView beforeAll(() => { Element.prototype.scrollIntoView = vi.fn() @@ -113,14 +127,13 @@ describe('DatePicker', () => { render() openPicker() + expect(screen.getByTestId('popover')).toHaveAttribute('data-open', 'true') - // Simulate a mousedown event outside the container act(() => { document.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })) }) - // The picker should now be closed - input shows its value - // The picker should now be closed - input shows its value + expect(screen.getByTestId('popover')).toHaveAttribute('data-open', 'false') expect(screen.getByRole('textbox'))!.toBeInTheDocument() }) }) diff --git a/web/app/components/base/date-and-time-picker/date-picker/index.tsx b/web/app/components/base/date-and-time-picker/date-picker/index.tsx index 9c84e4c096..7858fa2fbe 100644 --- a/web/app/components/base/date-and-time-picker/date-picker/index.tsx +++ b/web/app/components/base/date-and-time-picker/date-picker/index.tsx @@ -1,14 +1,10 @@ import type { Dayjs } from 'dayjs' import type { DatePickerProps, Period } from '../types' import { cn } from '@langgenius/dify-ui/cn' +import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import * as React from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import { - PortalToFollowElem, - PortalToFollowElemContent, - PortalToFollowElemTrigger, -} from '@/app/components/base/portal-to-follow-elem' import Calendar from '../calendar' import TimePickerHeader from '../time-picker/header' import TimePickerOptions from '../time-picker/options' @@ -35,15 +31,14 @@ const DatePicker = ({ needTimePicker = true, renderTrigger, triggerWrapClassName, - popupZIndexClassname = 'z-11', + popupZIndexClassname, noConfirm, getIsDateDisabled, }: DatePickerProps) => { const { t } = useTranslation() const [isOpen, setIsOpen] = useState(false) const [view, setView] = useState(ViewType.date) - const containerRef = useRef(null) - const isInitial = useRef(true) + const isInitialRef = useRef(true) // Normalize the value to ensure that all subsequent uses are Day.js objects. const normalizedValue = useMemo(() => { @@ -62,46 +57,41 @@ const DatePicker = ({ const [selectedYear, setSelectedYear] = useState(() => (inputValue || defaultValue).year()) useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if (containerRef.current && !containerRef.current.contains(event.target as Node)) { - setIsOpen(false) - setView(ViewType.date) - } - } - document.addEventListener('mousedown', handleClickOutside) - return () => document.removeEventListener('mousedown', handleClickOutside) - }, []) - - useEffect(() => { - if (isInitial.current) { - isInitial.current = false + if (isInitialRef.current) { + isInitialRef.current = false return } clearMonthMapCache() if (normalizedValue) { const newValue = getDateWithTimezone({ date: normalizedValue, timezone }) + // eslint-disable-next-line react/set-state-in-effect -- timezone changes intentionally resync the displayed calendar state. setCurrentDate(newValue) + // eslint-disable-next-line react/set-state-in-effect -- timezone changes intentionally resync the selected value. setSelectedDate(newValue) onChange(newValue) } else { + // eslint-disable-next-line react/set-state-in-effect -- timezone changes intentionally resync the displayed calendar state. setCurrentDate(prev => getDateWithTimezone({ date: prev, timezone })) + // eslint-disable-next-line react/set-state-in-effect -- timezone changes intentionally resync the selected value. setSelectedDate(prev => prev ? getDateWithTimezone({ date: prev, timezone }) : undefined) } + // eslint-disable-next-line react/exhaustive-deps -- this effect intentionally runs only when timezone changes. }, [timezone]) - const handleClickTrigger = (e: React.MouseEvent) => { - e.stopPropagation() - if (isOpen) { - setIsOpen(false) - return - } + const handleOpenChange = useCallback((nextOpen: boolean) => { + setIsOpen(nextOpen) setView(ViewType.date) - setIsOpen(true) - if (normalizedValue) { + if (nextOpen && normalizedValue) { setCurrentDate(normalizedValue) setSelectedDate(normalizedValue) } + }, [normalizedValue]) + + const handleClickTrigger = (e: React.MouseEvent) => { + e.preventDefault() + e.stopPropagation() + handleOpenChange(!isOpen) } const handleClear = (e: React.MouseEvent) => { @@ -210,21 +200,21 @@ const DatePicker = ({ const placeholderDate = isOpen && selectedDate ? selectedDate.format(timeFormat) : (placeholder || t('defaultPlaceholder', { ns: 'time' })) return ( - - - {renderTrigger - ? ( - renderTrigger({ - value: normalizedValue, - selectedDate, - isOpen, - handleClear, - handleClickTrigger, - })) +
)} -
- + /> +
{/* Header */} {view === ViewType.date @@ -319,8 +314,8 @@ const DatePicker = ({ ) }
-
-
+ + ) } diff --git a/web/app/components/base/date-and-time-picker/time-picker/__tests__/index.spec.tsx b/web/app/components/base/date-and-time-picker/time-picker/__tests__/index.spec.tsx index 7fbed3a736..0d02b3b5d5 100644 --- a/web/app/components/base/date-and-time-picker/time-picker/__tests__/index.spec.tsx +++ b/web/app/components/base/date-and-time-picker/time-picker/__tests__/index.spec.tsx @@ -3,6 +3,20 @@ import { fireEvent, render, screen, within } from '@testing-library/react' import dayjs, { isDayjsObject } from '../../utils/dayjs' import TimePicker from '../index' +vi.mock('@langgenius/dify-ui/popover', async () => await import('@/__mocks__/base-ui-popover')) +vi.mock('@langgenius/dify-ui/button', () => ({ + Button: ({ children, onClick, disabled, className }: { + children?: React.ReactNode + onClick?: () => void + disabled?: boolean + className?: string + }) => ( + + ), +})) + // Mock scrollIntoView since the test DOM runtime doesn't implement it beforeAll(() => { Element.prototype.scrollIntoView = vi.fn() @@ -106,7 +120,7 @@ describe('TimePicker', () => { expect(input)!.toHaveValue('') fireEvent.mouseDown(document.body) - expect(input)!.toHaveValue('') + expect(input)!.toHaveValue('10:00 AM') }) it('should call onClear when clear is clicked while picker is closed', () => { diff --git a/web/app/components/base/date-and-time-picker/time-picker/index.tsx b/web/app/components/base/date-and-time-picker/time-picker/index.tsx index e07ea177a5..3fcb88215e 100644 --- a/web/app/components/base/date-and-time-picker/time-picker/index.tsx +++ b/web/app/components/base/date-and-time-picker/time-picker/index.tsx @@ -1,14 +1,10 @@ import type { Dayjs } from 'dayjs' import type { TimePickerProps } from '../types' import { cn } from '@langgenius/dify-ui/cn' +import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import * as React from 'react' import { useCallback, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import { - PortalToFollowElem, - PortalToFollowElemContent, - PortalToFollowElemTrigger, -} from '@/app/components/base/portal-to-follow-elem' import TimezoneLabel from '@/app/components/base/timezone-label' import { Period } from '../types' import dayjs, { @@ -43,31 +39,20 @@ const TimePicker = ({ }: TimePickerProps) => { const { t } = useTranslation() const [isOpen, setIsOpen] = useState(false) - const containerRef = useRef(null) - const isInitial = useRef(true) + const isInitialRef = useRef(true) // Initialize selectedTime const [selectedTime, setSelectedTime] = useState(() => { return toDayjs(value, { timezone }) }) - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - /* v8 ignore next 2 -- outside-click closing is handled by PortalToFollowElem; this local ref guard is a defensive fallback. */ - if (containerRef.current && !containerRef.current.contains(event.target as Node)) - setIsOpen(false) - } - document.addEventListener('mousedown', handleClickOutside) - return () => document.removeEventListener('mousedown', handleClickOutside) - }, []) - // Track previous values to avoid unnecessary updates const prevValueRef = useRef(value) const prevTimezoneRef = useRef(timezone) useEffect(() => { - if (isInitial.current) { - isInitial.current = false + if (isInitialRef.current) { + isInitialRef.current = false // Save initial values on first render prevValueRef.current = value prevTimezoneRef.current = timezone @@ -91,6 +76,7 @@ const TimePicker = ({ if (!dayjsValue) return + // eslint-disable-next-line react/set-state-in-effect -- value/timezone changes intentionally resync the internal selected time. setSelectedTime(dayjsValue) if (timezoneChanged && !valueChanged) @@ -98,6 +84,7 @@ const TimePicker = ({ return } + // eslint-disable-next-line react/set-state-in-effect -- value/timezone changes intentionally resync the internal selected time. setSelectedTime((prev) => { if (!isDayjsObject(prev)) return undefined @@ -105,24 +92,30 @@ const TimePicker = ({ }) }, [timezone, value, onChange]) - const handleClickTrigger = (e: React.MouseEvent) => { - e.stopPropagation() - if (isOpen) { - setIsOpen(false) + const syncSelectedTimeFromValue = useCallback(() => { + if (!value) return - } - setIsOpen(true) - if (value) { - const dayjsValue = toDayjs(value, { timezone }) - const needsUpdate = dayjsValue && ( - !selectedTime - || !isDayjsObject(selectedTime) - || !dayjsValue.isSame(selectedTime, 'minute') - ) - if (needsUpdate) - setSelectedTime(dayjsValue) - } + const dayjsValue = toDayjs(value, { timezone }) + const needsUpdate = dayjsValue && ( + !selectedTime + || !isDayjsObject(selectedTime) + || !dayjsValue.isSame(selectedTime, 'minute') + ) + if (needsUpdate) + setSelectedTime(dayjsValue) + }, [selectedTime, timezone, value]) + + const handleOpenChange = useCallback((nextOpen: boolean) => { + setIsOpen(nextOpen) + if (nextOpen) + syncSelectedTimeFromValue() + }, [syncSelectedTimeFromValue]) + + const handleClickTrigger = (e: React.MouseEvent) => { + e.preventDefault() + e.stopPropagation() + handleOpenChange(!isOpen) } const handleClear = (e: React.MouseEvent) => { @@ -132,7 +125,7 @@ const TimePicker = ({ onClear() } - const handleTimeSelect = (hour: string, minute: string, period: Period) => { + const handleTimeSelect = useCallback((hour: string, minute: string, period: Period) => { const periodAdjustedHour = to24Hour(hour, period) const nextMinute = Number.parseInt(minute, 10) setSelectedTime((prev) => { @@ -145,7 +138,7 @@ const TimePicker = ({ .set('second', 0) .set('millisecond', 0) }) - } + }, [timezone]) const getSafeTimeObject = useCallback(() => { if (isDayjsObject(selectedTime)) @@ -156,17 +149,17 @@ const TimePicker = ({ const handleSelectHour = useCallback((hour: string) => { const time = getSafeTimeObject() handleTimeSelect(hour, time.minute().toString().padStart(2, '0'), time.format('A') as Period) - }, [getSafeTimeObject]) + }, [getSafeTimeObject, handleTimeSelect]) const handleSelectMinute = useCallback((minute: string) => { const time = getSafeTimeObject() handleTimeSelect(getHourIn12Hour(time).toString().padStart(2, '0'), minute, time.format('A') as Period) - }, [getSafeTimeObject]) + }, [getSafeTimeObject, handleTimeSelect]) const handleSelectPeriod = useCallback((period: Period) => { const time = getSafeTimeObject() handleTimeSelect(getHourIn12Hour(time).toString().padStart(2, '0'), time.minute().toString().padStart(2, '0'), period) - }, [getSafeTimeObject]) + }, [getSafeTimeObject, handleTimeSelect]) const handleSelectCurrentTime = useCallback(() => { const newDate = getDateWithTimezone({ timezone }) @@ -207,18 +200,19 @@ const TimePicker = ({ /> ) return ( - - - {renderTrigger - ? (renderTrigger({ + - + /> +
{/* Header */}
@@ -258,8 +257,8 @@ const TimePicker = ({ />
-
-
+ + ) } diff --git a/web/app/components/base/date-and-time-picker/types.ts b/web/app/components/base/date-and-time-picker/types.ts index 0068ec22ac..2773fb7bc7 100644 --- a/web/app/components/base/date-and-time-picker/types.ts +++ b/web/app/components/base/date-and-time-picker/types.ts @@ -28,7 +28,7 @@ export type DatePickerProps = { onChange: (date: Dayjs | undefined) => void onClear: () => void triggerWrapClassName?: string - renderTrigger?: (props: TriggerProps) => React.ReactNode + renderTrigger?: (props: TriggerProps) => React.ReactElement minuteFilter?: (minutes: string[]) => string[] popupZIndexClassname?: string noConfirm?: boolean @@ -62,7 +62,7 @@ export type TimePickerProps = { placeholder?: string onChange: (date: Dayjs | undefined) => void onClear: () => void - renderTrigger?: (props: TriggerParams) => React.ReactNode + renderTrigger?: (props: TriggerParams) => React.ReactElement title?: string minuteFilter?: (minutes: string[]) => string[] popupClassName?: string diff --git a/web/app/components/base/features/new-feature-panel/file-upload/__tests__/setting-modal.spec.tsx b/web/app/components/base/features/new-feature-panel/file-upload/__tests__/setting-modal.spec.tsx index dc111a680b..6259c7cb4f 100644 --- a/web/app/components/base/features/new-feature-panel/file-upload/__tests__/setting-modal.spec.tsx +++ b/web/app/components/base/features/new-feature-panel/file-upload/__tests__/setting-modal.spec.tsx @@ -61,7 +61,7 @@ describe('FileUploadSettings (setting-modal)', () => { }) }) - it('should call onOpen with toggle function when trigger is clicked', () => { + it('should call onOpen with true when trigger is clicked', () => { const onOpen = vi.fn() renderWithProvider( @@ -71,12 +71,7 @@ describe('FileUploadSettings (setting-modal)', () => { fireEvent.click(screen.getByText('Upload Settings')) - expect(onOpen).toHaveBeenCalled() - // The toggle function should flip the open state - const toggleFn = onOpen.mock.calls[0]![0] - expect(typeof toggleFn).toBe('function') - expect(toggleFn(false)).toBe(true) - expect(toggleFn(true)).toBe(false) + expect(onOpen).toHaveBeenCalledWith(true) }) it('should not call onOpen when disabled', () => { diff --git a/web/app/components/base/features/new-feature-panel/file-upload/setting-modal.tsx b/web/app/components/base/features/new-feature-panel/file-upload/setting-modal.tsx index 2a09f63bee..a1c6bffbe0 100644 --- a/web/app/components/base/features/new-feature-panel/file-upload/setting-modal.tsx +++ b/web/app/components/base/features/new-feature-panel/file-upload/setting-modal.tsx @@ -1,16 +1,16 @@ 'use client' import type { OnFeaturesChange } from '@/app/components/base/features/types' +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@langgenius/dify-ui/popover' import { memo } from 'react' import SettingContent from '@/app/components/base/features/new-feature-panel/file-upload/setting-content' -import { - PortalToFollowElem, - PortalToFollowElemContent, - PortalToFollowElemTrigger, -} from '@/app/components/base/portal-to-follow-elem' type FileUploadSettingsProps = { open: boolean - onOpen: (state: any) => void + onOpen: (state: boolean) => void onChange?: OnFeaturesChange disabled?: boolean children?: React.ReactNode @@ -25,18 +25,27 @@ const FileUploadSettings = ({ imageUpload, }: FileUploadSettingsProps) => { return ( - { + if (disabled) + return + onOpen(nextOpen) }} > - !disabled && onOpen((open: boolean) => !open)}> - {children} - - + + {children} + + )} + /> +
-
-
+ + ) } export default memo(FileUploadSettings) diff --git a/web/app/components/base/features/new-feature-panel/text-to-speech/__tests__/voice-settings.spec.tsx b/web/app/components/base/features/new-feature-panel/text-to-speech/__tests__/voice-settings.spec.tsx index 574aeddd4a..b2dd37d1e8 100644 --- a/web/app/components/base/features/new-feature-panel/text-to-speech/__tests__/voice-settings.spec.tsx +++ b/web/app/components/base/features/new-feature-panel/text-to-speech/__tests__/voice-settings.spec.tsx @@ -1,38 +1,17 @@ +import type { ReactNode } from 'react' import type { Features } from '../../../types' import { fireEvent, render, screen } from '@testing-library/react' import { FeaturesProvider } from '../../../context' import VoiceSettings from '../voice-settings' -vi.mock('@/app/components/base/portal-to-follow-elem', () => ({ - PortalToFollowElem: ({ - children, - placement, - offset, - }: { - children: React.ReactNode - placement?: string - offset?: { mainAxis?: number } - }) => ( -
- {children} -
- ), - PortalToFollowElemTrigger: ({ - children, - onClick, - }: { - children: React.ReactNode - onClick?: () => void - }) => ( -
- {children} -
- ), - PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) =>
{children}
, +vi.mock('@langgenius/dify-ui/popover', () => import('@/__mocks__/base-ui-popover')) +vi.mock('@langgenius/dify-ui/toast', () => ({ + toast: { + success: vi.fn(), + error: vi.fn(), + warning: vi.fn(), + info: vi.fn(), + }, })) vi.mock('@/next/navigation', () => ({ @@ -46,6 +25,25 @@ vi.mock('@/service/use-apps', () => ({ }), })) +vi.mock('@langgenius/dify-ui/switch', () => ({ + Switch: ({ + checked, + onCheckedChange, + ...props + }: { + checked?: boolean + onCheckedChange?: (checked: boolean) => void + }) => ( + , ) - const portal = screen.getAllByTestId('voice-settings-portal') - .find(item => item.hasAttribute('data-main-axis')) - - expect(portal).toBeDefined() - expect(portal)!.toHaveAttribute('data-placement', 'top') - expect(portal)!.toHaveAttribute('data-main-axis', '4') + const content = screen.getByTestId('popover-content') + expect(content).toHaveAttribute('data-placement', 'top') + expect(content).toHaveAttribute('data-side-offset', '4') }) }) diff --git a/web/app/components/base/features/new-feature-panel/text-to-speech/voice-settings.tsx b/web/app/components/base/features/new-feature-panel/text-to-speech/voice-settings.tsx index 3717c76352..7375733299 100644 --- a/web/app/components/base/features/new-feature-panel/text-to-speech/voice-settings.tsx +++ b/web/app/components/base/features/new-feature-panel/text-to-speech/voice-settings.tsx @@ -1,16 +1,16 @@ 'use client' import type { OnFeaturesChange } from '@/app/components/base/features/types' +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@langgenius/dify-ui/popover' import { memo } from 'react' import ParamConfigContent from '@/app/components/base/features/new-feature-panel/text-to-speech/param-config-content' -import { - PortalToFollowElem, - PortalToFollowElemContent, - PortalToFollowElemTrigger, -} from '@/app/components/base/portal-to-follow-elem' type VoiceSettingsProps = { open: boolean - onOpen: (state: any) => void + onOpen: (state: boolean) => void onChange?: OnFeaturesChange disabled?: boolean children?: React.ReactNode @@ -25,23 +25,32 @@ const VoiceSettings = ({ placementLeft = true, }: VoiceSettingsProps) => { return ( - { + if (disabled) + return + onOpen(nextOpen) }} > - !disabled && onOpen((open: boolean) => !open)}> - {children} - - + + {children} + + )} + /> +
onOpen(false)} onChange={onChange} />
-
-
+ + ) } export default memo(VoiceSettings) diff --git a/web/app/components/base/file-uploader/file-from-link-or-local/index.tsx b/web/app/components/base/file-uploader/file-from-link-or-local/index.tsx index 912c2d3c48..27c6c36f6c 100644 --- a/web/app/components/base/file-uploader/file-from-link-or-local/index.tsx +++ b/web/app/components/base/file-uploader/file-from-link-or-local/index.tsx @@ -1,17 +1,17 @@ import type { FileUpload } from '@/app/components/base/features/types' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@langgenius/dify-ui/popover' import { RiUploadCloud2Line } from '@remixicon/react' import { memo, useState, } from 'react' import { useTranslation } from 'react-i18next' -import { - PortalToFollowElem, - PortalToFollowElemContent, - PortalToFollowElemTrigger, -} from '@/app/components/base/portal-to-follow-elem' import { FILE_URL_REGEX } from '../constants' import FileInput from '../file-input' import { useFile } from '../hooks' @@ -54,16 +54,16 @@ const FileFromLinkOrLocal = ({ } return ( - - setOpen(v => !v)} asChild> - {trigger(open)} - - + +
{ showFromLink && ( @@ -126,8 +126,8 @@ const FileFromLinkOrLocal = ({ ) }
-
-
+ + ) } diff --git a/web/app/components/base/image-uploader/chat-image-uploader.tsx b/web/app/components/base/image-uploader/chat-image-uploader.tsx index b2f6a106c6..5093b33bd0 100644 --- a/web/app/components/base/image-uploader/chat-image-uploader.tsx +++ b/web/app/components/base/image-uploader/chat-image-uploader.tsx @@ -1,13 +1,13 @@ import type { FC } from 'react' import type { ImageFile, VisionSettings } from '@/types/app' import { cn } from '@langgenius/dify-ui/cn' +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@langgenius/dify-ui/popover' import { useState } from 'react' import { useTranslation } from 'react-i18next' -import { - PortalToFollowElem, - PortalToFollowElemContent, - PortalToFollowElemTrigger, -} from '@/app/components/base/portal-to-follow-elem' import { TransferMethod } from '@/types/app' import ImageLinkInput from './image-link-input' import Uploader from './uploader' @@ -63,29 +63,31 @@ const UploaderButton: FC = ({ const closePopover = () => setOpen(false) - const handleToggle = () => { - if (disabled) - return - - setOpen(v => !v) - } - return ( - { + if (disabled) + return + setOpen(nextOpen) + }} > - - - - + + + + )} + /> +
{!!hasUploadFromLocal && ( @@ -115,8 +117,8 @@ const UploaderButton: FC = ({ )}
-
-
+ + ) } diff --git a/web/app/components/base/image-uploader/text-generation-image-uploader.tsx b/web/app/components/base/image-uploader/text-generation-image-uploader.tsx index 1b986744f2..af05eb2a35 100644 --- a/web/app/components/base/image-uploader/text-generation-image-uploader.tsx +++ b/web/app/components/base/image-uploader/text-generation-image-uploader.tsx @@ -1,5 +1,10 @@ import type { FC } from 'react' import type { ImageFile, VisionSettings } from '@/types/app' +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@langgenius/dify-ui/popover' import { Fragment, useEffect, @@ -8,11 +13,6 @@ import { import { useTranslation } from 'react-i18next' import { Link03 } from '@/app/components/base/icons/src/vender/line/general' import { ImagePlus } from '@/app/components/base/icons/src/vender/line/images' -import { - PortalToFollowElem, - PortalToFollowElemContent, - PortalToFollowElemTrigger, -} from '@/app/components/base/portal-to-follow-elem' import { TransferMethod } from '@/types/app' import { useImageFiles } from './hooks' import ImageLinkInput from './image-link-input' @@ -35,35 +35,38 @@ const PasteImageLinkButton: FC = ({ onUpload(imageFile) } - const handleToggle = () => { - if (disabled) - return - - setOpen(v => !v) - } - return ( - { + if (disabled) + return + setOpen(nextOpen) + }} > - -
- - {t('imageUploader.pasteImageLink', { ns: 'common' })} -
-
- + + + {t('imageUploader.pasteImageLink', { ns: 'common' })} + + )} + /> +
-
-
+ + ) } diff --git a/web/app/components/base/markdown-with-directive/index.tsx b/web/app/components/base/markdown-with-directive/index.tsx index 552d8a3aaa..8da4cf7ede 100644 --- a/web/app/components/base/markdown-with-directive/index.tsx +++ b/web/app/components/base/markdown-with-directive/index.tsx @@ -91,7 +91,7 @@ function buildDirectiveRehypePlugins(): PluggableList { ]) const attributes: Record = { - ...(defaultSanitizeSchema.attributes ?? {}), + ...defaultSanitizeSchema.attributes, } for (const [tagName, allowedAttributes] of Object.entries(DIRECTIVE_ALLOWED_TAGS)) diff --git a/web/app/components/base/markdown/streamdown-wrapper.tsx b/web/app/components/base/markdown/streamdown-wrapper.tsx index 342f5cf44b..e566ffc611 100644 --- a/web/app/components/base/markdown/streamdown-wrapper.tsx +++ b/web/app/components/base/markdown/streamdown-wrapper.tsx @@ -86,7 +86,7 @@ function buildRehypePlugins(extraPlugins?: PluggableList): PluggableList { ]) const mergedAttributes: Record = { - ...(defaultSanitizeSchema.attributes ?? {}), + ...defaultSanitizeSchema.attributes, } for (const tag of Object.keys(ALLOWED_TAGS)) { diff --git a/web/app/components/base/portal-to-follow-elem/index.tsx b/web/app/components/base/portal-to-follow-elem/index.tsx index 8b531be309..fbadeaf302 100644 --- a/web/app/components/base/portal-to-follow-elem/index.tsx +++ b/web/app/components/base/portal-to-follow-elem/index.tsx @@ -148,14 +148,17 @@ export const PortalToFollowElemTrigger = ( }: React.HTMLProps & { ref?: React.RefObject, asChild?: boolean }, ) => { const context = usePortalToFollowElemContext() - const childrenRef = (children as any).props?.ref + const childElement = React.isValidElement<{ ref?: React.Ref }>(children) + ? children + : null + const childrenRef = childElement?.props.ref const ref = useMergeRefs([context.refs.setReference, propRef, childrenRef]) // `asChild` allows the user to pass any element as the anchor - if (asChild && React.isValidElement(children)) { - const childProps = (children.props ?? {}) as Record + if (asChild && childElement) { + const childProps = (childElement.props ?? {}) as Record return React.cloneElement( - children, + childElement, context.getReferenceProps({ ref, ...props, diff --git a/web/app/components/base/prompt-editor/plugins/context-block/__tests__/component.spec.tsx b/web/app/components/base/prompt-editor/plugins/context-block/__tests__/component.spec.tsx index eb011af528..64bca0a24d 100644 --- a/web/app/components/base/prompt-editor/plugins/context-block/__tests__/component.spec.tsx +++ b/web/app/components/base/prompt-editor/plugins/context-block/__tests__/component.spec.tsx @@ -2,6 +2,9 @@ import { act, render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { UPDATE_DATASETS_EVENT_EMITTER } from '../../../constants' import ContextBlockComponent from '../component' + +vi.mock('@langgenius/dify-ui/popover', async () => await import('@/__mocks__/base-ui-popover')) + // Mock the hooks used by ContextBlockComponent const mockUseSelectOrDelete = vi.fn() const mockUseTrigger = vi.fn() @@ -223,6 +226,21 @@ describe('ContextBlockComponent', () => { }) describe('User Interactions', () => { + it('should keep the popover closed when the trigger prevents the default click', async () => { + const user = userEvent.setup() + const { triggerSetOpen } = defaultSetup() + render( + , + ) + + await user.click(screen.getByTestId('popover-trigger')) + + expect(triggerSetOpen).not.toHaveBeenCalled() + expect( + screen.queryByText('common.promptEditor.context.modal.add'), + ).not.toBeInTheDocument() + }) + it('should call onAddContext when add button is clicked', async () => { defaultSetup({ open: true }) const handleAddContext = vi.fn() @@ -345,6 +363,29 @@ describe('ContextBlockComponent', () => { // Original datasets still there expect(screen.getByText('Dataset A')).toBeInTheDocument() }) + + it('should ignore string events from the event emitter', () => { + defaultSetup({ open: true }) + let subscriptionCallback: (v: Record | string) => void = () => { } + mockUseSubscription.mockImplementation((cb: (v: Record | string) => void) => { + subscriptionCallback = cb + }) + + render( + , + ) + + act(() => { + subscriptionCallback('ignore-me') + }) + + expect(screen.getByText('Dataset A')).toBeInTheDocument() + expect(screen.getByText('Dataset B')).toBeInTheDocument() + }) }) describe('Edge Cases', () => { diff --git a/web/app/components/base/prompt-editor/plugins/context-block/component.tsx b/web/app/components/base/prompt-editor/plugins/context-block/component.tsx index 35f6948e07..05fff5b4e8 100644 --- a/web/app/components/base/prompt-editor/plugins/context-block/component.tsx +++ b/web/app/components/base/prompt-editor/plugins/context-block/component.tsx @@ -1,13 +1,10 @@ import type { FC } from 'react' import type { Dataset } from './index' +import type { EventEmitterValue } from '@/context/event-emitter' +import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import { useState } from 'react' import { useTranslation } from 'react-i18next' -import { - PortalToFollowElem, - PortalToFollowElemContent, - PortalToFollowElemTrigger, -} from '@/app/components/base/portal-to-follow-elem' import { useEventEmitterContextContext } from '@/context/event-emitter' import { UPDATE_DATASETS_EVENT_EMITTER } from '../../constants' import { useSelectOrDelete, useTrigger } from '../../hooks' @@ -32,9 +29,12 @@ const ContextBlockComponent: FC = ({ const { eventEmitter } = useEventEmitterContextContext() const [localDatasets, setLocalDatasets] = useState(datasets) - eventEmitter?.useSubscription((v: any) => { - if (v?.type === UPDATE_DATASETS_EVENT_EMITTER) - setLocalDatasets(v.payload) + eventEmitter?.useSubscription((event?: EventEmitterValue) => { + if (typeof event === 'string') + return + + if (event?.type === UPDATE_DATASETS_EVENT_EMITTER && Array.isArray(event.payload)) + setLocalDatasets(event.payload as Dataset[]) }) return ( @@ -49,24 +49,31 @@ const ContextBlockComponent: FC = ({
{t('promptEditor.context.item.title', { ns: 'common' })}
{!canNotAddContext && ( - - -
- {localDatasets.length} -
-
- + `} + ref={triggerRef} + onClick={e => e.preventDefault()} + > + {localDatasets.length} + + )} + /> +
@@ -95,8 +102,8 @@ const ContextBlockComponent: FC = ({ {t('promptEditor.context.modal.footer', { ns: 'common' })}
- - + + )}
diff --git a/web/app/components/base/prompt-editor/plugins/history-block/__tests__/component.spec.tsx b/web/app/components/base/prompt-editor/plugins/history-block/__tests__/component.spec.tsx index aa4f0a85ca..4d57498424 100644 --- a/web/app/components/base/prompt-editor/plugins/history-block/__tests__/component.spec.tsx +++ b/web/app/components/base/prompt-editor/plugins/history-block/__tests__/component.spec.tsx @@ -6,6 +6,8 @@ import { UPDATE_HISTORY_EVENT_EMITTER } from '../../../constants' import HistoryBlockComponent from '../component' import { DELETE_HISTORY_BLOCK_COMMAND } from '../index' +vi.mock('@langgenius/dify-ui/popover', async () => await import('@/__mocks__/base-ui-popover')) + type HistoryEventPayload = { type?: string payload?: RoleName @@ -109,6 +111,24 @@ describe('HistoryBlockComponent', () => { expect(screen.getByText('common.promptEditor.history.modal.assistant')).toBeInTheDocument() }) + it('should keep the popover closed when the trigger prevents the default click', async () => { + const user = userEvent.setup() + const setOpen = vi.fn() as unknown as Dispatch> + mockUseTrigger.mockReturnValue(createTriggerHookReturn(false, setOpen)) + + render( + , + ) + + await user.click(screen.getByTestId('popover-trigger')) + + expect(setOpen).not.toHaveBeenCalled() + expect(screen.queryByText('common.promptEditor.history.modal.edit')).not.toBeInTheDocument() + }) + it('should call onEditRole when edit action is clicked', async () => { const user = userEvent.setup() const onEditRole = vi.fn() @@ -188,6 +208,29 @@ describe('HistoryBlockComponent', () => { expect(screen.getByText('kept-assistant')).toBeInTheDocument() }) + it('should ignore string events from the event emitter', () => { + mockUseTrigger.mockReturnValue(createTriggerHookReturn(true)) + + render( + , + ) + + expect(subscribedHandler).not.toBeNull() + act(() => { + subscribedHandler?.('ignore-me' as unknown as HistoryEventPayload) + }) + + expect(screen.getByText('kept-user')).toBeInTheDocument() + expect(screen.getByText('kept-assistant')).toBeInTheDocument() + }) + it('should render when event emitter is unavailable', () => { mockUseEventEmitterContextContext.mockReturnValue({ eventEmitter: undefined, diff --git a/web/app/components/base/prompt-editor/plugins/history-block/component.tsx b/web/app/components/base/prompt-editor/plugins/history-block/component.tsx index 15ce102bd9..a9bc68ac30 100644 --- a/web/app/components/base/prompt-editor/plugins/history-block/component.tsx +++ b/web/app/components/base/prompt-editor/plugins/history-block/component.tsx @@ -1,16 +1,13 @@ import type { FC } from 'react' import type { RoleName } from './index' +import type { EventEmitterValue } from '@/context/event-emitter' +import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import { RiMoreFill, } from '@remixicon/react' import { useState } from 'react' import { useTranslation } from 'react-i18next' import { MessageClockCircle } from '@/app/components/base/icons/src/vender/solid/general' -import { - PortalToFollowElem, - PortalToFollowElemContent, - PortalToFollowElemTrigger, -} from '@/app/components/base/portal-to-follow-elem' import { useEventEmitterContextContext } from '@/context/event-emitter' import { UPDATE_HISTORY_EVENT_EMITTER } from '../../constants' import { useSelectOrDelete, useTrigger } from '../../hooks' @@ -33,9 +30,12 @@ const HistoryBlockComponent: FC = ({ const { eventEmitter } = useEventEmitterContextContext() const [localRoleName, setLocalRoleName] = useState(roleName) - eventEmitter?.useSubscription((v: any) => { - if (v?.type === UPDATE_HISTORY_EVENT_EMITTER) - setLocalRoleName(v.payload) + eventEmitter?.useSubscription((event?: EventEmitterValue) => { + if (typeof event === 'string') + return + + if (event?.type === UPDATE_HISTORY_EVENT_EMITTER && event.payload && typeof event.payload === 'object') + setLocalRoleName(event.payload as RoleName) }) return ( @@ -49,25 +49,31 @@ const HistoryBlockComponent: FC = ({ >
{t('promptEditor.history.item.title', { ns: 'common' })}
- - -
- -
-
- + ref={triggerRef} + onClick={e => e.preventDefault()} + > + + + )} + /> +
{t('promptEditor.history.modal.title', { ns: 'common' })}
@@ -87,8 +93,8 @@ const HistoryBlockComponent: FC = ({ {t('promptEditor.history.modal.edit', { ns: 'common' })}
-
-
+
+ ) } diff --git a/web/app/components/datasets/common/document-picker/__tests__/index.spec.tsx b/web/app/components/datasets/common/document-picker/__tests__/index.spec.tsx index f8f0ce6e12..1251eab9fb 100644 --- a/web/app/components/datasets/common/document-picker/__tests__/index.spec.tsx +++ b/web/app/components/datasets/common/document-picker/__tests__/index.spec.tsx @@ -5,34 +5,7 @@ import * as React from 'react' import { ChunkingMode, DataSourceType } from '@/models/datasets' import DocumentPicker from '../index' -// Mock portal-to-follow-elem - always render content for testing -vi.mock('@/app/components/base/portal-to-follow-elem', () => ({ - PortalToFollowElem: ({ children, open }: { - children: React.ReactNode - open?: boolean - }) => ( -
- {children} -
- ), - PortalToFollowElemTrigger: ({ children, onClick }: { - children: React.ReactNode - onClick?: () => void - }) => ( -
- {children} -
- ), - // Always render content to allow testing document selection - PortalToFollowElemContent: ({ children, className }: { - children: React.ReactNode - className?: string - }) => ( -
- {children} -
- ), -})) +vi.mock('@langgenius/dify-ui/popover', () => import('@/__mocks__/base-ui-popover')) // Mock useDocumentList hook with controllable return value let mockDocumentListData: { data: SimpleDocumentDetail[] } | undefined @@ -152,6 +125,10 @@ const renderComponent = (props: Partial { + fireEvent.click(screen.getByTestId('popover-trigger')) +} + describe('DocumentPicker', () => { beforeEach(() => { vi.clearAllMocks() @@ -165,7 +142,7 @@ describe('DocumentPicker', () => { it('should render without crashing', () => { renderComponent() - expect(screen.getByTestId('portal-elem')).toBeInTheDocument() + expect(screen.getByTestId('popover')).toBeInTheDocument() }) it('should render document name when provided', () => { @@ -273,7 +250,7 @@ describe('DocumentPicker', () => { onChange, }) - expect(screen.getByTestId('portal-elem')).toBeInTheDocument() + expect(screen.getByTestId('popover')).toBeInTheDocument() }) it('should handle value with all fields', () => { @@ -318,13 +295,13 @@ describe('DocumentPicker', () => { it('should initialize with popup closed', () => { renderComponent() - expect(screen.getByTestId('portal-elem')).toHaveAttribute('data-open', 'false') + expect(screen.getByTestId('popover')).toHaveAttribute('data-open', 'false') }) it('should open popup when trigger is clicked', () => { renderComponent() - const trigger = screen.getByTestId('portal-trigger') + const trigger = screen.getByTestId('popover-trigger') fireEvent.click(trigger) // Verify click handler is called @@ -430,7 +407,7 @@ describe('DocumentPicker', () => { ) // The component should use the new callback - expect(screen.getByTestId('portal-elem')).toBeInTheDocument() + expect(screen.getByTestId('popover')).toBeInTheDocument() }) it('should memoize handleChange callback with useCallback', () => { @@ -440,7 +417,7 @@ describe('DocumentPicker', () => { renderComponent({ onChange }) // Verify component renders correctly, callback memoization is internal - expect(screen.getByTestId('portal-elem')).toBeInTheDocument() + expect(screen.getByTestId('popover')).toBeInTheDocument() }) }) @@ -518,7 +495,7 @@ describe('DocumentPicker', () => { it('should toggle popup when trigger is clicked', () => { renderComponent() - const trigger = screen.getByTestId('portal-trigger') + const trigger = screen.getByTestId('popover-trigger') fireEvent.click(trigger) // Trigger click should be handled @@ -591,7 +568,7 @@ describe('DocumentPicker', () => { renderComponent() // When loading, component should still render without crashing - expect(screen.getByTestId('portal-elem')).toBeInTheDocument() + expect(screen.getByTestId('popover')).toBeInTheDocument() }) it('should fetch documents on mount', () => { @@ -611,7 +588,7 @@ describe('DocumentPicker', () => { renderComponent() // Component should render without crashing - expect(screen.getByTestId('portal-elem')).toBeInTheDocument() + expect(screen.getByTestId('popover')).toBeInTheDocument() }) it('should handle undefined data response', () => { @@ -620,7 +597,7 @@ describe('DocumentPicker', () => { renderComponent() // Should not crash - expect(screen.getByTestId('portal-elem')).toBeInTheDocument() + expect(screen.getByTestId('popover')).toBeInTheDocument() }) }) @@ -732,13 +709,13 @@ describe('DocumentPicker', () => { renderComponent() // Should not crash - expect(screen.getByTestId('portal-elem')).toBeInTheDocument() + expect(screen.getByTestId('popover')).toBeInTheDocument() }) it('should handle rapid toggle clicks', () => { renderComponent() - const trigger = screen.getByTestId('portal-trigger') + const trigger = screen.getByTestId('popover-trigger') // Rapid clicks fireEvent.click(trigger) @@ -795,7 +772,7 @@ describe('DocumentPicker', () => { renderComponent() // Should not crash - expect(screen.getByTestId('portal-elem')).toBeInTheDocument() + expect(screen.getByTestId('popover')).toBeInTheDocument() }) it('should handle document list mapping with various data_source_detail_dict states', () => { @@ -819,7 +796,7 @@ describe('DocumentPicker', () => { renderComponent() // Should not crash during mapping - expect(screen.getByTestId('portal-elem')).toBeInTheDocument() + expect(screen.getByTestId('popover')).toBeInTheDocument() }) }) @@ -829,13 +806,13 @@ describe('DocumentPicker', () => { it('should handle empty datasetId', () => { renderComponent({ datasetId: '' }) - expect(screen.getByTestId('portal-elem')).toBeInTheDocument() + expect(screen.getByTestId('popover')).toBeInTheDocument() }) it('should handle UUID format datasetId', () => { renderComponent({ datasetId: '123e4567-e89b-12d3-a456-426614174000' }) - expect(screen.getByTestId('portal-elem')).toBeInTheDocument() + expect(screen.getByTestId('popover')).toBeInTheDocument() }) }) @@ -926,6 +903,7 @@ describe('DocumentPicker', () => { const onChange = vi.fn() renderComponent({ onChange }) + openPopover() fireEvent.click(screen.getByText('Document 2')) @@ -939,6 +917,7 @@ describe('DocumentPicker', () => { mockDocumentListData = { data: docs } renderComponent() + openPopover() // Documents should be rendered in the list expect(screen.getByText('Document 1')).toBeInTheDocument() @@ -978,14 +957,14 @@ describe('DocumentPicker', () => { // The mapping: d.data_source_detail_dict?.upload_file?.extension || '' // Should extract 'pdf' from the document - expect(screen.getByTestId('portal-elem')).toBeInTheDocument() + expect(screen.getByTestId('popover')).toBeInTheDocument() }) it('should render trigger with SearchInput integration', () => { renderComponent() // The trigger is always rendered - expect(screen.getByTestId('portal-trigger')).toBeInTheDocument() + expect(screen.getByTestId('popover-trigger')).toBeInTheDocument() }) it('should integrate FileIcon component', () => { @@ -1001,7 +980,7 @@ describe('DocumentPicker', () => { }) // FileIcon should render an SVG icon for the file extension - const trigger = screen.getByTestId('portal-trigger') + const trigger = screen.getByTestId('popover-trigger') expect(trigger.querySelector('svg')).toBeInTheDocument() }) }) @@ -1010,9 +989,10 @@ describe('DocumentPicker', () => { describe('Visual States', () => { it('should render portal content for document selection', () => { renderComponent() + openPopover() - // Portal content is rendered in our mock for testing - expect(screen.getByTestId('portal-content')).toBeInTheDocument() + // Popover content is rendered after opening the trigger in our mock + expect(screen.getByTestId('popover-content')).toBeInTheDocument() }) }) }) diff --git a/web/app/components/datasets/common/document-picker/__tests__/preview-document-picker.spec.tsx b/web/app/components/datasets/common/document-picker/__tests__/preview-document-picker.spec.tsx index 7178e9f60c..c7eb2c740c 100644 --- a/web/app/components/datasets/common/document-picker/__tests__/preview-document-picker.spec.tsx +++ b/web/app/components/datasets/common/document-picker/__tests__/preview-document-picker.spec.tsx @@ -3,34 +3,7 @@ import { fireEvent, render, screen } from '@testing-library/react' import * as React from 'react' import PreviewDocumentPicker from '../preview-document-picker' -// Mock portal-to-follow-elem - always render content for testing -vi.mock('@/app/components/base/portal-to-follow-elem', () => ({ - PortalToFollowElem: ({ children, open }: { - children: React.ReactNode - open?: boolean - }) => ( -
- {children} -
- ), - PortalToFollowElemTrigger: ({ children, onClick }: { - children: React.ReactNode - onClick?: () => void - }) => ( -
- {children} -
- ), - // Always render content to allow testing document selection - PortalToFollowElemContent: ({ children, className }: { - children: React.ReactNode - className?: string - }) => ( -
- {children} -
- ), -})) +vi.mock('@langgenius/dify-ui/popover', () => import('@/__mocks__/base-ui-popover')) // Factory function to create mock DocumentItem const createMockDocumentItem = (overrides: Partial = {}): DocumentItem => ({ @@ -67,6 +40,10 @@ const renderComponent = (props: Partial { + fireEvent.click(screen.getByTestId('popover-trigger')) +} + describe('PreviewDocumentPicker', () => { beforeEach(() => { vi.clearAllMocks() @@ -77,7 +54,7 @@ describe('PreviewDocumentPicker', () => { it('should render without crashing', () => { renderComponent() - expect(screen.getByTestId('portal-elem')).toBeInTheDocument() + expect(screen.getByTestId('popover')).toBeInTheDocument() }) it('should render document name from value prop', () => { @@ -110,7 +87,7 @@ describe('PreviewDocumentPicker', () => { files: [], // Use empty files to avoid duplicate icons }) - const trigger = screen.getByTestId('portal-trigger') + const trigger = screen.getByTestId('popover-trigger') expect(trigger.querySelector('svg')).toBeInTheDocument() }) @@ -120,7 +97,7 @@ describe('PreviewDocumentPicker', () => { files: [], // Use empty files to avoid duplicate icons }) - const trigger = screen.getByTestId('portal-trigger') + const trigger = screen.getByTestId('popover-trigger') expect(trigger.querySelector('svg')).toBeInTheDocument() }) }) @@ -131,22 +108,21 @@ describe('PreviewDocumentPicker', () => { const props = createDefaultProps() render() - expect(screen.getByTestId('portal-elem')).toBeInTheDocument() + expect(screen.getByTestId('popover')).toBeInTheDocument() }) it('should apply className to trigger element', () => { renderComponent({ className: 'custom-class' }) - const trigger = screen.getByTestId('portal-trigger') - const innerDiv = trigger.querySelector('.custom-class') - expect(innerDiv).toBeInTheDocument() + const trigger = screen.getByTestId('popover-trigger') + expect(trigger).toHaveClass('custom-class') }) it('should handle empty files array', () => { // Component should render without crashing with empty files renderComponent({ files: [] }) - expect(screen.getByTestId('portal-elem')).toBeInTheDocument() + expect(screen.getByTestId('popover')).toBeInTheDocument() }) it('should handle single file', () => { @@ -155,7 +131,7 @@ describe('PreviewDocumentPicker', () => { files: [createMockDocumentItem({ id: 'single-doc', name: 'Single File' })], }) - expect(screen.getByTestId('portal-elem')).toBeInTheDocument() + expect(screen.getByTestId('popover')).toBeInTheDocument() }) it('should handle multiple files', () => { @@ -164,7 +140,7 @@ describe('PreviewDocumentPicker', () => { files: createMockDocumentList(5), }) - expect(screen.getByTestId('portal-elem')).toBeInTheDocument() + expect(screen.getByTestId('popover')).toBeInTheDocument() }) it('should use value.extension for file icon', () => { @@ -172,7 +148,7 @@ describe('PreviewDocumentPicker', () => { value: createMockDocumentItem({ name: 'test.docx', extension: 'docx' }), }) - const trigger = screen.getByTestId('portal-trigger') + const trigger = screen.getByTestId('popover-trigger') expect(trigger.querySelector('svg')).toBeInTheDocument() }) }) @@ -182,13 +158,13 @@ describe('PreviewDocumentPicker', () => { it('should initialize with popup closed', () => { renderComponent() - expect(screen.getByTestId('portal-elem')).toHaveAttribute('data-open', 'false') + expect(screen.getByTestId('popover')).toHaveAttribute('data-open', 'false') }) it('should toggle popup when trigger is clicked', () => { renderComponent() - const trigger = screen.getByTestId('portal-trigger') + const trigger = screen.getByTestId('popover-trigger') fireEvent.click(trigger) expect(trigger).toBeInTheDocument() @@ -196,9 +172,10 @@ describe('PreviewDocumentPicker', () => { it('should render portal content for document selection', () => { renderComponent() + openPopover() - // Portal content is always rendered in our mock for testing - expect(screen.getByTestId('portal-content')).toBeInTheDocument() + // Popover content is rendered after opening the trigger in our mock + expect(screen.getByTestId('popover-content')).toBeInTheDocument() }) }) @@ -242,7 +219,7 @@ describe('PreviewDocumentPicker', () => { , ) - expect(screen.getByTestId('portal-elem')).toBeInTheDocument() + expect(screen.getByTestId('popover')).toBeInTheDocument() }) }) @@ -265,7 +242,7 @@ describe('PreviewDocumentPicker', () => { , ) - expect(screen.getByTestId('portal-elem')).toBeInTheDocument() + expect(screen.getByTestId('popover')).toBeInTheDocument() }) }) @@ -274,7 +251,7 @@ describe('PreviewDocumentPicker', () => { it('should toggle popup when trigger is clicked', () => { renderComponent() - const trigger = screen.getByTestId('portal-trigger') + const trigger = screen.getByTestId('popover-trigger') fireEvent.click(trigger) expect(trigger).toBeInTheDocument() @@ -283,6 +260,7 @@ describe('PreviewDocumentPicker', () => { it('should render document list with files', () => { const files = createMockDocumentList(3) renderComponent({ files }) + openPopover() // Documents should be visible in the list expect(screen.getByText('Document 1')).toBeInTheDocument() @@ -295,6 +273,7 @@ describe('PreviewDocumentPicker', () => { const files = createMockDocumentList(3) renderComponent({ files, onChange }) + openPopover() fireEvent.click(screen.getByText('Document 2')) @@ -306,7 +285,7 @@ describe('PreviewDocumentPicker', () => { it('should handle rapid toggle clicks', () => { renderComponent() - const trigger = screen.getByTestId('portal-trigger') + const trigger = screen.getByTestId('popover-trigger') // Rapid clicks fireEvent.click(trigger) @@ -337,14 +316,14 @@ describe('PreviewDocumentPicker', () => { // Renders placeholder for missing name expect(screen.getByText('--')).toBeInTheDocument() // Portal wrapper renders - expect(screen.getByTestId('portal-elem')).toBeInTheDocument() + expect(screen.getByTestId('popover')).toBeInTheDocument() }) it('should handle empty files array', () => { renderComponent({ files: [] }) // Component should render without crashing - expect(screen.getByTestId('portal-elem')).toBeInTheDocument() + expect(screen.getByTestId('popover')).toBeInTheDocument() }) it('should handle very long document names', () => { @@ -374,7 +353,7 @@ describe('PreviewDocumentPicker', () => { render() // Component should render without crashing - expect(screen.getByTestId('portal-elem')).toBeInTheDocument() + expect(screen.getByTestId('popover')).toBeInTheDocument() }) it('should handle large number of files', () => { @@ -382,7 +361,7 @@ describe('PreviewDocumentPicker', () => { renderComponent({ files: manyFiles }) // Component should accept large files array - expect(screen.getByTestId('portal-elem')).toBeInTheDocument() + expect(screen.getByTestId('popover')).toBeInTheDocument() }) it('should handle files with same name but different extensions', () => { @@ -393,7 +372,7 @@ describe('PreviewDocumentPicker', () => { renderComponent({ files }) // Component should handle duplicate names - expect(screen.getByTestId('portal-elem')).toBeInTheDocument() + expect(screen.getByTestId('popover')).toBeInTheDocument() }) }) @@ -427,7 +406,7 @@ describe('PreviewDocumentPicker', () => { files: [createMockDocumentItem({ name: 'Single' })], }) - expect(screen.getByTestId('portal-elem')).toBeInTheDocument() + expect(screen.getByTestId('popover')).toBeInTheDocument() }) it('should handle two files', () => { @@ -435,7 +414,7 @@ describe('PreviewDocumentPicker', () => { files: createMockDocumentList(2), }) - expect(screen.getByTestId('portal-elem')).toBeInTheDocument() + expect(screen.getByTestId('popover')).toBeInTheDocument() }) it('should handle many files', () => { @@ -443,7 +422,7 @@ describe('PreviewDocumentPicker', () => { files: createMockDocumentList(50), }) - expect(screen.getByTestId('portal-elem')).toBeInTheDocument() + expect(screen.getByTestId('popover')).toBeInTheDocument() }) }) @@ -451,23 +430,22 @@ describe('PreviewDocumentPicker', () => { it('should apply custom className', () => { renderComponent({ className: 'my-custom-class' }) - const trigger = screen.getByTestId('portal-trigger') - expect(trigger.querySelector('.my-custom-class')).toBeInTheDocument() + const trigger = screen.getByTestId('popover-trigger') + expect(trigger).toHaveClass('my-custom-class') }) it('should work without className', () => { renderComponent({ className: undefined }) - expect(screen.getByTestId('portal-trigger')).toBeInTheDocument() + expect(screen.getByTestId('popover-trigger')).toBeInTheDocument() }) it('should handle multiple class names', () => { renderComponent({ className: 'class-one class-two' }) - const trigger = screen.getByTestId('portal-trigger') - const element = trigger.querySelector('.class-one') - expect(element).toBeInTheDocument() - expect(element).toHaveClass('class-two') + const trigger = screen.getByTestId('popover-trigger') + expect(trigger).toHaveClass('class-one') + expect(trigger).toHaveClass('class-two') }) }) @@ -480,7 +458,7 @@ describe('PreviewDocumentPicker', () => { files: [], // Use empty files to avoid duplicate icons }) - const trigger = screen.getByTestId('portal-trigger') + const trigger = screen.getByTestId('popover-trigger') expect(trigger.querySelector('svg')).toBeInTheDocument() }) }) @@ -491,6 +469,7 @@ describe('PreviewDocumentPicker', () => { it('should render all documents in the list', () => { const files = createMockDocumentList(5) renderComponent({ files }) + openPopover() // All documents should be visible files.forEach((file) => { @@ -503,6 +482,7 @@ describe('PreviewDocumentPicker', () => { const files = createMockDocumentList(3) renderComponent({ files, onChange }) + openPopover() fireEvent.click(screen.getByText('Document 1')) @@ -528,6 +508,7 @@ describe('PreviewDocumentPicker', () => { onChange={vi.fn()} />, ) + openPopover() expect(screen.getByText(/dataset\.preprocessDocument/)).toBeInTheDocument() }) }) @@ -537,9 +518,8 @@ describe('PreviewDocumentPicker', () => { it('should apply hover styles on trigger', () => { renderComponent() - const trigger = screen.getByTestId('portal-trigger') - const innerDiv = trigger.querySelector('.hover\\:bg-state-base-hover') - expect(innerDiv).toBeInTheDocument() + const trigger = screen.getByTestId('popover-trigger') + expect(trigger).toHaveClass('hover:bg-state-base-hover') }) it('should have truncate class for long names', () => { @@ -568,6 +548,7 @@ describe('PreviewDocumentPicker', () => { const files = createMockDocumentList(3) renderComponent({ files, onChange }) + openPopover() fireEvent.click(screen.getByText('Document 1')) @@ -582,10 +563,12 @@ describe('PreviewDocumentPicker', () => { ] renderComponent({ files: customFiles, onChange }) + openPopover() fireEvent.click(screen.getByText('Custom File 1')) expect(onChange).toHaveBeenCalledWith(customFiles[0]) + openPopover() fireEvent.click(screen.getByText('Custom File 2')) expect(onChange).toHaveBeenCalledWith(customFiles[1]) }) @@ -597,8 +580,11 @@ describe('PreviewDocumentPicker', () => { renderComponent({ files, onChange }) // Select multiple documents sequentially + openPopover() fireEvent.click(screen.getByText('Document 1')) + openPopover() fireEvent.click(screen.getByText('Document 3')) + openPopover() fireEvent.click(screen.getByText('Document 2')) expect(onChange).toHaveBeenCalledTimes(3) diff --git a/web/app/components/datasets/common/document-picker/index.tsx b/web/app/components/datasets/common/document-picker/index.tsx index d0e389255a..0566b590de 100644 --- a/web/app/components/datasets/common/document-picker/index.tsx +++ b/web/app/components/datasets/common/document-picker/index.tsx @@ -2,6 +2,11 @@ import type { FC } from 'react' import type { DocumentItem, ParentMode, SimpleDocumentDetail } from '@/models/datasets' import { cn } from '@langgenius/dify-ui/cn' +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@langgenius/dify-ui/popover' import { RiArrowDownSLine } from '@remixicon/react' import { useBoolean } from 'ahooks' import * as React from 'react' @@ -9,11 +14,6 @@ import { useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { GeneralChunk, ParentChildChunk } from '@/app/components/base/icons/src/vender/knowledge' import Loading from '@/app/components/base/loading' -import { - PortalToFollowElem, - PortalToFollowElemContent, - PortalToFollowElemTrigger, -} from '@/app/components/base/portal-to-follow-elem' import SearchInput from '@/app/components/base/search-input' import { ChunkingMode } from '@/models/datasets' import { useDocumentList } from '@/service/knowledge/use-document' @@ -61,7 +61,6 @@ const DocumentPicker: FC = ({ const [open, { set: setOpen, - toggle: togglePopup, }] = useBoolean(false) const ArrowIcon = RiArrowDownSLine @@ -77,34 +76,40 @@ const DocumentPicker: FC = ({ }, [parentMode, t]) return ( - - -
- -
-
- - {' '} - {name || '--'} - - -
-
- - - {isGeneralMode && t('chunkingMode.general', { ns: 'dataset' })} - {isQAMode && t('chunkingMode.qa', { ns: 'dataset' })} - {isParentChild && `${t('chunkingMode.parentChild', { ns: 'dataset' })} · ${parentModeLabel}`} - + + +
+
+ + {' '} + {name || '--'} + + +
+
+ + + {isGeneralMode && t('chunkingMode.general', { ns: 'dataset' })} + {isQAMode && t('chunkingMode.qa', { ns: 'dataset' })} + {isParentChild && `${t('chunkingMode.parentChild', { ns: 'dataset' })} · ${parentModeLabel}`} + +
-
- - + )} + /> +
{documentsList @@ -125,9 +130,8 @@ const DocumentPicker: FC = ({
)}
- -
-
+ + ) } export default React.memo(DocumentPicker) diff --git a/web/app/components/datasets/common/document-picker/preview-document-picker.tsx b/web/app/components/datasets/common/document-picker/preview-document-picker.tsx index 03ee13b513..597ceda9a5 100644 --- a/web/app/components/datasets/common/document-picker/preview-document-picker.tsx +++ b/web/app/components/datasets/common/document-picker/preview-document-picker.tsx @@ -2,17 +2,17 @@ import type { FC } from 'react' import type { DocumentItem } from '@/models/datasets' import { cn } from '@langgenius/dify-ui/cn' +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@langgenius/dify-ui/popover' import { RiArrowDownSLine } from '@remixicon/react' import { useBoolean } from 'ahooks' import * as React from 'react' import { useCallback } from 'react' import { useTranslation } from 'react-i18next' import Loading from '@/app/components/base/loading' -import { - PortalToFollowElem, - PortalToFollowElemContent, - PortalToFollowElemTrigger, -} from '@/app/components/base/portal-to-follow-elem' import FileIcon from '../document-file-icon' import DocumentList from './document-list' @@ -35,7 +35,6 @@ const PreviewDocumentPicker: FC = ({ const [open, { set: setOpen, - toggle: togglePopup, }] = useBoolean(false) const ArrowIcon = RiArrowDownSLine @@ -45,27 +44,32 @@ const PreviewDocumentPicker: FC = ({ }, [onChange, setOpen]) return ( - - -
- -
-
- - {' '} - {name || '--'} - - + + +
+
+ + {' '} + {name || '--'} + + +
-
- - + )} + /> +
{files?.length > 1 &&
{t('preprocessDocument', { ns: 'dataset', num: files.length })}
} {files?.length > 0 @@ -81,9 +85,8 @@ const PreviewDocumentPicker: FC = ({
)}
- - -
+ + ) } export default React.memo(PreviewDocumentPicker) diff --git a/web/app/components/datasets/documents/create-from-pipeline/steps/__tests__/preview-panel.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/steps/__tests__/preview-panel.spec.tsx index 11f1286306..9c29206e7d 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/steps/__tests__/preview-panel.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/steps/__tests__/preview-panel.spec.tsx @@ -231,8 +231,9 @@ describe('StepTwoPreview', () => { describe('Props Passing', () => { it('should render preview button when isIdle is true', () => { render() - // ChunkPreview shows a preview button when idle - const previewButton = screen.queryByRole('button') + const previewButton = screen.getByRole('button', { + name: 'datasetPipeline.addDocuments.stepTwo.previewChunks', + }) expect(previewButton).toBeInTheDocument() }) @@ -240,13 +241,13 @@ describe('StepTwoPreview', () => { const onPreview = vi.fn() render() - // Find and click the preview button - const buttons = screen.getAllByRole('button') - const previewButton = buttons.find(btn => btn.textContent?.toLowerCase().includes('preview')) - if (previewButton) { - previewButton.click() - expect(onPreview).toHaveBeenCalled() - } + const previewButton = screen.getByRole('button', { + name: 'datasetPipeline.addDocuments.stepTwo.previewChunks', + }) + + previewButton.click() + + expect(onPreview).toHaveBeenCalled() }) }) diff --git a/web/app/components/datasets/documents/detail/metadata/hooks/use-metadata-state.ts b/web/app/components/datasets/documents/detail/metadata/hooks/use-metadata-state.ts index 7bfa64ac8e..7fc0c17cb0 100644 --- a/web/app/components/datasets/documents/detail/metadata/hooks/use-metadata-state.ts +++ b/web/app/components/datasets/documents/detail/metadata/hooks/use-metadata-state.ts @@ -75,7 +75,7 @@ export function useMetadataState({ docDetail, onUpdate }: UseMetadataStateOption setEditStatus(true) } const cancelEdit = () => { - setMetadataParams({ documentType: docType || '', metadata: { ...(docDetail?.doc_metadata || {}) } }) + setMetadataParams({ documentType: docType || '', metadata: { ...docDetail?.doc_metadata } }) setEditStatus(!docType) if (!docType) setShowDocTypes(true) diff --git a/web/app/components/datasets/settings/permission-selector/index.tsx b/web/app/components/datasets/settings/permission-selector/index.tsx index a9b0c348d8..724a239c94 100644 --- a/web/app/components/datasets/settings/permission-selector/index.tsx +++ b/web/app/components/datasets/settings/permission-selector/index.tsx @@ -1,17 +1,17 @@ import type { Member } from '@/models/common' import { Avatar } from '@langgenius/dify-ui/avatar' import { cn } from '@langgenius/dify-ui/cn' +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@langgenius/dify-ui/popover' import { RiArrowDownSLine, RiGroup2Line, RiLock2Line } from '@remixicon/react' import { useDebounceFn } from 'ahooks' import * as React from 'react' import { useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import Input from '@/app/components/base/input' -import { - PortalToFollowElem, - PortalToFollowElemContent, - PortalToFollowElemTrigger, -} from '@/app/components/base/portal-to-follow-elem' import { useSelector as useAppContextWithSelector } from '@/context/app-context' import { DatasetPermission } from '@/models/datasets' import MemberItem from './member-item' @@ -90,93 +90,98 @@ const PermissionSelector = ({ const selectedMemberNames = selectedMembers.map(member => member.name).join(', ') return ( - { + if (disabled) + return + setOpen(nextOpen) + }} >
- !disabled && setOpen(v => !v)} - className="block" - > -
- { - isOnlyMe && ( - <> -
- -
-
- {t('form.permissionsOnlyMe', { ns: 'datasetSettings' })} -
- - ) - } - { - isAllTeamMembers && ( - <> -
- -
-
- {t('form.permissionsAllMember', { ns: 'datasetSettings' })} -
- - ) - } - { - isPartialMembers && ( - <> -
- { - selectedMembers.length === 1 && ( - - ) - } - { - selectedMembers.length >= 2 && ( - <> + + { + isOnlyMe && ( + <> +
+ +
+
+ {t('form.permissionsOnlyMe', { ns: 'datasetSettings' })} +
+ + ) + } + { + isAllTeamMembers && ( + <> +
+ +
+
+ {t('form.permissionsAllMember', { ns: 'datasetSettings' })} +
+ + ) + } + { + isPartialMembers && ( + <> +
+ { + selectedMembers.length === 1 && ( - - - ) - } -
-
- {selectedMemberNames} -
- - ) - } - -
- - + ) + } + { + selectedMembers.length >= 2 && ( + <> + + + + ) + } +
+
+ {selectedMemberNames} +
+ + ) + } + +
+ )} + /> +
{/* Only me */} @@ -236,6 +241,7 @@ const PermissionSelector = ({ )} {filteredMemberList.map(member => ( } @@ -256,9 +262,9 @@ const PermissionSelector = ({
)}
- +
-
+ ) } diff --git a/web/app/components/header/account-setting/api-based-extension-page/__tests__/selector.spec.tsx b/web/app/components/header/account-setting/api-based-extension-page/__tests__/selector.spec.tsx index 82acd22d97..44dfdbc5a5 100644 --- a/web/app/components/header/account-setting/api-based-extension-page/__tests__/selector.spec.tsx +++ b/web/app/components/header/account-setting/api-based-extension-page/__tests__/selector.spec.tsx @@ -15,6 +15,8 @@ vi.mock('@/service/use-common', () => ({ useApiBasedExtensions: vi.fn(), })) +vi.mock('@langgenius/dify-ui/popover', async () => await import('@/__mocks__/base-ui-popover')) + describe('ApiBasedExtensionSelector', () => { const mockOnChange = vi.fn() const mockSetShowAccountSettingModal = vi.fn() diff --git a/web/app/components/header/account-setting/api-based-extension-page/selector.tsx b/web/app/components/header/account-setting/api-based-extension-page/selector.tsx index 041657d4be..3f207ef23f 100644 --- a/web/app/components/header/account-setting/api-based-extension-page/selector.tsx +++ b/web/app/components/header/account-setting/api-based-extension-page/selector.tsx @@ -1,4 +1,5 @@ import type { FC } from 'react' +import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import { RiAddLine, RiArrowDownSLine, @@ -8,11 +9,6 @@ import { useTranslation } from 'react-i18next' import { ArrowUpRight, } from '@/app/components/base/icons/src/vender/line/arrows' -import { - PortalToFollowElem, - PortalToFollowElemContent, - PortalToFollowElemTrigger, -} from '@/app/components/base/portal-to-follow-elem' import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' import { useModalContext } from '@/context/modal-context' import { useApiBasedExtensions } from '@/service/use-common' @@ -41,35 +37,42 @@ const ApiBasedExtensionSelector: FC = ({ const currentItem = data?.find(item => item.id === value) return ( - - setOpen(v => !v)} className="w-full"> - { - currentItem - ? ( -
-
{currentItem.name}
-
-
- {currentItem.api_endpoint} + + { + currentItem + ? ( +
+
{currentItem.name}
+
+
+ {currentItem.api_endpoint} +
+ +
- -
-
- ) - : ( -
- {t('apiBasedExtension.selector.placeholder', { ns: 'common' })} - -
- ) - } - - + ) + : ( +
+ {t('apiBasedExtension.selector.placeholder', { ns: 'common' })} + +
+ ) + } + + )} + /> +
@@ -116,8 +119,8 @@ const ApiBasedExtensionSelector: FC = ({
-
- + + ) } diff --git a/web/app/components/header/account-setting/data-source-page-new/__tests__/configure.spec.tsx b/web/app/components/header/account-setting/data-source-page-new/__tests__/configure.spec.tsx index 5f844d02e3..07344343f8 100644 --- a/web/app/components/header/account-setting/data-source-page-new/__tests__/configure.spec.tsx +++ b/web/app/components/header/account-setting/data-source-page-new/__tests__/configure.spec.tsx @@ -1,3 +1,4 @@ +import type { ButtonHTMLAttributes, ReactNode } from 'react' import type { DataSourceAuth } from '../types' import type { FormSchema } from '@/app/components/base/form/types' import type { AddApiKeyButtonProps, AddOAuthButtonProps, PluginPayload } from '@/app/components/plugins/plugin-auth/types' @@ -6,6 +7,15 @@ import { FormTypeEnum } from '@/app/components/base/form/types' import { AuthCategory } from '@/app/components/plugins/plugin-auth/types' import Configure from '../configure' +vi.mock('@langgenius/dify-ui/popover', () => import('@/__mocks__/base-ui-popover')) +vi.mock('@langgenius/dify-ui/button', () => ({ + Button: ({ children, ...props }: ButtonHTMLAttributes & { children?: ReactNode }) => ( + + ), +})) + /** * Configure Component Tests * Using Unit approach to ensure 100% coverage and stable tests. diff --git a/web/app/components/header/account-setting/data-source-page-new/configure.tsx b/web/app/components/header/account-setting/data-source-page-new/configure.tsx index 712fb91415..f242d17079 100644 --- a/web/app/components/header/account-setting/data-source-page-new/configure.tsx +++ b/web/app/components/header/account-setting/data-source-page-new/configure.tsx @@ -5,6 +5,11 @@ import type { PluginPayload, } from '@/app/components/plugins/plugin-auth/types' import { Button } from '@langgenius/dify-ui/button' +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@langgenius/dify-ui/popover' import { RiAddLine, } from '@remixicon/react' @@ -15,11 +20,6 @@ import { useState, } from 'react' import { useTranslation } from 'react-i18next' -import { - PortalToFollowElem, - PortalToFollowElemContent, - PortalToFollowElemTrigger, -} from '@/app/components/base/portal-to-follow-elem' import { AddApiKeyButton, AddOAuthButton, @@ -56,10 +56,6 @@ const Configure = ({ } }, [pluginPayload, t]) - const handleToggle = useCallback(() => { - setOpen(v => !v) - }, []) - const handleUpdate = useCallback(() => { setOpen(false) onUpdate?.() @@ -67,24 +63,26 @@ const Configure = ({ return ( <> - - - - - + + + {t('dataSource.configure', { ns: 'common' })} + + )} + /> +
{ !!canOAuth && ( @@ -122,8 +120,8 @@ const Configure = ({ ) }
-
-
+ + ) } diff --git a/web/app/components/header/account-setting/model-provider-page/model-auth/__tests__/add-custom-model.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-auth/__tests__/add-custom-model.spec.tsx index 43a27dac9b..d0ba140fde 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-auth/__tests__/add-custom-model.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-auth/__tests__/add-custom-model.spec.tsx @@ -34,33 +34,17 @@ vi.mock('@remixicon/react', () => ({ RiAddLine: () =>
, })) -vi.mock('@/app/components/base/tooltip', () => ({ - default: ({ children, popupContent }: { children: React.ReactNode, popupContent: string }) => ( +vi.mock('@langgenius/dify-ui/tooltip', () => ({ + Tooltip: ({ children }: { children: React.ReactNode }) => (
{children} -
{popupContent}
), + TooltipTrigger: ({ render }: { render: React.ReactNode }) => <>{render}, + TooltipContent: ({ children }: { children: React.ReactNode }) =>
{children}
, })) -// Mock portal components to avoid async test DOM issues (consistent with sibling tests) -vi.mock('@/app/components/base/portal-to-follow-elem', () => ({ - PortalToFollowElem: ({ children, open }: { children: React.ReactNode, open: boolean, onOpenChange: (open: boolean) => void }) => ( -
- {children} -
- ), - PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick: () => void }) => ( -
{children}
- ), - PortalToFollowElemContent: ({ children }: { children: React.ReactNode, open?: boolean }) => { - // In many tests, we need to find elements inside the content even if "closed" in state - // but not yet "removed" from DOM. However, to avoid multiple elements issues, - // we should be careful. - // For AddCustomModel, we need the content to be present when we click a model. - return
{children}
- }, -})) +vi.mock('@langgenius/dify-ui/popover', async () => await import('@/__mocks__/base-ui-popover')) describe('AddCustomModel', () => { const mockProvider = { @@ -94,7 +78,7 @@ describe('AddCustomModel', () => { />, ) - fireEvent.click(screen.getByTestId('portal-trigger')) + fireEvent.click(screen.getByRole('button', { name: /modelProvider.addModel/i })) expect(mockHandleOpenModalForAddNewCustomModel).toHaveBeenCalled() }) @@ -107,10 +91,10 @@ describe('AddCustomModel', () => { />, ) - fireEvent.click(screen.getByTestId('portal-trigger')) + fireEvent.click(screen.getByTestId('popover-trigger')) // The portal should be "open" - expect(screen.getByTestId('portal')).toHaveAttribute('data-open', 'true') + expect(screen.getByTestId('popover')).toHaveAttribute('data-open', 'true') expect(screen.getByText('gpt-4')).toBeInTheDocument() expect(screen.getByTestId('model-icon')).toBeInTheDocument() }) @@ -125,7 +109,7 @@ describe('AddCustomModel', () => { />, ) - fireEvent.click(screen.getByTestId('portal-trigger')) + fireEvent.click(screen.getByTestId('popover-trigger')) fireEvent.click(screen.getByText('gpt-4')) expect(mockHandleOpenModalForAddCustomModelToModelList).toHaveBeenCalledWith(undefined, model) @@ -140,7 +124,7 @@ describe('AddCustomModel', () => { />, ) - fireEvent.click(screen.getByTestId('portal-trigger')) + fireEvent.click(screen.getByTestId('popover-trigger')) fireEvent.click(screen.getByText(/modelProvider.auth.addNewModel/)) expect(mockHandleOpenModalForAddNewCustomModel).toHaveBeenCalled() @@ -159,7 +143,7 @@ describe('AddCustomModel', () => { expect(screen.getByTestId('tooltip-mock')).toBeInTheDocument() expect(screen.getByText('plugin.auth.credentialUnavailable')).toBeInTheDocument() - fireEvent.click(screen.getByTestId('portal-trigger')) + fireEvent.click(screen.getByRole('button', { name: /modelProvider.addModel/i })) expect(mockHandleOpenModalForAddNewCustomModel).not.toHaveBeenCalled() }) }) diff --git a/web/app/components/header/account-setting/model-provider-page/model-auth/add-custom-model.tsx b/web/app/components/header/account-setting/model-provider-page/model-auth/add-custom-model.tsx index a1e181e97e..5191357d83 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-auth/add-custom-model.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-auth/add-custom-model.tsx @@ -7,6 +7,16 @@ import { Button, } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@langgenius/dify-ui/popover' +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@langgenius/dify-ui/tooltip' import { RiAddCircleFill, RiAddLine, @@ -17,12 +27,6 @@ import { useState, } from 'react' import { useTranslation } from 'react-i18next' -import { - PortalToFollowElem, - PortalToFollowElemContent, - PortalToFollowElemTrigger, -} from '@/app/components/base/portal-to-follow-elem' -import Tooltip from '@/app/components/base/tooltip' import { ModelModalModeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' import ModelIcon from '../model-icon' import { useAuth } from './hooks/use-auth' @@ -67,12 +71,12 @@ const AddCustomModel = ({ }, ) const notAllowCustomCredential = provider.allow_custom_token === false - - const renderTrigger = useCallback((open?: boolean) => { - const Item = ( + const renderTrigger = useCallback((open?: boolean, onClick?: () => void) => { + const item = (
} + /> + - {renderTrigger(open)} - -
{ @@ -125,8 +123,8 @@ const AddCustomModel = ({ key={model.model} className="flex h-8 cursor-pointer items-center rounded-lg px-2 hover:bg-state-base-hover" onClick={() => { - handleOpenModalForAddCustomModelToModelList(undefined, model) setOpen(false) + handleOpenModalForAddCustomModelToModelList(undefined, model) }} > { - handleOpenModalForAddNewCustomModel() setOpen(false) + handleOpenModalForAddNewCustomModel() }} > @@ -160,8 +158,8 @@ const AddCustomModel = ({ ) }
- - + + ) } diff --git a/web/app/components/header/account-setting/model-provider-page/model-auth/authorized/__tests__/index.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-auth/authorized/__tests__/index.spec.tsx index a331181619..f405fb5528 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-auth/authorized/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-auth/authorized/__tests__/index.spec.tsx @@ -45,6 +45,8 @@ vi.mock('../authorized-item', () => ({ ), })) +vi.mock('@langgenius/dify-ui/popover', async () => await import('@/__mocks__/base-ui-popover')) + describe('Authorized', () => { const mockProvider: ModelProvider = { provider: 'openai', diff --git a/web/app/components/header/account-setting/model-provider-page/model-auth/authorized/index.tsx b/web/app/components/header/account-setting/model-provider-page/model-auth/authorized/index.tsx index ca0e0d9c73..d86eaf40c5 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-auth/authorized/index.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-auth/authorized/index.tsx @@ -1,3 +1,8 @@ +import type { + OffsetOptions, +} from '@floating-ui/react' +import type { Placement } from '@langgenius/dify-ui/popover' +import type { MouseEvent } from 'react' import type { ConfigurationMethodEnum, Credential, @@ -6,9 +11,6 @@ import type { ModelModalModeEnum, ModelProvider, } from '../../declarations' -import type { - PortalToFollowElemOptions, -} from '@/app/components/base/portal-to-follow-elem' import { AlertDialog, AlertDialogActions, @@ -19,6 +21,11 @@ import { } from '@langgenius/dify-ui/alert-dialog' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@langgenius/dify-ui/popover' import { RiAddLine, } from '@remixicon/react' @@ -29,11 +36,6 @@ import { useState, } from 'react' import { useTranslation } from 'react-i18next' -import { - PortalToFollowElem, - PortalToFollowElemContent, - PortalToFollowElemTrigger, -} from '@/app/components/base/portal-to-follow-elem' import { useAuth } from '../hooks' import AuthorizedItem from './authorized-item' @@ -43,7 +45,7 @@ type AuthorizedProps = { currentCustomConfigurationModelFixedFields?: CustomConfigurationModelFixedFields authParams?: { isModelCredential?: boolean - onUpdate?: (newPayload?: any, formValues?: Record) => void + onUpdate?: (newPayload?: Record, formValues?: Record) => void onRemove?: (credentialId: string) => void mode?: ModelModalModeEnum } @@ -57,8 +59,8 @@ type AuthorizedProps = { renderTrigger: (open?: boolean) => React.ReactNode isOpen?: boolean onOpenChange?: (open: boolean) => void - offset?: PortalToFollowElemOptions['offset'] - placement?: PortalToFollowElemOptions['placement'] + offset?: number | OffsetOptions + placement?: Placement triggerPopupSameWidth?: boolean popupClassName?: string showItemSelectedIcon?: boolean @@ -132,9 +134,13 @@ const Authorized = ({ ) const handleEdit = useCallback((credential?: Credential, model?: CustomModel) => { - handleOpenModal(credential, model) setMergedIsOpen(false) + handleOpenModal(credential, model) }, [handleOpenModal, setMergedIsOpen]) + const handleDelete = useCallback((credential?: Credential, model?: CustomModel) => { + setMergedIsOpen(false) + openConfirmDelete(credential, model) + }, [openConfirmDelete, setMergedIsOpen]) const handleItemClick = useCallback((credential: Credential, model?: CustomModel) => { if (disableItemClick) @@ -148,30 +154,37 @@ const Authorized = ({ setMergedIsOpen(false) }, [handleActiveCredential, onItemClick, setMergedIsOpen, disableItemClick]) const notAllowCustomCredential = provider.allow_custom_token === false + const resolvedOffset = typeof offset === 'number' || typeof offset === 'function' ? undefined : offset + const sideOffset = typeof offset === 'number' ? offset : resolvedOffset?.mainAxis ?? 0 + const alignOffset = typeof offset === 'number' ? 0 : resolvedOffset?.crossAxis ?? resolvedOffset?.alignmentAxis ?? 0 + const popupProps = triggerPopupSameWidth + ? { style: { width: 'var(--anchor-width, auto)' } } + : undefined + const handleTriggerClick = useCallback((event: MouseEvent) => { + if (!triggerOnlyOpenModal) + return + + event.preventDefault() + handleOpenModal() + }, [handleOpenModal, triggerOnlyOpenModal]) return ( <> - - { - if (triggerOnlyOpenModal) { - handleOpenModal() - return - } - - setMergedIsOpen(!mergedIsOpen) - }} - asChild + {renderTrigger(mergedIsOpen)}
} + onClick={handleTriggerClick} + /> + - {renderTrigger(mergedIsOpen)} - -
{ - items.map((item, index) => ( - + items.map(item => ( + credential.credential_id).join('-')}> { - index !== items.length - 1 && ( + item !== items[items.length - 1] && (
) } @@ -245,8 +258,8 @@ const Authorized = ({ ) }
-
- +
+ !open && closeConfirmDelete()}>
diff --git a/web/app/components/plugins/marketplace/search-box/__tests__/index.spec.tsx b/web/app/components/plugins/marketplace/search-box/__tests__/index.spec.tsx index 8609ba5539..51b4087659 100644 --- a/web/app/components/plugins/marketplace/search-box/__tests__/index.spec.tsx +++ b/web/app/components/plugins/marketplace/search-box/__tests__/index.spec.tsx @@ -62,31 +62,38 @@ vi.mock('@/app/components/plugins/hooks', () => ({ }), })) -// Mock portal-to-follow-elem with shared open state +// Mock popover with shared open state let mockPortalOpenState = false +let mockPopoverOnOpenChange: ((open: boolean) => void) | undefined -vi.mock('@/app/components/base/portal-to-follow-elem', () => ({ - PortalToFollowElem: ({ children, open }: { +vi.mock('@langgenius/dify-ui/popover', () => ({ + Popover: ({ children, open, onOpenChange }: { children: React.ReactNode open: boolean + onOpenChange?: (open: boolean) => void }) => { mockPortalOpenState = open + mockPopoverOnOpenChange = onOpenChange return (
{children}
) }, - PortalToFollowElemTrigger: ({ children, onClick, className }: { - children: React.ReactNode - onClick: () => void + PopoverTrigger: ({ children, render, className }: { + children?: React.ReactNode + render?: React.ReactNode className?: string }) => ( -
- {children} +
mockPopoverOnOpenChange?.(!mockPortalOpenState)} + className={className} + > + {render ?? children}
), - PortalToFollowElemContent: ({ children, className }: { + PopoverContent: ({ children, className }: { children: React.ReactNode className?: string }) => { diff --git a/web/app/components/plugins/marketplace/search-box/__tests__/tags-filter.spec.tsx b/web/app/components/plugins/marketplace/search-box/__tests__/tags-filter.spec.tsx index bb5d8e734c..e87022fe38 100644 --- a/web/app/components/plugins/marketplace/search-box/__tests__/tags-filter.spec.tsx +++ b/web/app/components/plugins/marketplace/search-box/__tests__/tags-filter.spec.tsx @@ -1,10 +1,19 @@ -import { fireEvent, render, screen } from '@testing-library/react' +import { + fireEvent, + render, + screen, + within, +} from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import TagsFilter from '../tags-filter' +const { mockTranslate } = vi.hoisted(() => ({ + mockTranslate: vi.fn((key: string, options?: { ns?: string }) => options?.ns ? `${options.ns}.${key}` : key), +})) + vi.mock('#i18n', () => ({ useTranslation: () => ({ - t: (key: string, options?: { ns?: string }) => options?.ns ? `${options.ns}.${key}` : key, + t: mockTranslate, }), })) @@ -46,20 +55,7 @@ vi.mock('@/app/components/base/input', () => ({ ), })) -vi.mock('@/app/components/base/portal-to-follow-elem', async () => { - const React = await import('react') - return { - PortalToFollowElem: ({ children }: { children: React.ReactNode }) =>
{children}
, - PortalToFollowElemTrigger: ({ - children, - onClick, - }: { - children: React.ReactNode - onClick: () => void - }) => , - PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) =>
{children}
, - } -}) +vi.mock('@langgenius/dify-ui/popover', () => import('@/__mocks__/base-ui-popover')) vi.mock('../trigger/marketplace', () => ({ default: ({ selectedTagsLength }: { selectedTagsLength: number }) => ( @@ -80,8 +76,16 @@ vi.mock('../trigger/tool-selector', () => ({ })) describe('TagsFilter', () => { + const ensurePopoverOpen = () => { + if (!screen.queryByTestId('popover-content')) + fireEvent.click(screen.getByTestId('popover-trigger')) + + return screen.getByTestId('popover-content') + } + beforeEach(() => { vi.clearAllMocks() + mockTranslate.mockImplementation((key: string, options?: { ns?: string }) => options?.ns ? `${options.ns}.${key}` : key) }) it('renders marketplace trigger when used in marketplace', () => { @@ -100,6 +104,7 @@ describe('TagsFilter', () => { it('filters tag options by search text', () => { render() + fireEvent.click(screen.getByTestId('popover-trigger')) expect(screen.getByText('Agent')).toBeInTheDocument() expect(screen.getByText('RAG')).toBeInTheDocument() @@ -116,11 +121,20 @@ describe('TagsFilter', () => { const onTagsChange = vi.fn() const { rerender } = render() - fireEvent.click(screen.getByText('Agent')) + fireEvent.click(within(ensurePopoverOpen()).getByText('Agent')) expect(onTagsChange).toHaveBeenCalledWith([]) rerender() - fireEvent.click(screen.getByText('RAG')) + fireEvent.click(within(ensurePopoverOpen()).getByText('RAG')) expect(onTagsChange).toHaveBeenCalledWith(['agent', 'rag']) }) + + it('falls back to an empty placeholder when translation is missing', () => { + mockTranslate.mockImplementation(() => undefined as unknown as string) + + render() + fireEvent.click(screen.getByTestId('popover-trigger')) + + expect(screen.getByLabelText('tags-search')).toHaveAttribute('placeholder', '') + }) }) diff --git a/web/app/components/plugins/marketplace/search-box/tags-filter.tsx b/web/app/components/plugins/marketplace/search-box/tags-filter.tsx index b078dbaa9b..d97420b672 100644 --- a/web/app/components/plugins/marketplace/search-box/tags-filter.tsx +++ b/web/app/components/plugins/marketplace/search-box/tags-filter.tsx @@ -1,14 +1,14 @@ 'use client' import { useTranslation } from '#i18n' +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@langgenius/dify-ui/popover' import { useState } from 'react' import Checkbox from '@/app/components/base/checkbox' import Input from '@/app/components/base/input' -import { - PortalToFollowElem, - PortalToFollowElemContent, - PortalToFollowElemTrigger, -} from '@/app/components/base/portal-to-follow-elem' import { useTags } from '@/app/components/plugins/hooks' import MarketplaceTrigger from './trigger/marketplace' import ToolSelectorTrigger from './trigger/tool-selector' @@ -37,43 +37,45 @@ const TagsFilter = ({ const selectedTagsLength = tags.length return ( - - setOpen(v => !v)} + + { + usedInMarketplace && ( + + ) + } + { + !usedInMarketplace && ( + + ) + } +
+ )} + /> + - { - usedInMarketplace && ( - - ) - } - { - !usedInMarketplace && ( - - ) - } - -
- - + + ) } diff --git a/web/app/components/plugins/plugin-auth/authorized/__tests__/index.spec.tsx b/web/app/components/plugins/plugin-auth/authorized/__tests__/index.spec.tsx index 01e195b21b..271252464a 100644 --- a/web/app/components/plugins/plugin-auth/authorized/__tests__/index.spec.tsx +++ b/web/app/components/plugins/plugin-auth/authorized/__tests__/index.spec.tsx @@ -73,6 +73,8 @@ vi.mock('@/hooks/use-oauth', () => ({ openOAuthPopup: vi.fn(), })) +vi.mock('@langgenius/dify-ui/popover', async () => await import('@/__mocks__/base-ui-popover')) + // Mock service/use-triggers vi.mock('@/service/use-triggers', () => ({ useTriggerPluginDynamicOptions: () => ({ diff --git a/web/app/components/plugins/plugin-auth/authorized/index.tsx b/web/app/components/plugins/plugin-auth/authorized/index.tsx index fed2873b98..b8b34e33e0 100644 --- a/web/app/components/plugins/plugin-auth/authorized/index.tsx +++ b/web/app/components/plugins/plugin-auth/authorized/index.tsx @@ -1,7 +1,8 @@ -import type { Credential, PluginPayload } from '../types' import type { - PortalToFollowElemOptions, -} from '@/app/components/base/portal-to-follow-elem' + OffsetOptions, +} from '@floating-ui/react' +import type { Placement } from '@langgenius/dify-ui/popover' +import type { Credential, PluginPayload } from '../types' import { AlertDialog, AlertDialogActions, @@ -12,6 +13,11 @@ import { } from '@langgenius/dify-ui/alert-dialog' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@langgenius/dify-ui/popover' import { toast } from '@langgenius/dify-ui/toast' import { RiArrowDownSLine, @@ -23,11 +29,6 @@ import { useState, } from 'react' import { useTranslation } from 'react-i18next' -import { - PortalToFollowElem, - PortalToFollowElemContent, - PortalToFollowElemTrigger, -} from '@/app/components/base/portal-to-follow-elem' import Indicator from '@/app/components/header/indicator' import Authorize from '../authorize' import ApiKeyModal from '../authorize/api-key-modal' @@ -48,8 +49,8 @@ type AuthorizedProps = { renderTrigger?: (open?: boolean) => React.ReactNode isOpen?: boolean onOpenChange?: (open: boolean) => void - offset?: PortalToFollowElemOptions['offset'] - placement?: PortalToFollowElemOptions['placement'] + offset?: number | OffsetOptions + placement?: Placement triggerPopupSameWidth?: boolean popupClassName?: string disableSetDefault?: boolean @@ -96,11 +97,12 @@ const Authorized = ({ const [deleteCredentialId, setDeleteCredentialId] = useState(null) const { mutateAsync: deletePluginCredential } = useDeletePluginCredentialHook(pluginPayload) const openConfirm = useCallback((credentialId?: string) => { + setMergedIsOpen(false) if (credentialId) pendingOperationCredentialId.current = credentialId setDeleteCredentialId(pendingOperationCredentialId.current) - }, []) + }, [setMergedIsOpen]) const closeConfirm = useCallback(() => { setDeleteCredentialId(null) pendingOperationCredentialId.current = null @@ -130,11 +132,12 @@ const Authorized = ({ handleSetDoingAction(false) } }, [deletePluginCredential, onUpdate, t, handleSetDoingAction]) - const [editValues, setEditValues] = useState | null>(null) - const handleEdit = useCallback((id: string, values: Record) => { + const [editValues, setEditValues] = useState | null>(null) + const handleEdit = useCallback((id: string, values: Record) => { + setMergedIsOpen(false) pendingOperationCredentialId.current = id setEditValues(values) - }, []) + }, [setMergedIsOpen]) const handleRemove = useCallback(() => { setDeleteCredentialId(pendingOperationCredentialId.current) }, []) @@ -171,49 +174,59 @@ const Authorized = ({ }, [updatePluginCredential, t, handleSetDoingAction, onUpdate]) const unavailableCredentials = credentials.filter(credential => credential.not_allowed_to_use) const unavailableCredential = credentials.find(credential => credential.not_allowed_to_use && credential.is_default) + const resolvedOffset = typeof offset === 'number' || typeof offset === 'function' ? undefined : offset + const sideOffset = typeof offset === 'number' ? offset : resolvedOffset?.mainAxis ?? 0 + const alignOffset = typeof offset === 'number' ? 0 : resolvedOffset?.crossAxis ?? resolvedOffset?.alignmentAxis ?? 0 + const popupProps = triggerPopupSameWidth + ? { style: { width: 'var(--anchor-width, auto)' } } + : undefined return ( <> - - setMergedIsOpen(!mergedIsOpen)} - asChild - > - { - renderTrigger - ? renderTrigger(mergedIsOpen) - : ( - - ) - } - - + { + credentials.length > 1 + ? t('auth.authorizations', { ns: 'plugin' }) + : t('auth.authorization', { ns: 'plugin' }) + } + { + !!unavailableCredentials.length && ( + ` (${unavailableCredentials.length} ${t('auth.unavailable', { ns: 'plugin' })})` + ) + } + + + ) + } +
+ )} + /> +
- - + + !open && closeConfirm()}>
diff --git a/web/app/components/plugins/plugin-detail-panel/app-selector/__tests__/app-picker.spec.tsx b/web/app/components/plugins/plugin-detail-panel/app-selector/__tests__/app-picker.spec.tsx index a319d2f8c4..af3f97c889 100644 --- a/web/app/components/plugins/plugin-detail-panel/app-selector/__tests__/app-picker.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/app-selector/__tests__/app-picker.spec.tsx @@ -46,8 +46,8 @@ vi.mock('@/app/components/base/input', () => ({ ), })) -vi.mock('@/app/components/base/portal-to-follow-elem', () => ({ - PortalToFollowElem: ({ +vi.mock('@langgenius/dify-ui/popover', () => ({ + Popover: ({ children, open, }: { @@ -58,18 +58,20 @@ vi.mock('@/app/components/base/portal-to-follow-elem', () => ({ {children}
), - PortalToFollowElemTrigger: ({ + PopoverTrigger: ({ children, + render, onClick, }: { children: ReactNode + render?: ReactNode onClick?: () => void }) => ( ), - PortalToFollowElemContent: ({ children }: { children: ReactNode }) => ( + PopoverContent: ({ children }: { children: ReactNode }) => (
{children}
), })) diff --git a/web/app/components/plugins/plugin-detail-panel/app-selector/__tests__/index.spec.tsx b/web/app/components/plugins/plugin-detail-panel/app-selector/__tests__/index.spec.tsx index f7dd1921e4..7bc23d0223 100644 --- a/web/app/components/plugins/plugin-detail-panel/app-selector/__tests__/index.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/app-selector/__tests__/index.spec.tsx @@ -76,7 +76,7 @@ afterAll(() => { // Mock portal components for controlled positioning in tests // Use React context to properly scope open state per portal instance (for nested portals) -vi.mock('@/app/components/base/portal-to-follow-elem', () => { +vi.mock('@langgenius/dify-ui/popover', () => { // Context reference shared across mock components let sharedContext: React.Context | null = null @@ -90,7 +90,7 @@ vi.mock('@/app/components/base/portal-to-follow-elem', () => { } return { - PortalToFollowElem: ({ + Popover: ({ children, open, }: { @@ -104,20 +104,22 @@ vi.mock('@/app/components/base/portal-to-follow-elem', () => { React.createElement('div', { 'data-testid': 'portal-to-follow-elem', 'data-open': open }, children), ) }, - PortalToFollowElemTrigger: ({ + PopoverTrigger: ({ children, + render, onClick, className, }: { children: ReactNode + render?: ReactNode onClick?: () => void className?: string }) => (
- {children} + {render ?? children}
), - PortalToFollowElemContent: ({ children, className }: { children: ReactNode, className?: string }) => { + PopoverContent: ({ children, className }: { children: ReactNode, className?: string }) => { const Context = getContext() const isOpen = React.useContext(Context) if (!isOpen) diff --git a/web/app/components/plugins/plugin-detail-panel/app-selector/app-picker.tsx b/web/app/components/plugins/plugin-detail-panel/app-selector/app-picker.tsx index 41140ac63b..cf387b1715 100644 --- a/web/app/components/plugins/plugin-detail-panel/app-selector/app-picker.tsx +++ b/web/app/components/plugins/plugin-detail-panel/app-selector/app-picker.tsx @@ -5,16 +5,16 @@ import type { } from '@floating-ui/react' import type { FC } from 'react' import type { App } from '@/types/app' +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@langgenius/dify-ui/popover' import * as React from 'react' import { useCallback, useEffect, useRef } from 'react' import { useTranslation } from 'react-i18next' import AppIcon from '@/app/components/base/app-icon' import Input from '@/app/components/base/input' -import { - PortalToFollowElem, - PortalToFollowElemContent, - PortalToFollowElemTrigger, -} from '@/app/components/base/portal-to-follow-elem' import { AppModeEnum } from '@/types/app' type Props = { @@ -154,26 +154,33 @@ const AppPicker: FC = ({ } } - const handleTriggerClick = () => { - if (disabled) + const resolvedOffset = typeof offset === 'number' || typeof offset === 'function' ? undefined : offset + const sideOffset = typeof offset === 'number' ? offset : resolvedOffset?.mainAxis ?? 0 + const alignOffset = typeof offset === 'number' ? 0 : resolvedOffset?.crossAxis ?? resolvedOffset?.alignmentAxis ?? 0 + const handleTriggerClick = useCallback((event: React.MouseEvent) => { + event.preventDefault() + if (disabled || isShow) return + onShowChange(true) - } + }, [disabled, isShow, onShowChange]) return ( - - {trigger}
} onClick={handleTriggerClick} - > - {trigger} - + /> - +
= ({
-
- +
+ ) } diff --git a/web/app/components/plugins/plugin-detail-panel/app-selector/index.tsx b/web/app/components/plugins/plugin-detail-panel/app-selector/index.tsx index 97e144af6f..76dbdba7aa 100644 --- a/web/app/components/plugins/plugin-detail-panel/app-selector/index.tsx +++ b/web/app/components/plugins/plugin-detail-panel/app-selector/index.tsx @@ -5,14 +5,14 @@ import type { } from '@floating-ui/react' import type { FC } from 'react' import type { App } from '@/types/app' +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@langgenius/dify-ui/popover' import * as React from 'react' import { useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' -import { - PortalToFollowElem, - PortalToFollowElemContent, - PortalToFollowElemTrigger, -} from '@/app/components/base/portal-to-follow-elem' import AppInputsPanel from '@/app/components/plugins/plugin-detail-panel/app-selector/app-inputs-panel' import AppPicker from '@/app/components/plugins/plugin-detail-panel/app-selector/app-picker' import AppTrigger from '@/app/components/plugins/plugin-detail-panel/app-selector/app-trigger' @@ -94,6 +94,9 @@ const AppSelector: FC = ({ }, [currentAppInfo, displayedApps]) const hasMore = hasNextPage ?? true + const resolvedOffset = typeof offset === 'number' || typeof offset === 'function' ? undefined : offset + const sideOffset = typeof offset === 'number' ? offset : resolvedOffset?.mainAxis ?? 0 + const alignOffset = typeof offset === 'number' ? 0 : resolvedOffset?.crossAxis ?? resolvedOffset?.alignmentAxis ?? 0 const handleLoadMore = useCallback(async () => { if (isFetchingNextPage || !hasMore) @@ -102,11 +105,13 @@ const AppSelector: FC = ({ await fetchNextPage() }, [fetchNextPage, hasMore, isFetchingNextPage]) - const handleTriggerClick = () => { - if (disabled) + const handleTriggerClick = useCallback((event: React.MouseEvent) => { + event.preventDefault() + if (disabled || isShow) return + setIsShow(true) - } + }, [disabled, isShow]) const [isShowChooseApp, setIsShowChooseApp] = useState(false) const handleSelectApp = (app: App) => { @@ -143,22 +148,27 @@ const AppSelector: FC = ({ return ( <> - - + +
+ )} onClick={handleTriggerClick} + /> + - -
-
{t('appSelector.label', { ns: 'app' })}
@@ -193,8 +203,8 @@ const AppSelector: FC = ({ /> )}
- - + + ) } diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/log-viewer.spec.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/log-viewer.spec.tsx index 3594c10ce2..3b179d0881 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/log-viewer.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/log-viewer.spec.tsx @@ -145,7 +145,7 @@ describe('LogViewer', () => { }) it('should parse request data when it is raw JSON', () => { - const log = createLog({ request: { ...createLog().request, data: '{\"hello\":1}' } }) + const log = createLog({ request: { ...createLog().request, data: '{"hello":1}' } }) render() diff --git a/web/app/components/plugins/plugin-detail-panel/tool-selector/__tests__/index.spec.tsx b/web/app/components/plugins/plugin-detail-panel/tool-selector/__tests__/index.spec.tsx index 168e4f1eba..acd9a9b146 100644 --- a/web/app/components/plugins/plugin-detail-panel/tool-selector/__tests__/index.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/tool-selector/__tests__/index.spec.tsx @@ -153,8 +153,8 @@ vi.mock('@/app/components/plugins/plugin-auth', () => ({ })) // Portal components need mocking for controlled positioning in tests -vi.mock('@/app/components/base/portal-to-follow-elem', () => ({ - PortalToFollowElem: ({ +vi.mock('@langgenius/dify-ui/popover', () => ({ + Popover: ({ children, open, }: { @@ -165,18 +165,20 @@ vi.mock('@/app/components/base/portal-to-follow-elem', () => ({ {children}
), - PortalToFollowElemTrigger: ({ + PopoverTrigger: ({ children, + render, onClick, }: { children: ReactNode + render?: ReactNode onClick?: () => void }) => (
- {children} + {render ?? children}
), - PortalToFollowElemContent: ({ children }: { children: ReactNode }) => ( + PopoverContent: ({ children }: { children: ReactNode }) => (
{children}
), })) diff --git a/web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/reasoning-config-form.spec.tsx b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/reasoning-config-form.spec.tsx index beab35595c..3c56089b97 100644 --- a/web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/reasoning-config-form.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/__tests__/reasoning-config-form.spec.tsx @@ -67,7 +67,7 @@ vi.mock('@/app/components/plugins/plugin-detail-panel/model-selector', () => ({ vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({ default: ({ onChange }: { onChange: (value: string) => void }) => ( - ), diff --git a/web/app/components/plugins/plugin-detail-panel/tool-selector/index.tsx b/web/app/components/plugins/plugin-detail-panel/tool-selector/index.tsx index b5e69ce254..109b0ece4c 100644 --- a/web/app/components/plugins/plugin-detail-panel/tool-selector/index.tsx +++ b/web/app/components/plugins/plugin-detail-panel/tool-selector/index.tsx @@ -8,13 +8,13 @@ import type { Node } from 'reactflow' import type { ToolValue } from '@/app/components/workflow/block-selector/types' import type { NodeOutPutVar } from '@/app/components/workflow/types' import { cn } from '@langgenius/dify-ui/cn' +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@langgenius/dify-ui/popover' import * as React from 'react' import { useTranslation } from 'react-i18next' -import { - PortalToFollowElem, - PortalToFollowElemContent, - PortalToFollowElemTrigger, -} from '@/app/components/base/portal-to-follow-elem' import { CollectionType } from '@/app/components/tools/types' import Link from '@/next/link' import { @@ -102,15 +102,21 @@ const ToolSelector: FC = ({ getSettingsValue, } = state - const handleTriggerClick = () => { + const handleTriggerClick = (event: React.MouseEvent) => { + event.preventDefault() if (disabled) return + if (!currentProvider || !currentTool) + return setIsShow(true) } // Determine portal open state based on controlled vs uncontrolled mode const portalOpen = trigger ? controlledState : isShow const onPortalOpenChange = trigger ? onControlledStateChange : setIsShow + const resolvedOffset = typeof offset === 'number' || typeof offset === 'function' ? undefined : offset + const sideOffset = typeof offset === 'number' ? offset : resolvedOffset?.mainAxis ?? 0 + const alignOffset = typeof offset === 'number' ? 0 : resolvedOffset?.crossAxis ?? resolvedOffset?.alignmentAxis ?? 0 // Build error tooltip content const renderErrorTip = () => ( @@ -134,57 +140,58 @@ const ToolSelector: FC = ({ ) return ( - - { - if (!currentProvider || !currentTool) - return - handleTriggerClick() - }} + + {trigger} + + {/* Default trigger - no value */} + {!trigger && !value?.provider_name && ( + + )} + + {/* Default trigger - with value */} + {!trigger && value?.provider_name && ( + + )} + + )} + onClick={handleTriggerClick} + /> + + - {trigger} - - {/* Default trigger - no value */} - {!trigger && !value?.provider_name && ( - - )} - - {/* Default trigger - with value */} - {!trigger && value?.provider_name && ( - - )} - - -
= ({ onParamsFormChange={handleParamsFormChange} />
-
-
+ + ) } diff --git a/web/app/components/plugins/plugin-page/filter-management/__tests__/category-filter.spec.tsx b/web/app/components/plugins/plugin-page/filter-management/__tests__/category-filter.spec.tsx index e4b698a5f8..c04012c498 100644 --- a/web/app/components/plugins/plugin-page/filter-management/__tests__/category-filter.spec.tsx +++ b/web/app/components/plugins/plugin-page/filter-management/__tests__/category-filter.spec.tsx @@ -2,17 +2,7 @@ import { fireEvent, render, screen } from '@testing-library/react' import * as React from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' -vi.mock('@/app/components/base/portal-to-follow-elem', () => ({ - PortalToFollowElem: ({ children, open }: { children: React.ReactNode, open: boolean }) => ( -
{children}
- ), - PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick: () => void }) => ( -
{children}
- ), - PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) => ( -
{children}
- ), -})) +vi.mock('@langgenius/dify-ui/popover', () => import('@/__mocks__/base-ui-popover')) vi.mock('@langgenius/dify-ui/cn', () => ({ cn: (...args: unknown[]) => args.filter(Boolean).join(' '), @@ -67,7 +57,7 @@ describe('CategoriesFilter', () => { const mockOnChange = vi.fn() render() - const trigger = screen.getByTestId('portal-trigger') + const trigger = screen.getByTestId('popover-trigger') const clearSvg = trigger.querySelector('svg') fireEvent.click(clearSvg!) expect(mockOnChange).toHaveBeenCalledWith([]) @@ -75,6 +65,7 @@ describe('CategoriesFilter', () => { it('should render category options in dropdown', () => { render() + fireEvent.click(screen.getByTestId('popover-trigger')) expect(screen.getByText('Tool'))!.toBeInTheDocument() expect(screen.getByText('Model'))!.toBeInTheDocument() @@ -85,6 +76,7 @@ describe('CategoriesFilter', () => { const mockOnChange = vi.fn() render() + fireEvent.click(screen.getByTestId('popover-trigger')) fireEvent.click(screen.getByText('Tool')) expect(mockOnChange).toHaveBeenCalledWith(['tool']) }) @@ -93,8 +85,20 @@ describe('CategoriesFilter', () => { const mockOnChange = vi.fn() render() + fireEvent.click(screen.getByTestId('popover-trigger')) const toolElements = screen.getAllByText('Tool') fireEvent.click(toolElements[toolElements.length - 1]!) expect(mockOnChange).toHaveBeenCalledWith([]) }) + + it('should filter categories by search text', () => { + render() + + fireEvent.click(screen.getByTestId('popover-trigger')) + fireEvent.change(screen.getByPlaceholderText('plugin.searchCategories'), { target: { value: 'mod' } }) + + expect(screen.queryByText('Tool')).not.toBeInTheDocument() + expect(screen.getByText('Model')).toBeInTheDocument() + expect(screen.queryByText('Extension')).not.toBeInTheDocument() + }) }) diff --git a/web/app/components/plugins/plugin-page/filter-management/__tests__/index.spec.tsx b/web/app/components/plugins/plugin-page/filter-management/__tests__/index.spec.tsx index f30b5fb5fa..46493a87df 100644 --- a/web/app/components/plugins/plugin-page/filter-management/__tests__/index.spec.tsx +++ b/web/app/components/plugins/plugin-page/filter-management/__tests__/index.spec.tsx @@ -1,6 +1,7 @@ import type { Category, Tag } from '../constant' import type { FilterState } from '../index' import { act, fireEvent, render, renderHook, screen, waitFor } from '@testing-library/react' +import { createContext, useContext } from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' // ==================== Imports (after mocks) ==================== @@ -68,19 +69,47 @@ vi.mock('../../../hooks', () => ({ }), })) -// Track portal open state for testing -let mockPortalOpenState = false +type MockPopoverContextValue = { + open: boolean + onOpenChange?: (open: boolean) => void +} -vi.mock('@/app/components/base/portal-to-follow-elem', () => ({ - PortalToFollowElem: ({ children, open }: { children: React.ReactNode, open: boolean }) => { - mockPortalOpenState = open - return
{children}
- }, - PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick: () => void }) => ( -
{children}
+const MockPopoverContext = createContext({ + open: false, +}) + +vi.mock('@langgenius/dify-ui/popover', () => ({ + Popover: ({ children, open, onOpenChange }: { + children: React.ReactNode + open: boolean + onOpenChange?: (open: boolean) => void + }) => ( + +
{children}
+
), - PortalToFollowElemContent: ({ children, className }: { children: React.ReactNode, className?: string }) => { - if (!mockPortalOpenState) + PopoverTrigger: ({ children, render, className }: { + children?: React.ReactNode + render?: React.ReactNode + className?: string + }) => { + const { open, onOpenChange } = useContext(MockPopoverContext) + return ( +
onOpenChange?.(!open)} + className={className} + > + {render ?? children} +
+ ) + }, + PopoverContent: ({ children, className }: { + children: React.ReactNode + className?: string + }) => { + const { open } = useContext(MockPopoverContext) + if (!open) return null return
{children}
}, @@ -457,7 +486,6 @@ describe('SearchBox Component', () => { describe('CategoriesFilter Component', () => { beforeEach(() => { vi.clearAllMocks() - mockPortalOpenState = false }) describe('Rendering', () => { @@ -694,7 +722,6 @@ describe('CategoriesFilter Component', () => { describe('TagFilter Component', () => { beforeEach(() => { vi.clearAllMocks() - mockPortalOpenState = false }) describe('Rendering', () => { @@ -857,7 +884,6 @@ describe('FilterManagement Component', () => { beforeEach(() => { vi.clearAllMocks() mockInitFilters = createFilterState() - mockPortalOpenState = false }) describe('Rendering', () => { diff --git a/web/app/components/plugins/plugin-page/filter-management/__tests__/tag-filter.spec.tsx b/web/app/components/plugins/plugin-page/filter-management/__tests__/tag-filter.spec.tsx index ff3cd3d97c..f5db25bf5a 100644 --- a/web/app/components/plugins/plugin-page/filter-management/__tests__/tag-filter.spec.tsx +++ b/web/app/components/plugins/plugin-page/filter-management/__tests__/tag-filter.spec.tsx @@ -2,8 +2,6 @@ import { fireEvent, render, screen, within } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import TagFilter from '../tag-filter' -let portalOpen = false - vi.mock('../../../hooks', () => ({ useTags: () => ({ tags: [ @@ -19,35 +17,17 @@ vi.mock('../../../hooks', () => ({ }), })) -vi.mock('@/app/components/base/portal-to-follow-elem', () => ({ - PortalToFollowElem: ({ - children, - open, - }: { - children: React.ReactNode - open: boolean - }) => { - portalOpen = open - return
{children}
- }, - PortalToFollowElemTrigger: ({ - children, - onClick, - }: { - children: React.ReactNode - onClick: () => void - }) => , - PortalToFollowElemContent: ({ - children, - }: { - children: React.ReactNode - }) => portalOpen ?
{children}
: null, -})) +vi.mock('@langgenius/dify-ui/popover', () => import('@/__mocks__/base-ui-popover')) describe('TagFilter', () => { beforeEach(() => { vi.clearAllMocks() - portalOpen = false + }) + + it('renders the all tags placeholder when nothing is selected', () => { + render() + + expect(screen.getByText('pluginTags.allTags')).toBeInTheDocument() }) it('renders selected tag labels and the overflow counter', () => { @@ -61,8 +41,8 @@ describe('TagFilter', () => { const onChange = vi.fn() render() - fireEvent.click(screen.getByTestId('trigger')) - const portal = screen.getByTestId('portal-content') + fireEvent.click(screen.getByTestId('popover-trigger')) + const portal = screen.getByTestId('popover-content') fireEvent.change(screen.getByPlaceholderText('pluginTags.searchTags'), { target: { value: 'ra' } }) @@ -73,4 +53,24 @@ describe('TagFilter', () => { expect(onChange).toHaveBeenCalledWith(['agent', 'rag']) }) + + it('clears all selected tags when the clear icon is clicked', () => { + const onChange = vi.fn() + render() + + const trigger = screen.getByTestId('popover-trigger') + fireEvent.click(trigger.querySelector('svg')!) + + expect(onChange).toHaveBeenCalledWith([]) + }) + + it('removes a selected tag when clicking the same option again', () => { + const onChange = vi.fn() + render() + + fireEvent.click(screen.getByTestId('popover-trigger')) + fireEvent.click(within(screen.getByTestId('popover-content')).getByText('Agent')) + + expect(onChange).toHaveBeenCalledWith([]) + }) }) diff --git a/web/app/components/plugins/plugin-page/filter-management/category-filter.tsx b/web/app/components/plugins/plugin-page/filter-management/category-filter.tsx index 8dbef5395d..f75c63be94 100644 --- a/web/app/components/plugins/plugin-page/filter-management/category-filter.tsx +++ b/web/app/components/plugins/plugin-page/filter-management/category-filter.tsx @@ -1,6 +1,11 @@ 'use client' import { cn } from '@langgenius/dify-ui/cn' +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@langgenius/dify-ui/popover' import { RiArrowDownSLine, RiCloseCircleFill, @@ -9,11 +14,6 @@ import { useState } from 'react' import { useTranslation } from 'react-i18next' import Checkbox from '@/app/components/base/checkbox' import Input from '@/app/components/base/input' -import { - PortalToFollowElem, - PortalToFollowElemContent, - PortalToFollowElemTrigger, -} from '@/app/components/base/portal-to-follow-elem' import { useCategories } from '../../hooks' type CategoriesFilterProps = { @@ -38,61 +38,64 @@ const CategoriesFilter = ({ const selectedTagsLength = value.length return ( - - setOpen(v => !v)}> -
+ +
+ { + !selectedTagsLength && t('allCategories', { ns: 'plugin' }) + } + { + !!selectedTagsLength && value.map(val => categoriesMap[val]!.label).slice(0, 2).join(',') + } + { + selectedTagsLength > 2 && ( +
+ + + {selectedTagsLength - 2} +
+ ) + } +
{ - !selectedTagsLength && t('allCategories', { ns: 'plugin' }) + !!selectedTagsLength && ( + { + e.stopPropagation() + onChange([]) + } + } + /> + ) } { - !!selectedTagsLength && value.map(val => categoriesMap[val]!.label).slice(0, 2).join(',') - } - { - selectedTagsLength > 2 && ( -
- + - {selectedTagsLength - 2} -
+ !selectedTagsLength && ( + ) }
- { - !!selectedTagsLength && ( - { - e.stopPropagation() - onChange([]) - } - } - /> - ) - } - { - !selectedTagsLength && ( - - ) - } - -
- + )} + /> +
- - + + ) } diff --git a/web/app/components/plugins/plugin-page/filter-management/tag-filter.tsx b/web/app/components/plugins/plugin-page/filter-management/tag-filter.tsx index e245895b3b..6916edd219 100644 --- a/web/app/components/plugins/plugin-page/filter-management/tag-filter.tsx +++ b/web/app/components/plugins/plugin-page/filter-management/tag-filter.tsx @@ -1,6 +1,11 @@ 'use client' import { cn } from '@langgenius/dify-ui/cn' +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@langgenius/dify-ui/popover' import { RiArrowDownSLine, RiCloseCircleFill, @@ -9,11 +14,6 @@ import { useState } from 'react' import { useTranslation } from 'react-i18next' import Checkbox from '@/app/components/base/checkbox' import Input from '@/app/components/base/input' -import { - PortalToFollowElem, - PortalToFollowElemContent, - PortalToFollowElemTrigger, -} from '@/app/components/base/portal-to-follow-elem' import { useTags } from '../../hooks' type TagsFilterProps = { @@ -38,56 +38,62 @@ const TagsFilter = ({ const selectedTagsLength = value.length return ( - - setOpen(v => !v)}> -
+ +
+ { + !selectedTagsLength && t('allTags', { ns: 'pluginTags' }) + } + { + !!selectedTagsLength && value.map(val => getTagLabel(val)).slice(0, 2).join(',') + } + { + selectedTagsLength > 2 && ( +
+ + + {selectedTagsLength - 2} +
+ ) + } +
{ - !selectedTagsLength && t('allTags', { ns: 'pluginTags' }) + !!selectedTagsLength && ( + { + e.stopPropagation() + onChange([]) + }} + /> + ) } { - !!selectedTagsLength && value.map(val => getTagLabel(val)).slice(0, 2).join(',') - } - { - selectedTagsLength > 2 && ( -
- + - {selectedTagsLength - 2} -
+ !selectedTagsLength && ( + ) }
- { - !!selectedTagsLength && ( - onChange([])} - /> - ) - } - { - !selectedTagsLength && ( - - ) - } -
- - + )} + /> +
- - + + ) } diff --git a/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/strategy-picker.spec.tsx b/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/strategy-picker.spec.tsx index 5be10ff146..e287e48985 100644 --- a/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/strategy-picker.spec.tsx +++ b/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/strategy-picker.spec.tsx @@ -14,7 +14,7 @@ vi.mock('@langgenius/dify-ui/button', () => ({ })) vi.mock('@/app/components/base/portal-to-follow-elem', async () => { - const React = await import('react') + const _React = await import('react') return { PortalToFollowElem: ({ open, diff --git a/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/tool-picker.spec.tsx b/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/tool-picker.spec.tsx index 9e63622d3f..e89b1b3161 100644 --- a/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/tool-picker.spec.tsx +++ b/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/tool-picker.spec.tsx @@ -22,7 +22,7 @@ vi.mock('@/app/components/base/loading', () => ({ })) vi.mock('@/app/components/base/portal-to-follow-elem', async () => { - const React = await import('react') + const _React = await import('react') return { PortalToFollowElem: ({ open, diff --git a/web/app/components/rag-pipeline/components/rag-pipeline-header/__tests__/index.spec.tsx b/web/app/components/rag-pipeline/components/rag-pipeline-header/__tests__/index.spec.tsx index dda765b48f..cff2a5f4c2 100644 --- a/web/app/components/rag-pipeline/components/rag-pipeline-header/__tests__/index.spec.tsx +++ b/web/app/components/rag-pipeline/components/rag-pipeline-header/__tests__/index.spec.tsx @@ -192,27 +192,6 @@ vi.mock('ahooks', () => ({ useKeyPress: vi.fn(), })) -let portalOpenState = false -vi.mock('@/app/components/base/portal-to-follow-elem', () => ({ - PortalToFollowElem: ({ children, open, onOpenChange: _onOpenChange }: PropsWithChildren<{ - open: boolean - onOpenChange: (open: boolean) => void - placement?: string - offset?: unknown - }>) => { - portalOpenState = open - return
{children}
- }, - PortalToFollowElemTrigger: ({ children, onClick }: PropsWithChildren<{ onClick?: () => void }>) => ( -
{children}
- ), - PortalToFollowElemContent: ({ children }: PropsWithChildren) => { - if (!portalOpenState) - return null - return
{children}
- }, -})) - vi.mock('../../../publish-as-knowledge-pipeline-modal', () => ({ default: ({ onConfirm, onCancel }: { onConfirm: (name: string, icon: unknown, description?: string) => void @@ -229,7 +208,6 @@ vi.mock('../../../publish-as-knowledge-pipeline-modal', () => ({ describe('RagPipelineHeader', () => { beforeEach(() => { vi.clearAllMocks() - portalOpenState = false mockStoreState = { pipelineId: 'test-pipeline-id', showDebugAndPreviewPanel: false, @@ -351,7 +329,6 @@ describe('InputFieldButton', () => { describe('Publisher', () => { beforeEach(() => { vi.clearAllMocks() - portalOpenState = false }) describe('Rendering', () => { @@ -367,9 +344,9 @@ describe('Publisher', () => { expect(button)!.toHaveClass('px-2') }) - it('should render portal trigger element', () => { + it('should render publish trigger button', () => { render() - expect(screen.getByTestId('portal-trigger'))!.toBeInTheDocument() + expect(screen.getByRole('button', { name: /workflow\.common\.publish/i }))!.toBeInTheDocument() }) }) @@ -377,7 +354,7 @@ describe('Publisher', () => { it('should call handleSyncWorkflowDraft when opening', () => { render() - fireEvent.click(screen.getByTestId('portal-trigger')) + fireEvent.click(screen.getByRole('button', { name: /workflow\.common\.publish/i })) expect(mockHandleSyncWorkflowDraft).toHaveBeenCalledWith(true) }) @@ -385,12 +362,14 @@ describe('Publisher', () => { it('should toggle open state when trigger clicked', () => { render() - const portal = screen.getByTestId('portal-elem') - expect(portal)!.toHaveAttribute('data-open', 'false') + const trigger = screen.getByRole('button', { name: /workflow\.common\.publish/i }) + expect(trigger)!.toHaveAttribute('aria-expanded', 'false') - fireEvent.click(screen.getByTestId('portal-trigger')) + fireEvent.click(trigger) expect(mockHandleSyncWorkflowDraft).toHaveBeenCalled() + expect(trigger)!.toHaveAttribute('aria-expanded', 'true') + expect(screen.getByText(/workflow\.common\.publishUpdate/i))!.toBeInTheDocument() }) }) }) @@ -978,7 +957,6 @@ describe('RunMode', () => { describe('Integration', () => { beforeEach(() => { vi.clearAllMocks() - portalOpenState = false mockStoreState = { pipelineId: 'test-pipeline-id', showDebugAndPreviewPanel: false, diff --git a/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/__tests__/index.spec.tsx b/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/__tests__/index.spec.tsx index abb334b393..afd7c04ed1 100644 --- a/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/__tests__/index.spec.tsx +++ b/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/__tests__/index.spec.tsx @@ -6,6 +6,40 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import Publisher from '../index' import Popup from '../popup' +vi.mock('@langgenius/dify-ui/popover', async () => await import('@/__mocks__/base-ui-popover')) +vi.mock('@langgenius/dify-ui/button', () => ({ + Button: ({ children, onClick, disabled, variant, className }: Record) => ( + + ), +})) +vi.mock('@langgenius/dify-ui/alert-dialog', () => ({ + AlertDialog: ({ children, open, onOpenChange }: { children: React.ReactNode, open?: boolean, onOpenChange?: (open: boolean) => void }) => ( + open + ? ( +
+ {children} + +
+ ) + : null + ), + AlertDialogActions: ({ children }: { children: React.ReactNode }) =>
{children}
, + AlertDialogCancelButton: ({ children }: { children: React.ReactNode }) => , + AlertDialogConfirmButton: ({ children, onClick, disabled }: Record) => , + AlertDialogContent: ({ children }: { children: React.ReactNode }) =>
{children}
, + AlertDialogDescription: ({ children }: { children: React.ReactNode }) =>
{children}
, + AlertDialogTitle: ({ children }: { children: React.ReactNode }) =>
{children}
, +})) + const mockPush = vi.fn() vi.mock('@/next/navigation', () => ({ useParams: () => ({ datasetId: 'test-dataset-id' }), @@ -60,7 +94,8 @@ vi.mock('@/context/dataset-detail', () => ({ const mockSetShowPricingModal = vi.fn() vi.mock('@/context/modal-context', () => ({ - useModalContextSelector: () => mockSetShowPricingModal, + useModalContextSelector: (selector: (state: { setShowPricingModal: typeof mockSetShowPricingModal }) => T): T => + selector({ setShowPricingModal: mockSetShowPricingModal }), })) const mockIsAllowPublishAsCustomKnowledgePipelineTemplate = vi.fn(() => true) @@ -200,8 +235,7 @@ describe('publisher', () => { it('should render portal element in closed state by default', () => { renderWithQueryClient() - const trigger = screen.getByText('workflow.common.publish').closest('[data-state]') - expect(trigger).toHaveAttribute('data-state', 'closed') + expect(screen.getByTestId('popover')).toHaveAttribute('data-open', 'false') expect(screen.queryByText('workflow.common.publishUpdate')).not.toBeInTheDocument() }) @@ -277,6 +311,25 @@ describe('publisher', () => { expect(screen.getByText('workflow.common.publishUpdate')).toBeInTheDocument() }) }) + + it('should close the outer popover before opening publish-as follow-up flow', async () => { + mockPublishedAt.mockReturnValue(1700000000) + mockIsAllowPublishAsCustomKnowledgePipelineTemplate.mockReturnValue(false) + renderWithQueryClient() + + fireEvent.click(screen.getByText('workflow.common.publish')) + + await waitFor(() => { + expect(screen.getByText('pipeline.common.publishAs')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByText('pipeline.common.publishAs')) + + await waitFor(() => { + expect(screen.queryByText('pipeline.common.publishAs')).not.toBeInTheDocument() + }) + expect(mockSetShowPricingModal).toHaveBeenCalled() + }) }) }) @@ -688,7 +741,7 @@ describe('publisher', () => { expect(screen.getByText('pipeline.common.confirmPublish')).toBeInTheDocument() }) - fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' })) + fireEvent.click(screen.getByTestId('alert-dialog-close')) await waitFor(() => { expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument() diff --git a/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/__tests__/popup.spec.tsx b/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/__tests__/popup.spec.tsx index 103ee53210..dab8046c43 100644 --- a/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/__tests__/popup.spec.tsx +++ b/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/__tests__/popup.spec.tsx @@ -3,6 +3,31 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import Popup from '../popup' +vi.mock('@langgenius/dify-ui/alert-dialog', () => ({ + AlertDialog: ({ children, open, onOpenChange }: { children: React.ReactNode, open?: boolean, onOpenChange?: (open: boolean) => void }) => ( + open + ? ( +
+ {children} + +
+ ) + : null + ), + AlertDialogActions: ({ children }: { children?: React.ReactNode }) =>
{children}
, + AlertDialogCancelButton: ({ children }: { children?: React.ReactNode }) => , + AlertDialogConfirmButton: ({ children, onClick, disabled }: { + children?: React.ReactNode + onClick?: () => void + disabled?: boolean + }) => , + AlertDialogContent: ({ children }: { children?: React.ReactNode }) =>
{children}
, + AlertDialogDescription: ({ children }: { children?: React.ReactNode }) =>
{children}
, + AlertDialogTitle: ({ children }: { children?: React.ReactNode }) =>
{children}
, +})) + const mockPublishWorkflow = vi.fn().mockResolvedValue({ created_at: '2024-01-01T00:00:00Z' }) const mockPublishAsCustomizedPipeline = vi.fn().mockResolvedValue({}) const toastMocks = vi.hoisted(() => ({ @@ -36,6 +61,8 @@ let mockPublishedAt: string | undefined = '2024-01-01T00:00:00Z' let mockDraftUpdatedAt: string | undefined = '2024-06-01T00:00:00Z' let mockPipelineId: string | undefined = 'pipeline-123' let mockIsAllowPublishAsCustom = true +const mockUseBoolean = vi.hoisted(() => vi.fn()) +const mockUseKeyPress = vi.hoisted(() => vi.fn()) vi.mock('@/next/navigation', () => ({ useParams: () => ({ datasetId: 'ds-123' }), useRouter: () => ({ push: mockPush }), @@ -48,14 +75,8 @@ vi.mock('@/next/link', () => ({ })) vi.mock('ahooks', () => ({ - useBoolean: (initial: boolean) => { - const state = { value: initial } - return [state.value, { - setFalse: vi.fn(), - setTrue: vi.fn(), - }] - }, - useKeyPress: vi.fn(), + useBoolean: (initial: boolean) => mockUseBoolean(initial), + useKeyPress: (...args: unknown[]) => mockUseKeyPress(...args), })) vi.mock('@/app/components/workflow/store', () => ({ @@ -126,7 +147,8 @@ vi.mock('@/context/i18n', () => ({ })) vi.mock('@/context/modal-context', () => ({ - useModalContextSelector: () => mockSetShowPricingModal, + useModalContextSelector: (selector: (state: { setShowPricingModal: typeof mockSetShowPricingModal }) => T) => + selector({ setShowPricingModal: mockSetShowPricingModal }), })) vi.mock('@/context/provider-context', () => ({ @@ -194,6 +216,11 @@ describe('Popup', () => { mockDraftUpdatedAt = '2024-06-01T00:00:00Z' mockPipelineId = 'pipeline-123' mockIsAllowPublishAsCustom = true + mockUseBoolean.mockImplementation((initial: boolean) => [initial, { + setFalse: vi.fn(), + setTrue: vi.fn(), + }]) + mockUseKeyPress.mockImplementation(() => {}) }) afterEach(() => { @@ -289,12 +316,61 @@ describe('Popup', () => { describe('Publish As Knowledge Pipeline', () => { it('should show pricing modal when not allowed', () => { mockIsAllowPublishAsCustom = false - render() + const onRequestClose = vi.fn() + render() fireEvent.click(screen.getByText('pipeline.common.publishAs')) + expect(onRequestClose).toHaveBeenCalledTimes(1) expect(mockSetShowPricingModal).toHaveBeenCalled() }) + + it('should request closing the outer popover before opening publish-as modal', () => { + const onRequestClose = vi.fn() + render() + + fireEvent.click(screen.getByText('pipeline.common.publishAs')) + + expect(onRequestClose).toHaveBeenCalledTimes(1) + }) + }) + + describe('Overlay cleanup', () => { + it('should close confirm dialog when alert dialog requests close', () => { + const hideConfirm = vi.fn() + mockUseBoolean + .mockImplementationOnce(() => [true, { setFalse: hideConfirm, setTrue: vi.fn() }]) + .mockImplementationOnce((initial: boolean) => [initial, { setFalse: vi.fn(), setTrue: vi.fn() }]) + .mockImplementationOnce((initial: boolean) => [initial, { setFalse: vi.fn(), setTrue: vi.fn() }]) + .mockImplementationOnce((initial: boolean) => [initial, { setFalse: vi.fn(), setTrue: vi.fn() }]) + + render() + + fireEvent.click(screen.getByTestId('alert-dialog-close')) + + expect(hideConfirm).toHaveBeenCalledTimes(1) + }) + }) + + describe('Publish params', () => { + it('should publish as template with empty pipeline id fallback', async () => { + mockPipelineId = undefined + mockUseBoolean + .mockImplementationOnce((initial: boolean) => [initial, { setFalse: vi.fn(), setTrue: vi.fn() }]) + .mockImplementationOnce((initial: boolean) => [initial, { setFalse: vi.fn(), setTrue: vi.fn() }]) + .mockImplementationOnce(() => [true, { setFalse: vi.fn(), setTrue: vi.fn() }]) + .mockImplementationOnce((initial: boolean) => [initial, { setFalse: vi.fn(), setTrue: vi.fn() }]) + render() + + fireEvent.click(screen.getByTestId('publish-as-confirm')) + + expect(mockPublishAsCustomizedPipeline).toHaveBeenCalledWith({ + pipelineId: '', + name: 'My Pipeline', + icon_info: { icon_type: 'emoji' }, + description: 'desc', + }) + }) }) describe('Time formatting', () => { diff --git a/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/index.tsx b/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/index.tsx index 3ea9aa0c1f..649b06ebca 100644 --- a/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/index.tsx +++ b/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/index.tsx @@ -1,4 +1,5 @@ import { Button } from '@langgenius/dify-ui/button' +import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import { RiArrowDownSLine } from '@remixicon/react' import { memo, @@ -6,11 +7,6 @@ import { useState, } from 'react' import { useTranslation } from 'react-i18next' -import { - PortalToFollowElem, - PortalToFollowElemContent, - PortalToFollowElemTrigger, -} from '@/app/components/base/portal-to-follow-elem' import { useNodesSyncDraft } from '@/app/components/workflow/hooks' import Popup from './popup' @@ -26,28 +22,31 @@ const Publisher = () => { }, [handleSyncWorkflowDraft]) return ( - - handleOpenChange(!open)}> - - - - - - + + {t('common.publish', { ns: 'workflow' })} + + + )} + /> + + handleOpenChange(false)} /> + + ) } diff --git a/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/popup.tsx b/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/popup.tsx index 9cb026dffe..31f5957029 100644 --- a/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/popup.tsx +++ b/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/popup.tsx @@ -39,7 +39,11 @@ import { usePublishWorkflow } from '@/service/use-workflow' import PublishAsKnowledgePipelineModal from '../../publish-as-knowledge-pipeline-modal' const PUBLISH_SHORTCUT = ['ctrl', '⇧', 'P'] -const Popup = () => { +type PopupProps = { + onRequestClose?: () => void +} + +const Popup = ({ onRequestClose }: PopupProps) => { const { t } = useTranslation() const { datasetId } = useParams() const { push } = useRouter() @@ -70,6 +74,7 @@ const Popup = () => { const checked = await handleCheckBeforePublish() if (checked) { if (!publishedAt && !confirmVisible) { + onRequestClose?.() showConfirm() return } @@ -114,7 +119,7 @@ const Popup = () => { if (confirmVisible) hideConfirm() } - }, [publishing, handleCheckBeforePublish, publishedAt, confirmVisible, showPublishing, publishWorkflow, pipelineId, datasetId, showConfirm, t, workflowStore, mutateDatasetRes, invalidPublishedPipelineInfo, invalidDatasetList, hidePublishing, hideConfirm]) + }, [publishing, handleCheckBeforePublish, publishedAt, confirmVisible, showPublishing, publishWorkflow, pipelineId, datasetId, showConfirm, t, workflowStore, mutateDatasetRes, invalidPublishedPipelineInfo, invalidDatasetList, hidePublishing, hideConfirm, onRequestClose]) useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.shift.p`, (e) => { e.preventDefault() if (published) @@ -155,13 +160,14 @@ const Popup = () => { hidePublishingAsCustomizedPipeline() hidePublishAsKnowledgePipelineModal() } - }, [showPublishingAsCustomizedPipeline, publishAsCustomizedPipeline, pipelineId, t, invalidCustomizedTemplateList, hidePublishingAsCustomizedPipeline, hidePublishAsKnowledgePipelineModal]) + }, [showPublishingAsCustomizedPipeline, publishAsCustomizedPipeline, pipelineId, t, invalidCustomizedTemplateList, hidePublishingAsCustomizedPipeline, hidePublishAsKnowledgePipelineModal, docLink]) const handleClickPublishAsKnowledgePipeline = useCallback(() => { + onRequestClose?.() if (!isAllowPublishAsCustomKnowledgePipelineTemplate) setShowPricingModal() else setShowPublishAsKnowledgePipelineModal() - }, [isAllowPublishAsCustomKnowledgePipelineTemplate, setShowPublishAsKnowledgePipelineModal, setShowPricingModal]) + }, [isAllowPublishAsCustomKnowledgePipelineTemplate, onRequestClose, setShowPublishAsKnowledgePipelineModal, setShowPricingModal]) return (
diff --git a/web/app/components/workflow-app/hooks/__tests__/use-workflow-run.spec.ts b/web/app/components/workflow-app/hooks/__tests__/use-workflow-run.spec.ts index 7b54598774..59ba667f8d 100644 --- a/web/app/components/workflow-app/hooks/__tests__/use-workflow-run.spec.ts +++ b/web/app/components/workflow-app/hooks/__tests__/use-workflow-run.spec.ts @@ -501,7 +501,8 @@ describe('useWorkflowRun', () => { windowWithDebugControllers.__allTriggersDebugAbortController = { abort: allTriggersAbort } const refController = new AbortController() const refAbortSpy = vi.spyOn(refController, 'abort') - const { getAbortController } = mocks.mockSsePost.mock.calls.at(-1)?.[2] as { + const lastCall = mocks.mockSsePost.mock.calls.at(-1) + const { getAbortController } = (lastCall?.[2] ?? {}) as { getAbortController?: (controller: AbortController) => void } getAbortController?.(refController) diff --git a/web/app/components/workflow/block-selector/__tests__/main.spec.tsx b/web/app/components/workflow/block-selector/__tests__/main.spec.tsx index 2cb0d3e98f..8dc1e81379 100644 --- a/web/app/components/workflow/block-selector/__tests__/main.spec.tsx +++ b/web/app/components/workflow/block-selector/__tests__/main.spec.tsx @@ -86,4 +86,53 @@ describe('NodeSelector', () => { expect(reopenedInput.value).toBe('') expect(screen.getByText('End')).toBeInTheDocument() }) + + it('does not open or emit open changes when disabled', async () => { + const user = userEvent.setup() + const onOpenChange = vi.fn() + + renderWorkflowComponent( + ( + + )} + />, + ) + + await user.click(screen.getByRole('button', { name: 'selector-closed' })) + + expect(onOpenChange).not.toHaveBeenCalled() + expect(screen.queryByPlaceholderText('workflow.tabs.searchBlock')).not.toBeInTheDocument() + }) + + it('preserves the child trigger click handler when rendered as child', async () => { + const user = userEvent.setup() + const onTriggerClick = vi.fn() + + renderWorkflowComponent( + ( + + )} + />, + ) + + await user.click(screen.getByRole('button', { name: 'open-selector' })) + + expect(onTriggerClick).toHaveBeenCalledTimes(1) + expect(screen.getByPlaceholderText('workflow.tabs.searchBlock')).toBeInTheDocument() + }) }) diff --git a/web/app/components/workflow/block-selector/main.tsx b/web/app/components/workflow/block-selector/main.tsx index 34f286da89..76854ebf0a 100644 --- a/web/app/components/workflow/block-selector/main.tsx +++ b/web/app/components/workflow/block-selector/main.tsx @@ -5,6 +5,7 @@ import type { import type { FC, MouseEventHandler, + MouseEvent as ReactMouseEvent, } from 'react' import type { CommonNodeType, @@ -12,6 +13,12 @@ import type { OnSelectBlock, ToolWithProvider, } from '../types' +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@langgenius/dify-ui/popover' +import * as React from 'react' import { memo, useCallback, @@ -24,11 +31,6 @@ import { Plus02, } from '@/app/components/base/icons/src/vender/line/general' import Input from '@/app/components/base/input' -import { - PortalToFollowElem, - PortalToFollowElemContent, - PortalToFollowElemTrigger, -} from '@/app/components/base/portal-to-follow-elem' import SearchBox from '@/app/components/plugins/marketplace/search-box' import { useHooksStore } from '@/app/components/workflow/hooks-store' import useNodes from '@/app/components/workflow/store/workflow/use-nodes' @@ -142,6 +144,9 @@ const NodeSelector: FC = ({ }) const open = openFromProps === undefined ? localOpen : openFromProps const handleOpenChange = useCallback((newOpen: boolean) => { + if (disabled) + return + setLocalOpen(newOpen) if (!newOpen) { @@ -154,13 +159,10 @@ const NodeSelector: FC = ({ if (onOpenChange) onOpenChange(newOpen) - }, [activeTab, onOpenChange]) - const handleTrigger = useCallback>((e) => { - if (disabled) - return + }, [disabled, onOpenChange]) + const handleTrigger = useCallback>((e) => { e.stopPropagation() - handleOpenChange(!open) - }, [handleOpenChange, open, disabled]) + }, []) const handleSelect = useCallback((type, pluginDefaultValue) => { handleOpenChange(false) @@ -203,36 +205,61 @@ const NodeSelector: FC = ({ return '' }, [activeTab, t]) + const defaultTriggerElement = ( +
+ +
+ ) + const triggerElement = trigger ? trigger(open) : defaultTriggerElement + const triggerElementProps = React.isValidElement(triggerElement) + ? (triggerElement.props as { + onClick?: MouseEventHandler + }) + : null + const resolvedTriggerElement = asChild && React.isValidElement(triggerElement) + ? React.cloneElement( + triggerElement as React.ReactElement<{ + onClick?: MouseEventHandler + }>, + { + onClick: (e: ReactMouseEvent) => { + handleTrigger(e) + if (typeof triggerElementProps?.onClick === 'function') + triggerElementProps.onClick(e) + }, + }, + ) + : ( +
+ {triggerElement} +
+ ) + const resolvedOffset = typeof offset === 'number' || typeof offset === 'function' ? undefined : offset + const sideOffset = typeof offset === 'number' ? offset : (resolvedOffset?.mainAxis ?? 0) + const alignOffset = typeof offset === 'number' ? 0 : (resolvedOffset?.crossAxis ?? 0) + const nativeButton = asChild + && React.isValidElement(triggerElement) + && (typeof triggerElement.type !== 'string' || triggerElement.type === 'button') + return ( - - + - { - trigger - ? trigger(open) - : ( -
- -
- ) - } -
-
= ({ snippetsElem={} />
-
-
+ + ) } diff --git a/web/app/components/workflow/header/__tests__/view-history.spec.tsx b/web/app/components/workflow/header/__tests__/view-history.spec.tsx index 4481c72cf7..93e0b56125 100644 --- a/web/app/components/workflow/header/__tests__/view-history.spec.tsx +++ b/web/app/components/workflow/header/__tests__/view-history.spec.tsx @@ -10,14 +10,13 @@ const mockFormatTimeFromNow = vi.fn((value: number) => `from-now:${value}`) const mockCloseAllInputFieldPanels = vi.fn() const mockHandleNodesCancelSelected = vi.fn() const mockHandleCancelDebugAndPreviewPanel = vi.fn() +const mockHandleBackupDraft = vi.fn() const mockFormatWorkflowRunIdentifier = vi.fn((finishedAt?: number, status?: string) => ` (${status || finishedAt || 'unknown'})`) let mockIsChatMode = false -vi.mock('../../hooks', async () => { - const actual = await vi.importActual('../../hooks') +vi.mock('../../hooks', () => { return { - ...actual, useIsChatMode: () => mockIsChatMode, useNodesInteractions: () => ({ handleNodesCancelSelected: mockHandleNodesCancelSelected, @@ -25,6 +24,9 @@ vi.mock('../../hooks', async () => { useWorkflowInteractions: () => ({ handleCancelDebugAndPreviewPanel: mockHandleCancelDebugAndPreviewPanel, }), + useWorkflowRun: () => ({ + handleBackupDraft: mockHandleBackupDraft, + }), } }) @@ -48,38 +50,46 @@ vi.mock('@/app/components/base/loading', () => ({ default: () =>
, })) -vi.mock('@/app/components/base/tooltip', () => ({ - default: ({ children }: { children?: React.ReactNode }) => <>{children}, +vi.mock('@langgenius/dify-ui/toast', () => ({ + toast: { + success: vi.fn(), + error: vi.fn(), + warning: vi.fn(), + info: vi.fn(), + }, })) -vi.mock('@/app/components/base/portal-to-follow-elem', () => { - const PortalContext = React.createContext({ open: false }) +vi.mock('@langgenius/dify-ui/button', () => ({ + Button: ({ + children, + ...props + }: React.ButtonHTMLAttributes) => ( + + ), +})) - return { - PortalToFollowElem: ({ - children, - open, - }: { - children?: React.ReactNode - open: boolean - }) => {children}, - PortalToFollowElemTrigger: ({ - children, - onClick, - }: { - children?: React.ReactNode - onClick?: () => void - }) =>
{children}
, - PortalToFollowElemContent: ({ - children, - }: { - children?: React.ReactNode - }) => { - const { open } = React.useContext(PortalContext) - return open ?
{children}
: null - }, - } -}) +vi.mock('@langgenius/dify-ui/tooltip', () => ({ + Tooltip: ({ children }: { children?: React.ReactNode }) => <>{children}, + TooltipTrigger: ({ + children, + render, + }: { + children?: React.ReactNode + render?: React.ReactElement + }) => { + if (render && React.isValidElement(render)) { + const renderElement = render as React.ReactElement<{ children?: React.ReactNode }> + return React.cloneElement(renderElement, renderElement.props, children) + } + + return <>{children} + }, + TooltipContent: ({ children }: { children?: React.ReactNode }) => <>{children}, +})) + +vi.mock('@langgenius/dify-ui/popover', () => import('@/__mocks__/base-ui-popover')) vi.mock('../../utils', async () => { const actual = await vi.importActual('../../utils') @@ -130,7 +140,7 @@ describe('ViewHistory', () => { }) expect(mockUseWorkflowRunHistory).toHaveBeenCalledWith('/history', false) - expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument() + expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument() fireEvent.click(screen.getByRole('button', { name: 'workflow.common.showRunHistory' })) @@ -165,7 +175,6 @@ describe('ViewHistory', () => { }) it('renders workflow run history items and updates the workflow store when one is selected', () => { - const handleBackupDraft = vi.fn() const pausedRun = createHistoryItem({ id: 'run-paused', status: WorkflowRunningStatus.Paused, @@ -199,9 +208,6 @@ describe('ViewHistory', () => { showEnvPanel: true, controlMode: ControlMode.Pointer, }, - hooksStoreProps: { - handleBackupDraft, - }, }) fireEvent.click(screen.getByRole('button', { name: 'workflow.common.showRunHistory' })) @@ -217,7 +223,7 @@ describe('ViewHistory', () => { expect(store.getState().showEnvPanel).toBe(false) expect(store.getState().controlMode).toBe(ControlMode.Hand) expect(mockCloseAllInputFieldPanels).toHaveBeenCalledTimes(1) - expect(handleBackupDraft).toHaveBeenCalledTimes(1) + expect(mockHandleBackupDraft).toHaveBeenCalledTimes(1) expect(mockHandleNodesCancelSelected).toHaveBeenCalledTimes(1) expect(mockHandleCancelDebugAndPreviewPanel).toHaveBeenCalledTimes(1) }) @@ -271,6 +277,6 @@ describe('ViewHistory', () => { fireEvent.click(screen.getByRole('button', { name: 'common.operation.close' })) expect(onClearLogAndMessageModal).toHaveBeenCalledTimes(1) - expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument() + expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument() }) }) diff --git a/web/app/components/workflow/header/view-history.tsx b/web/app/components/workflow/header/view-history.tsx index 3f98f6cb6c..bde9f370c9 100644 --- a/web/app/components/workflow/header/view-history.tsx +++ b/web/app/components/workflow/header/view-history.tsx @@ -1,16 +1,20 @@ import { cn } from '@langgenius/dify-ui/cn' +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@langgenius/dify-ui/popover' +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@langgenius/dify-ui/tooltip' import { memo, useState, } from 'react' import { useTranslation } from 'react-i18next' import Loading from '@/app/components/base/loading' -import { - PortalToFollowElem, - PortalToFollowElemContent, - PortalToFollowElemTrigger, -} from '@/app/components/base/portal-to-follow-elem' -import Tooltip from '@/app/components/base/tooltip' import { useInputFieldPanel } from '@/app/components/rag-pipeline/hooks' import { useStore, @@ -61,52 +65,60 @@ const ViewHistory = ({ return ( ( - - setOpen(v => !v)}> - { - withText && ( - )} - > - - {t('common.showRunHistory', { ns: 'workflow' })} - + /> ) - } - { - !withText && ( - - + { + onClearLogAndMessageModal?.() + }} + > + + + )} + /> + + + {t('common.viewRunHistory', { ns: 'workflow' })} + - ) - } - - + )} +
- - + + ) ) } diff --git a/web/app/components/workflow/header/view-workflow-history.tsx b/web/app/components/workflow/header/view-workflow-history.tsx index 9f70187941..036f27d38d 100644 --- a/web/app/components/workflow/header/view-workflow-history.tsx +++ b/web/app/components/workflow/header/view-workflow-history.tsx @@ -1,5 +1,10 @@ import type { WorkflowHistoryState } from '../workflow-history-store' import { cn } from '@langgenius/dify-ui/cn' +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@langgenius/dify-ui/popover' import { RiCloseLine, RiHistoryLine, @@ -13,11 +18,6 @@ import { import { useTranslation } from 'react-i18next' import { useShallow } from 'zustand/react/shallow' import { useStore as useAppStore } from '@/app/components/app/store' -import { - PortalToFollowElem, - PortalToFollowElemContent, - PortalToFollowElemTrigger, -} from '@/app/components/base/portal-to-follow-elem' import Divider from '../../base/divider' import { collaborationManager } from '../collaboration/core/collaboration-manager' import { @@ -91,12 +91,20 @@ const ViewWorkflowHistory = () => { }, [t]) const calculateChangeList: ChangeHistoryList = useMemo(() => { - const filterList = (list: any, startIndex = 0, reverse = false) => list.map((state: Partial, index: number) => { - const nodes = (state.nodes || store.getState().nodes) || [] - const nodeId = state?.workflowHistoryEventMeta?.nodeId + const filterList = ( + list: Array | undefined>, + startIndex = 0, + reverse = false, + ) => list.flatMap((state, index) => { + if (!state) + return [] + + const nodes = state.nodes || store.getState().nodes || [] + const nodeId = state.workflowHistoryEventMeta?.nodeId const targetTitle = nodes.find(n => n.id === nodeId)?.data?.title ?? '' - return { - label: state.workflowHistoryEvent && getHistoryLabel(state.workflowHistoryEvent), + + return [{ + label: state.workflowHistoryEvent ? getHistoryLabel(state.workflowHistoryEvent) : '', index: reverse ? list.length - 1 - index - startIndex : index - startIndex, state: { ...state, @@ -107,8 +115,8 @@ const ViewWorkflowHistory = () => { } : undefined, }, - } - }).filter(Boolean) + }] + }) const historyData = { pastStates: filterList(pastStates, pastStates.length).reverse(), @@ -132,35 +140,42 @@ const ViewWorkflowHistory = () => { return ( ( - { + if (nodesReadOnly) + return + setOpen(nextOpen) + }} > - !nodesReadOnly && setOpen(v => !v)}> - -
{ - if (nodesReadOnly) - return - setCurrentLogItem() - setShowMessageLogModal(false) - }} - > - -
-
-
- + + { + if (nodesReadOnly) + return + setCurrentLogItem() + setShowMessageLogModal(false) + }} + > + +
+ )} + /> + +
@@ -293,8 +308,8 @@ const ViewWorkflowHistory = () => {
{t('changeHistory.hintText', { ns: 'workflow' })}
- - + + ) ) } diff --git a/web/app/components/workflow/hooks/use-nodes-interactions.ts b/web/app/components/workflow/hooks/use-nodes-interactions.ts index f0e23586eb..0ce86602fa 100644 --- a/web/app/components/workflow/hooks/use-nodes-interactions.ts +++ b/web/app/components/workflow/hooks/use-nodes-interactions.ts @@ -1543,7 +1543,7 @@ export const useNodesInteractions = () => { targetHandle, type: CUSTOM_EDGE, data: { - ...(edge.data || {}), + ...edge.data, sourceType: newCurrentNode.data.type, targetType: targetNodeForEdge.data.type, isInIteration, @@ -1583,7 +1583,7 @@ export const useNodesInteractions = () => { targetHandle, type: CUSTOM_EDGE, data: { - ...(edge.data || {}), + ...edge.data, sourceType: sourceNode.data.type, targetType: newCurrentNode.data.type, isInIteration: newNodeIsInIteration, diff --git a/web/app/components/workflow/nodes/assigner/__tests__/integration.spec.tsx b/web/app/components/workflow/nodes/assigner/__tests__/integration.spec.tsx index 5cd378ffa2..f1feb0a669 100644 --- a/web/app/components/workflow/nodes/assigner/__tests__/integration.spec.tsx +++ b/web/app/components/workflow/nodes/assigner/__tests__/integration.spec.tsx @@ -320,7 +320,7 @@ describe('assigner path', () => { true)} filterToAssignedVar={vi.fn(() => true)} @@ -332,9 +332,9 @@ describe('assigner path', () => { />, ) - fireEvent.change(screen.getByLabelText('code-editor'), { target: { value: '{\"a\":2}' } }) + fireEvent.change(screen.getByLabelText('code-editor'), { target: { value: '{"a":2}' } }) expect(onChange).toHaveBeenLastCalledWith([ - createOperation({ operation: WriteMode.set, value: '{\"a\":2}' }), + createOperation({ operation: WriteMode.set, value: '{"a":2}' }), ], '{"a":2}') onChange.mockClear() diff --git a/web/app/components/workflow/nodes/http/components/__tests__/curl-panel.spec.tsx b/web/app/components/workflow/nodes/http/components/__tests__/curl-panel.spec.tsx index 141f8fd5f9..069ab4547e 100644 --- a/web/app/components/workflow/nodes/http/components/__tests__/curl-panel.spec.tsx +++ b/web/app/components/workflow/nodes/http/components/__tests__/curl-panel.spec.tsx @@ -32,7 +32,7 @@ describe('curl-panel', () => { describe('parseCurl', () => { it('should parse method, headers, json body, and query params from a valid curl command', () => { - const { node, error } = curlParser.parseCurl('curl -X POST -H \"Authorization: Bearer token\" --json \"{\"name\":\"openai\"}\" https://example.com/users?page=1&size=2') + const { node, error } = curlParser.parseCurl('curl -X POST -H "Authorization: Bearer token" --json "{"name":"openai"}" https://example.com/users?page=1&size=2') expect(error).toBeNull() expect(node).toMatchObject({ diff --git a/web/app/components/workflow/nodes/human-input/components/__tests__/button-style-dropdown.spec.tsx b/web/app/components/workflow/nodes/human-input/components/__tests__/button-style-dropdown.spec.tsx index 056ebf4795..fe288899bd 100644 --- a/web/app/components/workflow/nodes/human-input/components/__tests__/button-style-dropdown.spec.tsx +++ b/web/app/components/workflow/nodes/human-input/components/__tests__/button-style-dropdown.spec.tsx @@ -21,42 +21,7 @@ vi.mock('@langgenius/dify-ui/button', () => ({ }, })) -vi.mock('@/app/components/base/portal-to-follow-elem', () => { - const OpenContext = React.createContext(false) - - return { - PortalToFollowElem: ({ - open, - children, - }: { - open: boolean - children?: React.ReactNode - }) => ( - -
{children}
-
- ), - PortalToFollowElemTrigger: ({ - children, - onClick, - }: { - children?: React.ReactNode - onClick?: () => void - }) => ( - - ), - PortalToFollowElemContent: ({ - children, - }: { - children?: React.ReactNode - }) => { - const open = React.use(OpenContext) - return open ?
{children}
: null - }, - } -}) +vi.mock('@langgenius/dify-ui/popover', () => import('@/__mocks__/base-ui-popover')) describe('ButtonStyleDropdown', () => { const onChange = vi.fn() @@ -80,10 +45,10 @@ describe('ButtonStyleDropdown', () => { expect(mockButton).toHaveBeenCalledWith(expect.objectContaining({ variant: 'ghost', })) - expect(screen.getByTestId('portal'))!.toHaveAttribute('data-open', 'false') + expect(screen.getByTestId('popover'))!.toHaveAttribute('data-open', 'false') - fireEvent.click(screen.getByTestId('portal-trigger')) - expect(screen.getByTestId('portal'))!.toHaveAttribute('data-open', 'true') + fireEvent.click(screen.getByTestId('popover-trigger')) + expect(screen.getByTestId('popover'))!.toHaveAttribute('data-open', 'true') expect(screen.getByText('nodes.humanInput.userActions.chooseStyle'))!.toBeInTheDocument() fireEvent.click(screen.getByTestId('button-primary').parentElement as HTMLElement) @@ -111,10 +76,10 @@ describe('ButtonStyleDropdown', () => { variant: 'secondary', })) - fireEvent.click(screen.getByTestId('portal-trigger')) + fireEvent.click(screen.getByTestId('popover-trigger')) - expect(screen.getByTestId('portal'))!.toHaveAttribute('data-open', 'false') - expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument() + expect(screen.getByTestId('popover'))!.toHaveAttribute('data-open', 'false') + expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument() expect(onChange).not.toHaveBeenCalled() }) diff --git a/web/app/components/workflow/nodes/human-input/components/button-style-dropdown.tsx b/web/app/components/workflow/nodes/human-input/components/button-style-dropdown.tsx index 44ddbbfa34..688f7a62f6 100644 --- a/web/app/components/workflow/nodes/human-input/components/button-style-dropdown.tsx +++ b/web/app/components/workflow/nodes/human-input/components/button-style-dropdown.tsx @@ -1,17 +1,17 @@ import type { FC } from 'react' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@langgenius/dify-ui/popover' import { RiFontSize, } from '@remixicon/react' import * as React from 'react' import { useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' -import { - PortalToFollowElem, - PortalToFollowElemContent, - PortalToFollowElemTrigger, -} from '@/app/components/base/portal-to-follow-elem' import { UserActionButtonType } from '../types' const i18nPrefix = 'nodes.humanInput' @@ -45,23 +45,29 @@ const ButtonStyleDropdown: FC = ({ }, [data]) return ( - { + if (readonly) + return + setOpen(nextOpen) }} > - !readonly && setOpen(v => !v)}> -
- -
-
- + + +
+ )} + /> +
{t(`${i18nPrefix}.userActions.chooseStyle`, { ns: 'workflow' })}
@@ -103,8 +109,8 @@ const ButtonStyleDropdown: FC = ({
- - + + ) } diff --git a/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/metadata-trigger.tsx b/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/metadata-trigger.tsx index dd530ae679..da17b4b2b3 100644 --- a/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/metadata-trigger.tsx +++ b/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/metadata-trigger.tsx @@ -1,16 +1,16 @@ import type { MetadataShape } from '@/app/components/workflow/nodes/knowledge-retrieval/types' import { Button } from '@langgenius/dify-ui/button' +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@langgenius/dify-ui/popover' import { RiFilter3Line } from '@remixicon/react' import { useEffect, useState, } from 'react' import { useTranslation } from 'react-i18next' -import { - PortalToFollowElem, - PortalToFollowElemContent, - PortalToFollowElemTrigger, -} from '@/app/components/base/portal-to-follow-elem' import MetadataPanel from './metadata-panel' const MetadataTrigger = ({ @@ -40,25 +40,29 @@ const MetadataTrigger = ({ }, [metadataFilteringConditions, metadataList, handleRemoveCondition, selectedDatasetsLoaded]) return ( - - setOpen(!open)}> - - - + + + {t('nodes.knowledgeRetrieval.metadata.panel.conditions', { ns: 'workflow' })} +
+ {metadataFilteringConditions?.conditions.length || 0} +
+ + )} + /> + setOpen(false)} @@ -66,8 +70,8 @@ const MetadataTrigger = ({ handleRemoveCondition={handleRemoveCondition} {...restProps} /> -
-
+ + ) } diff --git a/web/app/components/workflow/nodes/knowledge-retrieval/components/retrieval-config.tsx b/web/app/components/workflow/nodes/knowledge-retrieval/components/retrieval-config.tsx index da71682b35..953e8474e4 100644 --- a/web/app/components/workflow/nodes/knowledge-retrieval/components/retrieval-config.tsx +++ b/web/app/components/workflow/nodes/knowledge-retrieval/components/retrieval-config.tsx @@ -7,16 +7,16 @@ import type { DataSet } from '@/models/datasets' import type { DatasetConfigs } from '@/models/debug' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@langgenius/dify-ui/popover' import { RiEqualizer2Line } from '@remixicon/react' import * as React from 'react' import { useCallback, useMemo } from 'react' import { useTranslation } from 'react-i18next' import ConfigRetrievalContent from '@/app/components/app/configuration/dataset-config/params-config/config-content' -import { - PortalToFollowElem, - PortalToFollowElemContent, - PortalToFollowElemTrigger, -} from '@/app/components/base/portal-to-follow-elem' import { DATASET_DEFAULT } from '@/config' import { RETRIEVE_TYPE } from '@/types/app' @@ -114,32 +114,33 @@ const RetrievalConfig: FC = ({ }, [onMultipleRetrievalConfigChange, retrieval_mode, onRetrievalModeChange]) return ( - { + if (readonly) + return + handleOpen(nextOpen) }} > - { - if (readonly) - return - handleOpen(!rerankModalOpen) - }} + + + {t('retrievalSettings', { ns: 'dataset' })} + + )} + /> + - - -
= ({ onSingleRetrievalModelParamsChange={onSingleRetrievalModelParamsChange} />
-
-
+ + ) } export default React.memo(RetrievalConfig) diff --git a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/index.tsx b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/index.tsx index c949b89adb..bf15761d9d 100644 --- a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/index.tsx +++ b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/index.tsx @@ -3,14 +3,14 @@ import type { SchemaRoot } from '../../../types' import type { FormValue } from '@/app/components/header/account-setting/model-provider-page/declarations' import type { CompletionParams, Model } from '@/types/app' import { cn } from '@langgenius/dify-ui/cn' +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@langgenius/dify-ui/popover' import { toast } from '@langgenius/dify-ui/toast' import * as React from 'react' -import { useCallback, useEffect, useState } from 'react' -import { - PortalToFollowElem, - PortalToFollowElemContent, - PortalToFollowElemTrigger, -} from '@/app/components/base/portal-to-follow-elem' +import { useCallback, useState } from 'react' import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' import { useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/components/header/account-setting/model-provider-page/hooks' import useTheme from '@/hooks/use-theme' @@ -27,61 +27,68 @@ type JsonSchemaGeneratorProps = { crossAxisOffset?: number } -enum GeneratorView { - promptEditor = 'promptEditor', - result = 'result', +const GENERATOR_VIEWS = { + promptEditor: 'promptEditor', + result: 'result', +} as const + +type GeneratorView = typeof GENERATOR_VIEWS[keyof typeof GENERATOR_VIEWS] + +const createEmptyModel = (): Model => ({ + name: '', + provider: '', + mode: ModelModeType.completion, + completion_params: {} as CompletionParams, +}) + +const getStoredModel = (): Model | null => { + if (typeof window === 'undefined') + return null + + const savedModel = window.localStorage.getItem('auto-gen-model') + + if (!savedModel) + return null + + return JSON.parse(savedModel) as Model } const JsonSchemaGenerator: FC = ({ onApply, crossAxisOffset, }) => { - const localModel = localStorage.getItem('auto-gen-model') - ? JSON.parse(localStorage.getItem('auto-gen-model') as string) as Model - : null const [open, setOpen] = useState(false) - const [view, setView] = useState(GeneratorView.promptEditor) - const [model, setModel] = useState(localModel || { - name: '', - provider: '', - mode: ModelModeType.completion, - completion_params: {} as CompletionParams, - }) + const [view, setView] = useState(GENERATOR_VIEWS.promptEditor) + const [model, setModel] = useState(() => getStoredModel()) const [instruction, setInstruction] = useState('') const [schema, setSchema] = useState(null) const { theme } = useTheme() const { defaultModel, } = useModelListAndDefaultModelAndCurrentProviderAndModel(ModelTypeEnum.textGeneration) + const resolvedModel = React.useMemo(() => { + if (model) + return model + + if (!defaultModel) + return createEmptyModel() + + return { + ...createEmptyModel(), + name: defaultModel.model, + provider: defaultModel.provider.provider, + } + }, [defaultModel, model]) const advancedEditing = useVisualEditorStore(state => state.advancedEditing) const isAddingNewField = useVisualEditorStore(state => state.isAddingNewField) const { emit } = useMittContext() const SchemaGenerator = theme === Theme.light ? SchemaGeneratorLight : SchemaGeneratorDark - useEffect(() => { - if (defaultModel) { - const localModel = localStorage.getItem('auto-gen-model') - ? JSON.parse(localStorage.getItem('auto-gen-model') || '') - : null - if (localModel) { - setModel(localModel) - } - else { - setModel(prev => ({ - ...prev, - name: defaultModel.model, - provider: defaultModel.provider.provider, - })) - } - } - }, [defaultModel]) - const handleTrigger = useCallback((e: React.MouseEvent) => { e.stopPropagation() if (advancedEditing || isAddingNewField) emit('quitEditing', {}) - setOpen(!open) - }, [open, advancedEditing, isAddingNewField, emit]) + }, [advancedEditing, isAddingNewField, emit]) const onClose = useCallback(() => { setOpen(false) @@ -89,39 +96,39 @@ const JsonSchemaGenerator: FC = ({ const handleModelChange = useCallback((newValue: { modelId: string, provider: string, mode?: string, features?: string[] }) => { const newModel = { - ...model, + ...resolvedModel, provider: newValue.provider, name: newValue.modelId, mode: newValue.mode as ModelModeType, } setModel(newModel) - localStorage.setItem('auto-gen-model', JSON.stringify(newModel)) - }, [model, setModel]) + window.localStorage.setItem('auto-gen-model', JSON.stringify(newModel)) + }, [resolvedModel]) const handleCompletionParamsChange = useCallback((newParams: FormValue) => { const newModel = { - ...model, + ...resolvedModel, completion_params: newParams as CompletionParams, } setModel(newModel) - localStorage.setItem('auto-gen-model', JSON.stringify(newModel)) - }, [model, setModel]) + window.localStorage.setItem('auto-gen-model', JSON.stringify(newModel)) + }, [resolvedModel]) const { mutateAsync: generateStructuredOutputRules, isPending: isGenerating } = useGenerateStructuredOutputRules() const generateSchema = useCallback(async () => { - const { output, error } = await generateStructuredOutputRules({ instruction, model_config: model! }) + const { output, error } = await generateStructuredOutputRules({ instruction, model_config: resolvedModel }) if (error) { toast.error(error) setSchema(null) - setView(GeneratorView.promptEditor) + setView(GENERATOR_VIEWS.promptEditor) return } return output - }, [instruction, model, generateStructuredOutputRules]) + }, [generateStructuredOutputRules, instruction, resolvedModel]) const handleGenerate = useCallback(async () => { - setView(GeneratorView.result) + setView(GENERATOR_VIEWS.result) const output = await generateSchema() if (output === undefined) return @@ -129,7 +136,7 @@ const JsonSchemaGenerator: FC = ({ }, [generateSchema]) const goBackToPromptEditor = () => { - setView(GeneratorView.promptEditor) + setView(GENERATOR_VIEWS.promptEditor) } const handleRegenerate = useCallback(async () => { @@ -145,31 +152,34 @@ const JsonSchemaGenerator: FC = ({ } return ( - - - - - - {view === GeneratorView.promptEditor && ( + + + + )} + /> + + {view === GENERATOR_VIEWS.promptEditor && ( = ({ onModelChange={handleModelChange} /> )} - {view === GeneratorView.result && ( + {view === GENERATOR_VIEWS.result && ( = ({ onClose={onClose} /> )} - - + + ) } diff --git a/web/app/components/workflow/nodes/loop/__tests__/integration.spec.tsx b/web/app/components/workflow/nodes/loop/__tests__/integration.spec.tsx index 0af05e9674..a4a00fee67 100644 --- a/web/app/components/workflow/nodes/loop/__tests__/integration.spec.tsx +++ b/web/app/components/workflow/nodes/loop/__tests__/integration.spec.tsx @@ -563,7 +563,7 @@ describe('loop path', () => { id: 'loop-var-object', var_type: VarType.arrayObject, value_type: ValueType.constant, - value: '[{\"id\":1}]', + value: '[{"id":1}]', })} onChange={onObjectChange} /> @@ -571,7 +571,7 @@ describe('loop path', () => { ) fireEvent.change(screen.getByDisplayValue('draft'), { target: { value: 'published' } }) - fireEvent.change(screen.getByLabelText('code-editor'), { target: { value: '[{\"id\":2}]' } }) + fireEvent.change(screen.getByLabelText('code-editor'), { target: { value: '[{"id":2}]' } }) expect(onStringChange).toHaveBeenCalledWith('published') expect(onObjectChange).toHaveBeenCalledWith('[{"id":2}]') diff --git a/web/app/components/workflow/note-node/note-editor/toolbar/__tests__/color-picker.spec.tsx b/web/app/components/workflow/note-node/note-editor/toolbar/__tests__/color-picker.spec.tsx index 9f36b4a7ac..b70559aa6b 100644 --- a/web/app/components/workflow/note-node/note-editor/toolbar/__tests__/color-picker.spec.tsx +++ b/web/app/components/workflow/note-node/note-editor/toolbar/__tests__/color-picker.spec.tsx @@ -1,32 +1,32 @@ -import { fireEvent, render, waitFor } from '@testing-library/react' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { NoteTheme } from '../../../types' -import ColorPicker, { COLOR_LIST } from '../color-picker' +import ColorPicker from '../color-picker' + +vi.mock('@langgenius/dify-ui/popover', () => import('@/__mocks__/base-ui-popover')) describe('NoteEditor ColorPicker', () => { it('should open the palette and apply the selected theme', async () => { const onThemeChange = vi.fn() - const { container } = render( + render( , ) - const trigger = container.querySelector('[data-state="closed"]') as HTMLElement + fireEvent.click(screen.getByTestId('popover-trigger')) - fireEvent.click(trigger) - - const popup = document.body.querySelector('[role="tooltip"]') + const popup = screen.getByTestId('popover-content') expect(popup).toBeInTheDocument() - const options = popup?.querySelectorAll('.group.relative') + const options = popup.querySelectorAll('.group.relative') - expect(options).toHaveLength(COLOR_LIST.length) + expect(options).toHaveLength(6) - fireEvent.click(options?.[COLOR_LIST.length - 1] as Element) + fireEvent.click(options[5] as Element) expect(onThemeChange).toHaveBeenCalledWith(NoteTheme.violet) await waitFor(() => { - expect(document.body.querySelector('[role="tooltip"]')).not.toBeInTheDocument() + expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument() }) }) }) diff --git a/web/app/components/workflow/note-node/note-editor/toolbar/__tests__/font-size-selector.spec.tsx b/web/app/components/workflow/note-node/note-editor/toolbar/__tests__/font-size-selector.spec.tsx index e94b66e695..bce7bb326d 100644 --- a/web/app/components/workflow/note-node/note-editor/toolbar/__tests__/font-size-selector.spec.tsx +++ b/web/app/components/workflow/note-node/note-editor/toolbar/__tests__/font-size-selector.spec.tsx @@ -1,6 +1,8 @@ import { fireEvent, render, screen } from '@testing-library/react' import FontSizeSelector from '../font-size-selector' +vi.mock('@langgenius/dify-ui/popover', () => import('@/__mocks__/base-ui-popover')) + const { mockHandleFontSize, mockHandleOpenFontSizeSelector, @@ -52,4 +54,12 @@ describe('NoteEditor FontSizeSelector', () => { expect(mockHandleFontSize).toHaveBeenCalledWith('16px') expect(mockHandleOpenFontSizeSelector).toHaveBeenCalledWith(false) }) + + it('should fall back to the small label when current font size is unknown', () => { + mockFontSize = '18px' + + render() + + expect(screen.getByText('workflow.nodes.note.editor.small')).toBeInTheDocument() + }) }) diff --git a/web/app/components/workflow/note-node/note-editor/toolbar/__tests__/index.spec.tsx b/web/app/components/workflow/note-node/note-editor/toolbar/__tests__/index.spec.tsx index cee2b7fd40..cb50f641b4 100644 --- a/web/app/components/workflow/note-node/note-editor/toolbar/__tests__/index.spec.tsx +++ b/web/app/components/workflow/note-node/note-editor/toolbar/__tests__/index.spec.tsx @@ -76,11 +76,14 @@ describe('NoteEditor Toolbar', () => { expect(screen.getByText('workflow.nodes.note.editor.medium')).toBeInTheDocument() - const triggers = container.querySelectorAll('[data-state="closed"]') + const buttons = container.querySelectorAll('button[type="button"]') + fireEvent.click(buttons[0] as HTMLElement) - fireEvent.click(triggers[0] as HTMLElement) + await waitFor(() => { + expect(document.body.querySelectorAll('.group.relative').length).toBeGreaterThan(0) + }) - const colorOptions = document.body.querySelectorAll('[role="tooltip"] .group.relative') + const colorOptions = document.body.querySelectorAll('.group.relative') fireEvent.click(colorOptions[colorOptions.length - 1] as Element) diff --git a/web/app/components/workflow/note-node/note-editor/toolbar/color-picker.tsx b/web/app/components/workflow/note-node/note-editor/toolbar/color-picker.tsx index e8c5055962..2b12a947ea 100644 --- a/web/app/components/workflow/note-node/note-editor/toolbar/color-picker.tsx +++ b/web/app/components/workflow/note-node/note-editor/toolbar/color-picker.tsx @@ -1,17 +1,17 @@ import { cn } from '@langgenius/dify-ui/cn' +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@langgenius/dify-ui/popover' import { memo, useState, } from 'react' -import { - PortalToFollowElem, - PortalToFollowElemContent, - PortalToFollowElemTrigger, -} from '@/app/components/base/portal-to-follow-elem' import { THEME_MAP } from '../../constants' import { NoteTheme } from '../../types' -export const COLOR_LIST = [ +const COLOR_LIST = [ { key: NoteTheme.blue, inner: THEME_MAP[NoteTheme.blue]!.title, @@ -55,28 +55,35 @@ const ColorPicker = ({ const [open, setOpen] = useState(false) return ( - - setOpen(!open)}> -
-
-
-
-
- +
+
+ + )} + /> +
{ COLOR_LIST.map(color => ( @@ -107,8 +114,8 @@ const ColorPicker = ({ )) }
-
-
+ + ) } diff --git a/web/app/components/workflow/note-node/note-editor/toolbar/font-size-selector.tsx b/web/app/components/workflow/note-node/note-editor/toolbar/font-size-selector.tsx index a217d7de72..13da51deb7 100644 --- a/web/app/components/workflow/note-node/note-editor/toolbar/font-size-selector.tsx +++ b/web/app/components/workflow/note-node/note-editor/toolbar/font-size-selector.tsx @@ -1,13 +1,13 @@ import { cn } from '@langgenius/dify-ui/cn' +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@langgenius/dify-ui/popover' import { RiFontSize } from '@remixicon/react' import { memo } from 'react' import { useTranslation } from 'react-i18next' import { Check } from '@/app/components/base/icons/src/vender/line/general' -import { - PortalToFollowElem, - PortalToFollowElemContent, - PortalToFollowElemTrigger, -} from '@/app/components/base/portal-to-follow-elem' import { useFontSize } from './hooks' const FontSizeSelector = () => { @@ -34,23 +34,30 @@ const FontSizeSelector = () => { } = useFontSize() return ( - - handleOpenFontSizeSelector(!fontSizeSelectorShow)}> -
+ + {FONT_SIZE_LIST.find(font => font.key === fontSize)?.value || t('nodes.note.editor.small', { ns: 'workflow' })} + )} - > - - {FONT_SIZE_LIST.find(font => font.key === fontSize)?.value || t('nodes.note.editor.small', { ns: 'workflow' })} -
-
- + /> +
{ FONT_SIZE_LIST.map(font => ( @@ -77,8 +84,8 @@ const FontSizeSelector = () => { )) }
-
-
+ + ) } diff --git a/web/app/components/workflow/panel/version-history-panel/filter/index.tsx b/web/app/components/workflow/panel/version-history-panel/filter/index.tsx index f48804d921..bb94e3727a 100644 --- a/web/app/components/workflow/panel/version-history-panel/filter/index.tsx +++ b/web/app/components/workflow/panel/version-history-panel/filter/index.tsx @@ -1,14 +1,14 @@ import type { FC } from 'react' import { cn } from '@langgenius/dify-ui/cn' +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@langgenius/dify-ui/popover' import { RiFilter3Line } from '@remixicon/react' import * as React from 'react' import { useCallback, useState } from 'react' import Divider from '@/app/components/base/divider' -import { - PortalToFollowElem, - PortalToFollowElemContent, - PortalToFollowElemTrigger, -} from '@/app/components/base/portal-to-follow-elem' import { WorkflowVersionFilterOptions } from '../../../types' import FilterItem from './filter-item' import FilterSwitch from './filter-switch' @@ -37,26 +37,28 @@ const Filter: FC = ({ const isFiltering = filterValue !== WorkflowVersionFilterOptions.all || isOnlyShowNamedVersions return ( - - setOpen(v => !v)}> -
- -
-
- + + +
+ )} + /> +
{ @@ -75,8 +77,8 @@ const Filter: FC = ({
- - + + ) } diff --git a/web/context/modal-context-provider.tsx b/web/context/modal-context-provider.tsx index 5facc9a202..fcc37a1030 100644 --- a/web/context/modal-context-provider.tsx +++ b/web/context/modal-context-provider.tsx @@ -122,7 +122,7 @@ export const ModalContextProvider = ({ const setShowAccountSettingModal = useCallback((next: SetStateAction | null>) => { const currentState = accountSettingTab - ? { payload: accountSettingTab, ...(accountSettingCallbacksRef.current ?? {}) } + ? { payload: accountSettingTab, ...accountSettingCallbacksRef.current } : null const resolvedState = typeof next === 'function' ? next(currentState) : next if (!resolvedState) { diff --git a/web/docs/overlay-migration.md b/web/docs/overlay-migration.md index 0fa584ecfc..73c0f02d9d 100644 --- a/web/docs/overlay-migration.md +++ b/web/docs/overlay-migration.md @@ -55,7 +55,7 @@ pnpm -C web lint:fix --prune-suppressions ## z-index strategy -All new overlay primitives in `@langgenius/dify-ui` share a single z-index value: +All new overlay primitives in `@langgenius/dify-ui/*` share a single z-index value: **`z-1002`**, except Toast which stays one layer above at **`z-1003`**. ### Why z-[1002]? @@ -94,7 +94,7 @@ back to `z-9999`. Once all legacy overlays are removed: -1. Reduce `z-1002` back to `z-50` across all `@langgenius/dify-ui` primitives. +1. Reduce `z-1002` back to `z-50` across all `@langgenius/dify-ui/*` primitives. 1. Reduce Toast from `z-1003` to `z-51`. 1. Remove this section from the migration guide. diff --git a/web/eslint.constants.mjs b/web/eslint.constants.mjs index 5a2330c00e..1c09cbcb23 100644 --- a/web/eslint.constants.mjs +++ b/web/eslint.constants.mjs @@ -64,20 +64,13 @@ export const OVERLAY_RESTRICTED_IMPORT_PATTERNS = [ export const OVERLAY_MIGRATION_LEGACY_BASE_FILES = [ 'app/components/base/chat/chat-with-history/header/mobile-operation-dropdown.tsx', 'app/components/base/chat/chat-with-history/header/operation.tsx', - 'app/components/base/chat/chat-with-history/inputs-form/view-form-dropdown.tsx', 'app/components/base/chat/chat-with-history/sidebar/operation.tsx', 'app/components/base/chat/chat/citation/popup.tsx', 'app/components/base/chat/chat/citation/progress-tooltip.tsx', 'app/components/base/chat/chat/citation/tooltip.tsx', - 'app/components/base/chat/embedded-chatbot/inputs-form/view-form-dropdown.tsx', 'app/components/base/chip/index.tsx', 'app/components/base/date-and-time-picker/date-picker/index.tsx', 'app/components/base/date-and-time-picker/time-picker/index.tsx', - 'app/components/base/features/new-feature-panel/file-upload/setting-modal.tsx', - 'app/components/base/features/new-feature-panel/text-to-speech/voice-settings.tsx', - 'app/components/base/file-uploader/file-from-link-or-local/index.tsx', - 'app/components/base/image-uploader/chat-image-uploader.tsx', - 'app/components/base/image-uploader/text-generation-image-uploader.tsx', 'app/components/base/modal/modal.tsx', 'app/components/base/prompt-editor/plugins/context-block/component.tsx', 'app/components/base/prompt-editor/plugins/history-block/component.tsx', diff --git a/web/public/embed.js b/web/public/embed.js index d5eabc0533..f7c6fdaf4a 100644 --- a/web/public/embed.js +++ b/web/public/embed.js @@ -312,9 +312,11 @@ } targetIframe.style.display = targetIframe.style.display === "none" ? "block" : "none"; - targetIframe.style.display === "none" - ? setSvgIcon("open") - : setSvgIcon("close"); + if (targetIframe.style.display === "none") { + setSvgIcon("open") + } else { + setSvgIcon("close") + } if (targetIframe.style.display === "none") { document.removeEventListener("keydown", handleEscKey); diff --git a/web/scripts/check-i18n.js b/web/scripts/check-i18n.js index 34b842cc00..6936bcb452 100644 --- a/web/scripts/check-i18n.js +++ b/web/scripts/check-i18n.js @@ -116,7 +116,7 @@ async function getKeysFromLanguage(language) { } // Filter only .json files - const translationFiles = files.filter(file => /\.json$/.test(file)) + const translationFiles = files.filter(file => file.endsWith('.json')) translationFiles.forEach((file) => { const filePath = path.join(folderPath, file) @@ -263,7 +263,7 @@ async function main() { // Get all translation files const i18nFolder = path.resolve(__dirname, '../i18n', language) const files = fs.readdirSync(i18nFolder) - .filter(file => /\.json$/.test(file)) + .filter(file => file.endsWith('.json')) .map(file => file.replace(/\.json$/, '')) .filter(f => targetFiles.length === 0 || targetFiles.includes(f)) diff --git a/web/scripts/component-analyzer.js b/web/scripts/component-analyzer.js index 4612981f82..59f1e3f10e 100644 --- a/web/scripts/component-analyzer.js +++ b/web/scripts/component-analyzer.js @@ -206,10 +206,10 @@ export class ComponentAnalyzer { const escapedName = ComponentAnalyzer.escapeRegExp(searchName) const patterns = [ - new RegExp(`from\\s+['\"][^'\"]*(?:/|^)${escapedName}(?:['\"/]|$)`), - new RegExp(`import\\s*\\(\\s*['\"][^'\"]*(?:/|^)${escapedName}(?:['\"/]|$)`), - new RegExp(`export\\s+(?:\\*|{[^}]*})\\s*from\\s+['\"][^'\"]*(?:/|^)${escapedName}(?:['\"/]|$)`), - new RegExp(`require\\(\\s*['\"][^'\"]*(?:/|^)${escapedName}(?:['\"/]|$)`), + new RegExp(`from\\s+['"][^'"]*(?:/|^)${escapedName}(?:['"/]|$)`), + new RegExp(`import\\s*\\(\\s*['"][^'"]*(?:/|^)${escapedName}(?:['"/]|$)`), + new RegExp(`export\\s+(?:\\*|{[^}]*})\\s*from\\s+['"][^'"]*(?:/|^)${escapedName}(?:['"/]|$)`), + new RegExp(`require\\s*\\(\\s*['"][^'"]*(?:/|^)${escapedName}(?:['"/]|$)`), ] const visited = new Set() diff --git a/web/types/doc-paths.ts b/web/types/doc-paths.ts index a0f2d12097..3f030a2733 100644 --- a/web/types/doc-paths.ts +++ b/web/types/doc-paths.ts @@ -272,9 +272,6 @@ export type DocPathWithoutLang = | DocPathWithoutLangBase | `${DocPathWithoutLangBase}#${string}` -// Full documentation path with language prefix -type DifyDocPath = `${DocLanguage}/${DocPathWithoutLang}` - // API Reference path translations (English -> other languages) export const apiReferencePathTranslations: Record = { '/api-reference/annotations/configure-annotation-reply': { zh: '/api-reference/标注管理/配置标注回复', ja: '/api-reference/アノテーション管理/アノテーション返信を設定' },