mirror of
https://github.com/langgenius/dify.git
synced 2026-05-06 18:27:19 +08:00
Merge branch 'main' into jzh
This commit is contained in:
commit
33f9d96caa
19
.github/workflows/anti-slop.yml
vendored
19
.github/workflows/anti-slop.yml
vendored
@ -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"
|
||||
8
.github/workflows/api-tests.yml
vendored
8
.github/workflows/api-tests.yml
vendored
@ -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"
|
||||
|
||||
10
.github/workflows/autofix.yml
vendored
10
.github/workflows/autofix.yml
vendored
@ -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
|
||||
|
||||
8
.github/workflows/db-migration-test.yml
vendored
8
.github/workflows/db-migration-test.yml
vendored
@ -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
|
||||
|
||||
2
.github/workflows/pyrefly-diff.yml
vendored
2
.github/workflows/pyrefly-diff.yml
vendored
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
2
.github/workflows/pyrefly-type-coverage.yml
vendored
2
.github/workflows/pyrefly-type-coverage.yml
vendored
@ -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
|
||||
|
||||
|
||||
12
.github/workflows/style.yml
vendored
12
.github/workflows/style.yml
vendored
@ -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
|
||||
|
||||
2
.github/workflows/tool-test-sdks.yaml
vendored
2
.github/workflows/tool-test-sdks.yaml
vendored
@ -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: ''
|
||||
|
||||
2
.github/workflows/translate-i18n-claude.yml
vendored
2
.github/workflows/translate-i18n-claude.yml
vendored
@ -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 }}
|
||||
|
||||
4
.github/workflows/vdb-tests-full.yml
vendored
4
.github/workflows/vdb-tests-full.yml
vendored
@ -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
|
||||
|
||||
4
.github/workflows/vdb-tests.yml
vendored
4
.github/workflows/vdb-tests.yml
vendored
@ -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
|
||||
|
||||
2
.github/workflows/web-e2e.yml
vendored
2
.github/workflows/web-e2e.yml
vendored
@ -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"
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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")
|
||||
|
||||
162
api/uv.lock
generated
162
api/uv.lock
generated
@ -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]]
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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
|
||||
|
||||
127
web/__mocks__/__tests__/base-ui-popover.spec.tsx
Normal file
127
web/__mocks__/__tests__/base-ui-popover.spec.tsx
Normal file
@ -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 (
|
||||
<div>
|
||||
<div data-testid="outside-area">outside</div>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger
|
||||
render={useRenderElement
|
||||
? (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="custom-trigger"
|
||||
onClick={(event) => {
|
||||
if (preventDefaultOnTrigger)
|
||||
event.preventDefault()
|
||||
}}
|
||||
>
|
||||
toggle
|
||||
</button>
|
||||
)
|
||||
: undefined}
|
||||
>
|
||||
fallback trigger
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="custom-content"
|
||||
placement="bottom-start"
|
||||
sideOffset={4}
|
||||
alignOffset={8}
|
||||
positionerProps={{ 'data-positioner': 'true' } as unknown as React.HTMLAttributes<HTMLDivElement>}
|
||||
popupProps={{ 'data-popup': 'true' } as unknown as React.HTMLAttributes<HTMLDivElement>}
|
||||
>
|
||||
<div>popover body</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<div data-testid="open-state">{open ? 'open' : 'closed'}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
describe('base-ui-popover mock', () => {
|
||||
it('should toggle popover content from the fallback trigger and expose content props', () => {
|
||||
render(<PopoverHarness />)
|
||||
|
||||
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(<PopoverHarness useRenderElement />)
|
||||
|
||||
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(<PopoverHarness useRenderElement preventDefaultOnTrigger />)
|
||||
|
||||
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<HTMLElement>) => {
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
render(
|
||||
<div>
|
||||
<Popover open={false} onOpenChange={vi.fn()}>
|
||||
<PopoverTrigger onClick={handleClick}>
|
||||
fallback trigger
|
||||
</PopoverTrigger>
|
||||
<PopoverContent>
|
||||
<div>popover body</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('popover-trigger'))
|
||||
|
||||
expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
154
web/__mocks__/base-ui-popover.tsx
Normal file
154
web/__mocks__/base-ui-popover.tsx
Normal file
@ -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<HTMLElement> & {
|
||||
children?: ReactNode
|
||||
nativeButton?: boolean
|
||||
render?: React.ReactElement
|
||||
}
|
||||
|
||||
type PopoverContentProps = React.HTMLAttributes<HTMLDivElement> & {
|
||||
children?: ReactNode
|
||||
placement?: string
|
||||
sideOffset?: number
|
||||
alignOffset?: number
|
||||
positionerProps?: React.HTMLAttributes<HTMLDivElement>
|
||||
popupProps?: React.HTMLAttributes<HTMLDivElement>
|
||||
}
|
||||
|
||||
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 (
|
||||
<PopoverContext.Provider value={{
|
||||
open,
|
||||
onOpenChange: onOpenChange ?? (() => {}),
|
||||
}}
|
||||
>
|
||||
<div data-testid="popover" data-open={String(open)}>
|
||||
{children}
|
||||
</div>
|
||||
</PopoverContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
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<Record<string, unknown>>
|
||||
const childProps = (triggerElement.props ?? {}) as React.HTMLAttributes<HTMLElement> & { 'data-testid'?: string }
|
||||
|
||||
return React.cloneElement(triggerElement, {
|
||||
...props,
|
||||
...childProps,
|
||||
'data-testid': childProps['data-testid'] ?? 'popover-trigger',
|
||||
'data-popover-trigger': 'true',
|
||||
'onClick': (event: React.MouseEvent<HTMLElement>) => {
|
||||
childProps.onClick?.(event)
|
||||
onClick?.(event)
|
||||
if (event.defaultPrevented)
|
||||
return
|
||||
onOpenChange(!open)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="popover-trigger"
|
||||
data-popover-trigger="true"
|
||||
onClick={(event) => {
|
||||
onClick?.(event)
|
||||
if (event.defaultPrevented)
|
||||
return
|
||||
onOpenChange(!open)
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{node}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const PopoverContent = ({
|
||||
children,
|
||||
className,
|
||||
placement,
|
||||
sideOffset,
|
||||
alignOffset,
|
||||
positionerProps,
|
||||
popupProps,
|
||||
...props
|
||||
}: PopoverContentProps) => {
|
||||
const { open } = React.useContext(PopoverContext)
|
||||
|
||||
if (!open)
|
||||
return null
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="popover-content"
|
||||
data-popover-content="true"
|
||||
data-placement={placement}
|
||||
data-side-offset={sideOffset}
|
||||
data-align-offset={alignOffset}
|
||||
className={className}
|
||||
{...positionerProps}
|
||||
{...popupProps}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const PopoverClose = ({ children }: { children?: ReactNode }) => <>{children}</>
|
||||
export const PopoverTitle = ({ children }: { children?: ReactNode }) => <>{children}</>
|
||||
export const PopoverDescription = ({ children }: { children?: ReactNode }) => <>{children}</>
|
||||
@ -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<Props> = ({
|
||||
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 (
|
||||
<PortalToFollowElem
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement="bottom-end"
|
||||
offset={12}
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={handleTrigger}>
|
||||
<div className={cn('select-none', className)}>
|
||||
{children}
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-11">
|
||||
<PopoverTrigger
|
||||
render={(
|
||||
<div className={cn('select-none', className)}>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<PopoverContent
|
||||
placement="bottom-end"
|
||||
sideOffset={12}
|
||||
popupClassName="border-none bg-transparent shadow-none"
|
||||
>
|
||||
<ConfigPopup {...popupProps} />
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
export default React.memo(ConfigBtn)
|
||||
|
||||
@ -43,7 +43,7 @@ vi.mock('../form-fields', () => ({
|
||||
>
|
||||
invalid-name-change
|
||||
</button>
|
||||
<button data-testid="valid-json-change" onClick={() => props.onJSONSchemaChange('{\n \"foo\": \"bar\"\n}')}>valid-json-change</button>
|
||||
<button data-testid="valid-json-change" onClick={() => props.onJSONSchemaChange('{\n "foo": "bar"\n}')}>valid-json-change</button>
|
||||
<button data-testid="empty-json-change" onClick={() => props.onJSONSchemaChange(' ')}>empty-json-change</button>
|
||||
<button data-testid="invalid-json-change" onClick={() => props.onJSONSchemaChange('{invalid-json}')}>invalid-json-change</button>
|
||||
<button data-testid="type-change" onClick={() => props.onTypeChange({ value: InputVarType.singleFile })}>type-change</button>
|
||||
|
||||
@ -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 (
|
||||
<PortalToFollowElem
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement="bottom-end"
|
||||
offset={{
|
||||
mainAxis: 4,
|
||||
crossAxis: 4,
|
||||
}}
|
||||
>
|
||||
<PortalToFollowElemTrigger
|
||||
onClick={() => setOpen(v => !v)}
|
||||
<PopoverTrigger
|
||||
render={(
|
||||
<ActionButton size="l" state={open ? ActionButtonState.Hover : ActionButtonState.Default}>
|
||||
<RiChatSettingsLine className="h-[18px] w-[18px]" />
|
||||
</ActionButton>
|
||||
)}
|
||||
/>
|
||||
<PopoverContent
|
||||
placement="bottom-end"
|
||||
sideOffset={4}
|
||||
alignOffset={4}
|
||||
popupClassName="border-none bg-transparent shadow-none"
|
||||
>
|
||||
<ActionButton size="l" state={open ? ActionButtonState.Hover : ActionButtonState.Default}>
|
||||
<RiChatSettingsLine className="h-[18px] w-[18px]" />
|
||||
</ActionButton>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-50">
|
||||
<div className="w-[400px] rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg backdrop-blur-xs">
|
||||
<div className="flex items-center gap-3 rounded-t-2xl border-b border-divider-subtle px-6 py-4">
|
||||
<Message3Fill className="h-6 w-6 shrink-0" />
|
||||
@ -39,8 +39,8 @@ const ViewFormDropdown = () => {
|
||||
<InputsFormContent />
|
||||
</div>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
)
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
}))
|
||||
|
||||
@ -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<PopupProps> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement="top-start"
|
||||
offset={{
|
||||
mainAxis: 8,
|
||||
crossAxis: -2,
|
||||
}}
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={() => setOpen(v => !v)}>
|
||||
<div data-testid="popup-trigger" className="flex h-7 max-w-[240px] items-center rounded-lg bg-components-button-secondary-bg px-2">
|
||||
<FileIcon type={fileType} className="mr-1 h-4 w-4 shrink-0" />
|
||||
<div className="truncate text-xs text-text-tertiary">{data.documentName}</div>
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent style={{ zIndex: 1000 }}>
|
||||
<PopoverTrigger
|
||||
nativeButton={false}
|
||||
render={(
|
||||
<div data-testid="popup-trigger" className="flex h-7 max-w-[240px] items-center rounded-lg bg-components-button-secondary-bg px-2">
|
||||
<FileIcon type={fileType} className="mr-1 h-4 w-4 shrink-0" />
|
||||
<div className="truncate text-xs text-text-tertiary">{data.documentName}</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<PopoverContent
|
||||
placement="top-start"
|
||||
sideOffset={8}
|
||||
alignOffset={-2}
|
||||
popupClassName="border-none bg-transparent shadow-none"
|
||||
>
|
||||
<div data-testid="popup-content" className="max-w-[360px] rounded-xl bg-background-section-burn shadow-lg backdrop-blur-[5px]">
|
||||
<div className="px-4 pt-3 pb-2">
|
||||
<div className="flex h-[18px] items-center">
|
||||
@ -156,8 +155,8 @@ const Popup: FC<PopupProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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 (
|
||||
<PortalToFollowElem
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement="bottom-end"
|
||||
offset={{
|
||||
mainAxis: 4,
|
||||
crossAxis: 4,
|
||||
}}
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={() => setOpen(v => !v)}>
|
||||
<ActionButton
|
||||
size="l"
|
||||
state={open ? ActionButtonState.Hover : ActionButtonState.Default}
|
||||
data-testid="view-form-dropdown-trigger"
|
||||
>
|
||||
<div className={cn('i-ri-chat-settings-line h-[18px] w-[18px] shrink-0', iconColor)} />
|
||||
</ActionButton>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-99">
|
||||
<PopoverTrigger
|
||||
render={(
|
||||
<ActionButton
|
||||
size="l"
|
||||
state={open ? ActionButtonState.Hover : ActionButtonState.Default}
|
||||
data-testid="view-form-dropdown-trigger"
|
||||
>
|
||||
<div className={cn('i-ri-chat-settings-line h-[18px] w-[18px] shrink-0', iconColor)} />
|
||||
</ActionButton>
|
||||
)}
|
||||
/>
|
||||
<PopoverContent
|
||||
placement="bottom-end"
|
||||
sideOffset={4}
|
||||
alignOffset={4}
|
||||
popupClassName="border-none bg-transparent shadow-none"
|
||||
>
|
||||
<div
|
||||
data-testid="view-form-dropdown-content"
|
||||
className="w-[400px] rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg backdrop-blur-xs"
|
||||
@ -48,8 +50,8 @@ const ViewFormDropdown = ({
|
||||
<InputsFormContent />
|
||||
</div>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
}) => (
|
||||
<button onClick={onClick as (() => void) | undefined} disabled={disabled as boolean | undefined} className={className as string | undefined}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock scrollIntoView
|
||||
beforeAll(() => {
|
||||
Element.prototype.scrollIntoView = vi.fn()
|
||||
@ -113,14 +127,13 @@ describe('DatePicker', () => {
|
||||
render(<DatePicker {...props} />)
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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<HTMLDivElement>(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 (
|
||||
<PortalToFollowElem
|
||||
<Popover
|
||||
open={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
placement="bottom-end"
|
||||
onOpenChange={handleOpenChange}
|
||||
>
|
||||
<PortalToFollowElemTrigger className={triggerWrapClassName}>
|
||||
{renderTrigger
|
||||
? (
|
||||
renderTrigger({
|
||||
value: normalizedValue,
|
||||
selectedDate,
|
||||
isOpen,
|
||||
handleClear,
|
||||
handleClickTrigger,
|
||||
}))
|
||||
<PopoverTrigger
|
||||
nativeButton={false}
|
||||
className={triggerWrapClassName}
|
||||
render={renderTrigger
|
||||
? renderTrigger({
|
||||
value: normalizedValue,
|
||||
selectedDate,
|
||||
isOpen,
|
||||
handleClear,
|
||||
handleClickTrigger,
|
||||
})
|
||||
: (
|
||||
<div
|
||||
className="group flex w-[252px] cursor-pointer items-center gap-x-0.5 rounded-lg bg-components-input-bg-normal px-2 py-1 hover:bg-state-base-hover-alt"
|
||||
@ -242,8 +232,13 @@ const DatePicker = ({
|
||||
<span className={cn('i-ri-close-circle-fill hidden h-4 w-4 shrink-0 text-text-quaternary', (displayValue || (isOpen && selectedDate)) && 'group-hover:inline-block hover:text-text-secondary')} onClick={handleClear} data-testid="date-picker-clear-button" />
|
||||
</div>
|
||||
)}
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className={popupZIndexClassname}>
|
||||
/>
|
||||
<PopoverContent
|
||||
placement="bottom-end"
|
||||
sideOffset={0}
|
||||
className={popupZIndexClassname}
|
||||
popupClassName="border-none bg-transparent shadow-none"
|
||||
>
|
||||
<div className="mt-1 w-[252px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg shadow-shadow-shadow-5">
|
||||
{/* Header */}
|
||||
{view === ViewType.date
|
||||
@ -319,8 +314,8 @@ const DatePicker = ({
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
}) => (
|
||||
<button onClick={onClick as (() => void) | undefined} disabled={disabled as boolean | undefined} className={className as string | undefined}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}))
|
||||
|
||||
// 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', () => {
|
||||
|
||||
@ -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<HTMLDivElement>(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 (
|
||||
<PortalToFollowElem
|
||||
<Popover
|
||||
open={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
placement={placement}
|
||||
onOpenChange={handleOpenChange}
|
||||
>
|
||||
<PortalToFollowElemTrigger className={triggerFullWidth ? 'block! w-full' : undefined}>
|
||||
{renderTrigger
|
||||
? (renderTrigger({
|
||||
<PopoverTrigger
|
||||
nativeButton={false}
|
||||
className={triggerFullWidth ? 'block! w-full' : undefined}
|
||||
render={renderTrigger
|
||||
? renderTrigger({
|
||||
inputElem,
|
||||
onClick: handleClickTrigger,
|
||||
isOpen,
|
||||
}))
|
||||
})
|
||||
: (
|
||||
<div
|
||||
className={cn(
|
||||
@ -236,8 +230,13 @@ const TimePicker = ({
|
||||
<span className={cn('i-ri-close-circle-fill hidden h-4 w-4 shrink-0 text-text-quaternary', (displayValue || (isOpen && selectedTime)) && !notClearable && 'group-hover:inline-block hover:text-text-secondary')} role="button" aria-label={t('operation.clear', { ns: 'common' })} onClick={handleClear} />
|
||||
</div>
|
||||
)}
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className={cn('z-50', popupClassName)}>
|
||||
/>
|
||||
<PopoverContent
|
||||
placement={placement}
|
||||
sideOffset={0}
|
||||
className={popupClassName}
|
||||
popupClassName="border-none bg-transparent shadow-none"
|
||||
>
|
||||
<div className="mt-1 w-[252px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg shadow-shadow-shadow-5">
|
||||
{/* Header */}
|
||||
<Header title={title} />
|
||||
@ -258,8 +257,8 @@ const TimePicker = ({
|
||||
/>
|
||||
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(
|
||||
<FileUploadSettings open={false} onOpen={onOpen}>
|
||||
@ -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', () => {
|
||||
|
||||
@ -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 (
|
||||
<PortalToFollowElem
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={onOpen}
|
||||
placement="left"
|
||||
offset={{
|
||||
mainAxis: 32,
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (disabled)
|
||||
return
|
||||
onOpen(nextOpen)
|
||||
}}
|
||||
>
|
||||
<PortalToFollowElemTrigger className="flex" onClick={() => !disabled && onOpen((open: boolean) => !open)}>
|
||||
{children}
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent style={{ zIndex: 50 }}>
|
||||
<PopoverTrigger
|
||||
nativeButton={false}
|
||||
render={(
|
||||
<div className="flex">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<PopoverContent
|
||||
placement="left"
|
||||
sideOffset={32}
|
||||
popupClassName="border-none bg-transparent shadow-none"
|
||||
>
|
||||
<div className="max-h-[calc(100vh-20px)] w-[360px] overflow-y-auto rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-4 shadow-2xl">
|
||||
<SettingContent
|
||||
imageUpload={imageUpload}
|
||||
@ -47,8 +56,8 @@ const FileUploadSettings = ({
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
export default memo(FileUploadSettings)
|
||||
|
||||
@ -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 }
|
||||
}) => (
|
||||
<div
|
||||
data-testid="voice-settings-portal"
|
||||
data-placement={placement}
|
||||
data-main-axis={offset?.mainAxis}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
PortalToFollowElemTrigger: ({
|
||||
children,
|
||||
onClick,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
onClick?: () => void
|
||||
}) => (
|
||||
<div data-testid="voice-settings-trigger" onClick={onClick}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
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
|
||||
}) => (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="switch"
|
||||
data-checked={String(checked)}
|
||||
onClick={() => onCheckedChange?.(!checked)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
}))
|
||||
|
||||
const defaultFeatures: Features = {
|
||||
moreLikeThis: { enabled: false },
|
||||
opening: { enabled: false },
|
||||
@ -58,7 +56,7 @@ const defaultFeatures: Features = {
|
||||
annotationReply: { enabled: false },
|
||||
}
|
||||
|
||||
const renderWithProvider = (ui: React.ReactNode) => {
|
||||
const renderWithProvider = (ui: ReactNode) => {
|
||||
return render(
|
||||
<FeaturesProvider features={defaultFeatures}>
|
||||
{ui}
|
||||
@ -101,12 +99,7 @@ describe('VoiceSettings', () => {
|
||||
|
||||
fireEvent.click(screen.getByText('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 and trigger is clicked', () => {
|
||||
@ -137,16 +130,13 @@ describe('VoiceSettings', () => {
|
||||
|
||||
it('should use top placement and mainAxis 4 when placementLeft is false', () => {
|
||||
renderWithProvider(
|
||||
<VoiceSettings open={false} onOpen={vi.fn()} placementLeft={false}>
|
||||
<VoiceSettings open={true} onOpen={vi.fn()} placementLeft={false}>
|
||||
<button>Settings</button>
|
||||
</VoiceSettings>,
|
||||
)
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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 (
|
||||
<PortalToFollowElem
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={onOpen}
|
||||
placement={placementLeft ? 'left' : 'top'}
|
||||
offset={{
|
||||
mainAxis: placementLeft ? 32 : 4,
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (disabled)
|
||||
return
|
||||
onOpen(nextOpen)
|
||||
}}
|
||||
>
|
||||
<PortalToFollowElemTrigger className="flex" onClick={() => !disabled && onOpen((open: boolean) => !open)}>
|
||||
{children}
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent style={{ zIndex: 50 }}>
|
||||
<PopoverTrigger
|
||||
nativeButton={false}
|
||||
render={(
|
||||
<div className="flex">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<PopoverContent
|
||||
placement={placementLeft ? 'left' : 'top'}
|
||||
sideOffset={placementLeft ? 32 : 4}
|
||||
popupClassName="border-none bg-transparent shadow-none"
|
||||
>
|
||||
<div className="w-[360px] rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-4 shadow-2xl">
|
||||
<ParamConfigContent onClose={() => onOpen(false)} onChange={onChange} />
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
export default memo(VoiceSettings)
|
||||
|
||||
@ -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 (
|
||||
<PortalToFollowElem
|
||||
placement="top"
|
||||
offset={4}
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={() => setOpen(v => !v)} asChild>
|
||||
{trigger(open)}
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-1001">
|
||||
<PopoverTrigger render={trigger(open) as React.ReactElement} />
|
||||
<PopoverContent
|
||||
placement="top"
|
||||
sideOffset={4}
|
||||
popupClassName="border-none bg-transparent shadow-none"
|
||||
>
|
||||
<div className="w-[280px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-3 shadow-lg">
|
||||
{
|
||||
showFromLink && (
|
||||
@ -126,8 +126,8 @@ const FileFromLinkOrLocal = ({
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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<UploaderButtonProps> = ({
|
||||
|
||||
const closePopover = () => setOpen(false)
|
||||
|
||||
const handleToggle = () => {
|
||||
if (disabled)
|
||||
return
|
||||
|
||||
setOpen(v => !v)
|
||||
}
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement="top-start"
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (disabled)
|
||||
return
|
||||
setOpen(nextOpen)
|
||||
}}
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={handleToggle}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
className="relative flex h-8 w-8 items-center justify-center rounded-lg enabled:hover:bg-gray-100 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span className="i-custom-vender-line-images-image-plus h-4 w-4 text-gray-500" />
|
||||
</button>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-50">
|
||||
<PopoverTrigger
|
||||
render={(
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
className="relative flex h-8 w-8 items-center justify-center rounded-lg enabled:hover:bg-gray-100 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span className="i-custom-vender-line-images-image-plus h-4 w-4 text-gray-500" />
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
<PopoverContent
|
||||
placement="top-start"
|
||||
sideOffset={0}
|
||||
popupClassName="border-none bg-transparent shadow-none"
|
||||
>
|
||||
<div className="w-[260px] rounded-lg border-[0.5px] border-gray-200 bg-white p-2 shadow-lg">
|
||||
<ImageLinkInput onUpload={handleUpload} disabled={disabled} />
|
||||
{!!hasUploadFromLocal && (
|
||||
@ -115,8 +117,8 @@ const UploaderButton: FC<UploaderButtonProps> = ({
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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<PasteImageLinkButtonProps> = ({
|
||||
onUpload(imageFile)
|
||||
}
|
||||
|
||||
const handleToggle = () => {
|
||||
if (disabled)
|
||||
return
|
||||
|
||||
setOpen(v => !v)
|
||||
}
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement="top-start"
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (disabled)
|
||||
return
|
||||
setOpen(nextOpen)
|
||||
}}
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={handleToggle}>
|
||||
<div className={`
|
||||
relative flex h-8 items-center justify-center rounded-lg bg-components-button-tertiary-bg px-3 text-xs text-text-tertiary hover:bg-components-button-tertiary-bg-hover
|
||||
${disabled ? 'cursor-not-allowed' : 'cursor-pointer'}
|
||||
`}
|
||||
>
|
||||
<Link03 className="mr-2 h-4 w-4" />
|
||||
{t('imageUploader.pasteImageLink', { ns: 'common' })}
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-10">
|
||||
<PopoverTrigger
|
||||
render={(
|
||||
<div
|
||||
className={`
|
||||
relative flex h-8 items-center justify-center rounded-lg bg-components-button-tertiary-bg px-3 text-xs text-text-tertiary hover:bg-components-button-tertiary-bg-hover
|
||||
${disabled ? 'cursor-not-allowed' : 'cursor-pointer'}
|
||||
`}
|
||||
>
|
||||
<Link03 className="mr-2 h-4 w-4" />
|
||||
{t('imageUploader.pasteImageLink', { ns: 'common' })}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<PopoverContent
|
||||
placement="top-start"
|
||||
sideOffset={0}
|
||||
popupClassName="border-none bg-transparent shadow-none"
|
||||
>
|
||||
<div className="w-[320px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg p-2 shadow-lg">
|
||||
<ImageLinkInput onUpload={handleUpload} />
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -91,7 +91,7 @@ function buildDirectiveRehypePlugins(): PluggableList {
|
||||
])
|
||||
|
||||
const attributes: Record<string, AttributeDefinition[]> = {
|
||||
...(defaultSanitizeSchema.attributes ?? {}),
|
||||
...defaultSanitizeSchema.attributes,
|
||||
}
|
||||
|
||||
for (const [tagName, allowedAttributes] of Object.entries(DIRECTIVE_ALLOWED_TAGS))
|
||||
|
||||
@ -86,7 +86,7 @@ function buildRehypePlugins(extraPlugins?: PluggableList): PluggableList {
|
||||
])
|
||||
|
||||
const mergedAttributes: Record<string, AttributeDefinition[]> = {
|
||||
...(defaultSanitizeSchema.attributes ?? {}),
|
||||
...defaultSanitizeSchema.attributes,
|
||||
}
|
||||
|
||||
for (const tag of Object.keys(ALLOWED_TAGS)) {
|
||||
|
||||
@ -148,14 +148,17 @@ export const PortalToFollowElemTrigger = (
|
||||
}: React.HTMLProps<HTMLElement> & { ref?: React.RefObject<HTMLElement | null>, asChild?: boolean },
|
||||
) => {
|
||||
const context = usePortalToFollowElemContext()
|
||||
const childrenRef = (children as any).props?.ref
|
||||
const childElement = React.isValidElement<{ ref?: React.Ref<HTMLElement | null> }>(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<string, unknown>
|
||||
if (asChild && childElement) {
|
||||
const childProps = (childElement.props ?? {}) as Record<string, unknown>
|
||||
return React.cloneElement(
|
||||
children,
|
||||
childElement,
|
||||
context.getReferenceProps({
|
||||
ref,
|
||||
...props,
|
||||
|
||||
@ -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(
|
||||
<ContextBlockComponent nodeKey="test-key" onAddContext={vi.fn()} />,
|
||||
)
|
||||
|
||||
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, unknown> | string) => void = () => { }
|
||||
mockUseSubscription.mockImplementation((cb: (v: Record<string, unknown> | string) => void) => {
|
||||
subscriptionCallback = cb
|
||||
})
|
||||
|
||||
render(
|
||||
<ContextBlockComponent
|
||||
nodeKey="test-key"
|
||||
datasets={mockDatasets}
|
||||
onAddContext={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
act(() => {
|
||||
subscriptionCallback('ignore-me')
|
||||
})
|
||||
|
||||
expect(screen.getByText('Dataset A')).toBeInTheDocument()
|
||||
expect(screen.getByText('Dataset B')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
|
||||
@ -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<ContextBlockComponentProps> = ({
|
||||
const { eventEmitter } = useEventEmitterContextContext()
|
||||
const [localDatasets, setLocalDatasets] = useState<Dataset[]>(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<ContextBlockComponentProps> = ({
|
||||
<span className="mr-1 i-custom-vender-solid-files-file-05 h-[14px] w-[14px]" data-testid="file-icon" />
|
||||
<div className="mr-1 text-xs font-medium">{t('promptEditor.context.item.title', { ns: 'common' })}</div>
|
||||
{!canNotAddContext && (
|
||||
<PortalToFollowElem
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement="bottom-end"
|
||||
offset={{
|
||||
mainAxis: 3,
|
||||
alignmentAxis: -147,
|
||||
}}
|
||||
>
|
||||
<PortalToFollowElemTrigger ref={triggerRef}>
|
||||
<div className={`
|
||||
<PopoverTrigger
|
||||
nativeButton={false}
|
||||
render={(
|
||||
<div
|
||||
className={`
|
||||
flex h-[18px] w-[18px] cursor-pointer items-center justify-center rounded text-[11px] font-semibold
|
||||
${open ? 'bg-[#6938EF] text-white' : 'bg-white/50 group-hover:bg-white group-hover:shadow-xs'}
|
||||
`}>
|
||||
{localDatasets.length}
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent style={{ zIndex: 100 }}>
|
||||
`}
|
||||
ref={triggerRef}
|
||||
onClick={e => e.preventDefault()}
|
||||
>
|
||||
{localDatasets.length}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<PopoverContent
|
||||
placement="bottom-end"
|
||||
sideOffset={3}
|
||||
alignOffset={-147}
|
||||
popupClassName="border-none bg-transparent shadow-none"
|
||||
>
|
||||
<div className="w-[360px] rounded-xl bg-white shadow-lg">
|
||||
<div className="p-4">
|
||||
<div className="mb-2 text-xs font-medium text-gray-500">
|
||||
@ -95,8 +102,8 @@ const ContextBlockComponent: FC<ContextBlockComponentProps> = ({
|
||||
{t('promptEditor.context.modal.footer', { ns: 'common' })}
|
||||
</div>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
@ -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<SetStateAction<boolean>>
|
||||
mockUseTrigger.mockReturnValue(createTriggerHookReturn(false, setOpen))
|
||||
|
||||
render(
|
||||
<HistoryBlockComponent
|
||||
nodeKey="history-node-trigger"
|
||||
onEditRole={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
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(
|
||||
<HistoryBlockComponent
|
||||
nodeKey="history-node-6-string"
|
||||
roleName={createRoleName({
|
||||
user: 'kept-user',
|
||||
assistant: 'kept-assistant',
|
||||
})}
|
||||
onEditRole={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
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,
|
||||
|
||||
@ -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<HistoryBlockComponentProps> = ({
|
||||
const { eventEmitter } = useEventEmitterContextContext()
|
||||
const [localRoleName, setLocalRoleName] = useState<RoleName>(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<HistoryBlockComponentProps> = ({
|
||||
>
|
||||
<MessageClockCircle className="mr-1 h-[14px] w-[14px]" />
|
||||
<div className="mr-1 text-xs font-medium">{t('promptEditor.history.item.title', { ns: 'common' })}</div>
|
||||
<PortalToFollowElem
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement="top-end"
|
||||
offset={{
|
||||
mainAxis: 4,
|
||||
alignmentAxis: -148,
|
||||
}}
|
||||
>
|
||||
<PortalToFollowElemTrigger ref={triggerRef}>
|
||||
<div className={`
|
||||
<PopoverTrigger
|
||||
nativeButton={false}
|
||||
render={(
|
||||
<div
|
||||
className={`
|
||||
flex h-[18px] w-[18px] cursor-pointer items-center justify-center rounded
|
||||
${open ? 'bg-[#DD2590] text-white' : 'bg-white/50 group-hover:bg-white group-hover:shadow-xs'}
|
||||
`}
|
||||
>
|
||||
<RiMoreFill className="h-3 w-3" />
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent style={{ zIndex: 100 }}>
|
||||
ref={triggerRef}
|
||||
onClick={e => e.preventDefault()}
|
||||
>
|
||||
<RiMoreFill className="h-3 w-3" />
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<PopoverContent
|
||||
placement="top-end"
|
||||
sideOffset={4}
|
||||
alignOffset={-148}
|
||||
popupClassName="border-none bg-transparent shadow-none"
|
||||
>
|
||||
<div className="w-[360px] rounded-xl bg-white shadow-lg">
|
||||
<div className="p-4">
|
||||
<div className="mb-2 text-xs font-medium text-gray-500">{t('promptEditor.history.modal.title', { ns: 'common' })}</div>
|
||||
@ -87,8 +93,8 @@ const HistoryBlockComponent: FC<HistoryBlockComponentProps> = ({
|
||||
{t('promptEditor.history.modal.edit', { ns: 'common' })}
|
||||
</div>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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
|
||||
}) => (
|
||||
<div data-testid="portal-elem" data-open={String(open || false)}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
PortalToFollowElemTrigger: ({ children, onClick }: {
|
||||
children: React.ReactNode
|
||||
onClick?: () => void
|
||||
}) => (
|
||||
<div data-testid="portal-trigger" onClick={onClick}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
// Always render content to allow testing document selection
|
||||
PortalToFollowElemContent: ({ children, className }: {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}) => (
|
||||
<div data-testid="portal-content" className={className}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
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<React.ComponentProps<typeof DocumentPick
|
||||
}
|
||||
}
|
||||
|
||||
const openPopover = () => {
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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
|
||||
}) => (
|
||||
<div data-testid="portal-elem" data-open={String(open || false)}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
PortalToFollowElemTrigger: ({ children, onClick }: {
|
||||
children: React.ReactNode
|
||||
onClick?: () => void
|
||||
}) => (
|
||||
<div data-testid="portal-trigger" onClick={onClick}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
// Always render content to allow testing document selection
|
||||
PortalToFollowElemContent: ({ children, className }: {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}) => (
|
||||
<div data-testid="portal-content" className={className}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
vi.mock('@langgenius/dify-ui/popover', () => import('@/__mocks__/base-ui-popover'))
|
||||
|
||||
// Factory function to create mock DocumentItem
|
||||
const createMockDocumentItem = (overrides: Partial<DocumentItem> = {}): DocumentItem => ({
|
||||
@ -67,6 +40,10 @@ const renderComponent = (props: Partial<React.ComponentProps<typeof PreviewDocum
|
||||
}
|
||||
}
|
||||
|
||||
const openPopover = () => {
|
||||
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(<PreviewDocumentPicker {...props} />)
|
||||
|
||||
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', () => {
|
||||
<PreviewDocumentPicker value={value} files={files} onChange={onChange2} />,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('popover')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -265,7 +242,7 @@ describe('PreviewDocumentPicker', () => {
|
||||
<PreviewDocumentPicker value={value} files={files} onChange={onChange} />,
|
||||
)
|
||||
|
||||
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(<PreviewDocumentPicker {...props} />)
|
||||
|
||||
// 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)
|
||||
|
||||
@ -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<Props> = ({
|
||||
|
||||
const [open, {
|
||||
set: setOpen,
|
||||
toggle: togglePopup,
|
||||
}] = useBoolean(false)
|
||||
const ArrowIcon = RiArrowDownSLine
|
||||
|
||||
@ -77,34 +76,40 @@ const DocumentPicker: FC<Props> = ({
|
||||
}, [parentMode, t])
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement="bottom-start"
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={togglePopup}>
|
||||
<div className={cn('ml-1 flex cursor-pointer items-center rounded-lg px-2 py-0.5 select-none hover:bg-state-base-hover', open && 'bg-state-base-hover')}>
|
||||
<FileIcon name={name} extension={extension} size="xl" />
|
||||
<div className="mr-0.5 ml-1 flex flex-col items-start">
|
||||
<div className="flex items-center space-x-0.5">
|
||||
<span className={cn('system-md-semibold text-text-primary')}>
|
||||
{' '}
|
||||
{name || '--'}
|
||||
</span>
|
||||
<ArrowIcon className="h-4 w-4 text-text-primary" />
|
||||
</div>
|
||||
<div className="flex h-3 items-center space-x-0.5 text-text-tertiary">
|
||||
<TypeIcon className="h-3 w-3" />
|
||||
<span className={cn('system-2xs-medium-uppercase', isParentChild && 'mt-0.5' /* to icon problem cause not ver align */)}>
|
||||
{isGeneralMode && t('chunkingMode.general', { ns: 'dataset' })}
|
||||
{isQAMode && t('chunkingMode.qa', { ns: 'dataset' })}
|
||||
{isParentChild && `${t('chunkingMode.parentChild', { ns: 'dataset' })} · ${parentModeLabel}`}
|
||||
</span>
|
||||
<PopoverTrigger
|
||||
nativeButton={false}
|
||||
render={(
|
||||
<div className={cn('ml-1 flex cursor-pointer items-center rounded-lg px-2 py-0.5 select-none hover:bg-state-base-hover', open && 'bg-state-base-hover')}>
|
||||
<FileIcon name={name} extension={extension} size="xl" />
|
||||
<div className="mr-0.5 ml-1 flex flex-col items-start">
|
||||
<div className="flex items-center space-x-0.5">
|
||||
<span className={cn('system-md-semibold text-text-primary')}>
|
||||
{' '}
|
||||
{name || '--'}
|
||||
</span>
|
||||
<ArrowIcon className="h-4 w-4 text-text-primary" />
|
||||
</div>
|
||||
<div className="flex h-3 items-center space-x-0.5 text-text-tertiary">
|
||||
<TypeIcon className="h-3 w-3" />
|
||||
<span className={cn('system-2xs-medium-uppercase', isParentChild && 'mt-0.5' /* to icon problem cause not ver align */)}>
|
||||
{isGeneralMode && t('chunkingMode.general', { ns: 'dataset' })}
|
||||
{isQAMode && t('chunkingMode.qa', { ns: 'dataset' })}
|
||||
{isParentChild && `${t('chunkingMode.parentChild', { ns: 'dataset' })} · ${parentModeLabel}`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-11">
|
||||
)}
|
||||
/>
|
||||
<PopoverContent
|
||||
placement="bottom-start"
|
||||
sideOffset={0}
|
||||
popupClassName="border-none bg-transparent shadow-none"
|
||||
>
|
||||
<div className="w-[360px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 pt-2 shadow-lg backdrop-blur-[5px]">
|
||||
<SearchInput value={query} onChange={setQuery} className="mx-1" />
|
||||
{documentsList
|
||||
@ -125,9 +130,8 @@ const DocumentPicker: FC<Props> = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
export default React.memo(DocumentPicker)
|
||||
|
||||
@ -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<Props> = ({
|
||||
|
||||
const [open, {
|
||||
set: setOpen,
|
||||
toggle: togglePopup,
|
||||
}] = useBoolean(false)
|
||||
const ArrowIcon = RiArrowDownSLine
|
||||
|
||||
@ -45,27 +44,32 @@ const PreviewDocumentPicker: FC<Props> = ({
|
||||
}, [onChange, setOpen])
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement="bottom-start"
|
||||
offset={4}
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={togglePopup}>
|
||||
<div className={cn('flex h-6 items-center rounded-md px-1 select-none hover:bg-state-base-hover', open && 'bg-state-base-hover', className)}>
|
||||
<FileIcon name={name} extension={extension} size="lg" />
|
||||
<div className="ml-1 flex flex-col items-start">
|
||||
<div className="flex items-center space-x-0.5">
|
||||
<span className={cn('max-w-[200px] truncate system-md-semibold text-text-primary')}>
|
||||
{' '}
|
||||
{name || '--'}
|
||||
</span>
|
||||
<ArrowIcon className="h-[18px] w-[18px] text-text-primary" />
|
||||
<PopoverTrigger
|
||||
nativeButton={false}
|
||||
render={(
|
||||
<div className={cn('flex h-6 items-center rounded-md px-1 select-none hover:bg-state-base-hover', open && 'bg-state-base-hover', className)}>
|
||||
<FileIcon name={name} extension={extension} size="lg" />
|
||||
<div className="ml-1 flex flex-col items-start">
|
||||
<div className="flex items-center space-x-0.5">
|
||||
<span className={cn('max-w-[200px] truncate system-md-semibold text-text-primary')}>
|
||||
{' '}
|
||||
{name || '--'}
|
||||
</span>
|
||||
<ArrowIcon className="h-[18px] w-[18px] text-text-primary" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-11">
|
||||
)}
|
||||
/>
|
||||
<PopoverContent
|
||||
placement="bottom-start"
|
||||
sideOffset={4}
|
||||
popupClassName="border-none bg-transparent shadow-none"
|
||||
>
|
||||
<div className="w-[392px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg backdrop-blur-[5px]">
|
||||
{files?.length > 1 && <div className="flex h-8 items-center pl-2 system-xs-medium-uppercase text-text-tertiary">{t('preprocessDocument', { ns: 'dataset', num: files.length })}</div>}
|
||||
{files?.length > 0
|
||||
@ -81,9 +85,8 @@ const PreviewDocumentPicker: FC<Props> = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
export default React.memo(PreviewDocumentPicker)
|
||||
|
||||
@ -231,8 +231,9 @@ describe('StepTwoPreview', () => {
|
||||
describe('Props Passing', () => {
|
||||
it('should render preview button when isIdle is true', () => {
|
||||
render(<StepTwoPreview {...defaultProps} isIdle={true} />)
|
||||
// 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(<StepTwoPreview {...defaultProps} isIdle={true} onPreview={onPreview} />)
|
||||
|
||||
// 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()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 (
|
||||
<PortalToFollowElem
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement="bottom-start"
|
||||
offset={4}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (disabled)
|
||||
return
|
||||
setOpen(nextOpen)
|
||||
}}
|
||||
>
|
||||
<div className="relative">
|
||||
<PortalToFollowElemTrigger
|
||||
onClick={() => !disabled && setOpen(v => !v)}
|
||||
className="block"
|
||||
>
|
||||
<div className={cn('flex cursor-pointer items-center gap-x-0.5 rounded-lg bg-components-input-bg-normal px-2 py-1 hover:bg-state-base-hover-alt', open && 'bg-state-base-hover-alt', disabled && 'cursor-not-allowed! bg-components-input-bg-disabled! hover:bg-components-input-bg-disabled!')}>
|
||||
{
|
||||
isOnlyMe && (
|
||||
<>
|
||||
<div className="flex size-6 shrink-0 items-center justify-center">
|
||||
<Avatar avatar={userProfile.avatar_url} name={userProfile.name} size="xs" />
|
||||
</div>
|
||||
<div className="grow p-1 system-sm-regular text-components-input-text-filled">
|
||||
{t('form.permissionsOnlyMe', { ns: 'datasetSettings' })}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
{
|
||||
isAllTeamMembers && (
|
||||
<>
|
||||
<div className="flex size-6 shrink-0 items-center justify-center">
|
||||
<RiGroup2Line className="size-4 text-text-secondary" />
|
||||
</div>
|
||||
<div className="grow p-1 system-sm-regular text-components-input-text-filled">
|
||||
{t('form.permissionsAllMember', { ns: 'datasetSettings' })}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
{
|
||||
isPartialMembers && (
|
||||
<>
|
||||
<div className="relative flex size-6 shrink-0 items-center justify-center">
|
||||
{
|
||||
selectedMembers.length === 1 && (
|
||||
<Avatar
|
||||
avatar={selectedMembers[0]!.avatar_url}
|
||||
name={selectedMembers[0]!.name}
|
||||
size="xs"
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
selectedMembers.length >= 2 && (
|
||||
<>
|
||||
<PopoverTrigger
|
||||
render={(
|
||||
<div className={cn('flex cursor-pointer items-center gap-x-0.5 rounded-lg bg-components-input-bg-normal px-2 py-1 hover:bg-state-base-hover-alt', open && 'bg-state-base-hover-alt', disabled && 'cursor-not-allowed! bg-components-input-bg-disabled! hover:bg-components-input-bg-disabled!')}>
|
||||
{
|
||||
isOnlyMe && (
|
||||
<>
|
||||
<div className="flex size-6 shrink-0 items-center justify-center">
|
||||
<Avatar avatar={userProfile.avatar_url} name={userProfile.name} size="xs" />
|
||||
</div>
|
||||
<div className="grow p-1 system-sm-regular text-components-input-text-filled">
|
||||
{t('form.permissionsOnlyMe', { ns: 'datasetSettings' })}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
{
|
||||
isAllTeamMembers && (
|
||||
<>
|
||||
<div className="flex size-6 shrink-0 items-center justify-center">
|
||||
<RiGroup2Line className="size-4 text-text-secondary" />
|
||||
</div>
|
||||
<div className="grow p-1 system-sm-regular text-components-input-text-filled">
|
||||
{t('form.permissionsAllMember', { ns: 'datasetSettings' })}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
{
|
||||
isPartialMembers && (
|
||||
<>
|
||||
<div className="relative flex size-6 shrink-0 items-center justify-center">
|
||||
{
|
||||
selectedMembers.length === 1 && (
|
||||
<Avatar
|
||||
avatar={selectedMembers[0]!.avatar_url}
|
||||
name={selectedMembers[0]!.name}
|
||||
className="absolute top-0 left-0 z-0"
|
||||
size="xxs"
|
||||
size="xs"
|
||||
/>
|
||||
<Avatar
|
||||
avatar={selectedMembers[1]!.avatar_url}
|
||||
name={selectedMembers[1]!.name}
|
||||
className="absolute right-0 bottom-0 z-10"
|
||||
size="xxs"
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
<div
|
||||
title={selectedMemberNames}
|
||||
className="grow truncate p-1 system-sm-regular text-components-input-text-filled"
|
||||
>
|
||||
{selectedMemberNames}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
<RiArrowDownSLine
|
||||
className={cn(
|
||||
'h-4 w-4 shrink-0 text-text-quaternary group-hover:text-text-secondary',
|
||||
open && 'text-text-secondary',
|
||||
disabled && 'text-components-input-text-placeholder!',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-1002">
|
||||
)
|
||||
}
|
||||
{
|
||||
selectedMembers.length >= 2 && (
|
||||
<>
|
||||
<Avatar
|
||||
avatar={selectedMembers[0]!.avatar_url}
|
||||
name={selectedMembers[0]!.name}
|
||||
className="absolute top-0 left-0 z-0"
|
||||
size="xxs"
|
||||
/>
|
||||
<Avatar
|
||||
avatar={selectedMembers[1]!.avatar_url}
|
||||
name={selectedMembers[1]!.name}
|
||||
className="absolute right-0 bottom-0 z-10"
|
||||
size="xxs"
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
<div
|
||||
title={selectedMemberNames}
|
||||
className="grow truncate p-1 system-sm-regular text-components-input-text-filled"
|
||||
>
|
||||
{selectedMemberNames}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
<RiArrowDownSLine
|
||||
className={cn(
|
||||
'h-4 w-4 shrink-0 text-text-quaternary group-hover:text-text-secondary',
|
||||
open && 'text-text-secondary',
|
||||
disabled && 'text-components-input-text-placeholder!',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<PopoverContent
|
||||
placement="bottom-start"
|
||||
sideOffset={4}
|
||||
popupClassName="border-none bg-transparent shadow-none"
|
||||
>
|
||||
<div className="relative w-[480px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg shadow-shadow-shadow-5">
|
||||
<div className="p-1">
|
||||
{/* Only me */}
|
||||
@ -236,6 +241,7 @@ const PermissionSelector = ({
|
||||
)}
|
||||
{filteredMemberList.map(member => (
|
||||
<MemberItem
|
||||
key={member.id}
|
||||
leftIcon={
|
||||
<Avatar avatar={member.avatar_url} name={member.name} className="shrink-0" size="sm" />
|
||||
}
|
||||
@ -256,9 +262,9 @@ const PermissionSelector = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PopoverContent>
|
||||
</div>
|
||||
</PortalToFollowElem>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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<ApiBasedExtensionSelectorProps> = ({
|
||||
const currentItem = data?.find(item => item.id === value)
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement="bottom-start"
|
||||
offset={4}
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={() => setOpen(v => !v)} className="w-full">
|
||||
{
|
||||
currentItem
|
||||
? (
|
||||
<div className="flex h-9 cursor-pointer items-center justify-between rounded-lg bg-components-input-bg-normal pr-2.5 pl-3">
|
||||
<div className="text-sm text-text-primary">{currentItem.name}</div>
|
||||
<div className="flex items-center">
|
||||
<div className="mr-1.5 w-[270px] truncate text-right text-xs text-text-quaternary">
|
||||
{currentItem.api_endpoint}
|
||||
<PopoverTrigger
|
||||
render={(
|
||||
<button type="button" className="block w-full border-0 bg-transparent p-0 text-left">
|
||||
{
|
||||
currentItem
|
||||
? (
|
||||
<div className="flex h-9 cursor-pointer items-center justify-between rounded-lg bg-components-input-bg-normal pr-2.5 pl-3">
|
||||
<div className="text-sm text-text-primary">{currentItem.name}</div>
|
||||
<div className="flex items-center">
|
||||
<div className="mr-1.5 w-[270px] truncate text-right text-xs text-text-quaternary">
|
||||
{currentItem.api_endpoint}
|
||||
</div>
|
||||
<RiArrowDownSLine className={`h-4 w-4 text-text-secondary ${!open && 'opacity-60'}`} />
|
||||
</div>
|
||||
</div>
|
||||
<RiArrowDownSLine className={`h-4 w-4 text-text-secondary ${!open && 'opacity-60'}`} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<div className="flex h-9 cursor-pointer items-center justify-between rounded-lg bg-components-input-bg-normal pr-2.5 pl-3 text-sm text-text-quaternary">
|
||||
{t('apiBasedExtension.selector.placeholder', { ns: 'common' })}
|
||||
<RiArrowDownSLine className={`h-4 w-4 text-text-secondary ${!open && 'opacity-60'}`} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-1002 w-[calc(100%-32px)] max-w-[576px]">
|
||||
)
|
||||
: (
|
||||
<div className="flex h-9 cursor-pointer items-center justify-between rounded-lg bg-components-input-bg-normal pr-2.5 pl-3 text-sm text-text-quaternary">
|
||||
{t('apiBasedExtension.selector.placeholder', { ns: 'common' })}
|
||||
<RiArrowDownSLine className={`h-4 w-4 text-text-secondary ${!open && 'opacity-60'}`} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
<PopoverContent
|
||||
placement="bottom-start"
|
||||
sideOffset={4}
|
||||
className="w-[calc(100%-32px)] max-w-[576px]"
|
||||
popupClassName="border-0 bg-transparent p-0 shadow-none backdrop-blur-none"
|
||||
>
|
||||
<div className="z-10 w-full rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg">
|
||||
<div className="p-1">
|
||||
<div className="flex items-center justify-between px-3 pt-2 pb-1">
|
||||
@ -116,8 +119,8 @@ const ApiBasedExtensionSelector: FC<ApiBasedExtensionSelectorProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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<HTMLButtonElement> & { children?: ReactNode }) => (
|
||||
<button {...props}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}))
|
||||
|
||||
/**
|
||||
* Configure Component Tests
|
||||
* Using Unit approach to ensure 100% coverage and stable tests.
|
||||
|
||||
@ -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 (
|
||||
<>
|
||||
<PortalToFollowElem
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement="bottom-end"
|
||||
offset={{
|
||||
mainAxis: 4,
|
||||
crossAxis: -4,
|
||||
}}
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={handleToggle}>
|
||||
<Button
|
||||
variant="secondary-accent"
|
||||
>
|
||||
<RiAddLine className="h-4 w-4" />
|
||||
{t('dataSource.configure', { ns: 'common' })}
|
||||
</Button>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-1002">
|
||||
<PopoverTrigger
|
||||
render={(
|
||||
<Button
|
||||
variant="secondary-accent"
|
||||
>
|
||||
<RiAddLine className="h-4 w-4" />
|
||||
{t('dataSource.configure', { ns: 'common' })}
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
<PopoverContent
|
||||
placement="bottom-end"
|
||||
sideOffset={4}
|
||||
alignOffset={-4}
|
||||
popupClassName="border-none bg-transparent shadow-none"
|
||||
>
|
||||
<div className="w-[240px] space-y-1.5 rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-2 shadow-lg">
|
||||
{
|
||||
!!canOAuth && (
|
||||
@ -122,8 +120,8 @@ const Configure = ({
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -34,33 +34,17 @@ vi.mock('@remixicon/react', () => ({
|
||||
RiAddLine: () => <div data-testid="add-line-icon" />,
|
||||
}))
|
||||
|
||||
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 }) => (
|
||||
<div data-testid="tooltip-mock">
|
||||
{children}
|
||||
<div>{popupContent}</div>
|
||||
</div>
|
||||
),
|
||||
TooltipTrigger: ({ render }: { render: React.ReactNode }) => <>{render}</>,
|
||||
TooltipContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}))
|
||||
|
||||
// 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 }) => (
|
||||
<div data-testid="portal" data-open={open}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick: () => void }) => (
|
||||
<div data-testid="portal-trigger" onClick={onClick}>{children}</div>
|
||||
),
|
||||
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 <div data-testid="portal-content" style={{ display: 'block' }}>{children}</div>
|
||||
},
|
||||
}))
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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 = (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="small"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'text-text-tertiary',
|
||||
open && 'bg-components-button-ghost-bg-hover',
|
||||
@ -85,38 +89,32 @@ const AddCustomModel = ({
|
||||
)
|
||||
if (notAllowCustomCredential && !!noModels) {
|
||||
return (
|
||||
<Tooltip asChild popupContent={t('auth.credentialUnavailable', { ns: 'plugin' })}>
|
||||
{Item}
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={item} />
|
||||
<TooltipContent>{t('auth.credentialUnavailable', { ns: 'plugin' })}</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
return Item
|
||||
return item
|
||||
}, [t, notAllowCustomCredential, noModels])
|
||||
|
||||
if (noModels) {
|
||||
return renderTrigger(false, notAllowCustomCredential ? undefined : handleOpenModalForAddNewCustomModel)
|
||||
}
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement="bottom-end"
|
||||
offset={{
|
||||
mainAxis: 4,
|
||||
crossAxis: 0,
|
||||
}}
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={() => {
|
||||
if (noModels) {
|
||||
if (notAllowCustomCredential)
|
||||
return
|
||||
handleOpenModalForAddNewCustomModel()
|
||||
return
|
||||
}
|
||||
|
||||
setOpen(prev => !prev)
|
||||
}}
|
||||
<PopoverTrigger
|
||||
render={<div className="inline-block">{renderTrigger(open)}</div>}
|
||||
/>
|
||||
<PopoverContent
|
||||
placement="bottom-end"
|
||||
sideOffset={4}
|
||||
popupClassName="border-0 bg-transparent p-0 shadow-none backdrop-blur-none"
|
||||
>
|
||||
{renderTrigger(open)}
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-1002">
|
||||
<div className="w-[320px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg">
|
||||
<div className="max-h-[304px] overflow-y-auto p-1">
|
||||
{
|
||||
@ -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)
|
||||
}}
|
||||
>
|
||||
<ModelIcon
|
||||
@ -150,8 +148,8 @@ const AddCustomModel = ({
|
||||
<div
|
||||
className="flex cursor-pointer items-center border-t border-t-divider-subtle p-3 system-xs-medium text-text-accent-light-mode-only"
|
||||
onClick={() => {
|
||||
handleOpenModalForAddNewCustomModel()
|
||||
setOpen(false)
|
||||
handleOpenModalForAddNewCustomModel()
|
||||
}}
|
||||
>
|
||||
<RiAddLine className="mr-1 h-4 w-4" />
|
||||
@ -160,8 +158,8 @@ const AddCustomModel = ({
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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<string, any>) => void
|
||||
onUpdate?: (newPayload?: Record<string, unknown>, formValues?: Record<string, unknown>) => 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<HTMLElement>) => {
|
||||
if (!triggerOnlyOpenModal)
|
||||
return
|
||||
|
||||
event.preventDefault()
|
||||
handleOpenModal()
|
||||
}, [handleOpenModal, triggerOnlyOpenModal])
|
||||
|
||||
return (
|
||||
<>
|
||||
<PortalToFollowElem
|
||||
<Popover
|
||||
open={mergedIsOpen}
|
||||
onOpenChange={setMergedIsOpen}
|
||||
placement={placement}
|
||||
offset={offset}
|
||||
triggerPopupSameWidth={triggerPopupSameWidth}
|
||||
>
|
||||
<PortalToFollowElemTrigger
|
||||
onClick={() => {
|
||||
if (triggerOnlyOpenModal) {
|
||||
handleOpenModal()
|
||||
return
|
||||
}
|
||||
|
||||
setMergedIsOpen(!mergedIsOpen)
|
||||
}}
|
||||
asChild
|
||||
<PopoverTrigger
|
||||
render={<div className={triggerPopupSameWidth ? 'w-full' : 'inline-block'}>{renderTrigger(mergedIsOpen)}</div>}
|
||||
onClick={handleTriggerClick}
|
||||
/>
|
||||
<PopoverContent
|
||||
placement={placement}
|
||||
sideOffset={sideOffset}
|
||||
alignOffset={alignOffset}
|
||||
popupProps={popupProps}
|
||||
popupClassName="border-0 bg-transparent p-0 shadow-none backdrop-blur-none"
|
||||
>
|
||||
{renderTrigger(mergedIsOpen)}
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-1002">
|
||||
<div className={cn(
|
||||
'w-[360px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-[5px]',
|
||||
popupClassName,
|
||||
@ -186,15 +199,15 @@ const Authorized = ({
|
||||
}
|
||||
<div className="max-h-[304px] overflow-y-auto">
|
||||
{
|
||||
items.map((item, index) => (
|
||||
<Fragment key={index}>
|
||||
items.map(item => (
|
||||
<Fragment key={item.model?.model ?? item.title ?? item.credentials.map(credential => credential.credential_id).join('-')}>
|
||||
<AuthorizedItem
|
||||
provider={provider}
|
||||
title={item.title}
|
||||
model={item.model}
|
||||
credentials={item.credentials}
|
||||
disabled={disabled}
|
||||
onDelete={openConfirmDelete}
|
||||
onDelete={handleDelete}
|
||||
disableDeleteButShowAction={disableDeleteButShowAction}
|
||||
disableDeleteTip={disableDeleteTip}
|
||||
onEdit={handleEdit}
|
||||
@ -204,7 +217,7 @@ const Authorized = ({
|
||||
showModelTitle={showModelTitle}
|
||||
/>
|
||||
{
|
||||
index !== items.length - 1 && (
|
||||
item !== items[items.length - 1] && (
|
||||
<div className="h-px bg-divider-subtle"></div>
|
||||
)
|
||||
}
|
||||
@ -245,8 +258,8 @@ const Authorized = ({
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<AlertDialog open={!!deleteCredentialId} onOpenChange={open => !open && closeConfirmDelete()}>
|
||||
<AlertDialogContent>
|
||||
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
|
||||
|
||||
@ -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 (
|
||||
<div data-testid="portal-elem" data-open={open}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
PortalToFollowElemTrigger: ({ children, onClick, className }: {
|
||||
children: React.ReactNode
|
||||
onClick: () => void
|
||||
PopoverTrigger: ({ children, render, className }: {
|
||||
children?: React.ReactNode
|
||||
render?: React.ReactNode
|
||||
className?: string
|
||||
}) => (
|
||||
<div data-testid="portal-trigger" onClick={onClick} className={className}>
|
||||
{children}
|
||||
<div
|
||||
data-testid="portal-trigger"
|
||||
onClick={() => mockPopoverOnOpenChange?.(!mockPortalOpenState)}
|
||||
className={className}
|
||||
>
|
||||
{render ?? children}
|
||||
</div>
|
||||
),
|
||||
PortalToFollowElemContent: ({ children, className }: {
|
||||
PopoverContent: ({ children, className }: {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}) => {
|
||||
|
||||
@ -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 }) => <div>{children}</div>,
|
||||
PortalToFollowElemTrigger: ({
|
||||
children,
|
||||
onClick,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
onClick: () => void
|
||||
}) => <button data-testid="portal-trigger" onClick={onClick}>{children}</button>,
|
||||
PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) => <div data-testid="portal-content">{children}</div>,
|
||||
}
|
||||
})
|
||||
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(<TagsFilter tags={[]} onTagsChange={vi.fn()} />)
|
||||
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(<TagsFilter tags={['agent']} onTagsChange={onTagsChange} />)
|
||||
|
||||
fireEvent.click(screen.getByText('Agent'))
|
||||
fireEvent.click(within(ensurePopoverOpen()).getByText('Agent'))
|
||||
expect(onTagsChange).toHaveBeenCalledWith([])
|
||||
|
||||
rerender(<TagsFilter tags={['agent']} onTagsChange={onTagsChange} />)
|
||||
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(<TagsFilter tags={[]} onTagsChange={vi.fn()} />)
|
||||
fireEvent.click(screen.getByTestId('popover-trigger'))
|
||||
|
||||
expect(screen.getByLabelText('tags-search')).toHaveAttribute('placeholder', '')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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 (
|
||||
<PortalToFollowElem
|
||||
placement="bottom-start"
|
||||
offset={{
|
||||
mainAxis: 4,
|
||||
crossAxis: -6,
|
||||
}}
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<PortalToFollowElemTrigger
|
||||
className="shrink-0"
|
||||
onClick={() => setOpen(v => !v)}
|
||||
<PopoverTrigger
|
||||
nativeButton={false}
|
||||
render={(
|
||||
<div className="shrink-0">
|
||||
{
|
||||
usedInMarketplace && (
|
||||
<MarketplaceTrigger
|
||||
selectedTagsLength={selectedTagsLength}
|
||||
open={open}
|
||||
tags={tags}
|
||||
tagsMap={tagsMap}
|
||||
onTagsChange={onTagsChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
!usedInMarketplace && (
|
||||
<ToolSelectorTrigger
|
||||
selectedTagsLength={selectedTagsLength}
|
||||
open={open}
|
||||
tags={tags}
|
||||
tagsMap={tagsMap}
|
||||
onTagsChange={onTagsChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<PopoverContent
|
||||
placement="bottom-start"
|
||||
sideOffset={4}
|
||||
alignOffset={-6}
|
||||
popupClassName="border-none bg-transparent shadow-none"
|
||||
>
|
||||
{
|
||||
usedInMarketplace && (
|
||||
<MarketplaceTrigger
|
||||
selectedTagsLength={selectedTagsLength}
|
||||
open={open}
|
||||
tags={tags}
|
||||
tagsMap={tagsMap}
|
||||
onTagsChange={onTagsChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
!usedInMarketplace && (
|
||||
<ToolSelectorTrigger
|
||||
selectedTagsLength={selectedTagsLength}
|
||||
open={open}
|
||||
tags={tags}
|
||||
tagsMap={tagsMap}
|
||||
onTagsChange={onTagsChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-1000">
|
||||
<div className="w-[240px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-xs">
|
||||
<div className="p-2 pb-1">
|
||||
<Input
|
||||
@ -103,8 +105,8 @@ const TagsFilter = ({
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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: () => ({
|
||||
|
||||
@ -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<string | null>(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<Record<string, any> | null>(null)
|
||||
const handleEdit = useCallback((id: string, values: Record<string, any>) => {
|
||||
const [editValues, setEditValues] = useState<Record<string, unknown> | null>(null)
|
||||
const handleEdit = useCallback((id: string, values: Record<string, unknown>) => {
|
||||
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 (
|
||||
<>
|
||||
<PortalToFollowElem
|
||||
<Popover
|
||||
open={mergedIsOpen}
|
||||
onOpenChange={setMergedIsOpen}
|
||||
placement={placement}
|
||||
offset={offset}
|
||||
triggerPopupSameWidth={triggerPopupSameWidth}
|
||||
>
|
||||
<PortalToFollowElemTrigger
|
||||
onClick={() => setMergedIsOpen(!mergedIsOpen)}
|
||||
asChild
|
||||
>
|
||||
{
|
||||
renderTrigger
|
||||
? renderTrigger(mergedIsOpen)
|
||||
: (
|
||||
<Button
|
||||
className={cn(
|
||||
'w-full',
|
||||
isOpen && 'bg-components-button-secondary-bg-hover',
|
||||
)}
|
||||
>
|
||||
<Indicator className="mr-2" color={unavailableCredential ? 'gray' : 'green'} />
|
||||
{credentials.length}
|
||||
<PopoverTrigger
|
||||
render={(
|
||||
<div className={triggerPopupSameWidth ? 'w-full' : 'inline-block'}>
|
||||
{
|
||||
renderTrigger
|
||||
? renderTrigger(mergedIsOpen)
|
||||
: (
|
||||
<Button
|
||||
className={cn(
|
||||
'w-full',
|
||||
mergedIsOpen && 'bg-components-button-secondary-bg-hover',
|
||||
)}
|
||||
>
|
||||
<Indicator className="mr-2" color={unavailableCredential ? 'gray' : 'green'} />
|
||||
{credentials.length}
|
||||
|
||||
{
|
||||
credentials.length > 1
|
||||
? t('auth.authorizations', { ns: 'plugin' })
|
||||
: t('auth.authorization', { ns: 'plugin' })
|
||||
}
|
||||
{
|
||||
!!unavailableCredentials.length && (
|
||||
` (${unavailableCredentials.length} ${t('auth.unavailable', { ns: 'plugin' })})`
|
||||
)
|
||||
}
|
||||
<RiArrowDownSLine className="ml-0.5 h-4 w-4" />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-100">
|
||||
{
|
||||
credentials.length > 1
|
||||
? t('auth.authorizations', { ns: 'plugin' })
|
||||
: t('auth.authorization', { ns: 'plugin' })
|
||||
}
|
||||
{
|
||||
!!unavailableCredentials.length && (
|
||||
` (${unavailableCredentials.length} ${t('auth.unavailable', { ns: 'plugin' })})`
|
||||
)
|
||||
}
|
||||
<RiArrowDownSLine className="ml-0.5 h-4 w-4" />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<PopoverContent
|
||||
placement={placement}
|
||||
sideOffset={sideOffset}
|
||||
alignOffset={alignOffset}
|
||||
popupProps={popupProps}
|
||||
popupClassName="border-0 bg-transparent p-0 shadow-none backdrop-blur-none"
|
||||
>
|
||||
<div className={cn(
|
||||
'max-h-[360px] overflow-y-auto rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg',
|
||||
popupClassName,
|
||||
@ -323,8 +336,8 @@ const Authorized = ({
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<AlertDialog open={!!deleteCredentialId} onOpenChange={open => !open && closeConfirm()}>
|
||||
<AlertDialogContent>
|
||||
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
|
||||
|
||||
@ -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}
|
||||
</div>
|
||||
),
|
||||
PortalToFollowElemTrigger: ({
|
||||
PopoverTrigger: ({
|
||||
children,
|
||||
render,
|
||||
onClick,
|
||||
}: {
|
||||
children: ReactNode
|
||||
render?: ReactNode
|
||||
onClick?: () => void
|
||||
}) => (
|
||||
<button data-testid="picker-trigger" onClick={onClick}>
|
||||
{children}
|
||||
{render ?? children}
|
||||
</button>
|
||||
),
|
||||
PortalToFollowElemContent: ({ children }: { children: ReactNode }) => (
|
||||
PopoverContent: ({ children }: { children: ReactNode }) => (
|
||||
<div data-testid="portal-content">{children}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
@ -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<boolean> | 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
|
||||
}) => (
|
||||
<div data-testid="portal-trigger" onClick={onClick} className={className}>
|
||||
{children}
|
||||
{render ?? children}
|
||||
</div>
|
||||
),
|
||||
PortalToFollowElemContent: ({ children, className }: { children: ReactNode, className?: string }) => {
|
||||
PopoverContent: ({ children, className }: { children: ReactNode, className?: string }) => {
|
||||
const Context = getContext()
|
||||
const isOpen = React.useContext(Context)
|
||||
if (!isOpen)
|
||||
|
||||
@ -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<Props> = ({
|
||||
}
|
||||
}
|
||||
|
||||
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<HTMLElement>) => {
|
||||
event.preventDefault()
|
||||
if (disabled || isShow)
|
||||
return
|
||||
|
||||
onShowChange(true)
|
||||
}
|
||||
}, [disabled, isShow, onShowChange])
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
placement={placement}
|
||||
offset={offset}
|
||||
<Popover
|
||||
open={isShow}
|
||||
onOpenChange={onShowChange}
|
||||
>
|
||||
<PortalToFollowElemTrigger
|
||||
<PopoverTrigger
|
||||
render={<div>{trigger}</div>}
|
||||
onClick={handleTriggerClick}
|
||||
>
|
||||
{trigger}
|
||||
</PortalToFollowElemTrigger>
|
||||
/>
|
||||
|
||||
<PortalToFollowElemContent className="z-1000">
|
||||
<PopoverContent
|
||||
placement={placement}
|
||||
sideOffset={sideOffset}
|
||||
alignOffset={alignOffset}
|
||||
popupClassName="border-0 bg-transparent p-0 shadow-none backdrop-blur-none"
|
||||
>
|
||||
<div className="relative flex max-h-[400px] min-h-20 w-[356px] flex-col rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-xs">
|
||||
<div className="p-2 pb-1">
|
||||
<Input
|
||||
@ -219,8 +226,8 @@ const AppPicker: FC<Props> = ({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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<Props> = ({
|
||||
}, [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<Props> = ({
|
||||
await fetchNextPage()
|
||||
}, [fetchNextPage, hasMore, isFetchingNextPage])
|
||||
|
||||
const handleTriggerClick = () => {
|
||||
if (disabled)
|
||||
const handleTriggerClick = useCallback((event: React.MouseEvent<HTMLElement>) => {
|
||||
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<Props> = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
<PortalToFollowElem
|
||||
placement={placement}
|
||||
offset={offset}
|
||||
<Popover
|
||||
open={isShow}
|
||||
onOpenChange={setIsShow}
|
||||
>
|
||||
<PortalToFollowElemTrigger
|
||||
className="w-full"
|
||||
<PopoverTrigger
|
||||
render={(
|
||||
<div className="w-full">
|
||||
<AppTrigger
|
||||
open={isShow}
|
||||
appDetail={currentAppInfo}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
onClick={handleTriggerClick}
|
||||
/>
|
||||
<PopoverContent
|
||||
placement={placement}
|
||||
sideOffset={sideOffset}
|
||||
alignOffset={alignOffset}
|
||||
popupClassName="border-0 bg-transparent p-0 shadow-none backdrop-blur-none"
|
||||
>
|
||||
<AppTrigger
|
||||
open={isShow}
|
||||
appDetail={currentAppInfo}
|
||||
/>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-1000">
|
||||
<div className="relative min-h-20 w-[389px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-xs">
|
||||
<div className="flex flex-col gap-1 px-4 py-3">
|
||||
<div className="flex h-6 items-center system-sm-semibold text-text-secondary">{t('appSelector.label', { ns: 'app' })}</div>
|
||||
@ -193,8 +203,8 @@ const AppSelector: FC<Props> = ({
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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(<LogViewer logs={[log]} />)
|
||||
|
||||
|
||||
@ -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}
|
||||
</div>
|
||||
),
|
||||
PortalToFollowElemTrigger: ({
|
||||
PopoverTrigger: ({
|
||||
children,
|
||||
render,
|
||||
onClick,
|
||||
}: {
|
||||
children: ReactNode
|
||||
render?: ReactNode
|
||||
onClick?: () => void
|
||||
}) => (
|
||||
<div data-testid="portal-trigger" onClick={onClick}>
|
||||
{children}
|
||||
{render ?? children}
|
||||
</div>
|
||||
),
|
||||
PortalToFollowElemContent: ({ children }: { children: ReactNode }) => (
|
||||
PopoverContent: ({ children }: { children: ReactNode }) => (
|
||||
<div data-testid="portal-content">{children}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
@ -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 }) => (
|
||||
<button data-testid="code-editor" onClick={() => onChange('{\"foo\":\"bar\"}')}>
|
||||
<button data-testid="code-editor" onClick={() => onChange('{"foo":"bar"}')}>
|
||||
Update JSON
|
||||
</button>
|
||||
),
|
||||
|
||||
@ -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<Props> = ({
|
||||
getSettingsValue,
|
||||
} = state
|
||||
|
||||
const handleTriggerClick = () => {
|
||||
const handleTriggerClick = (event: React.MouseEvent<HTMLElement>) => {
|
||||
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<Props> = ({
|
||||
)
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
placement={placement}
|
||||
offset={offset}
|
||||
<Popover
|
||||
open={portalOpen}
|
||||
onOpenChange={onPortalOpenChange}
|
||||
>
|
||||
<PortalToFollowElemTrigger
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
if (!currentProvider || !currentTool)
|
||||
return
|
||||
handleTriggerClick()
|
||||
}}
|
||||
<PopoverTrigger
|
||||
render={(
|
||||
<div className="w-full">
|
||||
{trigger}
|
||||
|
||||
{/* Default trigger - no value */}
|
||||
{!trigger && !value?.provider_name && (
|
||||
<ToolTrigger
|
||||
isConfigure
|
||||
open={isShow}
|
||||
value={value}
|
||||
provider={currentProvider}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Default trigger - with value */}
|
||||
{!trigger && value?.provider_name && (
|
||||
<ToolItem
|
||||
open={isShow}
|
||||
icon={currentProvider?.icon || manifestIcon}
|
||||
isMCPTool={currentProvider?.type === CollectionType.mcp}
|
||||
providerName={value.provider_name}
|
||||
providerShowName={value.provider_show_name}
|
||||
toolLabel={value.tool_label || value.tool_name}
|
||||
showSwitch={supportEnableSwitch}
|
||||
switchValue={value.enabled}
|
||||
onSwitchChange={handleEnabledChange}
|
||||
onDelete={onDelete}
|
||||
noAuth={currentProvider && currentTool && !currentProvider.is_team_authorization}
|
||||
uninstalled={!currentProvider && inMarketPlace}
|
||||
versionMismatch={currentProvider && inMarketPlace && !currentTool}
|
||||
installInfo={manifest?.latest_package_identifier}
|
||||
onInstall={handleInstall}
|
||||
isError={(!currentProvider || !currentTool) && !inMarketPlace}
|
||||
errorTip={renderErrorTip()}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
onClick={handleTriggerClick}
|
||||
/>
|
||||
|
||||
<PopoverContent
|
||||
placement={placement}
|
||||
sideOffset={sideOffset}
|
||||
alignOffset={alignOffset}
|
||||
popupClassName="border-0 bg-transparent p-0 shadow-none backdrop-blur-none"
|
||||
>
|
||||
{trigger}
|
||||
|
||||
{/* Default trigger - no value */}
|
||||
{!trigger && !value?.provider_name && (
|
||||
<ToolTrigger
|
||||
isConfigure
|
||||
open={isShow}
|
||||
value={value}
|
||||
provider={currentProvider}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Default trigger - with value */}
|
||||
{!trigger && value?.provider_name && (
|
||||
<ToolItem
|
||||
open={isShow}
|
||||
icon={currentProvider?.icon || manifestIcon}
|
||||
isMCPTool={currentProvider?.type === CollectionType.mcp}
|
||||
providerName={value.provider_name}
|
||||
providerShowName={value.provider_show_name}
|
||||
toolLabel={value.tool_label || value.tool_name}
|
||||
showSwitch={supportEnableSwitch}
|
||||
switchValue={value.enabled}
|
||||
onSwitchChange={handleEnabledChange}
|
||||
onDelete={onDelete}
|
||||
noAuth={currentProvider && currentTool && !currentProvider.is_team_authorization}
|
||||
uninstalled={!currentProvider && inMarketPlace}
|
||||
versionMismatch={currentProvider && inMarketPlace && !currentTool}
|
||||
installInfo={manifest?.latest_package_identifier}
|
||||
onInstall={handleInstall}
|
||||
isError={(!currentProvider || !currentTool) && !inMarketPlace}
|
||||
errorTip={renderErrorTip()}
|
||||
/>
|
||||
)}
|
||||
</PortalToFollowElemTrigger>
|
||||
|
||||
<PortalToFollowElemContent className="z-10">
|
||||
<div className={cn(
|
||||
'relative max-h-[642px] min-h-20 w-[361px] rounded-xl',
|
||||
'border-[0.5px] border-components-panel-border bg-components-panel-bg-blur',
|
||||
@ -239,8 +246,8 @@ const ToolSelector: FC<Props> = ({
|
||||
onParamsFormChange={handleParamsFormChange}
|
||||
/>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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 }) => (
|
||||
<div data-testid="portal" data-open={open}>{children}</div>
|
||||
),
|
||||
PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick: () => void }) => (
|
||||
<div data-testid="portal-trigger" onClick={onClick}>{children}</div>
|
||||
),
|
||||
PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="portal-content">{children}</div>
|
||||
),
|
||||
}))
|
||||
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(<CategoriesFilter value={['tool']} onChange={mockOnChange} />)
|
||||
|
||||
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(<CategoriesFilter value={[]} onChange={vi.fn()} />)
|
||||
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(<CategoriesFilter value={[]} onChange={mockOnChange} />)
|
||||
|
||||
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(<CategoriesFilter value={['tool']} onChange={mockOnChange} />)
|
||||
|
||||
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(<CategoriesFilter value={[]} onChange={vi.fn()} />)
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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 <div data-testid="portal-container" data-open={open}>{children}</div>
|
||||
},
|
||||
PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick: () => void }) => (
|
||||
<div data-testid="portal-trigger" onClick={onClick}>{children}</div>
|
||||
const MockPopoverContext = createContext<MockPopoverContextValue>({
|
||||
open: false,
|
||||
})
|
||||
|
||||
vi.mock('@langgenius/dify-ui/popover', () => ({
|
||||
Popover: ({ children, open, onOpenChange }: {
|
||||
children: React.ReactNode
|
||||
open: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}) => (
|
||||
<MockPopoverContext.Provider value={{ open, onOpenChange }}>
|
||||
<div data-testid="portal-container" data-open={open}>{children}</div>
|
||||
</MockPopoverContext.Provider>
|
||||
),
|
||||
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 (
|
||||
<div
|
||||
data-testid="portal-trigger"
|
||||
onClick={() => onOpenChange?.(!open)}
|
||||
className={className}
|
||||
>
|
||||
{render ?? children}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
PopoverContent: ({ children, className }: {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}) => {
|
||||
const { open } = useContext(MockPopoverContext)
|
||||
if (!open)
|
||||
return null
|
||||
return <div data-testid="portal-content" className={className}>{children}</div>
|
||||
},
|
||||
@ -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', () => {
|
||||
|
||||
@ -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 <div>{children}</div>
|
||||
},
|
||||
PortalToFollowElemTrigger: ({
|
||||
children,
|
||||
onClick,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
onClick: () => void
|
||||
}) => <button data-testid="trigger" onClick={onClick}>{children}</button>,
|
||||
PortalToFollowElemContent: ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) => portalOpen ? <div data-testid="portal-content">{children}</div> : 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(<TagFilter value={[]} onChange={vi.fn()} />)
|
||||
|
||||
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(<TagFilter value={['agent']} onChange={onChange} />)
|
||||
|
||||
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(<TagFilter value={['agent']} onChange={onChange} />)
|
||||
|
||||
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(<TagFilter value={['agent']} onChange={onChange} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('popover-trigger'))
|
||||
fireEvent.click(within(screen.getByTestId('popover-content')).getByText('Agent'))
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith([])
|
||||
})
|
||||
})
|
||||
|
||||
@ -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 (
|
||||
<PortalToFollowElem
|
||||
placement="bottom-start"
|
||||
offset={{
|
||||
mainAxis: 4,
|
||||
}}
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={() => setOpen(v => !v)}>
|
||||
<div className={cn(
|
||||
'flex h-8 cursor-pointer items-center rounded-lg bg-components-input-bg-normal px-2 py-1 text-text-tertiary hover:bg-state-base-hover-alt',
|
||||
selectedTagsLength && 'text-text-secondary',
|
||||
open && 'bg-state-base-hover',
|
||||
)}
|
||||
>
|
||||
<PopoverTrigger
|
||||
nativeButton={false}
|
||||
render={(
|
||||
<div className={cn(
|
||||
'flex items-center p-1 system-sm-medium',
|
||||
'flex h-8 cursor-pointer items-center rounded-lg bg-components-input-bg-normal px-2 py-1 text-text-tertiary hover:bg-state-base-hover-alt',
|
||||
selectedTagsLength && 'text-text-secondary',
|
||||
open && 'bg-state-base-hover',
|
||||
)}
|
||||
>
|
||||
<div className={cn(
|
||||
'flex items-center p-1 system-sm-medium',
|
||||
)}
|
||||
>
|
||||
{
|
||||
!selectedTagsLength && t('allCategories', { ns: 'plugin' })
|
||||
}
|
||||
{
|
||||
!!selectedTagsLength && value.map(val => categoriesMap[val]!.label).slice(0, 2).join(',')
|
||||
}
|
||||
{
|
||||
selectedTagsLength > 2 && (
|
||||
<div className="ml-1 system-xs-medium text-text-tertiary">
|
||||
+
|
||||
{selectedTagsLength - 2}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
{
|
||||
!selectedTagsLength && t('allCategories', { ns: 'plugin' })
|
||||
!!selectedTagsLength && (
|
||||
<RiCloseCircleFill
|
||||
className="h-4 w-4 cursor-pointer text-text-quaternary"
|
||||
onClick={
|
||||
(e) => {
|
||||
e.stopPropagation()
|
||||
onChange([])
|
||||
}
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
!!selectedTagsLength && value.map(val => categoriesMap[val]!.label).slice(0, 2).join(',')
|
||||
}
|
||||
{
|
||||
selectedTagsLength > 2 && (
|
||||
<div className="ml-1 system-xs-medium text-text-tertiary">
|
||||
+
|
||||
{selectedTagsLength - 2}
|
||||
</div>
|
||||
!selectedTagsLength && (
|
||||
<RiArrowDownSLine className="h-4 w-4" />
|
||||
)
|
||||
}
|
||||
</div>
|
||||
{
|
||||
!!selectedTagsLength && (
|
||||
<RiCloseCircleFill
|
||||
className="h-4 w-4 cursor-pointer text-text-quaternary"
|
||||
onClick={
|
||||
(e) => {
|
||||
e.stopPropagation()
|
||||
onChange([])
|
||||
}
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
!selectedTagsLength && (
|
||||
<RiArrowDownSLine className="h-4 w-4" />
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-10">
|
||||
)}
|
||||
/>
|
||||
<PopoverContent
|
||||
placement="bottom-start"
|
||||
sideOffset={4}
|
||||
popupClassName="border-none bg-transparent shadow-none"
|
||||
>
|
||||
<div className="w-[240px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-xs">
|
||||
<div className="p-2 pb-1">
|
||||
<Input
|
||||
@ -122,8 +125,8 @@ const CategoriesFilter = ({
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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 (
|
||||
<PortalToFollowElem
|
||||
placement="bottom-start"
|
||||
offset={{
|
||||
mainAxis: 4,
|
||||
}}
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={() => setOpen(v => !v)}>
|
||||
<div className={cn(
|
||||
'flex h-8 cursor-pointer items-center rounded-lg bg-components-input-bg-normal px-2 py-1 text-text-tertiary select-none hover:bg-state-base-hover-alt',
|
||||
selectedTagsLength && 'text-text-secondary',
|
||||
open && 'bg-state-base-hover',
|
||||
)}
|
||||
>
|
||||
<PopoverTrigger
|
||||
nativeButton={false}
|
||||
render={(
|
||||
<div className={cn(
|
||||
'flex items-center p-1 system-sm-medium',
|
||||
'flex h-8 cursor-pointer items-center rounded-lg bg-components-input-bg-normal px-2 py-1 text-text-tertiary select-none hover:bg-state-base-hover-alt',
|
||||
selectedTagsLength && 'text-text-secondary',
|
||||
open && 'bg-state-base-hover',
|
||||
)}
|
||||
>
|
||||
<div className={cn(
|
||||
'flex items-center p-1 system-sm-medium',
|
||||
)}
|
||||
>
|
||||
{
|
||||
!selectedTagsLength && t('allTags', { ns: 'pluginTags' })
|
||||
}
|
||||
{
|
||||
!!selectedTagsLength && value.map(val => getTagLabel(val)).slice(0, 2).join(',')
|
||||
}
|
||||
{
|
||||
selectedTagsLength > 2 && (
|
||||
<div className="ml-1 system-xs-medium text-text-tertiary">
|
||||
+
|
||||
{selectedTagsLength - 2}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
{
|
||||
!selectedTagsLength && t('allTags', { ns: 'pluginTags' })
|
||||
!!selectedTagsLength && (
|
||||
<RiCloseCircleFill
|
||||
className="h-4 w-4 cursor-pointer text-text-quaternary"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onChange([])
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
!!selectedTagsLength && value.map(val => getTagLabel(val)).slice(0, 2).join(',')
|
||||
}
|
||||
{
|
||||
selectedTagsLength > 2 && (
|
||||
<div className="ml-1 system-xs-medium text-text-tertiary">
|
||||
+
|
||||
{selectedTagsLength - 2}
|
||||
</div>
|
||||
!selectedTagsLength && (
|
||||
<RiArrowDownSLine className="h-4 w-4" />
|
||||
)
|
||||
}
|
||||
</div>
|
||||
{
|
||||
!!selectedTagsLength && (
|
||||
<RiCloseCircleFill
|
||||
className="h-4 w-4 cursor-pointer text-text-quaternary"
|
||||
onClick={() => onChange([])}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
!selectedTagsLength && (
|
||||
<RiArrowDownSLine className="h-4 w-4" />
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-10">
|
||||
)}
|
||||
/>
|
||||
<PopoverContent
|
||||
placement="bottom-start"
|
||||
sideOffset={4}
|
||||
popupClassName="border-none bg-transparent shadow-none"
|
||||
>
|
||||
<div className="w-[240px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-xs">
|
||||
<div className="p-2 pb-1">
|
||||
<Input
|
||||
@ -117,8 +123,8 @@ const TagsFilter = ({
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 <div data-testid="portal-elem" data-open={open}>{children}</div>
|
||||
},
|
||||
PortalToFollowElemTrigger: ({ children, onClick }: PropsWithChildren<{ onClick?: () => void }>) => (
|
||||
<div data-testid="portal-trigger" onClick={onClick}>{children}</div>
|
||||
),
|
||||
PortalToFollowElemContent: ({ children }: PropsWithChildren) => {
|
||||
if (!portalOpenState)
|
||||
return null
|
||||
return <div data-testid="portal-content">{children}</div>
|
||||
},
|
||||
}))
|
||||
|
||||
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(<Publisher />)
|
||||
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(<Publisher />)
|
||||
|
||||
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(<Publisher />)
|
||||
|
||||
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,
|
||||
|
||||
@ -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<string, unknown>) => (
|
||||
<button
|
||||
onClick={onClick as (() => void) | undefined}
|
||||
disabled={disabled as boolean | undefined}
|
||||
data-variant={variant as string | undefined}
|
||||
className={className as string | undefined}
|
||||
>
|
||||
{children as React.ReactNode}
|
||||
</button>
|
||||
),
|
||||
}))
|
||||
vi.mock('@langgenius/dify-ui/alert-dialog', () => ({
|
||||
AlertDialog: ({ children, open, onOpenChange }: { children: React.ReactNode, open?: boolean, onOpenChange?: (open: boolean) => void }) => (
|
||||
open
|
||||
? (
|
||||
<div role="alertdialog">
|
||||
{children}
|
||||
<button data-testid="alert-dialog-close" onClick={() => onOpenChange?.(false)}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
: null
|
||||
),
|
||||
AlertDialogActions: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
AlertDialogCancelButton: ({ children }: { children: React.ReactNode }) => <button>{children}</button>,
|
||||
AlertDialogConfirmButton: ({ children, onClick, disabled }: Record<string, unknown>) => <button onClick={onClick as (() => void) | undefined} disabled={disabled as boolean | undefined}>{children as React.ReactNode}</button>,
|
||||
AlertDialogContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
AlertDialogDescription: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
AlertDialogTitle: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}))
|
||||
|
||||
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: <T,>(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(<Publisher />)
|
||||
|
||||
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(<Publisher />)
|
||||
|
||||
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()
|
||||
|
||||
@ -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
|
||||
? (
|
||||
<div role="alertdialog">
|
||||
{children}
|
||||
<button data-testid="alert-dialog-close" onClick={() => onOpenChange?.(false)}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
: null
|
||||
),
|
||||
AlertDialogActions: ({ children }: { children?: React.ReactNode }) => <div>{children}</div>,
|
||||
AlertDialogCancelButton: ({ children }: { children?: React.ReactNode }) => <button>{children}</button>,
|
||||
AlertDialogConfirmButton: ({ children, onClick, disabled }: {
|
||||
children?: React.ReactNode
|
||||
onClick?: () => void
|
||||
disabled?: boolean
|
||||
}) => <button onClick={onClick} disabled={disabled}>{children}</button>,
|
||||
AlertDialogContent: ({ children }: { children?: React.ReactNode }) => <div>{children}</div>,
|
||||
AlertDialogDescription: ({ children }: { children?: React.ReactNode }) => <div>{children}</div>,
|
||||
AlertDialogTitle: ({ children }: { children?: React.ReactNode }) => <div>{children}</div>,
|
||||
}))
|
||||
|
||||
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: <T,>(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(<Popup />)
|
||||
const onRequestClose = vi.fn()
|
||||
render(<Popup onRequestClose={onRequestClose} />)
|
||||
|
||||
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(<Popup onRequestClose={onRequestClose} />)
|
||||
|
||||
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(<Popup />)
|
||||
|
||||
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(<Popup />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('publish-as-confirm'))
|
||||
|
||||
expect(mockPublishAsCustomizedPipeline).toHaveBeenCalledWith({
|
||||
pipelineId: '',
|
||||
name: 'My Pipeline',
|
||||
icon_info: { icon_type: 'emoji' },
|
||||
description: 'desc',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Time formatting', () => {
|
||||
|
||||
@ -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 (
|
||||
<PortalToFollowElem
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement="bottom-end"
|
||||
offset={{
|
||||
mainAxis: 4,
|
||||
crossAxis: 40,
|
||||
}}
|
||||
onOpenChange={handleOpenChange}
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={() => handleOpenChange(!open)}>
|
||||
<Button
|
||||
className="px-2"
|
||||
variant="primary"
|
||||
>
|
||||
<span className="pl-1">{t('common.publish', { ns: 'workflow' })}</span>
|
||||
<RiArrowDownSLine className="h-4 w-4" />
|
||||
</Button>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-11">
|
||||
<Popup />
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
<PopoverTrigger
|
||||
nativeButton
|
||||
render={(
|
||||
<Button
|
||||
className="px-2"
|
||||
variant="primary"
|
||||
>
|
||||
<span className="pl-1">{t('common.publish', { ns: 'workflow' })}</span>
|
||||
<RiArrowDownSLine className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
<PopoverContent
|
||||
placement="bottom-end"
|
||||
sideOffset={4}
|
||||
alignOffset={40}
|
||||
popupClassName="border-none bg-transparent shadow-none"
|
||||
>
|
||||
<Popup onRequestClose={() => handleOpenChange(false)} />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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 (
|
||||
<div className={cn('rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xl shadow-shadow-shadow-5', isAllowPublishAsCustomKnowledgePipelineTemplate ? 'w-[360px]' : 'w-[400px]')}>
|
||||
<div className="p-4 pt-3">
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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(
|
||||
<NodeSelector
|
||||
disabled
|
||||
onOpenChange={onOpenChange}
|
||||
onSelect={vi.fn()}
|
||||
blocks={[createBlock(BlockEnum.LLM, 'LLM')]}
|
||||
availableBlocksTypes={[BlockEnum.LLM]}
|
||||
trigger={open => (
|
||||
<button type="button">
|
||||
{open ? 'selector-open' : 'selector-closed'}
|
||||
</button>
|
||||
)}
|
||||
/>,
|
||||
)
|
||||
|
||||
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(
|
||||
<NodeSelector
|
||||
asChild
|
||||
onSelect={vi.fn()}
|
||||
blocks={[createBlock(BlockEnum.LLM, 'LLM')]}
|
||||
availableBlocksTypes={[BlockEnum.LLM]}
|
||||
trigger={() => (
|
||||
<button type="button" onClick={onTriggerClick}>
|
||||
open-selector
|
||||
</button>
|
||||
)}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'open-selector' }))
|
||||
|
||||
expect(onTriggerClick).toHaveBeenCalledTimes(1)
|
||||
expect(screen.getByPlaceholderText('workflow.tabs.searchBlock')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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<NodeSelectorProps> = ({
|
||||
})
|
||||
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<NodeSelectorProps> = ({
|
||||
|
||||
if (onOpenChange)
|
||||
onOpenChange(newOpen)
|
||||
}, [activeTab, onOpenChange])
|
||||
const handleTrigger = useCallback<MouseEventHandler<HTMLDivElement>>((e) => {
|
||||
if (disabled)
|
||||
return
|
||||
}, [disabled, onOpenChange])
|
||||
const handleTrigger = useCallback<MouseEventHandler<HTMLElement>>((e) => {
|
||||
e.stopPropagation()
|
||||
handleOpenChange(!open)
|
||||
}, [handleOpenChange, open, disabled])
|
||||
}, [])
|
||||
|
||||
const handleSelect = useCallback<OnSelectBlock>((type, pluginDefaultValue) => {
|
||||
handleOpenChange(false)
|
||||
@ -203,36 +205,61 @@ const NodeSelector: FC<NodeSelectorProps> = ({
|
||||
return ''
|
||||
}, [activeTab, t])
|
||||
|
||||
const defaultTriggerElement = (
|
||||
<div
|
||||
className={`
|
||||
z-10 flex h-4
|
||||
w-4 cursor-pointer items-center justify-center rounded-full bg-components-button-primary-bg text-text-primary-on-surface hover:bg-components-button-primary-bg-hover
|
||||
${triggerClassName?.(open)}
|
||||
`}
|
||||
style={triggerStyle}
|
||||
>
|
||||
<Plus02 className="h-2.5 w-2.5" />
|
||||
</div>
|
||||
)
|
||||
const triggerElement = trigger ? trigger(open) : defaultTriggerElement
|
||||
const triggerElementProps = React.isValidElement(triggerElement)
|
||||
? (triggerElement.props as {
|
||||
onClick?: MouseEventHandler<HTMLElement>
|
||||
})
|
||||
: null
|
||||
const resolvedTriggerElement = asChild && React.isValidElement(triggerElement)
|
||||
? React.cloneElement(
|
||||
triggerElement as React.ReactElement<{
|
||||
onClick?: MouseEventHandler<HTMLElement>
|
||||
}>,
|
||||
{
|
||||
onClick: (e: ReactMouseEvent<HTMLElement>) => {
|
||||
handleTrigger(e)
|
||||
if (typeof triggerElementProps?.onClick === 'function')
|
||||
triggerElementProps.onClick(e)
|
||||
},
|
||||
},
|
||||
)
|
||||
: (
|
||||
<div className={triggerInnerClassName} onClick={handleTrigger}>
|
||||
{triggerElement}
|
||||
</div>
|
||||
)
|
||||
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 (
|
||||
<PortalToFollowElem
|
||||
placement={placement}
|
||||
offset={offset}
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={handleOpenChange}
|
||||
>
|
||||
<PortalToFollowElemTrigger
|
||||
asChild={asChild}
|
||||
onClick={handleTrigger}
|
||||
className={triggerInnerClassName}
|
||||
<PopoverTrigger nativeButton={nativeButton} render={resolvedTriggerElement as React.ReactElement} />
|
||||
<PopoverContent
|
||||
placement={placement}
|
||||
sideOffset={sideOffset}
|
||||
alignOffset={alignOffset}
|
||||
popupClassName="border-none bg-transparent shadow-none"
|
||||
>
|
||||
{
|
||||
trigger
|
||||
? trigger(open)
|
||||
: (
|
||||
<div
|
||||
className={`
|
||||
z-10 flex h-4
|
||||
w-4 cursor-pointer items-center justify-center rounded-full bg-components-button-primary-bg text-text-primary-on-surface hover:bg-components-button-primary-bg-hover
|
||||
${triggerClassName?.(open)}
|
||||
`}
|
||||
style={triggerStyle}
|
||||
>
|
||||
<Plus02 className="h-2.5 w-2.5" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-1002">
|
||||
<div className={`rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg ${popupClassName}`}>
|
||||
<Tabs
|
||||
tabs={tabs}
|
||||
@ -311,8 +338,8 @@ const NodeSelector: FC<NodeSelectorProps> = ({
|
||||
snippetsElem={<Snippets loading={snippetsLoading} searchText={searchText} />}
|
||||
/>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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<typeof import('../../hooks')>('../../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: () => <div data-testid="loading" />,
|
||||
}))
|
||||
|
||||
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<HTMLButtonElement>) => (
|
||||
<button {...props}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}))
|
||||
|
||||
return {
|
||||
PortalToFollowElem: ({
|
||||
children,
|
||||
open,
|
||||
}: {
|
||||
children?: React.ReactNode
|
||||
open: boolean
|
||||
}) => <PortalContext.Provider value={{ open }}>{children}</PortalContext.Provider>,
|
||||
PortalToFollowElemTrigger: ({
|
||||
children,
|
||||
onClick,
|
||||
}: {
|
||||
children?: React.ReactNode
|
||||
onClick?: () => void
|
||||
}) => <div data-testid="portal-trigger" onClick={onClick}>{children}</div>,
|
||||
PortalToFollowElemContent: ({
|
||||
children,
|
||||
}: {
|
||||
children?: React.ReactNode
|
||||
}) => {
|
||||
const { open } = React.useContext(PortalContext)
|
||||
return open ? <div data-testid="portal-content">{children}</div> : 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<typeof import('../../utils')>('../../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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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 (
|
||||
(
|
||||
<PortalToFollowElem
|
||||
placement={withText ? 'bottom-start' : 'bottom-end'}
|
||||
offset={{
|
||||
mainAxis: 4,
|
||||
crossAxis: withText ? -8 : 10,
|
||||
}}
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={() => setOpen(v => !v)}>
|
||||
{
|
||||
withText && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.showRunHistory', { ns: 'workflow' })}
|
||||
className={cn(
|
||||
'flex h-8 items-center rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-3 shadow-xs',
|
||||
'cursor-pointer text-[13px] font-medium text-components-button-secondary-text hover:bg-components-button-secondary-bg-hover',
|
||||
open && 'bg-components-button-secondary-bg-hover',
|
||||
{withText
|
||||
? (
|
||||
<PopoverTrigger
|
||||
render={(
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.showRunHistory', { ns: 'workflow' })}
|
||||
className={cn(
|
||||
'flex h-8 items-center rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-3 shadow-xs',
|
||||
'cursor-pointer text-[13px] font-medium text-components-button-secondary-text hover:bg-components-button-secondary-bg-hover',
|
||||
open && 'bg-components-button-secondary-bg-hover',
|
||||
)}
|
||||
>
|
||||
<span className="mr-1 i-custom-vender-line-time-clock-play h-4 w-4" />
|
||||
{t('common.showRunHistory', { ns: 'workflow' })}
|
||||
</button>
|
||||
)}
|
||||
>
|
||||
<span className="mr-1 i-custom-vender-line-time-clock-play h-4 w-4" />
|
||||
{t('common.showRunHistory', { ns: 'workflow' })}
|
||||
</button>
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
!withText && (
|
||||
<Tooltip
|
||||
popupContent={t('common.viewRunHistory', { ns: 'workflow' })}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.viewRunHistory', { ns: 'workflow' })}
|
||||
className={cn('group flex h-7 w-7 cursor-pointer items-center justify-center rounded-md hover:bg-state-accent-hover', open && 'bg-state-accent-hover')}
|
||||
onClick={() => {
|
||||
onClearLogAndMessageModal?.()
|
||||
}}
|
||||
: (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={<div className="flex" />}
|
||||
>
|
||||
<span className={cn('i-custom-vender-line-time-clock-play', 'h-4 w-4 group-hover:text-components-button-secondary-accent-text', open ? 'text-components-button-secondary-accent-text' : 'text-components-button-ghost-text')} />
|
||||
</button>
|
||||
<PopoverTrigger
|
||||
render={(
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.viewRunHistory', { ns: 'workflow' })}
|
||||
className={cn('group flex h-7 w-7 cursor-pointer items-center justify-center rounded-md hover:bg-state-accent-hover', open && 'bg-state-accent-hover')}
|
||||
onClick={() => {
|
||||
onClearLogAndMessageModal?.()
|
||||
}}
|
||||
>
|
||||
<span className={cn('i-custom-vender-line-time-clock-play', 'h-4 w-4 group-hover:text-components-button-secondary-accent-text', open ? 'text-components-button-secondary-accent-text' : 'text-components-button-ghost-text')} />
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{t('common.viewRunHistory', { ns: 'workflow' })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-12">
|
||||
)}
|
||||
<PopoverContent
|
||||
placement={withText ? 'bottom-start' : 'bottom-end'}
|
||||
sideOffset={4}
|
||||
alignOffset={withText ? -8 : 10}
|
||||
popupClassName="border-none bg-transparent shadow-none"
|
||||
>
|
||||
<div
|
||||
className="ml-2 flex w-[240px] flex-col overflow-y-auto rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xl"
|
||||
style={{
|
||||
@ -207,8 +219,8 @@ const ViewHistory = ({
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@ -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<WorkflowHistoryState>, index: number) => {
|
||||
const nodes = (state.nodes || store.getState().nodes) || []
|
||||
const nodeId = state?.workflowHistoryEventMeta?.nodeId
|
||||
const filterList = (
|
||||
list: Array<Partial<WorkflowHistoryState> | 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 (
|
||||
(
|
||||
<PortalToFollowElem
|
||||
placement="bottom-end"
|
||||
offset={{
|
||||
mainAxis: 4,
|
||||
crossAxis: 131,
|
||||
}}
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (nodesReadOnly)
|
||||
return
|
||||
setOpen(nextOpen)
|
||||
}}
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={() => !nodesReadOnly && setOpen(v => !v)}>
|
||||
<TipPopup
|
||||
title={t('changeHistory.title', { ns: 'workflow' })}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
cn('flex h-8 w-8 cursor-pointer items-center justify-center rounded-md text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary', open && 'bg-state-accent-active text-text-accent', nodesReadOnly && 'cursor-not-allowed text-text-disabled hover:bg-transparent hover:text-text-disabled')
|
||||
}
|
||||
onClick={() => {
|
||||
if (nodesReadOnly)
|
||||
return
|
||||
setCurrentLogItem()
|
||||
setShowMessageLogModal(false)
|
||||
}}
|
||||
>
|
||||
<RiHistoryLine className="h-4 w-4" />
|
||||
</div>
|
||||
</TipPopup>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-12">
|
||||
<TipPopup
|
||||
title={t('changeHistory.title', { ns: 'workflow' })}
|
||||
>
|
||||
<PopoverTrigger
|
||||
nativeButton={false}
|
||||
render={(
|
||||
<div
|
||||
className={
|
||||
cn('flex h-8 w-8 cursor-pointer items-center justify-center rounded-md text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary', open && 'bg-state-accent-active text-text-accent', nodesReadOnly && 'cursor-not-allowed text-text-disabled hover:bg-transparent hover:text-text-disabled')
|
||||
}
|
||||
onClick={() => {
|
||||
if (nodesReadOnly)
|
||||
return
|
||||
setCurrentLogItem()
|
||||
setShowMessageLogModal(false)
|
||||
}}
|
||||
>
|
||||
<RiHistoryLine className="h-4 w-4" />
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</TipPopup>
|
||||
<PopoverContent
|
||||
placement="bottom-end"
|
||||
sideOffset={4}
|
||||
alignOffset={131}
|
||||
popupClassName="border-none bg-transparent shadow-none"
|
||||
>
|
||||
<div
|
||||
className="ml-2 flex max-w-[360px] min-w-[240px] flex-col overflow-y-auto rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-xl backdrop-blur-[5px]"
|
||||
>
|
||||
@ -293,8 +308,8 @@ const ViewWorkflowHistory = () => {
|
||||
<div className="mb-1 leading-[18px] text-text-tertiary">{t('changeHistory.hintText', { ns: 'workflow' })}</div>
|
||||
</div>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -320,7 +320,7 @@ describe('assigner path', () => {
|
||||
<VarList
|
||||
readonly={false}
|
||||
nodeId="node-1"
|
||||
list={[createOperation({ operation: WriteMode.set, value: '{\"a\":1}' })]}
|
||||
list={[createOperation({ operation: WriteMode.set, value: '{"a":1}' })]}
|
||||
onChange={onChange}
|
||||
filterVar={vi.fn(() => 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()
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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
|
||||
}) => (
|
||||
<OpenContext value={open}>
|
||||
<div data-testid="portal" data-open={String(open)}>{children}</div>
|
||||
</OpenContext>
|
||||
),
|
||||
PortalToFollowElemTrigger: ({
|
||||
children,
|
||||
onClick,
|
||||
}: {
|
||||
children?: React.ReactNode
|
||||
onClick?: () => void
|
||||
}) => (
|
||||
<button type="button" data-testid="portal-trigger" onClick={onClick}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
PortalToFollowElemContent: ({
|
||||
children,
|
||||
}: {
|
||||
children?: React.ReactNode
|
||||
}) => {
|
||||
const open = React.use(OpenContext)
|
||||
return open ? <div data-testid="portal-content">{children}</div> : 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()
|
||||
})
|
||||
|
||||
|
||||
@ -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<Props> = ({
|
||||
}, [data])
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
<Popover
|
||||
open={open && !readonly}
|
||||
onOpenChange={setOpen}
|
||||
placement="bottom-end"
|
||||
offset={{
|
||||
mainAxis: 4,
|
||||
crossAxis: 44,
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (readonly)
|
||||
return
|
||||
setOpen(nextOpen)
|
||||
}}
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={() => !readonly && setOpen(v => !v)}>
|
||||
<div className={cn('flex items-center justify-center rounded-lg bg-components-button-tertiary-bg p-1', !readonly && 'cursor-pointer hover:bg-components-button-tertiary-bg-hover', open && 'bg-components-button-tertiary-bg-hover')}>
|
||||
<Button size="small" className="pointer-events-none px-1" variant={currentStyle}>
|
||||
<RiFontSize className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent style={{ zIndex: 1000 }}>
|
||||
<PopoverTrigger
|
||||
render={(
|
||||
<div className={cn('flex items-center justify-center rounded-lg bg-components-button-tertiary-bg p-1', !readonly && 'cursor-pointer hover:bg-components-button-tertiary-bg-hover', open && 'bg-components-button-tertiary-bg-hover')}>
|
||||
<Button size="small" className="pointer-events-none px-1" variant={currentStyle}>
|
||||
<RiFontSize className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<PopoverContent
|
||||
placement="bottom-end"
|
||||
sideOffset={4}
|
||||
alignOffset={44}
|
||||
popupClassName="border-none bg-transparent shadow-none"
|
||||
>
|
||||
<div className="rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-4 shadow-lg backdrop-blur-xs">
|
||||
<div className="system-md-medium text-text-primary">{t(`${i18nPrefix}.userActions.chooseStyle`, { ns: 'workflow' })}</div>
|
||||
<div className="mt-2 flex w-[324px] flex-wrap gap-1">
|
||||
@ -103,8 +109,8 @@ const ButtonStyleDropdown: FC<Props> = ({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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 (
|
||||
<PortalToFollowElem
|
||||
placement="left"
|
||||
offset={4}
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={() => setOpen(!open)}>
|
||||
<Button
|
||||
variant="secondary-accent"
|
||||
size="small"
|
||||
>
|
||||
<RiFilter3Line className="mr-1 h-3.5 w-3.5" />
|
||||
{t('nodes.knowledgeRetrieval.metadata.panel.conditions', { ns: 'workflow' })}
|
||||
<div className="ml-1 flex items-center rounded-[5px] border border-divider-deep px-1 system-2xs-medium-uppercase text-text-tertiary">
|
||||
{metadataFilteringConditions?.conditions.length || 0}
|
||||
</div>
|
||||
</Button>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-10">
|
||||
<PopoverTrigger
|
||||
render={(
|
||||
<Button
|
||||
variant="secondary-accent"
|
||||
size="small"
|
||||
>
|
||||
<RiFilter3Line className="mr-1 h-3.5 w-3.5" />
|
||||
{t('nodes.knowledgeRetrieval.metadata.panel.conditions', { ns: 'workflow' })}
|
||||
<div className="ml-1 flex items-center rounded-[5px] border border-divider-deep px-1 system-2xs-medium-uppercase text-text-tertiary">
|
||||
{metadataFilteringConditions?.conditions.length || 0}
|
||||
</div>
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
<PopoverContent
|
||||
placement="left"
|
||||
sideOffset={4}
|
||||
popupClassName="border-none bg-transparent shadow-none"
|
||||
>
|
||||
<MetadataPanel
|
||||
metadataFilteringConditions={metadataFilteringConditions}
|
||||
onCancel={() => setOpen(false)}
|
||||
@ -66,8 +70,8 @@ const MetadataTrigger = ({
|
||||
handleRemoveCondition={handleRemoveCondition}
|
||||
{...restProps}
|
||||
/>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user